PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.8.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.8.0
1.8.0 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 All 52 releases
← All changes | app/Helpers/CheckoutProcessor.php +424 -29 1.3.28 → 1.8.0 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', '');
@@ -579,8 +704,23 @@
579 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,15 +964,32 @@
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
976 + // A shipping pricing rule discounts this order's postage only; renewals bill the full charge.
977 + $shippingDiscount = $isPhysicalProduct ? (int)Arr::get($this->args, 'shipping_discount', 0) : 0;
978 + if ($shippingDiscount > 0) {
979 + $renewalTotal = $recurringTotal + $shippingDiscount;
980 + // A fully discounted charge left its shipping tax out above as well.
981 + if ($shippingCharge <= 0) {
982 + $storeTaxBehavior = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
983 + if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1)) {
984 + $renewalTotal += (int)Arr::get($this->args, 'shipping_tax', 0);
985 + }
986 + }
987 +
988 + $subscriptionPricing = $this->billRenewalsFullShipping($subscriptionPricing, $recurringTotal, $renewalTotal, $shippingDiscount);
989 + $recurringTotal = $renewalTotal;
990 + }
991 +
808 992 // removable upon discussion
809 993 $subscriptionItem = [
810 994 'product_id' => $item['post_id'],
811 995 'current_payment_method' => Arr::get($this->orderData, 'payment_method'),
@@ -818,20 +1002,97 @@
818 1002 'variation_id' => Arr::get($item, 'object_id', 0),
819 1003 'status' => Status::SUBSCRIPTION_PENDING,
820 1004 'config' => [
821 1005 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
822 - 'currency' => $this->orderData['currency']
1006 + 'currency' => $this->orderData['currency'],
1007 + // Snapshot the variant attribute map + variation type from the order
1008 + // item so the subscription carries the same pa_* set behind its item_name.
1009 + 'item_attributes' => Arr::get($item, 'other_info.item_attributes', []),
1010 + 'variation_type' => Arr::get($item, 'other_info.variation_type', '')
823 1011 ]
824 1012 ];
825 1013
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;
1014 + $subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
1015 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1016 +
1017 + $collectionMethod = apply_filters('fluent_cart/subscription_collection_method_' . $paymentMethod, $this->determineCollectionMethod());
1018 +
1019 + // A filter can hand back anything, but `system` only means something on a
1020 + // gateway that can charge a saved payment method.
1021 + $subscriptionData['collection_method'] = SubscriptionManagementMode::sanitizeCollectionMethod(
1022 + $collectionMethod,
1023 + GatewayManager::getInstance()->get($paymentMethod)
1024 + );
1025 +
1026 + // Stamp store-managed origin durably on the subscription. Gateways consult
1027 + // the stamp (not the current store setting) before converting a manual
1028 + // subscription to automatic, so switching the mode back to gateway-managed
1029 + // later never flips subscriptions born under store-managed.
1030 + if (in_array($subscriptionData['collection_method'], ['manual', 'system'], true) && SubscriptionManagementMode::isStoreManaged()) {
1031 + $subscriptionConfig = Arr::get($subscriptionData, 'config', []);
1032 + $subscriptionConfig[SubscriptionManagementMode::CONFIG_KEY] = SubscriptionManagementMode::STORE_MANAGED;
1033 + $subscriptionData['config'] = $subscriptionConfig;
829 1034 }
830 1035
831 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
1036 + $this->subscriptionData = $subscriptionData;
832 1037 }
833 1038
1039 + /**
1040 + * Raise the per-cycle price to the full postage while keeping the first payment.
1041 + *
1042 + * A gateway takes the signup fee alone during a trial, otherwise one cycle plus the
1043 + * signup fee. So without a trial the fee absorbs the difference, or the first cycle
1044 + * becomes a simulated trial, the same way a first-cycle coupon is expressed.
1045 + *
1046 + * @param array $pricing From convertToSubscriptionFormat(), built on the discounted postage.
1047 + * @param int $firstCycleTotal Recurring total on the discounted postage, in cents.
1048 + * @param int $renewalTotal Recurring total on the full postage, in cents.
1049 + * @param int $shippingDiscount In cents.
1050 + * @return array
1051 + */
1052 + private function billRenewalsFullShipping(array $pricing, $firstCycleTotal, $renewalTotal, $shippingDiscount)
1053 + {
1054 + $pricing['recurring_amount'] = (int)$pricing['recurring_amount'] + $shippingDiscount;
1055 +
1056 + if ((int)$pricing['trial_days'] > 0) {
1057 + return $pricing;
1058 + }
1059 +
1060 + $extra = $renewalTotal - $firstCycleTotal;
1061 + $signupFee = (int)$pricing['signup_fee'];
1062 +
1063 + if ($signupFee >= $extra) {
1064 + $pricing['signup_fee'] = $signupFee - $extra;
1065 + return $pricing;
1066 + }
1067 +
1068 + $pricing['trial_days'] = Helper::calculateAdjustedTrialDaysForInterval(0, $pricing['billing_interval']);
1069 + $pricing['is_trial_days_simulated'] = 'yes';
1070 + $pricing['signup_fee'] = $firstCycleTotal + $signupFee;
1071 +
1072 + return $pricing;
1073 + }
1074 +
1075 + private function determineCollectionMethod(): string
1076 + {
1077 + if (SubscriptionManagementMode::isStoreManaged()) {
1078 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1079 +
1080 + return SubscriptionManagementMode::resolveCollectionMethodFor(
1081 + GatewayManager::getInstance()->get($paymentMethod)
1082 + );
1083 + }
1084 +
1085 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1086 + $gateway = GatewayManager::getInstance()->get($paymentMethod);
1087 +
1088 + if ($gateway && $gateway->has('subscriptions')) {
1089 + return 'automatic';
1090 + }
1091 +
1092 + return 'manual';
1093 + }
1094 +
834 1095 private function prepareOrderData()
835 1096 {
836 1097 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
837 1098 return $item['fulfillment_type'] === 'physical';
@@ -851,8 +1112,24 @@
851 1112 }
852 1113 return $carry + $item['subtotal'];
853 1114 }, 0);
854 1115
1116 + $taxBehavior = (int) Arr::get($this->args, 'tax_behavior', 0);
1117 + $storeTaxBehavior = (int) Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
1118 + $exclusiveTaxTotal = (int) Arr::get($this->args, 'exclusive_tax_total', 0);
1119 + $feeTax = (int) Arr::get($this->args, 'fee_tax', 0);
1120 +
1121 + // Roll fee tax into fee_total for exclusive scenarios — gateways use fee_total as source of truth.
1122 + if ($feeTax && ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1))) {
1123 + $this->feeTotal += $feeTax;
1124 + }
1125 +
1126 + $this->prorateCreditTotal = (int) Arr::get($this->args, 'prorate_credit', 0);
1127 + // Upgrade-path discount is a post-tax adjustment like the prorate credit: it does
1128 + // not reduce the taxable base (tax args were computed on the full price), it only
1129 + // reduces the payable total via manual_discount_total below.
1130 + $this->upgradeDiscountTotal = (int) Arr::get($this->args, 'upgrade_discount', 0);
1131 +
855 1132 $orderData = [
856 1133 'status' => Status::ORDER_ON_HOLD,
857 1134 'fulfillment_type' => $hasPhysical ? Status::FULFILLMENT_TYPE_PHYSICAL : Status::FULFILLMENT_TYPE_DIGITAL,
858 1135 'type' => $hasSubscription ? Status::ORDER_TYPE_SUBSCRIPTION : Status::ORDER_TYPE_PAYMENT, // revisit this on manual renewal
@@ -864,15 +1141,15 @@
864 1141 'payment_method_title' => '',
865 1142 'currency' => $this->storeSettings->get('currency'),
866 1143 'subtotal' => $itemsSubtotal,
867 1144 'discount_tax' => 0,
868 - 'manual_discount_total' => $this->manualDiscountTotal,
1145 + 'manual_discount_total' => $this->manualDiscountTotal + $this->prorateCreditTotal + $this->upgradeDiscountTotal,
869 1146 'coupon_discount_total' => $this->couponDiscountTotal,
870 1147 'shipping_tax' => Arr::get($this->args, 'shipping_tax', 0),
871 1148 'shipping_total' => Arr::get($this->args, 'shipping_charge', 0),
872 1149 'fee_total' => $this->feeTotal,
873 1150 'tax_total' => Arr::get($this->args, 'tax_total', 0),
874 - 'tax_behavior' => Arr::get($this->args, 'tax_behavior', 0),
1151 + 'tax_behavior' => $taxBehavior,
875 1152 // 'total_amount' => $this->orderTotals['total_amount'],
876 1153 'total_paid' => 0,
877 1154 'total_refund' => 0,
878 1155 'rate' => 1,
@@ -879,18 +1156,74 @@
879 1156 'note' => Arr::get($this->args, 'note', ''),
880 1157 'ip_address' => Arr::get($this->args, 'ip_address', ''),
881 1158 'config' => [
882 1159 'user_tz' => Arr::get($this->args, 'user_tz', ''),
883 - 'create_account_after_paid' => Arr::get($this->args, 'create_account_after_paid', 'no')
1160 + 'create_account_after_paid' => Arr::get($this->args, 'create_account_after_paid', 'no'),
1161 + 'shipping_method_id' => Arr::get($this->args, 'shipping_method_id', 0),
1162 + 'shipping_method_title' => Arr::get($this->args, 'shipping_method_title', ''),
1163 + 'shipping_discount' => (int) Arr::get($this->args, 'shipping_discount', 0),
1164 + 'prorate_credit' => $this->prorateCreditTotal,
1165 + 'upgrade_discount' => $this->upgradeDiscountTotal,
884 1166 ],
885 1167 ];
886 1168
887 - $estimatedTaxTotal = $orderData['tax_behavior'] === 1 ? $orderData['tax_total'] : 0;
888 - $estimatedShippingTax = $orderData['tax_behavior'] === 1 ? $orderData['shipping_tax'] : 0;
1169 + if ($taxBehavior === 1) {
1170 + // Pure exclusive: fee_tax is already in fee_total; exclude it here to avoid double-count.
1171 + $estimatedTaxTotal = $orderData['tax_total'] - $feeTax;
1172 + $estimatedShippingTax = $orderData['shipping_tax'];
1173 + } elseif ($taxBehavior === 3) {
1174 + // Mixed: fee_tax rolled into fee_total for exclusive store; shipping still additive.
1175 + $estimatedTaxTotal = $exclusiveTaxTotal;
1176 + $estimatedShippingTax = ($storeTaxBehavior === 1) ? $orderData['shipping_tax'] : 0;
1177 + } else {
1178 + // Inclusive (2) or reverse-charge (0): nothing to add to total; tax is in item prices.
1179 + $estimatedTaxTotal = 0;
1180 + $estimatedShippingTax = 0;
1181 + if ($taxBehavior !== 2) {
1182 + // Reverse-charge (0): zero stored columns — no tax applies.
1183 + // Inclusive (2): keep orderData values so reporting surfaces can read them.
1184 + $orderData['tax_total'] = 0;
1185 + $orderData['shipping_tax'] = 0;
1186 + }
1187 + }
889 1188
890 - $totalAmount = $orderData['subtotal'] - $orderData['coupon_discount_total'] - $orderData['manual_discount_total'] + $orderData['fee_total'] + $orderData['shipping_total'] + $estimatedTaxTotal + $estimatedShippingTax;
1189 + $totalAmount = $orderData['subtotal']
1190 + - $orderData['coupon_discount_total']
1191 + - $orderData['manual_discount_total']
1192 + + $orderData['fee_total']
1193 + + $orderData['shipping_total']
1194 + + $estimatedTaxTotal
1195 + + $estimatedShippingTax;
891 1196
892 1197 $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0;
1198 +
1199 + /**
1200 + * Filter the prepared order data before it is used for order creation.
1201 + *
1202 + * This runs after FluentCart calculates totals, so plugins can adjust
1203 + * currency, rate, totals, config, mode, or any other order field before
1204 + * the order model, transaction, and subscription are derived from it.
1205 + *
1206 + * @param array $orderData Prepared order data array.
1207 + * @param array $context {
1208 + * Additional context for the filter.
1209 + *
1210 + * @type array $items Formatted order items with prices and quantities.
1211 + * @type array $args Checkout arguments: customer data, payment method,
1212 + * shipping, tax, coupons, fees, and IP data.
1213 + * }
1214 + */
1215 + $orderData = apply_filters('fluent_cart/checkout/order_data', $orderData, [
1216 + 'items' => $this->formattedIOrderItems,
1217 + 'args' => $this->args,
1218 + ]);
1219 +
1220 + if (!$this->isSafeAmount(Arr::get($orderData, 'total_amount', 0))) {
1221 + $this->validationError = $this->unsafeAmountError();
1222 +
1223 + return;
1224 + }
1225 +
893 1226 $this->orderData = $orderData;
894 1227 }
895 1228
896 1229 private function syncFeeItems()
@@ -1015,9 +1348,15 @@
1015 1348 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1016 1349 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1017 1350 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1018 1351 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1352 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1019 1353
1354 + // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1355 + $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1356 + $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
1357 + $isAdditiveTax = ($taxBehavior === 1) || ($taxBehavior === 3 && !$itemInclusive);
1358 +
1020 1359 // Validate repeat_interval
1021 1360 $validIntervals = array_keys(Helper::getAvailableSubscriptionIntervalMaps());
1022 1361
1023 1362 if (!in_array($repeatInterval, $validIntervals)) {
@@ -1045,16 +1384,19 @@
1045 1384 if ($trialDays > 0 && $signupFee > 0) {
1046 1385 $result['signup_fee'] = max(0, $signupFee - $totalDiscount);
1047 1386 $result['manage_setup_fee'] = 'yes';
1048 1387
1049 - if (Arr::get($inputData, 'tax_behavior', 0) == 1 && $firstIterationTax) {
1388 + if ($isAdditiveTax && $firstIterationTax) {
1050 1389 $result['signup_fee'] += $firstIterationTax;
1051 1390 }
1052 1391 } else {
1053 1392 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1054 1393
1055 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1056 - $recurringAmount -= $totalDiscount; // as now discount applied on recurring amount
1394 + // A recurring coupon discounts every cycle, so the per-cycle price itself
1395 + // is lower — the first cycle is not cheaper than the ones after it and
1396 + // must not be expressed as a trial.
1397 + if ($recurringDiscount > 0) {
1398 + $recurringAmount -= $recurringDiscount;
1057 1399 }
1058 1400
1059 1401 if ($firstCycleCost < $recurringAmount) {
1060 1402 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
@@ -1062,9 +1404,11 @@
1062 1404 $result['trial_days'] = $adjustedTrialDays;
1063 1405 $result['is_trial_days_simulated'] = 'yes';
1064 1406 $result['signup_fee'] = $firstCycleCost;
1065 1407 $result['manage_setup_fee'] = 'yes';
1066 - $result['times'] = $times > 0 ? $times - 1 : 0;
1408 + // bill_times stays the full installment count. The simulated trial cycle IS the
1409 + // first installment (charged as one-time payment / free when 100% discounted);
1410 + // gateways derive the remaining remote cycles from is_trial_days_simulated.
1067 1411 } else if ($firstCycleCost > $recurringAmount) {
1068 1412 $result['trial_days'] = 0;
1069 1413 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1070 1414 $result['manage_setup_fee'] = 'yes';
@@ -1074,9 +1418,9 @@
1074 1418 $result['manage_setup_fee'] = 'no';
1075 1419 }
1076 1420
1077 1421 // 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) {
1422 + if ($isAdditiveTax && $firstIterationTax) {
1079 1423 if ($result['trial_days'] > 0) {
1080 1424 $result['signup_fee'] += $firstIterationTax;
1081 1425 } else {
1082 1426 $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 +1430,9 @@
1086 1430
1087 1431 } else {
1088 1432 $result['signup_fee'] = $signupFee;
1089 1433
1090 - if (Arr::get($inputData, 'tax_behavior', 0) == 1) {
1434 + if ($isAdditiveTax) {
1091 1435 if ($signupFeeTax) {
1092 1436 $result['signup_fee'] += $signupFeeTax;
1093 1437 }
1094 1438 }
@@ -1106,6 +1450,57 @@
1106 1450 'recurring_amount' => $recurringAmount,
1107 1451 'recurring_tax_total' => $recurringTax,
1108 1452 'signup_fee' => $result['signup_fee'] ?? 0,
1109 1453 ];
1454 + }
1455 +
1456 + /**
1457 + * bill_count is derived from counting total > 0 CHARGE transactions linked to
1458 + * the subscription (see syncSubscriptionStates / getRequiredBillTimes), which
1459 + * can't tell "this was a billed cycle" from "this was something else
1460 + * charged alongside it." Two corrections needed only at initial checkout —
1461 + * is_trial_days_simulated alone can't be used at runtime because
1462 + * payment-method switching also sets that flag:
1463 + *
1464 + * - Simulated trial, $0 first cycle: consumes a cycle but produces no
1465 + * total > 0 transaction — add billed_cycles_offset so it still counts.
1466 + * - Real trial with a signup fee: the initial charge is the signup fee only
1467 + * (the recurring item isn't billed yet), but it IS a total > 0 transaction
1468 + * linked to the subscription — mark billed_cycles_deduction so it does
1469 + * NOT count as a cycle.
1470 + */
1471 + private function syncInitialCycleCounting()
1472 + {
1473 + if (!$this->subscriptionModel) {
1474 + return;
1475 + }
1476 +
1477 + $isSimulated = Arr::get($this->subscriptionData, 'config.is_trial_days_simulated', 'no') === 'yes';
1478 + $trialDays = (int)Arr::get($this->subscriptionData, 'trial_days', 0);
1479 + $billTimes = (int)$this->subscriptionModel->bill_times;
1480 + $orderTotal = (int)$this->orderModel->total_amount;
1481 +
1482 + // signup_fee <= 0 (not just == 0): prorate/upgrade credit can push the
1483 + // first cycle cost negative — still a free first cycle for counting
1484 + $isFreeFirstCycle = $isSimulated
1485 + && $billTimes > 0
1486 + && (int)$this->subscriptionModel->signup_fee <= 0
1487 + && !$orderTotal;
1488 +
1489 + if ($isFreeFirstCycle) {
1490 + $this->subscriptionModel->updateMeta('billed_cycles_offset', 1);
1491 + } else {
1492 + $this->subscriptionModel->deleteMeta('billed_cycles_offset');
1493 + }
1494 +
1495 + $isRealTrialWithCharge = !$isSimulated
1496 + && $trialDays > 0
1497 + && $billTimes > 0
1498 + && $orderTotal > 0;
1499 +
1500 + if ($isRealTrialWithCharge) {
1501 + $this->subscriptionModel->updateMeta('billed_cycles_deduction', 1);
1502 + } else {
1503 + $this->subscriptionModel->deleteMeta('billed_cycles_deduction');
1504 + }
1110 1505 }
1111 1506 }