PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.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 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Helpers / CheckoutProcessor.php

CheckoutProcessor.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.0, at app/Helpers/CheckoutProcessor.php

1,374 lines 59.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Helpers;
4
5 use FluentCart\Api\Checkout\CheckoutApi;
6 use FluentCart\Api\StoreSettings;
7 use FluentCart\App\Models\Cart;
8 use FluentCart\App\Models\Coupon;
9 use FluentCart\App\Models\Order;
10 use FluentCart\App\Models\OrderItem;
11 use FluentCart\App\Models\Product;
12 use FluentCart\App\Models\ProductVariation;
13 use FluentCart\App\Models\Subscription;
14 use FluentCart\App\Modules\Tax\TaxCalculator;
15 use FluentCart\Framework\Support\Arr;
16 use FluentCart\App\Helpers\Helper;
17 use FluentCart\App\Modules\PaymentMethods\Core\GatewayManager;
18 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionManagementMode;
19
20 class CheckoutProcessor
21 {
22
23 // Raw Data
24 private $cartItems = [];
25 private $args = [];
26
27 // Order Related Data
28 private $formattedIOrderItems = [];
29 private $orderData = [];
30 private $subscriptionData = [];
31
32 // Models
33
34 private $orderModel;
35
36 private $transactionModel;
37
38 private $subscriptionModel;
39
40 // Fee tracking
41 private $feeTotal = 0;
42
43 // Store Settings
44 private $storeSettings;
45
46 private $couponDiscountTotal = 0;
47
48 private $manualDiscountTotal = 0;
49
50 private $prorateCreditTotal = 0;
51
52 private $upgradeDiscountTotal = 0;
53
54 public function __construct($cartItems = [], $args = [])
55 {
56 $this->storeSettings = new StoreSettings();
57 $this->cartItems = $cartItems;
58 $this->args = $args;
59
60 $this->prepareData();
61 }
62
63 private function prepareData()
64 {
65 $this->prepareOrderItems();
66 $this->prepareOrderData();
67 $this->prepareSubscriptionData();
68 }
69
70 public function createDraftOrder($prevOrder = null)
71 {
72 if ($prevOrder) {
73 return $this->getAdjustedOrder($prevOrder);
74 }
75
76 $customerId = Arr::get($this->args, 'customer_id', '');
77 if (!$customerId) {
78 return new \WP_Error('customer_id_missing', __('Customer ID is required to create a draft order.', 'fluent-cart'));
79 }
80
81 $orderData = $this->orderData;
82 $orderData['customer_id'] = $customerId;
83 if (empty($orderData['currency'])) {
84 $orderData['currency'] = $this->storeSettings->getCurrency();
85 }
86
87 if (empty($orderData['mode'])) {
88 $orderData['mode'] = $this->storeSettings->get('order_mode', 'test');
89 }
90
91 if (empty($orderData['fee_total'])) {
92 unset($orderData['fee_total']);
93 }
94
95 $this->orderModel = \FluentCart\App\Models\Order::query()->create($orderData);
96
97 if (!$this->orderModel) {
98 return new \WP_Error('order_creation_failed', __('Failed to create order.', 'fluent-cart'));
99 }
100
101 // save order meta
102 if (Arr::get($this->args, 'tax_id', 0)) {
103 $this->orderModel->updateMeta('tax_id', Arr::get($this->args, 'tax_id', 0));
104 }
105
106 // Store tax meta for mixed carts (tax_behavior=3) - used by payment gateways and renewals
107 $this->persistTaxMeta();
108
109 // Let's create the order items
110 $normalOrderItems = array_filter($this->formattedIOrderItems, function ($item) {
111 return $item['payment_type'] != 'signup_fee';
112 });
113
114 foreach ($normalOrderItems as $orderItem) {
115 $orderItem['order_id'] = $this->orderModel->id;
116 $orderItem['line_total'] = $orderItem['subtotal'] - $orderItem['discount_total'];
117 $additionalItems = [];
118 $bundleItems = [];
119 if ($orderItem['payment_type'] == 'subscription') {
120 // this is a subscription type. We may have additional_items
121 $additionalItems = Arr::get($orderItem, 'additional_items', []);
122 unset($orderItem['additional_items']);
123 }
124
125 if (Arr::get($orderItem, 'other_info.is_bundle_product', 'no') == 'yes') {
126 $bundleItems = Arr::get($orderItem, 'bundle_items', []);
127 unset($orderItem['bundle_items']);
128 }
129
130 if (!empty($orderItem['coupon_discount'])) {
131 $lineMeta = Arr::get($orderItem, 'line_meta', []);
132 $lineMeta['coupon_discount'] = (int) $orderItem['coupon_discount'];
133 $orderItem['line_meta'] = $lineMeta;
134 }
135
136 $createdItem = OrderItem::query()->create($orderItem);
137
138 if ($additionalItems) {
139 $additionalItemIds = [];
140 foreach ($additionalItems as $additionalItem) {
141 $additionalItem['order_id'] = $this->orderModel->id;
142 $additionalItem['line_total'] = Arr::get($additionalItem, 'subtotal', 0) - Arr::get($additionalItem, 'discount_total', 0);
143 $mata = Arr::get($additionalItem, 'line_meta', []);
144 $mata['parent_item_id'] = $createdItem->id;
145 if (!empty($additionalItem['coupon_discount'])) {
146 $mata['coupon_discount'] = (int) $additionalItem['coupon_discount'];
147 }
148 $additionalItem['line_meta'] = $mata;
149 $childItem = OrderItem::query()->create($additionalItem);
150 $additionalItemIds[] = $childItem->id;
151 }
152
153 $createdItem->fill([
154 'line_meta' => array_merge(
155 $createdItem->line_meta,
156 [
157 'additional_item_ids' => $additionalItemIds
158 ]
159 )
160 ])->save();
161 }
162
163 if ($bundleItems) {
164 $bundleItemIds = [];
165 foreach ($bundleItems as $bundleItem) {
166 $bundleItem['order_id'] = $this->orderModel->id;
167 $bundleItem['line_total'] = Arr::get($bundleItem, 'subtotal', 0) - Arr::get($bundleItem, 'discount_total', 0);
168 $bundleItem['payment_type'] = 'bundle';
169 $meta = Arr::get($bundleItem, 'line_meta', []);
170 $meta['bundle_parent_item_id'] = $createdItem->id;
171 if (!empty($bundleItem['coupon_discount'])) {
172 $meta['coupon_discount'] = (int) $bundleItem['coupon_discount'];
173 }
174 $bundleItem['line_meta'] = $meta;
175 $bundleItem = OrderItem::query()->create($bundleItem);
176 $bundleItemIds[] = $bundleItem->id;
177 }
178
179 $createdItem->fill([
180 'line_meta' => array_merge(
181 $createdItem->line_meta,
182 [
183 'bundle_item_ids' => $bundleItemIds
184 ]
185 )
186 ])->save();
187 }
188 }
189
190
191 // Let's create the subscription if exists
192 /*
193 * TODO : on renewal order we shouldn't create another subscription,
194 * basically on manual renew we just create a new sub on gateway and update the existing one on our database
195 * which automates the renewal cycle for the existing subscription
196 * ....created new sub remains on pending state, will create inconsistency
197 * */
198
199 if ($this->subscriptionData) {
200 $subscriptionData = $this->subscriptionData;
201 $subscriptionData['customer_id'] = $customerId;
202 $subscriptionData['parent_order_id'] = $this->orderModel->id;
203
204 $this->subscriptionModel = Subscription::query()->create($subscriptionData);
205 $this->syncInitialCycleCounting();
206 }
207
208 // Let's create the transaction
209 $transactionData = [
210 'order_id' => $this->orderModel->id,
211 'order_type' => $this->orderModel->type,
212 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
213 'subscription_id' => $this->subscriptionModel ? $this->subscriptionModel->id : NULL,
214 'payment_method' => $this->orderModel->payment_method,
215 'payment_mode' => $this->orderModel->mode,
216 'payment_method_type' => '',
217 'status' => Status::PAYMENT_PENDING,
218 'currency' => $this->orderModel->currency,
219 'total' => $this->orderModel->total_amount,
220 'rate' => 1,
221 'meta' => [],
222 ];
223
224 $this->transactionModel = \FluentCart\App\Models\OrderTransaction::query()->create($transactionData);
225
226 // insert the applied coupons
227 $this->insertAppliedCoupons(
228 Arr::get($this->args, 'applied_coupons', []),
229 false,
230 $this->orderModel
231 );
232
233 $cartHash = Arr::get($this->args, 'cart_hash', '');
234 if ($cartHash) {
235 $cart = Cart::query()->where('cart_hash', $cartHash)->first();
236 if ($cart) {
237 $cart->order_id = $this->orderModel->id;
238 $cart->customer_id = $this->orderModel->customer_id;
239 $cart->stage = 'intended';
240 $customer = $this->orderModel->customer;
241
242 if ($customer) {
243 $cart->first_name = $customer->first_name;
244 $cart->last_name = $customer->last_name;
245 $cart->email = $customer->email;
246 $cart->user_id = $customer->user_id;
247 }
248
249 $cart->save();
250 $actions = Arr::get($cart->checkout_data, '__after_draft_created_actions__', []);
251 if ($actions) {
252 foreach ($actions as $actionName) {
253 $actionName = (string)$actionName;
254 if (has_action($actionName)) {
255 do_action($actionName, [
256 'order' => $this->orderModel,
257 'cart' => $cart,
258 ]);
259 }
260 }
261
262 // We are just renewing it!
263 $this->orderModel = \FluentCart\App\Models\Order::query()
264 ->where('id', $this->orderModel->id)
265 ->first();
266 }
267 }
268 }
269
270 // We are almost done!
271 return $this->orderModel;
272 }
273
274 private function getAdjustedOrder(Order $prevOrder)
275 {
276 $isLocked = Arr::get($this->args, 'is_locked', false);
277
278 $orderData = $this->orderData;
279 $customerId = Arr::get($this->args, 'customer_id', '');
280 if ($customerId) {
281 $orderData['customer_id'] = $customerId;
282 }
283
284 if ($isLocked) {
285 $orderData = array_filter(Arr::only($orderData, ['note', 'payment_method', 'ip_address', 'customer_id', 'shipping_total', 'total_amount', 'tax_total', 'fee_total', 'shipping_tax']), function ($value) {
286 return $value !== null && $value !== '';
287 });
288
289 $prevConfig = $prevOrder->config;
290 $prevConfig['user_tz'] = Arr::get($this->args, 'user_tz', '');
291 $orderData['config'] = $prevConfig;
292 $prevOrder->fill($orderData);
293 $prevOrder->save();
294 } else {
295 $prevOrder->fill($orderData);
296 $prevOrder->save();
297 }
298
299 $this->orderModel = $prevOrder;
300
301 if (!$this->orderModel) {
302 return new \WP_Error('order_creation_failed', __('Failed to create order.', 'fluent-cart'));
303 }
304
305 //for previous order if tax is enabled then we need to update the tax total
306 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
307 $taxEnabled = Arr::get($taxSettings, 'enable_tax', 'no');
308
309 if ($isLocked && $taxEnabled !== 'yes') {
310 // Locked orders skip full item sync, but fee items must stay in sync with fee_total
311 $this->syncFeeItems();
312
313 // Load existing subscription so the transaction gets the correct subscription_id
314 if ($this->orderModel->type === Status::ORDER_TYPE_SUBSCRIPTION) {
315 $this->subscriptionModel = Subscription::query()
316 ->where('parent_order_id', $this->orderModel->id)
317 ->first();
318 }
319 }
320
321 if (!$isLocked || $taxEnabled === 'yes') {
322 // Let's create the order items
323 $normalOrderItems = array_filter($this->formattedIOrderItems, function ($item) {
324 return $item['payment_type'] != 'signup_fee';
325 });
326
327 $createdItemIds = [];
328 $taxTotal = 0;
329 foreach ($normalOrderItems as $orderItem) {
330 $orderItem['order_id'] = $this->orderModel->id;
331 $orderItem['quantity'] = max(1, (int)$orderItem['quantity']);
332 $orderItem['line_total'] = $orderItem['subtotal'] - $orderItem['discount_total'];
333 $taxTotal += $orderItem['tax_amount'];
334 $additionalItems = [];
335 $bundleItems = [];
336 if ($orderItem['payment_type'] == 'subscription') {
337 // this is a subscription type. We may have additional_items
338 $additionalItems = Arr::get($orderItem, 'additional_items', []);
339 unset($orderItem['additional_items']);
340 }
341
342 if (Arr::get($orderItem, 'other_info.is_bundle_product', 'no') == 'yes') {
343 $bundleItems = Arr::get($orderItem, 'bundle_items', []);
344 unset($orderItem['bundle_items']);
345 }
346
347 $existingItem = OrderItem::query()->where('order_id', $this->orderModel->id)
348 ->whereNotIn('id', $createdItemIds)
349 ->first();
350
351 if ($existingItem) {
352 $existingItem->fill($orderItem);
353 $existingItem->save();
354 $createdItem = $existingItem;
355 } else {
356 $createdItem = OrderItem::query()->create($orderItem);
357 }
358
359 $createdItemIds[] = $createdItem->id;
360
361 if ($additionalItems) {
362 $additionalItemIds = [];
363 foreach ($additionalItems as $additionalItem) {
364 $additionalItem['order_id'] = $this->orderModel->id;
365 $additionalItem['quantity'] = max(1, (int)$additionalItem['quantity']);
366 $additionalItem['line_total'] = $additionalItem['subtotal'] - $additionalItem['discount_total'];
367 $mata = Arr::get($additionalItem, 'line_meta', []);
368 $mata['parent_item_id'] = $createdItem->id;
369 $additionalItem['line_meta'] = $mata;
370 $childItem = OrderItem::query()->create($additionalItem);
371 $createdItemIds[] = $childItem->id;
372 $additionalItemIds[] = $childItem->id;
373
374 }
375
376 $createdItem->fill([
377 'line_meta' => array_merge(
378 $createdItem->line_meta,
379 [
380 'additional_item_ids' => $additionalItemIds
381 ]
382 )
383 ])->save();
384 }
385
386 if ($bundleItems) {
387 $bundleItemIds = [];
388 foreach ($bundleItems as $bundleItem) {
389 $bundleItem['order_id'] = $this->orderModel->id;
390 $bundleItem['quantity'] = Arr::get($orderItem, 'quantity', 1);
391 $bundleItem['line_total'] = Arr::get($bundleItem, 'subtotal', 0) - Arr::get($bundleItem, 'discount_total', 0);
392 $bundleItem['payment_type'] = 'bundle';
393 $meta['bundle_parent_item_id'] = $createdItem->id;
394 $bundleItem['line_meta'] = $meta;
395 $childItem = OrderItem::query()->create($bundleItem);
396 $createdItemIds[] = $childItem->id;
397 $bundleItemIds[] = $childItem->id;
398 }
399
400 $createdItem->fill([
401 'line_meta' => array_merge(
402 $createdItem->line_meta,
403 [
404 'bundle_item_ids' => $bundleItemIds
405 ]
406 )
407 ])->save();
408 }
409 }
410 if ($taxTotal && !$this->orderModel->tax_total && !$isLocked) {
411 $this->orderModel->tax_total = $taxTotal;
412 $this->orderModel->total_amount += $taxTotal;
413 $this->orderModel->save();
414 }
415
416 OrderItem::query()->where('order_id', $this->orderModel->id)->whereNotIn('id', $createdItemIds)->delete();
417
418 // Let's create the subscription if exists
419 if ($this->subscriptionData) {
420 if ($this->orderModel->type === Status::ORDER_TYPE_RENEWAL) {
421 // it's a renewal order
422 $this->subscriptionModel = Subscription::query()
423 ->where('parent_order_id', $this->orderModel->parent_id)
424 ->first();
425 } else {
426 $subscriptionData = $this->subscriptionData;
427 $subscriptionData['customer_id'] = $customerId;
428 $subscriptionData['parent_order_id'] = $this->orderModel->id;
429 $existingSubscription = Subscription::query()->where('parent_order_id', $this->orderModel->id)->first();
430 if ($existingSubscription) {
431 $existingSubscription->fill($subscriptionData);
432 $existingSubscription->save();
433 $this->subscriptionModel = $existingSubscription;
434 } else {
435 $this->subscriptionModel = Subscription::query()->create($subscriptionData);
436 }
437 $this->syncInitialCycleCounting();
438 }
439 } else {
440 Subscription::query()->where('parent_order_id', $this->orderModel->id)->delete();
441 }
442 }
443
444 // Reload the order to get fresh item data after updates
445 $this->orderModel = $this->orderModel->fresh();
446
447 $this->persistTaxMeta();
448
449 // Let's create the transaction
450 $transactionData = [
451 'order_id' => $this->orderModel->id,
452 'order_type' => $this->orderModel->type,
453 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
454 'subscription_id' => $this->subscriptionModel ? $this->subscriptionModel->id : NULL,
455 'payment_method' => $this->orderModel->payment_method,
456 'payment_mode' => $this->orderModel->mode,
457 'payment_method_type' => '',
458 'status' => Status::PAYMENT_PENDING,
459 'currency' => $this->orderModel->currency,
460 'total' => $this->orderModel->total_amount,
461 'rate' => 1,
462 'meta' => [],
463 ];
464
465 if ($isLocked && $prevOrder->parent_id) {
466 $prevSubscription = Subscription::query()
467 ->where('parent_order_id', $prevOrder->parent_id)
468 ->first();
469
470 if ($prevSubscription) {
471 $transactionData['subscription_id'] = $prevSubscription->id;
472 }
473 }
474
475 $existingTransaction = \FluentCart\App\Models\OrderTransaction::query()
476 ->where('order_id', $this->orderModel->id)
477 ->first();
478
479 if ($existingTransaction) {
480 // Retry vs duplicate for gateway idempotency (PaymentInstance::getIdempotencySeed):
481 // re-submitting a pending transaction is a duplicate (keep attempt -> gateway
482 // dedupes); re-submitting a FAILED one is a retry (bump attempt -> fresh seed,
483 // never answered with the failed attempt's cached gateway response).
484 $attempt = (int) Arr::get($existingTransaction->meta ?: [], 'payment_attempt', 0);
485 if ($existingTransaction->status === Status::PAYMENT_FAILED) {
486 $attempt++;
487 }
488 if ($attempt) {
489 $transactionData['meta'] = ['payment_attempt' => $attempt];
490 }
491
492 $existingTransaction->fill($transactionData);
493 $existingTransaction->save();
494 $this->transactionModel = $existingTransaction;
495 } else {
496 $this->transactionModel = \FluentCart\App\Models\OrderTransaction::query()->create($transactionData);
497 }
498
499 // For locked carts (e.g. custom checkout), preserve the original order's applied coupon records.
500 // Re-inserting would delete existing records and incorrectly increment the coupon use_count.
501 if (!$isLocked) {
502 $this->insertAppliedCoupons(Arr::get($this->args, 'applied_coupons', []), true, $prevOrder);
503 }
504
505 $cartHash = Arr::get($this->args, 'cart_hash', '');
506
507 if ($cartHash) {
508 $cart = Cart::query()->where('cart_hash', $cartHash)->first();
509 if ($cart) {
510 $cart->order_id = $this->orderModel->id;
511 $cart->customer_id = $this->orderModel->customer_id;
512 $cart->stage = 'intended';
513 $cart->ip_address = $this->orderModel->ip_address;
514 $cart->save();
515 }
516 }
517
518 // We are almost done!
519 return $this->orderModel;
520 }
521
522 private function persistTaxMeta()
523 {
524 $exclusiveTaxTotal = (int) Arr::get($this->args, 'exclusive_tax_total', 0);
525 $storeTaxBehavior = (int) Arr::get($this->args, 'store_tax_behavior', 0);
526 $feeTax = (int) Arr::get($this->args, 'fee_tax', 0);
527 $feeTaxLines = (array) Arr::get($this->args, 'fee_tax_lines', []);
528
529 $this->orderModel->updateMeta('exclusive_tax_total', $exclusiveTaxTotal);
530 $this->orderModel->updateMeta('store_tax_behavior', $storeTaxBehavior);
531 $this->orderModel->updateMeta('fee_tax', $feeTax);
532
533 if (!empty($feeTaxLines)) {
534 $this->orderModel->updateMeta('fee_tax_lines', $feeTaxLines);
535 } else {
536 $this->orderModel->deleteMeta('fee_tax_lines');
537 }
538 }
539
540 private function insertAppliedCoupons($appliedCoupons, $removeOlds = false, $order = null): void
541 {
542 if ($removeOlds) {
543 $this->orderModel->appliedCoupons()->delete();
544 }
545
546 $couponCodes = Arr::pluck($appliedCoupons, 'code');
547
548 $customerId = $this->orderModel->customer_id;
549 if ($order instanceof Order) {
550 $customerId = $order->customer_id;
551 }
552
553 if (!empty($couponCodes)) {
554 $coupons = Coupon::query()->whereIn('code', $couponCodes)->get();
555
556 /*
557 * Resolve virtual (un-persisted) coupons so an AppliedCoupon row is written for
558 * them too — the row stores coupon_id = null (the column is nullable) with the
559 * code and computed discount, so it shows in the order's Coupons section like any
560 * coupon. See DiscountService::applyCouponCodes() for the same filter.
561 */
562 $coupons = apply_filters('fluent_cart/coupon/resolve_coupons', $coupons, $couponCodes, [
563 'order' => $this->orderModel,
564 ]);
565
566 $coupons = $coupons->keyBy('code')->toArray();
567
568 foreach ($coupons as $code => &$coupon) {
569 $coupon['coupon_id'] = $appliedCoupons[$code]['id'];
570 $coupon['amount'] = $appliedCoupons[$code]['discount'];
571 $coupon['customer_id'] = $customerId;
572 }
573 $this->orderModel->appliedCoupons()->createMany($coupons);
574
575 Coupon::query()
576 ->whereIn('code', $couponCodes)
577 ->increment('use_count', 1);
578 }
579 }
580
581 public function getTransaction()
582 {
583 return $this->transactionModel;
584 }
585
586 public function getOrder()
587 {
588 return $this->orderModel;
589 }
590
591 public function getSubscription()
592 {
593 return $this->subscriptionModel;
594 }
595
596 private function prepareOrderItems()
597 {
598 $formattedItems = [];
599
600 foreach ($this->cartItems as $cartItem) {
601 $unitPrice = (int)Arr::get($cartItem, 'unit_price', 0);
602 $quantity = (int)Arr::get($cartItem, 'quantity', 1);
603
604 $this->couponDiscountTotal += (int)Arr::get($cartItem, 'coupon_discount', 0);
605 $this->manualDiscountTotal += (int)Arr::get($cartItem, 'manual_discount', 0);
606
607 $discountTotal = (int)Arr::get($cartItem, 'manual_discount', 0) + (int)Arr::get($cartItem, 'coupon_discount', 0);
608 $shippingCharge = (int)Arr::get($cartItem, 'shipping_charge', 0);
609
610 $subtotal = (int) Arr::get($cartItem, 'subtotal', $unitPrice * $quantity);
611 $args = Arr::get($cartItem, 'other_info', []);
612 $paymentType = Arr::get($args, 'payment_type', 'default');
613
614 $postTitle = Arr::get($cartItem, 'product_title', '');
615 $variationTitle = Arr::get($cartItem, 'variation_title', '');
616
617 if (!$postTitle) {
618 if (Arr::get($cartItem, 'is_custom', false)) {
619 $postTitle = Arr::get($cartItem, 'post_title', '');
620 } else {
621 $product = Product::query()->find(Arr::get($cartItem, 'post_id', 0));
622 if ($product) {
623 $postTitle = $product->post_title;
624 }
625 }
626 }
627
628 if (!$variationTitle) {
629 if (Arr::get($cartItem, 'is_custom', false)) {
630 $variationTitle = Arr::get($cartItem, 'title', '');
631 } else {
632 $variation = ProductVariation::query()->find(Arr::get($cartItem, 'object_id', 0));
633 if ($variation) {
634 $variationTitle = $variation->variation_title;
635 }
636 }
637 }
638
639 // Snapshot package dimensions into other_info for email/PDF rendering
640 if (Arr::get($cartItem, 'fulfillment_type') === 'physical') {
641 $packageSlug = Arr::get($args, 'package_slug', '');
642 $package = Helper::getPackageBySlug($packageSlug);
643 if ($package) {
644 $args['package_name'] = Arr::get($package, 'name', '');
645 $args['package_type'] = Arr::get($package, 'type', '');
646 $args['package_length'] = Arr::get($package, 'length', '');
647 $args['package_width'] = Arr::get($package, 'width', '');
648 $args['package_height'] = Arr::get($package, 'height', '');
649 $args['package_dimension_unit'] = Arr::get($package, 'dimension_unit', 'cm');
650 $args['package_weight'] = Arr::get($package, 'weight', 0);
651 $args['package_weight_unit'] = Arr::get($package, 'weight_unit', 'kg');
652 }
653 }
654
655 // Carry the attribute snapshot onto the order item. It normally
656 // arrives via the cart item's other_info; rebuild it here as a
657 // fallback for items that reach checkout without one (instant
658 // checkout, legacy carts).
659 if (!isset($args['item_attributes'])) {
660 $args['item_attributes'] = AttributeHelper::getProductItemAttributes(
661 Arr::get($cartItem, 'object_id', 0),
662 Arr::get($cartItem, 'post_id', 0)
663 );
664 }
665
666 if (!isset($args['variation_type'])) {
667 $args['variation_type'] = (string) Arr::get($cartItem, 'variation_type', '');
668 }
669
670 $item = [
671 'payment_type' => $paymentType,
672 'post_id' => Arr::get($cartItem, 'post_id'),
673 'object_id' => Arr::get($cartItem, 'object_id'),
674 'post_title' => $postTitle,
675 'title' => $variationTitle,
676 'fulfillment_type' => Arr::get($cartItem, 'fulfillment_type', 'digital'),
677 'quantity' => $quantity,
678 'cost' => (int)Arr::get($cartItem, 'cost', 0),
679 'unit_price' => $unitPrice,
680 'subtotal' => $subtotal,
681 'tax_amount' => (int)Arr::get($cartItem, 'tax_amount', 0),
682 'shipping_charge' => $shippingCharge,
683 'discount_total' => $discountTotal,
684 'coupon_discount' => (int)Arr::get($cartItem, 'coupon_discount', 0),
685 'other_info' => $args,
686 'line_meta' => Arr::get($cartItem, 'line_meta', []),
687 ];
688
689 if (isset($cartItem['recurring_discounts'])) {
690 $item['recurring_discounts'] = $cartItem['recurring_discounts'];
691 }
692
693 $childItem = null;
694 if ($paymentType === 'subscription' && Arr::get($cartItem, 'other_info.signup_fee', 0)) {
695 // We have a signup fee for subscription
696 $signupFeeAmount = (int)Arr::get($cartItem, 'other_info.signup_fee', 0);
697
698 $signupFeeTax = (int)Arr::get($cartItem, 'other_info.signup_fee_tax', 0);
699
700 // Nest under tax_config — the same shape regular items and the admin
701 // order path use. Readers keep a fallback for the legacy flat shape.
702 $signupFeeTaxConfig = Arr::get($cartItem, 'signup_fee_tax_config', []);
703
704 $childDiscountTotal = 0;
705 $childCouponDiscount = 0;
706 $signupFeeSubtotal = $signupFeeAmount * $quantity;
707 $couponDiscount = $item['coupon_discount'];
708
709 $hasTrialDays = Arr::get($cartItem, 'other_info.trial_days', 0) > 0;
710
711 if ($discountTotal && !$hasTrialDays) {
712 $childDiscountTotal = (float)($discountTotal / ($subtotal + $signupFeeSubtotal) * $signupFeeSubtotal);
713 $discountTotal -= $childDiscountTotal;
714 $childCouponDiscount = (int) round($couponDiscount * $signupFeeSubtotal / ($subtotal + $signupFeeSubtotal));
715 $couponDiscount -= $childCouponDiscount;
716 } elseif ($discountTotal && $hasTrialDays) { // if trial days , then discount should be applied on signup fee only
717 $childDiscountTotal = min($discountTotal, $signupFeeSubtotal);
718 $discountTotal = 0;
719 $childCouponDiscount = min($couponDiscount, $signupFeeSubtotal);
720 $couponDiscount = 0;
721 }
722
723 $childItem = [
724 'payment_type' => 'signup_fee',
725 'post_id' => $item['post_id'],
726 'object_id' => $item['object_id'],
727 'post_title' => $item['post_title'],
728 'title' => Arr::get($cartItem, 'other_info.signup_fee_name', __('Signup Fee', 'fluent-cart')),
729 'fulfillment_type' => $item['fulfillment_type'],
730 'quantity' => $quantity,
731 'cost' => 0,
732 'unit_price' => $signupFeeAmount,
733 'subtotal' => $signupFeeSubtotal,
734 'tax_amount' => $signupFeeTax,
735 'shipping_charge' => 0,
736 'discount_total' => $childDiscountTotal,
737 'coupon_discount' => $childCouponDiscount,
738 'line_meta' => $signupFeeTaxConfig ? ['tax_config' => $signupFeeTaxConfig] : [],
739 ];
740
741 $item['discount_total'] = $discountTotal;
742 $item['coupon_discount'] = $couponDiscount;
743 $item['additional_items'] = [$childItem];
744
745 Arr::set($item, 'other_info.signup_fee', $signupFeeAmount);
746 Arr::set($item, 'other_info.signup_discount', $childDiscountTotal);
747 }
748
749 if (
750 Arr::get($cartItem, 'other_info.is_bundle_product', 'no') == 'yes'
751 || !empty(Arr::get($cartItem, 'other_info.bundle_child_ids', []))
752 ) {
753 $bundleItems = Arr::get($cartItem, 'child_variants', []);
754
755 foreach ($bundleItems as $bundleItem) {
756 $bundleChildItem = [
757 'payment_type' => 'bundle',
758 'post_id' => Arr::get($bundleItem, 'post_id', 0),
759 'object_id' => Arr::get($bundleItem, 'id', 0),
760 'post_title' => Arr::get($bundleItem, 'post_title', ''),
761 'title' => Arr::get($bundleItem, 'variation_title', ''),
762 'fulfillment_type' => Arr::get($bundleItem, 'fulfillment_type', 'digital'),
763 'quantity' => $quantity,
764 'cost' => 0,
765 'unit_price' => 0,
766 'subtotal' => 0,
767 'tax_amount' => 0,
768 'shipping_charge' => 0,
769 'discount_total' => 0,
770 'other_info' => [
771 'bundle_parent_product_id' => Arr::get($cartItem, 'post_id', 0),
772 'bundle_parent_variation_id' => Arr::get($cartItem, 'object_id', 0)
773 ],
774 ];
775
776 //TODO: if bundleItem price is included on for the bundle, then we need to set the price to the bundleItem price
777 // if (Arr::get($bundleItem, 'other_info.is_price_included', 'no') == 'yes') {
778 // $bundleChildItem['unit_price'] = Arr::get($bundleItem, 'unit_price', 0);
779 // $bundleChildItem['subtotal'] = Arr::get($bundleItem, 'subtotal', 0);
780 // $bundleChildItem['tax_amount'] = Arr::get($bundleItem, 'tax_amount', 0);
781 // $bundleChildItem['shipping_charge'] = Arr::get($bundleItem, 'shipping_charge', 0);
782 // $bundleChildItem['discount_total'] = Arr::get($bundleItem, 'discount_total', 0);
783 // }
784
785 $item['bundle_items'][] = $bundleChildItem;
786
787 }
788
789 }
790
791 $formattedItems[] = $item;
792 if ($childItem) {
793 $formattedItems[] = $childItem;
794 }
795 }
796
797 // Create order items for fees
798 $fees = (array)Arr::get($this->args, 'fees', []);
799 $this->feeTotal = 0;
800
801 foreach ($fees as $fee) {
802 $amount = (int)($fee['amount'] ?? 0);
803 if ($amount <= 0) {
804 continue;
805 }
806
807 $this->feeTotal += $amount;
808
809 $formattedItems[] = [
810 'payment_type' => 'fee',
811 'post_id' => 0,
812 'object_id' => 0,
813 'post_title' => '',
814 'title' => $fee['label'] ?? '',
815 'fulfillment_type' => 'digital',
816 'quantity' => 1,
817 'cost' => 0,
818 'unit_price' => $amount,
819 'subtotal' => $amount,
820 'tax_amount' => 0,
821 'shipping_charge' => 0,
822 'discount_total' => 0,
823 'other_info' => [
824 'payment_type' => 'fee',
825 'fee_key' => $fee['key'] ?? '',
826 'source' => $fee['source'] ?? 'custom',
827 'taxable' => !empty($fee['taxable']),
828 'meta' => $fee['meta'] ?? [],
829 ],
830 'line_meta' => [],
831 ];
832 }
833
834 $this->formattedIOrderItems = $formattedItems;
835 }
836
837 private function prepareSubscriptionData()
838 {
839 $subscriptionItems = array_filter($this->formattedIOrderItems, function ($item) {
840 return $item['payment_type'] === 'subscription';
841 });
842
843 $signupFeeItems = array_filter($this->formattedIOrderItems, function ($item) {
844 return $item['payment_type'] === 'signup_fee';
845 });
846
847 if (!$subscriptionItems) {
848 return;
849 }
850
851 if (count($subscriptionItems) > 1) {
852 return;
853 }
854
855 $item = reset($subscriptionItems);
856 $signupFeeItem = reset($signupFeeItems) ?? [];
857 $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0);
858 $taxBehavior = (int)Arr::get($this->args, 'tax_behavior', 0);
859
860 $recurringTotal = (int)$item['subtotal'];
861 $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0);
862
863 $recurringDiscountAmount = (int)Arr::get($item, 'recurring_discounts.amount', 0);
864
865 if ($recurringDiscountAmount && $recurringDiscountAmount > 0) {
866 $recurringTotal -= $recurringDiscountAmount;
867 }
868
869 // Add shipping charges (and tax) to recurring total for physical subscription products
870 $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0);
871 $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical';
872 if ($isPhysicalProduct && $shippingCharge > 0) {
873 $recurringTotal += $shippingCharge;
874 $shippingTax = (int)Arr::get($this->args, 'shipping_tax', 0);
875 if ($shippingTax > 0) {
876 $storeTaxBehavior = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
877 if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1)) {
878 $recurringTotal += $shippingTax;
879 }
880 }
881 }
882
883 $itemInclusive = (bool) Arr::get($item, 'line_meta.tax_config.inclusive', false);
884 if ($taxBehavior === 1 || ($taxBehavior === 3 && !$itemInclusive)) {
885 $recurringTotal += $recurringTax;
886 }
887
888 $signupFee = (int)Arr::get($signupFeeItem, 'subtotal', 0);
889
890 // in case of discount applied 'tax_amount' is different than recurring tax ,
891 $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax;
892
893 // Calculate recurring amount including shipping for physical products
894 $recurringAmount = (int)$item['subtotal'];
895 if ($isPhysicalProduct && $shippingCharge > 0) {
896 $recurringAmount += $shippingCharge;
897 $shippingTaxForFirst = (int)Arr::get($this->args, 'shipping_tax', 0);
898 if ($shippingTaxForFirst > 0) {
899 $storeTaxBehaviorForFirst = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
900 if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehaviorForFirst === 1)) {
901 $firstIterationTax += $shippingTaxForFirst;
902 }
903 }
904 }
905
906 $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0) + $this->prorateCreditTotal + $this->upgradeDiscountTotal;
907 $subscriptionPricing = $this->convertToSubscriptionFormat([
908 'initial_trial_days' => Arr::get($item, 'other_info.trial_days', 0),
909 'repeat_interval' => Arr::get($item, 'other_info.repeat_interval', 'monthly'),
910 'times' => Arr::get($item, 'other_info.times', 0),
911 'recurring_amount' => $recurringAmount,
912 'recurring_tax_total' => $recurringTax,
913 'recurring_total' => $recurringTotal,
914 'tax_behavior' => $taxBehavior,
915 'line_meta' => Arr::get($item, 'line_meta', []),
916 'signup_fee' => $signupFee,
917 'signup_fee_tax' => $signupFeeTax,
918 'first_iteration_tax' => $firstIterationTax,
919 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
920 'total_discount' => $discountTotal
921 ]);
922
923 // removable upon discussion
924 $subscriptionItem = [
925 'product_id' => $item['post_id'],
926 'current_payment_method' => Arr::get($this->orderData, 'payment_method'),
927 'object_id' => $item['object_id'],
928 'recurring_tax_total' => 0,
929 'recurring_total' => $recurringTotal, //use price not line_total to ignore discount
930 'item_name' => $item['post_title'] . ' - ' . $item['title'],
931 'bill_count' => 0,
932 'quantity' => 1,
933 'variation_id' => Arr::get($item, 'object_id', 0),
934 'status' => Status::SUBSCRIPTION_PENDING,
935 'config' => [
936 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
937 'currency' => $this->orderData['currency'],
938 // Snapshot the variant attribute map + variation type from the order
939 // item so the subscription carries the same pa_* set behind its item_name.
940 'item_attributes' => Arr::get($item, 'other_info.item_attributes', []),
941 'variation_type' => Arr::get($item, 'other_info.variation_type', '')
942 ]
943 ];
944
945 // if recurring coupon is applied, we need to subtract the total discount from the recurring total
946 if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') {
947 $subscriptionItem['recurring_total'] -= $discountTotal;
948 }
949
950 $subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
951 $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
952
953 $collectionMethod = apply_filters('fluent_cart/subscription_collection_method_' . $paymentMethod, $this->determineCollectionMethod());
954
955 // A filter can hand back anything, but `system` only means something on a
956 // gateway that can charge a saved payment method.
957 $subscriptionData['collection_method'] = SubscriptionManagementMode::sanitizeCollectionMethod(
958 $collectionMethod,
959 GatewayManager::getInstance()->get($paymentMethod)
960 );
961
962 // Stamp store-managed origin durably on the subscription. Gateways consult
963 // the stamp (not the current store setting) before converting a manual
964 // subscription to automatic, so switching the mode back to gateway-managed
965 // later never flips subscriptions born under store-managed.
966 if (in_array($subscriptionData['collection_method'], ['manual', 'system'], true) && SubscriptionManagementMode::isStoreManaged()) {
967 $subscriptionConfig = Arr::get($subscriptionData, 'config', []);
968 $subscriptionConfig[SubscriptionManagementMode::CONFIG_KEY] = SubscriptionManagementMode::STORE_MANAGED;
969 $subscriptionData['config'] = $subscriptionConfig;
970 }
971
972 $this->subscriptionData = $subscriptionData;
973 }
974
975 private function determineCollectionMethod(): string
976 {
977 if (SubscriptionManagementMode::isStoreManaged()) {
978 $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
979
980 return SubscriptionManagementMode::resolveCollectionMethodFor(
981 GatewayManager::getInstance()->get($paymentMethod)
982 );
983 }
984
985 $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
986 $gateway = GatewayManager::getInstance()->get($paymentMethod);
987
988 if ($gateway && $gateway->has('subscriptions')) {
989 return 'automatic';
990 }
991
992 return 'manual';
993 }
994
995 private function prepareOrderData()
996 {
997 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
998 return $item['fulfillment_type'] === 'physical';
999 });
1000
1001 $hasSubscription = array_filter($this->formattedIOrderItems, function ($item) {
1002 return $item['payment_type'] === 'subscription';
1003 });
1004
1005 $itemsSubtotal = array_reduce($this->formattedIOrderItems, function ($carry, $item) {
1006 if (Arr::get($item, 'other_info.trial_days', 0) > 0) {
1007 return $carry;
1008 }
1009 // Fee items are tracked separately via fee_total
1010 if (Arr::get($item, 'payment_type') === 'fee') {
1011 return $carry;
1012 }
1013 return $carry + $item['subtotal'];
1014 }, 0);
1015
1016 $taxBehavior = (int) Arr::get($this->args, 'tax_behavior', 0);
1017 $storeTaxBehavior = (int) Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
1018 $exclusiveTaxTotal = (int) Arr::get($this->args, 'exclusive_tax_total', 0);
1019 $feeTax = (int) Arr::get($this->args, 'fee_tax', 0);
1020
1021 // Roll fee tax into fee_total for exclusive scenarios — gateways use fee_total as source of truth.
1022 if ($feeTax && ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1))) {
1023 $this->feeTotal += $feeTax;
1024 }
1025
1026 $this->prorateCreditTotal = (int) Arr::get($this->args, 'prorate_credit', 0);
1027 // Upgrade-path discount is a post-tax adjustment like the prorate credit: it does
1028 // not reduce the taxable base (tax args were computed on the full price), it only
1029 // reduces the payable total via manual_discount_total below.
1030 $this->upgradeDiscountTotal = (int) Arr::get($this->args, 'upgrade_discount', 0);
1031
1032 $orderData = [
1033 'status' => Status::ORDER_ON_HOLD,
1034 'fulfillment_type' => $hasPhysical ? Status::FULFILLMENT_TYPE_PHYSICAL : Status::FULFILLMENT_TYPE_DIGITAL,
1035 'type' => $hasSubscription ? Status::ORDER_TYPE_SUBSCRIPTION : Status::ORDER_TYPE_PAYMENT, // revisit this on manual renewal
1036 'mode' => $this->storeSettings->get('order_mode', 'test'),
1037 'shipping_status' => $hasPhysical ? 'unshipped' : '',
1038 'customer_id' => '',
1039 'payment_method' => Arr::get($this->args, 'payment_method', ''),
1040 'payment_status' => Status::PAYMENT_PENDING,
1041 'payment_method_title' => '',
1042 'currency' => $this->storeSettings->get('currency'),
1043 'subtotal' => $itemsSubtotal,
1044 'discount_tax' => 0,
1045 'manual_discount_total' => $this->manualDiscountTotal + $this->prorateCreditTotal + $this->upgradeDiscountTotal,
1046 'coupon_discount_total' => $this->couponDiscountTotal,
1047 'shipping_tax' => Arr::get($this->args, 'shipping_tax', 0),
1048 'shipping_total' => Arr::get($this->args, 'shipping_charge', 0),
1049 'fee_total' => $this->feeTotal,
1050 'tax_total' => Arr::get($this->args, 'tax_total', 0),
1051 'tax_behavior' => $taxBehavior,
1052 // 'total_amount' => $this->orderTotals['total_amount'],
1053 'total_paid' => 0,
1054 'total_refund' => 0,
1055 'rate' => 1,
1056 'note' => Arr::get($this->args, 'note', ''),
1057 'ip_address' => Arr::get($this->args, 'ip_address', ''),
1058 'config' => [
1059 'user_tz' => Arr::get($this->args, 'user_tz', ''),
1060 'create_account_after_paid' => Arr::get($this->args, 'create_account_after_paid', 'no'),
1061 'shipping_method_id' => Arr::get($this->args, 'shipping_method_id', 0),
1062 'shipping_method_title' => Arr::get($this->args, 'shipping_method_title', ''),
1063 'prorate_credit' => $this->prorateCreditTotal,
1064 'upgrade_discount' => $this->upgradeDiscountTotal,
1065 ],
1066 ];
1067
1068 if ($taxBehavior === 1) {
1069 // Pure exclusive: fee_tax is already in fee_total; exclude it here to avoid double-count.
1070 $estimatedTaxTotal = $orderData['tax_total'] - $feeTax;
1071 $estimatedShippingTax = $orderData['shipping_tax'];
1072 } elseif ($taxBehavior === 3) {
1073 // Mixed: fee_tax rolled into fee_total for exclusive store; shipping still additive.
1074 $estimatedTaxTotal = $exclusiveTaxTotal;
1075 $estimatedShippingTax = ($storeTaxBehavior === 1) ? $orderData['shipping_tax'] : 0;
1076 } else {
1077 // Inclusive (2) or reverse-charge (0): nothing to add to total; tax is in item prices.
1078 $estimatedTaxTotal = 0;
1079 $estimatedShippingTax = 0;
1080 if ($taxBehavior !== 2) {
1081 // Reverse-charge (0): zero stored columns — no tax applies.
1082 // Inclusive (2): keep orderData values so reporting surfaces can read them.
1083 $orderData['tax_total'] = 0;
1084 $orderData['shipping_tax'] = 0;
1085 }
1086 }
1087
1088 $totalAmount = $orderData['subtotal']
1089 - $orderData['coupon_discount_total']
1090 - $orderData['manual_discount_total']
1091 + $orderData['fee_total']
1092 + $orderData['shipping_total']
1093 + $estimatedTaxTotal
1094 + $estimatedShippingTax;
1095
1096 $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0;
1097 $this->orderData = $orderData;
1098 }
1099
1100 private function syncFeeItems()
1101 {
1102 $orderId = $this->orderModel->id;
1103
1104 // Remove existing fee items
1105 OrderItem::query()->where('order_id', $orderId)->where('payment_type', 'fee')->delete();
1106
1107 // Create new fee items from formatted items
1108 $feeItems = array_filter($this->formattedIOrderItems, function ($item) {
1109 return $item['payment_type'] === 'fee';
1110 });
1111
1112 foreach ($feeItems as $feeItem) {
1113 $feeItem['order_id'] = $orderId;
1114 $feeItem['quantity'] = 1;
1115 $feeItem['line_total'] = $feeItem['subtotal'] - $feeItem['discount_total'];
1116 OrderItem::query()->create($feeItem);
1117 }
1118 }
1119
1120 /**
1121 * @param $inputData
1122 * @return array
1123 */
1124 private function convertToSubscriptionFormat($inputData)
1125 {
1126
1127 /**
1128 * Normal Subscription $100/month
1129 * {
1130 * 'trial_days' => 0,
1131 * 'repeat_interval' => 'month',
1132 * 'times' => 0, // 0 means unlimited
1133 * 'recurring_amount' => 100,
1134 * 'signup_fee' => 0
1135 * }
1136 *
1137 * 30 Days Trial $100/month
1138 * {
1139 * 'trial_days' => 30,
1140 * 'repeat_interval' => 'month',
1141 * 'times' => 0, // 0 means unlimited
1142 * 'recurring_amount' => 100,
1143 * }
1144 *
1145 * $100/month - 30 days Trial with $40 signup fee
1146 * {
1147 * 'trial_days' => 30,
1148 * 'repeat_interval' => 'month',
1149 * 'times' => 0, // 0 means unlimited
1150 * 'recurring_amount' => 100,
1151 * 'signup_fee' => 40
1152 * }
1153 *
1154 * $100 / month with $40 signup fee
1155 * {
1156 * 'trial_days' => 0,
1157 * 'repeat_interval' => 'month',
1158 * 'times' => 0, // 0 means unlimited
1159 * 'recurring_amount' => 100,
1160 * 'signup_fee' => 40
1161 * }
1162 *
1163 * $100 per month but 30% discount on first month
1164 * {
1165 * 'trial_days' => 30,
1166 * 'repeat_interval' => 'month',
1167 * 'times' => 0, // 0 means unlimited
1168 * 'recurring_amount' => 100,
1169 * 'signup_fee' => 70
1170 * }
1171 *
1172 * $100 per month but 50% extra on first month
1173 * {
1174 * 'trial_days' => 0,
1175 * 'repeat_interval' => 'month',
1176 * 'times' => 0, // 0 means unlimited
1177 * 'recurring_amount' => 100,
1178 * 'signup_fee' => 50
1179 * }
1180 *
1181 * $100 per month and 1 month trial and service_fee = $50
1182 * {
1183 * 'trial_days' => 30,
1184 * 'repeat_interval' => 'month',
1185 * 'times' => 0, // 0 means unlimited
1186 * 'recurring_amount' => 100,
1187 * 'signup_fee' => 50
1188 * }
1189 *
1190 *
1191 * $100 per month, signup fee $200 - First Month 50% discount
1192 *
1193 * $first Month = 100 + 200 = 300 - 50% discount = 150
1194 * {
1195 * 'trial_days' => 30,
1196 * 'repeat_interval' => 'month',
1197 * 'times' => 0, // 0 means unlimited
1198 * 'recurring_amount' => 100,
1199 * 'signup_fee' => 150
1200 * }
1201 *
1202 * // prefered when signup_fee > recurring_amount
1203 * {
1204 * 'trial_days' => 0,
1205 * 'repeat_interval' => 'month',
1206 * 'times' => 0, // 0 means unlimited
1207 * 'recurring_amount' => 100,
1208 * 'signup_fee' => 50
1209 * }
1210 */
1211
1212
1213 // Extract and validate input data
1214 $trialDays = (int)($inputData['initial_trial_days'] ?? 0);
1215 $repeatInterval = strtolower(trim($inputData['repeat_interval'] ?? 'yearly'));
1216 $times = (int)($inputData['times'] ?? 0);
1217 $recurringAmount = (int)($inputData['recurring_amount'] ?? 0);
1218 $recurringTax = (int)($inputData['recurring_tax_total'] ?? 0);
1219 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1220 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1221 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1222 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1223
1224 // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1225 $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1226 $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
1227 $isAdditiveTax = ($taxBehavior === 1) || ($taxBehavior === 3 && !$itemInclusive);
1228
1229 // Validate repeat_interval
1230 $validIntervals = array_keys(Helper::getAvailableSubscriptionIntervalMaps());
1231
1232 if (!in_array($repeatInterval, $validIntervals)) {
1233 $repeatInterval = 'yearly';
1234 }
1235
1236 // Convert repeat_interval to standard format
1237 $intervalMap = Helper::getAvailableSubscriptionIntervalMaps();
1238 $standardInterval = Helper::translateIntervalToStandardFormat($repeatInterval);
1239
1240 // Calculate trial days based on interval
1241
1242
1243 // Initialize result array
1244 $result = [
1245 'trial_days' => $trialDays,
1246 'repeat_interval' => $standardInterval,
1247 'times' => $times,
1248 ];
1249
1250
1251 // Calculate signup fee logic
1252 if ($totalDiscount > 0) {
1253 // case: discount applied on subscription with trial days and have signup_fee, otherise discount can't be applied on subscription with trial days.
1254 if ($trialDays > 0 && $signupFee > 0) {
1255 $result['signup_fee'] = max(0, $signupFee - $totalDiscount);
1256 $result['manage_setup_fee'] = 'yes';
1257
1258 if ($isAdditiveTax && $firstIterationTax) {
1259 $result['signup_fee'] += $firstIterationTax;
1260 }
1261 } else {
1262 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1263
1264 if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1265 $recurringAmount -= $totalDiscount; // as now discount applied on recurring amount
1266 }
1267
1268 if ($firstCycleCost < $recurringAmount) {
1269 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
1270
1271 $result['trial_days'] = $adjustedTrialDays;
1272 $result['is_trial_days_simulated'] = 'yes';
1273 $result['signup_fee'] = $firstCycleCost;
1274 $result['manage_setup_fee'] = 'yes';
1275 // bill_times stays the full installment count. The simulated trial cycle IS the
1276 // first installment (charged as one-time payment / free when 100% discounted);
1277 // gateways derive the remaining remote cycles from is_trial_days_simulated.
1278 } else if ($firstCycleCost > $recurringAmount) {
1279 $result['trial_days'] = 0;
1280 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1281 $result['manage_setup_fee'] = 'yes';
1282 } else if ($firstCycleCost == $recurringAmount) {
1283 $result['trial_days'] = 0;
1284 $result['signup_fee'] = 0;
1285 $result['manage_setup_fee'] = 'no';
1286 }
1287
1288 // only the signup fee is adjustable on our system, so we can adjust that to our needs
1289 if ($isAdditiveTax && $firstIterationTax) {
1290 if ($result['trial_days'] > 0) {
1291 $result['signup_fee'] += $firstIterationTax;
1292 } else {
1293 $result['signup_fee'] += ($firstIterationTax - $recurringTax); // need to minus the recurring tax as it would add automatically to the payable amount as no trial days
1294 }
1295 }
1296 }
1297
1298 } else {
1299 $result['signup_fee'] = $signupFee;
1300
1301 if ($isAdditiveTax) {
1302 if ($signupFeeTax) {
1303 $result['signup_fee'] += $signupFeeTax;
1304 }
1305 }
1306 }
1307
1308
1309 $result['repeat_interval'] = array_flip($intervalMap)[$result['repeat_interval']] ?? 'yearly';
1310
1311
1312 return [
1313 'billing_interval' => $result['repeat_interval'],
1314 'bill_times' => $result['times'],
1315 'trial_days' => $result['trial_days'],
1316 'is_trial_days_simulated' => Arr::get($result, 'is_trial_days_simulated', 'no'),
1317 'recurring_amount' => $recurringAmount,
1318 'recurring_tax_total' => $recurringTax,
1319 'signup_fee' => $result['signup_fee'] ?? 0,
1320 ];
1321 }
1322
1323 /**
1324 * bill_count is derived from counting total > 0 CHARGE transactions linked to
1325 * the subscription (see syncSubscriptionStates / getRequiredBillTimes), which
1326 * can't tell "this was a billed cycle" from "this was something else
1327 * charged alongside it." Two corrections needed only at initial checkout —
1328 * is_trial_days_simulated alone can't be used at runtime because
1329 * payment-method switching also sets that flag:
1330 *
1331 * - Simulated trial, $0 first cycle: consumes a cycle but produces no
1332 * total > 0 transaction — add billed_cycles_offset so it still counts.
1333 * - Real trial with a signup fee: the initial charge is the signup fee only
1334 * (the recurring item isn't billed yet), but it IS a total > 0 transaction
1335 * linked to the subscription — mark billed_cycles_deduction so it does
1336 * NOT count as a cycle.
1337 */
1338 private function syncInitialCycleCounting()
1339 {
1340 if (!$this->subscriptionModel) {
1341 return;
1342 }
1343
1344 $isSimulated = Arr::get($this->subscriptionData, 'config.is_trial_days_simulated', 'no') === 'yes';
1345 $trialDays = (int)Arr::get($this->subscriptionData, 'trial_days', 0);
1346 $billTimes = (int)$this->subscriptionModel->bill_times;
1347 $orderTotal = (int)$this->orderModel->total_amount;
1348
1349 // signup_fee <= 0 (not just == 0): prorate/upgrade credit can push the
1350 // first cycle cost negative — still a free first cycle for counting
1351 $isFreeFirstCycle = $isSimulated
1352 && $billTimes > 0
1353 && (int)$this->subscriptionModel->signup_fee <= 0
1354 && !$orderTotal;
1355
1356 if ($isFreeFirstCycle) {
1357 $this->subscriptionModel->updateMeta('billed_cycles_offset', 1);
1358 } else {
1359 $this->subscriptionModel->deleteMeta('billed_cycles_offset');
1360 }
1361
1362 $isRealTrialWithCharge = !$isSimulated
1363 && $trialDays > 0
1364 && $billTimes > 0
1365 && $orderTotal > 0;
1366
1367 if ($isRealTrialWithCharge) {
1368 $this->subscriptionModel->updateMeta('billed_cycles_deduction', 1);
1369 } else {
1370 $this->subscriptionModel->deleteMeta('billed_cycles_deduction');
1371 }
1372 }
1373 }
1374