PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 1.3.20 1.3.19 All 49 releases
← All changes | app/Helpers/CheckoutProcessor.php +334 -35 1.3.23 → 1.6.5 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
@@ -44,8 +46,12 @@
44 46 private $couponDiscountTotal = 0;
45 47
46 48 private $manualDiscountTotal = 0;
47 49
50 + private $prorateCreditTotal = 0;
51 +
52 + private $upgradeDiscountTotal = 0;
53 +
48 54 public function __construct($cartItems = [], $args = [])
49 55 {
50 56 $this->storeSettings = new StoreSettings();
51 57 $this->cartItems = $cartItems;
@@ -96,8 +102,11 @@
96 102 if (Arr::get($this->args, 'tax_id', 0)) {
97 103 $this->orderModel->updateMeta('tax_id', Arr::get($this->args, 'tax_id', 0));
98 104 }
99 105
106 + // Store tax meta for mixed carts (tax_behavior=3) - used by payment gateways and renewals
107 + $this->persistTaxMeta();
108 +
100 109 // Let's create the order items
101 110 $normalOrderItems = array_filter($this->formattedIOrderItems, function ($item) {
102 111 return $item['payment_type'] != 'signup_fee';
103 112 });
@@ -117,8 +126,14 @@
117 126 $bundleItems = Arr::get($orderItem, 'bundle_items', []);
118 127 unset($orderItem['bundle_items']);
119 128 }
120 129
130 + if (!empty($orderItem['coupon_discount'])) {
131 + $lineMeta = Arr::get($orderItem, 'line_meta', []);
132 + $lineMeta['coupon_discount'] = (int) $orderItem['coupon_discount'];
133 + $orderItem['line_meta'] = $lineMeta;
134 + }
135 +
121 136 $createdItem = OrderItem::query()->create($orderItem);
122 137
123 138 if ($additionalItems) {
124 139 $additionalItemIds = [];
@@ -126,8 +141,11 @@
126 141 $additionalItem['order_id'] = $this->orderModel->id;
127 142 $additionalItem['line_total'] = Arr::get($additionalItem, 'subtotal', 0) - Arr::get($additionalItem, 'discount_total', 0);
128 143 $mata = Arr::get($additionalItem, 'line_meta', []);
129 144 $mata['parent_item_id'] = $createdItem->id;
145 + if (!empty($additionalItem['coupon_discount'])) {
146 + $mata['coupon_discount'] = (int) $additionalItem['coupon_discount'];
147 + }
130 148 $additionalItem['line_meta'] = $mata;
131 149 $childItem = OrderItem::query()->create($additionalItem);
132 150 $additionalItemIds[] = $childItem->id;
133 151 }
@@ -149,8 +167,11 @@
149 167 $bundleItem['line_total'] = Arr::get($bundleItem, 'subtotal', 0) - Arr::get($bundleItem, 'discount_total', 0);
150 168 $bundleItem['payment_type'] = 'bundle';
151 169 $meta = Arr::get($bundleItem, 'line_meta', []);
152 170 $meta['bundle_parent_item_id'] = $createdItem->id;
171 + if (!empty($bundleItem['coupon_discount'])) {
172 + $meta['coupon_discount'] = (int) $bundleItem['coupon_discount'];
173 + }
153 174 $bundleItem['line_meta'] = $meta;
154 175 $bundleItem = OrderItem::query()->create($bundleItem);
155 176 $bundleItemIds[] = $bundleItem->id;
156 177 }
@@ -180,8 +201,9 @@
180 201 $subscriptionData['customer_id'] = $customerId;
181 202 $subscriptionData['parent_order_id'] = $this->orderModel->id;
182 203
183 204 $this->subscriptionModel = Subscription::query()->create($subscriptionData);
205 + $this->syncInitialCycleCounting();
184 206 }
185 207
186 208 // Let's create the transaction
187 209 $transactionData = [
@@ -224,8 +246,18 @@
224 246 $cart->user_id = $customer->user_id;
225 247 }
226 248
227 249 $cart->save();
250 +
251 + // Carry the traffic source onto the order while the cart still exists.
252 + // Carts are pruned on a schedule, so this is the last reliable point at
253 + // which the click that produced the sale can still be recovered.
254 + UtmHelper::addUtmToOrder(
255 + $this->orderModel->id,
256 + UtmHelper::resolveUtmData(UtmHelper::getUtmDataOfRequest(), $cart->utm_data),
257 + $cart->cart_hash
258 + );
259 +
228 260 $actions = Arr::get($cart->checkout_data, '__after_draft_created_actions__', []);
229 261 if ($actions) {
230 262 foreach ($actions as $actionName) {
231 263 $actionName = (string)$actionName;
@@ -286,8 +318,15 @@
286 318
287 319 if ($isLocked && $taxEnabled !== 'yes') {
288 320 // Locked orders skip full item sync, but fee items must stay in sync with fee_total
289 321 $this->syncFeeItems();
322 +
323 + // Load existing subscription so the transaction gets the correct subscription_id
324 + if ($this->orderModel->type === Status::ORDER_TYPE_SUBSCRIPTION) {
325 + $this->subscriptionModel = Subscription::query()
326 + ->where('parent_order_id', $this->orderModel->id)
327 + ->first();
328 + }
290 329 }
291 330
292 331 if (!$isLocked || $taxEnabled === 'yes') {
293 332 // Let's create the order items
@@ -377,9 +416,9 @@
377 416 )
378 417 ])->save();
379 418 }
380 419 }
381 - if ($taxTotal && !$this->orderModel->tax_total) {
420 + if ($taxTotal && !$this->orderModel->tax_total && !$isLocked) {
382 421 $this->orderModel->tax_total = $taxTotal;
383 422 $this->orderModel->total_amount += $taxTotal;
384 423 $this->orderModel->save();
385 424 }
@@ -404,8 +443,9 @@
404 443 $this->subscriptionModel = $existingSubscription;
405 444 } else {
406 445 $this->subscriptionModel = Subscription::query()->create($subscriptionData);
407 446 }
447 + $this->syncInitialCycleCounting();
408 448 }
409 449 } else {
410 450 Subscription::query()->where('parent_order_id', $this->orderModel->id)->delete();
411 451 }
@@ -413,8 +453,10 @@
413 453
414 454 // Reload the order to get fresh item data after updates
415 455 $this->orderModel = $this->orderModel->fresh();
416 456
457 + $this->persistTaxMeta();
458 +
417 459 // Let's create the transaction
418 460 $transactionData = [
419 461 'order_id' => $this->orderModel->id,
420 462 'order_type' => $this->orderModel->type,
@@ -444,8 +486,26 @@
444 486 ->where('order_id', $this->orderModel->id)
445 487 ->first();
446 488
447 489 if ($existingTransaction) {
490 + $meta = $existingTransaction->meta ?: [];
491 +
492 + // Retry vs duplicate for gateway idempotency (PaymentInstance::getIdempotencySeed):
493 + // re-submitting a pending transaction is a duplicate (keep attempt -> gateway
494 + // dedupes); re-submitting a FAILED one is a retry (bump attempt -> fresh seed,
495 + // never answered with the failed attempt's cached gateway response).
496 + $attempt = (int) Arr::get($meta, 'payment_attempt', 0);
497 + if ($existingTransaction->status === Status::PAYMENT_FAILED) {
498 + $attempt++;
499 + }
500 +
501 + // The gateway object prepared last time (a Paddle transaction, a PayPal
502 + // order) is kept so the gateway can reuse it instead of creating another.
503 + if ($attempt) {
504 + $meta['payment_attempt'] = $attempt;
505 + }
506 + $transactionData['meta'] = $meta;
507 +
448 508 $existingTransaction->fill($transactionData);
449 509 $existingTransaction->save();
450 510 $this->transactionModel = $existingTransaction;
451 511 } else {
@@ -474,8 +534,26 @@
474 534 // We are almost done!
475 535 return $this->orderModel;
476 536 }
477 537
538 + private function persistTaxMeta()
539 + {
540 + $exclusiveTaxTotal = (int) Arr::get($this->args, 'exclusive_tax_total', 0);
541 + $storeTaxBehavior = (int) Arr::get($this->args, 'store_tax_behavior', 0);
542 + $feeTax = (int) Arr::get($this->args, 'fee_tax', 0);
543 + $feeTaxLines = (array) Arr::get($this->args, 'fee_tax_lines', []);
544 +
545 + $this->orderModel->updateMeta('exclusive_tax_total', $exclusiveTaxTotal);
546 + $this->orderModel->updateMeta('store_tax_behavior', $storeTaxBehavior);
547 + $this->orderModel->updateMeta('fee_tax', $feeTax);
548 +
549 + if (!empty($feeTaxLines)) {
550 + $this->orderModel->updateMeta('fee_tax_lines', $feeTaxLines);
551 + } else {
552 + $this->orderModel->deleteMeta('fee_tax_lines');
553 + }
554 + }
555 +
478 556 private function insertAppliedCoupons($appliedCoupons, $removeOlds = false, $order = null): void
479 557 {
480 558 if ($removeOlds) {
481 559 $this->orderModel->appliedCoupons()->delete();
@@ -488,12 +566,22 @@
488 566 $customerId = $order->customer_id;
489 567 }
490 568
491 569 if (!empty($couponCodes)) {
492 - $coupons = Coupon::query()->whereIn('code', $couponCodes)->get()
493 - ->keyBy('code')
494 - ->toArray();
570 + $coupons = Coupon::query()->whereIn('code', $couponCodes)->get();
495 571
572 + /*
573 + * Resolve virtual (un-persisted) coupons so an AppliedCoupon row is written for
574 + * them too — the row stores coupon_id = null (the column is nullable) with the
575 + * code and computed discount, so it shows in the order's Coupons section like any
576 + * coupon. See DiscountService::applyCouponCodes() for the same filter.
577 + */
578 + $coupons = apply_filters('fluent_cart/coupon/resolve_coupons', $coupons, $couponCodes, [
579 + 'order' => $this->orderModel,
580 + ]);
581 +
582 + $coupons = $coupons->keyBy('code')->toArray();
583 +
496 584 foreach ($coupons as $code => &$coupon) {
497 585 $coupon['coupon_id'] = $appliedCoupons[$code]['id'];
498 586 $coupon['amount'] = $appliedCoupons[$code]['discount'];
499 587 $coupon['customer_id'] = $customerId;
@@ -534,9 +622,9 @@
534 622
535 623 $discountTotal = (int)Arr::get($cartItem, 'manual_discount', 0) + (int)Arr::get($cartItem, 'coupon_discount', 0);
536 624 $shippingCharge = (int)Arr::get($cartItem, 'shipping_charge', 0);
537 625
538 - $subtotal = $unitPrice * $quantity;
626 + $subtotal = (int) Arr::get($cartItem, 'subtotal', $unitPrice * $quantity);
539 627 $args = Arr::get($cartItem, 'other_info', []);
540 628 $paymentType = Arr::get($args, 'payment_type', 'default');
541 629
542 630 $postTitle = Arr::get($cartItem, 'product_title', '');
@@ -564,23 +652,38 @@
564 652 }
565 653 }
566 654
567 655 // Snapshot package dimensions into other_info for email/PDF rendering
568 - $packageSlug = Arr::get($args, 'package_slug', '');
569 - if ($packageSlug) {
656 + if (Arr::get($cartItem, 'fulfillment_type') === 'physical') {
657 + $packageSlug = Arr::get($args, 'package_slug', '');
570 658 $package = Helper::getPackageBySlug($packageSlug);
571 659 if ($package) {
572 - $args['package_name'] = Arr::get($package, 'name', '');
573 - $args['package_type'] = Arr::get($package, 'type', '');
660 + $args['package_name'] = Arr::get($package, 'name', '');
661 + $args['package_type'] = Arr::get($package, 'type', '');
574 662 $args['package_length'] = Arr::get($package, 'length', '');
575 663 $args['package_width'] = Arr::get($package, 'width', '');
576 664 $args['package_height'] = Arr::get($package, 'height', '');
577 665 $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');
666 + $args['package_weight'] = Arr::get($package, 'weight', 0);
667 + $args['package_weight_unit'] = Arr::get($package, 'weight_unit', 'kg');
580 668 }
581 669 }
582 670
671 + // Carry the attribute snapshot onto the order item. It normally
672 + // arrives via the cart item's other_info; rebuild it here as a
673 + // fallback for items that reach checkout without one (instant
674 + // checkout, legacy carts).
675 + if (!isset($args['item_attributes'])) {
676 + $args['item_attributes'] = AttributeHelper::getProductItemAttributes(
677 + Arr::get($cartItem, 'object_id', 0),
678 + Arr::get($cartItem, 'post_id', 0)
679 + );
680 + }
681 +
682 + if (!isset($args['variation_type'])) {
683 + $args['variation_type'] = (string) Arr::get($cartItem, 'variation_type', '');
684 + }
685 +
583 686 $item = [
584 687 'payment_type' => $paymentType,
585 688 'post_id' => Arr::get($cartItem, 'post_id'),
586 689 'object_id' => Arr::get($cartItem, 'object_id'),
@@ -593,8 +696,9 @@
593 696 'subtotal' => $subtotal,
594 697 'tax_amount' => (int)Arr::get($cartItem, 'tax_amount', 0),
595 698 'shipping_charge' => $shippingCharge,
596 699 'discount_total' => $discountTotal,
700 + 'coupon_discount' => (int)Arr::get($cartItem, 'coupon_discount', 0),
597 701 'other_info' => $args,
598 702 'line_meta' => Arr::get($cartItem, 'line_meta', []),
599 703 ];
600 704
@@ -608,10 +712,16 @@
608 712 $signupFeeAmount = (int)Arr::get($cartItem, 'other_info.signup_fee', 0);
609 713
610 714 $signupFeeTax = (int)Arr::get($cartItem, 'other_info.signup_fee_tax', 0);
611 715
716 + // Nest under tax_config — the same shape regular items and the admin
717 + // order path use. Readers keep a fallback for the legacy flat shape.
718 + $signupFeeTaxConfig = Arr::get($cartItem, 'signup_fee_tax_config', []);
719 +
612 720 $childDiscountTotal = 0;
721 + $childCouponDiscount = 0;
613 722 $signupFeeSubtotal = $signupFeeAmount * $quantity;
723 + $couponDiscount = $item['coupon_discount'];
614 724
615 725 $hasTrialDays = Arr::get($cartItem, 'other_info.trial_days', 0) > 0;
616 726
617 727 if ($discountTotal && !$hasTrialDays) {
@@ -616,11 +726,15 @@
616 726
617 727 if ($discountTotal && !$hasTrialDays) {
618 728 $childDiscountTotal = (float)($discountTotal / ($subtotal + $signupFeeSubtotal) * $signupFeeSubtotal);
619 729 $discountTotal -= $childDiscountTotal;
730 + $childCouponDiscount = (int) round($couponDiscount * $signupFeeSubtotal / ($subtotal + $signupFeeSubtotal));
731 + $couponDiscount -= $childCouponDiscount;
620 732 } elseif ($discountTotal && $hasTrialDays) { // if trial days , then discount should be applied on signup fee only
621 733 $childDiscountTotal = min($discountTotal, $signupFeeSubtotal);
622 734 $discountTotal = 0;
735 + $childCouponDiscount = min($couponDiscount, $signupFeeSubtotal);
736 + $couponDiscount = 0;
623 737 }
624 738
625 739 $childItem = [
626 740 'payment_type' => 'signup_fee',
@@ -635,12 +749,14 @@
635 749 'subtotal' => $signupFeeSubtotal,
636 750 'tax_amount' => $signupFeeTax,
637 751 'shipping_charge' => 0,
638 752 'discount_total' => $childDiscountTotal,
639 - 'line_meta' => Arr::get($cartItem, 'signup_fee_tax_config', []),
753 + 'coupon_discount' => $childCouponDiscount,
754 + 'line_meta' => $signupFeeTaxConfig ? ['tax_config' => $signupFeeTaxConfig] : [],
640 755 ];
641 756
642 757 $item['discount_total'] = $discountTotal;
758 + $item['coupon_discount'] = $couponDiscount;
643 759 $item['additional_items'] = [$childItem];
644 760
645 761 Arr::set($item, 'other_info.signup_fee', $signupFeeAmount);
646 762 Arr::set($item, 'other_info.signup_discount', $childDiscountTotal);
@@ -754,9 +870,9 @@
754 870
755 871 $item = reset($subscriptionItems);
756 872 $signupFeeItem = reset($signupFeeItems) ?? [];
757 873 $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0);
758 - $taxBehavior = Arr::get($this->args, 'tax_behavior', 0);
874 + $taxBehavior = (int)Arr::get($this->args, 'tax_behavior', 0);
759 875
760 876 $recurringTotal = (int)$item['subtotal'];
761 877 $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0);
762 878
@@ -765,16 +881,24 @@
765 881 if ($recurringDiscountAmount && $recurringDiscountAmount > 0) {
766 882 $recurringTotal -= $recurringDiscountAmount;
767 883 }
768 884
769 - // Add shipping charges to recurring total for physical subscription products
885 + // Add shipping charges (and tax) to recurring total for physical subscription products
770 886 $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0);
771 887 $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical';
772 888 if ($isPhysicalProduct && $shippingCharge > 0) {
773 889 $recurringTotal += $shippingCharge;
890 + $shippingTax = (int)Arr::get($this->args, 'shipping_tax', 0);
891 + if ($shippingTax > 0) {
892 + $storeTaxBehavior = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
893 + if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1)) {
894 + $recurringTotal += $shippingTax;
895 + }
896 + }
774 897 }
775 898
776 - if ($taxBehavior === 1) {
899 + $itemInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
900 + if ($taxBehavior === 1 || ($taxBehavior === 3 && !$itemInclusive)) {
777 901 $recurringTotal += $recurringTax;
778 902 }
779 903
780 904 $signupFee = (int)Arr::get($signupFeeItem, 'subtotal', 0);
@@ -781,16 +905,22 @@
781 905
782 906 // in case of discount applied 'tax_amount' is different than recurring tax ,
783 907 $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax;
784 908
785 -
786 909 // Calculate recurring amount including shipping for physical products
787 910 $recurringAmount = (int)$item['subtotal'];
788 911 if ($isPhysicalProduct && $shippingCharge > 0) {
789 912 $recurringAmount += $shippingCharge;
913 + $shippingTaxForFirst = (int)Arr::get($this->args, 'shipping_tax', 0);
914 + if ($shippingTaxForFirst > 0) {
915 + $storeTaxBehaviorForFirst = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
916 + if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehaviorForFirst === 1)) {
917 + $firstIterationTax += $shippingTaxForFirst;
918 + }
919 + }
790 920 }
791 921
792 - $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0);
922 + $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0) + $this->prorateCreditTotal + $this->upgradeDiscountTotal;
793 923 $subscriptionPricing = $this->convertToSubscriptionFormat([
794 924 'initial_trial_days' => Arr::get($item, 'other_info.trial_days', 0),
795 925 'repeat_interval' => Arr::get($item, 'other_info.repeat_interval', 'monthly'),
796 926 'times' => Arr::get($item, 'other_info.times', 0),
@@ -797,12 +927,13 @@
797 927 'recurring_amount' => $recurringAmount,
798 928 'recurring_tax_total' => $recurringTax,
799 929 'recurring_total' => $recurringTotal,
800 930 'tax_behavior' => $taxBehavior,
931 + 'line_meta' => Arr::get($item, 'line_meta', []),
801 932 'signup_fee' => $signupFee,
802 933 'signup_fee_tax' => $signupFeeTax,
803 934 'first_iteration_tax' => $firstIterationTax,
804 - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
935 + 'recurring_discount' => $recurringDiscountAmount,
805 936 'total_discount' => $discountTotal
806 937 ]);
807 938
808 939 // removable upon discussion
@@ -818,20 +949,61 @@
818 949 'variation_id' => Arr::get($item, 'object_id', 0),
819 950 'status' => Status::SUBSCRIPTION_PENDING,
820 951 'config' => [
821 952 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
822 - 'currency' => $this->orderData['currency']
953 + 'currency' => $this->orderData['currency'],
954 + // Snapshot the variant attribute map + variation type from the order
955 + // item so the subscription carries the same pa_* set behind its item_name.
956 + 'item_attributes' => Arr::get($item, 'other_info.item_attributes', []),
957 + 'variation_type' => Arr::get($item, 'other_info.variation_type', '')
823 958 ]
824 959 ];
825 960
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;
961 + $subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
962 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
963 +
964 + $collectionMethod = apply_filters('fluent_cart/subscription_collection_method_' . $paymentMethod, $this->determineCollectionMethod());
965 +
966 + // A filter can hand back anything, but `system` only means something on a
967 + // gateway that can charge a saved payment method.
968 + $subscriptionData['collection_method'] = SubscriptionManagementMode::sanitizeCollectionMethod(
969 + $collectionMethod,
970 + GatewayManager::getInstance()->get($paymentMethod)
971 + );
972 +
973 + // Stamp store-managed origin durably on the subscription. Gateways consult
974 + // the stamp (not the current store setting) before converting a manual
975 + // subscription to automatic, so switching the mode back to gateway-managed
976 + // later never flips subscriptions born under store-managed.
977 + if (in_array($subscriptionData['collection_method'], ['manual', 'system'], true) && SubscriptionManagementMode::isStoreManaged()) {
978 + $subscriptionConfig = Arr::get($subscriptionData, 'config', []);
979 + $subscriptionConfig[SubscriptionManagementMode::CONFIG_KEY] = SubscriptionManagementMode::STORE_MANAGED;
980 + $subscriptionData['config'] = $subscriptionConfig;
829 981 }
830 982
831 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
983 + $this->subscriptionData = $subscriptionData;
832 984 }
833 985
986 + private function determineCollectionMethod(): string
987 + {
988 + if (SubscriptionManagementMode::isStoreManaged()) {
989 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
990 +
991 + return SubscriptionManagementMode::resolveCollectionMethodFor(
992 + GatewayManager::getInstance()->get($paymentMethod)
993 + );
994 + }
995 +
996 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
997 + $gateway = GatewayManager::getInstance()->get($paymentMethod);
998 +
999 + if ($gateway && $gateway->has('subscriptions')) {
1000 + return 'automatic';
1001 + }
1002 +
1003 + return 'manual';
1004 + }
1005 +
834 1006 private function prepareOrderData()
835 1007 {
836 1008 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
837 1009 return $item['fulfillment_type'] === 'physical';
@@ -851,8 +1023,24 @@
851 1023 }
852 1024 return $carry + $item['subtotal'];
853 1025 }, 0);
854 1026
1027 + $taxBehavior = (int) Arr::get($this->args, 'tax_behavior', 0);
1028 + $storeTaxBehavior = (int) Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
1029 + $exclusiveTaxTotal = (int) Arr::get($this->args, 'exclusive_tax_total', 0);
1030 + $feeTax = (int) Arr::get($this->args, 'fee_tax', 0);
1031 +
1032 + // Roll fee tax into fee_total for exclusive scenarios — gateways use fee_total as source of truth.
1033 + if ($feeTax && ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1))) {
1034 + $this->feeTotal += $feeTax;
1035 + }
1036 +
1037 + $this->prorateCreditTotal = (int) Arr::get($this->args, 'prorate_credit', 0);
1038 + // Upgrade-path discount is a post-tax adjustment like the prorate credit: it does
1039 + // not reduce the taxable base (tax args were computed on the full price), it only
1040 + // reduces the payable total via manual_discount_total below.
1041 + $this->upgradeDiscountTotal = (int) Arr::get($this->args, 'upgrade_discount', 0);
1042 +
855 1043 $orderData = [
856 1044 'status' => Status::ORDER_ON_HOLD,
857 1045 'fulfillment_type' => $hasPhysical ? Status::FULFILLMENT_TYPE_PHYSICAL : Status::FULFILLMENT_TYPE_DIGITAL,
858 1046 'type' => $hasSubscription ? Status::ORDER_TYPE_SUBSCRIPTION : Status::ORDER_TYPE_PAYMENT, // revisit this on manual renewal
@@ -864,15 +1052,15 @@
864 1052 'payment_method_title' => '',
865 1053 'currency' => $this->storeSettings->get('currency'),
866 1054 'subtotal' => $itemsSubtotal,
867 1055 'discount_tax' => 0,
868 - 'manual_discount_total' => $this->manualDiscountTotal,
1056 + 'manual_discount_total' => $this->manualDiscountTotal + $this->prorateCreditTotal + $this->upgradeDiscountTotal,
869 1057 'coupon_discount_total' => $this->couponDiscountTotal,
870 1058 'shipping_tax' => Arr::get($this->args, 'shipping_tax', 0),
871 1059 'shipping_total' => Arr::get($this->args, 'shipping_charge', 0),
872 1060 'fee_total' => $this->feeTotal,
873 1061 'tax_total' => Arr::get($this->args, 'tax_total', 0),
874 - 'tax_behavior' => Arr::get($this->args, 'tax_behavior', 0),
1062 + 'tax_behavior' => $taxBehavior,
875 1063 // 'total_amount' => $this->orderTotals['total_amount'],
876 1064 'total_paid' => 0,
877 1065 'total_refund' => 0,
878 1066 'rate' => 1,
@@ -879,18 +1067,67 @@
879 1067 'note' => Arr::get($this->args, 'note', ''),
880 1068 'ip_address' => Arr::get($this->args, 'ip_address', ''),
881 1069 'config' => [
882 1070 'user_tz' => Arr::get($this->args, 'user_tz', ''),
883 - 'create_account_after_paid' => Arr::get($this->args, 'create_account_after_paid', 'no')
1071 + 'create_account_after_paid' => Arr::get($this->args, 'create_account_after_paid', 'no'),
1072 + 'shipping_method_id' => Arr::get($this->args, 'shipping_method_id', 0),
1073 + 'shipping_method_title' => Arr::get($this->args, 'shipping_method_title', ''),
1074 + 'prorate_credit' => $this->prorateCreditTotal,
1075 + 'upgrade_discount' => $this->upgradeDiscountTotal,
884 1076 ],
885 1077 ];
886 1078
887 - $estimatedTaxTotal = $orderData['tax_behavior'] === 1 ? $orderData['tax_total'] : 0;
888 - $estimatedShippingTax = $orderData['tax_behavior'] === 1 ? $orderData['shipping_tax'] : 0;
1079 + if ($taxBehavior === 1) {
1080 + // Pure exclusive: fee_tax is already in fee_total; exclude it here to avoid double-count.
1081 + $estimatedTaxTotal = $orderData['tax_total'] - $feeTax;
1082 + $estimatedShippingTax = $orderData['shipping_tax'];
1083 + } elseif ($taxBehavior === 3) {
1084 + // Mixed: fee_tax rolled into fee_total for exclusive store; shipping still additive.
1085 + $estimatedTaxTotal = $exclusiveTaxTotal;
1086 + $estimatedShippingTax = ($storeTaxBehavior === 1) ? $orderData['shipping_tax'] : 0;
1087 + } else {
1088 + // Inclusive (2) or reverse-charge (0): nothing to add to total; tax is in item prices.
1089 + $estimatedTaxTotal = 0;
1090 + $estimatedShippingTax = 0;
1091 + if ($taxBehavior !== 2) {
1092 + // Reverse-charge (0): zero stored columns — no tax applies.
1093 + // Inclusive (2): keep orderData values so reporting surfaces can read them.
1094 + $orderData['tax_total'] = 0;
1095 + $orderData['shipping_tax'] = 0;
1096 + }
1097 + }
889 1098
890 - $totalAmount = $orderData['subtotal'] - $orderData['coupon_discount_total'] - $orderData['manual_discount_total'] + $orderData['fee_total'] + $orderData['shipping_total'] + $estimatedTaxTotal + $estimatedShippingTax;
1099 + $totalAmount = $orderData['subtotal']
1100 + - $orderData['coupon_discount_total']
1101 + - $orderData['manual_discount_total']
1102 + + $orderData['fee_total']
1103 + + $orderData['shipping_total']
1104 + + $estimatedTaxTotal
1105 + + $estimatedShippingTax;
891 1106
892 1107 $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0;
1108 +
1109 + /**
1110 + * Filter the prepared order data before it is used for order creation.
1111 + *
1112 + * This runs after FluentCart calculates totals, so plugins can adjust
1113 + * currency, rate, totals, config, mode, or any other order field before
1114 + * the order model, transaction, and subscription are derived from it.
1115 + *
1116 + * @param array $orderData Prepared order data array.
1117 + * @param array $context {
1118 + * Additional context for the filter.
1119 + *
1120 + * @type array $items Formatted order items with prices and quantities.
1121 + * @type array $args Checkout arguments: customer data, payment method,
1122 + * shipping, tax, coupons, fees, and IP data.
1123 + * }
1124 + */
1125 + $orderData = apply_filters('fluent_cart/checkout/order_data', $orderData, [
1126 + 'items' => $this->formattedIOrderItems,
1127 + 'args' => $this->args,
1128 + ]);
1129 +
893 1130 $this->orderData = $orderData;
894 1131 }
895 1132
896 1133 private function syncFeeItems()
@@ -1015,9 +1252,15 @@
1015 1252 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1016 1253 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1017 1254 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1018 1255 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1256 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1019 1257
1258 + // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1259 + $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1260 + $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
1261 + $isAdditiveTax = ($taxBehavior === 1) || ($taxBehavior === 3 && !$itemInclusive);
1262 +
1020 1263 // Validate repeat_interval
1021 1264 $validIntervals = array_keys(Helper::getAvailableSubscriptionIntervalMaps());
1022 1265
1023 1266 if (!in_array($repeatInterval, $validIntervals)) {
@@ -1045,16 +1288,19 @@
1045 1288 if ($trialDays > 0 && $signupFee > 0) {
1046 1289 $result['signup_fee'] = max(0, $signupFee - $totalDiscount);
1047 1290 $result['manage_setup_fee'] = 'yes';
1048 1291
1049 - if (Arr::get($inputData, 'tax_behavior', 0) == 1 && $firstIterationTax) {
1292 + if ($isAdditiveTax && $firstIterationTax) {
1050 1293 $result['signup_fee'] += $firstIterationTax;
1051 1294 }
1052 1295 } else {
1053 1296 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1054 1297
1055 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1056 - $recurringAmount -= $totalDiscount; // as now discount applied on recurring amount
1298 + // A recurring coupon discounts every cycle, so the per-cycle price itself
1299 + // is lower — the first cycle is not cheaper than the ones after it and
1300 + // must not be expressed as a trial.
1301 + if ($recurringDiscount > 0) {
1302 + $recurringAmount -= $recurringDiscount;
1057 1303 }
1058 1304
1059 1305 if ($firstCycleCost < $recurringAmount) {
1060 1306 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
@@ -1062,9 +1308,11 @@
1062 1308 $result['trial_days'] = $adjustedTrialDays;
1063 1309 $result['is_trial_days_simulated'] = 'yes';
1064 1310 $result['signup_fee'] = $firstCycleCost;
1065 1311 $result['manage_setup_fee'] = 'yes';
1066 - $result['times'] = $times > 0 ? $times - 1 : 0;
1312 + // bill_times stays the full installment count. The simulated trial cycle IS the
1313 + // first installment (charged as one-time payment / free when 100% discounted);
1314 + // gateways derive the remaining remote cycles from is_trial_days_simulated.
1067 1315 } else if ($firstCycleCost > $recurringAmount) {
1068 1316 $result['trial_days'] = 0;
1069 1317 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1070 1318 $result['manage_setup_fee'] = 'yes';
@@ -1074,9 +1322,9 @@
1074 1322 $result['manage_setup_fee'] = 'no';
1075 1323 }
1076 1324
1077 1325 // 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) {
1326 + if ($isAdditiveTax && $firstIterationTax) {
1079 1327 if ($result['trial_days'] > 0) {
1080 1328 $result['signup_fee'] += $firstIterationTax;
1081 1329 } else {
1082 1330 $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 +1334,9 @@
1086 1334
1087 1335 } else {
1088 1336 $result['signup_fee'] = $signupFee;
1089 1337
1090 - if (Arr::get($inputData, 'tax_behavior', 0) == 1) {
1338 + if ($isAdditiveTax) {
1091 1339 if ($signupFeeTax) {
1092 1340 $result['signup_fee'] += $signupFeeTax;
1093 1341 }
1094 1342 }
@@ -1106,6 +1354,57 @@
1106 1354 'recurring_amount' => $recurringAmount,
1107 1355 'recurring_tax_total' => $recurringTax,
1108 1356 'signup_fee' => $result['signup_fee'] ?? 0,
1109 1357 ];
1358 + }
1359 +
1360 + /**
1361 + * bill_count is derived from counting total > 0 CHARGE transactions linked to
1362 + * the subscription (see syncSubscriptionStates / getRequiredBillTimes), which
1363 + * can't tell "this was a billed cycle" from "this was something else
1364 + * charged alongside it." Two corrections needed only at initial checkout —
1365 + * is_trial_days_simulated alone can't be used at runtime because
1366 + * payment-method switching also sets that flag:
1367 + *
1368 + * - Simulated trial, $0 first cycle: consumes a cycle but produces no
1369 + * total > 0 transaction — add billed_cycles_offset so it still counts.
1370 + * - Real trial with a signup fee: the initial charge is the signup fee only
1371 + * (the recurring item isn't billed yet), but it IS a total > 0 transaction
1372 + * linked to the subscription — mark billed_cycles_deduction so it does
1373 + * NOT count as a cycle.
1374 + */
1375 + private function syncInitialCycleCounting()
1376 + {
1377 + if (!$this->subscriptionModel) {
1378 + return;
1379 + }
1380 +
1381 + $isSimulated = Arr::get($this->subscriptionData, 'config.is_trial_days_simulated', 'no') === 'yes';
1382 + $trialDays = (int)Arr::get($this->subscriptionData, 'trial_days', 0);
1383 + $billTimes = (int)$this->subscriptionModel->bill_times;
1384 + $orderTotal = (int)$this->orderModel->total_amount;
1385 +
1386 + // signup_fee <= 0 (not just == 0): prorate/upgrade credit can push the
1387 + // first cycle cost negative — still a free first cycle for counting
1388 + $isFreeFirstCycle = $isSimulated
1389 + && $billTimes > 0
1390 + && (int)$this->subscriptionModel->signup_fee <= 0
1391 + && !$orderTotal;
1392 +
1393 + if ($isFreeFirstCycle) {
1394 + $this->subscriptionModel->updateMeta('billed_cycles_offset', 1);
1395 + } else {
1396 + $this->subscriptionModel->deleteMeta('billed_cycles_offset');
1397 + }
1398 +
1399 + $isRealTrialWithCharge = !$isSimulated
1400 + && $trialDays > 0
1401 + && $billTimes > 0
1402 + && $orderTotal > 0;
1403 +
1404 + if ($isRealTrialWithCharge) {
1405 + $this->subscriptionModel->updateMeta('billed_cycles_deduction', 1);
1406 + } else {
1407 + $this->subscriptionModel->deleteMeta('billed_cycles_deduction');
1408 + }
1110 1409 }
1111 1410 }