PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.3
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 / api / Resource / OrderResource.php

OrderResource.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.3, at api/Resource/OrderResource.php

2,373 lines 103.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\Api\Resource;
4
5 use FluentCart\Api\Orders;
6 use FluentCart\Api\StoreSettings;
7 use FluentCart\App\App;
8 use FluentCart\App\Events\Order\OrderDeleting;
9 use FluentCart\App\Events\Order\OrderDeleted;
10 use FluentCart\App\Events\Order\RenewalOrderDeleted;
11 use FluentCart\App\Events\Order\OrderStatusUpdated;
12 use FluentCart\App\Events\Order\OrderUpdated;
13 use FluentCart\App\Events\StockChanged;
14 use FluentCart\App\Helpers\AddressHelper;
15 use FluentCart\App\Helpers\Helper;
16 use FluentCart\App\Helpers\AdminOrderProcessor;
17 use FluentCart\App\Helpers\Status;
18 use FluentCart\App\Models\Activity;
19 use FluentCart\App\Models\AppliedCoupon;
20 use FluentCart\App\Models\Cart;
21 use FluentCart\App\Models\Coupon;
22 use FluentCart\App\Models\CustomerAddresses;
23 use FluentCart\App\Models\LabelRelationship;
24 use FluentCart\App\Models\Order;
25 use FluentCart\App\Models\OrderAddress;
26 use FluentCart\App\Models\OrderItem;
27 use FluentCart\App\Models\OrderDownloadPermission;
28 use FluentCart\App\Models\OrderMeta;
29 use FluentCart\App\Models\OrderOperation;
30 use FluentCart\App\Models\OrderTaxRate;
31 use FluentCart\App\Models\OrderTransaction;
32 use FluentCart\App\Models\Query\QueryParser;
33 use FluentCart\App\Models\Query\Sort;
34 use FluentCart\App\Models\ShippingMethod;
35 use FluentCart\App\Models\Subscription;
36 use FluentCart\App\Models\SubscriptionMeta;
37 use FluentCart\App\Services\DateTime\DateTime;
38 use FluentCart\App\Services\OrderService;
39 use FluentCart\App\Services\Payments\PaymentHelper;
40 use FluentCart\App\Services\Payments\PaymentInstance;
41 use FluentCart\App\Services\Tax\AdminOrderTaxService;
42 use FluentCart\App\Modules\Tax\TaxModule;
43 use FluentCart\Framework\Database\Orm\Builder;
44 use FluentCart\Framework\Database\Orm\Collection;
45 use FluentCart\Framework\Support\Arr;
46
47
48 class OrderResource extends BaseResourceApi
49 {
50 public static function getQuery(): Builder
51 {
52 return Order::query();
53 }
54
55 /**
56 * Retrieve orders with additional data based on specified parameters.
57 *
58 * @param array $params Optional. Additional parameters for order retrieval.
59 * $params = [
60 * 'search' => ( string ) Optional. Search Order.
61 * [
62 * 'column name(e.g., first_name|last_name|email|id)' => [
63 * column => 'column name(e.g., first_name|last_name|email|id)',
64 * operator => 'operator (e.g., like_all|rlike|or_rlike|or_like_all)',
65 * value => 'value' ]
66 * ],
67 * 'filters' => ( string ) Optional. Filters order.
68 * [
69 * 'column name(e.g., status|payment_status|payment_method)' => [
70 * column => 'column name(e.g., status|payment_status|payment_method)',
71 * operator => 'operator (e.g., in)',
72 * value => 'value' ]
73 * ],
74 * 'order_by' => ( string ) Optional. Column to order by,
75 * 'order_type' => ( string ) Optional. Order type for sorting ( ASC or DESC ),
76 * 'per_page' => ( int ) Optional. Number of items for per page,
77 * 'page' => ( int ) Optional. Page number for pagination
78 * ]
79 *
80 */
81 public static function get(array $params = [])
82 {
83 $query = static::getQuery();
84 $dynamicConditions = Arr::get($params, 'dynamic_filters') ?? [];
85 QueryParser::make()->parse($query, $dynamicConditions);
86 $sortCriteria = Arr::get($params, 'sort_criteria', []);
87 Sort::make()->apply($query, $sortCriteria);
88
89
90 $with = array_merge(['customer', 'filteredOrderItems'], Arr::get($params, 'with', []));
91
92 return $query->with($with)
93 ->whereHas('customer', function ($query) use ($params) {
94 $query->when(Arr::get($params, 'search'), function ($query) use ($params) {
95 return $query->search(Arr::get($params, 'search', ''));
96 });
97 })
98 ->applyCustomFilters(Arr::get($params, 'filters', []))
99 ->when(!count($sortCriteria), function ($query) use ($params) {
100 $query->orderBy(
101 sanitize_sql_orderby(Arr::get($params, 'order_by', 'id')),
102 sanitize_sql_orderby(Arr::get($params, 'order_type', 'DESC'))
103 );
104 })
105 ->paginate(Arr::get($params, 'per_page'), ['*'], 'page', Arr::get($params, 'page'));
106 }
107
108
109 /**
110 * Find an order by ID with associated customer and address details.
111 *
112 * @param string $id Required. The UUID of the order to find.
113 * @param array $params Optional. Additional parameters for order retrieval.
114 * [
115 * // Include optional parameters, if any.
116 * ]
117 *
118 */
119 public static function find($id, $params = [])
120 {
121 $with = Arr::get($params, 'with', []);
122 return static::getQuery()
123 ->with($with)
124 ->with([
125 'customer' => function ($query) {
126 $query->with([
127 'billing_address' => function ($query) {
128 $query->where('is_primary', '1');
129 }
130 ]);
131 $query->with([
132 'shipping_address' => function ($query) {
133 $query->where('is_primary', '1');
134 }
135 ]);
136 }
137 ])
138 ->where('uuid', $id)
139 ->first();
140 }
141
142 /**
143 * Create an order with the provided data.
144 *
145 * @param array $data Required. Array containing the necessary parameters for order creation.
146 * $data = [
147 * 'status' => ( string ) Required. The status of the order,
148 * // Include additional parameters, if any.
149 * ]
150 * @param array $params Optional. Additional parameters for order creation.
151 * [
152 * // Include optional parameters, if any.
153 * ]
154 *
155 */
156 public static function create($data, $params = [])
157 {
158 $order = $data;
159 $orderItems = Arr::except(Arr::get($order, 'order_items', []), ['*']);
160 $hasPhysicalProduct = false;
161
162 foreach ($orderItems as $item) {
163 if (isset($item['trial_days']) && $item['trial_days'] > 0) {
164 continue;
165 }
166 if (Arr::get($item, 'fulfillment_type') == 'physical') {
167 $hasPhysicalProduct = true;
168 }
169 }
170
171 $subtotal = OrderService::getItemsAmountWithoutDiscount($orderItems); //get order total without a discount
172
173 // because of decimal issue commented this below line, using OrderService::getCouponDiscountTotal instead
174 // $subtotalWithDiscount = OrderService::getItemsAmountTotal($orderItems, false, false); //get order total with discount
175 $couponDiscountTotal = OrderService::getCouponDiscountTotal($orderItems);
176
177 $totalAmount = floatVal($subtotal + Arr::get($order, 'tax_total', 0) + Arr::get($order, 'shipping_total', 0) - Arr::get($order, 'manual_discount_total', 0) - $couponDiscountTotal);
178
179 $latestOrder = static::getQuery()->latest()->first();
180 $latestOrderId = Arr::get($latestOrder, 'id', 0);
181
182 $fulfillmentType = $hasPhysicalProduct ? 'physical' : 'digital';
183 $storeSettings = new StoreSettings();
184
185 $shipping_total = Arr::get($order, 'shipping_total', 0);
186 $userTz = Arr::get($order, 'user_tz');
187 $config = [];
188
189 if (!empty($userTz)) {
190 $config['user_tz'] = $userTz;
191 }
192 $orderData = [
193 'subtotal' => $subtotal,
194 'total_amount' => $totalAmount,
195 'payment_status' => $totalAmount == 0 ? Status::PAYMENT_PAID : Status::PAYMENT_PENDING,
196 'status' => Status::ORDER_ON_HOLD,
197 'currency' => Helper::shopConfig('currency'),
198 'mode' => Helper::shopConfig('order_mode'),
199 'receipt_number' => ($latestOrderId + 1),
200 'invoice_no' => $storeSettings->getInvoicePrefix() . ($latestOrderId + 1) . $storeSettings->getInvoiceSuffix(),
201 'ip_address' => AddressHelper::getIpAddress(),
202 'fulfillment_type' => $fulfillmentType,
203 'manual_discount_total' => Arr::get($order, 'manual_discount_total', 0),
204 'coupon_discount_total' => $couponDiscountTotal,
205 'shipping_total' => $shipping_total,
206 'config' => $config
207 ];
208
209 $isPlanChange = Arr::get($params, 'is_plan_change', 'no');
210 $discountApplied = Arr::get($params, 'discount_applied', 'no');
211 if ('yes' == $isPlanChange && 'yes' == $discountApplied) {
212 $orderData['subtotal'] = $subtotal + Arr::get($params, 'discount_amount', 0);
213 $orderData['manual_discount_total'] = Arr::get($params, 'discount_amount', 0);
214 }
215 $orderData += $order;
216
217 $orderData['created_at'] = DateTime::gmtNow();
218 $orderData['updated_at'] = DateTime::gmtNow();
219
220 try {
221 $res = static::getQuery()->create($orderData);;
222 if (!$res || !$res->id) {
223 throw new \Exception(__('Order creation failed.', 'fluent-cart'));
224 }
225 return $res;
226 } catch (\Exception $e) {
227 return static::makeErrorResponse([
228 ['code' => 400, 'message' => $e->getMessage()]
229 ]);
230 }
231 }
232
233 /**
234 * @throws \Exception
235 */
236 public static function updatedPlaceOrder($data, $params = [])
237 {
238 $order = $data;
239 $discount = Arr::get($data, 'discount');
240 $shipping = Arr::get($data, 'shipping');
241 $newLabelIds = Arr::get($data, 'labels');
242 $paymentMethod = sanitize_text_field('offline_payment');
243
244 $items = Arr::except(Arr::get($order, 'order_items'), ['*']);
245 OrderService::validateProducts($items);
246
247 $customer = static::getCustomer($data);
248
249 if (Arr::get($discount, 'value', 0) > 0) {
250 static::distributeManualDiscount($items, Helper::toCent(Arr::get($discount, 'value', 0)));
251 }
252
253 // admin order processor
254 $adminOrderProcessor = new AdminOrderProcessor($items, [
255 'customer_id' => $customer->id,
256 'payment_method' => $paymentMethod,
257 'applied_coupons' => Arr::get($data, 'applied_coupon', []),
258 'shipping_total' => Arr::get($data, 'shipping_total', []),
259 'billing_address' => Arr::get($customer, 'billing_address', []),
260 'shipping_address' => Arr::get($customer, 'shipping_address', []),
261 'user_tz' => Arr::get($data, 'user_tz', ''),
262 ]);
263
264 $order = $adminOrderProcessor->createDraftOrder();
265
266 $data = Arr::except($data, ['order_items', 'customer', 'discount', 'shipping']);
267
268 try {
269 if ($paymentMethod) {
270 static::addOrderMeta($order->id, $discount, $shipping, $newLabelIds);
271
272 static::commitEvents($order);
273
274 static::createOrderAddresses($order->id, $data, $order->customer_id);
275
276 static::triggerStockChangedEvents($order);
277
278 // Calculate and persist tax for admin-created orders
279 static::applyAdminOrderTax($order, $items, $customer, $data);
280
281 if ($gateway = App::gateway($paymentMethod)) {
282 $paymentInstance = new PaymentInstance($order);
283 $gateway->makePaymentFromPaymentInstance($paymentInstance);
284 }
285
286 return $order;
287 } else {
288 return static::makeErrorResponse([
289 ['code' => 423, 'message' => __('Please select a payment method first!', 'fluent-cart')]
290 ]);
291 }
292 } catch (\Exception $e) {
293 return static::makeErrorResponse([
294 ['code' => 400, 'message' => $e->getMessage()]
295 ]);
296 }
297 }
298
299 /**
300 * Calculate tax for an admin-created order and persist it to fct_order_tax_rate.
301 * Updates order.tax_total and order.shipping_tax. Never throws — tax failure must
302 * not block order creation.
303 *
304 * @param \FluentCart\App\Models\Order $order The freshly created order.
305 * @param array $items Raw order_items from the create-order request.
306 * @param \FluentCart\App\Models\Customer $customer Customer with primary_billing_address loaded.
307 * @param array $data Raw request data (may include billing_address_id).
308 */
309 private static function applyAdminOrderTax($order, $items, $customer, $data = [])
310 {
311 try {
312 // Resolve billing address: prefer the address explicitly selected in the
313 // admin UI (billing_address_id), fall back to customer's primary address.
314 $billingAddress = null;
315 $billingAddressId = (int) Arr::get($data, 'billing_address_id', 0);
316 if ($billingAddressId > 0) {
317 $addr = CustomerAddresses::query()
318 ->where('customer_id', $order->customer_id)
319 ->find($billingAddressId);
320 if ($addr) {
321 $billingAddress = [
322 'country' => $addr->country ?: '',
323 'state' => $addr->state ?: '',
324 'city' => $addr->city ?: '',
325 'postcode' => $addr->postcode ?: '',
326 ];
327 }
328 }
329 $billingFallbackAddress = null;
330 if (!$billingAddress && $customer && $customer->primary_billing_address) {
331 $addr = $customer->primary_billing_address;
332 $billingFallbackAddress = $addr;
333 $billingAddress = [
334 'country' => $addr->country ?: '',
335 'state' => $addr->state ?: '',
336 'city' => $addr->city ?: '',
337 'postcode' => $addr->postcode ?: '',
338 ];
339 }
340
341 // Resolve shipping address for basis=shipping
342 $shippingAddress = null;
343 $shippingAddressId = (int) Arr::get($data, 'shipping_address_id', 0);
344 if ($shippingAddressId > 0) {
345 $addr = CustomerAddresses::query()
346 ->where('customer_id', $order->customer_id)
347 ->find($shippingAddressId);
348 if ($addr) {
349 $shippingAddress = [
350 'country' => $addr->country ?: '',
351 'state' => $addr->state ?: '',
352 'city' => $addr->city ?: '',
353 'postcode' => $addr->postcode ?: '',
354 ];
355 }
356 }
357 $shippingFallbackAddress = null;
358 if (!$shippingAddress && $customer && $customer->primary_shipping_address) {
359 $addr = $customer->primary_shipping_address;
360 $shippingFallbackAddress = $addr;
361 $shippingAddress = [
362 'country' => $addr->country ?: '',
363 'state' => $addr->state ?: '',
364 'city' => $addr->city ?: '',
365 'postcode' => $addr->postcode ?: '',
366 ];
367 }
368
369 $taxSettings = (new TaxModule())->getSettings();
370 $basis = Arr::get($taxSettings, 'tax_calculation_basis', 'shipping');
371 $taxAddress = AdminOrderTaxService::resolveAddressForBasis($basis, $billingAddress, $shippingAddress);
372
373 if (empty($taxAddress['country'])) {
374 // No address — can't calculate tax. Still write the zero-tax
375 // sentinel row so every order records "tax ran, no address"
376 // (same guarantee checkout gives via persistTaxRates).
377 TaxModule::persistTaxRates($order->id, [], [
378 'tax_country' => '',
379 'source' => 'admin_order',
380 'note' => 'no_tax_address',
381 ], 0);
382 return;
383 }
384
385 // Build line items from raw order_items
386 $taxItems = [];
387 foreach ($items as $item) {
388 $unitPrice = (int) Arr::get($item, 'unit_price', 0);
389 $qty = max(1, (int) Arr::get($item, 'quantity', 1));
390 $subtotal = $unitPrice * $qty;
391
392 // Include manual_discount (set by distributeManualDiscount) so tax is
393 // calculated on the after-discount amount, not the full subtotal.
394 $taxItems[] = [
395 'post_id' => (int) Arr::get($item, 'post_id', 0),
396 'object_id' => (int) Arr::get($item, 'object_id', 0),
397 'subtotal' => $subtotal,
398 'discount_total' => (int) Arr::get($item, 'discount_total', 0) + (int) Arr::get($item, 'manual_discount', 0),
399 'shipping_charge'=> (int) Arr::get($item, 'shipping_charge', 0),
400 'quantity' => $qty,
401 'other_info' => Arr::get($item, 'other_info', []),
402 ];
403 }
404
405 $taxResult = AdminOrderTaxService::calculate($taxItems, $taxAddress, $taxSettings);
406
407 if ($taxResult === null) {
408 return; // Tax disabled or no result
409 }
410
411 $taxTotal = (int) Arr::get($taxResult, 'tax_total', 0);
412 $exclusiveTaxTotal = (int) Arr::get($taxResult, 'exclusive_tax_total', 0);
413 $storeTaxBehavior = (int) Arr::get($taxResult, 'store_tax_behavior', 0);
414 $feeTax = (int) Arr::get($taxResult, 'fee_tax', 0);
415 $shippingTax = (int) Arr::get($taxResult, 'shipping_tax', 0);
416 $shippingTaxLines = Arr::get($taxResult, 'shipping_tax_lines', []);
417 $taxLines = Arr::get($taxResult, 'tax_lines', []);
418 $taxCountry = Arr::get($taxResult, 'tax_country', $taxAddress['country']);
419
420 // Always persist tax fields for reporting, even when amounts are zero
421 $taxBehavior = (int) Arr::get($taxResult, 'tax_behavior', 0);
422 $order->tax_behavior = $taxBehavior;
423 $order->tax_total = $taxTotal;
424 $order->shipping_tax = $shippingTax;
425
426 // Calculate total_amount based on tax behavior
427 if ($taxBehavior === 1) {
428 // Pure exclusive: all tax (product + fee) is on top of subtotals.
429 $order->total_amount = $order->total_amount + $taxTotal + $shippingTax;
430 } elseif ($taxBehavior === 3) {
431 // Mixed: only exclusive product tax + store-exclusive fee/shipping on top.
432 $order->total_amount = $order->total_amount + $exclusiveTaxTotal;
433 if ($storeTaxBehavior === 1) {
434 $order->total_amount = $order->total_amount + $feeTax + $shippingTax;
435 }
436 }
437 // behavior=2 (inclusive) or 0 (reverse charge): tax already in item prices
438
439 $DB = App::db();
440 $DB->beginTransaction();
441
442 $order->save();
443
444 // When tax was calculated from the customer's primary address (no address
445 // explicitly attached to the order), persist that address onto the order —
446 // the edit path reads fct_order_addresses, and without this row the next
447 // save would hit the no-country branch and clear the tax charged here.
448 if ($billingFallbackAddress) {
449 static::createOrderAddress($billingFallbackAddress->toArray(), $order->id);
450 }
451 if ($shippingFallbackAddress) {
452 static::createOrderAddress($shippingFallbackAddress->toArray(), $order->id);
453 }
454
455 // Always persist these meta keys so a later recalculation that returns
456 // zero values does not leave stale non-zero data from a prior edit.
457 $order->updateMeta('exclusive_tax_total', $exclusiveTaxTotal);
458 $order->updateMeta('store_tax_behavior', $storeTaxBehavior);
459 $order->updateMeta('fee_tax', $feeTax);
460
461 // Patch per-item tax_amount and line_meta so tax badges display correctly.
462 $lineItemsFromTax = Arr::get($taxResult, 'line_items', []);
463 if (!empty($lineItemsFromTax)) {
464 $savedItems = OrderItem::query()
465 ->where('order_id', $order->id)
466 ->whereNotIn('payment_type', ['fee', 'signup_fee'])
467 ->get()
468 ->toArray();
469 static::patchOrderItemTaxMeta($savedItems, $lineItemsFromTax);
470 static::patchSignupFeeTaxMeta($order->id, $lineItemsFromTax);
471 static::patchSubscriptionTax($order, $lineItemsFromTax, $taxBehavior);
472 }
473
474 // Persist tax-rate rows
475 $taxMeta = [
476 'tax_country' => $taxCountry,
477 'tax_behavior' => $taxBehavior,
478 'inclusive' => $taxBehavior === 2,
479 'shipping_inclusive' => $storeTaxBehavior === 2,
480 'source' => 'admin_order',
481 ];
482
483 TaxModule::persistTaxRates($order->id, $taxLines, $taxMeta, $shippingTax, $shippingTaxLines);
484
485 // Sync the pending charge transaction total so it matches the tax-adjusted order total.
486 $pendingTx = OrderTransaction::query()
487 ->where('order_id', $order->id)
488 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
489 ->where('status', 'pending')
490 ->first();
491 if ($pendingTx) {
492 $pendingTx->total = $order->total_amount;
493 $pendingTx->save();
494 }
495
496 $DB->commit();
497
498 } catch (\Exception $e) {
499 if (isset($DB)) {
500 $DB->rollBack();
501 }
502 // Log but never block order creation — tax calculation is non-critical
503 fluent_cart_warning_log(
504 'Admin order tax calculation failed',
505 get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
506 ['module_name' => 'tax', 'module_id' => $order->id, 'log_type' => 'api']
507 );
508 }
509 }
510
511 /**
512 * Recalculate and persist tax for an existing order after create or update.
513 * Reads saved items + billing address from the DB, runs AdminOrderTaxService,
514 * recomputes total_amount from scratch, and rewrites fct_order_tax_rate rows.
515 * Never throws — tax failure must not block the save.
516 */
517 private static function reapplyTaxAfterUpdate($orderId, $order)
518 {
519 try {
520 if (!$order->relationLoaded('order_items')) {
521 $order->load('order_items');
522 }
523
524 if ($order->isSubscription()) {
525 return;
526 }
527
528 if ($order->type === 'refund') {
529 return;
530 }
531
532 // Query addresses directly — ORM relation load() does not reliably apply
533 // the type WHERE constraint, so we query fct_order_addresses ourselves.
534 $billingAddr = OrderAddress::query()->where('order_id', $orderId)->where('type', 'billing')->first();
535 $shippingAddr = OrderAddress::query()->where('order_id', $orderId)->where('type', 'shipping')->first();
536
537 $billingAddress = null;
538 $shippingAddress = null;
539
540 if ($billingAddr) {
541 $billingAddress = [
542 'country' => $billingAddr->country ?: '',
543 'state' => $billingAddr->state ?: '',
544 'city' => $billingAddr->city ?: '',
545 'postcode' => $billingAddr->postcode ?: '',
546 ];
547 }
548 if ($shippingAddr) {
549 $shippingAddress = [
550 'country' => $shippingAddr->country ?: '',
551 'state' => $shippingAddr->state ?: '',
552 'city' => $shippingAddr->city ?: '',
553 'postcode' => $shippingAddr->postcode ?: '',
554 ];
555 }
556
557 $taxSettings = (new TaxModule())->getSettings();
558 $basis = Arr::get($taxSettings, 'tax_calculation_basis', 'shipping');
559 $taxAddress = AdminOrderTaxService::resolveAddressForBasis($basis, $billingAddress, $shippingAddress);
560
561 if (empty($taxAddress['country'])) {
562 static::clearOrderTax($orderId, $order);
563 return;
564 }
565
566 $productItems = $order->order_items->filter(function ($item) {
567 return !in_array($item->payment_type, ['fee', 'signup_fee'], true);
568 })->values();
569
570 $taxItems = [];
571 foreach ($productItems as $item) {
572 $unitPrice = (int) Arr::get($item, 'unit_price', 0);
573 $qty = max(1, (int) Arr::get($item, 'quantity', 1));
574 $taxItems[] = [
575 'post_id' => (int) Arr::get($item, 'post_id', 0),
576 'object_id' => (int) Arr::get($item, 'object_id', 0),
577 'subtotal' => $unitPrice * $qty,
578 'discount_total' => (int) Arr::get($item, 'discount_total', 0),
579 'shipping_charge' => (int) Arr::get($item, 'shipping_charge', 0),
580 'quantity' => $qty,
581 'other_info' => Arr::get($item, 'other_info', []),
582 ];
583 }
584
585 if (empty($taxItems)) {
586 static::clearOrderTax($orderId, $order);
587 return;
588 }
589
590 // Fee items only exist on checkout-created orders that are edited in
591 // admin. Mirror checkout (TaxModule::calculateCartTax()): only taxable,
592 // non-zero fees enter the calculator as is_fee lines. Fee item subtotal
593 // holds the NET fee amount (CheckoutProcessor::syncFeeItems() stores it
594 // tax-free), so it doubles as the net fee base for the total recompute.
595 // Guard: when the order has NO fee order items, the stored fee_total
596 // column is the only source (legacy / manually set) — keep it as-is and
597 // skip fee tax entirely.
598 $feeOrderItems = $order->order_items->filter(function ($item) {
599 return $item->payment_type === 'fee';
600 })->values();
601
602 $hasFeeItems = !$feeOrderItems->isEmpty();
603 $netFeeTotal = 0;
604 foreach ($feeOrderItems as $feeItem) {
605 $feeSubtotal = (int) Arr::get($feeItem, 'subtotal', 0);
606 $netFeeTotal += $feeSubtotal;
607
608 $feeOtherInfo = Arr::get($feeItem, 'other_info', []);
609 if (!is_array($feeOtherInfo)) {
610 $feeOtherInfo = [];
611 }
612 if (empty($feeOtherInfo['taxable']) || $feeSubtotal <= 0) {
613 continue;
614 }
615
616 $taxItems[] = [
617 'is_fee' => true,
618 'title' => (string) Arr::get($feeItem, 'title', ''),
619 'post_id' => 0,
620 'object_id' => 0,
621 'subtotal' => $feeSubtotal,
622 'discount_total' => 0,
623 'shipping_charge' => 0,
624 'quantity' => 1,
625 'other_info' => $feeOtherInfo,
626 ];
627 }
628
629 $taxResult = AdminOrderTaxService::calculate($taxItems, $taxAddress, $taxSettings);
630
631 if ($taxResult === null) {
632 if (!TaxModule::isTaxEnabled()) {
633 // Deterministic: tax was turned off — clear stale tax instead of leaving it.
634 static::clearOrderTax($orderId, $order);
635 }
636 // Transient calculation failure: keep existing tax untouched.
637 return;
638 }
639
640 $taxTotal = (int) Arr::get($taxResult, 'tax_total', 0);
641 $exclusiveTaxTotal = (int) Arr::get($taxResult, 'exclusive_tax_total', 0);
642 $storeTaxBehavior = (int) Arr::get($taxResult, 'store_tax_behavior', 0);
643 $feeTax = (int) Arr::get($taxResult, 'fee_tax', 0);
644 $feeTaxLines = (array) Arr::get($taxResult, 'fee_tax_lines', []);
645 $shippingTax = (int) Arr::get($taxResult, 'shipping_tax', 0);
646 $shippingTaxLines = Arr::get($taxResult, 'shipping_tax_lines', []);
647 $taxLines = Arr::get($taxResult, 'tax_lines', []);
648 $taxCountry = Arr::get($taxResult, 'tax_country', $taxAddress['country']);
649 $taxBehavior = (int) Arr::get($taxResult, 'tax_behavior', 0);
650 $lineItemsFromTax = Arr::get($taxResult, 'line_items', []);
651
652 // Respect a checkout-time VIES validation: when the order carries a
653 // validated VAT number and reverse charge still applies for the
654 // (possibly edited) address, zero the recalculated tax and keep the
655 // RC audit meta instead of re-adding tax the buyer does not owe.
656 $rcMeta = [];
657 $rcContext = static::resolveAdminReverseChargeContext($order, $taxAddress);
658 if ($rcContext !== null) {
659 $rcMode = $order->getOrderRcMode();
660 // tax_total includes fee tax; the inclusive portion must not
661 // (same formula as checkout: taxTotal - exclusiveTaxTotal - feeTax).
662 $inclusivePortion = max(0, $taxTotal - $exclusiveTaxTotal - $feeTax);
663
664 $rcMeta = [
665 'reverse_charge_applied' => true,
666 'vat_reverse' => $rcContext,
667 'reverse_charge_original_tax_total' => $exclusiveTaxTotal + $feeTax + $shippingTax + ($rcMode === 'dynamic' ? $inclusivePortion : 0),
668 'reverse_charge_original_shipping_tax' => $shippingTax,
669 'reverse_charge_price_mode' => $rcMode,
670 ];
671
672 // Zero RC-style — rate rows keep their identity with zero amounts,
673 // line items keep their tax_config rates (strikethrough display)
674 // while top-level tax_amount is zeroed. Same convention as checkout.
675 foreach ($taxLines as $lineIndex => $taxLine) {
676 $taxLines[$lineIndex]['tax_amount'] = 0;
677 }
678 foreach ($lineItemsFromTax as $itemIndex => $taxLineItem) {
679 $lineItemsFromTax[$itemIndex]['tax_amount'] = 0;
680 $lineItemsFromTax[$itemIndex]['signup_fee_tax'] = 0;
681 }
682 $taxTotal = 0;
683 $exclusiveTaxTotal = 0;
684 $shippingTax = 0;
685 $shippingTaxLines = [];
686 $taxBehavior = 0;
687 $feeTax = 0;
688 $feeTaxLines = [];
689 }
690
691 // Fee base for the total recompute. The stored fee_total column on a
692 // behavior-1 checkout order already contains the ORIGINAL fee tax
693 // (CheckoutProcessor rolled it in) — trusting it would double-count
694 // fee tax against the freshly calculated one. When fee order items
695 // exist, their subtotals are the net fee amounts; rebuild fee_total
696 // from net + new fee tax (checkout invariant: gateways read fee_total
697 // as the gross fee). Without fee items, keep the stored column as-is.
698 $feeBaseTotal = (int) $order->fee_total;
699 if ($hasFeeItems) {
700 $feeBaseTotal = $netFeeTotal;
701 $newFeeTotal = $netFeeTotal;
702 if ($feeTax && ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1))) {
703 $newFeeTotal += $feeTax;
704 }
705 $order->fee_total = $newFeeTotal;
706 }
707
708 // Recompute total_amount from first principles so old tax is never double-counted.
709 // fee base must be included — checkout orders carry payment/processing fees
710 // outside subtotal (see CheckoutProcessor::prepareOrderData()).
711 $baseTotal = (int)$order->subtotal
712 + (int)$order->shipping_total
713 + $feeBaseTotal
714 - (int)$order->coupon_discount_total
715 - (int)$order->manual_discount_total;
716
717 $order->tax_behavior = $taxBehavior;
718 $order->tax_total = $taxTotal;
719 $order->shipping_tax = $shippingTax;
720 $order->total_amount = $baseTotal;
721
722 if ($taxBehavior === 1) {
723 // taxTotal already includes feeTax → net fee + fee tax counted exactly once.
724 $order->total_amount += $taxTotal + $shippingTax;
725 } elseif ($taxBehavior === 3) {
726 // exclusiveTaxTotal excludes fee lines → add feeTax explicitly for exclusive stores.
727 $order->total_amount += $exclusiveTaxTotal;
728 if ($storeTaxBehavior === 1) {
729 $order->total_amount += $feeTax + $shippingTax;
730 }
731 }
732
733 $DB = App::db();
734 $DB->beginTransaction();
735
736 $order->save();
737
738 // Always persist these meta keys so a later recalculation that returns
739 // zero values does not leave stale non-zero data from a prior edit.
740 $order->updateMeta('exclusive_tax_total', $exclusiveTaxTotal);
741 $order->updateMeta('store_tax_behavior', $storeTaxBehavior);
742 $order->updateMeta('fee_tax', $feeTax);
743
744 // Same persist/delete pattern as CheckoutProcessor::persistTaxMeta() —
745 // a stale checkout-written fee_tax_lines must not survive an admin edit
746 // that produced no fee tax.
747 if (!empty($feeTaxLines)) {
748 $order->updateMeta('fee_tax_lines', $feeTaxLines);
749 } else {
750 $order->deleteMeta('fee_tax_lines');
751 }
752
753 // Patch per-item tax_amount and line_meta so tax badges display correctly.
754 // patchSignupFeeTaxMeta() is always called (even when no items have signup-fee tax)
755 // so it can zero out items that were previously taxed but are now exempt.
756 static::patchOrderItemTaxMeta($productItems->toArray(), $lineItemsFromTax);
757 static::patchSignupFeeTaxMeta($orderId, $lineItemsFromTax);
758
759 $taxMeta = array_merge([
760 'tax_country' => $taxCountry,
761 'tax_behavior' => $taxBehavior,
762 'inclusive' => $taxBehavior === 2,
763 'shipping_inclusive' => $storeTaxBehavior === 2,
764 'source' => 'admin_order_edit',
765 ], $rcMeta);
766
767 OrderTaxRate::query()->where('order_id', $orderId)->delete();
768 TaxModule::persistTaxRates($orderId, $taxLines, $taxMeta, $shippingTax, $shippingTaxLines);
769
770 $pendingTx = OrderTransaction::query()
771 ->where('order_id', $orderId)
772 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
773 ->where('status', 'pending')
774 ->first();
775 if ($pendingTx) {
776 $pendingTx->total = $order->total_amount;
777 $pendingTx->save();
778 }
779
780 // Paid orders: settled transactions are never touched — reflect the new
781 // total as a due / refund-owed state instead.
782 static::syncPaymentStatusWithTotals($order);
783
784 $DB->commit();
785
786 } catch (\Exception $e) {
787 if (isset($DB)) {
788 $DB->rollBack();
789 }
790 fluent_cart_warning_log(
791 'Admin order tax recalculation failed on update',
792 get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
793 ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api']
794 );
795 }
796 }
797
798 /**
799 * Re-derive payment_status after a tax recalculation changed total_amount on
800 * an order that already received money. A fully-paid order whose total grew
801 * becomes partially_paid (the admin UI then shows Total Due + Collect
802 * Payments); a partially_paid order whose total shrank to within total_paid
803 * becomes paid. Overpayment keeps status paid — the Total Refund Owed row is
804 * derived from the columns directly. Intentionally event-free: no payment was
805 * received, so OrderPaid side effects (emails) must not fire.
806 */
807 private static function syncPaymentStatusWithTotals($order)
808 {
809 $totalPaid = (int) $order->total_paid;
810 if ($totalPaid <= 0) {
811 return; // unpaid orders keep their pending/failed lifecycle
812 }
813
814 $totalAmount = (int) $order->total_amount;
815 if ($totalPaid < $totalAmount && $order->payment_status === Status::PAYMENT_PAID) {
816 $order->updatePaymentStatus(Status::PAYMENT_PARTIALLY_PAID);
817 } elseif ($totalPaid >= $totalAmount && $order->payment_status === Status::PAYMENT_PARTIALLY_PAID) {
818 $order->updatePaymentStatus(Status::PAYMENT_PAID);
819 }
820 }
821
822 /**
823 * Resolve whether a checkout-time VIES validation still grants reverse charge
824 * for an admin order edit.
825 *
826 * Sources the validated VAT from order business_info (rate-row vat_reverse
827 * meta as legacy fallback), then re-checks eligibility against the current
828 * tax address: the VAT's member state must match the tax country and the
829 * store settings must allow reverse charge for it. When the tax country
830 * changed since the order was placed, the VAT is re-validated against VIES —
831 * a definitive "invalid" drops reverse charge; an unreachable service trusts
832 * the stored validation (fail open, matching checkout behavior).
833 *
834 * @return array|null vat_reverse payload to persist, or null when reverse
835 * charge must not apply.
836 */
837 private static function resolveAdminReverseChargeContext($order, $taxAddress)
838 {
839 $businessInfo = $order->getBusinessInfo();
840 $vatNumber = (string) Arr::get($businessInfo, 'tax_number', '');
841 $validated = (bool) Arr::get($businessInfo, 'tax_number_validated', false);
842 $vatCountry = (string) Arr::get($businessInfo, 'tax_number_country', '');
843 $vatName = (string) Arr::get($businessInfo, 'tax_number_name', '');
844
845 $primaryRate = $order->getPrimaryOrderTaxRate();
846 $primaryRateMeta = $primaryRate ? (array) $primaryRate->meta : [];
847
848 if (!$validated || !$vatNumber) {
849 // Legacy orders: VAT data only exists on the rate-row meta.
850 $vatReverse = (array) Arr::get($primaryRateMeta, 'vat_reverse', []);
851 if (Arr::get($vatReverse, 'valid', false) && Arr::get($vatReverse, 'vat_number', '')) {
852 $vatNumber = (string) Arr::get($vatReverse, 'vat_number', '');
853 $vatCountry = (string) Arr::get($vatReverse, 'country', '');
854 $vatName = (string) Arr::get($vatReverse, 'name', '');
855 $validated = true;
856 }
857 }
858
859 if (!$validated || !$vatNumber) {
860 return null;
861 }
862
863 $taxCountry = strtoupper((string) Arr::get($taxAddress, 'country', ''));
864
865 // The validated VAT belongs to one member state — reverse charge only
866 // applies while the order is taxed in that country (same rule as checkout).
867 if (!$taxCountry || strtoupper($vatCountry) !== $taxCountry) {
868 return null;
869 }
870
871 $taxModule = new TaxModule();
872 if (!$taxModule->canApplyVatValidation($taxCountry)) {
873 return null;
874 }
875
876 // Excluded categories: refuse reverse charge when any order product belongs
877 // to a category listed in eu_vat_settings.vat_reverse_excluded_categories.
878 // Checkout applies this only under local_reverse_charge = yes
879 // (TaxModule::shouldApplyReverseCharge() / handleVatValidation()) — same gate
880 // here for exact parity.
881 $taxSettings = $taxModule->getSettings();
882 $excludedCategories = array_map('intval', (array) Arr::get(
883 $taxSettings, 'eu_vat_settings.vat_reverse_excluded_categories', []
884 ));
885 if (Arr::get($taxSettings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes' && !empty($excludedCategories)) {
886 if (!$order->relationLoaded('order_items')) {
887 $order->load('order_items');
888 }
889
890 $productIds = [];
891 foreach ($order->order_items as $orderItem) {
892 if (!in_array($orderItem->payment_type, ['fee', 'signup_fee'], true) && $orderItem->post_id) {
893 $productIds[] = (int) $orderItem->post_id;
894 }
895 }
896 $productIds = array_values(array_unique($productIds));
897
898 if (!empty($productIds)) {
899 // TaxModule::getTermsByProductIds() is protected — replicate its
900 // term_relationships lookup (object_id → term_taxonomy_id).
901 $termRows = App::db()->table('term_relationships')
902 ->whereIn('object_id', $productIds)
903 ->get();
904 foreach ($termRows as $termRow) {
905 if (in_array((int) $termRow->term_taxonomy_id, $excludedCategories, true)) {
906 return null;
907 }
908 }
909 }
910 }
911
912 // Tax country changed since placement → re-validate the VAT against VIES.
913 $previousTaxCountry = strtoupper((string) Arr::get($primaryRateMeta, 'tax_country', ''));
914 if ($previousTaxCountry && $previousTaxCountry !== $taxCountry) {
915 $revalidation = $taxModule->validateVatForAdmin($vatCountry, $vatNumber);
916 if (is_array($revalidation)) {
917 if (empty($revalidation['valid'])) {
918 return null;
919 }
920 $vatName = (string) Arr::get($revalidation, 'name', $vatName);
921 } elseif (is_wp_error($revalidation) && $revalidation->get_error_code() === 'invalid') {
922 // Definitive VIES answer: the number is no longer registered.
923 return null;
924 }
925 // service_unavailable / soap_fault → VIES unreachable: keep stored validation.
926 }
927
928 return [
929 'vat_number' => $vatNumber,
930 'country' => $vatCountry,
931 'valid' => true,
932 'name' => $vatName,
933 ];
934 }
935
936 /**
937 * Zero out all tax fields, rate rows, and per-item tax amounts for an order
938 * that has become definitively non-taxable (no address, no taxable items).
939 * Only called for deterministic states — not on transient calculation failures.
940 */
941 private static function clearOrderTax($orderId, $order)
942 {
943 try {
944 // No tax ⇒ no fee tax. When fee order items exist their subtotals are
945 // the net fee amounts — reset fee_total to net so a behavior-1 order
946 // whose fee_total had checkout fee tax rolled in doesn't keep it.
947 // Orders without fee items keep the stored fee_total untouched.
948 $feeSubtotals = OrderItem::query()
949 ->where('order_id', $orderId)
950 ->where('payment_type', 'fee')
951 ->pluck('subtotal')
952 ->toArray();
953 if (!empty($feeSubtotals)) {
954 $order->fee_total = (int) array_sum(array_map('intval', $feeSubtotals));
955 }
956
957 $baseTotal = (int)$order->subtotal
958 + (int)$order->shipping_total
959 + (int)$order->fee_total
960 - (int)$order->coupon_discount_total
961 - (int)$order->manual_discount_total;
962
963 $order->tax_behavior = 0;
964 $order->tax_total = 0;
965 $order->shipping_tax = 0;
966 $order->total_amount = $baseTotal;
967
968 $DB = App::db();
969 $DB->beginTransaction();
970
971 $order->save();
972 $order->updateMeta('exclusive_tax_total', 0);
973 $order->updateMeta('store_tax_behavior', 0);
974 $order->updateMeta('fee_tax', 0);
975 $order->deleteMeta('fee_tax_lines');
976
977 $productItemIds = OrderItem::query()
978 ->where('order_id', $orderId)
979 ->whereNotIn('payment_type', ['fee'])
980 ->pluck('id')
981 ->toArray();
982 if (!empty($productItemIds)) {
983 OrderItem::query()->whereIn('id', $productItemIds)->update(['tax_amount' => 0]);
984 }
985
986 // Strip stale tax_config from signup_fee line_meta so rate pills don't
987 // show a previous rate when tax is now zero.
988 $signupFeeItems = OrderItem::query()
989 ->where('order_id', $orderId)
990 ->where('payment_type', 'signup_fee')
991 ->get();
992 if (!$signupFeeItems->isEmpty()) {
993 $signupFeeUpdates = [];
994 foreach ($signupFeeItems as $signupFeeItem) {
995 $meta = $signupFeeItem->line_meta ?: [];
996 if (!is_array($meta)) {
997 $meta = json_decode($meta ?: '{}', true, 16) ?: [];
998 }
999 unset($meta['tax_config']);
1000 $signupFeeUpdates[] = [
1001 'id' => $signupFeeItem->id,
1002 'line_meta' => json_encode($meta),
1003 ];
1004 }
1005 OrderItem::query()->batchUpdate($signupFeeUpdates);
1006 }
1007
1008 // persistTaxRates with empty lines deletes all non-sentinel rate rows and
1009 // upserts the zero-tax sentinel (tax_rate_id=0) — same guarantee checkout
1010 // gives that every order keeps at least one fct_order_tax_rate row.
1011 TaxModule::persistTaxRates($orderId, [], [
1012 'tax_country' => '',
1013 'source' => 'admin_order_edit',
1014 'note' => 'tax_cleared',
1015 ], 0);
1016
1017 $pendingTx = OrderTransaction::query()
1018 ->where('order_id', $orderId)
1019 ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
1020 ->where('status', 'pending')
1021 ->first();
1022 if ($pendingTx) {
1023 $pendingTx->total = $order->total_amount;
1024 $pendingTx->save();
1025 }
1026
1027 // Paid orders: reflect the lowered total as paid / refund-owed state.
1028 static::syncPaymentStatusWithTotals($order);
1029
1030 $DB->commit();
1031 } catch (\Exception $e) {
1032 if (isset($DB)) {
1033 $DB->rollBack();
1034 }
1035 fluent_cart_warning_log(
1036 'Admin order tax clear failed on update',
1037 get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
1038 ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api']
1039 );
1040 }
1041 }
1042
1043 private static function patchOrderItemTaxMeta(array $savedItems, array $lineItemsFromTax)
1044 {
1045 $savedByKey = [];
1046 foreach ($savedItems as $item) {
1047 $key = $item['post_id'] . ':' . $item['object_id'];
1048 $savedByKey[$key] = $item;
1049 }
1050
1051 $updateData = [];
1052
1053 foreach ($lineItemsFromTax as $taxLineItem) {
1054 $key = Arr::get($taxLineItem, 'post_id', 0) . ':' . Arr::get($taxLineItem, 'object_id', 0);
1055 if (!isset($savedByKey[$key])) {
1056 continue;
1057 }
1058
1059 $savedItem = $savedByKey[$key];
1060 $taxAmount = (int) Arr::get($taxLineItem, 'tax_amount', 0);
1061 $taxLineMeta = Arr::get($taxLineItem, 'line_meta', []);
1062 $existingMeta = isset($savedItem['line_meta']) ? $savedItem['line_meta'] : [];
1063 if (!is_array($existingMeta)) {
1064 $existingMeta = json_decode($existingMeta ?: '{}', true, 16) ?: [];
1065 }
1066 if (!empty($taxLineMeta)) {
1067 $existingMeta = array_merge($existingMeta, $taxLineMeta);
1068 }
1069 $updateData[] = [
1070 'id' => $savedItem['id'],
1071 'tax_amount' => $taxAmount,
1072 'line_meta' => json_encode($existingMeta),
1073 ];
1074 }
1075
1076 if (!empty($updateData)) {
1077 OrderItem::query()->batchUpdate($updateData);
1078 }
1079 }
1080
1081 private static function patchSignupFeeTaxMeta($orderId, array $lineItemsFromTax)
1082 {
1083 // Build a map of post_id:object_id -> tax data for items that have signup fee tax.
1084 // Items absent from this map had their signup fee tax recalculated to zero.
1085 $taxByKey = [];
1086 foreach ($lineItemsFromTax as $taxLineItem) {
1087 $signupFeeTax = (int) Arr::get($taxLineItem, 'signup_fee_tax', 0);
1088 if (!$signupFeeTax) {
1089 continue;
1090 }
1091 $key = Arr::get($taxLineItem, 'post_id', 0) . ':' . Arr::get($taxLineItem, 'object_id', 0);
1092 $taxByKey[$key] = $taxLineItem;
1093 }
1094
1095 // Always fetch ALL signup_fee items for this order — not only those with non-zero
1096 // tax — so items that became untaxed after recalculation get their tax_amount cleared.
1097 $signupFeeItems = OrderItem::query()
1098 ->where('order_id', $orderId)
1099 ->where('payment_type', 'signup_fee')
1100 ->get();
1101
1102 if ($signupFeeItems->isEmpty()) {
1103 return;
1104 }
1105
1106 $updateData = [];
1107 foreach ($signupFeeItems as $signupFeeItem) {
1108 $key = $signupFeeItem->post_id . ':' . $signupFeeItem->object_id;
1109 $taxLineItem = isset($taxByKey[$key]) ? $taxByKey[$key] : null;
1110
1111 $signupFeeTax = $taxLineItem ? (int) Arr::get($taxLineItem, 'signup_fee_tax', 0) : 0;
1112 $existingMeta = $signupFeeItem->line_meta ?: [];
1113 if (!is_array($existingMeta)) {
1114 $existingMeta = json_decode($existingMeta ?: '{}', true, 16) ?: [];
1115 }
1116
1117 if ($taxLineItem) {
1118 $signupFeeTaxConfig = Arr::get($taxLineItem, 'signup_fee_tax_config', []);
1119 if ($signupFeeTaxConfig) {
1120 $existingMeta['tax_config'] = $signupFeeTaxConfig;
1121 } else {
1122 unset($existingMeta['tax_config']);
1123 }
1124 } else {
1125 unset($existingMeta['tax_config']);
1126 }
1127
1128 $updateData[] = [
1129 'id' => $signupFeeItem->id,
1130 'tax_amount' => $signupFeeTax,
1131 'line_meta' => json_encode($existingMeta),
1132 ];
1133 }
1134
1135 if (!empty($updateData)) {
1136 OrderItem::query()->batchUpdate($updateData);
1137 }
1138 }
1139
1140 /**
1141 * Patch subscription tax fields after admin order tax calculation.
1142 *
1143 * AdminOrderProcessor creates the subscription row before tax runs, with
1144 * recurring_tax_total = 0 and recurring_total at the untaxed recurring price.
1145 * Renewals read recurring_tax_total (and the parent item's
1146 * other_info.recurring_tax for inclusive items) — without this patch every
1147 * renewal of an admin-created subscription invoices zero tax.
1148 *
1149 * Mirrors CheckoutProcessor::prepareSubscriptionData(): the recurring tax is
1150 * folded into recurring_total only when additive (exclusive store, or mixed
1151 * cart with this line exclusive).
1152 */
1153 private static function patchSubscriptionTax($order, array $lineItemsFromTax, $taxBehavior)
1154 {
1155 $subscription = Subscription::query()->where('parent_order_id', $order->id)->first();
1156 if (!$subscription) {
1157 return;
1158 }
1159
1160 $subscriptionItem = OrderItem::query()
1161 ->where('order_id', $order->id)
1162 ->where('payment_type', 'subscription')
1163 ->first();
1164 if (!$subscriptionItem) {
1165 return;
1166 }
1167
1168 $taxLine = null;
1169 foreach ($lineItemsFromTax as $lineItem) {
1170 if ((int) Arr::get($lineItem, 'post_id', 0) === (int) $subscriptionItem->post_id
1171 && (int) Arr::get($lineItem, 'object_id', 0) === (int) $subscriptionItem->object_id
1172 ) {
1173 $taxLine = $lineItem;
1174 break;
1175 }
1176 }
1177 if ($taxLine === null) {
1178 return;
1179 }
1180
1181 $recurringTax = (int) Arr::get($taxLine, 'recurring_tax', 0);
1182 $signupFeeTax = (int) Arr::get($taxLine, 'signup_fee_tax', 0);
1183
1184 // Renewals fall back to the parent item's other_info for inclusive items;
1185 // checkout writes both keys on the cart line, mirror that here.
1186 $otherInfo = $subscriptionItem->other_info ?: [];
1187 if (!is_array($otherInfo)) {
1188 $otherInfo = json_decode($otherInfo ?: '{}', true, 16) ?: [];
1189 }
1190 $otherInfo['recurring_tax'] = $recurringTax;
1191 if ($signupFeeTax) {
1192 $otherInfo['signup_fee_tax'] = $signupFeeTax;
1193 }
1194 $subscriptionItem->other_info = $otherInfo;
1195 $subscriptionItem->save();
1196
1197 $lineInclusive = (bool) Arr::get($taxLine, 'line_meta.tax_config.inclusive', false);
1198 $isAdditive = (int) $taxBehavior === 1 || ((int) $taxBehavior === 3 && !$lineInclusive);
1199
1200 // Runs once, at order creation, while recurring_tax_total is still the 0 that
1201 // AdminOrderProcessor wrote. Guard against double-folding tax into
1202 // recurring_total if a future caller ever invokes this on a patched row.
1203 if ((int) $subscription->recurring_tax_total !== 0) {
1204 return;
1205 }
1206
1207 $subscription->recurring_tax_total = $recurringTax;
1208 if ($isAdditive && $recurringTax > 0) {
1209 $subscription->recurring_total = (int) $subscription->recurring_total + $recurringTax;
1210 }
1211 $subscription->save();
1212 }
1213
1214 private static function distributeManualDiscount(&$items, $manualDiscountTotal)
1215 {
1216 $totalSubtotal = array_reduce($items, function ($carry, $item) {
1217 return $carry + ((int)Arr::get($item, 'unit_price', 0) * (int)Arr::get($item, 'quantity', 1));
1218 }, 0);
1219
1220 if ($totalSubtotal <= 0) {
1221 return;
1222 }
1223
1224 $distributed = 0;
1225 foreach ($items as &$checkoutItem) {
1226 $unitPrice = (int)Arr::get($checkoutItem, 'unit_price', 0);
1227 $quantity = (int)Arr::get($checkoutItem, 'quantity', 1);
1228 $itemSubtotal = $unitPrice * $quantity;
1229
1230 $itemManualDiscount = (int) (($itemSubtotal / $totalSubtotal) * $manualDiscountTotal);
1231
1232 if ($itemManualDiscount > $itemSubtotal) {
1233 $itemManualDiscount = $itemSubtotal;
1234 }
1235
1236 $distributed += $itemManualDiscount;
1237
1238 Arr::set($checkoutItem, 'manual_discount', $itemManualDiscount);
1239
1240 }
1241
1242 $diff = round($manualDiscountTotal - $distributed, 2);
1243 // Adjust the first item to account for any precision differences
1244 if ($diff != 0) {
1245 $items[0]['manual_discount'] = (int) (Arr::get($items[0], 'manual_discount', 0) + $diff);
1246 }
1247 }
1248
1249
1250 private static function getCustomer($data)
1251 {
1252 $customer = CustomerResource::find(Arr::get($data, 'customer_id'), [
1253 'with' => ['primary_billing_address', 'primary_shipping_address']
1254 ]);
1255 return Arr::get($customer, 'customer');
1256 }
1257
1258 private static function addOrderMeta($orderId, $discount, $shipping, $newLabelIds)
1259 {
1260 if (!empty($discount)) {
1261 static::addOrUpdateOrderMeta([
1262 'order_id' => $orderId,
1263 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1264 'meta_key' => 'order_discount',
1265 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
1266 'meta_value' => $discount
1267 ]);
1268 }
1269
1270 if (!empty($shipping)) {
1271 $shipping = is_array($shipping) ? static::resolveShippingTitle($shipping) : $shipping;
1272 static::addOrUpdateOrderMeta([
1273 'order_id' => $orderId,
1274 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1275 'meta_key' => 'order_shipping',
1276 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
1277 'meta_value' => $shipping
1278 ]);
1279 }
1280
1281 if (!empty($newLabelIds)) {
1282 LabelResource::addLabelToLabelRelationships(Order::find($orderId), [
1283 'labelable_id' => $orderId,
1284 'labelable_type' => Order::class,
1285 'new_label_ids' => $newLabelIds,
1286 ]);
1287 }
1288 }
1289
1290 private static function commitEvents($order)
1291 {
1292
1293 if (!$order) {
1294 throw new \Exception(esc_html__('Please process order first', 'fluent-cart'));
1295 }
1296
1297 if (!$order->customer) {
1298 throw new \Exception(esc_html__('Please set customer first', 'fluent-cart'));
1299 }
1300
1301 if (!$order->latest_transaction) {
1302 throw new \Exception(esc_html__('Please set Transaction First', 'fluent-cart'));
1303 }
1304
1305 $paymentStatus = $order->payment_status;
1306
1307 $transactionStatus = $order->latest_transaction->status;
1308
1309 if (in_array($transactionStatus, Status::getTransactionSuccessStatuses())) {
1310
1311 do_action('fluent_cart/payment_' . $paymentStatus,
1312 [
1313 'order' => $order,
1314 'customer' => $order->customer,
1315 'transaction' => $order->latest_transaction
1316 ]);
1317
1318 do_action('fluent_cart/payment_' . $order->latest_transaction->transaction_type . '_' . $paymentStatus, [
1319 'order' => $order,
1320 'customer' => $order->customer,
1321 'transaction' => $order->latest_transaction
1322 ]);
1323 }
1324
1325 }
1326
1327 private static function createOrderAddresses($orderId, $data, $customerId = 0)
1328 {
1329 $billingAddressId = (int) Arr::get($data, 'billing_address_id', 0);
1330 $shippingAddressId = (int) Arr::get($data, 'shipping_address_id', 0);
1331
1332 $billingAddress = $billingAddressId > 0
1333 ? CustomerAddresses::query()->where('customer_id', $customerId)->find($billingAddressId)
1334 : null;
1335
1336 $shippingAddress = $shippingAddressId > 0
1337 ? CustomerAddresses::query()->where('customer_id', $customerId)->find($shippingAddressId)
1338 : null;
1339
1340 if (!empty($billingAddress)) {
1341 static::createOrderAddress($billingAddress->toArray(), $orderId);
1342 }
1343 if (!empty($shippingAddress)) {
1344 static::createOrderAddress($shippingAddress->toArray(), $orderId);
1345 }
1346 }
1347
1348 private static function triggerStockChangedEvents($order)
1349 {
1350 $productIds = OrderService::pluckProductIds($order);
1351 if (!empty($productIds)) {
1352 // (new StockChanged($productIds))->dispatch();
1353 }
1354 }
1355
1356 /**
1357 * Update an order with the provided data.
1358 *
1359 * @param array $data Required. Array containing the necessary parameters for order update.
1360 * $data = [
1361 * 'orderData' => ( array ) Required. Represents the main order details.
1362 * [
1363 * 'id' => (int) The id for the order.
1364 * 'status' => (string) The current status of the order
1365 * 'parent_id' => (int) The parent order ID, if applicable.
1366 * 'receipt_number' => (int) the unique sequential order number.
1367 * 'invoice_no' => (string) The order number assigned to the order.
1368 * 'fulfillment_type' => (string) (e.g., 'virtual', 'physical', etc.).
1369 * 'type' => (string) Type (e.g., 'sale', 'refund', etc.).
1370 * 'customer_id' => (int) The ID of the customer associated with the order.
1371 * 'payment_method' => (string) The payment method used for the order.
1372 * 'payment_method_title' => (string) The title of the payment method.
1373 * 'currency' => (string) The currency used for the order (e.g., 'BDT').
1374 * 'subtotal' => (float) The subtotal amount of the order.
1375 * 'discount_tax' => (float) The tax amount on discounts.
1376 * 'manual_discount_total' => (float) The total discount amount for the order.
1377 * 'shipping_tax' => (float) The tax amount on shipping.
1378 * 'shipping_total' => (float) The total shipping amount for the order.
1379 * 'tax_total' => (float) The total tax amount for the order.
1380 * 'total_amount' => (float) The total amount for the order.
1381 * 'total_paid' => (float) The total amount paid for the order.
1382 * 'rate' => (float) The exchange rate used for currency conversion.
1383 * 'ip_address' => (string) The IP address associated with the order.
1384 * 'completed_at' => (string|null) date-time order completed|null
1385 *  * 'refunded_at' => (string|null) date-time the order was refunded|null
1386 *  * 'uuid' => (string) The id for the order.
1387 *   * 'created_at' => (string) The date and time the order was created.
1388 *  * 'updated_at' => (string) The date and time the order was last updated.
1389 *  * 'customer' => (null|array) Info of customer associated with the order.
1390 * 'order_items' => (array) Required. Array of order item details.
1391 * [
1392 * 'id' => ( int ) The id for the order item.
1393 * 'order_id' => ( int ) The ID of the order to which the item belongs.
1394 * 'post_id' => ( int ) The product ID associated with the order item.
1395 * 'object_id' => ( int ) The variation ID of the order item.
1396 * 'thumbnail' => ( string ) The URL of the thumbnail of order item.
1397 * 'item_price' => ( float ) The price of the item.
1398 * 'item_name' => ( string ) The name of the item.
1399 * 'quantity' => ( int ) The quantity of the item.
1400 * 'type' => ( string ) Type ( e.g., 'simple', 'variable' ).
1401 * 'stockStatus' => ( string ) ( e.g., 'in-stock'|'out-of-stock' ).
1402 * 'stock' => ( int ) The current stock quantity.
1403 * 'tax_amount' => ( float ) The tax amount for the item.
1404 * 'manual_discount_total' => ( float ) The total discount amount for the item.
1405 * 'item_total' => ( float ) The total amount for the item.
1406 * 'line_total' => ( float ) The total amount for the line
1407 * ]
1408 * ],
1409 * 'discount' => ( array ) Optional. Represents the discount details
1410 * [
1411 * 'type' => ( string ) Required. type of discount ( e.g., 'amount', 'percentage' )
1412 * 'label' => ( string ) Optional. The label associated with the discount
1413 * 'reason' => ( string ) Optional. The reason for the discount
1414 * 'value' => ( float ) Required. The value of the discount
1415 * ],
1416 * 'shipping' => ( array ) Optional. Represents the shipping details.
1417 * [
1418 * 'type' => ( string ) Optional. The type of shipping.
1419 * 'value' => ( float|null ) Optional. Value associated with shipping|null if not
1420 * ],
1421 * 'deletedItems' => ( array ) Optional. IDs of items to be deleted.
1422 * [
1423 * ( e.g., 100, 501 etc )
1424 * ]
1425 * ]
1426 * @param int $id Required. The ID of the order to update.
1427 * @param array $params Optional. Additional parameters for order update.
1428 * [
1429 * // Include optional parameters, if any.
1430 * ]
1431 *
1432 */
1433 public static function update($data, $id, $params = [])
1434 {
1435
1436
1437 $order = static::getQuery()->with(["order_items", "appliedCoupons", "labels"])->where('id', $id)->first();
1438
1439 if (empty($order) || $order->status === Status::ORDER_COMPLETED || $order->status === Status::ORDER_CANCELED) {
1440 if (empty($order)) {
1441 return static::makeErrorResponse([
1442 ['code' => 404, 'message' => __('The order information does not match', 'fluent-cart')]
1443 ]);
1444 }
1445
1446 return static::makeErrorResponse([
1447 ['code' => 404, 'message' => sprintf(
1448 /* translators: %s is the order status */
1449 __('Your order status is marked as %s and not eligible for any further modifications at this time.', 'fluent-cart'), $order->status)]
1450 ]);
1451 }
1452
1453 // Server-authoritative columns (tax_total, shipping_tax, tax_behavior,
1454 // discount_tax, total_paid, total_refund, item tax_amount) must never
1455 // come from the client — see stripClientTaxFields().
1456 $orderData = static::stripClientTaxFields($data['orderData']);
1457 $deletedItems = $data['deletedItems'];
1458 $appliedCoupons = Arr::get($orderData, 'applied_coupon');
1459 $discount = $data['discount'];
1460 $shipping = $data['shipping'];
1461
1462 $orderId = $order->id;
1463
1464 /**
1465 * First delete the deleted items
1466 */
1467 if (!empty($deletedItems)) {
1468 // Filter only the custom items that are in the deleted IDs
1469 $customItems = $order->order_items
1470 ->filter(fn($item) => $item->is_custom && in_array($item->id, $deletedItems))
1471 ->values(); // reset keys
1472
1473 if ($customItems->isNotEmpty()) {
1474 do_action('fluent_cart/order/before_custom_items_deleted', $customItems, $order);
1475 }
1476
1477 OrderItem::destroy($deletedItems);
1478
1479 if ($customItems->isNotEmpty()) {
1480 do_action('fluent_cart/order/after_custom_items_deleted', $customItems, $order);
1481 }
1482 }
1483
1484 if (!empty($discount)) {
1485 if (!empty($appliedCoupons) && count($appliedCoupons) > 0) {
1486 // Remove the custom discount amount if coupon is applied.
1487 OrderMetaResource::delete($orderId, [
1488 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1489 'meta_key' => 'order_discount',
1490 ]);
1491 } else {
1492 static::addOrUpdateOrderMeta([
1493 'order_id' => $orderId,
1494 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1495 'meta_key' => 'order_discount',
1496 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
1497 'meta_value' => $discount
1498 ]);
1499 }
1500 }
1501 if (!empty($shipping)) {
1502 $shipping = is_array($shipping) ? static::resolveShippingTitle($shipping) : $shipping;
1503 static::addOrUpdateOrderMeta([
1504 'order_id' => $orderId,
1505 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1506 'meta_key' => 'order_shipping',
1507 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
1508 'meta_value' => $shipping
1509 ]);
1510 }
1511
1512 $items = Arr::get($orderData, 'order_items');
1513 $isUpdatedOrderItems = OrderItemResource::updateOrInsertOrderItems($order, $orderId, Arr::except($items, ['*']));
1514
1515 if ($isUpdatedOrderItems) {
1516 unset($orderData['order_items']);
1517 unset($orderData['customer']);
1518 unset($orderData['tax_lines']);
1519
1520
1521 $orderData['currency'] = Helper::shopConfig('currency');
1522
1523 $oldOrder = clone $order;
1524 $isUpdated = $order->update($orderData);
1525
1526 if ($isUpdated) {
1527 $newOrder = $order->refresh();
1528
1529 if (!empty($appliedCoupons)) {
1530 $appliedCoupons = Arr::except($appliedCoupons, ['*']);
1531 $couponCodes = array_keys($appliedCoupons);
1532 if (!empty($couponCodes)) {
1533 $coupons = Coupon::query()->whereIn('code', $couponCodes)->get()
1534 ->keyBy('code')
1535 ->toArray();
1536
1537 foreach ($coupons as $code => &$coupon) {
1538 $coupon['order_id'] = $orderId;
1539 $coupon['coupon_id'] = $appliedCoupons[$code]['id'];
1540 $coupon['amount'] = $appliedCoupons[$code]['discount'];
1541 $coupon['created_at'] = $order->updated_at;
1542 $coupon['updated_at'] = $order->updated_at;
1543 }
1544 $order->appliedCoupons()->delete();
1545 $order->appliedCoupons()->createMany($coupons);
1546 Coupon::query()->whereIn('code', $couponCodes)->increment('use_count', 1);
1547 }
1548 }
1549
1550 if (empty($appliedCoupons) && count($order->appliedCoupons) > 0) {
1551 $order->appliedCoupons()->delete();
1552 }
1553
1554 // $getOrderNoActionableStatuses = ['unshippable'];
1555 // if(in_array($newOrder->shipping_status, $getOrderNoActionableStatuses)) {
1556 // $newOrder->shipping_status = OrderMetaResource::find($orderId, ['meta_key' => 'shipping_previous_status']);
1557 // }
1558 static::reapplyTaxAfterUpdate($orderId, $newOrder);
1559
1560 $newOrder = $newOrder->refresh();
1561
1562 (new OrderUpdated($newOrder, $oldOrder))->dispatch();
1563
1564 $oldOrderItems = json_decode(json_encode(Arr::get($oldOrder, 'order_items', [])), true);
1565 $newOrderItems = json_decode(json_encode(Arr::get($newOrder, 'order_items', [])), true);
1566 $pluckOldVariationIds = array_column($oldOrderItems, 'object_id');
1567 foreach ($newOrderItems as $newItem) {
1568 if (!in_array($newItem['object_id'], $pluckOldVariationIds)) {
1569 $oldOrderItems[] = $newItem;
1570 }
1571 }
1572
1573 static::triggerEventsOnStockChanged($oldOrderItems);
1574
1575 return static::makeSuccessResponse(
1576 $isUpdated,
1577 __('Order updated successfully', 'fluent-cart')
1578 );
1579 }
1580 }
1581
1582 return static::makeErrorResponse([
1583 ['code' => 400, 'message' => __('Order update failed.', 'fluent-cart')]
1584 ]);
1585 }
1586
1587 /**
1588 * Strip server-authoritative columns from a client-supplied order payload
1589 * before it is persisted by update().
1590 *
1591 * The admin edit screen sends the whole order object back — including
1592 * tax_total, shipping_tax, tax_behavior, discount_tax and per-item
1593 * tax_amount. For normal orders reapplyTaxAfterUpdate() recalculates and
1594 * overwrites these server-side right after the save, but subscription and
1595 * refund-type orders skip that recalc — whatever the client sent would
1596 * become final (stale values from a race, or forged values from a
1597 * tampered request). These columns must therefore never be
1598 * client-writable on this path: the existing DB values persist unless
1599 * the server-side recalc changes them.
1600 *
1601 * total_paid / total_refund only move via payment & refund flows. The
1602 * controller already drops them (OrderRequest::sanitize() is a whitelist
1603 * and getSafe() only returns whitelisted keys), so stripping them here is
1604 * defense in depth for direct OrderResource::update() callers.
1605 *
1606 * total_amount is intentionally NOT stripped: it is client-computed for
1607 * legitimate item edits on subscription/refund orders, and for normal
1608 * orders reapplyTaxAfterUpdate() recomputes it from scratch anyway.
1609 *
1610 * Removing the per-item tax_amount key (rather than zeroing it) makes
1611 * OrderItemResource::updateOrInsertOrderItems() leave the existing DB
1612 * value untouched on updated rows; inserted rows fall back to the column
1613 * default (0) and normal orders get patched by patchOrderItemTaxMeta()
1614 * after the recalc.
1615 *
1616 * @param array $orderData The 'orderData' payload consumed by update().
1617 * @return array
1618 */
1619 private static function stripClientTaxFields($orderData)
1620 {
1621 $orderData = Arr::except((array) $orderData, [
1622 'tax_total',
1623 'shipping_tax',
1624 'tax_behavior',
1625 'discount_tax',
1626 'total_paid',
1627 'total_refund',
1628 ]);
1629
1630 $items = Arr::get($orderData, 'order_items');
1631 if (is_array($items)) {
1632 foreach ($items as $itemIndex => $item) {
1633 if (is_array($item)) {
1634 unset($orderData['order_items'][$itemIndex]['tax_amount']);
1635 }
1636 }
1637 }
1638
1639 return $orderData;
1640 }
1641
1642 public static function updateOrderAddressId($data, Order $order)
1643 {
1644
1645 $addressType = Arr::get($data, 'address_type') ?? 'billing';
1646 $addressId = Arr::get($data, 'address_id');
1647 $addressRelation = $addressType === 'billing' ? 'billing_address' : 'shipping_address';
1648
1649 $address = CustomerAddresses::query()->find($addressId);
1650 if (!empty($address)) {
1651 $order->load($addressRelation);
1652 $currentAddress = $order->{$addressRelation};
1653 if (empty($currentAddress)) {
1654 $result = static::createOrderAddress($address->toArray(), $order->id);
1655 } else {
1656 $result = static::mergeOrderAddress($currentAddress, $address->toArray());
1657 }
1658 if (!$order->isSubscription() && $order->type !== 'refund') {
1659 static::reapplyTaxAfterUpdate($order->id, $order->refresh());
1660 }
1661 return $result;
1662 }
1663 }
1664
1665 public static function updateOrderAddress($data)
1666 {
1667 $orderId = sanitize_text_field(Arr::get($data, 'order_id'));
1668 $addressId = sanitize_text_field(Arr::get($data, 'id'));
1669 $orderAddress = OrderAddress::query()->where('order_id', $orderId)->where('id', $addressId)->first();
1670 if (empty($orderAddress)) {
1671 return static::makeErrorResponse([
1672 ['code' => 404, 'message' => __('The address information does not match', 'fluent-cart')]
1673 ]);
1674 }
1675
1676 $updateData = Arr::only($data, ['name', 'first_name', 'last_name', 'full_name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country']);
1677 // sanitize the data before updating
1678 $updateData = array_map('sanitize_text_field', $updateData);
1679 $result = $orderAddress->update($updateData);
1680
1681 $reloadedOrder = Order::find($orderId);
1682 if ($reloadedOrder && !$reloadedOrder->isSubscription() && $reloadedOrder->type !== 'refund') {
1683 static::reapplyTaxAfterUpdate($orderId, $reloadedOrder);
1684 }
1685
1686 return $result;
1687
1688 }
1689
1690 /**
1691 * Delete an order and associated data by ID.Including order meta, order items, transactions,
1692 *
1693 * @param int $id Required. The ID of the order to delete.
1694 * @param array $params Optional. Additional parameters for order deletion.
1695 * [
1696 * // Include optional parameters, if any.
1697 * ]
1698 *
1699 */
1700 public static function delete($id, $params = [])
1701 {
1702 $DB = App::db();
1703
1704 try {
1705 /** @var Order $order */
1706 $order = static::getQuery()->with("order_items")->find($id);
1707 if (!$order) {
1708 return static::makeErrorResponse([
1709 ['code' => 404, 'message' => __('Order not found', 'fluent-cart')]
1710 ]);
1711 }
1712
1713 $canBeDeleted = $order->canBeDeleted();
1714 if (is_wp_error($canBeDeleted)) {
1715 return $canBeDeleted;
1716 }
1717
1718 $deletedOrder = clone $order;
1719 $deletedOrderItems = json_decode(json_encode(Arr::get($order, 'order_items', [])), true);
1720 $connectedOrderIds = [$order->id];
1721 $isTestMode = $order->mode === Status::ORDER_MODE_TEST;
1722
1723 if ($order->type === 'subscription') {
1724 $childOrderIds = Order::query()->where('parent_id', $order->id)->pluck('id')->toArray();
1725 $connectedOrderIds = array_merge($childOrderIds, $connectedOrderIds);
1726 }
1727
1728 $DB->beginTransaction();
1729
1730 if ($order->type === 'subscription') {
1731 $subscriptionIds = Subscription::query()->whereIn('parent_order_id', $connectedOrderIds)->pluck('id')->toArray();
1732 if ($subscriptionIds) {
1733 SubscriptionMeta::query()->whereIn('subscription_id', $subscriptionIds)->delete();
1734 }
1735
1736 Subscription::query()->whereIn('parent_order_id', $connectedOrderIds)->delete();
1737 }
1738
1739 // Dispatch inside transaction so stock restore is atomic with deletion.
1740 // Must run before deleteOrderRelatedData() which removes stock_movement meta and order items.
1741 (new OrderDeleting($order, $connectedOrderIds, $isTestMode, $order->type))->dispatch();
1742
1743 // Pre-load relations before cleanup so the OrderDeleted event has address data
1744 $deletedOrder->load('customer', 'shipping_address', 'billing_address');
1745
1746 static::deleteOrderRelatedData($connectedOrderIds, $isTestMode);
1747 $DB->commit();
1748
1749 if (!empty($deletedOrder)) {
1750 if ($order->type === 'renewal') {
1751 (new RenewalOrderDeleted($deletedOrder))->dispatch();
1752 } else {
1753 (new OrderDeleted($deletedOrder, $connectedOrderIds))->dispatch();
1754 }
1755 }
1756 if (!empty($deletedOrderItems)) {
1757 static::triggerEventsOnStockChanged($deletedOrderItems);
1758 }
1759
1760 return static::makeSuccessResponse(
1761 '',
1762 __('Selected order and associated data has been deleted', 'fluent-cart')
1763 );
1764
1765 } catch (\Exception $e) {
1766 $DB->rollBack();
1767 return static::makeErrorResponse([
1768 ['code' => 400, 'message' => __('Failed to delete', 'fluent-cart')]
1769 ]);
1770 }
1771 }
1772
1773 protected static function deleteOrderRelatedData(array $orderIds, bool $isTestMode = false): void
1774 {
1775 OrderTransaction::query()->whereIn('order_id', $orderIds)->delete();
1776 OrderAddress::query()->whereIn('order_id', $orderIds)->delete();
1777 OrderItem::query()->whereIn('order_id', $orderIds)->delete();
1778 OrderMeta::query()->whereIn('order_id', $orderIds)->delete();
1779 OrderTaxRate::query()->whereIn('order_id', $orderIds)->delete();
1780 OrderOperation::query()->whereIn('order_id', $orderIds)->delete();
1781 AppliedCoupon::query()->whereIn('order_id', $orderIds)->delete();
1782 Cart::query()->whereIn('order_id', $orderIds)->delete();
1783 OrderDownloadPermission::query()->whereIn('order_id', $orderIds)->delete();
1784 LabelRelationship::query()->where('labelable_type', Order::class)
1785 ->whereIn('labelable_id', $orderIds)->delete();
1786
1787 if ($isTestMode) {
1788 Activity::query()->where('module_type', Order::class)
1789 ->whereIn('module_id', $orderIds)->delete();
1790 }
1791
1792 Order::query()->whereIn('id', $orderIds)->delete();
1793 }
1794
1795 /**
1796 * View details of an order by ID.
1797 *
1798 * This function retrieves details of an order by the specified ID. It includes information about the customer, order items with variants, transactions, discount meta, shipping meta,
1799 * and order settings.
1800 *
1801 * @param int $id Required. The ID of the order to view.
1802 *
1803 */
1804 public static function view(int $id)
1805 {
1806 $orders = static::search(
1807 ['fct_orders.id' => $id],
1808 function (Builder $query) {
1809 return $query
1810 ->with(
1811 [
1812 'parentOrder' => function ($query) {
1813 return $query->select('id')
1814 ->with('subscriptions.product');
1815 },
1816 'subscriptions.product',
1817 'activities.user',
1818 'labels',
1819 'customer',
1820 'children' => function ($query) {
1821 return $query->select('id', 'parent_id', 'created_at');
1822 },
1823 //'order_items.variants.product_detail',
1824 'order_items' => function ($query) {
1825 $query->addAppends(['coupon_discount']);
1826 },
1827 'order_items.variants.media',
1828 'transactions',
1829 'order_addresses',
1830 'orderTaxRates.tax_rate',
1831 'billing_address',
1832 'shipping_address',
1833 'appliedCoupons' => function ($query) {
1834 $query->select('*');
1835 }
1836 ]
1837 )
1838 ->addAppends(['business_info', 'customer_tax_number', 'is_b2b_order', 'display_tax_lines', 'display_shipping_tax_lines', 'is_reverse_charge_tax_order', 'tax_summary']);
1839 }
1840 );
1841
1842 if (empty($orders[0])) {
1843 return new \WP_Error('403', __('Order not found!', 'fluent-cart'));
1844 }
1845
1846 $subscriptions = Arr::get($orders, '0.subscriptions');
1847
1848 if (empty($subscriptions)) {
1849 $config = Arr::get($orders, '0.config', null);
1850 $upgradedFrom = is_array($config)
1851 ? Arr::get($config, 'upgraded_from', null)
1852 : (is_string($config) ? Arr::get(json_decode($config, true), 'upgraded_from', null) : null);
1853
1854 $orders[0]['subscriptions'] = $upgradedFrom
1855 ? []
1856 : Arr::get($orders, '0.parent_order.subscriptions', []);
1857 }
1858
1859 $data = [];
1860
1861 if (isset($orders[0])) {
1862 $order = $orders[0];
1863 $selectedLabels = Collection::make($order['labels'])->pluck('label_id');
1864 $order['custom_checkout_url'] = PaymentHelper::getCustomPaymentLink(Arr::get($order, 'uuid'));
1865
1866 $orderModel = Order::find($id);
1867 $rcMode = $orderModel ? $orderModel->getOrderRcMode() : 'fixed';
1868
1869 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1870 $shippingMeta = OrderMetaResource::find($order['id'], ['meta_key' => 'order_shipping']);
1871
1872 $orderConfig = is_array($order['config']) ? $order['config'] : (array)json_decode((string)($order['config'] ?? ''), true);
1873 $methodId = (int)Arr::get($orderConfig, 'shipping_method_id', 0);
1874 $methodTitle = (string)Arr::get($orderConfig, 'shipping_method_title', '');
1875
1876 if (!$methodId && is_array($shippingMeta) && isset($shippingMeta['id'], $shippingMeta['title'])) {
1877 $methodId = (int)$shippingMeta['id'];
1878 $methodTitle = (string)$shippingMeta['title'];
1879 }
1880
1881 $checkoutShipping = ($methodId && $methodTitle) ? [
1882 'method_id' => $methodId,
1883 'method_title' => $methodTitle,
1884 'shipping_total' => (int)Arr::get($order, 'shipping_total', 0),
1885 ] : null;
1886
1887 $data = [
1888 'order' => $order,
1889 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1890 'discount_meta' => OrderMetaResource::find($order['id'], ['meta_key' => 'order_discount']),
1891 'shipping_meta' => $shippingMeta,
1892 'checkout_shipping' => $checkoutShipping,
1893 'order_settings' => [
1894 'reverse_charge_price_mode' => $rcMode,
1895 ],
1896 'selected_labels' => $selectedLabels,
1897 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1898 'tax_id' => OrderMetaResource::find($order['id'], ['meta_key' => 'tax_id'])
1899 ];
1900 }
1901
1902 return $data;
1903 }
1904
1905 /**
1906 * Retrieve an overview of reports based on specified parameters.
1907 *
1908 * It calculates total sales, net sales, total discounts, total shipping tax, average order
1909 * value, and customer order count based on the reports data.
1910 *
1911 * @param array $params Required. Additional parameters for report overview.
1912 * $params = [
1913 * //(Required)
1914 * "status" => [
1915 * "column" => "status",
1916 * "operator" => "in",
1917 * "value" => "Order success status e.g. completed,
1918 * ],
1919 *
1920 * //(Required)
1921 * "payment_status" => [
1922 * "column" => "payment_status",
1923 * "operator" => "in",
1924 * "value" => "Transaction success status e.g. paid,
1925 * ],
1926 *
1927 * //(Optional)
1928 * "created_at" => [
1929 * "column" => "created_at",
1930 * "operator" => "between"
1931 * "value" => "from and to date"
1932 * ]
1933 * ]
1934 *
1935 */
1936 /**
1937 * @deprecated since v1.4. Use OverviewReportController::getOverview() via GET reports/overview instead.
1938 */
1939 public static function reportOverview($params = [])
1940 {
1941 return static::getQuery()->when(
1942 $params,
1943 function ($query) use ($params) {
1944 return $query->search($params);
1945 }
1946 )
1947 ->selectRaw('sum(total_amount) as total_sales')
1948 ->selectRaw('sum(total_amount - manual_discount_total - shipping_total - tax_total) as net_sales')
1949 ->selectRaw('sum(manual_discount_total + coupon_discount_total) as total_discounts')
1950 ->selectRaw('sum(shipping_total) as total_shipping_tax')
1951 ->selectRaw('avg(total_amount) as average_order_value')
1952 ->selectRaw('count(*) as customer_order_count')
1953 ->get()->first();
1954 }
1955
1956 /**
1957 * Retrieve order summary based on payment methods and specified parameters.
1958 *
1959 * This function generates order summary by payment method, applying filters provided in the parameters.
1960 *
1961 * It retrieves the count of orders, total transactions, and groups the results by payment method.
1962 *
1963 * @param array $params Required. Additional parameters for order summary generation.
1964 * $params = [
1965 * //(Required)
1966 * "status" => [
1967 * "column" => "status",
1968 * "operator" => "in",
1969 * "value" => "Order success status e.g. completed,
1970 * ],
1971 *
1972 * //(Required)
1973 * "payment_status" => [
1974 * "column" => "payment_status",
1975 * "operator" => "in",
1976 * "value" => "Transaction success status e.g. paid,
1977 * ],
1978 *
1979 * //( Optional )
1980 * 'created_at' => [
1981 * 'column' => 'created_at',
1982 * 'operator' => 'between'
1983 * 'value' => 'from and to date'
1984 * ]
1985 * ]
1986 *
1987 * @return Collection of orders
1988 */
1989 public static function orderSummaryByPayment(array $params = [])
1990 {
1991 return static::getQuery()->select('payment_method')
1992 ->when(
1993 $params,
1994 function ($query) use ($params) {
1995 return $query->search($params);
1996 }
1997 )
1998 ->selectRaw('COUNT(*) as order_count')
1999 ->selectRaw('SUM(total_amount) as transactions')
2000 ->groupBy('payment_method')
2001 ->get();
2002 }
2003
2004 private static function addOrUpdateOrderMeta($params = [])
2005 {
2006 $orderId = Arr::get($params, 'order_id', null);
2007 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
2008 $key = Arr::get($params, 'meta_key', '');
2009 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
2010 $value = Arr::get($params, 'meta_value', '');
2011 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
2012 $isExist = OrderMetaResource::find($orderId, ['meta_key' => $key]);
2013
2014 if ($isExist) {
2015 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
2016 return OrderMetaResource::update($value, $orderId, ['meta_key' => $key]);
2017 }
2018 return OrderMetaResource::create($params);
2019 }
2020
2021 private static function triggerEventsOnStockChanged($orderItems)
2022 {
2023 if (!empty($orderItems)) {
2024 $productIds = [];
2025 foreach ($orderItems as $orderItem) {
2026 $productIds[] = Arr::get($orderItem, 'post_id');
2027 }
2028 if (!empty($productIds)) {
2029 // (new StockChanged($productIds))->dispatch();
2030 }
2031 }
2032 }
2033
2034 public static function updateStatuses(array $params = [])
2035 {
2036
2037 $order = Arr::get($params, 'order');
2038 if (empty($order)) {
2039 return static::makeErrorResponse([
2040 ['code' => 404, 'message' => __('Order not found!', 'fluent-cart')]
2041 ]);
2042 }
2043
2044 $orderId = Arr::get($order, 'id');
2045
2046 $order = static::getQuery()->with("order_items.variants.product_detail")->where('id', $orderId)->first();
2047
2048 $action = Arr::get($params, 'action');
2049
2050 $changeType = $action === 'change_shipping_status' ? 'shipping_status' : 'order_status';
2051 $actionActivity = [];
2052
2053 if ($action === 'change_shipping_status') {
2054 $newStatus = Arr::get($params, 'statuses.shipping_status', null);
2055 $oldStatus = Arr::get($order, 'shipping_status');
2056 $validStatuses = Status::getEditableShippingStatuses();
2057 $actionActivity = [
2058 'title' => __('Shipping status updated', 'fluent-cart'),
2059 'content' => sprintf(
2060 /* translators: %1$s is the old status, %2$s is the new status */
2061 __('Shipping status has been updated from %1$s to %2$s', 'fluent-cart'), $oldStatus, $newStatus)
2062 ];
2063
2064 $orderItems = OrderItem::query()->where('fulfillment_type', 'physical')->where('order_id', $orderId)->get();
2065 $updateData = [];
2066 foreach ($orderItems as $item) {
2067 $updateData[] = [
2068 'id' => $item->id,
2069 'fulfilled_quantity' => in_array($newStatus, ['shipped', 'delivered']) ? $item->quantity : '0'
2070 ];
2071 }
2072 OrderItem::query()->batchUpdate($updateData);
2073 }
2074 if ($action === 'change_order_status') {
2075 $newStatus = Arr::get($params, 'statuses.order_status', null);
2076 $oldStatus = Arr::get($order, 'status');
2077 $validStatuses = Status::getEditableOrderStatuses();
2078 $shippingStatus = Arr::get($order, 'shipping_status');
2079 $actionActivity = [
2080 'title' => __('Order status updated', 'fluent-cart'),
2081 'content' => sprintf(
2082 /* translators: %1$s is the old status, %2$s is the new status */
2083 __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), $oldStatus, $newStatus)
2084 ];
2085 }
2086
2087 if ($newStatus !== null) {
2088 if (isset($validStatuses[$newStatus])) {
2089 if ($newStatus != $oldStatus) {
2090
2091 $getOrderNoActionableStatuses = [Status::SHIPPING_UNSHIPPABLE];
2092
2093 if ($action === 'change_order_status') {
2094 if ($oldStatus === Status::ORDER_CANCELED) {
2095 return static::makeErrorResponse([
2096 ['code' => 400, 'message' => __('You cannot change the order status once it has been canceled.', 'fluent-cart')]
2097 ]);
2098 }
2099
2100 $order = $order->updateStatus('status', $newStatus);
2101
2102 if ($newStatus === Status::ORDER_CANCELED) {
2103 if (in_array($shippingStatus, $getOrderNoActionableStatuses)) {
2104 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
2105 $shippingStatus = OrderMetaResource::find($orderId, ['meta_key' => 'shipping_previous_status']);
2106 }
2107 (new OrderStatusUpdated($order, $shippingStatus, $newStatus, Arr::get($params, 'manage_stock', true), $actionActivity, $changeType))->dispatch();
2108 } else {
2109 (new OrderStatusUpdated($order, $oldStatus, $newStatus, false, $actionActivity, $changeType))->dispatch();
2110 }
2111 }
2112
2113 if ($action === 'change_shipping_status') {
2114
2115 if (in_array($newStatus, $getOrderNoActionableStatuses)) {
2116 static::addOrUpdateOrderMeta([
2117 'order_id' => $orderId,
2118 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
2119 'meta_key' => 'shipping_previous_status',
2120 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
2121 'meta_value' => $oldStatus
2122 ]);
2123 }
2124 if (in_array($oldStatus, $getOrderNoActionableStatuses)) {
2125 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
2126 $oldStatus = OrderMetaResource::find($orderId, ['meta_key' => 'shipping_previous_status']);
2127 }
2128
2129 if ($action !== 'change_shipping_status' && Arr::get($params, 'manage_stock') == 'true') {
2130 $validationSucceeded = static::validateStock(Arr::get($order, 'order_items', []));
2131
2132 if (Arr::get($validationSucceeded, 'status') === true) {
2133 return static::makeErrorResponse([
2134 ['code' => 400, 'message' => Arr::get($validationSucceeded, 'message')]
2135 ]);
2136 }
2137 }
2138
2139 $order = $order->updateStatus('shipping_status', $newStatus);
2140
2141 (new OrderStatusUpdated($order, $oldStatus, $newStatus, Arr::get($params, 'manage_stock'), $actionActivity, $changeType))->dispatch();
2142
2143 $orderItems = json_decode(json_encode(Arr::get($order, 'order_items', [])), true);
2144 static::triggerEventsOnStockChanged($orderItems);
2145 }
2146
2147 return static::makeSuccessResponse(
2148 $order,
2149 __('Status has been updated', 'fluent-cart')
2150 );
2151 }
2152 return static::makeErrorResponse([
2153 ['code' => 400, 'message' => __('Order already has the same status', 'fluent-cart')]
2154 ]);
2155 }
2156 return static::makeErrorResponse([
2157 ['code' => 400, 'message' => __('Provided status is not valid', 'fluent-cart')]
2158 ]);
2159 }
2160
2161 return static::makeErrorResponse([
2162 ['code' => 400, 'message' => __('Failed to update status', 'fluent-cart')]
2163 ]);
2164 }
2165
2166 private static function validateStock($orderItems)
2167 {
2168 $outOfStockVariants = [];
2169
2170 foreach ($orderItems as $orderItem) {
2171 $quantity = (int)Arr::get($orderItem, 'quantity', 0);
2172 $stock = (int)Arr::get($orderItem, 'variants.available', 0);
2173 // $manageStock = (int)Arr::get($orderItem, 'variants.product_detail.manage_stock');
2174 $manageStock = (int)Arr::get($orderItem, 'variants.manage_stock');
2175 $variationTitle = Arr::get($orderItem, 'variants.variation_title');
2176
2177 if ($manageStock == 1 && $stock - $quantity < 0) {
2178 $outOfStockVariants[] = $variationTitle;
2179 }
2180 }
2181
2182 if (!empty($outOfStockVariants)) {
2183 $message = (count($outOfStockVariants) > 1)
2184 ? sprintf(
2185 /* translators: %s is the list of out of stock variants */
2186 __('%s are out of stock', 'fluent-cart'), implode(', ', $outOfStockVariants))
2187 : sprintf(
2188 /* translators: %s is the out of stock variant */
2189 __('%s is out of stock', 'fluent-cart'), reset($outOfStockVariants));
2190
2191
2192 return [
2193 'status' => true,
2194 'message' => $message
2195 ];
2196 }
2197
2198 return false;
2199 }
2200
2201 /**
2202 * Delete orders and its associated data.
2203 *
2204 * @param array $orderIds The ids of the order to be deleted.
2205 * @param array $params Additional parameters for the deletion process.
2206 *
2207 */
2208 public static function bulkDeleteByOrderIds($orderIds, $params = [])
2209 {
2210 $failedOrderIds = [];
2211 $deletedOrderIds = [];
2212
2213 foreach ($orderIds as $order) {
2214 $isDeleted = static::delete($order);
2215
2216 if (is_wp_error($isDeleted)) {
2217 $failedOrderIds[] = $order;
2218 } else {
2219 $deletedOrderIds[] = $order;
2220 }
2221 }
2222
2223 if (count($failedOrderIds) > 0) {
2224 $failedOrderIdsString = implode(' , ', $failedOrderIds);
2225 return count($deletedOrderIds) > 0
2226 ? static::makeSuccessResponse([
2227 'deleted_order_ids' => $deletedOrderIds,
2228 'deleted_count' => count($deletedOrderIds),
2229 'failed_order_ids' => $failedOrderIds,
2230 'failed_count' => count($failedOrderIds)
2231 ], sprintf(
2232 /* translators: %s: The order ID(s) that could not be deleted. */
2233 __("The order ID - %s cannot be deleted at the moment as these orders status is not canceled. And remaining order and its associated data have been deleted", 'fluent-cart'), $failedOrderIdsString))
2234 : static::makeErrorResponse([['code' => 400, 'message' => sprintf(
2235 /* translators: %s: The order ID(s) that could not be deleted. */
2236 __("The order ID - %s cannot be deleted at the moment as these orders status is not canceled.", 'fluent-cart'), $failedOrderIdsString)]]);
2237 }
2238
2239 if (count($deletedOrderIds) > 0 && count($failedOrderIds) < 1) {
2240 return static::makeSuccessResponse([
2241 'deleted_order_ids' => $deletedOrderIds,
2242 'deleted_count' => count($deletedOrderIds),
2243 'failed_order_ids' => [],
2244 'failed_count' => 0
2245 ], __('Selected order and associated data have been deleted', 'fluent-cart'));
2246 }
2247
2248 return static::makeSuccessResponse([
2249 'deleted_order_ids' => [],
2250 'deleted_count' => 0,
2251 'failed_order_ids' => [],
2252 'failed_count' => 0
2253 ], __('No orders were deleted', 'fluent-cart'));
2254 }
2255
2256 public static function updatePaymentStatus(array $params = [])
2257 {
2258 $order = Arr::get($params, 'order');
2259 $transaction = Arr::get($params, 'transaction');
2260 $newStatus = Arr::get($params, 'status');
2261
2262 if (empty($transaction)) {
2263 return static::makeErrorResponse([
2264 ['code' => 404, 'message' => __('Transaction not found!', 'fluent-cart')]
2265 ]);
2266 }
2267
2268 if ($transaction->status == $newStatus) {
2269 return static::makeErrorResponse([
2270 ['code' => 400, 'message' => __('Transaction already has the same status', 'fluent-cart')]
2271 ]);
2272 }
2273
2274 if ($transaction->order_id != $order->id) {
2275 return static::makeErrorResponse([
2276 ['code' => 400, 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')]
2277 ]);
2278 }
2279
2280 $data = [];
2281 $totalPaid = ($order->total_paid - $transaction->total) < 0 ? 0 : $transaction->total;
2282
2283 if ($newStatus == Status::PAYMENT_PAID) {
2284 $data[] = [
2285 'id' => $order->id,
2286 'payment_status' => $newStatus,
2287 'total_paid' => ['+', $transaction->total],
2288 ];
2289 } elseif ($newStatus == Status::PAYMENT_REFUNDED) {
2290 $data[] = [
2291 'id' => $order->id,
2292 'payment_status' => $newStatus,
2293 'refunded_at' => DateTime::gmtNow(),
2294 'total_paid' => ['-', $totalPaid],
2295 'total_refund' => ['+', $transaction->total],
2296 ];
2297 } elseif ($newStatus == (Status::PAYMENT_PENDING || Status::PAYMENT_FAILED)) {
2298 $data[] = [
2299 'id' => $order->id,
2300 'payment_status' => $newStatus,
2301 'total_paid' => ['-', $totalPaid],
2302 ];
2303 }
2304
2305 $updatedStatus = $transaction->updateStatus($newStatus);
2306
2307 if (!empty($data) && $updatedStatus) {
2308 $oldStatus = Arr::get($order, 'payment_status');
2309 $actionActivity = [
2310 'title' => 'Payment status updated',
2311 'content' => sprintf(
2312 /* translators: %1$s is the old status, %2$s is the new status */
2313 __('Payment status has been updated from %1$s to %2$s', 'fluent-cart'), $oldStatus, $newStatus)
2314 ];
2315
2316 static::getQuery()->batchUpdate($data);
2317
2318 (new OrderStatusUpdated($order, $oldStatus, $newStatus, false, $actionActivity, 'payment_status'))->dispatch();
2319
2320 return static::makeSuccessResponse(
2321 $order,
2322 __('Payment Status has been updated', 'fluent-cart')
2323 );
2324
2325 } else {
2326 return static::makeErrorResponse([
2327 ['code' => 400, 'message' => __('Failed to update payment status', 'fluent-cart')]
2328 ]);
2329 }
2330 }
2331
2332 private static function mergeOrderAddress(OrderAddress $address, array $addressData)
2333 {
2334 $keysToInclude = ['type', 'name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country'];
2335 foreach ($keysToInclude as $key) {
2336 $address->{$key} = $addressData[$key];
2337 }
2338
2339 if ($address->save()) {
2340 return $address;
2341 }
2342 return static::makeErrorResponse([
2343 ['code' => 400, 'message' => __('Failed to update address', 'fluent-cart')]
2344 ]);
2345 }
2346
2347 private static function createOrderAddress(array $address, $orderId)
2348 {
2349 $keysToInclude = ['order_id', 'type', 'name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country'];
2350 $address = Arr::only($address, $keysToInclude);
2351 $address['order_id'] = $orderId;
2352
2353 if (!empty($address)) {
2354 return OrderAddressResource::create($address);
2355 }
2356 }
2357
2358 public static function getOrderByHash($orderHash)
2359 {
2360 return (new Orders())->getByHash($orderHash);
2361 }
2362
2363 private static function resolveShippingTitle(array $shipping): array
2364 {
2365 if (isset($shipping['id']) && empty($shipping['title'])) {
2366 $sm = ShippingMethod::find((int)$shipping['id']);
2367 $shipping['title'] = $sm ? $sm->title : '';
2368 }
2369 return $shipping;
2370 }
2371
2372 }
2373