PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 All 51 releases
← All changes | api/Resource/OrderResource.php +242 -18 1.4.2 → 1.7.1 View file →
@@ -232,8 +232,44 @@
232 232
233 233 /**
234 234 * @throws \Exception
235 235 */
236 + /**
237 + * Validate a shipping cents value for the DIRECT Resource API boundary.
238 + * REST callers can reach neither branch (OrderRequest's numeric/min:0 rules
239 + * 422 them first); both exist purely for direct callers.
240 + *
241 + * - Only absent/null may default to zero — that is the omitted-key shape
242 + * REST produces (pickKeys null-fill). A present non-numeric is a caller
243 + * bug, and coercing it to 0 would silently grant free shipping.
244 + * - The sign is checked on the RAW value, BEFORE rounding: roundCent(-0.4)
245 + * is 0, so a post-rounding check would wave fractional negatives through
246 + * as free shipping instead of rejecting them.
247 + *
248 + * @param mixed $value
249 + * @return mixed the value, unchanged, when null or a non-negative numeric
250 + */
251 + protected static function assertShippingCents($value)
252 + {
253 + if ($value === null) {
254 + return null;
255 + }
256 +
257 + if (!is_numeric($value)) {
258 + throw new \InvalidArgumentException(
259 + 'Shipping total must be a numeric cents amount or omitted, got: ' . gettype($value)
260 + );
261 + }
262 +
263 + if ((float) $value < 0) {
264 + throw new \InvalidArgumentException(
265 + 'Shipping total cannot be a negative cents amount: ' . var_export($value, true)
266 + );
267 + }
268 +
269 + return $value;
270 + }
271 +
236 272 public static function updatedPlaceOrder($data, $params = [])
237 273 {
238 274 $order = $data;
239 275 $discount = Arr::get($data, 'discount');
@@ -249,14 +285,27 @@
249 285 if (Arr::get($discount, 'value', 0) > 0) {
250 286 static::distributeManualDiscount($items, Helper::toCent(Arr::get($discount, 'value', 0)));
251 287 }
252 288
289 + $couponCheck = CouponResource::validateOrderCoupons($items, (array) Arr::get($data, 'applied_coupon', []), Arr::get($customer, 'email', ''));
290 + if (is_wp_error($couponCheck)) {
291 + return $couponCheck;
292 + }
293 + $items = $couponCheck['items'];
294 + $data['applied_coupon'] = $couponCheck['applied_coupons'];
295 +
253 296 // admin order processor
254 297 $adminOrderProcessor = new AdminOrderProcessor($items, [
255 298 'customer_id' => $customer->id,
256 299 'payment_method' => $paymentMethod,
257 300 'applied_coupons' => Arr::get($data, 'applied_coupon', []),
258 - 'shipping_total' => Arr::get($data, 'shipping_total', []),
301 + // Normalized here as well as in OrderRequest::sanitize(): this is a public
302 + // Resource API, and a direct caller never passes through the request layer. The
303 + // shared helper also absorbs the null that pickKeys() injects for an omitted key
304 + // AFTER Sanitizer::sanitize() has run, which no sanitizer can reach. Negative
305 + // and PRESENT-but-malformed shipping are rejected here too, on the RAW value
306 + // and BEFORE rounding — see assertShippingCents().
307 + 'shipping_total' => Helper::roundCent(static::assertShippingCents(Arr::get($data, 'shipping_total'))),
259 308 'billing_address' => Arr::get($customer, 'billing_address', []),
260 309 'shipping_address' => Arr::get($customer, 'shipping_address', []),
261 310 'user_tz' => Arr::get($data, 'user_tz', ''),
262 311 ]);
@@ -279,8 +328,14 @@
279 328 static::applyAdminOrderTax($order, $items, $customer, $data);
280 329
281 330 if ($gateway = App::gateway($paymentMethod)) {
282 331 $paymentInstance = new PaymentInstance($order);
332 +
333 + if ($paymentInstance->subscription && $paymentInstance->subscription->status === Status::SUBSCRIPTION_PENDING) {
334 + $paymentInstance->subscription->status = Status::SUBSCRIPTION_INTENDED;
335 + $paymentInstance->subscription->save();
336 + }
337 +
283 338 $gateway->makePaymentFromPaymentInstance($paymentInstance);
284 339 }
285 340
286 341 return $order;
@@ -391,8 +446,9 @@
391 446
392 447 // Include manual_discount (set by distributeManualDiscount) so tax is
393 448 // calculated on the after-discount amount, not the full subtotal.
394 449 $taxItems[] = [
450 + 'id' => (int) Arr::get($item, 'id', 0),
395 451 'post_id' => (int) Arr::get($item, 'post_id', 0),
396 452 'object_id' => (int) Arr::get($item, 'object_id', 0),
397 453 'subtotal' => $subtotal,
398 454 'discount_total' => (int) Arr::get($item, 'discount_total', 0) + (int) Arr::get($item, 'manual_discount', 0),
@@ -508,12 +564,94 @@
508 564 }
509 565 }
510 566
511 567 /**
568 + * Rebuild an order's item-derived totals from the rows actually in
569 + * fct_order_items, then let the tax pass derive total_amount from the new
570 + * subtotal.
571 + *
572 + * The whole-order save posts client-computed totals alongside the items, so
573 + * it does not need this. A caller that writes a single line item on its own
574 + * does — without it the order keeps the subtotal it had before the line
575 + * existed. Same aggregation as AdminOrderProcessor: fee lines live in
576 + * fee_total, and trial lines are not billed now.
577 + */
578 + public static function syncItemDerivedTotals(Order $order)
579 + {
580 + $order->load('order_items');
581 +
582 + // The tax pass early-returns for these before reaching the pending
583 + // charge transaction sync, so a total written here would go stale
584 + // against the recorded charge. Refuse instead of desynchronizing.
585 + if ($order->isSubscription() || $order->type === 'refund') {
586 + throw new \Exception(esc_html__('Order Not valid!', 'fluent-cart'));
587 + }
588 +
589 + $subtotal = 0;
590 +
591 + foreach ($order->order_items as $item) {
592 + if (in_array($item->payment_type, ['fee', 'signup_fee'], true)) {
593 + continue;
594 + }
595 +
596 + if (Arr::get($item->other_info, 'trial_days', 0) > 0) {
597 + continue;
598 + }
599 +
600 + $subtotal += (int) $item->subtotal;
601 + }
602 +
603 + $order->subtotal = $subtotal;
604 +
605 + // The parent's fulfillment fields are item-derived too — creation sets
606 + // them from whether any line is physical (AdminOrderProcessor). A
607 + // physical line added to a digital order must pull the order into the
608 + // shipping workflow. Upgrade only: a rebuild must never downgrade the
609 + // type or reset shipping progress already recorded.
610 + $hasPhysical = $order->order_items
611 + ->where('fulfillment_type', Status::FULFILLMENT_TYPE_PHYSICAL)
612 + ->isNotEmpty();
613 +
614 + if ($hasPhysical) {
615 + if ($order->fulfillment_type !== Status::FULFILLMENT_TYPE_PHYSICAL) {
616 + $order->fulfillment_type = Status::FULFILLMENT_TYPE_PHYSICAL;
617 + }
618 + if (!$order->shipping_status) {
619 + $order->shipping_status = 'unshipped';
620 + }
621 + }
622 +
623 + // Tax-free baseline; the tax pass recomputes it with tax on every path
624 + // it completes.
625 + $order->total_amount = max(0, $subtotal
626 + + (int) $order->shipping_total
627 + + (int) $order->fee_total
628 + - (int) $order->coupon_discount_total
629 + - (int) $order->manual_discount_total);
630 +
631 + $order->save();
632 +
633 + // The tax pass swallows its own failures so a whole-order save is never
634 + // blocked, but this caller has nothing else persisting the order — a
635 + // swallowed failure here would commit the new subtotal beside stale tax
636 + // fields and rate rows. Escalate so the caller's transaction rolls the
637 + // item and totals back together.
638 + if (!static::reapplyTaxAfterUpdate($order->id, $order->refresh())) {
639 + throw new \Exception(esc_html__('Order totals could not be recalculated. Please try again.', 'fluent-cart'));
640 + }
641 +
642 + return $order->refresh();
643 + }
644 +
645 + /**
512 646 * Recalculate and persist tax for an existing order after create or update.
513 647 * Reads saved items + billing address from the DB, runs AdminOrderTaxService,
514 648 * recomputes total_amount from scratch, and rewrites fct_order_tax_rate rows.
515 649 * Never throws — tax failure must not block the save.
650 + *
651 + * @return bool false when the order was left carrying tax data the current
652 + * items no longer justify (transient calculator failure or a
653 + * rolled-back write); true when it reached a coherent state.
516 654 */
517 655 private static function reapplyTaxAfterUpdate($orderId, $order)
518 656 {
519 657 try {
@@ -521,13 +659,13 @@
521 659 $order->load('order_items');
522 660 }
523 661
524 662 if ($order->isSubscription()) {
525 - return;
663 + return true;
526 664 }
527 665
528 666 if ($order->type === 'refund') {
529 - return;
667 + return true;
530 668 }
531 669
532 670 // Query addresses directly — ORM relation load() does not reliably apply
533 671 // the type WHERE constraint, so we query fct_order_addresses ourselves.
@@ -558,10 +696,9 @@
558 696 $basis = Arr::get($taxSettings, 'tax_calculation_basis', 'shipping');
559 697 $taxAddress = AdminOrderTaxService::resolveAddressForBasis($basis, $billingAddress, $shippingAddress);
560 698
561 699 if (empty($taxAddress['country'])) {
562 - static::clearOrderTax($orderId, $order);
563 - return;
700 + return static::clearOrderTax($orderId, $order);
564 701 }
565 702
566 703 $productItems = $order->order_items->filter(function ($item) {
567 704 return !in_array($item->payment_type, ['fee', 'signup_fee'], true);
@@ -571,8 +708,9 @@
571 708 foreach ($productItems as $item) {
572 709 $unitPrice = (int) Arr::get($item, 'unit_price', 0);
573 710 $qty = max(1, (int) Arr::get($item, 'quantity', 1));
574 711 $taxItems[] = [
712 + 'id' => (int) Arr::get($item, 'id', 0),
575 713 'post_id' => (int) Arr::get($item, 'post_id', 0),
576 714 'object_id' => (int) Arr::get($item, 'object_id', 0),
577 715 'subtotal' => $unitPrice * $qty,
578 716 'discount_total' => (int) Arr::get($item, 'discount_total', 0),
@@ -582,10 +720,9 @@
582 720 ];
583 721 }
584 722
585 723 if (empty($taxItems)) {
586 - static::clearOrderTax($orderId, $order);
587 - return;
724 + return static::clearOrderTax($orderId, $order);
588 725 }
589 726
590 727 // Fee items only exist on checkout-created orders that are edited in
591 728 // admin. Mirror checkout (TaxModule::calculateCartTax()): only taxable,
@@ -630,12 +767,12 @@
630 767
631 768 if ($taxResult === null) {
632 769 if (!TaxModule::isTaxEnabled()) {
633 770 // Deterministic: tax was turned off — clear stale tax instead of leaving it.
634 - static::clearOrderTax($orderId, $order);
771 + return static::clearOrderTax($orderId, $order);
635 772 }
636 773 // Transient calculation failure: keep existing tax untouched.
637 - return;
774 + return false;
638 775 }
639 776
640 777 $taxTotal = (int) Arr::get($taxResult, 'tax_total', 0);
641 778 $exclusiveTaxTotal = (int) Arr::get($taxResult, 'exclusive_tax_total', 0);
@@ -782,8 +919,9 @@
782 919 static::syncPaymentStatusWithTotals($order);
783 920
784 921 $DB->commit();
785 922
923 + return true;
786 924 } catch (\Exception $e) {
787 925 if (isset($DB)) {
788 926 $DB->rollBack();
789 927 }
@@ -791,8 +929,10 @@
791 929 'Admin order tax recalculation failed on update',
792 930 get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
793 931 ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api']
794 932 );
933 +
934 + return false;
795 935 }
796 936 }
797 937
798 938 /**
@@ -936,8 +1076,10 @@
936 1076 /**
937 1077 * Zero out all tax fields, rate rows, and per-item tax amounts for an order
938 1078 * that has become definitively non-taxable (no address, no taxable items).
939 1079 * Only called for deterministic states — not on transient calculation failures.
1080 + *
1081 + * @return bool false when the clear rolled back and the stale tax data remains.
940 1082 */
941 1083 private static function clearOrderTax($orderId, $order)
942 1084 {
943 1085 try {
@@ -1027,8 +1169,10 @@
1027 1169 // Paid orders: reflect the lowered total as paid / refund-owed state.
1028 1170 static::syncPaymentStatusWithTotals($order);
1029 1171
1030 1172 $DB->commit();
1173 +
1174 + return true;
1031 1175 } catch (\Exception $e) {
1032 1176 if (isset($DB)) {
1033 1177 $DB->rollBack();
1034 1178 }
@@ -1036,15 +1180,22 @@
1036 1180 'Admin order tax clear failed on update',
1037 1181 get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
1038 1182 ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api']
1039 1183 );
1184 +
1185 + return false;
1040 1186 }
1041 1187 }
1042 1188
1043 1189 private static function patchOrderItemTaxMeta(array $savedItems, array $lineItemsFromTax)
1044 1190 {
1191 + // Custom lines all carry post_id/object_id 0:0, so the composite key
1192 + // cannot tell two of them apart — match by order-item id first and only
1193 + // fall back to the key for tax results that did not carry one.
1194 + $savedById = [];
1045 1195 $savedByKey = [];
1046 1196 foreach ($savedItems as $item) {
1197 + $savedById[(int) $item['id']] = $item;
1047 1198 $key = $item['post_id'] . ':' . $item['object_id'];
1048 1199 $savedByKey[$key] = $item;
1049 1200 }
1050 1201
@@ -1050,14 +1201,20 @@
1050 1201
1051 1202 $updateData = [];
1052 1203
1053 1204 foreach ($lineItemsFromTax as $taxLineItem) {
1054 - $key = Arr::get($taxLineItem, 'post_id', 0) . ':' . Arr::get($taxLineItem, 'object_id', 0);
1055 - if (!isset($savedByKey[$key])) {
1056 - continue;
1205 + $itemId = (int) Arr::get($taxLineItem, 'id', 0);
1206 +
1207 + if ($itemId && isset($savedById[$itemId])) {
1208 + $savedItem = $savedById[$itemId];
1209 + } else {
1210 + $key = Arr::get($taxLineItem, 'post_id', 0) . ':' . Arr::get($taxLineItem, 'object_id', 0);
1211 + if (!isset($savedByKey[$key])) {
1212 + continue;
1213 + }
1214 + $savedItem = $savedByKey[$key];
1057 1215 }
1058 1216
1059 - $savedItem = $savedByKey[$key];
1060 1217 $taxAmount = (int) Arr::get($taxLineItem, 'tax_amount', 0);
1061 1218 $taxLineMeta = Arr::get($taxLineItem, 'line_meta', []);
1062 1219 $existingMeta = isset($savedItem['line_meta']) ? $savedItem['line_meta'] : [];
1063 1220 if (!is_array($existingMeta)) {
@@ -1210,8 +1367,35 @@
1210 1367 }
1211 1368 $subscription->save();
1212 1369 }
1213 1370
1371 + /**
1372 + * Whether the submitted coupon and item discounts match the calculated ones, within a cent of rounding.
1373 + *
1374 + * @param array $submittedItems
1375 + * @param array $submittedCoupons Applied-coupon map keyed by coupon code.
1376 + * @param array $couponCheck Result of CouponResource::validateOrderCoupons().
1377 + * @return bool
1378 + */
1379 + private static function couponDiscountsMatch(array $submittedItems, array $submittedCoupons, array $couponCheck): bool
1380 + {
1381 + foreach ($couponCheck['applied_coupons'] as $code => $calculated) {
1382 + $submitted = isset($submittedCoupons[$code]['discount']) ? (float) $submittedCoupons[$code]['discount'] : 0;
1383 + if (abs($submitted - (float) $calculated['discount']) > 1) {
1384 + return false;
1385 + }
1386 + }
1387 +
1388 + foreach ($couponCheck['items'] as $index => $calculatedItem) {
1389 + $submitted = (float) Arr::get($submittedItems, $index . '.discount_total', 0);
1390 + if (abs($submitted - (float) Arr::get($calculatedItem, 'discount_total', 0)) > 1) {
1391 + return false;
1392 + }
1393 + }
1394 +
1395 + return true;
1396 + }
1397 +
1214 1398 private static function distributeManualDiscount(&$items, $manualDiscountTotal)
1215 1399 {
1216 1400 $totalSubtotal = array_reduce($items, function ($carry, $item) {
1217 1401 return $carry + ((int)Arr::get($item, 'unit_price', 0) * (int)Arr::get($item, 'quantity', 1));
@@ -1458,8 +1642,27 @@
1458 1642 $appliedCoupons = Arr::get($orderData, 'applied_coupon');
1459 1643 $discount = $data['discount'];
1460 1644 $shipping = $data['shipping'];
1461 1645
1646 + if (!empty($appliedCoupons)) {
1647 + $submittedItems = Arr::except((array) Arr::get($orderData, 'order_items', []), ['*']);
1648 + $couponCheck = CouponResource::validateOrderCoupons(
1649 + $submittedItems,
1650 + (array) $appliedCoupons,
1651 + $order->customer ? $order->customer->email : ''
1652 + );
1653 + if (is_wp_error($couponCheck)) {
1654 + return $couponCheck;
1655 + }
1656 + // Update saves the submitted items and totals, so they must already carry the calculated discounts.
1657 + if (!static::couponDiscountsMatch($submittedItems, (array) $appliedCoupons, $couponCheck)) {
1658 + return static::makeErrorResponse([
1659 + ['code' => 'coupon_discount_changed', 'message' => __('Coupon discounts have changed. Please re-apply the coupons and save again.', 'fluent-cart')]
1660 + ], 422);
1661 + }
1662 + $appliedCoupons = $couponCheck['applied_coupons'];
1663 + }
1664 +
1462 1665 $orderId = $order->id;
1463 1666
1464 1667 /**
1465 1668 * First delete the deleted items
@@ -1810,11 +2013,11 @@
1810 2013 ->with(
1811 2014 [
1812 2015 'parentOrder' => function ($query) {
1813 2016 return $query->select('id')
1814 - ->with('subscriptions');
2017 + ->with('subscriptions.product');
1815 2018 },
1816 - 'subscriptions',
2019 + 'subscriptions.product',
1817 2020 'activities.user',
1818 2021 'labels',
1819 2022 'customer',
1820 2023 'children' => function ($query) {
@@ -1877,9 +2080,12 @@
1877 2080 $methodId = (int)$shippingMeta['id'];
1878 2081 $methodTitle = (string)$shippingMeta['title'];
1879 2082 }
1880 2083
1881 - $checkoutShipping = ($methodId && $methodTitle) ? [
2084 + // Gate on the title alone. Live-rate carriers use non-numeric method
2085 + // ids (e.g. "carrier:shippo:usps_priority") which (int) casts to 0,
2086 + // so requiring a truthy id silently hid the method name.
2087 + $checkoutShipping = $methodTitle ? [
1882 2088 'method_id' => $methodId,
1883 2089 'method_title' => $methodTitle,
1884 2090 'shipping_total' => (int)Arr::get($order, 'shipping_total', 0),
1885 2091 ] : null;
@@ -1932,8 +2138,11 @@
1932 2138 * ]
1933 2139 * ]
1934 2140 *
1935 2141 */
2142 + /**
2143 + * @deprecated since v1.4. Use OverviewReportController::getOverview() via GET reports/overview instead.
2144 + */
1936 2145 public static function reportOverview($params = [])
1937 2146 {
1938 2147 return static::getQuery()->when(
1939 2148 $params,
@@ -1942,9 +2151,9 @@
1942 2151 }
1943 2152 )
1944 2153 ->selectRaw('sum(total_amount) as total_sales')
1945 2154 ->selectRaw('sum(total_amount - manual_discount_total - shipping_total - tax_total) as net_sales')
1946 - ->selectRaw('sum(discount_total) as total_discounts')
2155 + ->selectRaw('sum(manual_discount_total + coupon_discount_total) as total_discounts')
1947 2156 ->selectRaw('sum(shipping_total) as total_shipping_tax')
1948 2157 ->selectRaw('avg(total_amount) as average_order_value')
1949 2158 ->selectRaw('count(*) as customer_order_count')
1950 2159 ->get()->first();
@@ -2043,8 +2252,19 @@
2043 2252 $order = static::getQuery()->with("order_items.variants.product_detail")->where('id', $orderId)->first();
2044 2253
2045 2254 $action = Arr::get($params, 'action');
2046 2255
2256 + // This endpoint's contract is order/shipping status only — payment-status
2257 + // transitions flow through their dedicated surfaces (mark-as-paid,
2258 + // transaction status updates, refunds, gateway webhooks) so money state
2259 + // stays consistent with transactions. Rejecting unknown actions up front
2260 + // also keeps them out of the order_status fallback below.
2261 + if (!in_array($action, ['change_order_status', 'change_shipping_status'], true)) {
2262 + return static::makeErrorResponse([
2263 + ['code' => 400, 'message' => __('Unsupported action — this endpoint changes order or shipping status only.', 'fluent-cart')]
2264 + ], 400);
2265 + }
2266 +
2047 2267 $changeType = $action === 'change_shipping_status' ? 'shipping_status' : 'order_status';
2048 2268 $actionActivity = [];
2049 2269
2050 2270 if ($action === 'change_shipping_status') {
@@ -2332,8 +2552,12 @@
2332 2552 foreach ($keysToInclude as $key) {
2333 2553 $address->{$key} = $addressData[$key];
2334 2554 }
2335 2555
2556 + if (array_key_exists('meta', $addressData)) {
2557 + $address->meta = $addressData['meta'];
2558 + }
2559 +
2336 2560 if ($address->save()) {
2337 2561 return $address;
2338 2562 }
2339 2563 return static::makeErrorResponse([
@@ -2342,9 +2566,9 @@
2342 2566 }
2343 2567
2344 2568 private static function createOrderAddress(array $address, $orderId)
2345 2569 {
2346 - $keysToInclude = ['order_id', 'type', 'name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country'];
2570 + $keysToInclude = ['order_id', 'type', 'name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'meta'];
2347 2571 $address = Arr::only($address, $keysToInclude);
2348 2572 $address['order_id'] = $orderId;
2349 2573
2350 2574 if (!empty($address)) {