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 +344 -29 1.3.21 → 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', '');
@@ -563,8 +651,39 @@
563 651 }
564 652 }
565 653 }
566 654
655 + // Snapshot package dimensions into other_info for email/PDF rendering
656 + if (Arr::get($cartItem, 'fulfillment_type') === 'physical') {
657 + $packageSlug = Arr::get($args, 'package_slug', '');
658 + $package = Helper::getPackageBySlug($packageSlug);
659 + if ($package) {
660 + $args['package_name'] = Arr::get($package, 'name', '');
661 + $args['package_type'] = Arr::get($package, 'type', '');
662 + $args['package_length'] = Arr::get($package, 'length', '');
663 + $args['package_width'] = Arr::get($package, 'width', '');
664 + $args['package_height'] = Arr::get($package, 'height', '');
665 + $args['package_dimension_unit'] = Arr::get($package, 'dimension_unit', 'cm');
666 + $args['package_weight'] = Arr::get($package, 'weight', 0);
667 + $args['package_weight_unit'] = Arr::get($package, 'weight_unit', 'kg');
668 + }
669 + }
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 +
567 686 $item = [
568 687 'payment_type' => $paymentType,
569 688 'post_id' => Arr::get($cartItem, 'post_id'),
570 689 'object_id' => Arr::get($cartItem, 'object_id'),
@@ -577,8 +696,9 @@
577 696 'subtotal' => $subtotal,
578 697 'tax_amount' => (int)Arr::get($cartItem, 'tax_amount', 0),
579 698 'shipping_charge' => $shippingCharge,
580 699 'discount_total' => $discountTotal,
700 + 'coupon_discount' => (int)Arr::get($cartItem, 'coupon_discount', 0),
581 701 'other_info' => $args,
582 702 'line_meta' => Arr::get($cartItem, 'line_meta', []),
583 703 ];
584 704
@@ -592,10 +712,16 @@
592 712 $signupFeeAmount = (int)Arr::get($cartItem, 'other_info.signup_fee', 0);
593 713
594 714 $signupFeeTax = (int)Arr::get($cartItem, 'other_info.signup_fee_tax', 0);
595 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 +
596 720 $childDiscountTotal = 0;
721 + $childCouponDiscount = 0;
597 722 $signupFeeSubtotal = $signupFeeAmount * $quantity;
723 + $couponDiscount = $item['coupon_discount'];
598 724
599 725 $hasTrialDays = Arr::get($cartItem, 'other_info.trial_days', 0) > 0;
600 726
601 727 if ($discountTotal && !$hasTrialDays) {
@@ -600,11 +726,15 @@
600 726
601 727 if ($discountTotal && !$hasTrialDays) {
602 728 $childDiscountTotal = (float)($discountTotal / ($subtotal + $signupFeeSubtotal) * $signupFeeSubtotal);
603 729 $discountTotal -= $childDiscountTotal;
730 + $childCouponDiscount = (int) round($couponDiscount * $signupFeeSubtotal / ($subtotal + $signupFeeSubtotal));
731 + $couponDiscount -= $childCouponDiscount;
604 732 } elseif ($discountTotal && $hasTrialDays) { // if trial days , then discount should be applied on signup fee only
605 733 $childDiscountTotal = min($discountTotal, $signupFeeSubtotal);
606 734 $discountTotal = 0;
735 + $childCouponDiscount = min($couponDiscount, $signupFeeSubtotal);
736 + $couponDiscount = 0;
607 737 }
608 738
609 739 $childItem = [
610 740 'payment_type' => 'signup_fee',
@@ -619,12 +749,14 @@
619 749 'subtotal' => $signupFeeSubtotal,
620 750 'tax_amount' => $signupFeeTax,
621 751 'shipping_charge' => 0,
622 752 'discount_total' => $childDiscountTotal,
623 - 'line_meta' => Arr::get($cartItem, 'signup_fee_tax_config', []),
753 + 'coupon_discount' => $childCouponDiscount,
754 + 'line_meta' => $signupFeeTaxConfig ? ['tax_config' => $signupFeeTaxConfig] : [],
624 755 ];
625 756
626 757 $item['discount_total'] = $discountTotal;
758 + $item['coupon_discount'] = $couponDiscount;
627 759 $item['additional_items'] = [$childItem];
628 760
629 761 Arr::set($item, 'other_info.signup_fee', $signupFeeAmount);
630 762 Arr::set($item, 'other_info.signup_discount', $childDiscountTotal);
@@ -738,9 +870,9 @@
738 870
739 871 $item = reset($subscriptionItems);
740 872 $signupFeeItem = reset($signupFeeItems) ?? [];
741 873 $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0);
742 - $taxBehavior = Arr::get($this->args, 'tax_behavior', 0);
874 + $taxBehavior = (int)Arr::get($this->args, 'tax_behavior', 0);
743 875
744 876 $recurringTotal = (int)$item['subtotal'];
745 877 $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0);
746 878
@@ -749,16 +881,24 @@
749 881 if ($recurringDiscountAmount && $recurringDiscountAmount > 0) {
750 882 $recurringTotal -= $recurringDiscountAmount;
751 883 }
752 884
753 - // Add shipping charges to recurring total for physical subscription products
885 + // Add shipping charges (and tax) to recurring total for physical subscription products
754 886 $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0);
755 887 $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical';
756 888 if ($isPhysicalProduct && $shippingCharge > 0) {
757 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 + }
758 897 }
759 898
760 - if ($taxBehavior === 1) {
899 + $itemInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
900 + if ($taxBehavior === 1 || ($taxBehavior === 3 && !$itemInclusive)) {
761 901 $recurringTotal += $recurringTax;
762 902 }
763 903
764 904 $signupFee = (int)Arr::get($signupFeeItem, 'subtotal', 0);
@@ -765,16 +905,22 @@
765 905
766 906 // in case of discount applied 'tax_amount' is different than recurring tax ,
767 907 $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax;
768 908
769 -
770 909 // Calculate recurring amount including shipping for physical products
771 910 $recurringAmount = (int)$item['subtotal'];
772 911 if ($isPhysicalProduct && $shippingCharge > 0) {
773 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 + }
774 920 }
775 921
776 - $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;
777 923 $subscriptionPricing = $this->convertToSubscriptionFormat([
778 924 'initial_trial_days' => Arr::get($item, 'other_info.trial_days', 0),
779 925 'repeat_interval' => Arr::get($item, 'other_info.repeat_interval', 'monthly'),
780 926 'times' => Arr::get($item, 'other_info.times', 0),
@@ -781,12 +927,13 @@
781 927 'recurring_amount' => $recurringAmount,
782 928 'recurring_tax_total' => $recurringTax,
783 929 'recurring_total' => $recurringTotal,
784 930 'tax_behavior' => $taxBehavior,
931 + 'line_meta' => Arr::get($item, 'line_meta', []),
785 932 'signup_fee' => $signupFee,
786 933 'signup_fee_tax' => $signupFeeTax,
787 934 'first_iteration_tax' => $firstIterationTax,
788 - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
935 + 'recurring_discount' => $recurringDiscountAmount,
789 936 'total_discount' => $discountTotal
790 937 ]);
791 938
792 939 // removable upon discussion
@@ -802,20 +949,61 @@
802 949 'variation_id' => Arr::get($item, 'object_id', 0),
803 950 'status' => Status::SUBSCRIPTION_PENDING,
804 951 'config' => [
805 952 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
806 - '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', '')
807 958 ]
808 959 ];
809 960
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;
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;
813 981 }
814 982
815 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
983 + $this->subscriptionData = $subscriptionData;
816 984 }
817 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 +
818 1006 private function prepareOrderData()
819 1007 {
820 1008 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
821 1009 return $item['fulfillment_type'] === 'physical';
@@ -835,8 +1023,24 @@
835 1023 }
836 1024 return $carry + $item['subtotal'];
837 1025 }, 0);
838 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 +
839 1043 $orderData = [
840 1044 'status' => Status::ORDER_ON_HOLD,
841 1045 'fulfillment_type' => $hasPhysical ? Status::FULFILLMENT_TYPE_PHYSICAL : Status::FULFILLMENT_TYPE_DIGITAL,
842 1046 'type' => $hasSubscription ? Status::ORDER_TYPE_SUBSCRIPTION : Status::ORDER_TYPE_PAYMENT, // revisit this on manual renewal
@@ -848,15 +1052,15 @@
848 1052 'payment_method_title' => '',
849 1053 'currency' => $this->storeSettings->get('currency'),
850 1054 'subtotal' => $itemsSubtotal,
851 1055 'discount_tax' => 0,
852 - 'manual_discount_total' => $this->manualDiscountTotal,
1056 + 'manual_discount_total' => $this->manualDiscountTotal + $this->prorateCreditTotal + $this->upgradeDiscountTotal,
853 1057 'coupon_discount_total' => $this->couponDiscountTotal,
854 1058 'shipping_tax' => Arr::get($this->args, 'shipping_tax', 0),
855 1059 'shipping_total' => Arr::get($this->args, 'shipping_charge', 0),
856 1060 'fee_total' => $this->feeTotal,
857 1061 'tax_total' => Arr::get($this->args, 'tax_total', 0),
858 - 'tax_behavior' => Arr::get($this->args, 'tax_behavior', 0),
1062 + 'tax_behavior' => $taxBehavior,
859 1063 // 'total_amount' => $this->orderTotals['total_amount'],
860 1064 'total_paid' => 0,
861 1065 'total_refund' => 0,
862 1066 'rate' => 1,
@@ -863,18 +1067,67 @@
863 1067 'note' => Arr::get($this->args, 'note', ''),
864 1068 'ip_address' => Arr::get($this->args, 'ip_address', ''),
865 1069 'config' => [
866 1070 'user_tz' => Arr::get($this->args, 'user_tz', ''),
867 - '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,
868 1076 ],
869 1077 ];
870 1078
871 - $estimatedTaxTotal = $orderData['tax_behavior'] === 1 ? $orderData['tax_total'] : 0;
872 - $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 + }
873 1098
874 - $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;
875 1106
876 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 +
877 1130 $this->orderData = $orderData;
878 1131 }
879 1132
880 1133 private function syncFeeItems()
@@ -999,9 +1252,15 @@
999 1252 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1000 1253 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1001 1254 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1002 1255 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1256 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1003 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 +
1004 1263 // Validate repeat_interval
1005 1264 $validIntervals = array_keys(Helper::getAvailableSubscriptionIntervalMaps());
1006 1265
1007 1266 if (!in_array($repeatInterval, $validIntervals)) {
@@ -1029,16 +1288,19 @@
1029 1288 if ($trialDays > 0 && $signupFee > 0) {
1030 1289 $result['signup_fee'] = max(0, $signupFee - $totalDiscount);
1031 1290 $result['manage_setup_fee'] = 'yes';
1032 1291
1033 - if (Arr::get($inputData, 'tax_behavior', 0) == 1 && $firstIterationTax) {
1292 + if ($isAdditiveTax && $firstIterationTax) {
1034 1293 $result['signup_fee'] += $firstIterationTax;
1035 1294 }
1036 1295 } else {
1037 1296 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1038 1297
1039 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1040 - $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;
1041 1303 }
1042 1304
1043 1305 if ($firstCycleCost < $recurringAmount) {
1044 1306 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
@@ -1046,9 +1308,11 @@
1046 1308 $result['trial_days'] = $adjustedTrialDays;
1047 1309 $result['is_trial_days_simulated'] = 'yes';
1048 1310 $result['signup_fee'] = $firstCycleCost;
1049 1311 $result['manage_setup_fee'] = 'yes';
1050 - $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.
1051 1315 } else if ($firstCycleCost > $recurringAmount) {
1052 1316 $result['trial_days'] = 0;
1053 1317 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1054 1318 $result['manage_setup_fee'] = 'yes';
@@ -1058,9 +1322,9 @@
1058 1322 $result['manage_setup_fee'] = 'no';
1059 1323 }
1060 1324
1061 1325 // 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) {
1326 + if ($isAdditiveTax && $firstIterationTax) {
1063 1327 if ($result['trial_days'] > 0) {
1064 1328 $result['signup_fee'] += $firstIterationTax;
1065 1329 } else {
1066 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
@@ -1070,9 +1334,9 @@
1070 1334
1071 1335 } else {
1072 1336 $result['signup_fee'] = $signupFee;
1073 1337
1074 - if (Arr::get($inputData, 'tax_behavior', 0) == 1) {
1338 + if ($isAdditiveTax) {
1075 1339 if ($signupFeeTax) {
1076 1340 $result['signup_fee'] += $signupFeeTax;
1077 1341 }
1078 1342 }
@@ -1090,6 +1354,57 @@
1090 1354 'recurring_amount' => $recurringAmount,
1091 1355 'recurring_tax_total' => $recurringTax,
1092 1356 'signup_fee' => $result['signup_fee'] ?? 0,
1093 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 + }
1094 1409 }
1095 1410 }