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

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