PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 trunk 1.2.0 All 47 releases
fluent-cart / app / Helpers / CheckoutProcessor.php

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

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