| @@ -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 | |
| @@ -199,8 +231,9 @@ | ||
| 199 | 231 | $subscriptionData['customer_id'] = $customerId; |
| 200 | 232 | $subscriptionData['parent_order_id'] = $this->orderModel->id; |
| 201 | 233 | |
| 202 | 234 | $this->subscriptionModel = Subscription::query()->create($subscriptionData); |
| 235 | + $this->syncInitialCycleCounting(); | |
| 203 | 236 | } |
| 204 | 237 | |
| 205 | 238 | // Let's create the transaction |
| 206 | 239 | $transactionData = [ |
| @@ -243,8 +276,18 @@ | ||
| 243 | 276 | $cart->user_id = $customer->user_id; |
| 244 | 277 | } |
| 245 | 278 | |
| 246 | 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 | + | |
| 247 | 290 | $actions = Arr::get($cart->checkout_data, '__after_draft_created_actions__', []); |
| 248 | 291 | if ($actions) { |
| 249 | 292 | foreach ($actions as $actionName) { |
| 250 | 293 | $actionName = (string)$actionName; |
| @@ -305,8 +348,15 @@ | ||
| 305 | 348 | |
| 306 | 349 | if ($isLocked && $taxEnabled !== 'yes') { |
| 307 | 350 | // Locked orders skip full item sync, but fee items must stay in sync with fee_total |
| 308 | 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 | + } | |
| 309 | 359 | } |
| 310 | 360 | |
| 311 | 361 | if (!$isLocked || $taxEnabled === 'yes') { |
| 312 | 362 | // Let's create the order items |
| @@ -423,8 +473,9 @@ | ||
| 423 | 473 | $this->subscriptionModel = $existingSubscription; |
| 424 | 474 | } else { |
| 425 | 475 | $this->subscriptionModel = Subscription::query()->create($subscriptionData); |
| 426 | 476 | } |
| 477 | + $this->syncInitialCycleCounting(); | |
| 427 | 478 | } |
| 428 | 479 | } else { |
| 429 | 480 | Subscription::query()->where('parent_order_id', $this->orderModel->id)->delete(); |
| 430 | 481 | } |
| @@ -465,8 +516,26 @@ | ||
| 465 | 516 | ->where('order_id', $this->orderModel->id) |
| 466 | 517 | ->first(); |
| 467 | 518 | |
| 468 | 519 | if ($existingTransaction) { |
| 520 | + $meta = $existingTransaction->meta ?: []; | |
| 521 | + | |
| 522 | + // Retry vs duplicate for gateway idempotency (PaymentInstance::getIdempotencySeed): | |
| 523 | + // re-submitting a pending transaction is a duplicate (keep attempt -> gateway | |
| 524 | + // dedupes); re-submitting a FAILED one is a retry (bump attempt -> fresh seed, | |
| 525 | + // never answered with the failed attempt's cached gateway response). | |
| 526 | + $attempt = (int) Arr::get($meta, 'payment_attempt', 0); | |
| 527 | + if ($existingTransaction->status === Status::PAYMENT_FAILED) { | |
| 528 | + $attempt++; | |
| 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. | |
| 533 | + if ($attempt) { | |
| 534 | + $meta['payment_attempt'] = $attempt; | |
| 535 | + } | |
| 536 | + $transactionData['meta'] = $meta; | |
| 537 | + | |
| 469 | 538 | $existingTransaction->fill($transactionData); |
| 470 | 539 | $existingTransaction->save(); |
| 471 | 540 | $this->transactionModel = $existingTransaction; |
| 472 | 541 | } else { |
| @@ -527,12 +596,22 @@ | ||
| 527 | 596 | $customerId = $order->customer_id; |
| 528 | 597 | } |
| 529 | 598 | |
| 530 | 599 | if (!empty($couponCodes)) { |
| 531 | - $coupons = Coupon::query()->whereIn('code', $couponCodes)->get() | |
| 532 | - ->keyBy('code') | |
| 533 | - ->toArray(); | |
| 600 | + $coupons = Coupon::query()->whereIn('code', $couponCodes)->get(); | |
| 534 | 601 | |
| 602 | + /* | |
| 603 | + * Resolve virtual (un-persisted) coupons so an AppliedCoupon row is written for | |
| 604 | + * them too — the row stores coupon_id = null (the column is nullable) with the | |
| 605 | + * code and computed discount, so it shows in the order's Coupons section like any | |
| 606 | + * coupon. See DiscountService::applyCouponCodes() for the same filter. | |
| 607 | + */ | |
| 608 | + $coupons = apply_filters('fluent_cart/coupon/resolve_coupons', $coupons, $couponCodes, [ | |
| 609 | + 'order' => $this->orderModel, | |
| 610 | + ]); | |
| 611 | + | |
| 612 | + $coupons = $coupons->keyBy('code')->toArray(); | |
| 613 | + | |
| 535 | 614 | foreach ($coupons as $code => &$coupon) { |
| 536 | 615 | $coupon['coupon_id'] = $appliedCoupons[$code]['id']; |
| 537 | 616 | $coupon['amount'] = $appliedCoupons[$code]['discount']; |
| 538 | 617 | $coupon['customer_id'] = $customerId; |
| @@ -573,9 +652,16 @@ | ||
| 573 | 652 | |
| 574 | 653 | $discountTotal = (int)Arr::get($cartItem, 'manual_discount', 0) + (int)Arr::get($cartItem, 'coupon_discount', 0); |
| 575 | 654 | $shippingCharge = (int)Arr::get($cartItem, 'shipping_charge', 0); |
| 576 | 655 | |
| 577 | - $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; | |
| 578 | 664 | $args = Arr::get($cartItem, 'other_info', []); |
| 579 | 665 | $paymentType = Arr::get($args, 'payment_type', 'default'); |
| 580 | 666 | |
| 581 | 667 | $postTitle = Arr::get($cartItem, 'product_title', ''); |
| @@ -618,8 +704,23 @@ | ||
| 618 | 704 | $args['package_weight_unit'] = Arr::get($package, 'weight_unit', 'kg'); |
| 619 | 705 | } |
| 620 | 706 | } |
| 621 | 707 | |
| 708 | + // Carry the attribute snapshot onto the order item. It normally | |
| 709 | + // arrives via the cart item's other_info; rebuild it here as a | |
| 710 | + // fallback for items that reach checkout without one (instant | |
| 711 | + // checkout, legacy carts). | |
| 712 | + if (!isset($args['item_attributes'])) { | |
| 713 | + $args['item_attributes'] = AttributeHelper::getProductItemAttributes( | |
| 714 | + Arr::get($cartItem, 'object_id', 0), | |
| 715 | + Arr::get($cartItem, 'post_id', 0) | |
| 716 | + ); | |
| 717 | + } | |
| 718 | + | |
| 719 | + if (!isset($args['variation_type'])) { | |
| 720 | + $args['variation_type'] = (string) Arr::get($cartItem, 'variation_type', ''); | |
| 721 | + } | |
| 722 | + | |
| 622 | 723 | $item = [ |
| 623 | 724 | 'payment_type' => $paymentType, |
| 624 | 725 | 'post_id' => Arr::get($cartItem, 'post_id'), |
| 625 | 726 | 'object_id' => Arr::get($cartItem, 'object_id'), |
| @@ -806,9 +907,9 @@ | ||
| 806 | 907 | |
| 807 | 908 | $item = reset($subscriptionItems); |
| 808 | 909 | $signupFeeItem = reset($signupFeeItems) ?? []; |
| 809 | 910 | $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0); |
| 810 | - $taxBehavior = Arr::get($this->args, 'tax_behavior', 0); | |
| 911 | + $taxBehavior = (int)Arr::get($this->args, 'tax_behavior', 0); | |
| 811 | 912 | |
| 812 | 913 | $recurringTotal = (int)$item['subtotal']; |
| 813 | 914 | $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0); |
| 814 | 915 | |
| @@ -817,13 +918,20 @@ | ||
| 817 | 918 | if ($recurringDiscountAmount && $recurringDiscountAmount > 0) { |
| 818 | 919 | $recurringTotal -= $recurringDiscountAmount; |
| 819 | 920 | } |
| 820 | 921 | |
| 821 | - // Add shipping charges to recurring total for physical subscription products | |
| 922 | + // Add shipping charges (and tax) to recurring total for physical subscription products | |
| 822 | 923 | $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0); |
| 823 | 924 | $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical'; |
| 824 | 925 | if ($isPhysicalProduct && $shippingCharge > 0) { |
| 825 | 926 | $recurringTotal += $shippingCharge; |
| 927 | + $shippingTax = (int)Arr::get($this->args, 'shipping_tax', 0); | |
| 928 | + if ($shippingTax > 0) { | |
| 929 | + $storeTaxBehavior = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior); | |
| 930 | + if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1)) { | |
| 931 | + $recurringTotal += $shippingTax; | |
| 932 | + } | |
| 933 | + } | |
| 826 | 934 | } |
| 827 | 935 | |
| 828 | 936 | $itemInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false); |
| 829 | 937 | if ($taxBehavior === 1 || ($taxBehavior === 3 && !$itemInclusive)) { |
| @@ -834,13 +942,19 @@ | ||
| 834 | 942 | |
| 835 | 943 | // in case of discount applied 'tax_amount' is different than recurring tax , |
| 836 | 944 | $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax; |
| 837 | 945 | |
| 838 | - | |
| 839 | 946 | // Calculate recurring amount including shipping for physical products |
| 840 | 947 | $recurringAmount = (int)$item['subtotal']; |
| 841 | 948 | if ($isPhysicalProduct && $shippingCharge > 0) { |
| 842 | 949 | $recurringAmount += $shippingCharge; |
| 950 | + $shippingTaxForFirst = (int)Arr::get($this->args, 'shipping_tax', 0); | |
| 951 | + if ($shippingTaxForFirst > 0) { | |
| 952 | + $storeTaxBehaviorForFirst = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior); | |
| 953 | + if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehaviorForFirst === 1)) { | |
| 954 | + $firstIterationTax += $shippingTaxForFirst; | |
| 955 | + } | |
| 956 | + } | |
| 843 | 957 | } |
| 844 | 958 | |
| 845 | 959 | $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0) + $this->prorateCreditTotal + $this->upgradeDiscountTotal; |
| 846 | 960 | $subscriptionPricing = $this->convertToSubscriptionFormat([ |
| @@ -854,9 +968,9 @@ | ||
| 854 | 968 | 'line_meta' => Arr::get($item, 'line_meta', []), |
| 855 | 969 | 'signup_fee' => $signupFee, |
| 856 | 970 | 'signup_fee_tax' => $signupFeeTax, |
| 857 | 971 | 'first_iteration_tax' => $firstIterationTax, |
| 858 | - 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'), | |
| 972 | + 'recurring_discount' => $recurringDiscountAmount, | |
| 859 | 973 | 'total_discount' => $discountTotal |
| 860 | 974 | ]); |
| 861 | 975 | |
| 862 | 976 | // removable upon discussion |
| @@ -872,20 +986,61 @@ | ||
| 872 | 986 | 'variation_id' => Arr::get($item, 'object_id', 0), |
| 873 | 987 | 'status' => Status::SUBSCRIPTION_PENDING, |
| 874 | 988 | 'config' => [ |
| 875 | 989 | 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'), |
| 876 | - 'currency' => $this->orderData['currency'] | |
| 990 | + 'currency' => $this->orderData['currency'], | |
| 991 | + // Snapshot the variant attribute map + variation type from the order | |
| 992 | + // item so the subscription carries the same pa_* set behind its item_name. | |
| 993 | + 'item_attributes' => Arr::get($item, 'other_info.item_attributes', []), | |
| 994 | + 'variation_type' => Arr::get($item, 'other_info.variation_type', '') | |
| 877 | 995 | ] |
| 878 | 996 | ]; |
| 879 | 997 | |
| 880 | - // if recurring coupon is applied, we need to subtract the total discount from the recurring total | |
| 881 | - if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') { | |
| 882 | - $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; | |
| 883 | 1018 | } |
| 884 | 1019 | |
| 885 | - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem); | |
| 1020 | + $this->subscriptionData = $subscriptionData; | |
| 886 | 1021 | } |
| 887 | 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 | + | |
| 888 | 1043 | private function prepareOrderData() |
| 889 | 1044 | { |
| 890 | 1045 | $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) { |
| 891 | 1046 | return $item['fulfillment_type'] === 'physical'; |
| @@ -986,8 +1141,36 @@ | ||
| 986 | 1141 | + $estimatedTaxTotal |
| 987 | 1142 | + $estimatedShippingTax; |
| 988 | 1143 | |
| 989 | 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 | + | |
| 990 | 1173 | $this->orderData = $orderData; |
| 991 | 1174 | } |
| 992 | 1175 | |
| 993 | 1176 | private function syncFeeItems() |
| @@ -1112,8 +1295,9 @@ | ||
| 1112 | 1295 | $signupFee = (int)($inputData['signup_fee'] ?? 0); |
| 1113 | 1296 | $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0); |
| 1114 | 1297 | $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0); |
| 1115 | 1298 | $totalDiscount = (int)($inputData['total_discount'] ?? 0); |
| 1299 | + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0); | |
| 1116 | 1300 | |
| 1117 | 1301 | // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts) |
| 1118 | 1302 | $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0); |
| 1119 | 1303 | $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false); |
| @@ -1153,10 +1337,13 @@ | ||
| 1153 | 1337 | } |
| 1154 | 1338 | } else { |
| 1155 | 1339 | $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount; |
| 1156 | 1340 | |
| 1157 | - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') { | |
| 1158 | - $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; | |
| 1159 | 1346 | } |
| 1160 | 1347 | |
| 1161 | 1348 | if ($firstCycleCost < $recurringAmount) { |
| 1162 | 1349 | $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval); |
| @@ -1164,9 +1351,11 @@ | ||
| 1164 | 1351 | $result['trial_days'] = $adjustedTrialDays; |
| 1165 | 1352 | $result['is_trial_days_simulated'] = 'yes'; |
| 1166 | 1353 | $result['signup_fee'] = $firstCycleCost; |
| 1167 | 1354 | $result['manage_setup_fee'] = 'yes'; |
| 1168 | - $result['times'] = $times > 0 ? $times - 1 : 0; | |
| 1355 | + // bill_times stays the full installment count. The simulated trial cycle IS the | |
| 1356 | + // first installment (charged as one-time payment / free when 100% discounted); | |
| 1357 | + // gateways derive the remaining remote cycles from is_trial_days_simulated. | |
| 1169 | 1358 | } else if ($firstCycleCost > $recurringAmount) { |
| 1170 | 1359 | $result['trial_days'] = 0; |
| 1171 | 1360 | $result['signup_fee'] = $firstCycleCost - $recurringAmount; |
| 1172 | 1361 | $result['manage_setup_fee'] = 'yes'; |
| @@ -1208,6 +1397,57 @@ | ||
| 1208 | 1397 | 'recurring_amount' => $recurringAmount, |
| 1209 | 1398 | 'recurring_tax_total' => $recurringTax, |
| 1210 | 1399 | 'signup_fee' => $result['signup_fee'] ?? 0, |
| 1211 | 1400 | ]; |
| 1401 | + } | |
| 1402 | + | |
| 1403 | + /** | |
| 1404 | + * bill_count is derived from counting total > 0 CHARGE transactions linked to | |
| 1405 | + * the subscription (see syncSubscriptionStates / getRequiredBillTimes), which | |
| 1406 | + * can't tell "this was a billed cycle" from "this was something else | |
| 1407 | + * charged alongside it." Two corrections needed only at initial checkout — | |
| 1408 | + * is_trial_days_simulated alone can't be used at runtime because | |
| 1409 | + * payment-method switching also sets that flag: | |
| 1410 | + * | |
| 1411 | + * - Simulated trial, $0 first cycle: consumes a cycle but produces no | |
| 1412 | + * total > 0 transaction — add billed_cycles_offset so it still counts. | |
| 1413 | + * - Real trial with a signup fee: the initial charge is the signup fee only | |
| 1414 | + * (the recurring item isn't billed yet), but it IS a total > 0 transaction | |
| 1415 | + * linked to the subscription — mark billed_cycles_deduction so it does | |
| 1416 | + * NOT count as a cycle. | |
| 1417 | + */ | |
| 1418 | + private function syncInitialCycleCounting() | |
| 1419 | + { | |
| 1420 | + if (!$this->subscriptionModel) { | |
| 1421 | + return; | |
| 1422 | + } | |
| 1423 | + | |
| 1424 | + $isSimulated = Arr::get($this->subscriptionData, 'config.is_trial_days_simulated', 'no') === 'yes'; | |
| 1425 | + $trialDays = (int)Arr::get($this->subscriptionData, 'trial_days', 0); | |
| 1426 | + $billTimes = (int)$this->subscriptionModel->bill_times; | |
| 1427 | + $orderTotal = (int)$this->orderModel->total_amount; | |
| 1428 | + | |
| 1429 | + // signup_fee <= 0 (not just == 0): prorate/upgrade credit can push the | |
| 1430 | + // first cycle cost negative — still a free first cycle for counting | |
| 1431 | + $isFreeFirstCycle = $isSimulated | |
| 1432 | + && $billTimes > 0 | |
| 1433 | + && (int)$this->subscriptionModel->signup_fee <= 0 | |
| 1434 | + && !$orderTotal; | |
| 1435 | + | |
| 1436 | + if ($isFreeFirstCycle) { | |
| 1437 | + $this->subscriptionModel->updateMeta('billed_cycles_offset', 1); | |
| 1438 | + } else { | |
| 1439 | + $this->subscriptionModel->deleteMeta('billed_cycles_offset'); | |
| 1440 | + } | |
| 1441 | + | |
| 1442 | + $isRealTrialWithCharge = !$isSimulated | |
| 1443 | + && $trialDays > 0 | |
| 1444 | + && $billTimes > 0 | |
| 1445 | + && $orderTotal > 0; | |
| 1446 | + | |
| 1447 | + if ($isRealTrialWithCharge) { | |
| 1448 | + $this->subscriptionModel->updateMeta('billed_cycles_deduction', 1); | |
| 1449 | + } else { | |
| 1450 | + $this->subscriptionModel->deleteMeta('billed_cycles_deduction'); | |
| 1451 | + } | |
| 1212 | 1452 | } |
| 1213 | 1453 | } |