| @@ -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)) { |