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

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

2,376 lines 103.5 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 // Gate on the title alone. Live-rate carriers use non-numeric method
1882 // ids (e.g. "carrier:shippo:usps_priority") which (int) casts to 0,
1883 // so requiring a truthy id silently hid the method name.
1884 $checkoutShipping = $methodTitle ? [
1885 'method_id' => $methodId,
1886 'method_title' => $methodTitle,
1887 'shipping_total' => (int)Arr::get($order, 'shipping_total', 0),
1888 ] : null;
1889
1890 $data = [
1891 'order' => $order,
1892 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1893 'discount_meta' => OrderMetaResource::find($order['id'], ['meta_key' => 'order_discount']),
1894 'shipping_meta' => $shippingMeta,
1895 'checkout_shipping' => $checkoutShipping,
1896 'order_settings' => [
1897 'reverse_charge_price_mode' => $rcMode,
1898 ],
1899 'selected_labels' => $selectedLabels,
1900 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1901 'tax_id' => OrderMetaResource::find($order['id'], ['meta_key' => 'tax_id'])
1902 ];
1903 }
1904
1905 return $data;
1906 }
1907
1908 /**
1909 * Retrieve an overview of reports based on specified parameters.
1910 *
1911 * It calculates total sales, net sales, total discounts, total shipping tax, average order
1912 * value, and customer order count based on the reports data.
1913 *
1914 * @param array $params Required. Additional parameters for report overview.
1915 * $params = [
1916 * //(Required)
1917 * "status" => [
1918 * "column" => "status",
1919 * "operator" => "in",
1920 * "value" => "Order success status e.g. completed,
1921 * ],
1922 *
1923 * //(Required)
1924 * "payment_status" => [
1925 * "column" => "payment_status",
1926 * "operator" => "in",
1927 * "value" => "Transaction success status e.g. paid,
1928 * ],
1929 *
1930 * //(Optional)
1931 * "created_at" => [
1932 * "column" => "created_at",
1933 * "operator" => "between"
1934 * "value" => "from and to date"
1935 * ]
1936 * ]
1937 *
1938 */
1939 /**
1940 * @deprecated since v1.4. Use OverviewReportController::getOverview() via GET reports/overview instead.
1941 */
1942 public static function reportOverview($params = [])
1943 {
1944 return static::getQuery()->when(
1945 $params,
1946 function ($query) use ($params) {
1947 return $query->search($params);
1948 }
1949 )
1950 ->selectRaw('sum(total_amount) as total_sales')
1951 ->selectRaw('sum(total_amount - manual_discount_total - shipping_total - tax_total) as net_sales')
1952 ->selectRaw('sum(manual_discount_total + coupon_discount_total) as total_discounts')
1953 ->selectRaw('sum(shipping_total) as total_shipping_tax')
1954 ->selectRaw('avg(total_amount) as average_order_value')
1955 ->selectRaw('count(*) as customer_order_count')
1956 ->get()->first();
1957 }
1958
1959 /**
1960 * Retrieve order summary based on payment methods and specified parameters.
1961 *
1962 * This function generates order summary by payment method, applying filters provided in the parameters.
1963 *
1964 * It retrieves the count of orders, total transactions, and groups the results by payment method.
1965 *
1966 * @param array $params Required. Additional parameters for order summary generation.
1967 * $params = [
1968 * //(Required)
1969 * "status" => [
1970 * "column" => "status",
1971 * "operator" => "in",
1972 * "value" => "Order success status e.g. completed,
1973 * ],
1974 *
1975 * //(Required)
1976 * "payment_status" => [
1977 * "column" => "payment_status",
1978 * "operator" => "in",
1979 * "value" => "Transaction success status e.g. paid,
1980 * ],
1981 *
1982 * //( Optional )
1983 * 'created_at' => [
1984 * 'column' => 'created_at',
1985 * 'operator' => 'between'
1986 * 'value' => 'from and to date'
1987 * ]
1988 * ]
1989 *
1990 * @return Collection of orders
1991 */
1992 public static function orderSummaryByPayment(array $params = [])
1993 {
1994 return static::getQuery()->select('payment_method')
1995 ->when(
1996 $params,
1997 function ($query) use ($params) {
1998 return $query->search($params);
1999 }
2000 )
2001 ->selectRaw('COUNT(*) as order_count')
2002 ->selectRaw('SUM(total_amount) as transactions')
2003 ->groupBy('payment_method')
2004 ->get();
2005 }
2006
2007 private static function addOrUpdateOrderMeta($params = [])
2008 {
2009 $orderId = Arr::get($params, 'order_id', null);
2010 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
2011 $key = Arr::get($params, 'meta_key', '');
2012 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
2013 $value = Arr::get($params, 'meta_value', '');
2014 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
2015 $isExist = OrderMetaResource::find($orderId, ['meta_key' => $key]);
2016
2017 if ($isExist) {
2018 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
2019 return OrderMetaResource::update($value, $orderId, ['meta_key' => $key]);
2020 }
2021 return OrderMetaResource::create($params);
2022 }
2023
2024 private static function triggerEventsOnStockChanged($orderItems)
2025 {
2026 if (!empty($orderItems)) {
2027 $productIds = [];
2028 foreach ($orderItems as $orderItem) {
2029 $productIds[] = Arr::get($orderItem, 'post_id');
2030 }
2031 if (!empty($productIds)) {
2032 // (new StockChanged($productIds))->dispatch();
2033 }
2034 }
2035 }
2036
2037 public static function updateStatuses(array $params = [])
2038 {
2039
2040 $order = Arr::get($params, 'order');
2041 if (empty($order)) {
2042 return static::makeErrorResponse([
2043 ['code' => 404, 'message' => __('Order not found!', 'fluent-cart')]
2044 ]);
2045 }
2046
2047 $orderId = Arr::get($order, 'id');
2048
2049 $order = static::getQuery()->with("order_items.variants.product_detail")->where('id', $orderId)->first();
2050
2051 $action = Arr::get($params, 'action');
2052
2053 $changeType = $action === 'change_shipping_status' ? 'shipping_status' : 'order_status';
2054 $actionActivity = [];
2055
2056 if ($action === 'change_shipping_status') {
2057 $newStatus = Arr::get($params, 'statuses.shipping_status', null);
2058 $oldStatus = Arr::get($order, 'shipping_status');
2059 $validStatuses = Status::getEditableShippingStatuses();
2060 $actionActivity = [
2061 'title' => __('Shipping status updated', 'fluent-cart'),
2062 'content' => sprintf(
2063 /* translators: %1$s is the old status, %2$s is the new status */
2064 __('Shipping status has been updated from %1$s to %2$s', 'fluent-cart'), $oldStatus, $newStatus)
2065 ];
2066
2067 $orderItems = OrderItem::query()->where('fulfillment_type', 'physical')->where('order_id', $orderId)->get();
2068 $updateData = [];
2069 foreach ($orderItems as $item) {
2070 $updateData[] = [
2071 'id' => $item->id,
2072 'fulfilled_quantity' => in_array($newStatus, ['shipped', 'delivered']) ? $item->quantity : '0'
2073 ];
2074 }
2075 OrderItem::query()->batchUpdate($updateData);
2076 }
2077 if ($action === 'change_order_status') {
2078 $newStatus = Arr::get($params, 'statuses.order_status', null);
2079 $oldStatus = Arr::get($order, 'status');
2080 $validStatuses = Status::getEditableOrderStatuses();
2081 $shippingStatus = Arr::get($order, 'shipping_status');
2082 $actionActivity = [
2083 'title' => __('Order status updated', 'fluent-cart'),
2084 'content' => sprintf(
2085 /* translators: %1$s is the old status, %2$s is the new status */
2086 __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), $oldStatus, $newStatus)
2087 ];
2088 }
2089
2090 if ($newStatus !== null) {
2091 if (isset($validStatuses[$newStatus])) {
2092 if ($newStatus != $oldStatus) {
2093
2094 $getOrderNoActionableStatuses = [Status::SHIPPING_UNSHIPPABLE];
2095
2096 if ($action === 'change_order_status') {
2097 if ($oldStatus === Status::ORDER_CANCELED) {
2098 return static::makeErrorResponse([
2099 ['code' => 400, 'message' => __('You cannot change the order status once it has been canceled.', 'fluent-cart')]
2100 ]);
2101 }
2102
2103 $order = $order->updateStatus('status', $newStatus);
2104
2105 if ($newStatus === Status::ORDER_CANCELED) {
2106 if (in_array($shippingStatus, $getOrderNoActionableStatuses)) {
2107 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
2108 $shippingStatus = OrderMetaResource::find($orderId, ['meta_key' => 'shipping_previous_status']);
2109 }
2110 (new OrderStatusUpdated($order, $shippingStatus, $newStatus, Arr::get($params, 'manage_stock', true), $actionActivity, $changeType))->dispatch();
2111 } else {
2112 (new OrderStatusUpdated($order, $oldStatus, $newStatus, false, $actionActivity, $changeType))->dispatch();
2113 }
2114 }
2115
2116 if ($action === 'change_shipping_status') {
2117
2118 if (in_array($newStatus, $getOrderNoActionableStatuses)) {
2119 static::addOrUpdateOrderMeta([
2120 'order_id' => $orderId,
2121 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
2122 'meta_key' => 'shipping_previous_status',
2123 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
2124 'meta_value' => $oldStatus
2125 ]);
2126 }
2127 if (in_array($oldStatus, $getOrderNoActionableStatuses)) {
2128 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
2129 $oldStatus = OrderMetaResource::find($orderId, ['meta_key' => 'shipping_previous_status']);
2130 }
2131
2132 if ($action !== 'change_shipping_status' && Arr::get($params, 'manage_stock') == 'true') {
2133 $validationSucceeded = static::validateStock(Arr::get($order, 'order_items', []));
2134
2135 if (Arr::get($validationSucceeded, 'status') === true) {
2136 return static::makeErrorResponse([
2137 ['code' => 400, 'message' => Arr::get($validationSucceeded, 'message')]
2138 ]);
2139 }
2140 }
2141
2142 $order = $order->updateStatus('shipping_status', $newStatus);
2143
2144 (new OrderStatusUpdated($order, $oldStatus, $newStatus, Arr::get($params, 'manage_stock'), $actionActivity, $changeType))->dispatch();
2145
2146 $orderItems = json_decode(json_encode(Arr::get($order, 'order_items', [])), true);
2147 static::triggerEventsOnStockChanged($orderItems);
2148 }
2149
2150 return static::makeSuccessResponse(
2151 $order,
2152 __('Status has been updated', 'fluent-cart')
2153 );
2154 }
2155 return static::makeErrorResponse([
2156 ['code' => 400, 'message' => __('Order already has the same status', 'fluent-cart')]
2157 ]);
2158 }
2159 return static::makeErrorResponse([
2160 ['code' => 400, 'message' => __('Provided status is not valid', 'fluent-cart')]
2161 ]);
2162 }
2163
2164 return static::makeErrorResponse([
2165 ['code' => 400, 'message' => __('Failed to update status', 'fluent-cart')]
2166 ]);
2167 }
2168
2169 private static function validateStock($orderItems)
2170 {
2171 $outOfStockVariants = [];
2172
2173 foreach ($orderItems as $orderItem) {
2174 $quantity = (int)Arr::get($orderItem, 'quantity', 0);
2175 $stock = (int)Arr::get($orderItem, 'variants.available', 0);
2176 // $manageStock = (int)Arr::get($orderItem, 'variants.product_detail.manage_stock');
2177 $manageStock = (int)Arr::get($orderItem, 'variants.manage_stock');
2178 $variationTitle = Arr::get($orderItem, 'variants.variation_title');
2179
2180 if ($manageStock == 1 && $stock - $quantity < 0) {
2181 $outOfStockVariants[] = $variationTitle;
2182 }
2183 }
2184
2185 if (!empty($outOfStockVariants)) {
2186 $message = (count($outOfStockVariants) > 1)
2187 ? sprintf(
2188 /* translators: %s is the list of out of stock variants */
2189 __('%s are out of stock', 'fluent-cart'), implode(', ', $outOfStockVariants))
2190 : sprintf(
2191 /* translators: %s is the out of stock variant */
2192 __('%s is out of stock', 'fluent-cart'), reset($outOfStockVariants));
2193
2194
2195 return [
2196 'status' => true,
2197 'message' => $message
2198 ];
2199 }
2200
2201 return false;
2202 }
2203
2204 /**
2205 * Delete orders and its associated data.
2206 *
2207 * @param array $orderIds The ids of the order to be deleted.
2208 * @param array $params Additional parameters for the deletion process.
2209 *
2210 */
2211 public static function bulkDeleteByOrderIds($orderIds, $params = [])
2212 {
2213 $failedOrderIds = [];
2214 $deletedOrderIds = [];
2215
2216 foreach ($orderIds as $order) {
2217 $isDeleted = static::delete($order);
2218
2219 if (is_wp_error($isDeleted)) {
2220 $failedOrderIds[] = $order;
2221 } else {
2222 $deletedOrderIds[] = $order;
2223 }
2224 }
2225
2226 if (count($failedOrderIds) > 0) {
2227 $failedOrderIdsString = implode(' , ', $failedOrderIds);
2228 return count($deletedOrderIds) > 0
2229 ? static::makeSuccessResponse([
2230 'deleted_order_ids' => $deletedOrderIds,
2231 'deleted_count' => count($deletedOrderIds),
2232 'failed_order_ids' => $failedOrderIds,
2233 'failed_count' => count($failedOrderIds)
2234 ], 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. And remaining order and its associated data have been deleted", 'fluent-cart'), $failedOrderIdsString))
2237 : static::makeErrorResponse([['code' => 400, 'message' => sprintf(
2238 /* translators: %s: The order ID(s) that could not be deleted. */
2239 __("The order ID - %s cannot be deleted at the moment as these orders status is not canceled.", 'fluent-cart'), $failedOrderIdsString)]]);
2240 }
2241
2242 if (count($deletedOrderIds) > 0 && count($failedOrderIds) < 1) {
2243 return static::makeSuccessResponse([
2244 'deleted_order_ids' => $deletedOrderIds,
2245 'deleted_count' => count($deletedOrderIds),
2246 'failed_order_ids' => [],
2247 'failed_count' => 0
2248 ], __('Selected order and associated data have been deleted', 'fluent-cart'));
2249 }
2250
2251 return static::makeSuccessResponse([
2252 'deleted_order_ids' => [],
2253 'deleted_count' => 0,
2254 'failed_order_ids' => [],
2255 'failed_count' => 0
2256 ], __('No orders were deleted', 'fluent-cart'));
2257 }
2258
2259 public static function updatePaymentStatus(array $params = [])
2260 {
2261 $order = Arr::get($params, 'order');
2262 $transaction = Arr::get($params, 'transaction');
2263 $newStatus = Arr::get($params, 'status');
2264
2265 if (empty($transaction)) {
2266 return static::makeErrorResponse([
2267 ['code' => 404, 'message' => __('Transaction not found!', 'fluent-cart')]
2268 ]);
2269 }
2270
2271 if ($transaction->status == $newStatus) {
2272 return static::makeErrorResponse([
2273 ['code' => 400, 'message' => __('Transaction already has the same status', 'fluent-cart')]
2274 ]);
2275 }
2276
2277 if ($transaction->order_id != $order->id) {
2278 return static::makeErrorResponse([
2279 ['code' => 400, 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')]
2280 ]);
2281 }
2282
2283 $data = [];
2284 $totalPaid = ($order->total_paid - $transaction->total) < 0 ? 0 : $transaction->total;
2285
2286 if ($newStatus == Status::PAYMENT_PAID) {
2287 $data[] = [
2288 'id' => $order->id,
2289 'payment_status' => $newStatus,
2290 'total_paid' => ['+', $transaction->total],
2291 ];
2292 } elseif ($newStatus == Status::PAYMENT_REFUNDED) {
2293 $data[] = [
2294 'id' => $order->id,
2295 'payment_status' => $newStatus,
2296 'refunded_at' => DateTime::gmtNow(),
2297 'total_paid' => ['-', $totalPaid],
2298 'total_refund' => ['+', $transaction->total],
2299 ];
2300 } elseif ($newStatus == (Status::PAYMENT_PENDING || Status::PAYMENT_FAILED)) {
2301 $data[] = [
2302 'id' => $order->id,
2303 'payment_status' => $newStatus,
2304 'total_paid' => ['-', $totalPaid],
2305 ];
2306 }
2307
2308 $updatedStatus = $transaction->updateStatus($newStatus);
2309
2310 if (!empty($data) && $updatedStatus) {
2311 $oldStatus = Arr::get($order, 'payment_status');
2312 $actionActivity = [
2313 'title' => 'Payment status updated',
2314 'content' => sprintf(
2315 /* translators: %1$s is the old status, %2$s is the new status */
2316 __('Payment status has been updated from %1$s to %2$s', 'fluent-cart'), $oldStatus, $newStatus)
2317 ];
2318
2319 static::getQuery()->batchUpdate($data);
2320
2321 (new OrderStatusUpdated($order, $oldStatus, $newStatus, false, $actionActivity, 'payment_status'))->dispatch();
2322
2323 return static::makeSuccessResponse(
2324 $order,
2325 __('Payment Status has been updated', 'fluent-cart')
2326 );
2327
2328 } else {
2329 return static::makeErrorResponse([
2330 ['code' => 400, 'message' => __('Failed to update payment status', 'fluent-cart')]
2331 ]);
2332 }
2333 }
2334
2335 private static function mergeOrderAddress(OrderAddress $address, array $addressData)
2336 {
2337 $keysToInclude = ['type', 'name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country'];
2338 foreach ($keysToInclude as $key) {
2339 $address->{$key} = $addressData[$key];
2340 }
2341
2342 if ($address->save()) {
2343 return $address;
2344 }
2345 return static::makeErrorResponse([
2346 ['code' => 400, 'message' => __('Failed to update address', 'fluent-cart')]
2347 ]);
2348 }
2349
2350 private static function createOrderAddress(array $address, $orderId)
2351 {
2352 $keysToInclude = ['order_id', 'type', 'name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country'];
2353 $address = Arr::only($address, $keysToInclude);
2354 $address['order_id'] = $orderId;
2355
2356 if (!empty($address)) {
2357 return OrderAddressResource::create($address);
2358 }
2359 }
2360
2361 public static function getOrderByHash($orderHash)
2362 {
2363 return (new Orders())->getByHash($orderHash);
2364 }
2365
2366 private static function resolveShippingTitle(array $shipping): array
2367 {
2368 if (isset($shipping['id']) && empty($shipping['title'])) {
2369 $sm = ShippingMethod::find((int)$shipping['id']);
2370 $shipping['title'] = $sm ? $sm->title : '';
2371 }
2372 return $shipping;
2373 }
2374
2375 }
2376