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