PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.3
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.5.3, at app/Helpers/CheckoutProcessor.php

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