| @@ -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 | ]); |
| @@ -279,8 +328,14 @@ | ||
| 279 | 328 | static::applyAdminOrderTax($order, $items, $customer, $data); |
| 280 | 329 | |
| 281 | 330 | if ($gateway = App::gateway($paymentMethod)) { |
| 282 | 331 | $paymentInstance = new PaymentInstance($order); |
| 332 | + | |
| 333 | + if ($paymentInstance->subscription && $paymentInstance->subscription->status === Status::SUBSCRIPTION_PENDING) { | |
| 334 | + $paymentInstance->subscription->status = Status::SUBSCRIPTION_INTENDED; | |
| 335 | + $paymentInstance->subscription->save(); | |
| 336 | + } | |
| 337 | + | |
| 283 | 338 | $gateway->makePaymentFromPaymentInstance($paymentInstance); |
| 284 | 339 | } |
| 285 | 340 | |
| 286 | 341 | return $order; |
| @@ -391,8 +446,9 @@ | ||
| 391 | 446 | |
| 392 | 447 | // Include manual_discount (set by distributeManualDiscount) so tax is |
| 393 | 448 | // calculated on the after-discount amount, not the full subtotal. |
| 394 | 449 | $taxItems[] = [ |
| 450 | + 'id' => (int) Arr::get($item, 'id', 0), | |
| 395 | 451 | 'post_id' => (int) Arr::get($item, 'post_id', 0), |
| 396 | 452 | 'object_id' => (int) Arr::get($item, 'object_id', 0), |
| 397 | 453 | 'subtotal' => $subtotal, |
| 398 | 454 | 'discount_total' => (int) Arr::get($item, 'discount_total', 0) + (int) Arr::get($item, 'manual_discount', 0), |
| @@ -508,12 +564,94 @@ | ||
| 508 | 564 | } |
| 509 | 565 | } |
| 510 | 566 | |
| 511 | 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 | + /** | |
| 512 | 646 | * Recalculate and persist tax for an existing order after create or update. |
| 513 | 647 | * Reads saved items + billing address from the DB, runs AdminOrderTaxService, |
| 514 | 648 | * recomputes total_amount from scratch, and rewrites fct_order_tax_rate rows. |
| 515 | 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. | |
| 516 | 654 | */ |
| 517 | 655 | private static function reapplyTaxAfterUpdate($orderId, $order) |
| 518 | 656 | { |
| 519 | 657 | try { |
| @@ -521,13 +659,13 @@ | ||
| 521 | 659 | $order->load('order_items'); |
| 522 | 660 | } |
| 523 | 661 | |
| 524 | 662 | if ($order->isSubscription()) { |
| 525 | - return; | |
| 663 | + return true; | |
| 526 | 664 | } |
| 527 | 665 | |
| 528 | 666 | if ($order->type === 'refund') { |
| 529 | - return; | |
| 667 | + return true; | |
| 530 | 668 | } |
| 531 | 669 | |
| 532 | 670 | // Query addresses directly — ORM relation load() does not reliably apply |
| 533 | 671 | // the type WHERE constraint, so we query fct_order_addresses ourselves. |
| @@ -558,10 +696,9 @@ | ||
| 558 | 696 | $basis = Arr::get($taxSettings, 'tax_calculation_basis', 'shipping'); |
| 559 | 697 | $taxAddress = AdminOrderTaxService::resolveAddressForBasis($basis, $billingAddress, $shippingAddress); |
| 560 | 698 | |
| 561 | 699 | if (empty($taxAddress['country'])) { |
| 562 | - static::clearOrderTax($orderId, $order); | |
| 563 | - return; | |
| 700 | + return static::clearOrderTax($orderId, $order); | |
| 564 | 701 | } |
| 565 | 702 | |
| 566 | 703 | $productItems = $order->order_items->filter(function ($item) { |
| 567 | 704 | return !in_array($item->payment_type, ['fee', 'signup_fee'], true); |
| @@ -571,8 +708,9 @@ | ||
| 571 | 708 | foreach ($productItems as $item) { |
| 572 | 709 | $unitPrice = (int) Arr::get($item, 'unit_price', 0); |
| 573 | 710 | $qty = max(1, (int) Arr::get($item, 'quantity', 1)); |
| 574 | 711 | $taxItems[] = [ |
| 712 | + 'id' => (int) Arr::get($item, 'id', 0), | |
| 575 | 713 | 'post_id' => (int) Arr::get($item, 'post_id', 0), |
| 576 | 714 | 'object_id' => (int) Arr::get($item, 'object_id', 0), |
| 577 | 715 | 'subtotal' => $unitPrice * $qty, |
| 578 | 716 | 'discount_total' => (int) Arr::get($item, 'discount_total', 0), |
| @@ -582,10 +720,9 @@ | ||
| 582 | 720 | ]; |
| 583 | 721 | } |
| 584 | 722 | |
| 585 | 723 | if (empty($taxItems)) { |
| 586 | - static::clearOrderTax($orderId, $order); | |
| 587 | - return; | |
| 724 | + return static::clearOrderTax($orderId, $order); | |
| 588 | 725 | } |
| 589 | 726 | |
| 590 | 727 | // Fee items only exist on checkout-created orders that are edited in |
| 591 | 728 | // admin. Mirror checkout (TaxModule::calculateCartTax()): only taxable, |
| @@ -630,12 +767,12 @@ | ||
| 630 | 767 | |
| 631 | 768 | if ($taxResult === null) { |
| 632 | 769 | if (!TaxModule::isTaxEnabled()) { |
| 633 | 770 | // Deterministic: tax was turned off — clear stale tax instead of leaving it. |
| 634 | - static::clearOrderTax($orderId, $order); | |
| 771 | + return static::clearOrderTax($orderId, $order); | |
| 635 | 772 | } |
| 636 | 773 | // Transient calculation failure: keep existing tax untouched. |
| 637 | - return; | |
| 774 | + return false; | |
| 638 | 775 | } |
| 639 | 776 | |
| 640 | 777 | $taxTotal = (int) Arr::get($taxResult, 'tax_total', 0); |
| 641 | 778 | $exclusiveTaxTotal = (int) Arr::get($taxResult, 'exclusive_tax_total', 0); |
| @@ -782,8 +919,9 @@ | ||
| 782 | 919 | static::syncPaymentStatusWithTotals($order); |
| 783 | 920 | |
| 784 | 921 | $DB->commit(); |
| 785 | 922 | |
| 923 | + return true; | |
| 786 | 924 | } catch (\Exception $e) { |
| 787 | 925 | if (isset($DB)) { |
| 788 | 926 | $DB->rollBack(); |
| 789 | 927 | } |
| @@ -791,8 +929,10 @@ | ||
| 791 | 929 | 'Admin order tax recalculation failed on update', |
| 792 | 930 | get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()), |
| 793 | 931 | ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api'] |
| 794 | 932 | ); |
| 933 | + | |
| 934 | + return false; | |
| 795 | 935 | } |
| 796 | 936 | } |
| 797 | 937 | |
| 798 | 938 | /** |
| @@ -936,8 +1076,10 @@ | ||
| 936 | 1076 | /** |
| 937 | 1077 | * Zero out all tax fields, rate rows, and per-item tax amounts for an order |
| 938 | 1078 | * that has become definitively non-taxable (no address, no taxable items). |
| 939 | 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. | |
| 940 | 1082 | */ |
| 941 | 1083 | private static function clearOrderTax($orderId, $order) |
| 942 | 1084 | { |
| 943 | 1085 | try { |
| @@ -1027,8 +1169,10 @@ | ||
| 1027 | 1169 | // Paid orders: reflect the lowered total as paid / refund-owed state. |
| 1028 | 1170 | static::syncPaymentStatusWithTotals($order); |
| 1029 | 1171 | |
| 1030 | 1172 | $DB->commit(); |
| 1173 | + | |
| 1174 | + return true; | |
| 1031 | 1175 | } catch (\Exception $e) { |
| 1032 | 1176 | if (isset($DB)) { |
| 1033 | 1177 | $DB->rollBack(); |
| 1034 | 1178 | } |
| @@ -1036,15 +1180,22 @@ | ||
| 1036 | 1180 | 'Admin order tax clear failed on update', |
| 1037 | 1181 | get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()), |
| 1038 | 1182 | ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api'] |
| 1039 | 1183 | ); |
| 1184 | + | |
| 1185 | + return false; | |
| 1040 | 1186 | } |
| 1041 | 1187 | } |
| 1042 | 1188 | |
| 1043 | 1189 | private static function patchOrderItemTaxMeta(array $savedItems, array $lineItemsFromTax) |
| 1044 | 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 = []; | |
| 1045 | 1195 | $savedByKey = []; |
| 1046 | 1196 | foreach ($savedItems as $item) { |
| 1197 | + $savedById[(int) $item['id']] = $item; | |
| 1047 | 1198 | $key = $item['post_id'] . ':' . $item['object_id']; |
| 1048 | 1199 | $savedByKey[$key] = $item; |
| 1049 | 1200 | } |
| 1050 | 1201 | |
| @@ -1050,14 +1201,20 @@ | ||
| 1050 | 1201 | |
| 1051 | 1202 | $updateData = []; |
| 1052 | 1203 | |
| 1053 | 1204 | 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; | |
| 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]; | |
| 1057 | 1215 | } |
| 1058 | 1216 | |
| 1059 | - $savedItem = $savedByKey[$key]; | |
| 1060 | 1217 | $taxAmount = (int) Arr::get($taxLineItem, 'tax_amount', 0); |
| 1061 | 1218 | $taxLineMeta = Arr::get($taxLineItem, 'line_meta', []); |
| 1062 | 1219 | $existingMeta = isset($savedItem['line_meta']) ? $savedItem['line_meta'] : []; |
| 1063 | 1220 | if (!is_array($existingMeta)) { |
| @@ -1210,8 +1367,35 @@ | ||
| 1210 | 1367 | } |
| 1211 | 1368 | $subscription->save(); |
| 1212 | 1369 | } |
| 1213 | 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 | + | |
| 1214 | 1398 | private static function distributeManualDiscount(&$items, $manualDiscountTotal) |
| 1215 | 1399 | { |
| 1216 | 1400 | $totalSubtotal = array_reduce($items, function ($carry, $item) { |
| 1217 | 1401 | return $carry + ((int)Arr::get($item, 'unit_price', 0) * (int)Arr::get($item, 'quantity', 1)); |
| @@ -1458,8 +1642,27 @@ | ||
| 1458 | 1642 | $appliedCoupons = Arr::get($orderData, 'applied_coupon'); |
| 1459 | 1643 | $discount = $data['discount']; |
| 1460 | 1644 | $shipping = $data['shipping']; |
| 1461 | 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 | + | |
| 1462 | 1665 | $orderId = $order->id; |
| 1463 | 1666 | |
| 1464 | 1667 | /** |
| 1465 | 1668 | * First delete the deleted items |
| @@ -1810,11 +2013,11 @@ | ||
| 1810 | 2013 | ->with( |
| 1811 | 2014 | [ |
| 1812 | 2015 | 'parentOrder' => function ($query) { |
| 1813 | 2016 | return $query->select('id') |
| 1814 | - ->with('subscriptions'); | |
| 2017 | + ->with('subscriptions.product'); | |
| 1815 | 2018 | }, |
| 1816 | - 'subscriptions', | |
| 2019 | + 'subscriptions.product', | |
| 1817 | 2020 | 'activities.user', |
| 1818 | 2021 | 'labels', |
| 1819 | 2022 | 'customer', |
| 1820 | 2023 | 'children' => function ($query) { |
| @@ -1877,9 +2080,12 @@ | ||
| 1877 | 2080 | $methodId = (int)$shippingMeta['id']; |
| 1878 | 2081 | $methodTitle = (string)$shippingMeta['title']; |
| 1879 | 2082 | } |
| 1880 | 2083 | |
| 1881 | - $checkoutShipping = ($methodId && $methodTitle) ? [ | |
| 2084 | + // Gate on the title alone. Live-rate carriers use non-numeric method | |
| 2085 | + // ids (e.g. "carrier:shippo:usps_priority") which (int) casts to 0, | |
| 2086 | + // so requiring a truthy id silently hid the method name. | |
| 2087 | + $checkoutShipping = $methodTitle ? [ | |
| 1882 | 2088 | 'method_id' => $methodId, |
| 1883 | 2089 | 'method_title' => $methodTitle, |
| 1884 | 2090 | 'shipping_total' => (int)Arr::get($order, 'shipping_total', 0), |
| 1885 | 2091 | ] : null; |
| @@ -1932,8 +2138,11 @@ | ||
| 1932 | 2138 | * ] |
| 1933 | 2139 | * ] |
| 1934 | 2140 | * |
| 1935 | 2141 | */ |
| 2142 | + /** | |
| 2143 | + * @deprecated since v1.4. Use OverviewReportController::getOverview() via GET reports/overview instead. | |
| 2144 | + */ | |
| 1936 | 2145 | public static function reportOverview($params = []) |
| 1937 | 2146 | { |
| 1938 | 2147 | return static::getQuery()->when( |
| 1939 | 2148 | $params, |
| @@ -1942,9 +2151,9 @@ | ||
| 1942 | 2151 | } |
| 1943 | 2152 | ) |
| 1944 | 2153 | ->selectRaw('sum(total_amount) as total_sales') |
| 1945 | 2154 | ->selectRaw('sum(total_amount - manual_discount_total - shipping_total - tax_total) as net_sales') |
| 1946 | - ->selectRaw('sum(discount_total) as total_discounts') | |
| 2155 | + ->selectRaw('sum(manual_discount_total + coupon_discount_total) as total_discounts') | |
| 1947 | 2156 | ->selectRaw('sum(shipping_total) as total_shipping_tax') |
| 1948 | 2157 | ->selectRaw('avg(total_amount) as average_order_value') |
| 1949 | 2158 | ->selectRaw('count(*) as customer_order_count') |
| 1950 | 2159 | ->get()->first(); |
| @@ -2043,8 +2252,19 @@ | ||
| 2043 | 2252 | $order = static::getQuery()->with("order_items.variants.product_detail")->where('id', $orderId)->first(); |
| 2044 | 2253 | |
| 2045 | 2254 | $action = Arr::get($params, 'action'); |
| 2046 | 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 | + | |
| 2047 | 2267 | $changeType = $action === 'change_shipping_status' ? 'shipping_status' : 'order_status'; |
| 2048 | 2268 | $actionActivity = []; |
| 2049 | 2269 | |
| 2050 | 2270 | if ($action === 'change_shipping_status') { |
| @@ -2332,8 +2552,12 @@ | ||
| 2332 | 2552 | foreach ($keysToInclude as $key) { |
| 2333 | 2553 | $address->{$key} = $addressData[$key]; |
| 2334 | 2554 | } |
| 2335 | 2555 | |
| 2556 | + if (array_key_exists('meta', $addressData)) { | |
| 2557 | + $address->meta = $addressData['meta']; | |
| 2558 | + } | |
| 2559 | + | |
| 2336 | 2560 | if ($address->save()) { |
| 2337 | 2561 | return $address; |
| 2338 | 2562 | } |
| 2339 | 2563 | return static::makeErrorResponse([ |
| @@ -2342,9 +2566,9 @@ | ||
| 2342 | 2566 | } |
| 2343 | 2567 | |
| 2344 | 2568 | private static function createOrderAddress(array $address, $orderId) |
| 2345 | 2569 | { |
| 2346 | - $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']; | |
| 2347 | 2571 | $address = Arr::only($address, $keysToInclude); |
| 2348 | 2572 | $address['order_id'] = $orderId; |
| 2349 | 2573 | |
| 2350 | 2574 | if (!empty($address)) { |