PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 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 All 51 releases
← All changes | app/Helpers/CheckoutProcessor.php +377 -35 1.3.22 → 1.7.1 View file →
@@ -13,8 +13,10 @@
13 13 use FluentCart\App\Models\Subscription;
14 14 use FluentCart\App\Modules\Tax\TaxCalculator;
15 15 use FluentCart\Framework\Support\Arr;
16 16 use FluentCart\App\Helpers\Helper;
17 +use FluentCart\App\Modules\PaymentMethods\Core\GatewayManager;
18 +use FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode;
17 19
18 20 class CheckoutProcessor
19 21 {
20 22
@@ -20,8 +22,9 @@
20 22
21 23 // Raw Data
22 24 private $cartItems = [];
23 25 private $args = [];
26 + private $validationError;
24 27
25 28 // Order Related Data
26 29 private $formattedIOrderItems = [];
27 30 private $orderData = [];
@@ -44,8 +47,12 @@
44 47 private $couponDiscountTotal = 0;
45 48
46 49 private $manualDiscountTotal = 0;
47 50
51 + private $prorateCreditTotal = 0;
52 +
53 + private $upgradeDiscountTotal = 0;
54 +
48 55 public function __construct($cartItems = [], $args = [])
49 56 {
50 57 $this->storeSettings = new StoreSettings();
51 58 $this->cartItems = $cartItems;
@@ -56,14 +63,43 @@
56 63
57 64 private function prepareData()
58 65 {
59 66 $this->prepareOrderItems();
67 + if ($this->validationError) {
68 + return;
69 + }
70 +
60 71 $this->prepareOrderData();
72 + if ($this->validationError) {
73 + return;
74 + }
75 +
61 76 $this->prepareSubscriptionData();
62 77 }
63 78
79 + /**
80 + * (int) on an out-of-range float wraps, and the max(0, ...) clamps downstream turn a
81 + * wrapped amount into a free but payable order. Refuse the checkout instead.
82 + */
83 + private function isSafeAmount($value)
84 + {
85 + return is_numeric($value) && is_finite((float)$value) && abs((float)$value) < (float)PHP_INT_MAX;
86 + }
87 +
88 + private function unsafeAmountError()
89 + {
90 + return new \WP_Error(
91 + 'invalid_order_total',
92 + __('The order total is too large to process. Please reduce the quantity.', 'fluent-cart')
93 + );
94 + }
95 +
64 96 public function createDraftOrder($prevOrder = null)
65 97 {
98 + if ($this->validationError) {
99 + return $this->validationError;
100 + }
101 +
66 102 if ($prevOrder) {
67 103 return $this->getAdjustedOrder($prevOrder);
68 104 }
69 105
@@ -96,8 +132,11 @@
96 132 if (Arr::get($this->args, 'tax_id', 0)) {
97 133 $this->orderModel->updateMeta('tax_id', Arr::get($this->args, 'tax_id', 0));
98 134 }
99 135
136 + // Store tax meta for mixed carts (tax_behavior=3) - used by payment gateways and renewals
137 + $this->persistTaxMeta();
138 +
100 139 // Let's create the order items
101 140 $normalOrderItems = array_filter($this->formattedIOrderItems, function ($item) {
102 141 return $item['payment_type'] != 'signup_fee';
103 142 });
@@ -117,8 +156,14 @@
117 156 $bundleItems = Arr::get($orderItem, 'bundle_items', []);
118 157 unset($orderItem['bundle_items']);
119 158 }
120 159
160 + if (!empty($orderItem['coupon_discount'])) {
161 + $lineMeta = Arr::get($orderItem, 'line_meta', []);
162 + $lineMeta['coupon_discount'] = (int) $orderItem['coupon_discount'];
163 + $orderItem['line_meta'] = $lineMeta;
164 + }
165 +
121 166 $createdItem = OrderItem::query()->create($orderItem);
122 167
123 168 if ($additionalItems) {
124 169 $additionalItemIds = [];
@@ -126,8 +171,11 @@
126 171 $additionalItem['order_id'] = $this->orderModel->id;
127 172 $additionalItem['line_total'] = Arr::get($additionalItem, 'subtotal', 0) - Arr::get($additionalItem, 'discount_total', 0);
128 173 $mata = Arr::get($additionalItem, 'line_meta', []);
129 174 $mata['parent_item_id'] = $createdItem->id;
175 + if (!empty($additionalItem['coupon_discount'])) {
176 + $mata['coupon_discount'] = (int) $additionalItem['coupon_discount'];
177 + }
130 178 $additionalItem['line_meta'] = $mata;
131 179 $childItem = OrderItem::query()->create($additionalItem);
132 180 $additionalItemIds[] = $childItem->id;
133 181 }
@@ -149,8 +197,11 @@
149 197 $bundleItem['line_total'] = Arr::get($bundleItem, 'subtotal', 0) - Arr::get($bundleItem, 'discount_total', 0);
150 198 $bundleItem['payment_type'] = 'bundle';
151 199 $meta = Arr::get($bundleItem, 'line_meta', []);
152 200 $meta['bundle_parent_item_id'] = $createdItem->id;
201 + if (!empty($bundleItem['coupon_discount'])) {
202 + $meta['coupon_discount'] = (int) $bundleItem['coupon_discount'];
203 + }
153 204 $bundleItem['line_meta'] = $meta;
154 205 $bundleItem = OrderItem::query()->create($bundleItem);
155 206 $bundleItemIds[] = $bundleItem->id;
156 207 }
@@ -180,8 +231,9 @@
180 231 $subscriptionData['customer_id'] = $customerId;
181 232 $subscriptionData['parent_order_id'] = $this->orderModel->id;
182 233
183 234 $this->subscriptionModel = Subscription::query()->create($subscriptionData);
235 + $this->syncInitialCycleCounting();
184 236 }
185 237
186 238 // Let's create the transaction
187 239 $transactionData = [
@@ -224,8 +276,18 @@
224 276 $cart->user_id = $customer->user_id;
225 277 }
226 278
227 279 $cart->save();
280 +
281 + // Carry the traffic source onto the order while the cart still exists.
282 + // Carts are pruned on a schedule, so this is the last reliable point at
283 + // which the click that produced the sale can still be recovered.
284 + UtmHelper::addUtmToOrder(
285 + $this->orderModel->id,
286 + UtmHelper::resolveUtmData(UtmHelper::getUtmDataOfRequest(), $cart->utm_data),
287 + $cart->cart_hash
288 + );
289 +
228 290 $actions = Arr::get($cart->checkout_data, '__after_draft_created_actions__', []);
229 291 if ($actions) {
230 292 foreach ($actions as $actionName) {
231 293 $actionName = (string)$actionName;
@@ -286,8 +348,15 @@
286 348
287 349 if ($isLocked && $taxEnabled !== 'yes') {
288 350 // Locked orders skip full item sync, but fee items must stay in sync with fee_total
289 351 $this->syncFeeItems();
352 +
353 + // Load existing subscription so the transaction gets the correct subscription_id
354 + if ($this->orderModel->type === Status::ORDER_TYPE_SUBSCRIPTION) {
355 + $this->subscriptionModel = Subscription::query()
356 + ->where('parent_order_id', $this->orderModel->id)
357 + ->first();
358 + }
290 359 }
291 360
292 361 if (!$isLocked || $taxEnabled === 'yes') {
293 362 // Let's create the order items
@@ -377,9 +446,9 @@
377 446 )
378 447 ])->save();
379 448 }
380 449 }
381 - if ($taxTotal && !$this->orderModel->tax_total) {
450 + if ($taxTotal && !$this->orderModel->tax_total && !$isLocked) {
382 451 $this->orderModel->tax_total = $taxTotal;
383 452 $this->orderModel->total_amount += $taxTotal;
384 453 $this->orderModel->save();
385 454 }
@@ -404,8 +473,9 @@
404 473 $this->subscriptionModel = $existingSubscription;
405 474 } else {
406 475 $this->subscriptionModel = Subscription::query()->create($subscriptionData);
407 476 }
477 + $this->syncInitialCycleCounting();
408 478 }
409 479 } else {
410 480 Subscription::query()->where('parent_order_id', $this->orderModel->id)->delete();
411 481 }
@@ -413,8 +483,10 @@
413 483
414 484 // Reload the order to get fresh item data after updates
415 485 $this->orderModel = $this->orderModel->fresh();
416 486
487 + $this->persistTaxMeta();
488 +
417 489 // Let's create the transaction
418 490 $transactionData = [
419 491 'order_id' => $this->orderModel->id,
420 492 'order_type' => $this->orderModel->type,
@@ -444,8 +516,26 @@
444 516 ->where('order_id', $this->orderModel->id)
445 517 ->first();
446 518
447 519 if ($existingTransaction) {
520 + $meta = $existingTransaction->meta ?: [];
521 +
522 + // Retry vs duplicate for gateway idempotency (PaymentInstance::getIdempotencySeed):
523 + // re-submitting a pending transaction is a duplicate (keep attempt -> gateway
524 + // dedupes); re-submitting a FAILED one is a retry (bump attempt -> fresh seed,
525 + // never answered with the failed attempt's cached gateway response).
526 + $attempt = (int) Arr::get($meta, 'payment_attempt', 0);
527 + if ($existingTransaction->status === Status::PAYMENT_FAILED) {
528 + $attempt++;
529 + }
530 +
531 + // The gateway object prepared last time (a Paddle transaction, a PayPal
532 + // order) is kept so the gateway can reuse it instead of creating another.
533 + if ($attempt) {
534 + $meta['payment_attempt'] = $attempt;
535 + }
536 + $transactionData['meta'] = $meta;
537 +
448 538 $existingTransaction->fill($transactionData);
449 539 $existingTransaction->save();
450 540 $this->transactionModel = $existingTransaction;
451 541 } else {
@@ -474,8 +564,26 @@
474 564 // We are almost done!
475 565 return $this->orderModel;
476 566 }
477 567
568 + private function persistTaxMeta()
569 + {
570 + $exclusiveTaxTotal = (int) Arr::get($this->args, 'exclusive_tax_total', 0);
571 + $storeTaxBehavior = (int) Arr::get($this->args, 'store_tax_behavior', 0);
572 + $feeTax = (int) Arr::get($this->args, 'fee_tax', 0);
573 + $feeTaxLines = (array) Arr::get($this->args, 'fee_tax_lines', []);
574 +
575 + $this->orderModel->updateMeta('exclusive_tax_total', $exclusiveTaxTotal);
576 + $this->orderModel->updateMeta('store_tax_behavior', $storeTaxBehavior);
577 + $this->orderModel->updateMeta('fee_tax', $feeTax);
578 +
579 + if (!empty($feeTaxLines)) {
580 + $this->orderModel->updateMeta('fee_tax_lines', $feeTaxLines);
581 + } else {
582 + $this->orderModel->deleteMeta('fee_tax_lines');
583 + }
584 + }
585 +
478 586 private function insertAppliedCoupons($appliedCoupons, $removeOlds = false, $order = null): void
479 587 {
480 588 if ($removeOlds) {
481 589 $this->orderModel->appliedCoupons()->delete();
@@ -488,12 +596,22 @@
488 596 $customerId = $order->customer_id;
489 597 }
490 598
491 599 if (!empty($couponCodes)) {
492 - $coupons = Coupon::query()->whereIn('code', $couponCodes)->get()
493 - ->keyBy('code')
494 - ->toArray();
600 + $coupons = Coupon::query()->whereIn('code', $couponCodes)->get();
495 601
602 + /*
603 + * Resolve virtual (un-persisted) coupons so an AppliedCoupon row is written for
604 + * them too — the row stores coupon_id = null (the column is nullable) with the
605 + * code and computed discount, so it shows in the order's Coupons section like any
606 + * coupon. See DiscountService::applyCouponCodes() for the same filter.
607 + */
608 + $coupons = apply_filters('fluent_cart/coupon/resolve_coupons', $coupons, $couponCodes, [
609 + 'order' => $this->orderModel,
610 + ]);
611 +
612 + $coupons = $coupons->keyBy('code')->toArray();
613 +
496 614 foreach ($coupons as $code => &$coupon) {
497 615 $coupon['coupon_id'] = $appliedCoupons[$code]['id'];
498 616 $coupon['amount'] = $appliedCoupons[$code]['discount'];
499 617 $coupon['customer_id'] = $customerId;
@@ -534,9 +652,16 @@
534 652
535 653 $discountTotal = (int)Arr::get($cartItem, 'manual_discount', 0) + (int)Arr::get($cartItem, 'coupon_discount', 0);
536 654 $shippingCharge = (int)Arr::get($cartItem, 'shipping_charge', 0);
537 655
538 - $subtotal = $unitPrice * $quantity;
656 + $rawSubtotal = Arr::get($cartItem, 'subtotal', $unitPrice * $quantity);
657 + if (!$this->isSafeAmount($rawSubtotal)) {
658 + $this->validationError = $this->unsafeAmountError();
659 +
660 + return;
661 + }
662 +
663 + $subtotal = (int) $rawSubtotal;
539 664 $args = Arr::get($cartItem, 'other_info', []);
540 665 $paymentType = Arr::get($args, 'payment_type', 'default');
541 666
542 667 $postTitle = Arr::get($cartItem, 'product_title', '');
@@ -564,23 +689,38 @@
564 689 }
565 690 }
566 691
567 692 // Snapshot package dimensions into other_info for email/PDF rendering
568 - $packageSlug = Arr::get($args, 'package_slug', '');
569 - if ($packageSlug) {
693 + if (Arr::get($cartItem, 'fulfillment_type') === 'physical') {
694 + $packageSlug = Arr::get($args, 'package_slug', '');
570 695 $package = Helper::getPackageBySlug($packageSlug);
571 696 if ($package) {
572 - $args['package_name'] = Arr::get($package, 'name', '');
573 - $args['package_type'] = Arr::get($package, 'type', '');
697 + $args['package_name'] = Arr::get($package, 'name', '');
698 + $args['package_type'] = Arr::get($package, 'type', '');
574 699 $args['package_length'] = Arr::get($package, 'length', '');
575 700 $args['package_width'] = Arr::get($package, 'width', '');
576 701 $args['package_height'] = Arr::get($package, 'height', '');
577 702 $args['package_dimension_unit'] = Arr::get($package, 'dimension_unit', 'cm');
578 - $args['package_weight'] = Arr::get($package, 'weight', 0);
579 - $args['package_weight_unit'] = Arr::get($package, 'weight_unit', 'kg');
703 + $args['package_weight'] = Arr::get($package, 'weight', 0);
704 + $args['package_weight_unit'] = Arr::get($package, 'weight_unit', 'kg');
580 705 }
581 706 }
582 707
708 + // Carry the attribute snapshot onto the order item. It normally
709 + // arrives via the cart item's other_info; rebuild it here as a
710 + // fallback for items that reach checkout without one (instant
711 + // checkout, legacy carts).
712 + if (!isset($args['item_attributes'])) {
713 + $args['item_attributes'] = AttributeHelper::getProductItemAttributes(
714 + Arr::get($cartItem, 'object_id', 0),
715 + Arr::get($cartItem, 'post_id', 0)
716 + );
717 + }
718 +
719 + if (!isset($args['variation_type'])) {
720 + $args['variation_type'] = (string) Arr::get($cartItem, 'variation_type', '');
721 + }
722 +
583 723 $item = [
584 724 'payment_type' => $paymentType,
585 725 'post_id' => Arr::get($cartItem, 'post_id'),
586 726 'object_id' => Arr::get($cartItem, 'object_id'),
@@ -593,8 +733,9 @@
593 733 'subtotal' => $subtotal,
594 734 'tax_amount' => (int)Arr::get($cartItem, 'tax_amount', 0),
595 735 'shipping_charge' => $shippingCharge,
596 736 'discount_total' => $discountTotal,
737 + 'coupon_discount' => (int)Arr::get($cartItem, 'coupon_discount', 0),
597 738 'other_info' => $args,
598 739 'line_meta' => Arr::get($cartItem, 'line_meta', []),
599 740 ];
600 741
@@ -608,10 +749,16 @@
608 749 $signupFeeAmount = (int)Arr::get($cartItem, 'other_info.signup_fee', 0);
609 750
610 751 $signupFeeTax = (int)Arr::get($cartItem, 'other_info.signup_fee_tax', 0);
611 752
753 + // Nest under tax_config — the same shape regular items and the admin
754 + // order path use. Readers keep a fallback for the legacy flat shape.
755 + $signupFeeTaxConfig = Arr::get($cartItem, 'signup_fee_tax_config', []);
756 +
612 757 $childDiscountTotal = 0;
758 + $childCouponDiscount = 0;
613 759 $signupFeeSubtotal = $signupFeeAmount * $quantity;
760 + $couponDiscount = $item['coupon_discount'];
614 761
615 762 $hasTrialDays = Arr::get($cartItem, 'other_info.trial_days', 0) > 0;
616 763
617 764 if ($discountTotal && !$hasTrialDays) {
@@ -616,11 +763,15 @@
616 763
617 764 if ($discountTotal && !$hasTrialDays) {
618 765 $childDiscountTotal = (float)($discountTotal / ($subtotal + $signupFeeSubtotal) * $signupFeeSubtotal);
619 766 $discountTotal -= $childDiscountTotal;
767 + $childCouponDiscount = (int) round($couponDiscount * $signupFeeSubtotal / ($subtotal + $signupFeeSubtotal));
768 + $couponDiscount -= $childCouponDiscount;
620 769 } elseif ($discountTotal && $hasTrialDays) { // if trial days , then discount should be applied on signup fee only
621 770 $childDiscountTotal = min($discountTotal, $signupFeeSubtotal);
622 771 $discountTotal = 0;
772 + $childCouponDiscount = min($couponDiscount, $signupFeeSubtotal);
773 + $couponDiscount = 0;
623 774 }
624 775
625 776 $childItem = [
626 777 'payment_type' => 'signup_fee',
@@ -635,12 +786,14 @@
635 786 'subtotal' => $signupFeeSubtotal,
636 787 'tax_amount' => $signupFeeTax,
637 788 'shipping_charge' => 0,
638 789 'discount_total' => $childDiscountTotal,
639 - 'line_meta' => Arr::get($cartItem, 'signup_fee_tax_config', []),
790 + 'coupon_discount' => $childCouponDiscount,
791 + 'line_meta' => $signupFeeTaxConfig ? ['tax_config' => $signupFeeTaxConfig] : [],
640 792 ];
641 793
642 794 $item['discount_total'] = $discountTotal;
795 + $item['coupon_discount'] = $couponDiscount;
643 796 $item['additional_items'] = [$childItem];
644 797
645 798 Arr::set($item, 'other_info.signup_fee', $signupFeeAmount);
646 799 Arr::set($item, 'other_info.signup_discount', $childDiscountTotal);
@@ -754,9 +907,9 @@
754 907
755 908 $item = reset($subscriptionItems);
756 909 $signupFeeItem = reset($signupFeeItems) ?? [];
757 910 $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0);
758 - $taxBehavior = Arr::get($this->args, 'tax_behavior', 0);
911 + $taxBehavior = (int)Arr::get($this->args, 'tax_behavior', 0);
759 912
760 913 $recurringTotal = (int)$item['subtotal'];
761 914 $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0);
762 915
@@ -765,16 +918,24 @@
765 918 if ($recurringDiscountAmount && $recurringDiscountAmount > 0) {
766 919 $recurringTotal -= $recurringDiscountAmount;
767 920 }
768 921
769 - // Add shipping charges to recurring total for physical subscription products
922 + // Add shipping charges (and tax) to recurring total for physical subscription products
770 923 $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0);
771 924 $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical';
772 925 if ($isPhysicalProduct && $shippingCharge > 0) {
773 926 $recurringTotal += $shippingCharge;
927 + $shippingTax = (int)Arr::get($this->args, 'shipping_tax', 0);
928 + if ($shippingTax > 0) {
929 + $storeTaxBehavior = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
930 + if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1)) {
931 + $recurringTotal += $shippingTax;
932 + }
933 + }
774 934 }
775 935
776 - if ($taxBehavior === 1) {
936 + $itemInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
937 + if ($taxBehavior === 1 || ($taxBehavior === 3 && !$itemInclusive)) {
777 938 $recurringTotal += $recurringTax;
778 939 }
779 940
780 941 $signupFee = (int)Arr::get($signupFeeItem, 'subtotal', 0);
@@ -781,16 +942,22 @@
781 942
782 943 // in case of discount applied 'tax_amount' is different than recurring tax ,
783 944 $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax;
784 945
785 -
786 946 // Calculate recurring amount including shipping for physical products
787 947 $recurringAmount = (int)$item['subtotal'];
788 948 if ($isPhysicalProduct && $shippingCharge > 0) {
789 949 $recurringAmount += $shippingCharge;
950 + $shippingTaxForFirst = (int)Arr::get($this->args, 'shipping_tax', 0);
951 + if ($shippingTaxForFirst > 0) {
952 + $storeTaxBehaviorForFirst = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
953 + if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehaviorForFirst === 1)) {
954 + $firstIterationTax += $shippingTaxForFirst;
955 + }
956 + }
790 957 }
791 958
792 - $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0);
959 + $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0) + $this->prorateCreditTotal + $this->upgradeDiscountTotal;
793 960 $subscriptionPricing = $this->convertToSubscriptionFormat([
794 961 'initial_trial_days' => Arr::get($item, 'other_info.trial_days', 0),
795 962 'repeat_interval' => Arr::get($item, 'other_info.repeat_interval', 'monthly'),
796 963 'times' => Arr::get($item, 'other_info.times', 0),
@@ -797,12 +964,13 @@
797 964 'recurring_amount' => $recurringAmount,
798 965 'recurring_tax_total' => $recurringTax,
799 966 'recurring_total' => $recurringTotal,
800 967 'tax_behavior' => $taxBehavior,
968 + 'line_meta' => Arr::get($item, 'line_meta', []),
801 969 'signup_fee' => $signupFee,
802 970 'signup_fee_tax' => $signupFeeTax,
803 971 'first_iteration_tax' => $firstIterationTax,
804 - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
972 + 'recurring_discount' => $recurringDiscountAmount,
805 973 'total_discount' => $discountTotal
806 974 ]);
807 975
808 976 // removable upon discussion
@@ -818,20 +986,61 @@
818 986 'variation_id' => Arr::get($item, 'object_id', 0),
819 987 'status' => Status::SUBSCRIPTION_PENDING,
820 988 'config' => [
821 989 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
822 - 'currency' => $this->orderData['currency']
990 + 'currency' => $this->orderData['currency'],
991 + // Snapshot the variant attribute map + variation type from the order
992 + // item so the subscription carries the same pa_* set behind its item_name.
993 + 'item_attributes' => Arr::get($item, 'other_info.item_attributes', []),
994 + 'variation_type' => Arr::get($item, 'other_info.variation_type', '')
823 995 ]
824 996 ];
825 997
826 - // if recurring coupon is applied, we need to subtract the total discount from the recurring total
827 - if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') {
828 - $subscriptionItem['recurring_total'] -= $discountTotal;
998 + $subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
999 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1000 +
1001 + $collectionMethod = apply_filters('fluent_cart/subscription_collection_method_' . $paymentMethod, $this->determineCollectionMethod());
1002 +
1003 + // A filter can hand back anything, but `system` only means something on a
1004 + // gateway that can charge a saved payment method.
1005 + $subscriptionData['collection_method'] = SubscriptionManagementMode::sanitizeCollectionMethod(
1006 + $collectionMethod,
1007 + GatewayManager::getInstance()->get($paymentMethod)
1008 + );
1009 +
1010 + // Stamp store-managed origin durably on the subscription. Gateways consult
1011 + // the stamp (not the current store setting) before converting a manual
1012 + // subscription to automatic, so switching the mode back to gateway-managed
1013 + // later never flips subscriptions born under store-managed.
1014 + if (in_array($subscriptionData['collection_method'], ['manual', 'system'], true) && SubscriptionManagementMode::isStoreManaged()) {
1015 + $subscriptionConfig = Arr::get($subscriptionData, 'config', []);
1016 + $subscriptionConfig[SubscriptionManagementMode::CONFIG_KEY] = SubscriptionManagementMode::STORE_MANAGED;
1017 + $subscriptionData['config'] = $subscriptionConfig;
829 1018 }
830 1019
831 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
1020 + $this->subscriptionData = $subscriptionData;
832 1021 }
833 1022
1023 + private function determineCollectionMethod(): string
1024 + {
1025 + if (SubscriptionManagementMode::isStoreManaged()) {
1026 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1027 +
1028 + return SubscriptionManagementMode::resolveCollectionMethodFor(
1029 + GatewayManager::getInstance()->get($paymentMethod)
1030 + );
1031 + }
1032 +
1033 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1034 + $gateway = GatewayManager::getInstance()->get($paymentMethod);
1035 +
1036 + if ($gateway && $gateway->has('subscriptions')) {
1037 + return 'automatic';
1038 + }
1039 +
1040 + return 'manual';
1041 + }
1042 +
834 1043 private function prepareOrderData()
835 1044 {
836 1045 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
837 1046 return $item['fulfillment_type'] === 'physical';
@@ -851,8 +1060,24 @@
851 1060 }
852 1061 return $carry + $item['subtotal'];
853 1062 }, 0);
854 1063
1064 + $taxBehavior = (int) Arr::get($this->args, 'tax_behavior', 0);
1065 + $storeTaxBehavior = (int) Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
1066 + $exclusiveTaxTotal = (int) Arr::get($this->args, 'exclusive_tax_total', 0);
1067 + $feeTax = (int) Arr::get($this->args, 'fee_tax', 0);
1068 +
1069 + // Roll fee tax into fee_total for exclusive scenarios — gateways use fee_total as source of truth.
1070 + if ($feeTax && ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1))) {
1071 + $this->feeTotal += $feeTax;
1072 + }
1073 +
1074 + $this->prorateCreditTotal = (int) Arr::get($this->args, 'prorate_credit', 0);
1075 + // Upgrade-path discount is a post-tax adjustment like the prorate credit: it does
1076 + // not reduce the taxable base (tax args were computed on the full price), it only
1077 + // reduces the payable total via manual_discount_total below.
1078 + $this->upgradeDiscountTotal = (int) Arr::get($this->args, 'upgrade_discount', 0);
1079 +
855 1080 $orderData = [
856 1081 'status' => Status::ORDER_ON_HOLD,
857 1082 'fulfillment_type' => $hasPhysical ? Status::FULFILLMENT_TYPE_PHYSICAL : Status::FULFILLMENT_TYPE_DIGITAL,
858 1083 'type' => $hasSubscription ? Status::ORDER_TYPE_SUBSCRIPTION : Status::ORDER_TYPE_PAYMENT, // revisit this on manual renewal
@@ -864,15 +1089,15 @@
864 1089 'payment_method_title' => '',
865 1090 'currency' => $this->storeSettings->get('currency'),
866 1091 'subtotal' => $itemsSubtotal,
867 1092 'discount_tax' => 0,
868 - 'manual_discount_total' => $this->manualDiscountTotal,
1093 + 'manual_discount_total' => $this->manualDiscountTotal + $this->prorateCreditTotal + $this->upgradeDiscountTotal,
869 1094 'coupon_discount_total' => $this->couponDiscountTotal,
870 1095 'shipping_tax' => Arr::get($this->args, 'shipping_tax', 0),
871 1096 'shipping_total' => Arr::get($this->args, 'shipping_charge', 0),
872 1097 'fee_total' => $this->feeTotal,
873 1098 'tax_total' => Arr::get($this->args, 'tax_total', 0),
874 - 'tax_behavior' => Arr::get($this->args, 'tax_behavior', 0),
1099 + 'tax_behavior' => $taxBehavior,
875 1100 // 'total_amount' => $this->orderTotals['total_amount'],
876 1101 'total_paid' => 0,
877 1102 'total_refund' => 0,
878 1103 'rate' => 1,
@@ -879,18 +1104,73 @@
879 1104 'note' => Arr::get($this->args, 'note', ''),
880 1105 'ip_address' => Arr::get($this->args, 'ip_address', ''),
881 1106 'config' => [
882 1107 'user_tz' => Arr::get($this->args, 'user_tz', ''),
883 - 'create_account_after_paid' => Arr::get($this->args, 'create_account_after_paid', 'no')
1108 + 'create_account_after_paid' => Arr::get($this->args, 'create_account_after_paid', 'no'),
1109 + 'shipping_method_id' => Arr::get($this->args, 'shipping_method_id', 0),
1110 + 'shipping_method_title' => Arr::get($this->args, 'shipping_method_title', ''),
1111 + 'prorate_credit' => $this->prorateCreditTotal,
1112 + 'upgrade_discount' => $this->upgradeDiscountTotal,
884 1113 ],
885 1114 ];
886 1115
887 - $estimatedTaxTotal = $orderData['tax_behavior'] === 1 ? $orderData['tax_total'] : 0;
888 - $estimatedShippingTax = $orderData['tax_behavior'] === 1 ? $orderData['shipping_tax'] : 0;
1116 + if ($taxBehavior === 1) {
1117 + // Pure exclusive: fee_tax is already in fee_total; exclude it here to avoid double-count.
1118 + $estimatedTaxTotal = $orderData['tax_total'] - $feeTax;
1119 + $estimatedShippingTax = $orderData['shipping_tax'];
1120 + } elseif ($taxBehavior === 3) {
1121 + // Mixed: fee_tax rolled into fee_total for exclusive store; shipping still additive.
1122 + $estimatedTaxTotal = $exclusiveTaxTotal;
1123 + $estimatedShippingTax = ($storeTaxBehavior === 1) ? $orderData['shipping_tax'] : 0;
1124 + } else {
1125 + // Inclusive (2) or reverse-charge (0): nothing to add to total; tax is in item prices.
1126 + $estimatedTaxTotal = 0;
1127 + $estimatedShippingTax = 0;
1128 + if ($taxBehavior !== 2) {
1129 + // Reverse-charge (0): zero stored columns — no tax applies.
1130 + // Inclusive (2): keep orderData values so reporting surfaces can read them.
1131 + $orderData['tax_total'] = 0;
1132 + $orderData['shipping_tax'] = 0;
1133 + }
1134 + }
889 1135
890 - $totalAmount = $orderData['subtotal'] - $orderData['coupon_discount_total'] - $orderData['manual_discount_total'] + $orderData['fee_total'] + $orderData['shipping_total'] + $estimatedTaxTotal + $estimatedShippingTax;
1136 + $totalAmount = $orderData['subtotal']
1137 + - $orderData['coupon_discount_total']
1138 + - $orderData['manual_discount_total']
1139 + + $orderData['fee_total']
1140 + + $orderData['shipping_total']
1141 + + $estimatedTaxTotal
1142 + + $estimatedShippingTax;
891 1143
892 1144 $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0;
1145 +
1146 + /**
1147 + * Filter the prepared order data before it is used for order creation.
1148 + *
1149 + * This runs after FluentCart calculates totals, so plugins can adjust
1150 + * currency, rate, totals, config, mode, or any other order field before
1151 + * the order model, transaction, and subscription are derived from it.
1152 + *
1153 + * @param array $orderData Prepared order data array.
1154 + * @param array $context {
1155 + * Additional context for the filter.
1156 + *
1157 + * @type array $items Formatted order items with prices and quantities.
1158 + * @type array $args Checkout arguments: customer data, payment method,
1159 + * shipping, tax, coupons, fees, and IP data.
1160 + * }
1161 + */
1162 + $orderData = apply_filters('fluent_cart/checkout/order_data', $orderData, [
1163 + 'items' => $this->formattedIOrderItems,
1164 + 'args' => $this->args,
1165 + ]);
1166 +
1167 + if (!$this->isSafeAmount(Arr::get($orderData, 'total_amount', 0))) {
1168 + $this->validationError = $this->unsafeAmountError();
1169 +
1170 + return;
1171 + }
1172 +
893 1173 $this->orderData = $orderData;
894 1174 }
895 1175
896 1176 private function syncFeeItems()
@@ -1015,9 +1295,15 @@
1015 1295 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1016 1296 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1017 1297 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1018 1298 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1299 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1019 1300
1301 + // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1302 + $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1303 + $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
1304 + $isAdditiveTax = ($taxBehavior === 1) || ($taxBehavior === 3 && !$itemInclusive);
1305 +
1020 1306 // Validate repeat_interval
1021 1307 $validIntervals = array_keys(Helper::getAvailableSubscriptionIntervalMaps());
1022 1308
1023 1309 if (!in_array($repeatInterval, $validIntervals)) {
@@ -1045,16 +1331,19 @@
1045 1331 if ($trialDays > 0 && $signupFee > 0) {
1046 1332 $result['signup_fee'] = max(0, $signupFee - $totalDiscount);
1047 1333 $result['manage_setup_fee'] = 'yes';
1048 1334
1049 - if (Arr::get($inputData, 'tax_behavior', 0) == 1 && $firstIterationTax) {
1335 + if ($isAdditiveTax && $firstIterationTax) {
1050 1336 $result['signup_fee'] += $firstIterationTax;
1051 1337 }
1052 1338 } else {
1053 1339 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1054 1340
1055 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1056 - $recurringAmount -= $totalDiscount; // as now discount applied on recurring amount
1341 + // A recurring coupon discounts every cycle, so the per-cycle price itself
1342 + // is lower — the first cycle is not cheaper than the ones after it and
1343 + // must not be expressed as a trial.
1344 + if ($recurringDiscount > 0) {
1345 + $recurringAmount -= $recurringDiscount;
1057 1346 }
1058 1347
1059 1348 if ($firstCycleCost < $recurringAmount) {
1060 1349 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
@@ -1062,9 +1351,11 @@
1062 1351 $result['trial_days'] = $adjustedTrialDays;
1063 1352 $result['is_trial_days_simulated'] = 'yes';
1064 1353 $result['signup_fee'] = $firstCycleCost;
1065 1354 $result['manage_setup_fee'] = 'yes';
1066 - $result['times'] = $times > 0 ? $times - 1 : 0;
1355 + // bill_times stays the full installment count. The simulated trial cycle IS the
1356 + // first installment (charged as one-time payment / free when 100% discounted);
1357 + // gateways derive the remaining remote cycles from is_trial_days_simulated.
1067 1358 } else if ($firstCycleCost > $recurringAmount) {
1068 1359 $result['trial_days'] = 0;
1069 1360 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1070 1361 $result['manage_setup_fee'] = 'yes';
@@ -1074,9 +1365,9 @@
1074 1365 $result['manage_setup_fee'] = 'no';
1075 1366 }
1076 1367
1077 1368 // only the signup fee is adjustable on our system, so we can adjust that to our needs
1078 - if (Arr::get($inputData, 'tax_behavior', 0) == 1 && $firstIterationTax) {
1369 + if ($isAdditiveTax && $firstIterationTax) {
1079 1370 if ($result['trial_days'] > 0) {
1080 1371 $result['signup_fee'] += $firstIterationTax;
1081 1372 } else {
1082 1373 $result['signup_fee'] += ($firstIterationTax - $recurringTax); // need to minus the recurring tax as it would add automatically to the payable amount as no trial days
@@ -1086,9 +1377,9 @@
1086 1377
1087 1378 } else {
1088 1379 $result['signup_fee'] = $signupFee;
1089 1380
1090 - if (Arr::get($inputData, 'tax_behavior', 0) == 1) {
1381 + if ($isAdditiveTax) {
1091 1382 if ($signupFeeTax) {
1092 1383 $result['signup_fee'] += $signupFeeTax;
1093 1384 }
1094 1385 }
@@ -1106,6 +1397,57 @@
1106 1397 'recurring_amount' => $recurringAmount,
1107 1398 'recurring_tax_total' => $recurringTax,
1108 1399 'signup_fee' => $result['signup_fee'] ?? 0,
1109 1400 ];
1401 + }
1402 +
1403 + /**
1404 + * bill_count is derived from counting total > 0 CHARGE transactions linked to
1405 + * the subscription (see syncSubscriptionStates / getRequiredBillTimes), which
1406 + * can't tell "this was a billed cycle" from "this was something else
1407 + * charged alongside it." Two corrections needed only at initial checkout —
1408 + * is_trial_days_simulated alone can't be used at runtime because
1409 + * payment-method switching also sets that flag:
1410 + *
1411 + * - Simulated trial, $0 first cycle: consumes a cycle but produces no
1412 + * total > 0 transaction — add billed_cycles_offset so it still counts.
1413 + * - Real trial with a signup fee: the initial charge is the signup fee only
1414 + * (the recurring item isn't billed yet), but it IS a total > 0 transaction
1415 + * linked to the subscription — mark billed_cycles_deduction so it does
1416 + * NOT count as a cycle.
1417 + */
1418 + private function syncInitialCycleCounting()
1419 + {
1420 + if (!$this->subscriptionModel) {
1421 + return;
1422 + }
1423 +
1424 + $isSimulated = Arr::get($this->subscriptionData, 'config.is_trial_days_simulated', 'no') === 'yes';
1425 + $trialDays = (int)Arr::get($this->subscriptionData, 'trial_days', 0);
1426 + $billTimes = (int)$this->subscriptionModel->bill_times;
1427 + $orderTotal = (int)$this->orderModel->total_amount;
1428 +
1429 + // signup_fee <= 0 (not just == 0): prorate/upgrade credit can push the
1430 + // first cycle cost negative — still a free first cycle for counting
1431 + $isFreeFirstCycle = $isSimulated
1432 + && $billTimes > 0
1433 + && (int)$this->subscriptionModel->signup_fee <= 0
1434 + && !$orderTotal;
1435 +
1436 + if ($isFreeFirstCycle) {
1437 + $this->subscriptionModel->updateMeta('billed_cycles_offset', 1);
1438 + } else {
1439 + $this->subscriptionModel->deleteMeta('billed_cycles_offset');
1440 + }
1441 +
1442 + $isRealTrialWithCharge = !$isSimulated
1443 + && $trialDays > 0
1444 + && $billTimes > 0
1445 + && $orderTotal > 0;
1446 +
1447 + if ($isRealTrialWithCharge) {
1448 + $this->subscriptionModel->updateMeta('billed_cycles_deduction', 1);
1449 + } else {
1450 + $this->subscriptionModel->deleteMeta('billed_cycles_deduction');
1451 + }
1110 1452 }
1111 1453 }