PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 1.3.20 1.3.19 trunk All 48 releases
← All changes | api/Resource/OrderResource.php +173 -14 1.6.0 → 1.6.5 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');
@@ -254,9 +290,15 @@
254 290 $adminOrderProcessor = new AdminOrderProcessor($items, [
255 291 'customer_id' => $customer->id,
256 292 'payment_method' => $paymentMethod,
257 293 'applied_coupons' => Arr::get($data, 'applied_coupon', []),
258 - 'shipping_total' => Arr::get($data, 'shipping_total', []),
294 + // Normalized here as well as in OrderRequest::sanitize(): this is a public
295 + // Resource API, and a direct caller never passes through the request layer. The
296 + // shared helper also absorbs the null that pickKeys() injects for an omitted key
297 + // AFTER Sanitizer::sanitize() has run, which no sanitizer can reach. Negative
298 + // and PRESENT-but-malformed shipping are rejected here too, on the RAW value
299 + // and BEFORE rounding — see assertShippingCents().
300 + 'shipping_total' => Helper::roundCent(static::assertShippingCents(Arr::get($data, 'shipping_total'))),
259 301 'billing_address' => Arr::get($customer, 'billing_address', []),
260 302 'shipping_address' => Arr::get($customer, 'shipping_address', []),
261 303 'user_tz' => Arr::get($data, 'user_tz', ''),
262 304 ]);
@@ -397,8 +439,9 @@
397 439
398 440 // Include manual_discount (set by distributeManualDiscount) so tax is
399 441 // calculated on the after-discount amount, not the full subtotal.
400 442 $taxItems[] = [
443 + 'id' => (int) Arr::get($item, 'id', 0),
401 444 'post_id' => (int) Arr::get($item, 'post_id', 0),
402 445 'object_id' => (int) Arr::get($item, 'object_id', 0),
403 446 'subtotal' => $subtotal,
404 447 'discount_total' => (int) Arr::get($item, 'discount_total', 0) + (int) Arr::get($item, 'manual_discount', 0),
@@ -514,12 +557,94 @@
514 557 }
515 558 }
516 559
517 560 /**
561 + * Rebuild an order's item-derived totals from the rows actually in
562 + * fct_order_items, then let the tax pass derive total_amount from the new
563 + * subtotal.
564 + *
565 + * The whole-order save posts client-computed totals alongside the items, so
566 + * it does not need this. A caller that writes a single line item on its own
567 + * does — without it the order keeps the subtotal it had before the line
568 + * existed. Same aggregation as AdminOrderProcessor: fee lines live in
569 + * fee_total, and trial lines are not billed now.
570 + */
571 + public static function syncItemDerivedTotals(Order $order)
572 + {
573 + $order->load('order_items');
574 +
575 + // The tax pass early-returns for these before reaching the pending
576 + // charge transaction sync, so a total written here would go stale
577 + // against the recorded charge. Refuse instead of desynchronizing.
578 + if ($order->isSubscription() || $order->type === 'refund') {
579 + throw new \Exception(esc_html__('Order Not valid!', 'fluent-cart'));
580 + }
581 +
582 + $subtotal = 0;
583 +
584 + foreach ($order->order_items as $item) {
585 + if (in_array($item->payment_type, ['fee', 'signup_fee'], true)) {
586 + continue;
587 + }
588 +
589 + if (Arr::get($item->other_info, 'trial_days', 0) > 0) {
590 + continue;
591 + }
592 +
593 + $subtotal += (int) $item->subtotal;
594 + }
595 +
596 + $order->subtotal = $subtotal;
597 +
598 + // The parent's fulfillment fields are item-derived too — creation sets
599 + // them from whether any line is physical (AdminOrderProcessor). A
600 + // physical line added to a digital order must pull the order into the
601 + // shipping workflow. Upgrade only: a rebuild must never downgrade the
602 + // type or reset shipping progress already recorded.
603 + $hasPhysical = $order->order_items
604 + ->where('fulfillment_type', Status::FULFILLMENT_TYPE_PHYSICAL)
605 + ->isNotEmpty();
606 +
607 + if ($hasPhysical) {
608 + if ($order->fulfillment_type !== Status::FULFILLMENT_TYPE_PHYSICAL) {
609 + $order->fulfillment_type = Status::FULFILLMENT_TYPE_PHYSICAL;
610 + }
611 + if (!$order->shipping_status) {
612 + $order->shipping_status = 'unshipped';
613 + }
614 + }
615 +
616 + // Tax-free baseline; the tax pass recomputes it with tax on every path
617 + // it completes.
618 + $order->total_amount = max(0, $subtotal
619 + + (int) $order->shipping_total
620 + + (int) $order->fee_total
621 + - (int) $order->coupon_discount_total
622 + - (int) $order->manual_discount_total);
623 +
624 + $order->save();
625 +
626 + // The tax pass swallows its own failures so a whole-order save is never
627 + // blocked, but this caller has nothing else persisting the order — a
628 + // swallowed failure here would commit the new subtotal beside stale tax
629 + // fields and rate rows. Escalate so the caller's transaction rolls the
630 + // item and totals back together.
631 + if (!static::reapplyTaxAfterUpdate($order->id, $order->refresh())) {
632 + throw new \Exception(esc_html__('Order totals could not be recalculated. Please try again.', 'fluent-cart'));
633 + }
634 +
635 + return $order->refresh();
636 + }
637 +
638 + /**
518 639 * Recalculate and persist tax for an existing order after create or update.
519 640 * Reads saved items + billing address from the DB, runs AdminOrderTaxService,
520 641 * recomputes total_amount from scratch, and rewrites fct_order_tax_rate rows.
521 642 * Never throws — tax failure must not block the save.
643 + *
644 + * @return bool false when the order was left carrying tax data the current
645 + * items no longer justify (transient calculator failure or a
646 + * rolled-back write); true when it reached a coherent state.
522 647 */
523 648 private static function reapplyTaxAfterUpdate($orderId, $order)
524 649 {
525 650 try {
@@ -527,13 +652,13 @@
527 652 $order->load('order_items');
528 653 }
529 654
530 655 if ($order->isSubscription()) {
531 - return;
656 + return true;
532 657 }
533 658
534 659 if ($order->type === 'refund') {
535 - return;
660 + return true;
536 661 }
537 662
538 663 // Query addresses directly — ORM relation load() does not reliably apply
539 664 // the type WHERE constraint, so we query fct_order_addresses ourselves.
@@ -564,10 +689,9 @@
564 689 $basis = Arr::get($taxSettings, 'tax_calculation_basis', 'shipping');
565 690 $taxAddress = AdminOrderTaxService::resolveAddressForBasis($basis, $billingAddress, $shippingAddress);
566 691
567 692 if (empty($taxAddress['country'])) {
568 - static::clearOrderTax($orderId, $order);
569 - return;
693 + return static::clearOrderTax($orderId, $order);
570 694 }
571 695
572 696 $productItems = $order->order_items->filter(function ($item) {
573 697 return !in_array($item->payment_type, ['fee', 'signup_fee'], true);
@@ -577,8 +701,9 @@
577 701 foreach ($productItems as $item) {
578 702 $unitPrice = (int) Arr::get($item, 'unit_price', 0);
579 703 $qty = max(1, (int) Arr::get($item, 'quantity', 1));
580 704 $taxItems[] = [
705 + 'id' => (int) Arr::get($item, 'id', 0),
581 706 'post_id' => (int) Arr::get($item, 'post_id', 0),
582 707 'object_id' => (int) Arr::get($item, 'object_id', 0),
583 708 'subtotal' => $unitPrice * $qty,
584 709 'discount_total' => (int) Arr::get($item, 'discount_total', 0),
@@ -588,10 +713,9 @@
588 713 ];
589 714 }
590 715
591 716 if (empty($taxItems)) {
592 - static::clearOrderTax($orderId, $order);
593 - return;
717 + return static::clearOrderTax($orderId, $order);
594 718 }
595 719
596 720 // Fee items only exist on checkout-created orders that are edited in
597 721 // admin. Mirror checkout (TaxModule::calculateCartTax()): only taxable,
@@ -636,12 +760,12 @@
636 760
637 761 if ($taxResult === null) {
638 762 if (!TaxModule::isTaxEnabled()) {
639 763 // Deterministic: tax was turned off — clear stale tax instead of leaving it.
640 - static::clearOrderTax($orderId, $order);
764 + return static::clearOrderTax($orderId, $order);
641 765 }
642 766 // Transient calculation failure: keep existing tax untouched.
643 - return;
767 + return false;
644 768 }
645 769
646 770 $taxTotal = (int) Arr::get($taxResult, 'tax_total', 0);
647 771 $exclusiveTaxTotal = (int) Arr::get($taxResult, 'exclusive_tax_total', 0);
@@ -788,8 +912,9 @@
788 912 static::syncPaymentStatusWithTotals($order);
789 913
790 914 $DB->commit();
791 915
916 + return true;
792 917 } catch (\Exception $e) {
793 918 if (isset($DB)) {
794 919 $DB->rollBack();
795 920 }
@@ -797,8 +922,10 @@
797 922 'Admin order tax recalculation failed on update',
798 923 get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
799 924 ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api']
800 925 );
926 +
927 + return false;
801 928 }
802 929 }
803 930
804 931 /**
@@ -942,8 +1069,10 @@
942 1069 /**
943 1070 * Zero out all tax fields, rate rows, and per-item tax amounts for an order
944 1071 * that has become definitively non-taxable (no address, no taxable items).
945 1072 * Only called for deterministic states — not on transient calculation failures.
1073 + *
1074 + * @return bool false when the clear rolled back and the stale tax data remains.
946 1075 */
947 1076 private static function clearOrderTax($orderId, $order)
948 1077 {
949 1078 try {
@@ -1033,8 +1162,10 @@
1033 1162 // Paid orders: reflect the lowered total as paid / refund-owed state.
1034 1163 static::syncPaymentStatusWithTotals($order);
1035 1164
1036 1165 $DB->commit();
1166 +
1167 + return true;
1037 1168 } catch (\Exception $e) {
1038 1169 if (isset($DB)) {
1039 1170 $DB->rollBack();
1040 1171 }
@@ -1042,15 +1173,22 @@
1042 1173 'Admin order tax clear failed on update',
1043 1174 get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
1044 1175 ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api']
1045 1176 );
1177 +
1178 + return false;
1046 1179 }
1047 1180 }
1048 1181
1049 1182 private static function patchOrderItemTaxMeta(array $savedItems, array $lineItemsFromTax)
1050 1183 {
1184 + // Custom lines all carry post_id/object_id 0:0, so the composite key
1185 + // cannot tell two of them apart — match by order-item id first and only
1186 + // fall back to the key for tax results that did not carry one.
1187 + $savedById = [];
1051 1188 $savedByKey = [];
1052 1189 foreach ($savedItems as $item) {
1190 + $savedById[(int) $item['id']] = $item;
1053 1191 $key = $item['post_id'] . ':' . $item['object_id'];
1054 1192 $savedByKey[$key] = $item;
1055 1193 }
1056 1194
@@ -1056,14 +1194,20 @@
1056 1194
1057 1195 $updateData = [];
1058 1196
1059 1197 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;
1198 + $itemId = (int) Arr::get($taxLineItem, 'id', 0);
1199 +
1200 + if ($itemId && isset($savedById[$itemId])) {
1201 + $savedItem = $savedById[$itemId];
1202 + } else {
1203 + $key = Arr::get($taxLineItem, 'post_id', 0) . ':' . Arr::get($taxLineItem, 'object_id', 0);
1204 + if (!isset($savedByKey[$key])) {
1205 + continue;
1206 + }
1207 + $savedItem = $savedByKey[$key];
1063 1208 }
1064 1209
1065 - $savedItem = $savedByKey[$key];
1066 1210 $taxAmount = (int) Arr::get($taxLineItem, 'tax_amount', 0);
1067 1211 $taxLineMeta = Arr::get($taxLineItem, 'line_meta', []);
1068 1212 $existingMeta = isset($savedItem['line_meta']) ? $savedItem['line_meta'] : [];
1069 1213 if (!is_array($existingMeta)) {
@@ -2055,8 +2199,19 @@
2055 2199 $order = static::getQuery()->with("order_items.variants.product_detail")->where('id', $orderId)->first();
2056 2200
2057 2201 $action = Arr::get($params, 'action');
2058 2202
2203 + // This endpoint's contract is order/shipping status only — payment-status
2204 + // transitions flow through their dedicated surfaces (mark-as-paid,
2205 + // transaction status updates, refunds, gateway webhooks) so money state
2206 + // stays consistent with transactions. Rejecting unknown actions up front
2207 + // also keeps them out of the order_status fallback below.
2208 + if (!in_array($action, ['change_order_status', 'change_shipping_status'], true)) {
2209 + return static::makeErrorResponse([
2210 + ['code' => 400, 'message' => __('Unsupported action — this endpoint changes order or shipping status only.', 'fluent-cart')]
2211 + ], 400);
2212 + }
2213 +
2059 2214 $changeType = $action === 'change_shipping_status' ? 'shipping_status' : 'order_status';
2060 2215 $actionActivity = [];
2061 2216
2062 2217 if ($action === 'change_shipping_status') {
@@ -2344,8 +2499,12 @@
2344 2499 foreach ($keysToInclude as $key) {
2345 2500 $address->{$key} = $addressData[$key];
2346 2501 }
2347 2502
2503 + if (array_key_exists('meta', $addressData)) {
2504 + $address->meta = $addressData['meta'];
2505 + }
2506 +
2348 2507 if ($address->save()) {
2349 2508 return $address;
2350 2509 }
2351 2510 return static::makeErrorResponse([
@@ -2354,9 +2513,9 @@
2354 2513 }
2355 2514
2356 2515 private static function createOrderAddress(array $address, $orderId)
2357 2516 {
2358 - $keysToInclude = ['order_id', 'type', 'name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country'];
2517 + $keysToInclude = ['order_id', 'type', 'name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'meta'];
2359 2518 $address = Arr::only($address, $keysToInclude);
2360 2519 $address['order_id'] = $orderId;
2361 2520
2362 2521 if (!empty($address)) {