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