PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.4
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 / Services / OrderService.php

OrderService.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.4, at app/Services/OrderService.php

657 lines 24.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services;
4
5 use FluentCart\Api\ModuleSettings;
6 use FluentCart\Api\StoreSettings;
7 use FluentCart\App\App;
8 use FluentCart\App\Helpers\AddressHelper;
9 use FluentCart\App\Helpers\Helper;
10 use FluentCart\App\Models\Model;
11 use FluentCart\App\Models\Order;
12 use FluentCart\App\Models\OrderItem;
13 use FluentCart\App\Models\OrderMeta;
14 use FluentCart\App\Models\OrderTransaction;
15 use FluentCart\App\Models\ProductVariation;
16 use FluentCart\App\Models\Subscription;
17 use FluentCart\Framework\Support\Arr;
18
19 class OrderService
20 {
21 /**
22 * Group address and other data from the order data array.
23 *
24 * @param array $order_data
25 * @return array
26 */
27 public static function groupSanitizedData($order_data = []): array
28 {
29 // fct_billing_tax_id is the checkout field name for the VAT number; normalise it to
30 // billing_vat_number so extractAddressData maps it into billing_address.vat_number
31 if (isset($order_data['fct_billing_tax_id'])) {
32 $order_data['billing_vat_number'] = $order_data['fct_billing_tax_id'];
33 unset($order_data['fct_billing_tax_id']);
34 }
35
36 $billingAddress = static::extractAddressData($order_data, 'billing_');
37 $shippingAddress = static::extractAddressData($order_data, 'shipping_');
38 $others = static::extractOtherData($order_data);
39
40 if (Arr::get($others, 'ship_to_different', 'no') === 'no') {
41 $shippingAddress = $billingAddress;
42 }
43
44 $billingAddress = static::finalizeAddress($billingAddress, 'billing');
45 $shippingAddress = static::finalizeAddress($shippingAddress, 'shipping');
46
47 return [
48 'billing_address' => $billingAddress,
49 'shipping_address' => $shippingAddress,
50 'others' => $others,
51 ];
52 }
53
54 private static function extractAddressData($data, $prefix): array
55 {
56 $address = [];
57 foreach ($data as $key => $value) {
58 if (strpos($key, $prefix) === 0) {
59 $newKey = str_replace($prefix, '', $key);
60 $address[$newKey] = static::sanitizeEmailOrText($newKey, $value);
61 }
62 }
63 return $address;
64 }
65
66 private static function extractOtherData($data): array
67 {
68 $others = [];
69 foreach ($data as $key => $value) {
70 if (strpos($key, 'billing_') !== 0 && strpos($key, 'shipping_') !== 0) {
71 $others[$key] = sanitize_text_field($value);
72 }
73 }
74
75 return $others;
76 }
77
78 private static function finalizeAddress($address, $type): array
79 {
80 $fullName = Arr::get($address, 'full_name', '');
81 $name = Arr::get($address, 'name', '');
82 $address['full_name'] = $fullName ?? $name;
83 $address['first_name'] = AddressHelper::guessFirstNameAndLastName($address['full_name']);
84 $address['type'] = $type;
85 $name = $fullName ?? $name;
86 $address = array_merge($address, AddressHelper::guessFirstNameAndLastName($name));
87 $address['name'] = Arr::get($address, 'first_name', '') . ' ' . Arr::get($address, 'last_name', '');
88 return $address;
89 }
90
91
92 /**
93 * Sanitize email or text field based on the key.
94 *
95 * @param string $key
96 * @param string $value
97 * @return string
98 */
99 public static function sanitizeEmailOrText($key, $value): string
100 {
101 return $key === 'email' ? sanitize_email($value ?? '') : sanitize_text_field($value ?? '');
102 }
103
104 /**
105 * Pluck product IDs from the order.
106 *
107 * @param Order $order
108 * @return array
109 */
110 public static function pluckProductIds(Order $order): array
111 {
112 return array_column($order->order_items->toArray(), 'post_id');
113 }
114
115
116 /**
117 * Validate products and check if they are available by stock.
118 *
119 * @param array $products
120 * @return void
121 * @throws \Exception
122 */
123 public static function validateProducts($products,$prevOrder = null)
124 {
125 $itemIds = array_column($products, 'object_id');
126 $currentVariations = ProductVariation::query()
127 ->whereIn('id', $itemIds)
128 ->with(['product.detail', 'product_detail'])
129 ->get()
130 ->keyBy('id')
131 ->toArray();
132
133 foreach ($products as $product) {
134 // Custom item validation occurs during cart insertion.
135 // If the validation hook is missing or fails, the item is not added to the cart
136 // and therefore never reaches OrderService.
137 // Adding a fallback here would duplicate responsibility and break cart invariants.
138 // is_custom is already normalized and validated at cart level.
139 // No coercion required here.
140 if(!Arr::get($product, 'is_custom', false)) {
141 static::validateProductAvailability($product, $currentVariations);
142 static::validateSubscriptionQuantity($product);
143 static::validateStockStatus($product, $currentVariations, $prevOrder);
144 static::validateStockQuantity($product, $currentVariations, $prevOrder);
145 }
146 }
147 }
148
149 private static function validateProductAvailability($product, $currentVariations)
150 {
151 $currentVariation = $currentVariations[$product['object_id']] ?? null;
152
153 if (
154 empty($currentVariation) ||
155 empty($currentVariation['product']) ||
156 !in_array($currentVariation['product']['post_status'], ['publish', 'private'])
157 ) {
158 throw new \Exception(sprintf(
159 /* translators: %s: product title */
160 esc_html__('[%s] is not available.', 'fluent-cart'),
161 esc_html(Arr::get($product, 'title'))
162 ));
163 }
164 }
165
166 private static function validateSubscriptionQuantity($product)
167 {
168 $paymentType = Arr::get($product, 'other_info.payment_type', 'onetime');
169
170 if ($paymentType === 'subscription' && Arr::get($product, 'quantity') > 1) {
171 throw new \Exception(
172 sprintf(
173 /* translators: %s: product title */
174 esc_html__('You cannot purchase multiple quantities of the subscription product [%s]. Please adjust the quantity to 1.', 'fluent-cart'),
175 esc_html(Arr::get($product, 'title'))
176 )
177 );
178 }
179 }
180
181 private static function validateStockStatus($product, $currentVariations, $prevOrder = null)
182 {
183 if (!ModuleSettings::isActive('stock_management')) {
184 return;
185 }
186
187 $currentVariation = $currentVariations[$product['object_id']] ?? null;
188
189 // check if $prevOrder is not null.
190 if ($prevOrder) {
191
192 // get order_items from OrderItems
193 $orderItems = $prevOrder->order_items;
194
195 // filter payment_type is not signup_fee or fee
196 $orderItems = $orderItems->filter(function ($item) {
197 return !in_array($item->payment_type, ['signup_fee', 'fee']);
198 });
199
200 $stockMovement = OrderMeta::where('order_id', $prevOrder->id)
201 ->where('meta_key', 'stock_movement')
202 ->value('meta_value');
203
204 // Decode stock movement if it's JSON
205 $stockMovementData = $stockMovement ?? [];
206
207 // Check if the current product variation is already on hold in stock movement
208 $isStockOnHold = false;
209
210 if (!empty($stockMovementData)) {
211
212 // Find the order item that matches this variation ID
213 foreach ($orderItems as $item) {
214 if ($item->object_id == Arr::get($currentVariation, 'id')) {
215 $orderItemId = $item->id;
216
217 // Check if this order item has stock on hold
218 // Structure: {"95":{"committed":10,"on_hold":0}} where 95 is order item ID
219 if (isset($stockMovementData[$orderItemId]['on_hold']) &&
220 $stockMovementData[$orderItemId]['on_hold'] > 0) {
221 $isStockOnHold = true;
222 break;
223 }
224 }
225 }
226 }
227
228
229 // If stock is already on hold for this variation, skip stock validation
230 if ($isStockOnHold) {
231 // Stock is already reserved for this order, no need to check
232 return;
233 }
234
235 // If stock is NOT on hold, perform normal stock validation
236 if (
237 Arr::get($currentVariation, 'stock_status') !== "in-stock" &&
238 Arr::get($currentVariation, 'manage_stock') == 1
239 ) {
240 throw new \Exception(sprintf(
241 /* translators: %s: product title */
242 esc_html__('[%s] is out of stock.', 'fluent-cart'),
243 esc_html(Arr::get($product, 'title'))
244 ));
245 }
246
247 } else {
248
249 // No previous order, perform normal stock validation
250 if (
251 Arr::get($currentVariation, 'stock_status') !== "in-stock" &&
252 Arr::get($currentVariation, 'manage_stock') == 1
253 ) {
254 throw new \Exception(sprintf(
255 /* translators: %s: product title */
256 esc_html__('[%s] is out of stock.', 'fluent-cart'),
257 esc_html(Arr::get($product, 'title'))
258 ));
259 }
260 }
261 }
262
263 private static function validateStockQuantity($product, $currentVariations, $prevOrder = null)
264 {
265 if (!ModuleSettings::isActive('stock_management')) {
266 return;
267 }
268 $currentVariation = $currentVariations[$product['object_id']] ?? null;
269 $productQuantity = (int)Arr::get($product, 'quantity');
270
271 // check if $prevOrder is not null
272 if ($prevOrder) {
273
274 // get order_items from OrderItems
275 $orderItems = $prevOrder->order_items;
276
277
278 $orderItems = $orderItems->filter(function ($item) {
279 return !in_array($item->payment_type, ['signup_fee', 'fee']);
280 });
281
282 // get stock movement for this order
283 $stockMovement = OrderMeta::where('order_id', $prevOrder->id)
284 ->where('meta_key', 'stock_movement')
285 ->value('meta_value');
286
287 // Decode stock movement if it's JSON
288 $stockMovementData = $stockMovement ?? [];
289
290 // Check if the current product variation is already on hold in stock movement
291 $isStockOnHold = false;
292
293 if (!empty($stockMovementData)) {
294
295 // Find the order item that matches this variation ID
296 foreach ($orderItems as $item) {
297 if ($item->object_id == Arr::get($currentVariation, 'id')) {
298 $orderItemId = $item->id;
299
300 // Check if this order item has stock on hold
301 // Structure: {"95":{"committed":10,"on_hold":0}} where 95 is order item ID
302 if (isset($stockMovementData[$orderItemId]['on_hold']) &&
303 $stockMovementData[$orderItemId]['on_hold'] > 0) {
304 $isStockOnHold = true;
305 break;
306 }
307 }
308 }
309 }
310
311 // If stock is already on hold for this variation, skip quantity validation
312 if ($isStockOnHold) {
313 // Stock is already reserved for this order, no need to check quantity
314 return;
315 }
316 }
317
318 // Perform normal quantity validation if no previous order OR stock is not on hold
319 if (!static::allowItemToOrder($currentVariation, $productQuantity)) {
320 $note = sprintf(
321 /* translators: %s: product title */
322 esc_html__('[%1$s] is out of stock. Only %2$s left.', 'fluent-cart'),
323 esc_html(Arr::get($product, 'title')),
324 esc_html(Arr::get($currentVariation, 'available'))
325 );
326 throw new \Exception(esc_html($note));
327 }
328 }
329
330 /**
331 * Check if the item can be ordered based on stock availability.
332 *
333 * @param array $variation
334 * @param int $updatedQuantity
335 * @return bool
336 */
337 private static function allowItemToOrder($variation, $updatedQuantity): bool
338 {
339 if (Arr::get($variation, 'manage_stock') == 0) {
340 return true;
341 }
342 return $updatedQuantity <= Arr::get($variation, 'available');
343 }
344
345 /**
346 * Get the total amount of items without discount.
347 *
348 * @param array $orderItems
349 * @return float
350 */
351 public static function getItemsAmountWithoutDiscount(array $orderItems)
352 {
353 $total = 0;
354 foreach ($orderItems as $orderItem) {
355 $paymentType = Arr::get($orderItem, 'payment_type');
356 if (!$paymentType) {
357 $paymentType = Arr::get($orderItem, 'other_info.payment_type');
358 }
359
360 $total += self::calculateItemTotal($orderItem, $paymentType);
361
362 }
363 return $total;
364 }
365
366 private static function isDiscountedSubscription($orderItem): bool
367 {
368 $paymentType = Arr::get($orderItem, 'other_info.payment_type', '');
369 return $paymentType === 'subscription' && Arr::get($orderItem, 'discount_total', 0) > 0;
370 }
371
372 private static function isPlanChangeAdjustment($orderItem, $paymentType): bool
373 {
374 $orderType = Arr::get($orderItem, 'other_info.order_type', '');
375 return $orderType === 'plan_change' && $paymentType === 'adjustment';
376 }
377
378 private static function calculateItemTotal($orderItem, $paymentType): float
379 {
380
381 if (in_array($paymentType, ['signup_fee'], true)) {
382 return floatval($orderItem['unit_price']);
383 }
384
385 if ($paymentType == 'subscription') {
386 if ((int)Arr::get($orderItem, 'other_info.trial_days', 0) > 0) {
387 return (int)Arr::get($orderItem, 'other_info.signup_fee', 0);
388 }
389 return (int) Arr::get($orderItem, 'subtotal', intval($orderItem['unit_price'] * $orderItem['quantity'])) + (int)Arr::get($orderItem, 'other_info.signup_fee', 0);
390 }
391
392 return (int) Arr::get($orderItem, 'subtotal', intval($orderItem['unit_price'] * $orderItem['quantity']));
393 }
394
395 /**
396 * Get the total amount of items.
397 *
398 * @param array|Model $items
399 * @param bool $formatted
400 * @param bool $withCurrency
401 * @return float|string
402 */
403 public static function getItemsAmountTotal($items = [], $formatted = true, $withCurrency = true, $shippingTotal = 0)
404 {
405 $items = $items instanceof Model ? $items->toArray() : $items;
406
407 $total = 0;
408
409 foreach ($items as $cartItem) {
410 // according to cart structure, line total is item_price * quantity
411 $subtotal = floatval(Arr::get($cartItem, 'subtotal', 0));
412 $discountTotal = floatval(Arr::get($cartItem, 'discount_total', 0));
413
414 $otherInfo = Arr::get($cartItem, 'other_info', []);
415 if (is_object($otherInfo)) {
416 $otherInfo = (array)$otherInfo;
417 }
418
419 $trialDays = intval(Arr::get($otherInfo, 'trial_days', 0));
420 $signupFee = floatval(Arr::get($otherInfo, 'signup_fee', 0));
421
422 if ($trialDays > 0) {
423 // For subscriptions with trial days, charge only signup fee minus discount
424 // The discount_total is the discount applied to the signup fee
425 $total += max(0,$signupFee - $discountTotal);
426 continue;
427 }
428
429
430 $total += $subtotal - $discountTotal;
431
432
433 $total += floatval($signupFee - Arr::get($otherInfo, 'signup_discount', 0));
434 }
435
436 $total += $shippingTotal;
437
438 $total = apply_filters('fluent_cart/cart/items_total', $total, [
439 'items' => $items,
440 'shipping_total' => $shippingTotal,
441 ]);
442
443 // Ensure filtered total is non-negative
444 $total = max(0, (int)round((float)$total));
445
446 return $formatted ? Helper::toDecimal($total, $withCurrency) : $total;
447 }
448
449 private static function calculateItemAmount(array $cartItem, $price, $paymentType, $formatted, $withCurrency)
450 {
451 if ($paymentType === 'signup_fee') {
452 return $price;
453 }
454 if ($paymentType === 'adjustment') {
455 return $formatted ? Helper::toDecimal(intval($price), $withCurrency) : intval($price);
456 }
457 $quantity = intval(Arr::get($cartItem, 'quantity', 1));
458 $discount = floatval(Arr::get($cartItem, 'discount_total', 0));
459 return (($price * $quantity) - $discount);
460 }
461
462 /**
463 * Create line items from order items.
464 *
465 * @param array $orderItems
466 * @return array
467 */
468 public static function makeLineItemsFromOrderItems($orderItems): array
469 {
470 $subscriptionItems = [];
471 $items = [];
472
473 foreach ($orderItems as $orderItem) {
474 $orderItem = $orderItem instanceof Model ? $orderItem->toArray() : $orderItem;
475 if (static::isSubscriptionItem($orderItem)) {
476 $subscriptionItems[] = static::prepareSubscriptionItem($orderItem);
477 } else {
478 $items[] = static::prepareRegularItem($orderItem);
479 }
480 }
481
482 return [
483 'items' => $items,
484 'subscriptionItems' => $subscriptionItems
485 ];
486 }
487
488 private static function isSubscriptionItem(array $orderItem): bool
489 {
490 return Arr::get($orderItem, 'payment_type') === 'subscription';
491 }
492
493 private static function prepareSubscriptionItem(array $orderItem): array
494 {
495 $orderItem['other_info'] = Arr::get($orderItem, 'other_info', []);
496
497 $otherInfo = (array)Arr::get($orderItem, 'other_info');
498
499 return [
500 'product_id' => Arr::get($orderItem, 'post_id'),
501 'object_id' => Arr::get($orderItem, 'object_id', ''),
502 'billing_interval' => Arr::get($otherInfo, 'repeat_interval'),
503 'signup_fee' => Arr::get($otherInfo, 'signup_fee'),
504 'bill_times' => Arr::get($otherInfo, 'times'),
505 'recurring_amount' => floatval(Arr::get($orderItem, 'unit_price')) * intval(Arr::get($orderItem, 'quantity')),
506 'unit_price' => floatval(Arr::get($orderItem, 'unit_price', 0)),
507 'recurring_tax_total' => floatval(Arr::get($orderItem, 'tax_amount')),
508 'recurring_total' => floatval(Arr::get($orderItem, 'unit_price')),
509 'line_total' => floatval(Arr::get($orderItem, 'line_total')),
510 'trial_days' => empty(Arr::get($otherInfo, 'trial_days')) ? 0 : Arr::get($otherInfo, 'trial_days'),
511 'item_name' => Arr::get($orderItem, 'title') . ' ' . Arr::get($orderItem, 'post_title'),
512 'title' => Arr::get($orderItem, 'title') . ' ' . Arr::get($orderItem, 'post_title'),
513 'quantity' => intval(Arr::get($orderItem, 'quantity')),
514 'variation_id' => Arr::get($orderItem, 'object_id', 0),
515 'id' => Arr::get($orderItem, 'id'),
516 'other_info' => Arr::get($orderItem, 'other_info'),
517 ];
518 }
519
520 private static function prepareRegularItem(array $orderItem): array
521 {
522 return [
523 'product_id' => Arr::get($orderItem, 'post_id'),
524 'object_id' => Arr::get($orderItem, 'object_id', ''),
525 'payment_type' => Arr::get($orderItem, 'payment_type'),
526 'variation_id' => Arr::get($orderItem, 'id'),
527 'quantity' => intval(Arr::get($orderItem, 'quantity')),
528 'post_title' => Arr::get($orderItem, 'post_title'),
529 'title' => Arr::get($orderItem, 'title'),
530 'unit_price' => floatval(Arr::get($orderItem, 'unit_price', 0)),
531 'item_price' => floatval(Arr::get($orderItem, 'unit_price')),
532 'line_total' => floatval(Arr::get($orderItem, 'line_total')),
533 'fallback_title' => Arr::get($orderItem, 'title'),
534 ];
535 }
536
537 /**
538 * Get the root order ID from a given order.
539 *
540 * @param Order $order
541 * @return id|null
542 */
543 public static function getRootOrderId($order, $visited = [])
544 {
545 if (in_array($order->id, $visited)) {
546 return null; // breaking the loop
547 }
548
549 $visited[] = $order->id;
550 if (empty($order->parent_id)) {
551 return $order->id;
552 }
553
554 $parentOrder = Order::query()->where('id', $order->parent_id)->first();
555
556 if (!$parentOrder) {
557 return $order->id;
558 }
559
560 return static::getRootOrderId($parentOrder, $visited);
561 }
562
563 public static function getCouponDiscountTotal($orderItems)
564 {
565 if (empty($orderItems)) {
566 return 0;
567 }
568
569 $coupon_discount_total = 0;
570 foreach ($orderItems as $item) {
571 $coupon_discount_total += Arr::get($item, 'discount_total', 0);
572 }
573
574 return $coupon_discount_total;
575 }
576
577 public static function getNextReceiptNumber()
578 {
579 $lastOrder = Order::query()->max('receipt_number');
580 if (empty($lastOrder)) {
581 $lastOrder = 0;
582 }
583
584 $nextOrderNumber = $lastOrder + 1;
585
586 $min_receipt_number = (new StoreSettings())->get('min_receipt_number') ?? 1;
587 $minReceiptNumber = apply_filters('fluent_cart/min_receipt_number', $min_receipt_number);
588
589 if ($nextOrderNumber < $minReceiptNumber) {
590 $nextOrderNumber = $minReceiptNumber;
591 }
592
593 return $nextOrderNumber;
594 }
595
596 public static function getInvoicePrefix()
597 {
598 $prefix = (new StoreSettings())->get('inv_prefix') ?? 'INV-';
599 return apply_filters('fluent_cart/invoice_prefix', $prefix);
600 }
601
602
603 public static function transformSubscription(Subscription $subscription)
604 {
605 return [
606 'uuid' => $subscription->uuid,
607 'vendor_subscription_id' => $subscription->vendor_subscription_id,
608 'status' => $subscription->status,
609 'overridden_status' => $subscription->overridden_status,
610 'next_billing_date' => $subscription->next_billing_date,
611 'billing_info' => $subscription->billingInfo,
612 'current_payment_method' => $subscription->current_payment_method,
613 'payment_method' => $subscription->payment_method,
614 'payment_info' => $subscription->payment_info,
615 'bill_times' => $subscription->bill_times,
616 'bill_count' => $subscription->bill_count,
617 'config' => $subscription->config,
618 'reactivate_url' => $subscription->getReactivateUrl(),
619 'can_upgrade' => $subscription->canUpgrade(),
620 'can_switch_payment_method' => $subscription->canSwitchPaymentMethod(),
621 'can_update_payment_method' => $subscription->canUpdatePaymentMethod(),
622 'item_name' => $subscription->display_item_name
623 ];
624 }
625
626 public static function transformTransaction(OrderTransaction $transaction)
627 {
628 $data = [
629 'uuid' => $transaction->uuid,
630 'invoice_no' => $transaction->order ? $transaction->order->invoice_no : 'n/a',
631 'created_at' => $transaction->created_at->format('Y-m-d H:i:s'),
632 'total' => $transaction->total,
633 'order_type' => $transaction->order_type,
634 'currency' => $transaction->currency,
635 'status' => $transaction->status,
636 'payment_method' => $transaction->payment_method,
637 'card_brand' => $transaction->card_brand,
638 'card_last_4' => $transaction->card_last_4,
639 'vendor_charge_id' => $transaction->vendor_charge_id,
640 'transaction_type' => $transaction->transaction_type,
641 'receipt_download_url' => $transaction->order ? $transaction->order->getReceiptDownloadUrl() : '',
642 ];
643
644 if ($transaction->order && self::canGenerateReceiptPdf()) {
645 $data['receipt_view_url'] = $transaction->order->getReceiptViewUrl();
646 }
647
648 return $data;
649 }
650
651 public static function canGenerateReceiptPdf(): bool
652 {
653 return App::isProActive() && defined('FLUENT_PDF');
654 }
655
656 }
657