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.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 trunk All 48 releases
← All changes | app/Helpers/CheckoutProcessor.php +179 -11 1.5.2 → 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
@@ -199,8 +201,9 @@
199 201 $subscriptionData['customer_id'] = $customerId;
200 202 $subscriptionData['parent_order_id'] = $this->orderModel->id;
201 203
202 204 $this->subscriptionModel = Subscription::query()->create($subscriptionData);
205 + $this->syncInitialCycleCounting();
203 206 }
204 207
205 208 // Let's create the transaction
206 209 $transactionData = [
@@ -243,8 +246,18 @@
243 246 $cart->user_id = $customer->user_id;
244 247 }
245 248
246 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 +
247 260 $actions = Arr::get($cart->checkout_data, '__after_draft_created_actions__', []);
248 261 if ($actions) {
249 262 foreach ($actions as $actionName) {
250 263 $actionName = (string)$actionName;
@@ -305,8 +318,15 @@
305 318
306 319 if ($isLocked && $taxEnabled !== 'yes') {
307 320 // Locked orders skip full item sync, but fee items must stay in sync with fee_total
308 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 + }
309 329 }
310 330
311 331 if (!$isLocked || $taxEnabled === 'yes') {
312 332 // Let's create the order items
@@ -423,8 +443,9 @@
423 443 $this->subscriptionModel = $existingSubscription;
424 444 } else {
425 445 $this->subscriptionModel = Subscription::query()->create($subscriptionData);
426 446 }
447 + $this->syncInitialCycleCounting();
427 448 }
428 449 } else {
429 450 Subscription::query()->where('parent_order_id', $this->orderModel->id)->delete();
430 451 }
@@ -465,8 +486,26 @@
465 486 ->where('order_id', $this->orderModel->id)
466 487 ->first();
467 488
468 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 +
469 508 $existingTransaction->fill($transactionData);
470 509 $existingTransaction->save();
471 510 $this->transactionModel = $existingTransaction;
472 511 } else {
@@ -831,9 +870,9 @@
831 870
832 871 $item = reset($subscriptionItems);
833 872 $signupFeeItem = reset($signupFeeItems) ?? [];
834 873 $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0);
835 - $taxBehavior = Arr::get($this->args, 'tax_behavior', 0);
874 + $taxBehavior = (int)Arr::get($this->args, 'tax_behavior', 0);
836 875
837 876 $recurringTotal = (int)$item['subtotal'];
838 877 $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0);
839 878
@@ -842,13 +881,20 @@
842 881 if ($recurringDiscountAmount && $recurringDiscountAmount > 0) {
843 882 $recurringTotal -= $recurringDiscountAmount;
844 883 }
845 884
846 - // Add shipping charges to recurring total for physical subscription products
885 + // Add shipping charges (and tax) to recurring total for physical subscription products
847 886 $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0);
848 887 $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical';
849 888 if ($isPhysicalProduct && $shippingCharge > 0) {
850 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 + }
851 897 }
852 898
853 899 $itemInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
854 900 if ($taxBehavior === 1 || ($taxBehavior === 3 && !$itemInclusive)) {
@@ -859,13 +905,19 @@
859 905
860 906 // in case of discount applied 'tax_amount' is different than recurring tax ,
861 907 $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax;
862 908
863 -
864 909 // Calculate recurring amount including shipping for physical products
865 910 $recurringAmount = (int)$item['subtotal'];
866 911 if ($isPhysicalProduct && $shippingCharge > 0) {
867 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 + }
868 920 }
869 921
870 922 $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0) + $this->prorateCreditTotal + $this->upgradeDiscountTotal;
871 923 $subscriptionPricing = $this->convertToSubscriptionFormat([
@@ -879,9 +931,9 @@
879 931 'line_meta' => Arr::get($item, 'line_meta', []),
880 932 'signup_fee' => $signupFee,
881 933 'signup_fee_tax' => $signupFeeTax,
882 934 'first_iteration_tax' => $firstIterationTax,
883 - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
935 + 'recurring_discount' => $recurringDiscountAmount,
884 936 'total_discount' => $discountTotal
885 937 ]);
886 938
887 939 // removable upon discussion
@@ -905,16 +957,53 @@
905 957 'variation_type' => Arr::get($item, 'other_info.variation_type', '')
906 958 ]
907 959 ];
908 960
909 - // if recurring coupon is applied, we need to subtract the total discount from the recurring total
910 - if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') {
911 - $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;
912 981 }
913 982
914 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
983 + $this->subscriptionData = $subscriptionData;
915 984 }
916 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 +
917 1006 private function prepareOrderData()
918 1007 {
919 1008 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
920 1009 return $item['fulfillment_type'] === 'physical';
@@ -1015,8 +1104,30 @@
1015 1104 + $estimatedTaxTotal
1016 1105 + $estimatedShippingTax;
1017 1106
1018 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 +
1019 1130 $this->orderData = $orderData;
1020 1131 }
1021 1132
1022 1133 private function syncFeeItems()
@@ -1141,8 +1252,9 @@
1141 1252 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1142 1253 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1143 1254 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1144 1255 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1256 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1145 1257
1146 1258 // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1147 1259 $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1148 1260 $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
@@ -1182,10 +1294,13 @@
1182 1294 }
1183 1295 } else {
1184 1296 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1185 1297
1186 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1187 - $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;
1188 1303 }
1189 1304
1190 1305 if ($firstCycleCost < $recurringAmount) {
1191 1306 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
@@ -1193,9 +1308,11 @@
1193 1308 $result['trial_days'] = $adjustedTrialDays;
1194 1309 $result['is_trial_days_simulated'] = 'yes';
1195 1310 $result['signup_fee'] = $firstCycleCost;
1196 1311 $result['manage_setup_fee'] = 'yes';
1197 - $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.
1198 1315 } else if ($firstCycleCost > $recurringAmount) {
1199 1316 $result['trial_days'] = 0;
1200 1317 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1201 1318 $result['manage_setup_fee'] = 'yes';
@@ -1237,6 +1354,57 @@
1237 1354 'recurring_amount' => $recurringAmount,
1238 1355 'recurring_tax_total' => $recurringTax,
1239 1356 'signup_fee' => $result['signup_fee'] ?? 0,
1240 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 + }
1241 1409 }
1242 1410 }