PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.21
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.21
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.21, at app/Helpers/CheckoutProcessor.php

1,096 lines 45.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 $item = [
568 'payment_type' => $paymentType,
569 'post_id' => Arr::get($cartItem, 'post_id'),
570 'object_id' => Arr::get($cartItem, 'object_id'),
571 'post_title' => $postTitle,
572 'title' => $variationTitle,
573 'fulfillment_type' => Arr::get($cartItem, 'fulfillment_type', 'digital'),
574 'quantity' => $quantity,
575 'cost' => (int)Arr::get($cartItem, 'cost', 0),
576 'unit_price' => $unitPrice,
577 'subtotal' => $subtotal,
578 'tax_amount' => (int)Arr::get($cartItem, 'tax_amount', 0),
579 'shipping_charge' => $shippingCharge,
580 'discount_total' => $discountTotal,
581 'other_info' => $args,
582 'line_meta' => Arr::get($cartItem, 'line_meta', []),
583 ];
584
585 if (isset($cartItem['recurring_discounts'])) {
586 $item['recurring_discounts'] = $cartItem['recurring_discounts'];
587 }
588
589 $childItem = null;
590 if ($paymentType === 'subscription' && Arr::get($cartItem, 'other_info.signup_fee', 0)) {
591 // We have a signup fee for subscription
592 $signupFeeAmount = (int)Arr::get($cartItem, 'other_info.signup_fee', 0);
593
594 $signupFeeTax = (int)Arr::get($cartItem, 'other_info.signup_fee_tax', 0);
595
596 $childDiscountTotal = 0;
597 $signupFeeSubtotal = $signupFeeAmount * $quantity;
598
599 $hasTrialDays = Arr::get($cartItem, 'other_info.trial_days', 0) > 0;
600
601 if ($discountTotal && !$hasTrialDays) {
602 $childDiscountTotal = (float)($discountTotal / ($subtotal + $signupFeeSubtotal) * $signupFeeSubtotal);
603 $discountTotal -= $childDiscountTotal;
604 } elseif ($discountTotal && $hasTrialDays) { // if trial days , then discount should be applied on signup fee only
605 $childDiscountTotal = min($discountTotal, $signupFeeSubtotal);
606 $discountTotal = 0;
607 }
608
609 $childItem = [
610 'payment_type' => 'signup_fee',
611 'post_id' => $item['post_id'],
612 'object_id' => $item['object_id'],
613 'post_title' => $item['post_title'],
614 'title' => Arr::get($cartItem, 'other_info.signup_fee_name', __('Signup Fee', 'fluent-cart')),
615 'fulfillment_type' => $item['fulfillment_type'],
616 'quantity' => $quantity,
617 'cost' => 0,
618 'unit_price' => $signupFeeAmount,
619 'subtotal' => $signupFeeSubtotal,
620 'tax_amount' => $signupFeeTax,
621 'shipping_charge' => 0,
622 'discount_total' => $childDiscountTotal,
623 'line_meta' => Arr::get($cartItem, 'signup_fee_tax_config', []),
624 ];
625
626 $item['discount_total'] = $discountTotal;
627 $item['additional_items'] = [$childItem];
628
629 Arr::set($item, 'other_info.signup_fee', $signupFeeAmount);
630 Arr::set($item, 'other_info.signup_discount', $childDiscountTotal);
631 }
632
633 if (
634 Arr::get($cartItem, 'other_info.is_bundle_product', 'no') == 'yes'
635 || !empty(Arr::get($cartItem, 'other_info.bundle_child_ids', []))
636 ) {
637 $bundleItems = Arr::get($cartItem, 'child_variants', []);
638
639 foreach ($bundleItems as $bundleItem) {
640 $bundleChildItem = [
641 'payment_type' => 'bundle',
642 'post_id' => Arr::get($bundleItem, 'post_id', 0),
643 'object_id' => Arr::get($bundleItem, 'id', 0),
644 'post_title' => Arr::get($bundleItem, 'post_title', ''),
645 'title' => Arr::get($bundleItem, 'variation_title', ''),
646 'fulfillment_type' => Arr::get($bundleItem, 'fulfillment_type', 'digital'),
647 'quantity' => $quantity,
648 'cost' => 0,
649 'unit_price' => 0,
650 'subtotal' => 0,
651 'tax_amount' => 0,
652 'shipping_charge' => 0,
653 'discount_total' => 0,
654 'other_info' => [
655 'bundle_parent_product_id' => Arr::get($cartItem, 'post_id', 0),
656 'bundle_parent_variation_id' => Arr::get($cartItem, 'object_id', 0)
657 ],
658 ];
659
660 //TODO: if bundleItem price is included on for the bundle, then we need to set the price to the bundleItem price
661 // if (Arr::get($bundleItem, 'other_info.is_price_included', 'no') == 'yes') {
662 // $bundleChildItem['unit_price'] = Arr::get($bundleItem, 'unit_price', 0);
663 // $bundleChildItem['subtotal'] = Arr::get($bundleItem, 'subtotal', 0);
664 // $bundleChildItem['tax_amount'] = Arr::get($bundleItem, 'tax_amount', 0);
665 // $bundleChildItem['shipping_charge'] = Arr::get($bundleItem, 'shipping_charge', 0);
666 // $bundleChildItem['discount_total'] = Arr::get($bundleItem, 'discount_total', 0);
667 // }
668
669 $item['bundle_items'][] = $bundleChildItem;
670
671 }
672
673 }
674
675 $formattedItems[] = $item;
676 if ($childItem) {
677 $formattedItems[] = $childItem;
678 }
679 }
680
681 // Create order items for fees
682 $fees = (array)Arr::get($this->args, 'fees', []);
683 $this->feeTotal = 0;
684
685 foreach ($fees as $fee) {
686 $amount = (int)($fee['amount'] ?? 0);
687 if ($amount <= 0) {
688 continue;
689 }
690
691 $this->feeTotal += $amount;
692
693 $formattedItems[] = [
694 'payment_type' => 'fee',
695 'post_id' => 0,
696 'object_id' => 0,
697 'post_title' => '',
698 'title' => $fee['label'] ?? '',
699 'fulfillment_type' => 'digital',
700 'quantity' => 1,
701 'cost' => 0,
702 'unit_price' => $amount,
703 'subtotal' => $amount,
704 'tax_amount' => 0,
705 'shipping_charge' => 0,
706 'discount_total' => 0,
707 'other_info' => [
708 'payment_type' => 'fee',
709 'fee_key' => $fee['key'] ?? '',
710 'source' => $fee['source'] ?? 'custom',
711 'taxable' => !empty($fee['taxable']),
712 'meta' => $fee['meta'] ?? [],
713 ],
714 'line_meta' => [],
715 ];
716 }
717
718 $this->formattedIOrderItems = $formattedItems;
719 }
720
721 private function prepareSubscriptionData()
722 {
723 $subscriptionItems = array_filter($this->formattedIOrderItems, function ($item) {
724 return $item['payment_type'] === 'subscription';
725 });
726
727 $signupFeeItems = array_filter($this->formattedIOrderItems, function ($item) {
728 return $item['payment_type'] === 'signup_fee';
729 });
730
731 if (!$subscriptionItems) {
732 return;
733 }
734
735 if (count($subscriptionItems) > 1) {
736 return;
737 }
738
739 $item = reset($subscriptionItems);
740 $signupFeeItem = reset($signupFeeItems) ?? [];
741 $signupFeeTax = (int)Arr::get($signupFeeItem, 'tax_amount', 0);
742 $taxBehavior = Arr::get($this->args, 'tax_behavior', 0);
743
744 $recurringTotal = (int)$item['subtotal'];
745 $recurringTax = (int)Arr::get($item, 'other_info.recurring_tax', 0);
746
747 $recurringDiscountAmount = (int)Arr::get($item, 'recurring_discounts.amount', 0);
748
749 if ($recurringDiscountAmount && $recurringDiscountAmount > 0) {
750 $recurringTotal -= $recurringDiscountAmount;
751 }
752
753 // Add shipping charges to recurring total for physical subscription products
754 $shippingCharge = (int)Arr::get($this->args, 'shipping_charge', 0);
755 $isPhysicalProduct = Arr::get($item, 'fulfillment_type') === 'physical';
756 if ($isPhysicalProduct && $shippingCharge > 0) {
757 $recurringTotal += $shippingCharge;
758 }
759
760 if ($taxBehavior === 1) {
761 $recurringTotal += $recurringTax;
762 }
763
764 $signupFee = (int)Arr::get($signupFeeItem, 'subtotal', 0);
765
766 // in case of discount applied 'tax_amount' is different than recurring tax ,
767 $firstIterationTax = (int)Arr::get($item, 'tax_amount', 0) + $signupFeeTax;
768
769
770 // Calculate recurring amount including shipping for physical products
771 $recurringAmount = (int)$item['subtotal'];
772 if ($isPhysicalProduct && $shippingCharge > 0) {
773 $recurringAmount += $shippingCharge;
774 }
775
776 $discountTotal = $item['discount_total'] + Arr::get($signupFeeItem, 'discount_total', 0);
777 $subscriptionPricing = $this->convertToSubscriptionFormat([
778 'initial_trial_days' => Arr::get($item, 'other_info.trial_days', 0),
779 'repeat_interval' => Arr::get($item, 'other_info.repeat_interval', 'monthly'),
780 'times' => Arr::get($item, 'other_info.times', 0),
781 'recurring_amount' => $recurringAmount,
782 'recurring_tax_total' => $recurringTax,
783 'recurring_total' => $recurringTotal,
784 'tax_behavior' => $taxBehavior,
785 'signup_fee' => $signupFee,
786 'signup_fee_tax' => $signupFeeTax,
787 'first_iteration_tax' => $firstIterationTax,
788 'is_recurring_coupon' => Arr::get($item, 'is_recurring_coupon', 'no'),
789 'total_discount' => $discountTotal
790 ]);
791
792 // removable upon discussion
793 $subscriptionItem = [
794 'product_id' => $item['post_id'],
795 'current_payment_method' => Arr::get($this->orderData, 'payment_method'),
796 'object_id' => $item['object_id'],
797 'recurring_tax_total' => 0,
798 'recurring_total' => $recurringTotal, //use price not line_total to ignore discount
799 'item_name' => $item['post_title'] . ' - ' . $item['title'],
800 'bill_count' => 0,
801 'quantity' => 1,
802 'variation_id' => Arr::get($item, 'object_id', 0),
803 'status' => Status::SUBSCRIPTION_PENDING,
804 'config' => [
805 'is_trial_days_simulated' => Arr::get($subscriptionPricing, 'is_trial_days_simulated', 'no'),
806 'currency' => $this->orderData['currency']
807 ]
808 ];
809
810 // if recurring coupon is applied, we need to subtract the total discount from the recurring total
811 if (Arr::get($item, 'is_recurring_coupon', 'no') === 'yes') {
812 $subscriptionItem['recurring_total'] -= $discountTotal;
813 }
814
815 $this->subscriptionData = wp_parse_args($subscriptionPricing, $subscriptionItem);
816 }
817
818 private function prepareOrderData()
819 {
820 $hasPhysical = array_filter($this->formattedIOrderItems, function ($item) {
821 return $item['fulfillment_type'] === 'physical';
822 });
823
824 $hasSubscription = array_filter($this->formattedIOrderItems, function ($item) {
825 return $item['payment_type'] === 'subscription';
826 });
827
828 $itemsSubtotal = array_reduce($this->formattedIOrderItems, function ($carry, $item) {
829 if (Arr::get($item, 'other_info.trial_days', 0) > 0) {
830 return $carry;
831 }
832 // Fee items are tracked separately via fee_total
833 if (Arr::get($item, 'payment_type') === 'fee') {
834 return $carry;
835 }
836 return $carry + $item['subtotal'];
837 }, 0);
838
839 $orderData = [
840 'status' => Status::ORDER_ON_HOLD,
841 'fulfillment_type' => $hasPhysical ? Status::FULFILLMENT_TYPE_PHYSICAL : Status::FULFILLMENT_TYPE_DIGITAL,
842 'type' => $hasSubscription ? Status::ORDER_TYPE_SUBSCRIPTION : Status::ORDER_TYPE_PAYMENT, // revisit this on manual renewal
843 'mode' => $this->storeSettings->get('order_mode', 'test'),
844 'shipping_status' => $hasPhysical ? 'unshipped' : '',
845 'customer_id' => '',
846 'payment_method' => Arr::get($this->args, 'payment_method', ''),
847 'payment_status' => Status::PAYMENT_PENDING,
848 'payment_method_title' => '',
849 'currency' => $this->storeSettings->get('currency'),
850 'subtotal' => $itemsSubtotal,
851 'discount_tax' => 0,
852 'manual_discount_total' => $this->manualDiscountTotal,
853 'coupon_discount_total' => $this->couponDiscountTotal,
854 'shipping_tax' => Arr::get($this->args, 'shipping_tax', 0),
855 'shipping_total' => Arr::get($this->args, 'shipping_charge', 0),
856 'fee_total' => $this->feeTotal,
857 'tax_total' => Arr::get($this->args, 'tax_total', 0),
858 'tax_behavior' => Arr::get($this->args, 'tax_behavior', 0),
859 // 'total_amount' => $this->orderTotals['total_amount'],
860 'total_paid' => 0,
861 'total_refund' => 0,
862 'rate' => 1,
863 'note' => Arr::get($this->args, 'note', ''),
864 'ip_address' => Arr::get($this->args, 'ip_address', ''),
865 'config' => [
866 'user_tz' => Arr::get($this->args, 'user_tz', ''),
867 'create_account_after_paid' => Arr::get($this->args, 'create_account_after_paid', 'no')
868 ],
869 ];
870
871 $estimatedTaxTotal = $orderData['tax_behavior'] === 1 ? $orderData['tax_total'] : 0;
872 $estimatedShippingTax = $orderData['tax_behavior'] === 1 ? $orderData['shipping_tax'] : 0;
873
874 $totalAmount = $orderData['subtotal'] - $orderData['coupon_discount_total'] - $orderData['manual_discount_total'] + $orderData['fee_total'] + $orderData['shipping_total'] + $estimatedTaxTotal + $estimatedShippingTax;
875
876 $orderData['total_amount'] = $totalAmount > 0 ? $totalAmount : 0;
877 $this->orderData = $orderData;
878 }
879
880 private function syncFeeItems()
881 {
882 $orderId = $this->orderModel->id;
883
884 // Remove existing fee items
885 OrderItem::query()->where('order_id', $orderId)->where('payment_type', 'fee')->delete();
886
887 // Create new fee items from formatted items
888 $feeItems = array_filter($this->formattedIOrderItems, function ($item) {
889 return $item['payment_type'] === 'fee';
890 });
891
892 foreach ($feeItems as $feeItem) {
893 $feeItem['order_id'] = $orderId;
894 $feeItem['quantity'] = 1;
895 $feeItem['line_total'] = $feeItem['subtotal'] - $feeItem['discount_total'];
896 OrderItem::query()->create($feeItem);
897 }
898 }
899
900 /**
901 * @param $inputData
902 * @return array
903 */
904 private function convertToSubscriptionFormat($inputData)
905 {
906
907 /**
908 * Normal Subscription $100/month
909 * {
910 * 'trial_days' => 0,
911 * 'repeat_interval' => 'month',
912 * 'times' => 0, // 0 means unlimited
913 * 'recurring_amount' => 100,
914 * 'signup_fee' => 0
915 * }
916 *
917 * 30 Days Trial $100/month
918 * {
919 * 'trial_days' => 30,
920 * 'repeat_interval' => 'month',
921 * 'times' => 0, // 0 means unlimited
922 * 'recurring_amount' => 100,
923 * }
924 *
925 * $100/month - 30 days Trial with $40 signup fee
926 * {
927 * 'trial_days' => 30,
928 * 'repeat_interval' => 'month',
929 * 'times' => 0, // 0 means unlimited
930 * 'recurring_amount' => 100,
931 * 'signup_fee' => 40
932 * }
933 *
934 * $100 / month with $40 signup fee
935 * {
936 * 'trial_days' => 0,
937 * 'repeat_interval' => 'month',
938 * 'times' => 0, // 0 means unlimited
939 * 'recurring_amount' => 100,
940 * 'signup_fee' => 40
941 * }
942 *
943 * $100 per month but 30% discount on first month
944 * {
945 * 'trial_days' => 30,
946 * 'repeat_interval' => 'month',
947 * 'times' => 0, // 0 means unlimited
948 * 'recurring_amount' => 100,
949 * 'signup_fee' => 70
950 * }
951 *
952 * $100 per month but 50% extra on first month
953 * {
954 * 'trial_days' => 0,
955 * 'repeat_interval' => 'month',
956 * 'times' => 0, // 0 means unlimited
957 * 'recurring_amount' => 100,
958 * 'signup_fee' => 50
959 * }
960 *
961 * $100 per month and 1 month trial and service_fee = $50
962 * {
963 * 'trial_days' => 30,
964 * 'repeat_interval' => 'month',
965 * 'times' => 0, // 0 means unlimited
966 * 'recurring_amount' => 100,
967 * 'signup_fee' => 50
968 * }
969 *
970 *
971 * $100 per month, signup fee $200 - First Month 50% discount
972 *
973 * $first Month = 100 + 200 = 300 - 50% discount = 150
974 * {
975 * 'trial_days' => 30,
976 * 'repeat_interval' => 'month',
977 * 'times' => 0, // 0 means unlimited
978 * 'recurring_amount' => 100,
979 * 'signup_fee' => 150
980 * }
981 *
982 * // prefered when signup_fee > recurring_amount
983 * {
984 * 'trial_days' => 0,
985 * 'repeat_interval' => 'month',
986 * 'times' => 0, // 0 means unlimited
987 * 'recurring_amount' => 100,
988 * 'signup_fee' => 50
989 * }
990 */
991
992
993 // Extract and validate input data
994 $trialDays = (int)($inputData['initial_trial_days'] ?? 0);
995 $repeatInterval = strtolower(trim($inputData['repeat_interval'] ?? 'yearly'));
996 $times = (int)($inputData['times'] ?? 0);
997 $recurringAmount = (int)($inputData['recurring_amount'] ?? 0);
998 $recurringTax = (int)($inputData['recurring_tax_total'] ?? 0);
999 $signupFee = (int)($inputData['signup_fee'] ?? 0);
1000 $signupFeeTax = (int)($inputData['signup_fee_tax'] ?? 0);
1001 $firstIterationTax = (int)($inputData['first_iteration_tax'] ?? 0);
1002 $totalDiscount = (int)($inputData['total_discount'] ?? 0);
1003
1004 // Validate repeat_interval
1005 $validIntervals = array_keys(Helper::getAvailableSubscriptionIntervalMaps());
1006
1007 if (!in_array($repeatInterval, $validIntervals)) {
1008 $repeatInterval = 'yearly';
1009 }
1010
1011 // Convert repeat_interval to standard format
1012 $intervalMap = Helper::getAvailableSubscriptionIntervalMaps();
1013 $standardInterval = Helper::translateIntervalToStandardFormat($repeatInterval);
1014
1015 // Calculate trial days based on interval
1016
1017
1018 // Initialize result array
1019 $result = [
1020 'trial_days' => $trialDays,
1021 'repeat_interval' => $standardInterval,
1022 'times' => $times,
1023 ];
1024
1025
1026 // Calculate signup fee logic
1027 if ($totalDiscount > 0) {
1028 // case: discount applied on subscription with trial days and have signup_fee, otherise discount can't be applied on subscription with trial days.
1029 if ($trialDays > 0 && $signupFee > 0) {
1030 $result['signup_fee'] = max(0, $signupFee - $totalDiscount);
1031 $result['manage_setup_fee'] = 'yes';
1032
1033 if (Arr::get($inputData, 'tax_behavior', 0) == 1 && $firstIterationTax) {
1034 $result['signup_fee'] += $firstIterationTax;
1035 }
1036 } else {
1037 $firstCycleCost = $recurringAmount + $signupFee - $totalDiscount;
1038
1039 if (Arr::get($inputData, 'is_recurring_coupon', 'no') === 'yes') {
1040 $recurringAmount -= $totalDiscount; // as now discount applied on recurring amount
1041 }
1042
1043 if ($firstCycleCost < $recurringAmount) {
1044 $adjustedTrialDays = Helper::calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval);
1045
1046 $result['trial_days'] = $adjustedTrialDays;
1047 $result['is_trial_days_simulated'] = 'yes';
1048 $result['signup_fee'] = $firstCycleCost;
1049 $result['manage_setup_fee'] = 'yes';
1050 $result['times'] = $times > 0 ? $times - 1 : 0;
1051 } else if ($firstCycleCost > $recurringAmount) {
1052 $result['trial_days'] = 0;
1053 $result['signup_fee'] = $firstCycleCost - $recurringAmount;
1054 $result['manage_setup_fee'] = 'yes';
1055 } else if ($firstCycleCost == $recurringAmount) {
1056 $result['trial_days'] = 0;
1057 $result['signup_fee'] = 0;
1058 $result['manage_setup_fee'] = 'no';
1059 }
1060
1061 // only the signup fee is adjustable on our system, so we can adjust that to our needs
1062 if (Arr::get($inputData, 'tax_behavior', 0) == 1 && $firstIterationTax) {
1063 if ($result['trial_days'] > 0) {
1064 $result['signup_fee'] += $firstIterationTax;
1065 } else {
1066 $result['signup_fee'] += ($firstIterationTax - $recurringTax); // need to minus the recurring tax as it would add automatically to the payable amount as no trial days
1067 }
1068 }
1069 }
1070
1071 } else {
1072 $result['signup_fee'] = $signupFee;
1073
1074 if (Arr::get($inputData, 'tax_behavior', 0) == 1) {
1075 if ($signupFeeTax) {
1076 $result['signup_fee'] += $signupFeeTax;
1077 }
1078 }
1079 }
1080
1081
1082 $result['repeat_interval'] = array_flip($intervalMap)[$result['repeat_interval']] ?? 'yearly';
1083
1084
1085 return [
1086 'billing_interval' => $result['repeat_interval'],
1087 'bill_times' => $result['times'],
1088 'trial_days' => $result['trial_days'],
1089 'is_trial_days_simulated' => Arr::get($result, 'is_trial_days_simulated', 'no'),
1090 'recurring_amount' => $recurringAmount,
1091 'recurring_tax_total' => $recurringTax,
1092 'signup_fee' => $result['signup_fee'] ?? 0,
1093 ];
1094 }
1095 }
1096