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 +226 -14 1.6.0 → 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 ]);
@@ -397,8 +446,9 @@
397 446
398 447 // Include manual_discount (set by distributeManualDiscount) so tax is
399 448 // calculated on the after-discount amount, not the full subtotal.
400 449 $taxItems[] = [
450 + 'id' => (int) Arr::get($item, 'id', 0),
401 451 'post_id' => (int) Arr::get($item, 'post_id', 0),
402 452 'object_id' => (int) Arr::get($item, 'object_id', 0),
403 453 'subtotal' => $subtotal,
404 454 'discount_total' => (int) Arr::get($item, 'discount_total', 0) + (int) Arr::get($item, 'manual_discount', 0),
@@ -514,12 +564,94 @@
514 564 }
515 565 }
516 566
517 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 + /**
518 646 * Recalculate and persist tax for an existing order after create or update.
519 647 * Reads saved items + billing address from the DB, runs AdminOrderTaxService,
520 648 * recomputes total_amount from scratch, and rewrites fct_order_tax_rate rows.
521 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.
522 654 */
523 655 private static function reapplyTaxAfterUpdate($orderId, $order)
524 656 {
525 657 try {
@@ -527,13 +659,13 @@
527 659 $order->load('order_items');
528 660 }
529 661
530 662 if ($order->isSubscription()) {
531 - return;
663 + return true;
532 664 }
533 665
534 666 if ($order->type === 'refund') {
535 - return;
667 + return true;
536 668 }
537 669
538 670 // Query addresses directly — ORM relation load() does not reliably apply
539 671 // the type WHERE constraint, so we query fct_order_addresses ourselves.
@@ -564,10 +696,9 @@
564 696 $basis = Arr::get($taxSettings, 'tax_calculation_basis', 'shipping');
565 697 $taxAddress = AdminOrderTaxService::resolveAddressForBasis($basis, $billingAddress, $shippingAddress);
566 698
567 699 if (empty($taxAddress['country'])) {
568 - static::clearOrderTax($orderId, $order);
569 - return;
700 + return static::clearOrderTax($orderId, $order);
570 701 }
571 702
572 703 $productItems = $order->order_items->filter(function ($item) {
573 704 return !in_array($item->payment_type, ['fee', 'signup_fee'], true);
@@ -577,8 +708,9 @@
577 708 foreach ($productItems as $item) {
578 709 $unitPrice = (int) Arr::get($item, 'unit_price', 0);
579 710 $qty = max(1, (int) Arr::get($item, 'quantity', 1));
580 711 $taxItems[] = [
712 + 'id' => (int) Arr::get($item, 'id', 0),
581 713 'post_id' => (int) Arr::get($item, 'post_id', 0),
582 714 'object_id' => (int) Arr::get($item, 'object_id', 0),
583 715 'subtotal' => $unitPrice * $qty,
584 716 'discount_total' => (int) Arr::get($item, 'discount_total', 0),
@@ -588,10 +720,9 @@
588 720 ];
589 721 }
590 722
591 723 if (empty($taxItems)) {
592 - static::clearOrderTax($orderId, $order);
593 - return;
724 + return static::clearOrderTax($orderId, $order);
594 725 }
595 726
596 727 // Fee items only exist on checkout-created orders that are edited in
597 728 // admin. Mirror checkout (TaxModule::calculateCartTax()): only taxable,
@@ -636,12 +767,12 @@
636 767
637 768 if ($taxResult === null) {
638 769 if (!TaxModule::isTaxEnabled()) {
639 770 // Deterministic: tax was turned off — clear stale tax instead of leaving it.
640 - static::clearOrderTax($orderId, $order);
771 + return static::clearOrderTax($orderId, $order);
641 772 }
642 773 // Transient calculation failure: keep existing tax untouched.
643 - return;
774 + return false;
644 775 }
645 776
646 777 $taxTotal = (int) Arr::get($taxResult, 'tax_total', 0);
647 778 $exclusiveTaxTotal = (int) Arr::get($taxResult, 'exclusive_tax_total', 0);
@@ -788,8 +919,9 @@
788 919 static::syncPaymentStatusWithTotals($order);
789 920
790 921 $DB->commit();
791 922
923 + return true;
792 924 } catch (\Exception $e) {
793 925 if (isset($DB)) {
794 926 $DB->rollBack();
795 927 }
@@ -797,8 +929,10 @@
797 929 'Admin order tax recalculation failed on update',
798 930 get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
799 931 ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api']
800 932 );
933 +
934 + return false;
801 935 }
802 936 }
803 937
804 938 /**
@@ -942,8 +1076,10 @@
942 1076 /**
943 1077 * Zero out all tax fields, rate rows, and per-item tax amounts for an order
944 1078 * that has become definitively non-taxable (no address, no taxable items).
945 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.
946 1082 */
947 1083 private static function clearOrderTax($orderId, $order)
948 1084 {
949 1085 try {
@@ -1033,8 +1169,10 @@
1033 1169 // Paid orders: reflect the lowered total as paid / refund-owed state.
1034 1170 static::syncPaymentStatusWithTotals($order);
1035 1171
1036 1172 $DB->commit();
1173 +
1174 + return true;
1037 1175 } catch (\Exception $e) {
1038 1176 if (isset($DB)) {
1039 1177 $DB->rollBack();
1040 1178 }
@@ -1042,15 +1180,22 @@
1042 1180 'Admin order tax clear failed on update',
1043 1181 get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
1044 1182 ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api']
1045 1183 );
1184 +
1185 + return false;
1046 1186 }
1047 1187 }
1048 1188
1049 1189 private static function patchOrderItemTaxMeta(array $savedItems, array $lineItemsFromTax)
1050 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 = [];
1051 1195 $savedByKey = [];
1052 1196 foreach ($savedItems as $item) {
1197 + $savedById[(int) $item['id']] = $item;
1053 1198 $key = $item['post_id'] . ':' . $item['object_id'];
1054 1199 $savedByKey[$key] = $item;
1055 1200 }
1056 1201
@@ -1056,14 +1201,20 @@
1056 1201
1057 1202 $updateData = [];
1058 1203
1059 1204 foreach ($lineItemsFromTax as $taxLineItem) {
1060 - $key = Arr::get($taxLineItem, 'post_id', 0) . ':' . Arr::get($taxLineItem, 'object_id', 0);
1061 - if (!isset($savedByKey[$key])) {
1062 - 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];
1063 1215 }
1064 1216
1065 - $savedItem = $savedByKey[$key];
1066 1217 $taxAmount = (int) Arr::get($taxLineItem, 'tax_amount', 0);
1067 1218 $taxLineMeta = Arr::get($taxLineItem, 'line_meta', []);
1068 1219 $existingMeta = isset($savedItem['line_meta']) ? $savedItem['line_meta'] : [];
1069 1220 if (!is_array($existingMeta)) {
@@ -1216,8 +1367,35 @@
1216 1367 }
1217 1368 $subscription->save();
1218 1369 }
1219 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 +
1220 1398 private static function distributeManualDiscount(&$items, $manualDiscountTotal)
1221 1399 {
1222 1400 $totalSubtotal = array_reduce($items, function ($carry, $item) {
1223 1401 return $carry + ((int)Arr::get($item, 'unit_price', 0) * (int)Arr::get($item, 'quantity', 1));
@@ -1464,8 +1642,27 @@
1464 1642 $appliedCoupons = Arr::get($orderData, 'applied_coupon');
1465 1643 $discount = $data['discount'];
1466 1644 $shipping = $data['shipping'];
1467 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 +
1468 1665 $orderId = $order->id;
1469 1666
1470 1667 /**
1471 1668 * First delete the deleted items
@@ -2055,8 +2252,19 @@
2055 2252 $order = static::getQuery()->with("order_items.variants.product_detail")->where('id', $orderId)->first();
2056 2253
2057 2254 $action = Arr::get($params, 'action');
2058 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 +
2059 2267 $changeType = $action === 'change_shipping_status' ? 'shipping_status' : 'order_status';
2060 2268 $actionActivity = [];
2061 2269
2062 2270 if ($action === 'change_shipping_status') {
@@ -2344,8 +2552,12 @@
2344 2552 foreach ($keysToInclude as $key) {
2345 2553 $address->{$key} = $addressData[$key];
2346 2554 }
2347 2555
2556 + if (array_key_exists('meta', $addressData)) {
2557 + $address->meta = $addressData['meta'];
2558 + }
2559 +
2348 2560 if ($address->save()) {
2349 2561 return $address;
2350 2562 }
2351 2563 return static::makeErrorResponse([
@@ -2354,9 +2566,9 @@
2354 2566 }
2355 2567
2356 2568 private static function createOrderAddress(array $address, $orderId)
2357 2569 {
2358 - $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'];
2359 2571 $address = Arr::only($address, $keysToInclude);
2360 2572 $address['order_id'] = $orderId;
2361 2573
2362 2574 if (!empty($address)) {