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

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

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