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

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

1,507 lines 65.0 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 // A shipping pricing rule discounts this order's postage only; renewals bill the full charge.
977 $shippingDiscount = $isPhysicalProduct ? (int)Arr::get($this->args, 'shipping_discount', 0) : 0;
978 if ($shippingDiscount > 0) {
979 $renewalTotal = $recurringTotal + $shippingDiscount;
980 // A fully discounted charge left its shipping tax out above as well.
981 if ($shippingCharge <= 0) {
982 $storeTaxBehavior = (int)Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
983 if ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1)) {
984 $renewalTotal += (int)Arr::get($this->args, 'shipping_tax', 0);
985 }
986 }
987
988 $subscriptionPricing = $this->billRenewalsFullShipping($subscriptionPricing, $recurringTotal, $renewalTotal, $shippingDiscount);
989 $recurringTotal = $renewalTotal;
990 }
991
992 // removable upon discussion
993 $subscriptionItem = [
994 'product_id' => $item['post_id'],
995 'current_payment_method' => Arr::get($this->orderData, 'payment_method'),
996 'object_id' => $item['object_id'],
997 'recurring_tax_total' => 0,
998 'recurring_total' => $recurringTotal, //use price not line_total to ignore discount
999 'item_name' => $item['post_title'] . ' - ' . $item['title'],
1000 'bill_count' => 0,
1001 'quantity' => 1,
1002 'variation_id' => Arr::get($item, 'object_id', 0),
1003 'status' => Status::SUBSCRIPTION_PENDING,
1004 'config' => [
1005 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
1006 'currency' => $this->orderData['currency'],
1007 // Snapshot the variant attribute map + variation type from the order
1008 // item so the subscription carries the same pa_* set behind its item_name.
1009 'item_attributes' => Arr::get($item, 'other_info.item_attributes', []),
1010 'variation_type' => Arr::get($item, 'other_info.variation_type', '')
1011 ]
1012 ];
1013
1014 $subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
1015 $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1016
1017 $collectionMethod = apply_filters('fluent_cart/subscription_collection_method_' . $paymentMethod, $this->determineCollectionMethod());
1018
1019 // A filter can hand back anything, but `system` only means something on a
1020 // gateway that can charge a saved payment method.
1021 $subscriptionData['collection_method'] = SubscriptionManagementMode::sanitizeCollectionMethod(
1022 $collectionMethod,
1023 GatewayManager::getInstance()->get($paymentMethod)
1024 );
1025
1026 // Stamp store-managed origin durably on the subscription. Gateways consult
1027 // the stamp (not the current store setting) before converting a manual
1028 // subscription to automatic, so switching the mode back to gateway-managed
1029 // later never flips subscriptions born under store-managed.
1030 if (in_array($subscriptionData['collection_method'], ['manual', 'system'], true) && SubscriptionManagementMode::isStoreManaged()) {
1031 $subscriptionConfig = Arr::get($subscriptionData, 'config', []);
1032 $subscriptionConfig[SubscriptionManagementMode::CONFIG_KEY] = SubscriptionManagementMode::STORE_MANAGED;
1033 $subscriptionData['config'] = $subscriptionConfig;
1034 }
1035
1036 $this->subscriptionData = $subscriptionData;
1037 }
1038
1039 /**
1040 * Raise the per-cycle price to the full postage while keeping the first payment.
1041 *
1042 * A gateway takes the signup fee alone during a trial, otherwise one cycle plus the
1043 * signup fee. So without a trial the fee absorbs the difference, or the first cycle
1044 * becomes a simulated trial, the same way a first-cycle coupon is expressed.
1045 *
1046 * @param array $pricing From convertToSubscriptionFormat(), built on the discounted postage.
1047 * @param int $firstCycleTotal Recurring total on the discounted postage, in cents.
1048 * @param int $renewalTotal Recurring total on the full postage, in cents.
1049 * @param int $shippingDiscount In cents.
1050 * @return array
1051 */
1052 private function billRenewalsFullShipping(array $pricing, $firstCycleTotal, $renewalTotal, $shippingDiscount)
1053 {
1054 $pricing['recurring_amount'] = (int)$pricing['recurring_amount'] + $shippingDiscount;
1055
1056 if ((int)$pricing['trial_days'] > 0) {
1057 return $pricing;
1058 }
1059
1060 $extra = $renewalTotal - $firstCycleTotal;
1061 $signupFee = (int)$pricing['signup_fee'];
1062
1063 if ($signupFee >= $extra) {
1064 $pricing['signup_fee'] = $signupFee - $extra;
1065 return $pricing;
1066 }
1067
1068 $pricing['trial_days'] = Helper::calculateAdjustedTrialDaysForInterval(0, $pricing['billing_interval']);
1069 $pricing['is_trial_days_simulated'] = 'yes';
1070 $pricing['signup_fee'] = $firstCycleTotal + $signupFee;
1071
1072 return $pricing;
1073 }
1074
1075 private function determineCollectionMethod(): string
1076 {
1077 if (SubscriptionManagementMode::isStoreManaged()) {
1078 $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1079
1080 return SubscriptionManagementMode::resolveCollectionMethodFor(
1081 GatewayManager::getInstance()->get($paymentMethod)
1082 );
1083 }
1084
1085 $paymentMethod = Arr::get($this->orderData, 'payment_method', '');
1086 $gateway = GatewayManager::getInstance()->get($paymentMethod);
1087
1088 if ($gateway && $gateway->has('subscriptions')) {
1089 return 'automatic';
1090 }
1091
1092 return 'manual';
1093 }
1094
1095 private function prepareOrderData()
1096 {
1097 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
1098 return $item['fulfillment_type'] === 'physical';
1099 });
1100
1101 $hasSubscription = array_filter($this->formattedIOrderItems, function ($item) {
1102 return $item['payment_type'] === 'subscription';
1103 });
1104
1105 $itemsSubtotal = array_reduce($this->formattedIOrderItems, function ($carry, $item) {
1106 if (Arr::get($item, 'other_info.trial_days', 0) > 0) {
1107 return $carry;
1108 }
1109 // Fee items are tracked separately via fee_total
1110 if (Arr::get($item, 'payment_type') === 'fee') {
1111 return $carry;
1112 }
1113 return $carry + $item['subtotal'];
1114 }, 0);
1115
1116 $taxBehavior = (int) Arr::get($this->args, 'tax_behavior', 0);
1117 $storeTaxBehavior = (int) Arr::get($this->args, 'store_tax_behavior', $taxBehavior);
1118 $exclusiveTaxTotal = (int) Arr::get($this->args, 'exclusive_tax_total', 0);
1119 $feeTax = (int) Arr::get($this->args, 'fee_tax', 0);
1120
1121 // Roll fee tax into fee_total for exclusive scenarios — gateways use fee_total as source of truth.
1122 if ($feeTax && ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1))) {
1123 $this->feeTotal += $feeTax;
1124 }
1125
1126 $this->prorateCreditTotal = (int) Arr::get($this->args, 'prorate_credit', 0);
1127 // Upgrade-path discount is a post-tax adjustment like the prorate credit: it does
1128 // not reduce the taxable base (tax args were computed on the full price), it only
1129 // reduces the payable total via manual_discount_total below.
1130 $this->upgradeDiscountTotal = (int) Arr::get($this->args, 'upgrade_discount', 0);
1131
1132 $orderData = [
1133 'status' => Status::ORDER_ON_HOLD,
1134 'fulfillment_type' => $hasPhysical ? Status::FULFILLMENT_TYPE_PHYSICAL : Status::FULFILLMENT_TYPE_DIGITAL,
1135 'type' => $hasSubscription ? Status::ORDER_TYPE_SUBSCRIPTION : Status::ORDER_TYPE_PAYMENT, // revisit this on manual renewal
1136 'mode' => $this->storeSettings->get('order_mode', 'test'),
1137 'shipping_status' => $hasPhysical ? 'unshipped' : '',
1138 'customer_id' => '',
1139 'payment_method' => Arr::get($this->args, 'payment_method', ''),
1140 'payment_status' => Status::PAYMENT_PENDING,
1141 'payment_method_title' => '',
1142 'currency' => $this->storeSettings->get('currency'),
1143 'subtotal' => $itemsSubtotal,
1144 'discount_tax' => 0,
1145 'manual_discount_total' => $this->manualDiscountTotal + $this->prorateCreditTotal + $this->upgradeDiscountTotal,
1146 'coupon_discount_total' => $this->couponDiscountTotal,
1147 'shipping_tax' => Arr::get($this->args, 'shipping_tax', 0),
1148 'shipping_total' => Arr::get($this->args, 'shipping_charge', 0),
1149 'fee_total' => $this->feeTotal,
1150 'tax_total' => Arr::get($this->args, 'tax_total', 0),
1151 'tax_behavior' => $taxBehavior,
1152 // 'total_amount' => $this->orderTotals['total_amount'],
1153 'total_paid' => 0,
1154 'total_refund' => 0,
1155 'rate' => 1,
1156 'note' => Arr::get($this->args, 'note', ''),
1157 'ip_address' => Arr::get($this->args, 'ip_address', ''),
1158 'config' => [
1159 'user_tz' => Arr::get($this->args, 'user_tz', ''),
1160 'create_account_after_paid' => Arr::get($this->args, 'create_account_after_paid', 'no'),
1161 'shipping_method_id' => Arr::get($this->args, 'shipping_method_id', 0),
1162 'shipping_method_title' => Arr::get($this->args, 'shipping_method_title', ''),
1163 'shipping_discount' => (int) Arr::get($this->args, 'shipping_discount', 0),
1164 'prorate_credit' => $this->prorateCreditTotal,
1165 'upgrade_discount' => $this->upgradeDiscountTotal,
1166 ],
1167 ];
1168
1169 if ($taxBehavior === 1) {
1170 // Pure exclusive: fee_tax is already in fee_total; exclude it here to avoid double-count.
1171 $estimatedTaxTotal = $orderData['tax_total'] - $feeTax;
1172 $estimatedShippingTax = $orderData['shipping_tax'];
1173 } elseif ($taxBehavior === 3) {
1174 // Mixed: fee_tax rolled into fee_total for exclusive store; shipping still additive.
1175 $estimatedTaxTotal = $exclusiveTaxTotal;
1176 $estimatedShippingTax = ($storeTaxBehavior === 1) ? $orderData['shipping_tax'] : 0;
1177 } else {
1178 // Inclusive (2) or reverse-charge (0): nothing to add to total; tax is in item prices.
1179 $estimatedTaxTotal = 0;
1180 $estimatedShippingTax = 0;
1181 if ($taxBehavior !== 2) {
1182 // Reverse-charge (0): zero stored columns — no tax applies.
1183 // Inclusive (2): keep orderData values so reporting surfaces can read them.
1184 $orderData['tax_total'] = 0;
1185 $orderData['shipping_tax'] = 0;
1186 }
1187 }
1188
1189 $totalAmount = $orderData['subtotal']
1190 - $orderData['coupon_discount_total']
1191 - $orderData['manual_discount_total']
1192 + $orderData['fee_total']
1193 + $orderData['shipping_total']
1194 + $estimatedTaxTotal
1195 + $estimatedShippingTax;
1196
1197 $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0;
1198
1199 /**
1200 * Filter the prepared order data before it is used for order creation.
1201 *
1202 * This runs after FluentCart calculates totals, so plugins can adjust
1203 * currency, rate, totals, config, mode, or any other order field before
1204 * the order model, transaction, and subscription are derived from it.
1205 *
1206 * @param array $orderData Prepared order data array.
1207 * @param array $context {
1208 * Additional context for the filter.
1209 *
1210 * @type array $items Formatted order items with prices and quantities.
1211 * @type array $args Checkout arguments: customer data, payment method,
1212 * shipping, tax, coupons, fees, and IP data.
1213 * }
1214 */
1215 $orderData = apply_filters('fluent_cart/checkout/order_data', $orderData, [
1216 'items' => $this->formattedIOrderItems,
1217 'args' => $this->args,
1218 ]);
1219
1220 if (!$this->isSafeAmount(Arr::get($orderData, 'total_amount', 0))) {
1221 $this->validationError = $this->unsafeAmountError();
1222
1223 return;
1224 }
1225
1226 $this->orderData = $orderData;
1227 }
1228
1229 private function syncFeeItems()
1230 {
1231 $orderId = $this->orderModel->id;
1232
1233 // Remove existing fee items
1234 OrderItem::query()->where('order_id', $orderId)->where('payment_type', 'fee')->delete();
1235
1236 // Create new fee items from formatted items
1237 $feeItems = array_filter($this->formattedIOrderItems, function ($item) {
1238 return $item['payment_type'] === 'fee';
1239 });
1240
1241 foreach ($feeItems as $feeItem) {
1242 $feeItem['order_id'] = $orderId;
1243 $feeItem['quantity'] = 1;
1244 $feeItem['line_total'] = $feeItem['subtotal'] - $feeItem['discount_total'];
1245 OrderItem::query()->create($feeItem);
1246 }
1247 }
1248
1249 /**
1250 * @param $inputData
1251 * @return array
1252 */
1253 private function convertToSubscriptionFormat($inputData)
1254 {
1255
1256 /**
1257 * Normal Subscription $100/month
1258 * {
1259 * 'trial_days' => 0,
1260 * 'repeat_interval' => 'month',
1261 * 'times' => 0, // 0 means unlimited
1262 * 'recurring_amount' => 100,
1263 * 'signup_fee' => 0
1264 * }
1265 *
1266 * 30 Days Trial $100/month
1267 * {
1268 * 'trial_days' => 30,
1269 * 'repeat_interval' => 'month',
1270 * 'times' => 0, // 0 means unlimited
1271 * 'recurring_amount' => 100,
1272 * }
1273 *
1274 * $100/month - 30 days Trial with $40 signup fee
1275 * {
1276 * 'trial_days' => 30,
1277 * 'repeat_interval' => 'month',
1278 * 'times' => 0, // 0 means unlimited
1279 * 'recurring_amount' => 100,
1280 * 'signup_fee' => 40
1281 * }
1282 *
1283 * $100 / month with $40 signup fee
1284 * {
1285 * 'trial_days' => 0,
1286 * 'repeat_interval' => 'month',
1287 * 'times' => 0, // 0 means unlimited
1288 * 'recurring_amount' => 100,
1289 * 'signup_fee' => 40
1290 * }
1291 *
1292 * $100 per month but 30% discount on first month
1293 * {
1294 * 'trial_days' => 30,
1295 * 'repeat_interval' => 'month',
1296 * 'times' => 0, // 0 means unlimited
1297 * 'recurring_amount' => 100,
1298 * 'signup_fee' => 70
1299 * }
1300 *
1301 * $100 per month but 50% extra on first month
1302 * {
1303 * 'trial_days' => 0,
1304 * 'repeat_interval' => 'month',
1305 * 'times' => 0, // 0 means unlimited
1306 * 'recurring_amount' => 100,
1307 * 'signup_fee' => 50
1308 * }
1309 *
1310 * $100 per month and 1 month trial and service_fee = $50
1311 * {
1312 * 'trial_days' => 30,
1313 * 'repeat_interval' => 'month',
1314 * 'times' => 0, // 0 means unlimited
1315 * 'recurring_amount' => 100,
1316 * 'signup_fee' => 50
1317 * }
1318 *
1319 *
1320 * $100 per month, signup fee $200 - First Month 50% discount
1321 *
1322 * $first Month = 100 + 200 = 300 - 50% discount = 150
1323 * {
1324 * 'trial_days' => 30,
1325 * 'repeat_interval' => 'month',
1326 * 'times' => 0, // 0 means unlimited
1327 * 'recurring_amount' => 100,
1328 * 'signup_fee' => 150
1329 * }
1330 *
1331 * // prefered when signup_fee > recurring_amount
1332 * {
1333 * 'trial_days' => 0,
1334 * 'repeat_interval' => 'month',
1335 * 'times' => 0, // 0 means unlimited
1336 * 'recurring_amount' => 100,
1337 * 'signup_fee' => 50
1338 * }
1339 */
1340
1341
1342 // Extract and validate input data
1343 $trialDays = (int)($inputData['initial_trial_days'] ?? 0);
1344 $repeatInterval = strtolower(trim($inputData['repeat_interval'] ?? 'yearly'));
1345 $times = (int)($inputData['times'] ?? 0);
1346 $recurringAmount = (int)($inputData['recurring_amount'] ?? 0);
1347 $recurringTax = (int)($inputData['recurring_tax_total'] ?? 0);
1348 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1349 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1350 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1351 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1352 $recurringDiscount = (int)($inputData['recurring_discount'] ?? 0);
1353
1354 // Determine if THIS subscription item is tax-inclusive (for behavior=3 mixed carts)
1355 $taxBehavior = (int) Arr::get($inputData, 'tax_behavior', 0);
1356 $itemInclusive = (bool) Arr::get($inputData, 'line_meta.tax_config.inclusive', false);
1357 $isAdditiveTax = ($taxBehavior === 1) || ($taxBehavior === 3 && !$itemInclusive);
1358
1359 // Validate repeat_interval
1360 $validIntervals = array_keys(Helper::getAvailableSubscriptionIntervalMaps());
1361
1362 if (!in_array($repeatInterval, $validIntervals)) {
1363 $repeatInterval = 'yearly';
1364 }
1365
1366 // Convert repeat_interval to standard format
1367 $intervalMap = Helper::getAvailableSubscriptionIntervalMaps();
1368 $standardInterval = Helper::translateIntervalToStandardFormat($repeatInterval);
1369
1370 // Calculate trial days based on interval
1371
1372
1373 // Initialize result array
1374 $result = [
1375 'trial_days' => $trialDays,
1376 'repeat_interval' => $standardInterval,
1377 'times' => $times,
1378 ];
1379
1380
1381 // Calculate signup fee logic
1382 if ($totalDiscount > 0) {
1383 // case: discount applied on subscription with trial days and have signup_fee, otherise discount can't be applied on subscription with trial days.
1384 if ($trialDays > 0 && $signupFee > 0) {
1385 $result['signup_fee'] = max(0, $signupFee - $totalDiscount);
1386 $result['manage_setup_fee'] = 'yes';
1387
1388 if ($isAdditiveTax && $firstIterationTax) {
1389 $result['signup_fee'] += $firstIterationTax;
1390 }
1391 } else {
1392 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1393
1394 // A recurring coupon discounts every cycle, so the per-cycle price itself
1395 // is lower — the first cycle is not cheaper than the ones after it and
1396 // must not be expressed as a trial.
1397 if ($recurringDiscount > 0) {
1398 $recurringAmount -= $recurringDiscount;
1399 }
1400
1401 if ($firstCycleCost < $recurringAmount) {
1402 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
1403
1404 $result['trial_days'] = $adjustedTrialDays;
1405 $result['is_trial_days_simulated'] = 'yes';
1406 $result['signup_fee'] = $firstCycleCost;
1407 $result['manage_setup_fee'] = 'yes';
1408 // bill_times stays the full installment count. The simulated trial cycle IS the
1409 // first installment (charged as one-time payment / free when 100% discounted);
1410 // gateways derive the remaining remote cycles from is_trial_days_simulated.
1411 } else if ($firstCycleCost > $recurringAmount) {
1412 $result['trial_days'] = 0;
1413 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1414 $result['manage_setup_fee'] = 'yes';
1415 } else if ($firstCycleCost == $recurringAmount) {
1416 $result['trial_days'] = 0;
1417 $result['signup_fee'] = 0;
1418 $result['manage_setup_fee'] = 'no';
1419 }
1420
1421 // only the signup fee is adjustable on our system, so we can adjust that to our needs
1422 if ($isAdditiveTax && $firstIterationTax) {
1423 if ($result['trial_days'] > 0) {
1424 $result['signup_fee'] += $firstIterationTax;
1425 } else {
1426 $result['signup_fee'] += ($firstIterationTax - $recurringTax); // need to minus the recurring tax as it would add automatically to the payable amount as no trial days
1427 }
1428 }
1429 }
1430
1431 } else {
1432 $result['signup_fee'] = $signupFee;
1433
1434 if ($isAdditiveTax) {
1435 if ($signupFeeTax) {
1436 $result['signup_fee'] += $signupFeeTax;
1437 }
1438 }
1439 }
1440
1441
1442 $result['repeat_interval'] = array_flip($intervalMap)[$result['repeat_interval']] ?? 'yearly';
1443
1444
1445 return [
1446 'billing_interval' => $result['repeat_interval'],
1447 'bill_times' => $result['times'],
1448 'trial_days' => $result['trial_days'],
1449 'is_trial_days_simulated' => Arr::get($result, 'is_trial_days_simulated', 'no'),
1450 'recurring_amount' => $recurringAmount,
1451 'recurring_tax_total' => $recurringTax,
1452 'signup_fee' => $result['signup_fee'] ?? 0,
1453 ];
1454 }
1455
1456 /**
1457 * bill_count is derived from counting total > 0 CHARGE transactions linked to
1458 * the subscription (see syncSubscriptionStates / getRequiredBillTimes), which
1459 * can't tell "this was a billed cycle" from "this was something else
1460 * charged alongside it." Two corrections needed only at initial checkout —
1461 * is_trial_days_simulated alone can't be used at runtime because
1462 * payment-method switching also sets that flag:
1463 *
1464 * - Simulated trial, $0 first cycle: consumes a cycle but produces no
1465 * total > 0 transaction — add billed_cycles_offset so it still counts.
1466 * - Real trial with a signup fee: the initial charge is the signup fee only
1467 * (the recurring item isn't billed yet), but it IS a total > 0 transaction
1468 * linked to the subscription — mark billed_cycles_deduction so it does
1469 * NOT count as a cycle.
1470 */
1471 private function syncInitialCycleCounting()
1472 {
1473 if (!$this->subscriptionModel) {
1474 return;
1475 }
1476
1477 $isSimulated = Arr::get($this->subscriptionData, 'config.is_trial_days_simulated', 'no') === 'yes';
1478 $trialDays = (int)Arr::get($this->subscriptionData, 'trial_days', 0);
1479 $billTimes = (int)$this->subscriptionModel->bill_times;
1480 $orderTotal = (int)$this->orderModel->total_amount;
1481
1482 // signup_fee <= 0 (not just == 0): prorate/upgrade credit can push the
1483 // first cycle cost negative — still a free first cycle for counting
1484 $isFreeFirstCycle = $isSimulated
1485 && $billTimes > 0
1486 && (int)$this->subscriptionModel->signup_fee <= 0
1487 && !$orderTotal;
1488
1489 if ($isFreeFirstCycle) {
1490 $this->subscriptionModel->updateMeta('billed_cycles_offset', 1);
1491 } else {
1492 $this->subscriptionModel->deleteMeta('billed_cycles_offset');
1493 }
1494
1495 $isRealTrialWithCharge = !$isSimulated
1496 && $trialDays > 0
1497 && $billTimes > 0
1498 && $orderTotal > 0;
1499
1500 if ($isRealTrialWithCharge) {
1501 $this->subscriptionModel->updateMeta('billed_cycles_deduction', 1);
1502 } else {
1503 $this->subscriptionModel->deleteMeta('billed_cycles_deduction');
1504 }
1505 }
1506 }
1507