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 +97 -9 1.5.3 → 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
@@ -244,8 +246,18 @@
244 246 $cart->user_id = $customer->user_id;
245 247 }
246 248
247 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 +
248 260 $actions = Arr::get($cart->checkout_data, '__after_draft_created_actions__', []);
249 261 if ($actions) {
250 262 foreach ($actions as $actionName) {
251 263 $actionName = (string)$actionName;
@@ -306,8 +318,15 @@
306 318
307 319 if ($isLocked && $taxEnabled !== 'yes') {
308 320 // Locked orders skip full item sync, but fee items must stay in sync with fee_total
309 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 + }
310 329 }
311 330
312 331 if (!$isLocked || $taxEnabled === 'yes') {
313 332 // Let's create the order items
@@ -467,19 +486,25 @@
467 486 ->where('order_id', $this->orderModel->id)
468 487 ->first();
469 488
470 489 if ($existingTransaction) {
490 + $meta = $existingTransaction->meta ?: [];
491 +
471 492 // Retry vs duplicate for gateway idempotency (PaymentInstance::getIdempotencySeed):
472 493 // re-submitting a pending transaction is a duplicate (keep attempt -> gateway
473 494 // dedupes); re-submitting a FAILED one is a retry (bump attempt -> fresh seed,
474 495 // never answered with the failed attempt's cached gateway response).
475 - $attempt = (int) Arr::get($existingTransaction->meta ?: [], 'payment_attempt', 0);
496 + $attempt = (int) Arr::get($meta, 'payment_attempt', 0);
476 497 if ($existingTransaction->status === Status::PAYMENT_FAILED) {
477 498 $attempt++;
478 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.
479 503 if ($attempt) {
480 - $transactionData['meta'] = ['payment_attempt' => $attempt];
504 + $meta['payment_attempt'] = $attempt;
481 505 }
506 + $transactionData['meta'] = $meta;
482 507
483 508 $existingTransaction->fill($transactionData);
484 509 $existingTransaction->save();
485 510 $this->transactionModel = $existingTransaction;
@@ -906,9 +931,9 @@
906 931 'line_meta' => Arr::get($item, 'line_meta', []),
907 932 'signup_fee' => $signupFee,
908 933 'signup_fee_tax' => $signupFeeTax,
909 934 'first_iteration_tax' => $firstIterationTax,
910 - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
935 + 'recurring_discount' => $recurringDiscountAmount,
911 936 'total_discount' => $discountTotal
912 937 ]);
913 938
914 939 // removable upon discussion
@@ -932,16 +957,53 @@
932 957 'variation_type' => Arr::get($item, 'other_info.variation_type', '')
933 958 ]
934 959 ];
935 960
936 - // if recurring coupon is applied, we need to subtract the total discount from the recurring total
937 - if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') {
938 - $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;
939 981 }
940 982
941 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
983 + $this->subscriptionData = $subscriptionData;
942 984 }
943 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 +
944 1006 private function prepareOrderData()
945 1007 {
946 1008 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
947 1009 return $item['fulfillment_type'] === 'physical';
@@ -1042,8 +1104,30 @@
1042 1104 + $estimatedTaxTotal
1043 1105 + $estimatedShippingTax;
1044 1106
1045 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 +
1046 1130 $this->orderData = $orderData;
1047 1131 }
1048 1132
1049 1133 private function syncFeeItems()
@@ -1168,8 +1252,9 @@
1168 1252 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1169 1253 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1170 1254 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1171 1255 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1256 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1172 1257
1173 1258 // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1174 1259 $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1175 1260 $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
@@ -1209,10 +1294,13 @@
1209 1294 }
1210 1295 } else {
1211 1296 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1212 1297
1213 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1214 - $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;
1215 1303 }
1216 1304
1217 1305 if ($firstCycleCost < $recurringAmount) {
1218 1306 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);