PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.8.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.8.0
1.8.0 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 All 52 releases
← All changes | app/Helpers/CheckoutProcessor.php +194 -10 1.5.4 → 1.8.0 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,12 +968,28 @@
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
976 + // A shipping pricing rule discounts this order's postage only; renewals bill the full charge.
977 + $shippingDiscount = $isPhysicalProduct ? (int)Arr::get($this->args, 'shipping_discount', 0) : 0;
978 + if ($shippingDiscount > 0) {
979 + $renewalTotal = $recurringTotal + $shippingDiscount;
980 + // A fully discounted charge left its shipping tax out above as well.
981 + if ($shippingCharge <= 0) {
982 + $storeTaxBehavior = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
983 + if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1)) {
984 + $renewalTotal += (int)Arr::get($this->args, 'shipping_tax', 0);
985 + }
986 + }
987 +
988 + $subscriptionPricing = $this->billRenewalsFullShipping($subscriptionPricing, $recurringTotal, $renewalTotal, $shippingDiscount);
989 + $recurringTotal = $renewalTotal;
990 + }
991 +
914 992 // removable upon discussion
915 993 $subscriptionItem = [
916 994 'product_id' => $item['post_id'],
917 995 'current_payment_method' => Arr::get($this->orderData, 'payment_method'),
@@ -932,16 +1010,89 @@
932 1010 'variation_type' => Arr::get($item, 'other_info.variation_type', '')
933 1011 ]
934 1012 ];
935 1013
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;
1014 + $subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
1015 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1016 +
1017 + $collectionMethod = apply_filters('fluent_cart/subscription_collection_method_' . $paymentMethod, $this->determineCollectionMethod());
1018 +
1019 + // A filter can hand back anything, but `system` only means something on a
1020 + // gateway that can charge a saved payment method.
1021 + $subscriptionData['collection_method'] = SubscriptionManagementMode::sanitizeCollectionMethod(
1022 + $collectionMethod,
1023 + GatewayManager::getInstance()->get($paymentMethod)
1024 + );
1025 +
1026 + // Stamp store-managed origin durably on the subscription. Gateways consult
1027 + // the stamp (not the current store setting) before converting a manual
1028 + // subscription to automatic, so switching the mode back to gateway-managed
1029 + // later never flips subscriptions born under store-managed.
1030 + if (in_array($subscriptionData['collection_method'], ['manual', 'system'], true) && SubscriptionManagementMode::isStoreManaged()) {
1031 + $subscriptionConfig = Arr::get($subscriptionData, 'config', []);
1032 + $subscriptionConfig[SubscriptionManagementMode::CONFIG_KEY] = SubscriptionManagementMode::STORE_MANAGED;
1033 + $subscriptionData['config'] = $subscriptionConfig;
939 1034 }
940 1035
941 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
1036 + $this->subscriptionData = $subscriptionData;
942 1037 }
943 1038
1039 + /**
1040 + * Raise the per-cycle price to the full postage while keeping the first payment.
1041 + *
1042 + * A gateway takes the signup fee alone during a trial, otherwise one cycle plus the
1043 + * signup fee. So without a trial the fee absorbs the difference, or the first cycle
1044 + * becomes a simulated trial, the same way a first-cycle coupon is expressed.
1045 + *
1046 + * @param array $pricing From convertToSubscriptionFormat(), built on the discounted postage.
1047 + * @param int $firstCycleTotal Recurring total on the discounted postage, in cents.
1048 + * @param int $renewalTotal Recurring total on the full postage, in cents.
1049 + * @param int $shippingDiscount In cents.
1050 + * @return array
1051 + */
1052 + private function billRenewalsFullShipping(array $pricing, $firstCycleTotal, $renewalTotal, $shippingDiscount)
1053 + {
1054 + $pricing['recurring_amount'] = (int)$pricing['recurring_amount'] + $shippingDiscount;
1055 +
1056 + if ((int)$pricing['trial_days'] > 0) {
1057 + return $pricing;
1058 + }
1059 +
1060 + $extra = $renewalTotal - $firstCycleTotal;
1061 + $signupFee = (int)$pricing['signup_fee'];
1062 +
1063 + if ($signupFee >= $extra) {
1064 + $pricing['signup_fee'] = $signupFee - $extra;
1065 + return $pricing;
1066 + }
1067 +
1068 + $pricing['trial_days'] = Helper::calculateAdjustedTrialDaysForInterval(0, $pricing['billing_interval']);
1069 + $pricing['is_trial_days_simulated'] = 'yes';
1070 + $pricing['signup_fee'] = $firstCycleTotal + $signupFee;
1071 +
1072 + return $pricing;
1073 + }
1074 +
1075 + private function determineCollectionMethod(): string
1076 + {
1077 + if (SubscriptionManagementMode::isStoreManaged()) {
1078 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1079 +
1080 + return SubscriptionManagementMode::resolveCollectionMethodFor(
1081 + GatewayManager::getInstance()->get($paymentMethod)
1082 + );
1083 + }
1084 +
1085 + $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1086 + $gateway = GatewayManager::getInstance()->get($paymentMethod);
1087 +
1088 + if ($gateway && $gateway->has('subscriptions')) {
1089 + return 'automatic';
1090 + }
1091 +
1092 + return 'manual';
1093 + }
1094 +
944 1095 private function prepareOrderData()
945 1096 {
946 1097 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
947 1098 return $item['fulfillment_type'] === 'physical';
@@ -1008,8 +1159,9 @@
1008 1159 'user_tz' => Arr::get($this->args, 'user_tz', ''),
1009 1160 'create_account_after_paid' => Arr::get($this->args, 'create_account_after_paid', 'no'),
1010 1161 'shipping_method_id' => Arr::get($this->args, 'shipping_method_id', 0),
1011 1162 'shipping_method_title' => Arr::get($this->args, 'shipping_method_title', ''),
1163 + 'shipping_discount' => (int) Arr::get($this->args, 'shipping_discount', 0),
1012 1164 'prorate_credit' => $this->prorateCreditTotal,
1013 1165 'upgrade_discount' => $this->upgradeDiscountTotal,
1014 1166 ],
1015 1167 ];
@@ -1042,8 +1194,36 @@
1042 1194 + $estimatedTaxTotal
1043 1195 + $estimatedShippingTax;
1044 1196
1045 1197 $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0;
1198 +
1199 + /**
1200 + * Filter the prepared order data before it is used for order creation.
1201 + *
1202 + * This runs after FluentCart calculates totals, so plugins can adjust
1203 + * currency, rate, totals, config, mode, or any other order field before
1204 + * the order model, transaction, and subscription are derived from it.
1205 + *
1206 + * @param array $orderData Prepared order data array.
1207 + * @param array $context {
1208 + * Additional context for the filter.
1209 + *
1210 + * @type array $items Formatted order items with prices and quantities.
1211 + * @type array $args Checkout arguments: customer data, payment method,
1212 + * shipping, tax, coupons, fees, and IP data.
1213 + * }
1214 + */
1215 + $orderData = apply_filters('fluent_cart/checkout/order_data', $orderData, [
1216 + 'items' => $this->formattedIOrderItems,
1217 + 'args' => $this->args,
1218 + ]);
1219 +
1220 + if (!$this->isSafeAmount(Arr::get($orderData, 'total_amount', 0))) {
1221 + $this->validationError = $this->unsafeAmountError();
1222 +
1223 + return;
1224 + }
1225 +
1046 1226 $this->orderData = $orderData;
1047 1227 }
1048 1228
1049 1229 private function syncFeeItems()
@@ -1168,8 +1348,9 @@
1168 1348 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1169 1349 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1170 1350 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1171 1351 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1352 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1172 1353
1173 1354 // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1174 1355 $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1175 1356 $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
@@ -1209,10 +1390,13 @@
1209 1390 }
1210 1391 } else {
1211 1392 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1212 1393
1213 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1214 - $recurringAmount -= $totalDiscount; // as now discount applied on recurring amount
1394 + // A recurring coupon discounts every cycle, so the per-cycle price itself
1395 + // is lower — the first cycle is not cheaper than the ones after it and
1396 + // must not be expressed as a trial.
1397 + if ($recurringDiscount > 0) {
1398 + $recurringAmount -= $recurringDiscount;
1215 1399 }
1216 1400
1217 1401 if ($firstCycleCost < $recurringAmount) {
1218 1402 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);