PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 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 All 51 releases
← All changes | app/Helpers/CheckoutProcessor.php +141 -10 1.5.3 → 1.7.1 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
@@ -20,8 +22,9 @@
20 22
21 23 // Raw Data
22 24 private $cartItems = [];
23 25 private $args = [];
26 + private $validationError;
24 27
25 28 // Order Related Data
26 29 private $formattedIOrderItems = [];
27 30 private $orderData = [];
@@ -60,14 +63,43 @@
60 63
61 64 private function prepareData()
62 65 {
63 66 $this->prepareOrderItems();
67 + if ($this->validationError) {
68 + return;
69 + }
70 +
64 71 $this->prepareOrderData();
72 + if ($this->validationError) {
73 + return;
74 + }
75 +
65 76 $this->prepareSubscriptionData();
66 77 }
67 78
79 + /**
80 + * (int) on an out-of-range float wraps, and the max(0, ...) clamps downstream turn a
81 + * wrapped amount into a free but payable order. Refuse the checkout instead.
82 + */
83 + private function isSafeAmount($value)
84 + {
85 + return is_numeric($value) && is_finite((float)$value) && abs((float)$value) < (float)PHP_INT_MAX;
86 + }
87 +
88 + private function unsafeAmountError()
89 + {
90 + return new \WP_Error(
91 + 'invalid_order_total',
92 + __('The order total is too large to process. Please reduce the quantity.', 'fluent-cart')
93 + );
94 + }
95 +
68 96 public function createDraftOrder($prevOrder = null)
69 97 {
98 + if ($this->validationError) {
99 + return $this->validationError;
100 + }
101 +
70 102 if ($prevOrder) {
71 103 return $this->getAdjustedOrder($prevOrder);
72 104 }
73 105
@@ -244,8 +276,18 @@
244 276 $cart->user_id = $customer->user_id;
245 277 }
246 278
247 279 $cart->save();
280 +
281 + // Carry the traffic source onto the order while the cart still exists.
282 + // Carts are pruned on a schedule, so this is the last reliable point at
283 + // which the click that produced the sale can still be recovered.
284 + UtmHelper::addUtmToOrder(
285 + $this->orderModel->id,
286 + UtmHelper::resolveUtmData(UtmHelper::getUtmDataOfRequest(), $cart->utm_data),
287 + $cart->cart_hash
288 + );
289 +
248 290 $actions = Arr::get($cart->checkout_data, '__after_draft_created_actions__', []);
249 291 if ($actions) {
250 292 foreach ($actions as $actionName) {
251 293 $actionName = (string)$actionName;
@@ -306,8 +348,15 @@
306 348
307 349 if ($isLocked && $taxEnabled !== 'yes') {
308 350 // Locked orders skip full item sync, but fee items must stay in sync with fee_total
309 351 $this->syncFeeItems();
352 +
353 + // Load existing subscription so the transaction gets the correct subscription_id
354 + if ($this->orderModel->type === Status::ORDER_TYPE_SUBSCRIPTION) {
355 + $this->subscriptionModel = Subscription::query()
356 + ->where('parent_order_id', $this->orderModel->id)
357 + ->first();
358 + }
310 359 }
311 360
312 361 if (!$isLocked || $taxEnabled === 'yes') {
313 362 // Let's create the order items
@@ -467,19 +516,25 @@
467 516 ->where('order_id', $this->orderModel->id)
468 517 ->first();
469 518
470 519 if ($existingTransaction) {
520 + $meta = $existingTransaction->meta ?: [];
521 +
471 522 // Retry vs duplicate for gateway idempotency (PaymentInstance::getIdempotencySeed):
472 523 // re-submitting a pending transaction is a duplicate (keep attempt -> gateway
473 524 // dedupes); re-submitting a FAILED one is a retry (bump attempt -> fresh seed,
474 525 // never answered with the failed attempt's cached gateway response).
475 - $attempt = (int) Arr::get($existingTransaction->meta ?: [], 'payment_attempt', 0);
526 + $attempt = (int) Arr::get($meta, 'payment_attempt', 0);
476 527 if ($existingTransaction->status === Status::PAYMENT_FAILED) {
477 528 $attempt++;
478 529 }
530 +
531 + // The gateway object prepared last time (a Paddle transaction, a PayPal
532 + // order) is kept so the gateway can reuse it instead of creating another.
479 533 if ($attempt) {
480 - $transactionData['meta'] = ['payment_attempt' => $attempt];
534 + $meta['payment_attempt'] = $attempt;
481 535 }
536 + $transactionData['meta'] = $meta;
482 537
483 538 $existingTransaction->fill($transactionData);
484 539 $existingTransaction->save();
485 540 $this->transactionModel = $existingTransaction;
@@ -597,9 +652,16 @@
597 652
598 653 $discountTotal = (int)Arr::get($cartItem, 'manual_discount', 0) + (int)Arr::get($cartItem, 'coupon_discount', 0);
599 654 $shippingCharge = (int)Arr::get($cartItem, 'shipping_charge', 0);
600 655
601 - $subtotal = (int) Arr::get($cartItem, 'subtotal', $unitPrice * $quantity);
656 + $rawSubtotal = Arr::get($cartItem, 'subtotal', $unitPrice * $quantity);
657 + if (!$this->isSafeAmount($rawSubtotal)) {
658 + $this->validationError = $this->unsafeAmountError();
659 +
660 + return;
661 + }
662 +
663 + $subtotal = (int) $rawSubtotal;
602 664 $args = Arr::get($cartItem, 'other_info', []);
603 665 $paymentType = Arr::get($args, 'payment_type', 'default');
604 666
605 667 $postTitle = Arr::get($cartItem, 'product_title', '');
@@ -906,9 +968,9 @@
906 968 'line_meta' => Arr::get($item, 'line_meta', []),
907 969 'signup_fee' => $signupFee,
908 970 'signup_fee_tax' => $signupFeeTax,
909 971 'first_iteration_tax' => $firstIterationTax,
910 - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
972 + 'recurring_discount' => $recurringDiscountAmount,
911 973 'total_discount' => $discountTotal
912 974 ]);
913 975
914 976 // removable upon discussion
@@ -932,16 +994,53 @@
932 994 'variation_type' => Arr::get($item, 'other_info.variation_type', '')
933 995 ]
934 996 ];
935 997
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;
998 + $subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
999 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1000 +
1001 + $collectionMethod = apply_filters('fluent_cart/subscription_collection_method_' . $paymentMethod, $this->determineCollectionMethod());
1002 +
1003 + // A filter can hand back anything, but `system` only means something on a
1004 + // gateway that can charge a saved payment method.
1005 + $subscriptionData['collection_method'] = SubscriptionManagementMode::sanitizeCollectionMethod(
1006 + $collectionMethod,
1007 + GatewayManager::getInstance()->get($paymentMethod)
1008 + );
1009 +
1010 + // Stamp store-managed origin durably on the subscription. Gateways consult
1011 + // the stamp (not the current store setting) before converting a manual
1012 + // subscription to automatic, so switching the mode back to gateway-managed
1013 + // later never flips subscriptions born under store-managed.
1014 + if (in_array($subscriptionData['collection_method'], ['manual', 'system'], true) && SubscriptionManagementMode::isStoreManaged()) {
1015 + $subscriptionConfig = Arr::get($subscriptionData, 'config', []);
1016 + $subscriptionConfig[SubscriptionManagementMode::CONFIG_KEY] = SubscriptionManagementMode::STORE_MANAGED;
1017 + $subscriptionData['config'] = $subscriptionConfig;
939 1018 }
940 1019
941 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
1020 + $this->subscriptionData = $subscriptionData;
942 1021 }
943 1022
1023 + private function determineCollectionMethod(): string
1024 + {
1025 + if (SubscriptionManagementMode::isStoreManaged()) {
1026 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1027 +
1028 + return SubscriptionManagementMode::resolveCollectionMethodFor(
1029 + GatewayManager::getInstance()->get($paymentMethod)
1030 + );
1031 + }
1032 +
1033 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1034 + $gateway = GatewayManager::getInstance()->get($paymentMethod);
1035 +
1036 + if ($gateway && $gateway->has('subscriptions')) {
1037 + return 'automatic';
1038 + }
1039 +
1040 + return 'manual';
1041 + }
1042 +
944 1043 private function prepareOrderData()
945 1044 {
946 1045 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
947 1046 return $item['fulfillment_type'] === 'physical';
@@ -1042,8 +1141,36 @@
1042 1141 + $estimatedTaxTotal
1043 1142 + $estimatedShippingTax;
1044 1143
1045 1144 $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0;
1145 +
1146 + /**
1147 + * Filter the prepared order data before it is used for order creation.
1148 + *
1149 + * This runs after FluentCart calculates totals, so plugins can adjust
1150 + * currency, rate, totals, config, mode, or any other order field before
1151 + * the order model, transaction, and subscription are derived from it.
1152 + *
1153 + * @param array $orderData Prepared order data array.
1154 + * @param array $context {
1155 + * Additional context for the filter.
1156 + *
1157 + * @type array $items Formatted order items with prices and quantities.
1158 + * @type array $args Checkout arguments: customer data, payment method,
1159 + * shipping, tax, coupons, fees, and IP data.
1160 + * }
1161 + */
1162 + $orderData = apply_filters('fluent_cart/checkout/order_data', $orderData, [
1163 + 'items' => $this->formattedIOrderItems,
1164 + 'args' => $this->args,
1165 + ]);
1166 +
1167 + if (!$this->isSafeAmount(Arr::get($orderData, 'total_amount', 0))) {
1168 + $this->validationError = $this->unsafeAmountError();
1169 +
1170 + return;
1171 + }
1172 +
1046 1173 $this->orderData = $orderData;
1047 1174 }
1048 1175
1049 1176 private function syncFeeItems()
@@ -1168,8 +1295,9 @@
1168 1295 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1169 1296 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1170 1297 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1171 1298 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1299 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1172 1300
1173 1301 // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1174 1302 $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1175 1303 $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
@@ -1209,10 +1337,13 @@
1209 1337 }
1210 1338 } else {
1211 1339 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1212 1340
1213 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1214 - $recurringAmount -= $totalDiscount; // as now discount applied on recurring amount
1341 + // A recurring coupon discounts every cycle, so the per-cycle price itself
1342 + // is lower — the first cycle is not cheaper than the ones after it and
1343 + // must not be expressed as a trial.
1344 + if ($recurringDiscount > 0) {
1345 + $recurringAmount -= $recurringDiscount;
1215 1346 }
1216 1347
1217 1348 if ($firstCycleCost < $recurringAmount) {
1218 1349 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);