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 +199 -12 1.5.0 → 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 {
@@ -628,8 +667,23 @@
628 667 $args['package_weight_unit'] = Arr::get($package, 'weight_unit', 'kg');
629 668 }
630 669 }
631 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 +
632 686 $item = [
633 687 'payment_type' => $paymentType,
634 688 'post_id' => Arr::get($cartItem, 'post_id'),
635 689 'object_id' => Arr::get($cartItem, 'object_id'),
@@ -816,9 +870,9 @@
816 870
817 871 $item = reset($subscriptionItems);
818 872 $signupFeeItem = reset($signupFeeItems) ?? [];
819 873 $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0);
820 - $taxBehavior = Arr::get($this->args, 'tax_behavior', 0);
874 + $taxBehavior = (int)Arr::get($this->args, 'tax_behavior', 0);
821 875
822 876 $recurringTotal = (int)$item['subtotal'];
823 877 $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0);
824 878
@@ -827,13 +881,20 @@
827 881 if ($recurringDiscountAmount && $recurringDiscountAmount > 0) {
828 882 $recurringTotal -= $recurringDiscountAmount;
829 883 }
830 884
831 - // Add shipping charges to recurring total for physical subscription products
885 + // Add shipping charges (and tax) to recurring total for physical subscription products
832 886 $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0);
833 887 $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical';
834 888 if ($isPhysicalProduct && $shippingCharge > 0) {
835 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 + }
836 897 }
837 898
838 899 $itemInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
839 900 if ($taxBehavior === 1 || ($taxBehavior === 3 && !$itemInclusive)) {
@@ -844,13 +905,19 @@
844 905
845 906 // in case of discount applied 'tax_amount' is different than recurring tax ,
846 907 $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax;
847 908
848 -
849 909 // Calculate recurring amount including shipping for physical products
850 910 $recurringAmount = (int)$item['subtotal'];
851 911 if ($isPhysicalProduct && $shippingCharge > 0) {
852 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 + }
853 920 }
854 921
855 922 $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0) + $this->prorateCreditTotal + $this->upgradeDiscountTotal;
856 923 $subscriptionPricing = $this->convertToSubscriptionFormat([
@@ -864,9 +931,9 @@
864 931 'line_meta' => Arr::get($item, 'line_meta', []),
865 932 'signup_fee' => $signupFee,
866 933 'signup_fee_tax' => $signupFeeTax,
867 934 'first_iteration_tax' => $firstIterationTax,
868 - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
935 + 'recurring_discount' => $recurringDiscountAmount,
869 936 'total_discount' => $discountTotal
870 937 ]);
871 938
872 939 // removable upon discussion
@@ -882,20 +949,61 @@
882 949 'variation_id' => Arr::get($item, 'object_id', 0),
883 950 'status' => Status::SUBSCRIPTION_PENDING,
884 951 'config' => [
885 952 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
886 - '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', '')
887 958 ]
888 959 ];
889 960
890 - // if recurring coupon is applied, we need to subtract the total discount from the recurring total
891 - if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') {
892 - $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;
893 981 }
894 982
895 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
983 + $this->subscriptionData = $subscriptionData;
896 984 }
897 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 +
898 1006 private function prepareOrderData()
899 1007 {
900 1008 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
901 1009 return $item['fulfillment_type'] === 'physical';
@@ -996,8 +1104,30 @@
996 1104 + $estimatedTaxTotal
997 1105 + $estimatedShippingTax;
998 1106
999 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 +
1000 1130 $this->orderData = $orderData;
1001 1131 }
1002 1132
1003 1133 private function syncFeeItems()
@@ -1122,8 +1252,9 @@
1122 1252 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1123 1253 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1124 1254 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1125 1255 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1256 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1126 1257
1127 1258 // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1128 1259 $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1129 1260 $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
@@ -1163,10 +1294,13 @@
1163 1294 }
1164 1295 } else {
1165 1296 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1166 1297
1167 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1168 - $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;
1169 1303 }
1170 1304
1171 1305 if ($firstCycleCost < $recurringAmount) {
1172 1306 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
@@ -1174,9 +1308,11 @@
1174 1308 $result['trial_days'] = $adjustedTrialDays;
1175 1309 $result['is_trial_days_simulated'] = 'yes';
1176 1310 $result['signup_fee'] = $firstCycleCost;
1177 1311 $result['manage_setup_fee'] = 'yes';
1178 - $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.
1179 1315 } else if ($firstCycleCost > $recurringAmount) {
1180 1316 $result['trial_days'] = 0;
1181 1317 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1182 1318 $result['manage_setup_fee'] = 'yes';
@@ -1218,6 +1354,57 @@
1218 1354 'recurring_amount' => $recurringAmount,
1219 1355 'recurring_tax_total' => $recurringTax,
1220 1356 'signup_fee' => $result['signup_fee'] ?? 0,
1221 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 + }
1222 1409 }
1223 1410 }