PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Helpers / CheckoutProcessor.php

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

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