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 +309 -16 1.4.0 → 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
@@ -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,12 +968,28 @@
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
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 +
862 992 // removable upon discussion
863 993 $subscriptionItem = [
864 994 'product_id' => $item['post_id'],
865 995 'current_payment_method' => Arr::get($this->orderData, 'payment_method'),
@@ -872,20 +1002,97 @@
872 1002 'variation_id' => Arr::get($item, 'object_id', 0),
873 1003 'status' => Status::SUBSCRIPTION_PENDING,
874 1004 'config' => [
875 1005 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
876 - 'currency' => $this->orderData['currency']
1006 + 'currency' => $this->orderData['currency'],
1007 + // Snapshot the variant attribute map + variation type from the order
1008 + // item so the subscription carries the same pa_* set behind its item_name.
1009 + 'item_attributes' => Arr::get($item, 'other_info.item_attributes', []),
1010 + 'variation_type' => Arr::get($item, 'other_info.variation_type', '')
877 1011 ]
878 1012 ];
879 1013
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;
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;
883 1034 }
884 1035
885 - $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
1036 + $this->subscriptionData = $subscriptionData;
886 1037 }
887 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 +
888 1095 private function prepareOrderData()
889 1096 {
890 1097 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
891 1098 return $item['fulfillment_type'] === 'physical';
@@ -952,8 +1159,9 @@
952 1159 'user_tz' => Arr::get($this->args, 'user_tz', ''),
953 1160 'create_account_after_paid' => Arr::get($this->args, 'create_account_after_paid', 'no'),
954 1161 'shipping_method_id' => Arr::get($this->args, 'shipping_method_id', 0),
955 1162 'shipping_method_title' => Arr::get($this->args, 'shipping_method_title', ''),
1163 + 'shipping_discount' => (int) Arr::get($this->args, 'shipping_discount', 0),
956 1164 'prorate_credit' => $this->prorateCreditTotal,
957 1165 'upgrade_discount' => $this->upgradeDiscountTotal,
958 1166 ],
959 1167 ];
@@ -986,8 +1194,36 @@
986 1194 + $estimatedTaxTotal
987 1195 + $estimatedShippingTax;
988 1196
989 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 +
990 1226 $this->orderData = $orderData;
991 1227 }
992 1228
993 1229 private function syncFeeItems()
@@ -1112,8 +1348,9 @@
1112 1348 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1113 1349 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1114 1350 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1115 1351 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1352 + $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1116 1353
1117 1354 // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1118 1355 $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1119 1356 $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
@@ -1153,10 +1390,13 @@
1153 1390 }
1154 1391 } else {
1155 1392 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1156 1393
1157 - if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1158 - $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;
1159 1399 }
1160 1400
1161 1401 if ($firstCycleCost < $recurringAmount) {
1162 1402 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
@@ -1164,9 +1404,11 @@
1164 1404 $result['trial_days'] = $adjustedTrialDays;
1165 1405 $result['is_trial_days_simulated'] = 'yes';
1166 1406 $result['signup_fee'] = $firstCycleCost;
1167 1407 $result['manage_setup_fee'] = 'yes';
1168 - $result['times'] = $times > 0 ? $times - 1 : 0;
1408 + // bill_times stays the full installment count. The simulated trial cycle IS the
1409 + // first installment (charged as one-time payment / free when 100% discounted);
1410 + // gateways derive the remaining remote cycles from is_trial_days_simulated.
1169 1411 } else if ($firstCycleCost > $recurringAmount) {
1170 1412 $result['trial_days'] = 0;
1171 1413 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1172 1414 $result['manage_setup_fee'] = 'yes';
@@ -1208,6 +1450,57 @@
1208 1450 'recurring_amount' => $recurringAmount,
1209 1451 'recurring_tax_total' => $recurringTax,
1210 1452 'signup_fee' => $result['signup_fee'] ?? 0,
1211 1453 ];
1454 + }
1455 +
1456 + /**
1457 + * bill_count is derived from counting total > 0 CHARGE transactions linked to
1458 + * the subscription (see syncSubscriptionStates / getRequiredBillTimes), which
1459 + * can't tell "this was a billed cycle" from "this was something else
1460 + * charged alongside it." Two corrections needed only at initial checkout —
1461 + * is_trial_days_simulated alone can't be used at runtime because
1462 + * payment-method switching also sets that flag:
1463 + *
1464 + * - Simulated trial, $0 first cycle: consumes a cycle but produces no
1465 + * total > 0 transaction — add billed_cycles_offset so it still counts.
1466 + * - Real trial with a signup fee: the initial charge is the signup fee only
1467 + * (the recurring item isn't billed yet), but it IS a total > 0 transaction
1468 + * linked to the subscription — mark billed_cycles_deduction so it does
1469 + * NOT count as a cycle.
1470 + */
1471 + private function syncInitialCycleCounting()
1472 + {
1473 + if (!$this->subscriptionModel) {
1474 + return;
1475 + }
1476 +
1477 + $isSimulated = Arr::get($this->subscriptionData, 'config.is_trial_days_simulated', 'no') === 'yes';
1478 + $trialDays = (int)Arr::get($this->subscriptionData, 'trial_days', 0);
1479 + $billTimes = (int)$this->subscriptionModel->bill_times;
1480 + $orderTotal = (int)$this->orderModel->total_amount;
1481 +
1482 + // signup_fee <= 0 (not just == 0): prorate/upgrade credit can push the
1483 + // first cycle cost negative — still a free first cycle for counting
1484 + $isFreeFirstCycle = $isSimulated
1485 + && $billTimes > 0
1486 + && (int)$this->subscriptionModel->signup_fee <= 0
1487 + && !$orderTotal;
1488 +
1489 + if ($isFreeFirstCycle) {
1490 + $this->subscriptionModel->updateMeta('billed_cycles_offset', 1);
1491 + } else {
1492 + $this->subscriptionModel->deleteMeta('billed_cycles_offset');
1493 + }
1494 +
1495 + $isRealTrialWithCharge = !$isSimulated
1496 + && $trialDays > 0
1497 + && $billTimes > 0
1498 + && $orderTotal > 0;
1499 +
1500 + if ($isRealTrialWithCharge) {
1501 + $this->subscriptionModel->updateMeta('billed_cycles_deduction', 1);
1502 + } else {
1503 + $this->subscriptionModel->deleteMeta('billed_cycles_deduction');
1504 + }
1212 1505 }
1213 1506 }