PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 All 51 releases
← All changes | app/Helpers/CheckoutProcessor.php +232 -13 1.5.1 → 1.7.1 View file →
@@ -13,8 +13,10 @@
13 13 use FluentCart\App\Models\Subscription;
14 14 use FluentCart\App\Modules\Tax\TaxCalculator;
15 15 use FluentCart\Framework\Support\Arr;
16 16 use FluentCart\App\Helpers\Helper;
17 +use FluentCart\App\Modules\PaymentMethods\Core\GatewayManager;
18 +use FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode;
17 19
18 20 class CheckoutProcessor
19 21 {
20 22
@@ -20,8 +22,9 @@
20 22
21 23 // Raw Data
22 24 private $cartItems = [];
23 25 private $args = [];
26 + private $validationError;
24 27
25 28 // Order Related Data
26 29 private $formattedIOrderItems = [];
27 30 private $orderData = [];
@@ -60,14 +63,43 @@
60 63
61 64 private function prepareData()
62 65 {
63 66 $this->prepareOrderItems();
67 + if ($this->validationError) {
68 + return;
69 + }
70 +
64 71 $this->prepareOrderData();
72 + if ($this->validationError) {
73 + return;
74 + }
75 +
65 76 $this->prepareSubscriptionData();
66 77 }
67 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 +
68 96 public function createDraftOrder($prevOrder = null)
69 97 {
98 + if ($this->validationError) {
99 + return $this->validationError;
100 + }
101 +
70 102 if ($prevOrder) {
71 103 return $this->getAdjustedOrder($prevOrder);
72 104 }
73 105
@@ -199,8 +231,9 @@
199 231 $subscriptionData['customer_id'] = $customerId;
200 232 $subscriptionData['parent_order_id'] = $this->orderModel->id;
201 233
202 234 $this->subscriptionModel = Subscription::query()->create($subscriptionData);
235 + $this->syncInitialCycleCounting();
203 236 }
204 237
205 238 // Let's create the transaction
206 239 $transactionData = [
@@ -243,8 +276,18 @@
243 276 $cart->user_id = $customer->user_id;
244 277 }
245 278
246 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 +
247 290 $actions = Arr::get($cart->checkout_data, '__after_draft_created_actions__', []);
248 291 if ($actions) {
249 292 foreach ($actions as $actionName) {
250 293 $actionName = (string)$actionName;
@@ -305,8 +348,15 @@
305 348
306 349 if ($isLocked && $taxEnabled !== 'yes') {
307 350 // Locked orders skip full item sync, but fee items must stay in sync with fee_total
308 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 + }
309 359 }
310 360
311 361 if (!$isLocked || $taxEnabled === 'yes') {
312 362 // Let's create the order items
@@ -423,8 +473,9 @@
423 473 $this->subscriptionModel = $existingSubscription;
424 474 } else {
425 475 $this->subscriptionModel = Subscription::query()->create($subscriptionData);
426 476 }
477 + $this->syncInitialCycleCounting();
427 478 }
428 479 } else {
429 480 Subscription::query()->where('parent_order_id', $this->orderModel->id)->delete();
430 481 }
@@ -465,8 +516,26 @@
465 516 ->where('order_id', $this->orderModel->id)
466 517 ->first();
467 518
468 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 +
469 538 $existingTransaction->fill($transactionData);
470 539 $existingTransaction->save();
471 540 $this->transactionModel = $existingTransaction;
472 541 } else {
@@ -583,9 +652,16 @@
583 652
584 653 $discountTotal = (int)Arr::get($cartItem, 'manual_discount', 0) + (int)Arr::get($cartItem, 'coupon_discount', 0);
585 654 $shippingCharge = (int)Arr::get($cartItem, 'shipping_charge', 0);
586 655
587 - $subtotal = (int) Arr::get($cartItem, '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;
588 664 $args = Arr::get($cartItem, 'other_info', []);
589 665 $paymentType = Arr::get($args, 'payment_type', 'default');
590 666
591 667 $postTitle = Arr::get($cartItem, 'product_title', '');
@@ -639,8 +715,12 @@
639 715 Arr::get($cartItem, 'post_id', 0)
640 716 );
641 717 }
642 718
719 + if (!isset($args['variation_type'])) {
720 + $args['variation_type'] = (string) Arr::get($cartItem, 'variation_type', '');
721 + }
722 +
643 723 $item = [
644 724 'payment_type' => $paymentType,
645 725 'post_id' => Arr::get($cartItem, 'post_id'),
646 726 'object_id' => Arr::get($cartItem, 'object_id'),
@@ -827,9 +907,9 @@
827 907
828 908 $item = reset($subscriptionItems);
829 909 $signupFeeItem = reset($signupFeeItems) ?? [];
830 910 $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0);
831 - $taxBehavior = Arr::get($this->args, 'tax_behavior', 0);
911 + $taxBehavior = (int)Arr::get($this->args, 'tax_behavior', 0);
832 912
833 913 $recurringTotal = (int)$item['subtotal'];
834 914 $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0);
835 915
@@ -838,13 +918,20 @@
838 918 if ($recurringDiscountAmount && $recurringDiscountAmount > 0) {
839 919 $recurringTotal -= $recurringDiscountAmount;
840 920 }
841 921
842 - // Add shipping charges to recurring total for physical subscription products
922 + // Add shipping charges (and tax) to recurring total for physical subscription products
843 923 $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0);
844 924 $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical';
845 925 if ($isPhysicalProduct && $shippingCharge > 0) {
846 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 + }
847 934 }
848 935
849 936 $itemInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
850 937 if ($taxBehavior === 1 || ($taxBehavior === 3 && !$itemInclusive)) {
@@ -855,13 +942,19 @@
855 942
856 943 // in case of discount applied 'tax_amount' is different than recurring tax ,
857 944 $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax;
858 945
859 -
860 946 // Calculate recurring amount including shipping for physical products
861 947 $recurringAmount = (int)$item['subtotal'];
862 948 if ($isPhysicalProduct && $shippingCharge > 0) {
863 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 + }
864 957 }
865 958
866 959 $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0) + $this->prorateCreditTotal + $this->upgradeDiscountTotal;
867 960 $subscriptionPricing = $this->convertToSubscriptionFormat([
@@ -875,9 +968,9 @@
875 968 'line_meta' => Arr::get($item, 'line_meta', []),
876 969 'signup_fee' => $signupFee,
877 970 'signup_fee_tax' => $signupFeeTax,
878 971 'first_iteration_tax' => $firstIterationTax,
879 - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
972 + 'recurring_discount' => $recurringDiscountAmount,
880 973 'total_discount' => $discountTotal
881 974 ]);
882 975
883 976 // removable upon discussion
@@ -893,20 +986,61 @@
893 986 'variation_id' => Arr::get($item, 'object_id', 0),
894 987 'status' => Status::SUBSCRIPTION_PENDING,
895 988 'config' => [
896 989 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
897 - 'currency' => $this->orderData['currency']
990 + 'currency' => $this->orderData['currency'],
991 + // Snapshot the variant attribute map + variation type from the order
992 + // item so the subscription carries the same pa_* set behind its item_name.
993 + 'item_attributes' => Arr::get($item, 'other_info.item_attributes', []),
994 + 'variation_type' => Arr::get($item, 'other_info.variation_type', '')
898 995 ]
899 996 ];
900 997
901 - // if recurring coupon is applied, we need to subtract the total discount from the recurring total
902 - if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') {
903 - $subscriptionItem['recurring_total'] -= $discountTotal;
998 + $subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
999 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1000 +
1001 + $collectionMethod = apply_filters('fluent_cart/subscription_collection_method_' . $paymentMethod, $this->determineCollectionMethod());
1002 +
1003 + // A filter can hand back anything, but `system` only means something on a
1004 + // gateway that can charge a saved payment method.
1005 + $subscriptionData['collection_method'] = SubscriptionManagementMode::sanitizeCollectionMethod(
1006 + $collectionMethod,
1007 + GatewayManager::getInstance()->get($paymentMethod)
1008 + );
1009 +
1010 + // Stamp store-managed origin durably on the subscription. Gateways consult
1011 + // the stamp (not the current store setting) before converting a manual
1012 + // subscription to automatic, so switching the mode back to gateway-managed
1013 + // later never flips subscriptions born under store-managed.
1014 + if (in_array($subscriptionData['collection_method'], ['manual', 'system'], true) && SubscriptionManagementMode::isStoreManaged()) {
1015 + $subscriptionConfig = Arr::get($subscriptionData, 'config', []);
1016 + $subscriptionConfig[SubscriptionManagementMode::CONFIG_KEY] = SubscriptionManagementMode::STORE_MANAGED;
1017 + $subscriptionData['config'] = $subscriptionConfig;
904 1018 }
905 1019
906 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
1020 + $this->subscriptionData = $subscriptionData;
907 1021 }
908 1022
1023 + private function determineCollectionMethod(): string
1024 + {
1025 + if (SubscriptionManagementMode::isStoreManaged()) {
1026 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1027 +
1028 + return SubscriptionManagementMode::resolveCollectionMethodFor(
1029 + GatewayManager::getInstance()->get($paymentMethod)
1030 + );
1031 + }
1032 +
1033 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1034 + $gateway = GatewayManager::getInstance()->get($paymentMethod);
1035 +
1036 + if ($gateway && $gateway->has('subscriptions')) {
1037 + return 'automatic';
1038 + }
1039 +
1040 + return 'manual';
1041 + }
1042 +
909 1043 private function prepareOrderData()
910 1044 {
911 1045 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
912 1046 return $item['fulfillment_type'] === 'physical';
@@ -1007,8 +1141,36 @@
1007 1141 + $estimatedTaxTotal
1008 1142 + $estimatedShippingTax;
1009 1143
1010 1144 $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0;
1145 +
1146 + /**
1147 + * Filter the prepared order data before it is used for order creation.
1148 + *
1149 + * This runs after FluentCart calculates totals, so plugins can adjust
1150 + * currency, rate, totals, config, mode, or any other order field before
1151 + * the order model, transaction, and subscription are derived from it.
1152 + *
1153 + * @param array $orderData Prepared order data array.
1154 + * @param array $context {
1155 + * Additional context for the filter.
1156 + *
1157 + * @type array $items Formatted order items with prices and quantities.
1158 + * @type array $args Checkout arguments: customer data, payment method,
1159 + * shipping, tax, coupons, fees, and IP data.
1160 + * }
1161 + */
1162 + $orderData = apply_filters('fluent_cart/checkout/order_data', $orderData, [
1163 + 'items' => $this->formattedIOrderItems,
1164 + 'args' => $this->args,
1165 + ]);
1166 +
1167 + if (!$this->isSafeAmount(Arr::get($orderData, 'total_amount', 0))) {
1168 + $this->validationError = $this->unsafeAmountError();
1169 +
1170 + return;
1171 + }
1172 +
1011 1173 $this->orderData = $orderData;
1012 1174 }
1013 1175
1014 1176 private function syncFeeItems()
@@ -1133,8 +1295,9 @@
1133 1295 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1134 1296 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1135 1297 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1136 1298 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1299 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1137 1300
1138 1301 // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1139 1302 $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1140 1303 $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
@@ -1174,10 +1337,13 @@
1174 1337 }
1175 1338 } else {
1176 1339 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1177 1340
1178 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1179 - $recurringAmount -= $totalDiscount; // as now discount applied on recurring amount
1341 + // A recurring coupon discounts every cycle, so the per-cycle price itself
1342 + // is lower — the first cycle is not cheaper than the ones after it and
1343 + // must not be expressed as a trial.
1344 + if ($recurringDiscount > 0) {
1345 + $recurringAmount -= $recurringDiscount;
1180 1346 }
1181 1347
1182 1348 if ($firstCycleCost < $recurringAmount) {
1183 1349 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
@@ -1185,9 +1351,11 @@
1185 1351 $result['trial_days'] = $adjustedTrialDays;
1186 1352 $result['is_trial_days_simulated'] = 'yes';
1187 1353 $result['signup_fee'] = $firstCycleCost;
1188 1354 $result['manage_setup_fee'] = 'yes';
1189 - $result['times'] = $times > 0 ? $times - 1 : 0;
1355 + // bill_times stays the full installment count. The simulated trial cycle IS the
1356 + // first installment (charged as one-time payment / free when 100% discounted);
1357 + // gateways derive the remaining remote cycles from is_trial_days_simulated.
1190 1358 } else if ($firstCycleCost > $recurringAmount) {
1191 1359 $result['trial_days'] = 0;
1192 1360 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1193 1361 $result['manage_setup_fee'] = 'yes';
@@ -1229,6 +1397,57 @@
1229 1397 'recurring_amount' => $recurringAmount,
1230 1398 'recurring_tax_total' => $recurringTax,
1231 1399 'signup_fee' => $result['signup_fee'] ?? 0,
1232 1400 ];
1401 + }
1402 +
1403 + /**
1404 + * bill_count is derived from counting total > 0 CHARGE transactions linked to
1405 + * the subscription (see syncSubscriptionStates / getRequiredBillTimes), which
1406 + * can't tell "this was a billed cycle" from "this was something else
1407 + * charged alongside it." Two corrections needed only at initial checkout —
1408 + * is_trial_days_simulated alone can't be used at runtime because
1409 + * payment-method switching also sets that flag:
1410 + *
1411 + * - Simulated trial, $0 first cycle: consumes a cycle but produces no
1412 + * total > 0 transaction — add billed_cycles_offset so it still counts.
1413 + * - Real trial with a signup fee: the initial charge is the signup fee only
1414 + * (the recurring item isn't billed yet), but it IS a total > 0 transaction
1415 + * linked to the subscription — mark billed_cycles_deduction so it does
1416 + * NOT count as a cycle.
1417 + */
1418 + private function syncInitialCycleCounting()
1419 + {
1420 + if (!$this->subscriptionModel) {
1421 + return;
1422 + }
1423 +
1424 + $isSimulated = Arr::get($this->subscriptionData, 'config.is_trial_days_simulated', 'no') === 'yes';
1425 + $trialDays = (int)Arr::get($this->subscriptionData, 'trial_days', 0);
1426 + $billTimes = (int)$this->subscriptionModel->bill_times;
1427 + $orderTotal = (int)$this->orderModel->total_amount;
1428 +
1429 + // signup_fee <= 0 (not just == 0): prorate/upgrade credit can push the
1430 + // first cycle cost negative — still a free first cycle for counting
1431 + $isFreeFirstCycle = $isSimulated
1432 + && $billTimes > 0
1433 + && (int)$this->subscriptionModel->signup_fee <= 0
1434 + && !$orderTotal;
1435 +
1436 + if ($isFreeFirstCycle) {
1437 + $this->subscriptionModel->updateMeta('billed_cycles_offset', 1);
1438 + } else {
1439 + $this->subscriptionModel->deleteMeta('billed_cycles_offset');
1440 + }
1441 +
1442 + $isRealTrialWithCharge = !$isSimulated
1443 + && $trialDays > 0
1444 + && $billTimes > 0
1445 + && $orderTotal > 0;
1446 +
1447 + if ($isRealTrialWithCharge) {
1448 + $this->subscriptionModel->updateMeta('billed_cycles_deduction', 1);
1449 + } else {
1450 + $this->subscriptionModel->deleteMeta('billed_cycles_deduction');
1451 + }
1233 1452 }
1234 1453 }