PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / api / Resource / OrderResource.php

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

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