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 +188 -12 1.5.1 → 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 {
@@ -639,8 +678,12 @@
639 678 Arr::get($cartItem, 'post_id', 0)
640 679 );
641 680 }
642 681
682 + if (!isset($args['variation_type'])) {
683 + $args['variation_type'] = (string) Arr::get($cartItem, 'variation_type', '');
684 + }
685 +
643 686 $item = [
644 687 'payment_type' => $paymentType,
645 688 'post_id' => Arr::get($cartItem, 'post_id'),
646 689 'object_id' => Arr::get($cartItem, 'object_id'),
@@ -827,9 +870,9 @@
827 870
828 871 $item = reset($subscriptionItems);
829 872 $signupFeeItem = reset($signupFeeItems) ?? [];
830 873 $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0);
831 - $taxBehavior = Arr::get($this->args, 'tax_behavior', 0);
874 + $taxBehavior = (int)Arr::get($this->args, 'tax_behavior', 0);
832 875
833 876 $recurringTotal = (int)$item['subtotal'];
834 877 $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0);
835 878
@@ -838,13 +881,20 @@
838 881 if ($recurringDiscountAmount && $recurringDiscountAmount > 0) {
839 882 $recurringTotal -= $recurringDiscountAmount;
840 883 }
841 884
842 - // Add shipping charges to recurring total for physical subscription products
885 + // Add shipping charges (and tax) to recurring total for physical subscription products
843 886 $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0);
844 887 $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical';
845 888 if ($isPhysicalProduct && $shippingCharge > 0) {
846 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 + }
847 897 }
848 898
849 899 $itemInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
850 900 if ($taxBehavior === 1 || ($taxBehavior === 3 && !$itemInclusive)) {
@@ -855,13 +905,19 @@
855 905
856 906 // in case of discount applied 'tax_amount' is different than recurring tax ,
857 907 $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax;
858 908
859 -
860 909 // Calculate recurring amount including shipping for physical products
861 910 $recurringAmount = (int)$item['subtotal'];
862 911 if ($isPhysicalProduct && $shippingCharge > 0) {
863 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 + }
864 920 }
865 921
866 922 $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0) + $this->prorateCreditTotal + $this->upgradeDiscountTotal;
867 923 $subscriptionPricing = $this->convertToSubscriptionFormat([
@@ -875,9 +931,9 @@
875 931 'line_meta' => Arr::get($item, 'line_meta', []),
876 932 'signup_fee' => $signupFee,
877 933 'signup_fee_tax' => $signupFeeTax,
878 934 'first_iteration_tax' => $firstIterationTax,
879 - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
935 + 'recurring_discount' => $recurringDiscountAmount,
880 936 'total_discount' => $discountTotal
881 937 ]);
882 938
883 939 // removable upon discussion
@@ -893,20 +949,61 @@
893 949 'variation_id' => Arr::get($item, 'object_id', 0),
894 950 'status' => Status::SUBSCRIPTION_PENDING,
895 951 'config' => [
896 952 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
897 - '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', '')
898 958 ]
899 959 ];
900 960
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;
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;
904 981 }
905 982
906 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
983 + $this->subscriptionData = $subscriptionData;
907 984 }
908 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 +
909 1006 private function prepareOrderData()
910 1007 {
911 1008 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
912 1009 return $item['fulfillment_type'] === 'physical';
@@ -1007,8 +1104,30 @@
1007 1104 + $estimatedTaxTotal
1008 1105 + $estimatedShippingTax;
1009 1106
1010 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 +
1011 1130 $this->orderData = $orderData;
1012 1131 }
1013 1132
1014 1133 private function syncFeeItems()
@@ -1133,8 +1252,9 @@
1133 1252 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1134 1253 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1135 1254 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1136 1255 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1256 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1137 1257
1138 1258 // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1139 1259 $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1140 1260 $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
@@ -1174,10 +1294,13 @@
1174 1294 }
1175 1295 } else {
1176 1296 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1177 1297
1178 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1179 - $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;
1180 1303 }
1181 1304
1182 1305 if ($firstCycleCost < $recurringAmount) {
1183 1306 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
@@ -1185,9 +1308,11 @@
1185 1308 $result['trial_days'] = $adjustedTrialDays;
1186 1309 $result['is_trial_days_simulated'] = 'yes';
1187 1310 $result['signup_fee'] = $firstCycleCost;
1188 1311 $result['manage_setup_fee'] = 'yes';
1189 - $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.
1190 1315 } else if ($firstCycleCost > $recurringAmount) {
1191 1316 $result['trial_days'] = 0;
1192 1317 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1193 1318 $result['manage_setup_fee'] = 'yes';
@@ -1229,6 +1354,57 @@
1229 1354 'recurring_amount' => $recurringAmount,
1230 1355 'recurring_tax_total' => $recurringTax,
1231 1356 'signup_fee' => $result['signup_fee'] ?? 0,
1232 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 + }
1233 1409 }
1234 1410 }