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 +387 -29 1.3.21 → 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', '');
@@ -563,8 +688,39 @@
563 688 }
564 689 }
565 690 }
566 691
692 + // Snapshot package dimensions into other_info for email/PDF rendering
693 + if (Arr::get($cartItem, 'fulfillment_type') === 'physical') {
694 + $packageSlug = Arr::get($args, 'package_slug', '');
695 + $package = Helper::getPackageBySlug($packageSlug);
696 + if ($package) {
697 + $args['package_name'] = Arr::get($package, 'name', '');
698 + $args['package_type'] = Arr::get($package, 'type', '');
699 + $args['package_length'] = Arr::get($package, 'length', '');
700 + $args['package_width'] = Arr::get($package, 'width', '');
701 + $args['package_height'] = Arr::get($package, 'height', '');
702 + $args['package_dimension_unit'] = Arr::get($package, 'dimension_unit', 'cm');
703 + $args['package_weight'] = Arr::get($package, 'weight', 0);
704 + $args['package_weight_unit'] = Arr::get($package, 'weight_unit', 'kg');
705 + }
706 + }
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 +
567 723 $item = [
568 724 'payment_type' => $paymentType,
569 725 'post_id' => Arr::get($cartItem, 'post_id'),
570 726 'object_id' => Arr::get($cartItem, 'object_id'),
@@ -577,8 +733,9 @@
577 733 'subtotal' => $subtotal,
578 734 'tax_amount' => (int)Arr::get($cartItem, 'tax_amount', 0),
579 735 'shipping_charge' => $shippingCharge,
580 736 'discount_total' => $discountTotal,
737 + 'coupon_discount' => (int)Arr::get($cartItem, 'coupon_discount', 0),
581 738 'other_info' => $args,
582 739 'line_meta' => Arr::get($cartItem, 'line_meta', []),
583 740 ];
584 741
@@ -592,10 +749,16 @@
592 749 $signupFeeAmount = (int)Arr::get($cartItem, 'other_info.signup_fee', 0);
593 750
594 751 $signupFeeTax = (int)Arr::get($cartItem, 'other_info.signup_fee_tax', 0);
595 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 +
596 757 $childDiscountTotal = 0;
758 + $childCouponDiscount = 0;
597 759 $signupFeeSubtotal = $signupFeeAmount * $quantity;
760 + $couponDiscount = $item['coupon_discount'];
598 761
599 762 $hasTrialDays = Arr::get($cartItem, 'other_info.trial_days', 0) > 0;
600 763
601 764 if ($discountTotal && !$hasTrialDays) {
@@ -600,11 +763,15 @@
600 763
601 764 if ($discountTotal && !$hasTrialDays) {
602 765 $childDiscountTotal = (float)($discountTotal / ($subtotal + $signupFeeSubtotal) * $signupFeeSubtotal);
603 766 $discountTotal -= $childDiscountTotal;
767 + $childCouponDiscount = (int) round($couponDiscount * $signupFeeSubtotal / ($subtotal + $signupFeeSubtotal));
768 + $couponDiscount -= $childCouponDiscount;
604 769 } elseif ($discountTotal && $hasTrialDays) { // if trial days , then discount should be applied on signup fee only
605 770 $childDiscountTotal = min($discountTotal, $signupFeeSubtotal);
606 771 $discountTotal = 0;
772 + $childCouponDiscount = min($couponDiscount, $signupFeeSubtotal);
773 + $couponDiscount = 0;
607 774 }
608 775
609 776 $childItem = [
610 777 'payment_type' => 'signup_fee',
@@ -619,12 +786,14 @@
619 786 'subtotal' => $signupFeeSubtotal,
620 787 'tax_amount' => $signupFeeTax,
621 788 'shipping_charge' => 0,
622 789 'discount_total' => $childDiscountTotal,
623 - 'line_meta' => Arr::get($cartItem, 'signup_fee_tax_config', []),
790 + 'coupon_discount' => $childCouponDiscount,
791 + 'line_meta' => $signupFeeTaxConfig ? ['tax_config' => $signupFeeTaxConfig] : [],
624 792 ];
625 793
626 794 $item['discount_total'] = $discountTotal;
795 + $item['coupon_discount'] = $couponDiscount;
627 796 $item['additional_items'] = [$childItem];
628 797
629 798 Arr::set($item, 'other_info.signup_fee', $signupFeeAmount);
630 799 Arr::set($item, 'other_info.signup_discount', $childDiscountTotal);
@@ -738,9 +907,9 @@
738 907
739 908 $item = reset($subscriptionItems);
740 909 $signupFeeItem = reset($signupFeeItems) ?? [];
741 910 $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0);
742 - $taxBehavior = Arr::get($this->args, 'tax_behavior', 0);
911 + $taxBehavior = (int)Arr::get($this->args, 'tax_behavior', 0);
743 912
744 913 $recurringTotal = (int)$item['subtotal'];
745 914 $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0);
746 915
@@ -749,16 +918,24 @@
749 918 if ($recurringDiscountAmount && $recurringDiscountAmount > 0) {
750 919 $recurringTotal -= $recurringDiscountAmount;
751 920 }
752 921
753 - // Add shipping charges to recurring total for physical subscription products
922 + // Add shipping charges (and tax) to recurring total for physical subscription products
754 923 $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0);
755 924 $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical';
756 925 if ($isPhysicalProduct && $shippingCharge > 0) {
757 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 + }
758 934 }
759 935
760 - if ($taxBehavior === 1) {
936 + $itemInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
937 + if ($taxBehavior === 1 || ($taxBehavior === 3 && !$itemInclusive)) {
761 938 $recurringTotal += $recurringTax;
762 939 }
763 940
764 941 $signupFee = (int)Arr::get($signupFeeItem, 'subtotal', 0);
@@ -765,16 +942,22 @@
765 942
766 943 // in case of discount applied 'tax_amount' is different than recurring tax ,
767 944 $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax;
768 945
769 -
770 946 // Calculate recurring amount including shipping for physical products
771 947 $recurringAmount = (int)$item['subtotal'];
772 948 if ($isPhysicalProduct && $shippingCharge > 0) {
773 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 + }
774 957 }
775 958
776 - $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;
777 960 $subscriptionPricing = $this->convertToSubscriptionFormat([
778 961 'initial_trial_days' => Arr::get($item, 'other_info.trial_days', 0),
779 962 'repeat_interval' => Arr::get($item, 'other_info.repeat_interval', 'monthly'),
780 963 'times' => Arr::get($item, 'other_info.times', 0),
@@ -781,12 +964,13 @@
781 964 'recurring_amount' => $recurringAmount,
782 965 'recurring_tax_total' => $recurringTax,
783 966 'recurring_total' => $recurringTotal,
784 967 'tax_behavior' => $taxBehavior,
968 + 'line_meta' => Arr::get($item, 'line_meta', []),
785 969 'signup_fee' => $signupFee,
786 970 'signup_fee_tax' => $signupFeeTax,
787 971 'first_iteration_tax' => $firstIterationTax,
788 - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
972 + 'recurring_discount' => $recurringDiscountAmount,
789 973 'total_discount' => $discountTotal
790 974 ]);
791 975
792 976 // removable upon discussion
@@ -802,20 +986,61 @@
802 986 'variation_id' => Arr::get($item, 'object_id', 0),
803 987 'status' => Status::SUBSCRIPTION_PENDING,
804 988 'config' => [
805 989 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
806 - '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', '')
807 995 ]
808 996 ];
809 997
810 - // if recurring coupon is applied, we need to subtract the total discount from the recurring total
811 - if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') {
812 - $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;
813 1018 }
814 1019
815 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
1020 + $this->subscriptionData = $subscriptionData;
816 1021 }
817 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 +
818 1043 private function prepareOrderData()
819 1044 {
820 1045 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
821 1046 return $item['fulfillment_type'] === 'physical';
@@ -835,8 +1060,24 @@
835 1060 }
836 1061 return $carry + $item['subtotal'];
837 1062 }, 0);
838 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 +
839 1080 $orderData = [
840 1081 'status' => Status::ORDER_ON_HOLD,
841 1082 'fulfillment_type' => $hasPhysical ? Status::FULFILLMENT_TYPE_PHYSICAL : Status::FULFILLMENT_TYPE_DIGITAL,
842 1083 'type' => $hasSubscription ? Status::ORDER_TYPE_SUBSCRIPTION : Status::ORDER_TYPE_PAYMENT, // revisit this on manual renewal
@@ -848,15 +1089,15 @@
848 1089 'payment_method_title' => '',
849 1090 'currency' => $this->storeSettings->get('currency'),
850 1091 'subtotal' => $itemsSubtotal,
851 1092 'discount_tax' => 0,
852 - 'manual_discount_total' => $this->manualDiscountTotal,
1093 + 'manual_discount_total' => $this->manualDiscountTotal + $this->prorateCreditTotal + $this->upgradeDiscountTotal,
853 1094 'coupon_discount_total' => $this->couponDiscountTotal,
854 1095 'shipping_tax' => Arr::get($this->args, 'shipping_tax', 0),
855 1096 'shipping_total' => Arr::get($this->args, 'shipping_charge', 0),
856 1097 'fee_total' => $this->feeTotal,
857 1098 'tax_total' => Arr::get($this->args, 'tax_total', 0),
858 - 'tax_behavior' => Arr::get($this->args, 'tax_behavior', 0),
1099 + 'tax_behavior' => $taxBehavior,
859 1100 // 'total_amount' => $this->orderTotals['total_amount'],
860 1101 'total_paid' => 0,
861 1102 'total_refund' => 0,
862 1103 'rate' => 1,
@@ -863,18 +1104,73 @@
863 1104 'note' => Arr::get($this->args, 'note', ''),
864 1105 'ip_address' => Arr::get($this->args, 'ip_address', ''),
865 1106 'config' => [
866 1107 'user_tz' => Arr::get($this->args, 'user_tz', ''),
867 - '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,
868 1113 ],
869 1114 ];
870 1115
871 - $estimatedTaxTotal = $orderData['tax_behavior'] === 1 ? $orderData['tax_total'] : 0;
872 - $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 + }
873 1135
874 - $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;
875 1143
876 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 +
877 1173 $this->orderData = $orderData;
878 1174 }
879 1175
880 1176 private function syncFeeItems()
@@ -999,9 +1295,15 @@
999 1295 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1000 1296 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1001 1297 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1002 1298 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1299 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1003 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 +
1004 1306 // Validate repeat_interval
1005 1307 $validIntervals = array_keys(Helper::getAvailableSubscriptionIntervalMaps());
1006 1308
1007 1309 if (!in_array($repeatInterval, $validIntervals)) {
@@ -1029,16 +1331,19 @@
1029 1331 if ($trialDays > 0 && $signupFee > 0) {
1030 1332 $result['signup_fee'] = max(0, $signupFee - $totalDiscount);
1031 1333 $result['manage_setup_fee'] = 'yes';
1032 1334
1033 - if (Arr::get($inputData, 'tax_behavior', 0) == 1 && $firstIterationTax) {
1335 + if ($isAdditiveTax && $firstIterationTax) {
1034 1336 $result['signup_fee'] += $firstIterationTax;
1035 1337 }
1036 1338 } else {
1037 1339 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1038 1340
1039 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1040 - $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;
1041 1346 }
1042 1347
1043 1348 if ($firstCycleCost < $recurringAmount) {
1044 1349 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
@@ -1046,9 +1351,11 @@
1046 1351 $result['trial_days'] = $adjustedTrialDays;
1047 1352 $result['is_trial_days_simulated'] = 'yes';
1048 1353 $result['signup_fee'] = $firstCycleCost;
1049 1354 $result['manage_setup_fee'] = 'yes';
1050 - $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.
1051 1358 } else if ($firstCycleCost > $recurringAmount) {
1052 1359 $result['trial_days'] = 0;
1053 1360 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1054 1361 $result['manage_setup_fee'] = 'yes';
@@ -1058,9 +1365,9 @@
1058 1365 $result['manage_setup_fee'] = 'no';
1059 1366 }
1060 1367
1061 1368 // only the signup fee is adjustable on our system, so we can adjust that to our needs
1062 - if (Arr::get($inputData, 'tax_behavior', 0) == 1 && $firstIterationTax) {
1369 + if ($isAdditiveTax && $firstIterationTax) {
1063 1370 if ($result['trial_days'] > 0) {
1064 1371 $result['signup_fee'] += $firstIterationTax;
1065 1372 } else {
1066 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
@@ -1070,9 +1377,9 @@
1070 1377
1071 1378 } else {
1072 1379 $result['signup_fee'] = $signupFee;
1073 1380
1074 - if (Arr::get($inputData, 'tax_behavior', 0) == 1) {
1381 + if ($isAdditiveTax) {
1075 1382 if ($signupFeeTax) {
1076 1383 $result['signup_fee'] += $signupFeeTax;
1077 1384 }
1078 1385 }
@@ -1090,6 +1397,57 @@
1090 1397 'recurring_amount' => $recurringAmount,
1091 1398 'recurring_tax_total' => $recurringTax,
1092 1399 'signup_fee' => $result['signup_fee'] ?? 0,
1093 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 + }
1094 1452 }
1095 1453 }