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 +183 -15 1.5.2 → 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 ]);
@@ -279,8 +321,14 @@
279 321 static::applyAdminOrderTax($order, $items, $customer, $data);
280 322
281 323 if ($gateway = App::gateway($paymentMethod)) {
282 324 $paymentInstance = new PaymentInstance($order);
325 +
326 + if ($paymentInstance->subscription && $paymentInstance->subscription->status === Status::SUBSCRIPTION_PENDING) {
327 + $paymentInstance->subscription->status = Status::SUBSCRIPTION_INTENDED;
328 + $paymentInstance->subscription->save();
329 + }
330 +
283 331 $gateway->makePaymentFromPaymentInstance($paymentInstance);
284 332 }
285 333
286 334 return $order;
@@ -391,8 +439,9 @@
391 439
392 440 // Include manual_discount (set by distributeManualDiscount) so tax is
393 441 // calculated on the after-discount amount, not the full subtotal.
394 442 $taxItems[] = [
443 + 'id' => (int) Arr::get($item, 'id', 0),
395 444 'post_id' => (int) Arr::get($item, 'post_id', 0),
396 445 'object_id' => (int) Arr::get($item, 'object_id', 0),
397 446 'subtotal' => $subtotal,
398 447 'discount_total' => (int) Arr::get($item, 'discount_total', 0) + (int) Arr::get($item, 'manual_discount', 0),
@@ -508,12 +557,94 @@
508 557 }
509 558 }
510 559
511 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 + /**
512 639 * Recalculate and persist tax for an existing order after create or update.
513 640 * Reads saved items + billing address from the DB, runs AdminOrderTaxService,
514 641 * recomputes total_amount from scratch, and rewrites fct_order_tax_rate rows.
515 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.
516 647 */
517 648 private static function reapplyTaxAfterUpdate($orderId, $order)
518 649 {
519 650 try {
@@ -521,13 +652,13 @@
521 652 $order->load('order_items');
522 653 }
523 654
524 655 if ($order->isSubscription()) {
525 - return;
656 + return true;
526 657 }
527 658
528 659 if ($order->type === 'refund') {
529 - return;
660 + return true;
530 661 }
531 662
532 663 // Query addresses directly — ORM relation load() does not reliably apply
533 664 // the type WHERE constraint, so we query fct_order_addresses ourselves.
@@ -558,10 +689,9 @@
558 689 $basis = Arr::get($taxSettings, 'tax_calculation_basis', 'shipping');
559 690 $taxAddress = AdminOrderTaxService::resolveAddressForBasis($basis, $billingAddress, $shippingAddress);
560 691
561 692 if (empty($taxAddress['country'])) {
562 - static::clearOrderTax($orderId, $order);
563 - return;
693 + return static::clearOrderTax($orderId, $order);
564 694 }
565 695
566 696 $productItems = $order->order_items->filter(function ($item) {
567 697 return !in_array($item->payment_type, ['fee', 'signup_fee'], true);
@@ -571,8 +701,9 @@
571 701 foreach ($productItems as $item) {
572 702 $unitPrice = (int) Arr::get($item, 'unit_price', 0);
573 703 $qty = max(1, (int) Arr::get($item, 'quantity', 1));
574 704 $taxItems[] = [
705 + 'id' => (int) Arr::get($item, 'id', 0),
575 706 'post_id' => (int) Arr::get($item, 'post_id', 0),
576 707 'object_id' => (int) Arr::get($item, 'object_id', 0),
577 708 'subtotal' => $unitPrice * $qty,
578 709 'discount_total' => (int) Arr::get($item, 'discount_total', 0),
@@ -582,10 +713,9 @@
582 713 ];
583 714 }
584 715
585 716 if (empty($taxItems)) {
586 - static::clearOrderTax($orderId, $order);
587 - return;
717 + return static::clearOrderTax($orderId, $order);
588 718 }
589 719
590 720 // Fee items only exist on checkout-created orders that are edited in
591 721 // admin. Mirror checkout (TaxModule::calculateCartTax()): only taxable,
@@ -630,12 +760,12 @@
630 760
631 761 if ($taxResult === null) {
632 762 if (!TaxModule::isTaxEnabled()) {
633 763 // Deterministic: tax was turned off — clear stale tax instead of leaving it.
634 - static::clearOrderTax($orderId, $order);
764 + return static::clearOrderTax($orderId, $order);
635 765 }
636 766 // Transient calculation failure: keep existing tax untouched.
637 - return;
767 + return false;
638 768 }
639 769
640 770 $taxTotal = (int) Arr::get($taxResult, 'tax_total', 0);
641 771 $exclusiveTaxTotal = (int) Arr::get($taxResult, 'exclusive_tax_total', 0);
@@ -782,8 +912,9 @@
782 912 static::syncPaymentStatusWithTotals($order);
783 913
784 914 $DB->commit();
785 915
916 + return true;
786 917 } catch (\Exception $e) {
787 918 if (isset($DB)) {
788 919 $DB->rollBack();
789 920 }
@@ -791,8 +922,10 @@
791 922 'Admin order tax recalculation failed on update',
792 923 get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
793 924 ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api']
794 925 );
926 +
927 + return false;
795 928 }
796 929 }
797 930
798 931 /**
@@ -936,8 +1069,10 @@
936 1069 /**
937 1070 * Zero out all tax fields, rate rows, and per-item tax amounts for an order
938 1071 * that has become definitively non-taxable (no address, no taxable items).
939 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.
940 1075 */
941 1076 private static function clearOrderTax($orderId, $order)
942 1077 {
943 1078 try {
@@ -1027,8 +1162,10 @@
1027 1162 // Paid orders: reflect the lowered total as paid / refund-owed state.
1028 1163 static::syncPaymentStatusWithTotals($order);
1029 1164
1030 1165 $DB->commit();
1166 +
1167 + return true;
1031 1168 } catch (\Exception $e) {
1032 1169 if (isset($DB)) {
1033 1170 $DB->rollBack();
1034 1171 }
@@ -1036,15 +1173,22 @@
1036 1173 'Admin order tax clear failed on update',
1037 1174 get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
1038 1175 ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api']
1039 1176 );
1177 +
1178 + return false;
1040 1179 }
1041 1180 }
1042 1181
1043 1182 private static function patchOrderItemTaxMeta(array $savedItems, array $lineItemsFromTax)
1044 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 = [];
1045 1188 $savedByKey = [];
1046 1189 foreach ($savedItems as $item) {
1190 + $savedById[(int) $item['id']] = $item;
1047 1191 $key = $item['post_id'] . ':' . $item['object_id'];
1048 1192 $savedByKey[$key] = $item;
1049 1193 }
1050 1194
@@ -1050,14 +1194,20 @@
1050 1194
1051 1195 $updateData = [];
1052 1196
1053 1197 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;
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];
1057 1208 }
1058 1209
1059 - $savedItem = $savedByKey[$key];
1060 1210 $taxAmount = (int) Arr::get($taxLineItem, 'tax_amount', 0);
1061 1211 $taxLineMeta = Arr::get($taxLineItem, 'line_meta', []);
1062 1212 $existingMeta = isset($savedItem['line_meta']) ? $savedItem['line_meta'] : [];
1063 1213 if (!is_array($existingMeta)) {
@@ -1877,9 +2027,12 @@
1877 2027 $methodId = (int)$shippingMeta['id'];
1878 2028 $methodTitle = (string)$shippingMeta['title'];
1879 2029 }
1880 2030
1881 - $checkoutShipping = ($methodId && $methodTitle) ? [
2031 + // Gate on the title alone. Live-rate carriers use non-numeric method
2032 + // ids (e.g. "carrier:shippo:usps_priority") which (int) casts to 0,
2033 + // so requiring a truthy id silently hid the method name.
2034 + $checkoutShipping = $methodTitle ? [
1882 2035 'method_id' => $methodId,
1883 2036 'method_title' => $methodTitle,
1884 2037 'shipping_total' => (int)Arr::get($order, 'shipping_total', 0),
1885 2038 ] : null;
@@ -2046,8 +2199,19 @@
2046 2199 $order = static::getQuery()->with("order_items.variants.product_detail")->where('id', $orderId)->first();
2047 2200
2048 2201 $action = Arr::get($params, 'action');
2049 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 +
2050 2214 $changeType = $action === 'change_shipping_status' ? 'shipping_status' : 'order_status';
2051 2215 $actionActivity = [];
2052 2216
2053 2217 if ($action === 'change_shipping_status') {
@@ -2335,8 +2499,12 @@
2335 2499 foreach ($keysToInclude as $key) {
2336 2500 $address->{$key} = $addressData[$key];
2337 2501 }
2338 2502
2503 + if (array_key_exists('meta', $addressData)) {
2504 + $address->meta = $addressData['meta'];
2505 + }
2506 +
2339 2507 if ($address->save()) {
2340 2508 return $address;
2341 2509 }
2342 2510 return static::makeErrorResponse([
@@ -2345,9 +2513,9 @@
2345 2513 }
2346 2514
2347 2515 private static function createOrderAddress(array $address, $orderId)
2348 2516 {
2349 - $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'];
2350 2518 $address = Arr::only($address, $keysToInclude);
2351 2519 $address['order_id'] = $orderId;
2352 2520
2353 2521 if (!empty($address)) {