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

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

1,112 lines 46.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Helpers;
4
5 use FluentCart\Api\Checkout\CheckoutApi;
6 use FluentCart\Api\StoreSettings;
7 use FluentCart\App\Models\Cart;
8 use FluentCart\App\Models\Coupon;
9 use FluentCart\App\Models\Order;
10 use FluentCart\App\Models\OrderItem;
11 use FluentCart\App\Models\Product;
12 use FluentCart\App\Models\ProductVariation;
13 use FluentCart\App\Models\Subscription;
14 use FluentCart\App\Modules\Tax\TaxCalculator;
15 use FluentCart\Framework\Support\Arr;
16 use FluentCart\App\Helpers\Helper;
17
18 class CheckoutProcessor
19 {
20
21 // Raw Data
22 private $cartItems = [];
23 private $args = [];
24
25 // Order Related Data
26 private $formattedIOrderItems = [];
27 private $orderData = [];
28 private $subscriptionData = [];
29
30 // Models
31
32 private $orderModel;
33
34 private $transactionModel;
35
36 private $subscriptionModel;
37
38 // Fee tracking
39 private $feeTotal = 0;
40
41 // Store Settings
42 private $storeSettings;
43
44 private $couponDiscountTotal = 0;
45
46 private $manualDiscountTotal = 0;
47
48 public function __construct($cartItems = [], $args = [])
49 {
50 $this->storeSettings = new StoreSettings();
51 $this->cartItems = $cartItems;
52 $this->args = $args;
53
54 $this->prepareData();
55 }
56
57 private function prepareData()
58 {
59 $this->prepareOrderItems();
60 $this->prepareOrderData();
61 $this->prepareSubscriptionData();
62 }
63
64 public function createDraftOrder($prevOrder = null)
65 {
66 if ($prevOrder) {
67 return $this->getAdjustedOrder($prevOrder);
68 }
69
70 $customerId = Arr::get($this->args, 'customer_id', '');
71 if (!$customerId) {
72 return new \WP_Error('customer_id_missing', __('Customer ID is required to create a draft order.', 'fluent-cart'));
73 }
74
75 $orderData = $this->orderData;
76 $orderData['customer_id'] = $customerId;
77 if (empty($orderData['currency'])) {
78 $orderData['currency'] = $this->storeSettings->getCurrency();
79 }
80
81 if (empty($orderData['mode'])) {
82 $orderData['mode'] = $this->storeSettings->get('order_mode', 'test');
83 }
84
85 if (empty($orderData['fee_total'])) {
86 unset($orderData['fee_total']);
87 }
88
89 $this->orderModel = \FluentCart\App\Models\Order::query()->create($orderData);
90
91 if (!$this->orderModel) {
92 return new \WP_Error('order_creation_failed', __('Failed to create order.', 'fluent-cart'));
93 }
94
95 // save order meta
96 if (Arr::get($this->args, 'tax_id', 0)) {
97 $this->orderModel->updateMeta('tax_id', Arr::get($this->args, 'tax_id', 0));
98 }
99
100 // Let's create the order items
101 $normalOrderItems = array_filter($this->formattedIOrderItems, function ($item) {
102 return $item['payment_type'] != 'signup_fee';
103 });
104
105 foreach ($normalOrderItems as $orderItem) {
106 $orderItem['order_id'] = $this->orderModel->id;
107 $orderItem['line_total'] = $orderItem['subtotal'] - $orderItem['discount_total'];
108 $additionalItems = [];
109 $bundleItems = [];
110 if ($orderItem['payment_type'] == 'subscription') {
111 // this is a subscription type. We may have additional_items
112 $additionalItems = Arr::get($orderItem, 'additional_items', []);
113 unset($orderItem['additional_items']);
114 }
115
116 if (Arr::get($orderItem, 'other_info.is_bundle_product', 'no') == 'yes') {
117 $bundleItems = Arr::get($orderItem, 'bundle_items', []);
118 unset($orderItem['bundle_items']);
119 }
120
121 $createdItem = OrderItem::query()->create($orderItem);
122
123 if ($additionalItems) {
124 $additionalItemIds = [];
125 foreach ($additionalItems as $additionalItem) {
126 $additionalItem['order_id'] = $this->orderModel->id;
127 $additionalItem['line_total'] = Arr::get($additionalItem, 'subtotal', 0) - Arr::get($additionalItem, 'discount_total', 0);
128 $mata = Arr::get($additionalItem, 'line_meta', []);
129 $mata['parent_item_id'] = $createdItem->id;
130 $additionalItem['line_meta'] = $mata;
131 $childItem = OrderItem::query()->create($additionalItem);
132 $additionalItemIds[] = $childItem->id;
133 }
134
135 $createdItem->fill([
136 'line_meta' => array_merge(
137 $createdItem->line_meta,
138 [
139 'additional_item_ids' => $additionalItemIds
140 ]
141 )
142 ])->save();
143 }
144
145 if ($bundleItems) {
146 $bundleItemIds = [];
147 foreach ($bundleItems as $bundleItem) {
148 $bundleItem['order_id'] = $this->orderModel->id;
149 $bundleItem['line_total'] = Arr::get($bundleItem, 'subtotal', 0) - Arr::get($bundleItem, 'discount_total', 0);
150 $bundleItem['payment_type'] = 'bundle';
151 $meta = Arr::get($bundleItem, 'line_meta', []);
152 $meta['bundle_parent_item_id'] = $createdItem->id;
153 $bundleItem['line_meta'] = $meta;
154 $bundleItem = OrderItem::query()->create($bundleItem);
155 $bundleItemIds[] = $bundleItem->id;
156 }
157
158 $createdItem->fill([
159 'line_meta' => array_merge(
160 $createdItem->line_meta,
161 [
162 'bundle_item_ids' => $bundleItemIds
163 ]
164 )
165 ])->save();
166 }
167 }
168
169
170 // Let's create the subscription if exists
171 /*
172 * TODO : on renewal order we shouldn't create another subscription,
173 * basically on manual renew we just create a new sub on gateway and update the existing one on our database
174 * which automates the renewal cycle for the existing subscription
175 * ....created new sub remains on pending state, will create inconsistency
176 * */
177
178 if ($this->subscriptionData) {
179 $subscriptionData = $this->subscriptionData;
180 $subscriptionData['customer_id'] = $customerId;
181 $subscriptionData['parent_order_id'] = $this->orderModel->id;
182
183 $this->subscriptionModel = Subscription::query()->create($subscriptionData);
184 }
185
186 // Let's create the transaction
187 $transactionData = [
188 'order_id' => $this->orderModel->id,
189 'order_type' => $this->orderModel->type,
190 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
191 'subscription_id' => $this->subscriptionModel ? $this->subscriptionModel->id : NULL,
192 'payment_method' => $this->orderModel->payment_method,
193 'payment_mode' => $this->orderModel->mode,
194 'payment_method_type' => '',
195 'status' => Status::PAYMENT_PENDING,
196 'currency' => $this->orderModel->currency,
197 'total' => $this->orderModel->total_amount,
198 'rate' => 1,
199 'meta' => [],
200 ];
201
202 $this->transactionModel = \FluentCart\App\Models\OrderTransaction::query()->create($transactionData);
203
204 // insert the applied coupons
205 $this->insertAppliedCoupons(
206 Arr::get($this->args, 'applied_coupons', []),
207 false,
208 $this->orderModel
209 );
210
211 $cartHash = Arr::get($this->args, 'cart_hash', '');
212 if ($cartHash) {
213 $cart = Cart::query()->where('cart_hash', $cartHash)->first();
214 if ($cart) {
215 $cart->order_id = $this->orderModel->id;
216 $cart->customer_id = $this->orderModel->customer_id;
217 $cart->stage = 'intended';
218 $customer = $this->orderModel->customer;
219
220 if ($customer) {
221 $cart->first_name = $customer->first_name;
222 $cart->last_name = $customer->last_name;
223 $cart->email = $customer->email;
224 $cart->user_id = $customer->user_id;
225 }
226
227 $cart->save();
228 $actions = Arr::get($cart->checkout_data, '__after_draft_created_actions__', []);
229 if ($actions) {
230 foreach ($actions as $actionName) {
231 $actionName = (string)$actionName;
232 if (has_action($actionName)) {
233 do_action($actionName, [
234 'order' => $this->orderModel,
235 'cart' => $cart,
236 ]);
237 }
238 }
239
240 // We are just renewing it!
241 $this->orderModel = \FluentCart\App\Models\Order::query()
242 ->where('id', $this->orderModel->id)
243 ->first();
244 }
245 }
246 }
247
248 // We are almost done!
249 return $this->orderModel;
250 }
251
252 private function getAdjustedOrder(Order $prevOrder)
253 {
254 $isLocked = Arr::get($this->args, 'is_locked', false);
255
256 $orderData = $this->orderData;
257 $customerId = Arr::get($this->args, 'customer_id', '');
258 if ($customerId) {
259 $orderData['customer_id'] = $customerId;
260 }
261
262 if ($isLocked) {
263 $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) {
264 return $value !== null && $value !== '';
265 });
266
267 $prevConfig = $prevOrder->config;
268 $prevConfig['user_tz'] = Arr::get($this->args, 'user_tz', '');
269 $orderData['config'] = $prevConfig;
270 $prevOrder->fill($orderData);
271 $prevOrder->save();
272 } else {
273 $prevOrder->fill($orderData);
274 $prevOrder->save();
275 }
276
277 $this->orderModel = $prevOrder;
278
279 if (!$this->orderModel) {
280 return new \WP_Error('order_creation_failed', __('Failed to create order.', 'fluent-cart'));
281 }
282
283 //for previous order if tax is enabled then we need to update the tax total
284 $taxSettings = get_option('fluent_cart_tax_configuration_settings', []);
285 $taxEnabled = Arr::get($taxSettings, 'enable_tax', 'no');
286
287 if ($isLocked && $taxEnabled !== 'yes') {
288 // Locked orders skip full item sync, but fee items must stay in sync with fee_total
289 $this->syncFeeItems();
290 }
291
292 if (!$isLocked || $taxEnabled === 'yes') {
293 // Let's create the order items
294 $normalOrderItems = array_filter($this->formattedIOrderItems, function ($item) {
295 return $item['payment_type'] != 'signup_fee';
296 });
297
298 $createdItemIds = [];
299 $taxTotal = 0;
300 foreach ($normalOrderItems as $orderItem) {
301 $orderItem['order_id'] = $this->orderModel->id;
302 $orderItem['quantity'] = max(1, (int)$orderItem['quantity']);
303 $orderItem['line_total'] = $orderItem['subtotal'] - $orderItem['discount_total'];
304 $taxTotal += $orderItem['tax_amount'];
305 $additionalItems = [];
306 $bundleItems = [];
307 if ($orderItem['payment_type'] == 'subscription') {
308 // this is a subscription type. We may have additional_items
309 $additionalItems = Arr::get($orderItem, 'additional_items', []);
310 unset($orderItem['additional_items']);
311 }
312
313 if (Arr::get($orderItem, 'other_info.is_bundle_product', 'no') == 'yes') {
314 $bundleItems = Arr::get($orderItem, 'bundle_items', []);
315 unset($orderItem['bundle_items']);
316 }
317
318 $existingItem = OrderItem::query()->where('order_id', $this->orderModel->id)
319 ->whereNotIn('id', $createdItemIds)
320 ->first();
321
322 if ($existingItem) {
323 $existingItem->fill($orderItem);
324 $existingItem->save();
325 $createdItem = $existingItem;
326 } else {
327 $createdItem = OrderItem::query()->create($orderItem);
328 }
329
330 $createdItemIds[] = $createdItem->id;
331
332 if ($additionalItems) {
333 $additionalItemIds = [];
334 foreach ($additionalItems as $additionalItem) {
335 $additionalItem['order_id'] = $this->orderModel->id;
336 $additionalItem['quantity'] = max(1, (int)$additionalItem['quantity']);
337 $additionalItem['line_total'] = $additionalItem['subtotal'] - $additionalItem['discount_total'];
338 $mata = Arr::get($additionalItem, 'line_meta', []);
339 $mata['parent_item_id'] = $createdItem->id;
340 $additionalItem['line_meta'] = $mata;
341 $childItem = OrderItem::query()->create($additionalItem);
342 $createdItemIds[] = $childItem->id;
343 $additionalItemIds[] = $childItem->id;
344
345 }
346
347 $createdItem->fill([
348 'line_meta' => array_merge(
349 $createdItem->line_meta,
350 [
351 'additional_item_ids' => $additionalItemIds
352 ]
353 )
354 ])->save();
355 }
356
357 if ($bundleItems) {
358 $bundleItemIds = [];
359 foreach ($bundleItems as $bundleItem) {
360 $bundleItem['order_id'] = $this->orderModel->id;
361 $bundleItem['quantity'] = Arr::get($orderItem, 'quantity', 1);
362 $bundleItem['line_total'] = Arr::get($bundleItem, 'subtotal', 0) - Arr::get($bundleItem, 'discount_total', 0);
363 $bundleItem['payment_type'] = 'bundle';
364 $meta['bundle_parent_item_id'] = $createdItem->id;
365 $bundleItem['line_meta'] = $meta;
366 $childItem = OrderItem::query()->create($bundleItem);
367 $createdItemIds[] = $childItem->id;
368 $bundleItemIds[] = $childItem->id;
369 }
370
371 $createdItem->fill([
372 'line_meta' => array_merge(
373 $createdItem->line_meta,
374 [
375 'bundle_item_ids' => $bundleItemIds
376 ]
377 )
378 ])->save();
379 }
380 }
381 if ($taxTotal && !$this->orderModel->tax_total) {
382 $this->orderModel->tax_total = $taxTotal;
383 $this->orderModel->total_amount += $taxTotal;
384 $this->orderModel->save();
385 }
386
387 OrderItem::query()->where('order_id', $this->orderModel->id)->whereNotIn('id', $createdItemIds)->delete();
388
389 // Let's create the subscription if exists
390 if ($this->subscriptionData) {
391 if ($this->orderModel->type === Status::ORDER_TYPE_RENEWAL) {
392 // it's a renewal order
393 $this->subscriptionModel = Subscription::query()
394 ->where('parent_order_id', $this->orderModel->parent_id)
395 ->first();
396 } else {
397 $subscriptionData = $this->subscriptionData;
398 $subscriptionData['customer_id'] = $customerId;
399 $subscriptionData['parent_order_id'] = $this->orderModel->id;
400 $existingSubscription = Subscription::query()->where('parent_order_id', $this->orderModel->id)->first();
401 if ($existingSubscription) {
402 $existingSubscription->fill($subscriptionData);
403 $existingSubscription->save();
404 $this->subscriptionModel = $existingSubscription;
405 } else {
406 $this->subscriptionModel = Subscription::query()->create($subscriptionData);
407 }
408 }
409 } else {
410 Subscription::query()->where('parent_order_id', $this->orderModel->id)->delete();
411 }
412 }
413
414 // Reload the order to get fresh item data after updates
415 $this->orderModel = $this->orderModel->fresh();
416
417 // Let's create the transaction
418 $transactionData = [
419 'order_id' => $this->orderModel->id,
420 'order_type' => $this->orderModel->type,
421 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
422 'subscription_id' => $this->subscriptionModel ? $this->subscriptionModel->id : NULL,
423 'payment_method' => $this->orderModel->payment_method,
424 'payment_mode' => $this->orderModel->mode,
425 'payment_method_type' => '',
426 'status' => Status::PAYMENT_PENDING,
427 'currency' => $this->orderModel->currency,
428 'total' => $this->orderModel->total_amount,
429 'rate' => 1,
430 'meta' => [],
431 ];
432
433 if ($isLocked && $prevOrder->parent_id) {
434 $prevSubscription = Subscription::query()
435 ->where('parent_order_id', $prevOrder->parent_id)
436 ->first();
437
438 if ($prevSubscription) {
439 $transactionData['subscription_id'] = $prevSubscription->id;
440 }
441 }
442
443 $existingTransaction = \FluentCart\App\Models\OrderTransaction::query()
444 ->where('order_id', $this->orderModel->id)
445 ->first();
446
447 if ($existingTransaction) {
448 $existingTransaction->fill($transactionData);
449 $existingTransaction->save();
450 $this->transactionModel = $existingTransaction;
451 } else {
452 $this->transactionModel = \FluentCart\App\Models\OrderTransaction::query()->create($transactionData);
453 }
454
455 // For locked carts (e.g. custom checkout), preserve the original order's applied coupon records.
456 // Re-inserting would delete existing records and incorrectly increment the coupon use_count.
457 if (!$isLocked) {
458 $this->insertAppliedCoupons(Arr::get($this->args, 'applied_coupons', []), true, $prevOrder);
459 }
460
461 $cartHash = Arr::get($this->args, 'cart_hash', '');
462
463 if ($cartHash) {
464 $cart = Cart::query()->where('cart_hash', $cartHash)->first();
465 if ($cart) {
466 $cart->order_id = $this->orderModel->id;
467 $cart->customer_id = $this->orderModel->customer_id;
468 $cart->stage = 'intended';
469 $cart->ip_address = $this->orderModel->ip_address;
470 $cart->save();
471 }
472 }
473
474 // We are almost done!
475 return $this->orderModel;
476 }
477
478 private function insertAppliedCoupons($appliedCoupons, $removeOlds = false, $order = null): void
479 {
480 if ($removeOlds) {
481 $this->orderModel->appliedCoupons()->delete();
482 }
483
484 $couponCodes = Arr::pluck($appliedCoupons, 'code');
485
486 $customerId = $this->orderModel->customer_id;
487 if ($order instanceof Order) {
488 $customerId = $order->customer_id;
489 }
490
491 if (!empty($couponCodes)) {
492 $coupons = Coupon::query()->whereIn('code', $couponCodes)->get()
493 ->keyBy('code')
494 ->toArray();
495
496 foreach ($coupons as $code => &$coupon) {
497 $coupon['coupon_id'] = $appliedCoupons[$code]['id'];
498 $coupon['amount'] = $appliedCoupons[$code]['discount'];
499 $coupon['customer_id'] = $customerId;
500 }
501 $this->orderModel->appliedCoupons()->createMany($coupons);
502
503 Coupon::query()
504 ->whereIn('code', $couponCodes)
505 ->increment('use_count', 1);
506 }
507 }
508
509 public function getTransaction()
510 {
511 return $this->transactionModel;
512 }
513
514 public function getOrder()
515 {
516 return $this->orderModel;
517 }
518
519 public function getSubscription()
520 {
521 return $this->subscriptionModel;
522 }
523
524 private function prepareOrderItems()
525 {
526 $formattedItems = [];
527
528 foreach ($this->cartItems as $cartItem) {
529 $unitPrice = (int)Arr::get($cartItem, 'unit_price', 0);
530 $quantity = (int)Arr::get($cartItem, 'quantity', 1);
531
532 $this->couponDiscountTotal += (int)Arr::get($cartItem, 'coupon_discount', 0);
533 $this->manualDiscountTotal += (int)Arr::get($cartItem, 'manual_discount', 0);
534
535 $discountTotal = (int)Arr::get($cartItem, 'manual_discount', 0) + (int)Arr::get($cartItem, 'coupon_discount', 0);
536 $shippingCharge = (int)Arr::get($cartItem, 'shipping_charge', 0);
537
538 $subtotal = $unitPrice * $quantity;
539 $args = Arr::get($cartItem, 'other_info', []);
540 $paymentType = Arr::get($args, 'payment_type', 'default');
541
542 $postTitle = Arr::get($cartItem, 'product_title', '');
543 $variationTitle = Arr::get($cartItem, 'variation_title', '');
544
545 if (!$postTitle) {
546 if (Arr::get($cartItem, 'is_custom', false)) {
547 $postTitle = Arr::get($cartItem, 'post_title', '');
548 } else {
549 $product = Product::query()->find(Arr::get($cartItem, 'post_id', 0));
550 if ($product) {
551 $postTitle = $product->post_title;
552 }
553 }
554 }
555
556 if (!$variationTitle) {
557 if (Arr::get($cartItem, 'is_custom', false)) {
558 $variationTitle = Arr::get($cartItem, 'title', '');
559 } else {
560 $variation = ProductVariation::query()->find(Arr::get($cartItem, 'object_id', 0));
561 if ($variation) {
562 $variationTitle = $variation->variation_title;
563 }
564 }
565 }
566
567 // Snapshot package dimensions into other_info for email/PDF rendering
568 $packageSlug = Arr::get($args, 'package_slug', '');
569 if ($packageSlug) {
570 $package = Helper::getPackageBySlug($packageSlug);
571 if ($package) {
572 $args['package_name'] = Arr::get($package, 'name', '');
573 $args['package_type'] = Arr::get($package, 'type', '');
574 $args['package_length'] = Arr::get($package, 'length', '');
575 $args['package_width'] = Arr::get($package, 'width', '');
576 $args['package_height'] = Arr::get($package, 'height', '');
577 $args['package_dimension_unit'] = Arr::get($package, 'dimension_unit', 'cm');
578 $args['package_weight'] = Arr::get($package, 'weight', 0);
579 $args['package_weight_unit'] = Arr::get($package, 'weight_unit', 'kg');
580 }
581 }
582
583 $item = [
584 'payment_type' => $paymentType,
585 'post_id' => Arr::get($cartItem, 'post_id'),
586 'object_id' => Arr::get($cartItem, 'object_id'),
587 'post_title' => $postTitle,
588 'title' => $variationTitle,
589 'fulfillment_type' => Arr::get($cartItem, 'fulfillment_type', 'digital'),
590 'quantity' => $quantity,
591 'cost' => (int)Arr::get($cartItem, 'cost', 0),
592 'unit_price' => $unitPrice,
593 'subtotal' => $subtotal,
594 'tax_amount' => (int)Arr::get($cartItem, 'tax_amount', 0),
595 'shipping_charge' => $shippingCharge,
596 'discount_total' => $discountTotal,
597 'other_info' => $args,
598 'line_meta' => Arr::get($cartItem, 'line_meta', []),
599 ];
600
601 if (isset($cartItem['recurring_discounts'])) {
602 $item['recurring_discounts'] = $cartItem['recurring_discounts'];
603 }
604
605 $childItem = null;
606 if ($paymentType === 'subscription' && Arr::get($cartItem, 'other_info.signup_fee', 0)) {
607 // We have a signup fee for subscription
608 $signupFeeAmount = (int)Arr::get($cartItem, 'other_info.signup_fee', 0);
609
610 $signupFeeTax = (int)Arr::get($cartItem, 'other_info.signup_fee_tax', 0);
611
612 $childDiscountTotal = 0;
613 $signupFeeSubtotal = $signupFeeAmount * $quantity;
614
615 $hasTrialDays = Arr::get($cartItem, 'other_info.trial_days', 0) > 0;
616
617 if ($discountTotal && !$hasTrialDays) {
618 $childDiscountTotal = (float)($discountTotal / ($subtotal + $signupFeeSubtotal) * $signupFeeSubtotal);
619 $discountTotal -= $childDiscountTotal;
620 } elseif ($discountTotal && $hasTrialDays) { // if trial days , then discount should be applied on signup fee only
621 $childDiscountTotal = min($discountTotal, $signupFeeSubtotal);
622 $discountTotal = 0;
623 }
624
625 $childItem = [
626 'payment_type' => 'signup_fee',
627 'post_id' => $item['post_id'],
628 'object_id' => $item['object_id'],
629 'post_title' => $item['post_title'],
630 'title' => Arr::get($cartItem, 'other_info.signup_fee_name', __('Signup Fee', 'fluent-cart')),
631 'fulfillment_type' => $item['fulfillment_type'],
632 'quantity' => $quantity,
633 'cost' => 0,
634 'unit_price' => $signupFeeAmount,
635 'subtotal' => $signupFeeSubtotal,
636 'tax_amount' => $signupFeeTax,
637 'shipping_charge' => 0,
638 'discount_total' => $childDiscountTotal,
639 'line_meta' => Arr::get($cartItem, 'signup_fee_tax_config', []),
640 ];
641
642 $item['discount_total'] = $discountTotal;
643 $item['additional_items'] = [$childItem];
644
645 Arr::set($item, 'other_info.signup_fee', $signupFeeAmount);
646 Arr::set($item, 'other_info.signup_discount', $childDiscountTotal);
647 }
648
649 if (
650 Arr::get($cartItem, 'other_info.is_bundle_product', 'no') == 'yes'
651 || !empty(Arr::get($cartItem, 'other_info.bundle_child_ids', []))
652 ) {
653 $bundleItems = Arr::get($cartItem, 'child_variants', []);
654
655 foreach ($bundleItems as $bundleItem) {
656 $bundleChildItem = [
657 'payment_type' => 'bundle',
658 'post_id' => Arr::get($bundleItem, 'post_id', 0),
659 'object_id' => Arr::get($bundleItem, 'id', 0),
660 'post_title' => Arr::get($bundleItem, 'post_title', ''),
661 'title' => Arr::get($bundleItem, 'variation_title', ''),
662 'fulfillment_type' => Arr::get($bundleItem, 'fulfillment_type', 'digital'),
663 'quantity' => $quantity,
664 'cost' => 0,
665 'unit_price' => 0,
666 'subtotal' => 0,
667 'tax_amount' => 0,
668 'shipping_charge' => 0,
669 'discount_total' => 0,
670 'other_info' => [
671 'bundle_parent_product_id' => Arr::get($cartItem, 'post_id', 0),
672 'bundle_parent_variation_id' => Arr::get($cartItem, 'object_id', 0)
673 ],
674 ];
675
676 //TODO: if bundleItem price is included on for the bundle, then we need to set the price to the bundleItem price
677 // if (Arr::get($bundleItem, 'other_info.is_price_included', 'no') == 'yes') {
678 // $bundleChildItem['unit_price'] = Arr::get($bundleItem, 'unit_price', 0);
679 // $bundleChildItem['subtotal'] = Arr::get($bundleItem, 'subtotal', 0);
680 // $bundleChildItem['tax_amount'] = Arr::get($bundleItem, 'tax_amount', 0);
681 // $bundleChildItem['shipping_charge'] = Arr::get($bundleItem, 'shipping_charge', 0);
682 // $bundleChildItem['discount_total'] = Arr::get($bundleItem, 'discount_total', 0);
683 // }
684
685 $item['bundle_items'][] = $bundleChildItem;
686
687 }
688
689 }
690
691 $formattedItems[] = $item;
692 if ($childItem) {
693 $formattedItems[] = $childItem;
694 }
695 }
696
697 // Create order items for fees
698 $fees = (array)Arr::get($this->args, 'fees', []);
699 $this->feeTotal = 0;
700
701 foreach ($fees as $fee) {
702 $amount = (int)($fee['amount'] ?? 0);
703 if ($amount <= 0) {
704 continue;
705 }
706
707 $this->feeTotal += $amount;
708
709 $formattedItems[] = [
710 'payment_type' => 'fee',
711 'post_id' => 0,
712 'object_id' => 0,
713 'post_title' => '',
714 'title' => $fee['label'] ?? '',
715 'fulfillment_type' => 'digital',
716 'quantity' => 1,
717 'cost' => 0,
718 'unit_price' => $amount,
719 'subtotal' => $amount,
720 'tax_amount' => 0,
721 'shipping_charge' => 0,
722 'discount_total' => 0,
723 'other_info' => [
724 'payment_type' => 'fee',
725 'fee_key' => $fee['key'] ?? '',
726 'source' => $fee['source'] ?? 'custom',
727 'taxable' => !empty($fee['taxable']),
728 'meta' => $fee['meta'] ?? [],
729 ],
730 'line_meta' => [],
731 ];
732 }
733
734 $this->formattedIOrderItems = $formattedItems;
735 }
736
737 private function prepareSubscriptionData()
738 {
739 $subscriptionItems = array_filter($this->formattedIOrderItems, function ($item) {
740 return $item['payment_type'] === 'subscription';
741 });
742
743 $signupFeeItems = array_filter($this->formattedIOrderItems, function ($item) {
744 return $item['payment_type'] === 'signup_fee';
745 });
746
747 if (!$subscriptionItems) {
748 return;
749 }
750
751 if (count($subscriptionItems) > 1) {
752 return;
753 }
754
755 $item = reset($subscriptionItems);
756 $signupFeeItem = reset($signupFeeItems) ?? [];
757 $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0);
758 $taxBehavior = Arr::get($this->args, 'tax_behavior', 0);
759
760 $recurringTotal = (int)$item['subtotal'];
761 $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0);
762
763 $recurringDiscountAmount = (int)Arr::get($item, 'recurring_discounts.amount', 0);
764
765 if ($recurringDiscountAmount && $recurringDiscountAmount > 0) {
766 $recurringTotal -= $recurringDiscountAmount;
767 }
768
769 // Add shipping charges to recurring total for physical subscription products
770 $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0);
771 $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical';
772 if ($isPhysicalProduct && $shippingCharge > 0) {
773 $recurringTotal += $shippingCharge;
774 }
775
776 if ($taxBehavior === 1) {
777 $recurringTotal += $recurringTax;
778 }
779
780 $signupFee = (int)Arr::get($signupFeeItem, 'subtotal', 0);
781
782 // in case of discount applied 'tax_amount' is different than recurring tax ,
783 $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax;
784
785
786 // Calculate recurring amount including shipping for physical products
787 $recurringAmount = (int)$item['subtotal'];
788 if ($isPhysicalProduct && $shippingCharge > 0) {
789 $recurringAmount += $shippingCharge;
790 }
791
792 $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0);
793 $subscriptionPricing = $this->convertToSubscriptionFormat([
794 'initial_trial_days' => Arr::get($item, 'other_info.trial_days', 0),
795 'repeat_interval' => Arr::get($item, 'other_info.repeat_interval', 'monthly'),
796 'times' => Arr::get($item, 'other_info.times', 0),
797 'recurring_amount' => $recurringAmount,
798 'recurring_tax_total' => $recurringTax,
799 'recurring_total' => $recurringTotal,
800 'tax_behavior' => $taxBehavior,
801 'signup_fee' => $signupFee,
802 'signup_fee_tax' => $signupFeeTax,
803 'first_iteration_tax' => $firstIterationTax,
804 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
805 'total_discount' => $discountTotal
806 ]);
807
808 // removable upon discussion
809 $subscriptionItem = [
810 'product_id' => $item['post_id'],
811 'current_payment_method' => Arr::get($this->orderData, 'payment_method'),
812 'object_id' => $item['object_id'],
813 'recurring_tax_total' => 0,
814 'recurring_total' => $recurringTotal, //use price not line_total to ignore discount
815 'item_name' => $item['post_title'] . ' - ' . $item['title'],
816 'bill_count' => 0,
817 'quantity' => 1,
818 'variation_id' => Arr::get($item, 'object_id', 0),
819 'status' => Status::SUBSCRIPTION_PENDING,
820 'config' => [
821 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
822 'currency' => $this->orderData['currency']
823 ]
824 ];
825
826 // if recurring coupon is applied, we need to subtract the total discount from the recurring total
827 if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') {
828 $subscriptionItem['recurring_total'] -= $discountTotal;
829 }
830
831 $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
832 }
833
834 private function prepareOrderData()
835 {
836 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
837 return $item['fulfillment_type'] === 'physical';
838 });
839
840 $hasSubscription = array_filter($this->formattedIOrderItems, function ($item) {
841 return $item['payment_type'] === 'subscription';
842 });
843
844 $itemsSubtotal = array_reduce($this->formattedIOrderItems, function ($carry, $item) {
845 if (Arr::get($item, 'other_info.trial_days', 0) > 0) {
846 return $carry;
847 }
848 // Fee items are tracked separately via fee_total
849 if (Arr::get($item, 'payment_type') === 'fee') {
850 return $carry;
851 }
852 return $carry + $item['subtotal'];
853 }, 0);
854
855 $orderData = [
856 'status' => Status::ORDER_ON_HOLD,
857 'fulfillment_type' => $hasPhysical ? Status::FULFILLMENT_TYPE_PHYSICAL : Status::FULFILLMENT_TYPE_DIGITAL,
858 'type' => $hasSubscription ? Status::ORDER_TYPE_SUBSCRIPTION : Status::ORDER_TYPE_PAYMENT, // revisit this on manual renewal
859 'mode' => $this->storeSettings->get('order_mode', 'test'),
860 'shipping_status' => $hasPhysical ? 'unshipped' : '',
861 'customer_id' => '',
862 'payment_method' => Arr::get($this->args, 'payment_method', ''),
863 'payment_status' => Status::PAYMENT_PENDING,
864 'payment_method_title' => '',
865 'currency' => $this->storeSettings->get('currency'),
866 'subtotal' => $itemsSubtotal,
867 'discount_tax' => 0,
868 'manual_discount_total' => $this->manualDiscountTotal,
869 'coupon_discount_total' => $this->couponDiscountTotal,
870 'shipping_tax' => Arr::get($this->args, 'shipping_tax', 0),
871 'shipping_total' => Arr::get($this->args, 'shipping_charge', 0),
872 'fee_total' => $this->feeTotal,
873 'tax_total' => Arr::get($this->args, 'tax_total', 0),
874 'tax_behavior' => Arr::get($this->args, 'tax_behavior', 0),
875 // 'total_amount' => $this->orderTotals['total_amount'],
876 'total_paid' => 0,
877 'total_refund' => 0,
878 'rate' => 1,
879 'note' => Arr::get($this->args, 'note', ''),
880 'ip_address' => Arr::get($this->args, 'ip_address', ''),
881 'config' => [
882 'user_tz' => Arr::get($this->args, 'user_tz', ''),
883 'create_account_after_paid' => Arr::get($this->args, 'create_account_after_paid', 'no')
884 ],
885 ];
886
887 $estimatedTaxTotal = $orderData['tax_behavior'] === 1 ? $orderData['tax_total'] : 0;
888 $estimatedShippingTax = $orderData['tax_behavior'] === 1 ? $orderData['shipping_tax'] : 0;
889
890 $totalAmount = $orderData['subtotal'] - $orderData['coupon_discount_total'] - $orderData['manual_discount_total'] + $orderData['fee_total'] + $orderData['shipping_total'] + $estimatedTaxTotal + $estimatedShippingTax;
891
892 $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0;
893 $this->orderData = $orderData;
894 }
895
896 private function syncFeeItems()
897 {
898 $orderId = $this->orderModel->id;
899
900 // Remove existing fee items
901 OrderItem::query()->where('order_id', $orderId)->where('payment_type', 'fee')->delete();
902
903 // Create new fee items from formatted items
904 $feeItems = array_filter($this->formattedIOrderItems, function ($item) {
905 return $item['payment_type'] === 'fee';
906 });
907
908 foreach ($feeItems as $feeItem) {
909 $feeItem['order_id'] = $orderId;
910 $feeItem['quantity'] = 1;
911 $feeItem['line_total'] = $feeItem['subtotal'] - $feeItem['discount_total'];
912 OrderItem::query()->create($feeItem);
913 }
914 }
915
916 /**
917 * @param $inputData
918 * @return array
919 */
920 private function convertToSubscriptionFormat($inputData)
921 {
922
923 /**
924 * Normal Subscription $100/month
925 * {
926 * 'trial_days' => 0,
927 * 'repeat_interval' => 'month',
928 * 'times' => 0, // 0 means unlimited
929 * 'recurring_amount' => 100,
930 * 'signup_fee' => 0
931 * }
932 *
933 * 30 Days Trial $100/month
934 * {
935 * 'trial_days' => 30,
936 * 'repeat_interval' => 'month',
937 * 'times' => 0, // 0 means unlimited
938 * 'recurring_amount' => 100,
939 * }
940 *
941 * $100/month - 30 days Trial with $40 signup fee
942 * {
943 * 'trial_days' => 30,
944 * 'repeat_interval' => 'month',
945 * 'times' => 0, // 0 means unlimited
946 * 'recurring_amount' => 100,
947 * 'signup_fee' => 40
948 * }
949 *
950 * $100 / month with $40 signup fee
951 * {
952 * 'trial_days' => 0,
953 * 'repeat_interval' => 'month',
954 * 'times' => 0, // 0 means unlimited
955 * 'recurring_amount' => 100,
956 * 'signup_fee' => 40
957 * }
958 *
959 * $100 per month but 30% discount on first month
960 * {
961 * 'trial_days' => 30,
962 * 'repeat_interval' => 'month',
963 * 'times' => 0, // 0 means unlimited
964 * 'recurring_amount' => 100,
965 * 'signup_fee' => 70
966 * }
967 *
968 * $100 per month but 50% extra on first month
969 * {
970 * 'trial_days' => 0,
971 * 'repeat_interval' => 'month',
972 * 'times' => 0, // 0 means unlimited
973 * 'recurring_amount' => 100,
974 * 'signup_fee' => 50
975 * }
976 *
977 * $100 per month and 1 month trial and service_fee = $50
978 * {
979 * 'trial_days' => 30,
980 * 'repeat_interval' => 'month',
981 * 'times' => 0, // 0 means unlimited
982 * 'recurring_amount' => 100,
983 * 'signup_fee' => 50
984 * }
985 *
986 *
987 * $100 per month, signup fee $200 - First Month 50% discount
988 *
989 * $first Month = 100 + 200 = 300 - 50% discount = 150
990 * {
991 * 'trial_days' => 30,
992 * 'repeat_interval' => 'month',
993 * 'times' => 0, // 0 means unlimited
994 * 'recurring_amount' => 100,
995 * 'signup_fee' => 150
996 * }
997 *
998 * // prefered when signup_fee > recurring_amount
999 * {
1000 * 'trial_days' => 0,
1001 * 'repeat_interval' => 'month',
1002 * 'times' => 0, // 0 means unlimited
1003 * 'recurring_amount' => 100,
1004 * 'signup_fee' => 50
1005 * }
1006 */
1007
1008
1009 // Extract and validate input data
1010 $trialDays = (int)($inputData['initial_trial_days'] ?? 0);
1011 $repeatInterval = strtolower(trim($inputData['repeat_interval'] ?? 'yearly'));
1012 $times = (int)($inputData['times'] ?? 0);
1013 $recurringAmount = (int)($inputData['recurring_amount'] ?? 0);
1014 $recurringTax = (int)($inputData['recurring_tax_total'] ?? 0);
1015 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1016 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1017 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1018 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1019
1020 // Validate repeat_interval
1021 $validIntervals = array_keys(Helper::getAvailableSubscriptionIntervalMaps());
1022
1023 if (!in_array($repeatInterval, $validIntervals)) {
1024 $repeatInterval = 'yearly';
1025 }
1026
1027 // Convert repeat_interval to standard format
1028 $intervalMap = Helper::getAvailableSubscriptionIntervalMaps();
1029 $standardInterval = Helper::translateIntervalToStandardFormat($repeatInterval);
1030
1031 // Calculate trial days based on interval
1032
1033
1034 // Initialize result array
1035 $result = [
1036 'trial_days' => $trialDays,
1037 'repeat_interval' => $standardInterval,
1038 'times' => $times,
1039 ];
1040
1041
1042 // Calculate signup fee logic
1043 if ($totalDiscount > 0) {
1044 // case: discount applied on subscription with trial days and have signup_fee, otherise discount can't be applied on subscription with trial days.
1045 if ($trialDays > 0 && $signupFee > 0) {
1046 $result['signup_fee'] = max(0, $signupFee - $totalDiscount);
1047 $result['manage_setup_fee'] = 'yes';
1048
1049 if (Arr::get($inputData, 'tax_behavior', 0) == 1 && $firstIterationTax) {
1050 $result['signup_fee'] += $firstIterationTax;
1051 }
1052 } else {
1053 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1054
1055 if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1056 $recurringAmount -= $totalDiscount; // as now discount applied on recurring amount
1057 }
1058
1059 if ($firstCycleCost < $recurringAmount) {
1060 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
1061
1062 $result['trial_days'] = $adjustedTrialDays;
1063 $result['is_trial_days_simulated'] = 'yes';
1064 $result['signup_fee'] = $firstCycleCost;
1065 $result['manage_setup_fee'] = 'yes';
1066 $result['times'] = $times > 0 ? $times - 1 : 0;
1067 } else if ($firstCycleCost > $recurringAmount) {
1068 $result['trial_days'] = 0;
1069 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1070 $result['manage_setup_fee'] = 'yes';
1071 } else if ($firstCycleCost == $recurringAmount) {
1072 $result['trial_days'] = 0;
1073 $result['signup_fee'] = 0;
1074 $result['manage_setup_fee'] = 'no';
1075 }
1076
1077 // only the signup fee is adjustable on our system, so we can adjust that to our needs
1078 if (Arr::get($inputData, 'tax_behavior', 0) == 1 && $firstIterationTax) {
1079 if ($result['trial_days'] > 0) {
1080 $result['signup_fee'] += $firstIterationTax;
1081 } else {
1082 $result['signup_fee'] += ($firstIterationTax - $recurringTax); // need to minus the recurring tax as it would add automatically to the payable amount as no trial days
1083 }
1084 }
1085 }
1086
1087 } else {
1088 $result['signup_fee'] = $signupFee;
1089
1090 if (Arr::get($inputData, 'tax_behavior', 0) == 1) {
1091 if ($signupFeeTax) {
1092 $result['signup_fee'] += $signupFeeTax;
1093 }
1094 }
1095 }
1096
1097
1098 $result['repeat_interval'] = array_flip($intervalMap)[$result['repeat_interval']] ?? 'yearly';
1099
1100
1101 return [
1102 'billing_interval' => $result['repeat_interval'],
1103 'bill_times' => $result['times'],
1104 'trial_days' => $result['trial_days'],
1105 'is_trial_days_simulated' => Arr::get($result, 'is_trial_days_simulated', 'no'),
1106 'recurring_amount' => $recurringAmount,
1107 'recurring_tax_total' => $recurringTax,
1108 'signup_fee' => $result['signup_fee'] ?? 0,
1109 ];
1110 }
1111 }
1112