| @@ -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); |