| @@ -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 | |
| @@ -199,8 +201,9 @@ | ||
| 199 | 201 | $subscriptionData['customer_id'] = $customerId; |
| 200 | 202 | $subscriptionData['parent_order_id'] = $this->orderModel->id; |
| 201 | 203 | |
| 202 | 204 | $this->subscriptionModel = Subscription::query()->create($subscriptionData); |
| 205 | + $this->syncInitialCycleCounting(); | |
| 203 | 206 | } |
| 204 | 207 | |
| 205 | 208 | // Let's create the transaction |
| 206 | 209 | $transactionData = [ |
| @@ -243,8 +246,18 @@ | ||
| 243 | 246 | $cart->user_id = $customer->user_id; |
| 244 | 247 | } |
| 245 | 248 | |
| 246 | 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 | + | |
| 247 | 260 | $actions = Arr::get($cart->checkout_data, '__after_draft_created_actions__', []); |
| 248 | 261 | if ($actions) { |
| 249 | 262 | foreach ($actions as $actionName) { |
| 250 | 263 | $actionName = (string)$actionName; |
| @@ -305,8 +318,15 @@ | ||
| 305 | 318 | |
| 306 | 319 | if ($isLocked && $taxEnabled !== 'yes') { |
| 307 | 320 | // Locked orders skip full item sync, but fee items must stay in sync with fee_total |
| 308 | 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 | + } | |
| 309 | 329 | } |
| 310 | 330 | |
| 311 | 331 | if (!$isLocked || $taxEnabled === 'yes') { |
| 312 | 332 | // Let's create the order items |
| @@ -423,8 +443,9 @@ | ||
| 423 | 443 | $this->subscriptionModel = $existingSubscription; |
| 424 | 444 | } else { |
| 425 | 445 | $this->subscriptionModel = Subscription::query()->create($subscriptionData); |
| 426 | 446 | } |
| 447 | + $this->syncInitialCycleCounting(); | |
| 427 | 448 | } |
| 428 | 449 | } else { |
| 429 | 450 | Subscription::query()->where('parent_order_id', $this->orderModel->id)->delete(); |
| 430 | 451 | } |
| @@ -465,8 +486,26 @@ | ||
| 465 | 486 | ->where('order_id', $this->orderModel->id) |
| 466 | 487 | ->first(); |
| 467 | 488 | |
| 468 | 489 | if ($existingTransaction) { |
| 490 | + $meta = $existingTransaction->meta ?: []; | |
| 491 | + | |
| 492 | + // Retry vs duplicate for gateway idempotency (PaymentInstance::getIdempotencySeed): | |
| 493 | + // re-submitting a pending transaction is a duplicate (keep attempt -> gateway | |
| 494 | + // dedupes); re-submitting a FAILED one is a retry (bump attempt -> fresh seed, | |
| 495 | + // never answered with the failed attempt's cached gateway response). | |
| 496 | + $attempt = (int) Arr::get($meta, 'payment_attempt', 0); | |
| 497 | + if ($existingTransaction->status === Status::PAYMENT_FAILED) { | |
| 498 | + $attempt++; | |
| 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. | |
| 503 | + if ($attempt) { | |
| 504 | + $meta['payment_attempt'] = $attempt; | |
| 505 | + } | |
| 506 | + $transactionData['meta'] = $meta; | |
| 507 | + | |
| 469 | 508 | $existingTransaction->fill($transactionData); |
| 470 | 509 | $existingTransaction->save(); |
| 471 | 510 | $this->transactionModel = $existingTransaction; |
| 472 | 511 | } else { |
| @@ -831,9 +870,9 @@ | ||
| 831 | 870 | |
| 832 | 871 | $item = reset($subscriptionItems); |
| 833 | 872 | $signupFeeItem = reset($signupFeeItems) ?? []; |
| 834 | 873 | $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0); |
| 835 | - $taxBehavior = Arr::get($this->args, 'tax_behavior', 0); | |
| 874 | + $taxBehavior = (int)Arr::get($this->args, 'tax_behavior', 0); | |
| 836 | 875 | |
| 837 | 876 | $recurringTotal = (int)$item['subtotal']; |
| 838 | 877 | $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0); |
| 839 | 878 | |
| @@ -842,13 +881,20 @@ | ||
| 842 | 881 | if ($recurringDiscountAmount && $recurringDiscountAmount > 0) { |
| 843 | 882 | $recurringTotal -= $recurringDiscountAmount; |
| 844 | 883 | } |
| 845 | 884 | |
| 846 | - // Add shipping charges to recurring total for physical subscription products | |
| 885 | + // Add shipping charges (and tax) to recurring total for physical subscription products | |
| 847 | 886 | $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0); |
| 848 | 887 | $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical'; |
| 849 | 888 | if ($isPhysicalProduct && $shippingCharge > 0) { |
| 850 | 889 | $recurringTotal += $shippingCharge; |
| 890 | + $shippingTax = (int)Arr::get($this->args, 'shipping_tax', 0); | |
| 891 | + if ($shippingTax > 0) { | |
| 892 | + $storeTaxBehavior = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior); | |
| 893 | + if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1)) { | |
| 894 | + $recurringTotal += $shippingTax; | |
| 895 | + } | |
| 896 | + } | |
| 851 | 897 | } |
| 852 | 898 | |
| 853 | 899 | $itemInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false); |
| 854 | 900 | if ($taxBehavior === 1 || ($taxBehavior === 3 && !$itemInclusive)) { |
| @@ -859,13 +905,19 @@ | ||
| 859 | 905 | |
| 860 | 906 | // in case of discount applied 'tax_amount' is different than recurring tax , |
| 861 | 907 | $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax; |
| 862 | 908 | |
| 863 | - | |
| 864 | 909 | // Calculate recurring amount including shipping for physical products |
| 865 | 910 | $recurringAmount = (int)$item['subtotal']; |
| 866 | 911 | if ($isPhysicalProduct && $shippingCharge > 0) { |
| 867 | 912 | $recurringAmount += $shippingCharge; |
| 913 | + $shippingTaxForFirst = (int)Arr::get($this->args, 'shipping_tax', 0); | |
| 914 | + if ($shippingTaxForFirst > 0) { | |
| 915 | + $storeTaxBehaviorForFirst = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior); | |
| 916 | + if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehaviorForFirst === 1)) { | |
| 917 | + $firstIterationTax += $shippingTaxForFirst; | |
| 918 | + } | |
| 919 | + } | |
| 868 | 920 | } |
| 869 | 921 | |
| 870 | 922 | $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0) + $this->prorateCreditTotal + $this->upgradeDiscountTotal; |
| 871 | 923 | $subscriptionPricing = $this->convertToSubscriptionFormat([ |
| @@ -879,9 +931,9 @@ | ||
| 879 | 931 | 'line_meta' => Arr::get($item, 'line_meta', []), |
| 880 | 932 | 'signup_fee' => $signupFee, |
| 881 | 933 | 'signup_fee_tax' => $signupFeeTax, |
| 882 | 934 | 'first_iteration_tax' => $firstIterationTax, |
| 883 | - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'), | |
| 935 | + 'recurring_discount' => $recurringDiscountAmount, | |
| 884 | 936 | 'total_discount' => $discountTotal |
| 885 | 937 | ]); |
| 886 | 938 | |
| 887 | 939 | // removable upon discussion |
| @@ -905,16 +957,53 @@ | ||
| 905 | 957 | 'variation_type' => Arr::get($item, 'other_info.variation_type', '') |
| 906 | 958 | ] |
| 907 | 959 | ]; |
| 908 | 960 | |
| 909 | - // if recurring coupon is applied, we need to subtract the total discount from the recurring total | |
| 910 | - if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') { | |
| 911 | - $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; | |
| 912 | 981 | } |
| 913 | 982 | |
| 914 | - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem); | |
| 983 | + $this->subscriptionData = $subscriptionData; | |
| 915 | 984 | } |
| 916 | 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 | + | |
| 917 | 1006 | private function prepareOrderData() |
| 918 | 1007 | { |
| 919 | 1008 | $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) { |
| 920 | 1009 | return $item['fulfillment_type'] === 'physical'; |
| @@ -1015,8 +1104,30 @@ | ||
| 1015 | 1104 | + $estimatedTaxTotal |
| 1016 | 1105 | + $estimatedShippingTax; |
| 1017 | 1106 | |
| 1018 | 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 | + | |
| 1019 | 1130 | $this->orderData = $orderData; |
| 1020 | 1131 | } |
| 1021 | 1132 | |
| 1022 | 1133 | private function syncFeeItems() |
| @@ -1141,8 +1252,9 @@ | ||
| 1141 | 1252 | $signupFee = (int)($inputData['signup_fee'] ?? 0); |
| 1142 | 1253 | $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0); |
| 1143 | 1254 | $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0); |
| 1144 | 1255 | $totalDiscount = (int)($inputData['total_discount'] ?? 0); |
| 1256 | + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0); | |
| 1145 | 1257 | |
| 1146 | 1258 | // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts) |
| 1147 | 1259 | $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0); |
| 1148 | 1260 | $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false); |
| @@ -1182,10 +1294,13 @@ | ||
| 1182 | 1294 | } |
| 1183 | 1295 | } else { |
| 1184 | 1296 | $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount; |
| 1185 | 1297 | |
| 1186 | - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') { | |
| 1187 | - $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; | |
| 1188 | 1303 | } |
| 1189 | 1304 | |
| 1190 | 1305 | if ($firstCycleCost < $recurringAmount) { |
| 1191 | 1306 | $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval); |
| @@ -1193,9 +1308,11 @@ | ||
| 1193 | 1308 | $result['trial_days'] = $adjustedTrialDays; |
| 1194 | 1309 | $result['is_trial_days_simulated'] = 'yes'; |
| 1195 | 1310 | $result['signup_fee'] = $firstCycleCost; |
| 1196 | 1311 | $result['manage_setup_fee'] = 'yes'; |
| 1197 | - $result['times'] = $times > 0 ? $times - 1 : 0; | |
| 1312 | + // bill_times stays the full installment count. The simulated trial cycle IS the | |
| 1313 | + // first installment (charged as one-time payment / free when 100% discounted); | |
| 1314 | + // gateways derive the remaining remote cycles from is_trial_days_simulated. | |
| 1198 | 1315 | } else if ($firstCycleCost > $recurringAmount) { |
| 1199 | 1316 | $result['trial_days'] = 0; |
| 1200 | 1317 | $result['signup_fee'] = $firstCycleCost - $recurringAmount; |
| 1201 | 1318 | $result['manage_setup_fee'] = 'yes'; |
| @@ -1237,6 +1354,57 @@ | ||
| 1237 | 1354 | 'recurring_amount' => $recurringAmount, |
| 1238 | 1355 | 'recurring_tax_total' => $recurringTax, |
| 1239 | 1356 | 'signup_fee' => $result['signup_fee'] ?? 0, |
| 1240 | 1357 | ]; |
| 1358 | + } | |
| 1359 | + | |
| 1360 | + /** | |
| 1361 | + * bill_count is derived from counting total > 0 CHARGE transactions linked to | |
| 1362 | + * the subscription (see syncSubscriptionStates / getRequiredBillTimes), which | |
| 1363 | + * can't tell "this was a billed cycle" from "this was something else | |
| 1364 | + * charged alongside it." Two corrections needed only at initial checkout — | |
| 1365 | + * is_trial_days_simulated alone can't be used at runtime because | |
| 1366 | + * payment-method switching also sets that flag: | |
| 1367 | + * | |
| 1368 | + * - Simulated trial, $0 first cycle: consumes a cycle but produces no | |
| 1369 | + * total > 0 transaction — add billed_cycles_offset so it still counts. | |
| 1370 | + * - Real trial with a signup fee: the initial charge is the signup fee only | |
| 1371 | + * (the recurring item isn't billed yet), but it IS a total > 0 transaction | |
| 1372 | + * linked to the subscription — mark billed_cycles_deduction so it does | |
| 1373 | + * NOT count as a cycle. | |
| 1374 | + */ | |
| 1375 | + private function syncInitialCycleCounting() | |
| 1376 | + { | |
| 1377 | + if (!$this->subscriptionModel) { | |
| 1378 | + return; | |
| 1379 | + } | |
| 1380 | + | |
| 1381 | + $isSimulated = Arr::get($this->subscriptionData, 'config.is_trial_days_simulated', 'no') === 'yes'; | |
| 1382 | + $trialDays = (int)Arr::get($this->subscriptionData, 'trial_days', 0); | |
| 1383 | + $billTimes = (int)$this->subscriptionModel->bill_times; | |
| 1384 | + $orderTotal = (int)$this->orderModel->total_amount; | |
| 1385 | + | |
| 1386 | + // signup_fee <= 0 (not just == 0): prorate/upgrade credit can push the | |
| 1387 | + // first cycle cost negative — still a free first cycle for counting | |
| 1388 | + $isFreeFirstCycle = $isSimulated | |
| 1389 | + && $billTimes > 0 | |
| 1390 | + && (int)$this->subscriptionModel->signup_fee <= 0 | |
| 1391 | + && !$orderTotal; | |
| 1392 | + | |
| 1393 | + if ($isFreeFirstCycle) { | |
| 1394 | + $this->subscriptionModel->updateMeta('billed_cycles_offset', 1); | |
| 1395 | + } else { | |
| 1396 | + $this->subscriptionModel->deleteMeta('billed_cycles_offset'); | |
| 1397 | + } | |
| 1398 | + | |
| 1399 | + $isRealTrialWithCharge = !$isSimulated | |
| 1400 | + && $trialDays > 0 | |
| 1401 | + && $billTimes > 0 | |
| 1402 | + && $orderTotal > 0; | |
| 1403 | + | |
| 1404 | + if ($isRealTrialWithCharge) { | |
| 1405 | + $this->subscriptionModel->updateMeta('billed_cycles_deduction', 1); | |
| 1406 | + } else { | |
| 1407 | + $this->subscriptionModel->deleteMeta('billed_cycles_deduction'); | |
| 1408 | + } | |
| 1241 | 1409 | } |
| 1242 | 1410 | } |