PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 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 All 51 releases
fluent-cart / api / Resource / OrderResource.php

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

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