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 +212 -15 1.4.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 {
@@ -527,12 +566,22 @@
527 566 $customerId = $order->customer_id;
528 567 }
529 568
530 569 if (!empty($couponCodes)) {
531 - $coupons = Coupon::query()->whereIn('code', $couponCodes)->get()
532 - ->keyBy('code')
533 - ->toArray();
570 + $coupons = Coupon::query()->whereIn('code', $couponCodes)->get();
534 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 +
535 584 foreach ($coupons as $code => &$coupon) {
536 585 $coupon['coupon_id'] = $appliedCoupons[$code]['id'];
537 586 $coupon['amount'] = $appliedCoupons[$code]['discount'];
538 587 $coupon['customer_id'] = $customerId;
@@ -618,8 +667,23 @@
618 667 $args['package_weight_unit'] = Arr::get($package, 'weight_unit', 'kg');
619 668 }
620 669 }
621 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 +
622 686 $item = [
623 687 'payment_type' => $paymentType,
624 688 'post_id' => Arr::get($cartItem, 'post_id'),
625 689 'object_id' => Arr::get($cartItem, 'object_id'),
@@ -806,9 +870,9 @@
806 870
807 871 $item = reset($subscriptionItems);
808 872 $signupFeeItem = reset($signupFeeItems) ?? [];
809 873 $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0);
810 - $taxBehavior = Arr::get($this->args, 'tax_behavior', 0);
874 + $taxBehavior = (int)Arr::get($this->args, 'tax_behavior', 0);
811 875
812 876 $recurringTotal = (int)$item['subtotal'];
813 877 $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0);
814 878
@@ -817,13 +881,20 @@
817 881 if ($recurringDiscountAmount && $recurringDiscountAmount > 0) {
818 882 $recurringTotal -= $recurringDiscountAmount;
819 883 }
820 884
821 - // Add shipping charges to recurring total for physical subscription products
885 + // Add shipping charges (and tax) to recurring total for physical subscription products
822 886 $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0);
823 887 $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical';
824 888 if ($isPhysicalProduct && $shippingCharge > 0) {
825 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 + }
826 897 }
827 898
828 899 $itemInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
829 900 if ($taxBehavior === 1 || ($taxBehavior === 3 && !$itemInclusive)) {
@@ -834,13 +905,19 @@
834 905
835 906 // in case of discount applied 'tax_amount' is different than recurring tax ,
836 907 $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax;
837 908
838 -
839 909 // Calculate recurring amount including shipping for physical products
840 910 $recurringAmount = (int)$item['subtotal'];
841 911 if ($isPhysicalProduct && $shippingCharge > 0) {
842 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 + }
843 920 }
844 921
845 922 $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0) + $this->prorateCreditTotal + $this->upgradeDiscountTotal;
846 923 $subscriptionPricing = $this->convertToSubscriptionFormat([
@@ -854,9 +931,9 @@
854 931 'line_meta' => Arr::get($item, 'line_meta', []),
855 932 'signup_fee' => $signupFee,
856 933 'signup_fee_tax' => $signupFeeTax,
857 934 'first_iteration_tax' => $firstIterationTax,
858 - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
935 + 'recurring_discount' => $recurringDiscountAmount,
859 936 'total_discount' => $discountTotal
860 937 ]);
861 938
862 939 // removable upon discussion
@@ -872,20 +949,61 @@
872 949 'variation_id' => Arr::get($item, 'object_id', 0),
873 950 'status' => Status::SUBSCRIPTION_PENDING,
874 951 'config' => [
875 952 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
876 - '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', '')
877 958 ]
878 959 ];
879 960
880 - // if recurring coupon is applied, we need to subtract the total discount from the recurring total
881 - if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') {
882 - $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;
883 981 }
884 982
885 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
983 + $this->subscriptionData = $subscriptionData;
886 984 }
887 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 +
888 1006 private function prepareOrderData()
889 1007 {
890 1008 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
891 1009 return $item['fulfillment_type'] === 'physical';
@@ -986,8 +1104,30 @@
986 1104 + $estimatedTaxTotal
987 1105 + $estimatedShippingTax;
988 1106
989 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 +
990 1130 $this->orderData = $orderData;
991 1131 }
992 1132
993 1133 private function syncFeeItems()
@@ -1112,8 +1252,9 @@
1112 1252 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1113 1253 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1114 1254 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1115 1255 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1256 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1116 1257
1117 1258 // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1118 1259 $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1119 1260 $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
@@ -1153,10 +1294,13 @@
1153 1294 }
1154 1295 } else {
1155 1296 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1156 1297
1157 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1158 - $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;
1159 1303 }
1160 1304
1161 1305 if ($firstCycleCost < $recurringAmount) {
1162 1306 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
@@ -1164,9 +1308,11 @@
1164 1308 $result['trial_days'] = $adjustedTrialDays;
1165 1309 $result['is_trial_days_simulated'] = 'yes';
1166 1310 $result['signup_fee'] = $firstCycleCost;
1167 1311 $result['manage_setup_fee'] = 'yes';
1168 - $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.
1169 1315 } else if ($firstCycleCost > $recurringAmount) {
1170 1316 $result['trial_days'] = 0;
1171 1317 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1172 1318 $result['manage_setup_fee'] = 'yes';
@@ -1208,6 +1354,57 @@
1208 1354 'recurring_amount' => $recurringAmount,
1209 1355 'recurring_tax_total' => $recurringTax,
1210 1356 'signup_fee' => $result['signup_fee'] ?? 0,
1211 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 + }
1212 1409 }
1213 1410 }