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

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

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