PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 All 51 releases
← All changes | api/Resource/OrderResource.php +1284 -27 1.3.21 → 1.7.1 View file →
@@ -30,8 +30,9 @@
30 30 use FluentCart\App\Models\OrderTaxRate;
31 31 use FluentCart\App\Models\OrderTransaction;
32 32 use FluentCart\App\Models\Query\QueryParser;
33 33 use FluentCart\App\Models\Query\Sort;
34 +use FluentCart\App\Models\ShippingMethod;
34 35 use FluentCart\App\Models\Subscription;
35 36 use FluentCart\App\Models\SubscriptionMeta;
36 37 use FluentCart\App\Services\DateTime\DateTime;
37 38 use FluentCart\App\Services\OrderService;
@@ -36,8 +37,10 @@
36 37 use FluentCart\App\Services\DateTime\DateTime;
37 38 use FluentCart\App\Services\OrderService;
38 39 use FluentCart\App\Services\Payments\PaymentHelper;
39 40 use FluentCart\App\Services\Payments\PaymentInstance;
41 +use FluentCart\App\Services\Tax\AdminOrderTaxService;
42 +use FluentCart\App\Modules\Tax\TaxModule;
40 43 use FluentCart\Framework\Database\Orm\Builder;
41 44 use FluentCart\Framework\Database\Orm\Collection;
42 45 use FluentCart\Framework\Support\Arr;
43 46
@@ -168,10 +171,9 @@
168 171 $subtotal = OrderService::getItemsAmountWithoutDiscount($orderItems); //get order total without a discount
169 172
170 173 // because of decimal issue commented this below line, using OrderService::getCouponDiscountTotal instead
171 174 // $subtotalWithDiscount = OrderService::getItemsAmountTotal($orderItems, false, false); //get order total with discount
172 - $coupon_discount_total = OrderService::getCouponDiscountTotal($orderItems);
173 - $couponDiscountTotal = $coupon_discount_total;
175 + $couponDiscountTotal = OrderService::getCouponDiscountTotal($orderItems);
174 176
175 177 $totalAmount = floatVal($subtotal + Arr::get($order, 'tax_total', 0) + Arr::get($order, 'shipping_total', 0) - Arr::get($order, 'manual_discount_total', 0) - $couponDiscountTotal);
176 178
177 179 $latestOrder = static::getQuery()->latest()->first();
@@ -230,8 +232,44 @@
230 232
231 233 /**
232 234 * @throws \Exception
233 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 +
234 272 public static function updatedPlaceOrder($data, $params = [])
235 273 {
236 274 $order = $data;
237 275 $discount = Arr::get($data, 'discount');
@@ -247,14 +285,27 @@
247 285 if (Arr::get($discount, 'value', 0) > 0) {
248 286 static::distributeManualDiscount($items, Helper::toCent(Arr::get($discount, 'value', 0)));
249 287 }
250 288
289 + $couponCheck = CouponResource::validateOrderCoupons($items, (array) Arr::get($data, 'applied_coupon', []), Arr::get($customer, 'email', ''));
290 + if (is_wp_error($couponCheck)) {
291 + return $couponCheck;
292 + }
293 + $items = $couponCheck['items'];
294 + $data['applied_coupon'] = $couponCheck['applied_coupons'];
295 +
251 296 // admin order processor
252 297 $adminOrderProcessor = new AdminOrderProcessor($items, [
253 298 'customer_id' => $customer->id,
254 299 'payment_method' => $paymentMethod,
255 300 'applied_coupons' => Arr::get($data, 'applied_coupon', []),
256 - 'shipping_total' => Arr::get($data, 'shipping_total', []),
301 + // Normalized here as well as in OrderRequest::sanitize(): this is a public
302 + // Resource API, and a direct caller never passes through the request layer. The
303 + // shared helper also absorbs the null that pickKeys() injects for an omitted key
304 + // AFTER Sanitizer::sanitize() has run, which no sanitizer can reach. Negative
305 + // and PRESENT-but-malformed shipping are rejected here too, on the RAW value
306 + // and BEFORE rounding — see assertShippingCents().
307 + 'shipping_total' => Helper::roundCent(static::assertShippingCents(Arr::get($data, 'shipping_total'))),
257 308 'billing_address' => Arr::get($customer, 'billing_address', []),
258 309 'shipping_address' => Arr::get($customer, 'shipping_address', []),
259 310 'user_tz' => Arr::get($data, 'user_tz', ''),
260 311 ]);
@@ -268,14 +319,23 @@
268 319 static::addOrderMeta($order->id, $discount, $shipping, $newLabelIds);
269 320
270 321 static::commitEvents($order);
271 322
272 - static::createOrderAddresses($order->id, $data);
323 + static::createOrderAddresses($order->id, $data, $order->customer_id);
273 324
274 325 static::triggerStockChangedEvents($order);
275 326
327 + // Calculate and persist tax for admin-created orders
328 + static::applyAdminOrderTax($order, $items, $customer, $data);
329 +
276 330 if ($gateway = App::gateway($paymentMethod)) {
277 331 $paymentInstance = new PaymentInstance($order);
332 +
333 + if ($paymentInstance->subscription && $paymentInstance->subscription->status === Status::SUBSCRIPTION_PENDING) {
334 + $paymentInstance->subscription->status = Status::SUBSCRIPTION_INTENDED;
335 + $paymentInstance->subscription->save();
336 + }
337 +
278 338 $gateway->makePaymentFromPaymentInstance($paymentInstance);
279 339 }
280 340
281 341 return $order;
@@ -290,8 +350,1052 @@
290 350 ]);
291 351 }
292 352 }
293 353
354 + /**
355 + * Calculate tax for an admin-created order and persist it to fct_order_tax_rate.
356 + * Updates order.tax_total and order.shipping_tax. Never throws — tax failure must
357 + * not block order creation.
358 + *
359 + * @param \FluentCart\App\Models\Order $order The freshly created order.
360 + * @param array $items Raw order_items from the create-order request.
361 + * @param \FluentCart\App\Models\Customer $customer Customer with primary_billing_address loaded.
362 + * @param array $data Raw request data (may include billing_address_id).
363 + */
364 + private static function applyAdminOrderTax($order, $items, $customer, $data = [])
365 + {
366 + try {
367 + // Resolve billing address: prefer the address explicitly selected in the
368 + // admin UI (billing_address_id), fall back to customer's primary address.
369 + $billingAddress = null;
370 + $billingAddressId = (int) Arr::get($data, 'billing_address_id', 0);
371 + if ($billingAddressId > 0) {
372 + $addr = CustomerAddresses::query()
373 + ->where('customer_id', $order->customer_id)
374 + ->find($billingAddressId);
375 + if ($addr) {
376 + $billingAddress = [
377 + 'country' => $addr->country ?: '',
378 + 'state' => $addr->state ?: '',
379 + 'city' => $addr->city ?: '',
380 + 'postcode' => $addr->postcode ?: '',
381 + ];
382 + }
383 + }
384 + $billingFallbackAddress = null;
385 + if (!$billingAddress && $customer && $customer->primary_billing_address) {
386 + $addr = $customer->primary_billing_address;
387 + $billingFallbackAddress = $addr;
388 + $billingAddress = [
389 + 'country' => $addr->country ?: '',
390 + 'state' => $addr->state ?: '',
391 + 'city' => $addr->city ?: '',
392 + 'postcode' => $addr->postcode ?: '',
393 + ];
394 + }
395 +
396 + // Resolve shipping address for basis=shipping
397 + $shippingAddress = null;
398 + $shippingAddressId = (int) Arr::get($data, 'shipping_address_id', 0);
399 + if ($shippingAddressId > 0) {
400 + $addr = CustomerAddresses::query()
401 + ->where('customer_id', $order->customer_id)
402 + ->find($shippingAddressId);
403 + if ($addr) {
404 + $shippingAddress = [
405 + 'country' => $addr->country ?: '',
406 + 'state' => $addr->state ?: '',
407 + 'city' => $addr->city ?: '',
408 + 'postcode' => $addr->postcode ?: '',
409 + ];
410 + }
411 + }
412 + $shippingFallbackAddress = null;
413 + if (!$shippingAddress && $customer && $customer->primary_shipping_address) {
414 + $addr = $customer->primary_shipping_address;
415 + $shippingFallbackAddress = $addr;
416 + $shippingAddress = [
417 + 'country' => $addr->country ?: '',
418 + 'state' => $addr->state ?: '',
419 + 'city' => $addr->city ?: '',
420 + 'postcode' => $addr->postcode ?: '',
421 + ];
422 + }
423 +
424 + $taxSettings = (new TaxModule())->getSettings();
425 + $basis = Arr::get($taxSettings, 'tax_calculation_basis', 'shipping');
426 + $taxAddress = AdminOrderTaxService::resolveAddressForBasis($basis, $billingAddress, $shippingAddress);
427 +
428 + if (empty($taxAddress['country'])) {
429 + // No address — can't calculate tax. Still write the zero-tax
430 + // sentinel row so every order records "tax ran, no address"
431 + // (same guarantee checkout gives via persistTaxRates).
432 + TaxModule::persistTaxRates($order->id, [], [
433 + 'tax_country' => '',
434 + 'source' => 'admin_order',
435 + 'note' => 'no_tax_address',
436 + ], 0);
437 + return;
438 + }
439 +
440 + // Build line items from raw order_items
441 + $taxItems = [];
442 + foreach ($items as $item) {
443 + $unitPrice = (int) Arr::get($item, 'unit_price', 0);
444 + $qty = max(1, (int) Arr::get($item, 'quantity', 1));
445 + $subtotal = $unitPrice * $qty;
446 +
447 + // Include manual_discount (set by distributeManualDiscount) so tax is
448 + // calculated on the after-discount amount, not the full subtotal.
449 + $taxItems[] = [
450 + 'id' => (int) Arr::get($item, 'id', 0),
451 + 'post_id' => (int) Arr::get($item, 'post_id', 0),
452 + 'object_id' => (int) Arr::get($item, 'object_id', 0),
453 + 'subtotal' => $subtotal,
454 + 'discount_total' => (int) Arr::get($item, 'discount_total', 0) + (int) Arr::get($item, 'manual_discount', 0),
455 + 'shipping_charge'=> (int) Arr::get($item, 'shipping_charge', 0),
456 + 'quantity' => $qty,
457 + 'other_info' => Arr::get($item, 'other_info', []),
458 + ];
459 + }
460 +
461 + $taxResult = AdminOrderTaxService::calculate($taxItems, $taxAddress, $taxSettings);
462 +
463 + if ($taxResult === null) {
464 + return; // Tax disabled or no result
465 + }
466 +
467 + $taxTotal = (int) Arr::get($taxResult, 'tax_total', 0);
468 + $exclusiveTaxTotal = (int) Arr::get($taxResult, 'exclusive_tax_total', 0);
469 + $storeTaxBehavior = (int) Arr::get($taxResult, 'store_tax_behavior', 0);
470 + $feeTax = (int) Arr::get($taxResult, 'fee_tax', 0);
471 + $shippingTax = (int) Arr::get($taxResult, 'shipping_tax', 0);
472 + $shippingTaxLines = Arr::get($taxResult, 'shipping_tax_lines', []);
473 + $taxLines = Arr::get($taxResult, 'tax_lines', []);
474 + $taxCountry = Arr::get($taxResult, 'tax_country', $taxAddress['country']);
475 +
476 + // Always persist tax fields for reporting, even when amounts are zero
477 + $taxBehavior = (int) Arr::get($taxResult, 'tax_behavior', 0);
478 + $order->tax_behavior = $taxBehavior;
479 + $order->tax_total = $taxTotal;
480 + $order->shipping_tax = $shippingTax;
481 +
482 + // Calculate total_amount based on tax behavior
483 + if ($taxBehavior === 1) {
484 + // Pure exclusive: all tax (product + fee) is on top of subtotals.
485 + $order->total_amount = $order->total_amount + $taxTotal + $shippingTax;
486 + } elseif ($taxBehavior === 3) {
487 + // Mixed: only exclusive product tax + store-exclusive fee/shipping on top.
488 + $order->total_amount = $order->total_amount + $exclusiveTaxTotal;
489 + if ($storeTaxBehavior === 1) {
490 + $order->total_amount = $order->total_amount + $feeTax + $shippingTax;
491 + }
492 + }
493 + // behavior=2 (inclusive) or 0 (reverse charge): tax already in item prices
494 +
495 + $DB = App::db();
496 + $DB->beginTransaction();
497 +
498 + $order->save();
499 +
500 + // When tax was calculated from the customer's primary address (no address
501 + // explicitly attached to the order), persist that address onto the order —
502 + // the edit path reads fct_order_addresses, and without this row the next
503 + // save would hit the no-country branch and clear the tax charged here.
504 + if ($billingFallbackAddress) {
505 + static::createOrderAddress($billingFallbackAddress->toArray(), $order->id);
506 + }
507 + if ($shippingFallbackAddress) {
508 + static::createOrderAddress($shippingFallbackAddress->toArray(), $order->id);
509 + }
510 +
511 + // Always persist these meta keys so a later recalculation that returns
512 + // zero values does not leave stale non-zero data from a prior edit.
513 + $order->updateMeta('exclusive_tax_total', $exclusiveTaxTotal);
514 + $order->updateMeta('store_tax_behavior', $storeTaxBehavior);
515 + $order->updateMeta('fee_tax', $feeTax);
516 +
517 + // Patch per-item tax_amount and line_meta so tax badges display correctly.
518 + $lineItemsFromTax = Arr::get($taxResult, 'line_items', []);
519 + if (!empty($lineItemsFromTax)) {
520 + $savedItems = OrderItem::query()
521 + ->where('order_id', $order->id)
522 + ->whereNotIn('payment_type', ['fee', 'signup_fee'])
523 + ->get()
524 + ->toArray();
525 + static::patchOrderItemTaxMeta($savedItems, $lineItemsFromTax);
526 + static::patchSignupFeeTaxMeta($order->id, $lineItemsFromTax);
527 + static::patchSubscriptionTax($order, $lineItemsFromTax, $taxBehavior);
528 + }
529 +
530 + // Persist tax-rate rows
531 + $taxMeta = [
532 + 'tax_country' => $taxCountry,
533 + 'tax_behavior' => $taxBehavior,
534 + 'inclusive' => $taxBehavior === 2,
535 + 'shipping_inclusive' => $storeTaxBehavior === 2,
536 + 'source' => 'admin_order',
537 + ];
538 +
539 + TaxModule::persistTaxRates($order->id, $taxLines, $taxMeta, $shippingTax, $shippingTaxLines);
540 +
541 + // Sync the pending charge transaction total so it matches the tax-adjusted order total.
542 + $pendingTx = OrderTransaction::query()
543 + ->where('order_id', $order->id)
544 + ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
545 + ->where('status', 'pending')
546 + ->first();
547 + if ($pendingTx) {
548 + $pendingTx->total = $order->total_amount;
549 + $pendingTx->save();
550 + }
551 +
552 + $DB->commit();
553 +
554 + } catch (\Exception $e) {
555 + if (isset($DB)) {
556 + $DB->rollBack();
557 + }
558 + // Log but never block order creation — tax calculation is non-critical
559 + fluent_cart_warning_log(
560 + 'Admin order tax calculation failed',
561 + get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
562 + ['module_name' => 'tax', 'module_id' => $order->id, 'log_type' => 'api']
563 + );
564 + }
565 + }
566 +
567 + /**
568 + * Rebuild an order's item-derived totals from the rows actually in
569 + * fct_order_items, then let the tax pass derive total_amount from the new
570 + * subtotal.
571 + *
572 + * The whole-order save posts client-computed totals alongside the items, so
573 + * it does not need this. A caller that writes a single line item on its own
574 + * does — without it the order keeps the subtotal it had before the line
575 + * existed. Same aggregation as AdminOrderProcessor: fee lines live in
576 + * fee_total, and trial lines are not billed now.
577 + */
578 + public static function syncItemDerivedTotals(Order $order)
579 + {
580 + $order->load('order_items');
581 +
582 + // The tax pass early-returns for these before reaching the pending
583 + // charge transaction sync, so a total written here would go stale
584 + // against the recorded charge. Refuse instead of desynchronizing.
585 + if ($order->isSubscription() || $order->type === 'refund') {
586 + throw new \Exception(esc_html__('Order Not valid!', 'fluent-cart'));
587 + }
588 +
589 + $subtotal = 0;
590 +
591 + foreach ($order->order_items as $item) {
592 + if (in_array($item->payment_type, ['fee', 'signup_fee'], true)) {
593 + continue;
594 + }
595 +
596 + if (Arr::get($item->other_info, 'trial_days', 0) > 0) {
597 + continue;
598 + }
599 +
600 + $subtotal += (int) $item->subtotal;
601 + }
602 +
603 + $order->subtotal = $subtotal;
604 +
605 + // The parent's fulfillment fields are item-derived too — creation sets
606 + // them from whether any line is physical (AdminOrderProcessor). A
607 + // physical line added to a digital order must pull the order into the
608 + // shipping workflow. Upgrade only: a rebuild must never downgrade the
609 + // type or reset shipping progress already recorded.
610 + $hasPhysical = $order->order_items
611 + ->where('fulfillment_type', Status::FULFILLMENT_TYPE_PHYSICAL)
612 + ->isNotEmpty();
613 +
614 + if ($hasPhysical) {
615 + if ($order->fulfillment_type !== Status::FULFILLMENT_TYPE_PHYSICAL) {
616 + $order->fulfillment_type = Status::FULFILLMENT_TYPE_PHYSICAL;
617 + }
618 + if (!$order->shipping_status) {
619 + $order->shipping_status = 'unshipped';
620 + }
621 + }
622 +
623 + // Tax-free baseline; the tax pass recomputes it with tax on every path
624 + // it completes.
625 + $order->total_amount = max(0, $subtotal
626 + + (int) $order->shipping_total
627 + + (int) $order->fee_total
628 + - (int) $order->coupon_discount_total
629 + - (int) $order->manual_discount_total);
630 +
631 + $order->save();
632 +
633 + // The tax pass swallows its own failures so a whole-order save is never
634 + // blocked, but this caller has nothing else persisting the order — a
635 + // swallowed failure here would commit the new subtotal beside stale tax
636 + // fields and rate rows. Escalate so the caller's transaction rolls the
637 + // item and totals back together.
638 + if (!static::reapplyTaxAfterUpdate($order->id, $order->refresh())) {
639 + throw new \Exception(esc_html__('Order totals could not be recalculated. Please try again.', 'fluent-cart'));
640 + }
641 +
642 + return $order->refresh();
643 + }
644 +
645 + /**
646 + * Recalculate and persist tax for an existing order after create or update.
647 + * Reads saved items + billing address from the DB, runs AdminOrderTaxService,
648 + * recomputes total_amount from scratch, and rewrites fct_order_tax_rate rows.
649 + * Never throws — tax failure must not block the save.
650 + *
651 + * @return bool false when the order was left carrying tax data the current
652 + * items no longer justify (transient calculator failure or a
653 + * rolled-back write); true when it reached a coherent state.
654 + */
655 + private static function reapplyTaxAfterUpdate($orderId, $order)
656 + {
657 + try {
658 + if (!$order->relationLoaded('order_items')) {
659 + $order->load('order_items');
660 + }
661 +
662 + if ($order->isSubscription()) {
663 + return true;
664 + }
665 +
666 + if ($order->type === 'refund') {
667 + return true;
668 + }
669 +
670 + // Query addresses directly — ORM relation load() does not reliably apply
671 + // the type WHERE constraint, so we query fct_order_addresses ourselves.
672 + $billingAddr = OrderAddress::query()->where('order_id', $orderId)->where('type', 'billing')->first();
673 + $shippingAddr = OrderAddress::query()->where('order_id', $orderId)->where('type', 'shipping')->first();
674 +
675 + $billingAddress = null;
676 + $shippingAddress = null;
677 +
678 + if ($billingAddr) {
679 + $billingAddress = [
680 + 'country' => $billingAddr->country ?: '',
681 + 'state' => $billingAddr->state ?: '',
682 + 'city' => $billingAddr->city ?: '',
683 + 'postcode' => $billingAddr->postcode ?: '',
684 + ];
685 + }
686 + if ($shippingAddr) {
687 + $shippingAddress = [
688 + 'country' => $shippingAddr->country ?: '',
689 + 'state' => $shippingAddr->state ?: '',
690 + 'city' => $shippingAddr->city ?: '',
691 + 'postcode' => $shippingAddr->postcode ?: '',
692 + ];
693 + }
694 +
695 + $taxSettings = (new TaxModule())->getSettings();
696 + $basis = Arr::get($taxSettings, 'tax_calculation_basis', 'shipping');
697 + $taxAddress = AdminOrderTaxService::resolveAddressForBasis($basis, $billingAddress, $shippingAddress);
698 +
699 + if (empty($taxAddress['country'])) {
700 + return static::clearOrderTax($orderId, $order);
701 + }
702 +
703 + $productItems = $order->order_items->filter(function ($item) {
704 + return !in_array($item->payment_type, ['fee', 'signup_fee'], true);
705 + })->values();
706 +
707 + $taxItems = [];
708 + foreach ($productItems as $item) {
709 + $unitPrice = (int) Arr::get($item, 'unit_price', 0);
710 + $qty = max(1, (int) Arr::get($item, 'quantity', 1));
711 + $taxItems[] = [
712 + 'id' => (int) Arr::get($item, 'id', 0),
713 + 'post_id' => (int) Arr::get($item, 'post_id', 0),
714 + 'object_id' => (int) Arr::get($item, 'object_id', 0),
715 + 'subtotal' => $unitPrice * $qty,
716 + 'discount_total' => (int) Arr::get($item, 'discount_total', 0),
717 + 'shipping_charge' => (int) Arr::get($item, 'shipping_charge', 0),
718 + 'quantity' => $qty,
719 + 'other_info' => Arr::get($item, 'other_info', []),
720 + ];
721 + }
722 +
723 + if (empty($taxItems)) {
724 + return static::clearOrderTax($orderId, $order);
725 + }
726 +
727 + // Fee items only exist on checkout-created orders that are edited in
728 + // admin. Mirror checkout (TaxModule::calculateCartTax()): only taxable,
729 + // non-zero fees enter the calculator as is_fee lines. Fee item subtotal
730 + // holds the NET fee amount (CheckoutProcessor::syncFeeItems() stores it
731 + // tax-free), so it doubles as the net fee base for the total recompute.
732 + // Guard: when the order has NO fee order items, the stored fee_total
733 + // column is the only source (legacy / manually set) — keep it as-is and
734 + // skip fee tax entirely.
735 + $feeOrderItems = $order->order_items->filter(function ($item) {
736 + return $item->payment_type === 'fee';
737 + })->values();
738 +
739 + $hasFeeItems = !$feeOrderItems->isEmpty();
740 + $netFeeTotal = 0;
741 + foreach ($feeOrderItems as $feeItem) {
742 + $feeSubtotal = (int) Arr::get($feeItem, 'subtotal', 0);
743 + $netFeeTotal += $feeSubtotal;
744 +
745 + $feeOtherInfo = Arr::get($feeItem, 'other_info', []);
746 + if (!is_array($feeOtherInfo)) {
747 + $feeOtherInfo = [];
748 + }
749 + if (empty($feeOtherInfo['taxable']) || $feeSubtotal <= 0) {
750 + continue;
751 + }
752 +
753 + $taxItems[] = [
754 + 'is_fee' => true,
755 + 'title' => (string) Arr::get($feeItem, 'title', ''),
756 + 'post_id' => 0,
757 + 'object_id' => 0,
758 + 'subtotal' => $feeSubtotal,
759 + 'discount_total' => 0,
760 + 'shipping_charge' => 0,
761 + 'quantity' => 1,
762 + 'other_info' => $feeOtherInfo,
763 + ];
764 + }
765 +
766 + $taxResult = AdminOrderTaxService::calculate($taxItems, $taxAddress, $taxSettings);
767 +
768 + if ($taxResult === null) {
769 + if (!TaxModule::isTaxEnabled()) {
770 + // Deterministic: tax was turned off — clear stale tax instead of leaving it.
771 + return static::clearOrderTax($orderId, $order);
772 + }
773 + // Transient calculation failure: keep existing tax untouched.
774 + return false;
775 + }
776 +
777 + $taxTotal = (int) Arr::get($taxResult, 'tax_total', 0);
778 + $exclusiveTaxTotal = (int) Arr::get($taxResult, 'exclusive_tax_total', 0);
779 + $storeTaxBehavior = (int) Arr::get($taxResult, 'store_tax_behavior', 0);
780 + $feeTax = (int) Arr::get($taxResult, 'fee_tax', 0);
781 + $feeTaxLines = (array) Arr::get($taxResult, 'fee_tax_lines', []);
782 + $shippingTax = (int) Arr::get($taxResult, 'shipping_tax', 0);
783 + $shippingTaxLines = Arr::get($taxResult, 'shipping_tax_lines', []);
784 + $taxLines = Arr::get($taxResult, 'tax_lines', []);
785 + $taxCountry = Arr::get($taxResult, 'tax_country', $taxAddress['country']);
786 + $taxBehavior = (int) Arr::get($taxResult, 'tax_behavior', 0);
787 + $lineItemsFromTax = Arr::get($taxResult, 'line_items', []);
788 +
789 + // Respect a checkout-time VIES validation: when the order carries a
790 + // validated VAT number and reverse charge still applies for the
791 + // (possibly edited) address, zero the recalculated tax and keep the
792 + // RC audit meta instead of re-adding tax the buyer does not owe.
793 + $rcMeta = [];
794 + $rcContext = static::resolveAdminReverseChargeContext($order, $taxAddress);
795 + if ($rcContext !== null) {
796 + $rcMode = $order->getOrderRcMode();
797 + // tax_total includes fee tax; the inclusive portion must not
798 + // (same formula as checkout: taxTotal - exclusiveTaxTotal - feeTax).
799 + $inclusivePortion = max(0, $taxTotal - $exclusiveTaxTotal - $feeTax);
800 +
801 + $rcMeta = [
802 + 'reverse_charge_applied' => true,
803 + 'vat_reverse' => $rcContext,
804 + 'reverse_charge_original_tax_total' => $exclusiveTaxTotal + $feeTax + $shippingTax + ($rcMode === 'dynamic' ? $inclusivePortion : 0),
805 + 'reverse_charge_original_shipping_tax' => $shippingTax,
806 + 'reverse_charge_price_mode' => $rcMode,
807 + ];
808 +
809 + // Zero RC-style — rate rows keep their identity with zero amounts,
810 + // line items keep their tax_config rates (strikethrough display)
811 + // while top-level tax_amount is zeroed. Same convention as checkout.
812 + foreach ($taxLines as $lineIndex => $taxLine) {
813 + $taxLines[$lineIndex]['tax_amount'] = 0;
814 + }
815 + foreach ($lineItemsFromTax as $itemIndex => $taxLineItem) {
816 + $lineItemsFromTax[$itemIndex]['tax_amount'] = 0;
817 + $lineItemsFromTax[$itemIndex]['signup_fee_tax'] = 0;
818 + }
819 + $taxTotal = 0;
820 + $exclusiveTaxTotal = 0;
821 + $shippingTax = 0;
822 + $shippingTaxLines = [];
823 + $taxBehavior = 0;
824 + $feeTax = 0;
825 + $feeTaxLines = [];
826 + }
827 +
828 + // Fee base for the total recompute. The stored fee_total column on a
829 + // behavior-1 checkout order already contains the ORIGINAL fee tax
830 + // (CheckoutProcessor rolled it in) — trusting it would double-count
831 + // fee tax against the freshly calculated one. When fee order items
832 + // exist, their subtotals are the net fee amounts; rebuild fee_total
833 + // from net + new fee tax (checkout invariant: gateways read fee_total
834 + // as the gross fee). Without fee items, keep the stored column as-is.
835 + $feeBaseTotal = (int) $order->fee_total;
836 + if ($hasFeeItems) {
837 + $feeBaseTotal = $netFeeTotal;
838 + $newFeeTotal = $netFeeTotal;
839 + if ($feeTax && ($taxBehavior === 1 || ($taxBehavior === 3 && $storeTaxBehavior === 1))) {
840 + $newFeeTotal += $feeTax;
841 + }
842 + $order->fee_total = $newFeeTotal;
843 + }
844 +
845 + // Recompute total_amount from first principles so old tax is never double-counted.
846 + // fee base must be included — checkout orders carry payment/processing fees
847 + // outside subtotal (see CheckoutProcessor::prepareOrderData()).
848 + $baseTotal = (int)$order->subtotal
849 + + (int)$order->shipping_total
850 + + $feeBaseTotal
851 + - (int)$order->coupon_discount_total
852 + - (int)$order->manual_discount_total;
853 +
854 + $order->tax_behavior = $taxBehavior;
855 + $order->tax_total = $taxTotal;
856 + $order->shipping_tax = $shippingTax;
857 + $order->total_amount = $baseTotal;
858 +
859 + if ($taxBehavior === 1) {
860 + // taxTotal already includes feeTax → net fee + fee tax counted exactly once.
861 + $order->total_amount += $taxTotal + $shippingTax;
862 + } elseif ($taxBehavior === 3) {
863 + // exclusiveTaxTotal excludes fee lines → add feeTax explicitly for exclusive stores.
864 + $order->total_amount += $exclusiveTaxTotal;
865 + if ($storeTaxBehavior === 1) {
866 + $order->total_amount += $feeTax + $shippingTax;
867 + }
868 + }
869 +
870 + $DB = App::db();
871 + $DB->beginTransaction();
872 +
873 + $order->save();
874 +
875 + // Always persist these meta keys so a later recalculation that returns
876 + // zero values does not leave stale non-zero data from a prior edit.
877 + $order->updateMeta('exclusive_tax_total', $exclusiveTaxTotal);
878 + $order->updateMeta('store_tax_behavior', $storeTaxBehavior);
879 + $order->updateMeta('fee_tax', $feeTax);
880 +
881 + // Same persist/delete pattern as CheckoutProcessor::persistTaxMeta() —
882 + // a stale checkout-written fee_tax_lines must not survive an admin edit
883 + // that produced no fee tax.
884 + if (!empty($feeTaxLines)) {
885 + $order->updateMeta('fee_tax_lines', $feeTaxLines);
886 + } else {
887 + $order->deleteMeta('fee_tax_lines');
888 + }
889 +
890 + // Patch per-item tax_amount and line_meta so tax badges display correctly.
891 + // patchSignupFeeTaxMeta() is always called (even when no items have signup-fee tax)
892 + // so it can zero out items that were previously taxed but are now exempt.
893 + static::patchOrderItemTaxMeta($productItems->toArray(), $lineItemsFromTax);
894 + static::patchSignupFeeTaxMeta($orderId, $lineItemsFromTax);
895 +
896 + $taxMeta = array_merge([
897 + 'tax_country' => $taxCountry,
898 + 'tax_behavior' => $taxBehavior,
899 + 'inclusive' => $taxBehavior === 2,
900 + 'shipping_inclusive' => $storeTaxBehavior === 2,
901 + 'source' => 'admin_order_edit',
902 + ], $rcMeta);
903 +
904 + OrderTaxRate::query()->where('order_id', $orderId)->delete();
905 + TaxModule::persistTaxRates($orderId, $taxLines, $taxMeta, $shippingTax, $shippingTaxLines);
906 +
907 + $pendingTx = OrderTransaction::query()
908 + ->where('order_id', $orderId)
909 + ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
910 + ->where('status', 'pending')
911 + ->first();
912 + if ($pendingTx) {
913 + $pendingTx->total = $order->total_amount;
914 + $pendingTx->save();
915 + }
916 +
917 + // Paid orders: settled transactions are never touched — reflect the new
918 + // total as a due / refund-owed state instead.
919 + static::syncPaymentStatusWithTotals($order);
920 +
921 + $DB->commit();
922 +
923 + return true;
924 + } catch (\Exception $e) {
925 + if (isset($DB)) {
926 + $DB->rollBack();
927 + }
928 + fluent_cart_warning_log(
929 + 'Admin order tax recalculation failed on update',
930 + get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
931 + ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api']
932 + );
933 +
934 + return false;
935 + }
936 + }
937 +
938 + /**
939 + * Re-derive payment_status after a tax recalculation changed total_amount on
940 + * an order that already received money. A fully-paid order whose total grew
941 + * becomes partially_paid (the admin UI then shows Total Due + Collect
942 + * Payments); a partially_paid order whose total shrank to within total_paid
943 + * becomes paid. Overpayment keeps status paid — the Total Refund Owed row is
944 + * derived from the columns directly. Intentionally event-free: no payment was
945 + * received, so OrderPaid side effects (emails) must not fire.
946 + */
947 + private static function syncPaymentStatusWithTotals($order)
948 + {
949 + $totalPaid = (int) $order->total_paid;
950 + if ($totalPaid <= 0) {
951 + return; // unpaid orders keep their pending/failed lifecycle
952 + }
953 +
954 + $totalAmount = (int) $order->total_amount;
955 + if ($totalPaid < $totalAmount && $order->payment_status === Status::PAYMENT_PAID) {
956 + $order->updatePaymentStatus(Status::PAYMENT_PARTIALLY_PAID);
957 + } elseif ($totalPaid >= $totalAmount && $order->payment_status === Status::PAYMENT_PARTIALLY_PAID) {
958 + $order->updatePaymentStatus(Status::PAYMENT_PAID);
959 + }
960 + }
961 +
962 + /**
963 + * Resolve whether a checkout-time VIES validation still grants reverse charge
964 + * for an admin order edit.
965 + *
966 + * Sources the validated VAT from order business_info (rate-row vat_reverse
967 + * meta as legacy fallback), then re-checks eligibility against the current
968 + * tax address: the VAT's member state must match the tax country and the
969 + * store settings must allow reverse charge for it. When the tax country
970 + * changed since the order was placed, the VAT is re-validated against VIES —
971 + * a definitive "invalid" drops reverse charge; an unreachable service trusts
972 + * the stored validation (fail open, matching checkout behavior).
973 + *
974 + * @return array|null vat_reverse payload to persist, or null when reverse
975 + * charge must not apply.
976 + */
977 + private static function resolveAdminReverseChargeContext($order, $taxAddress)
978 + {
979 + $businessInfo = $order->getBusinessInfo();
980 + $vatNumber = (string) Arr::get($businessInfo, 'tax_number', '');
981 + $validated = (bool) Arr::get($businessInfo, 'tax_number_validated', false);
982 + $vatCountry = (string) Arr::get($businessInfo, 'tax_number_country', '');
983 + $vatName = (string) Arr::get($businessInfo, 'tax_number_name', '');
984 +
985 + $primaryRate = $order->getPrimaryOrderTaxRate();
986 + $primaryRateMeta = $primaryRate ? (array) $primaryRate->meta : [];
987 +
988 + if (!$validated || !$vatNumber) {
989 + // Legacy orders: VAT data only exists on the rate-row meta.
990 + $vatReverse = (array) Arr::get($primaryRateMeta, 'vat_reverse', []);
991 + if (Arr::get($vatReverse, 'valid', false) && Arr::get($vatReverse, 'vat_number', '')) {
992 + $vatNumber = (string) Arr::get($vatReverse, 'vat_number', '');
993 + $vatCountry = (string) Arr::get($vatReverse, 'country', '');
994 + $vatName = (string) Arr::get($vatReverse, 'name', '');
995 + $validated = true;
996 + }
997 + }
998 +
999 + if (!$validated || !$vatNumber) {
1000 + return null;
1001 + }
1002 +
1003 + $taxCountry = strtoupper((string) Arr::get($taxAddress, 'country', ''));
1004 +
1005 + // The validated VAT belongs to one member state — reverse charge only
1006 + // applies while the order is taxed in that country (same rule as checkout).
1007 + if (!$taxCountry || strtoupper($vatCountry) !== $taxCountry) {
1008 + return null;
1009 + }
1010 +
1011 + $taxModule = new TaxModule();
1012 + if (!$taxModule->canApplyVatValidation($taxCountry)) {
1013 + return null;
1014 + }
1015 +
1016 + // Excluded categories: refuse reverse charge when any order product belongs
1017 + // to a category listed in eu_vat_settings.vat_reverse_excluded_categories.
1018 + // Checkout applies this only under local_reverse_charge = yes
1019 + // (TaxModule::shouldApplyReverseCharge() / handleVatValidation()) — same gate
1020 + // here for exact parity.
1021 + $taxSettings = $taxModule->getSettings();
1022 + $excludedCategories = array_map('intval', (array) Arr::get(
1023 + $taxSettings, 'eu_vat_settings.vat_reverse_excluded_categories', []
1024 + ));
1025 + if (Arr::get($taxSettings, 'eu_vat_settings.local_reverse_charge', 'no') === 'yes' && !empty($excludedCategories)) {
1026 + if (!$order->relationLoaded('order_items')) {
1027 + $order->load('order_items');
1028 + }
1029 +
1030 + $productIds = [];
1031 + foreach ($order->order_items as $orderItem) {
1032 + if (!in_array($orderItem->payment_type, ['fee', 'signup_fee'], true) && $orderItem->post_id) {
1033 + $productIds[] = (int) $orderItem->post_id;
1034 + }
1035 + }
1036 + $productIds = array_values(array_unique($productIds));
1037 +
1038 + if (!empty($productIds)) {
1039 + // TaxModule::getTermsByProductIds() is protected — replicate its
1040 + // term_relationships lookup (object_id → term_taxonomy_id).
1041 + $termRows = App::db()->table('term_relationships')
1042 + ->whereIn('object_id', $productIds)
1043 + ->get();
1044 + foreach ($termRows as $termRow) {
1045 + if (in_array((int) $termRow->term_taxonomy_id, $excludedCategories, true)) {
1046 + return null;
1047 + }
1048 + }
1049 + }
1050 + }
1051 +
1052 + // Tax country changed since placement → re-validate the VAT against VIES.
1053 + $previousTaxCountry = strtoupper((string) Arr::get($primaryRateMeta, 'tax_country', ''));
1054 + if ($previousTaxCountry && $previousTaxCountry !== $taxCountry) {
1055 + $revalidation = $taxModule->validateVatForAdmin($vatCountry, $vatNumber);
1056 + if (is_array($revalidation)) {
1057 + if (empty($revalidation['valid'])) {
1058 + return null;
1059 + }
1060 + $vatName = (string) Arr::get($revalidation, 'name', $vatName);
1061 + } elseif (is_wp_error($revalidation) && $revalidation->get_error_code() === 'invalid') {
1062 + // Definitive VIES answer: the number is no longer registered.
1063 + return null;
1064 + }
1065 + // service_unavailable / soap_fault → VIES unreachable: keep stored validation.
1066 + }
1067 +
1068 + return [
1069 + 'vat_number' => $vatNumber,
1070 + 'country' => $vatCountry,
1071 + 'valid' => true,
1072 + 'name' => $vatName,
1073 + ];
1074 + }
1075 +
1076 + /**
1077 + * Zero out all tax fields, rate rows, and per-item tax amounts for an order
1078 + * that has become definitively non-taxable (no address, no taxable items).
1079 + * Only called for deterministic states — not on transient calculation failures.
1080 + *
1081 + * @return bool false when the clear rolled back and the stale tax data remains.
1082 + */
1083 + private static function clearOrderTax($orderId, $order)
1084 + {
1085 + try {
1086 + // No tax ⇒ no fee tax. When fee order items exist their subtotals are
1087 + // the net fee amounts — reset fee_total to net so a behavior-1 order
1088 + // whose fee_total had checkout fee tax rolled in doesn't keep it.
1089 + // Orders without fee items keep the stored fee_total untouched.
1090 + $feeSubtotals = OrderItem::query()
1091 + ->where('order_id', $orderId)
1092 + ->where('payment_type', 'fee')
1093 + ->pluck('subtotal')
1094 + ->toArray();
1095 + if (!empty($feeSubtotals)) {
1096 + $order->fee_total = (int) array_sum(array_map('intval', $feeSubtotals));
1097 + }
1098 +
1099 + $baseTotal = (int)$order->subtotal
1100 + + (int)$order->shipping_total
1101 + + (int)$order->fee_total
1102 + - (int)$order->coupon_discount_total
1103 + - (int)$order->manual_discount_total;
1104 +
1105 + $order->tax_behavior = 0;
1106 + $order->tax_total = 0;
1107 + $order->shipping_tax = 0;
1108 + $order->total_amount = $baseTotal;
1109 +
1110 + $DB = App::db();
1111 + $DB->beginTransaction();
1112 +
1113 + $order->save();
1114 + $order->updateMeta('exclusive_tax_total', 0);
1115 + $order->updateMeta('store_tax_behavior', 0);
1116 + $order->updateMeta('fee_tax', 0);
1117 + $order->deleteMeta('fee_tax_lines');
1118 +
1119 + $productItemIds = OrderItem::query()
1120 + ->where('order_id', $orderId)
1121 + ->whereNotIn('payment_type', ['fee'])
1122 + ->pluck('id')
1123 + ->toArray();
1124 + if (!empty($productItemIds)) {
1125 + OrderItem::query()->whereIn('id', $productItemIds)->update(['tax_amount' => 0]);
1126 + }
1127 +
1128 + // Strip stale tax_config from signup_fee line_meta so rate pills don't
1129 + // show a previous rate when tax is now zero.
1130 + $signupFeeItems = OrderItem::query()
1131 + ->where('order_id', $orderId)
1132 + ->where('payment_type', 'signup_fee')
1133 + ->get();
1134 + if (!$signupFeeItems->isEmpty()) {
1135 + $signupFeeUpdates = [];
1136 + foreach ($signupFeeItems as $signupFeeItem) {
1137 + $meta = $signupFeeItem->line_meta ?: [];
1138 + if (!is_array($meta)) {
1139 + $meta = json_decode($meta ?: '{}', true, 16) ?: [];
1140 + }
1141 + unset($meta['tax_config']);
1142 + $signupFeeUpdates[] = [
1143 + 'id' => $signupFeeItem->id,
1144 + 'line_meta' => json_encode($meta),
1145 + ];
1146 + }
1147 + OrderItem::query()->batchUpdate($signupFeeUpdates);
1148 + }
1149 +
1150 + // persistTaxRates with empty lines deletes all non-sentinel rate rows and
1151 + // upserts the zero-tax sentinel (tax_rate_id=0) — same guarantee checkout
1152 + // gives that every order keeps at least one fct_order_tax_rate row.
1153 + TaxModule::persistTaxRates($orderId, [], [
1154 + 'tax_country' => '',
1155 + 'source' => 'admin_order_edit',
1156 + 'note' => 'tax_cleared',
1157 + ], 0);
1158 +
1159 + $pendingTx = OrderTransaction::query()
1160 + ->where('order_id', $orderId)
1161 + ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE)
1162 + ->where('status', 'pending')
1163 + ->first();
1164 + if ($pendingTx) {
1165 + $pendingTx->total = $order->total_amount;
1166 + $pendingTx->save();
1167 + }
1168 +
1169 + // Paid orders: reflect the lowered total as paid / refund-owed state.
1170 + static::syncPaymentStatusWithTotals($order);
1171 +
1172 + $DB->commit();
1173 +
1174 + return true;
1175 + } catch (\Exception $e) {
1176 + if (isset($DB)) {
1177 + $DB->rollBack();
1178 + }
1179 + fluent_cart_warning_log(
1180 + 'Admin order tax clear failed on update',
1181 + get_class($e) . ': ' . wp_strip_all_tags($e->getMessage()),
1182 + ['module_name' => 'tax', 'module_id' => $orderId, 'log_type' => 'api']
1183 + );
1184 +
1185 + return false;
1186 + }
1187 + }
1188 +
1189 + private static function patchOrderItemTaxMeta(array $savedItems, array $lineItemsFromTax)
1190 + {
1191 + // Custom lines all carry post_id/object_id 0:0, so the composite key
1192 + // cannot tell two of them apart — match by order-item id first and only
1193 + // fall back to the key for tax results that did not carry one.
1194 + $savedById = [];
1195 + $savedByKey = [];
1196 + foreach ($savedItems as $item) {
1197 + $savedById[(int) $item['id']] = $item;
1198 + $key = $item['post_id'] . ':' . $item['object_id'];
1199 + $savedByKey[$key] = $item;
1200 + }
1201 +
1202 + $updateData = [];
1203 +
1204 + foreach ($lineItemsFromTax as $taxLineItem) {
1205 + $itemId = (int) Arr::get($taxLineItem, 'id', 0);
1206 +
1207 + if ($itemId && isset($savedById[$itemId])) {
1208 + $savedItem = $savedById[$itemId];
1209 + } else {
1210 + $key = Arr::get($taxLineItem, 'post_id', 0) . ':' . Arr::get($taxLineItem, 'object_id', 0);
1211 + if (!isset($savedByKey[$key])) {
1212 + continue;
1213 + }
1214 + $savedItem = $savedByKey[$key];
1215 + }
1216 +
1217 + $taxAmount = (int) Arr::get($taxLineItem, 'tax_amount', 0);
1218 + $taxLineMeta = Arr::get($taxLineItem, 'line_meta', []);
1219 + $existingMeta = isset($savedItem['line_meta']) ? $savedItem['line_meta'] : [];
1220 + if (!is_array($existingMeta)) {
1221 + $existingMeta = json_decode($existingMeta ?: '{}', true, 16) ?: [];
1222 + }
1223 + if (!empty($taxLineMeta)) {
1224 + $existingMeta = array_merge($existingMeta, $taxLineMeta);
1225 + }
1226 + $updateData[] = [
1227 + 'id' => $savedItem['id'],
1228 + 'tax_amount' => $taxAmount,
1229 + 'line_meta' => json_encode($existingMeta),
1230 + ];
1231 + }
1232 +
1233 + if (!empty($updateData)) {
1234 + OrderItem::query()->batchUpdate($updateData);
1235 + }
1236 + }
1237 +
1238 + private static function patchSignupFeeTaxMeta($orderId, array $lineItemsFromTax)
1239 + {
1240 + // Build a map of post_id:object_id -> tax data for items that have signup fee tax.
1241 + // Items absent from this map had their signup fee tax recalculated to zero.
1242 + $taxByKey = [];
1243 + foreach ($lineItemsFromTax as $taxLineItem) {
1244 + $signupFeeTax = (int) Arr::get($taxLineItem, 'signup_fee_tax', 0);
1245 + if (!$signupFeeTax) {
1246 + continue;
1247 + }
1248 + $key = Arr::get($taxLineItem, 'post_id', 0) . ':' . Arr::get($taxLineItem, 'object_id', 0);
1249 + $taxByKey[$key] = $taxLineItem;
1250 + }
1251 +
1252 + // Always fetch ALL signup_fee items for this order — not only those with non-zero
1253 + // tax — so items that became untaxed after recalculation get their tax_amount cleared.
1254 + $signupFeeItems = OrderItem::query()
1255 + ->where('order_id', $orderId)
1256 + ->where('payment_type', 'signup_fee')
1257 + ->get();
1258 +
1259 + if ($signupFeeItems->isEmpty()) {
1260 + return;
1261 + }
1262 +
1263 + $updateData = [];
1264 + foreach ($signupFeeItems as $signupFeeItem) {
1265 + $key = $signupFeeItem->post_id . ':' . $signupFeeItem->object_id;
1266 + $taxLineItem = isset($taxByKey[$key]) ? $taxByKey[$key] : null;
1267 +
1268 + $signupFeeTax = $taxLineItem ? (int) Arr::get($taxLineItem, 'signup_fee_tax', 0) : 0;
1269 + $existingMeta = $signupFeeItem->line_meta ?: [];
1270 + if (!is_array($existingMeta)) {
1271 + $existingMeta = json_decode($existingMeta ?: '{}', true, 16) ?: [];
1272 + }
1273 +
1274 + if ($taxLineItem) {
1275 + $signupFeeTaxConfig = Arr::get($taxLineItem, 'signup_fee_tax_config', []);
1276 + if ($signupFeeTaxConfig) {
1277 + $existingMeta['tax_config'] = $signupFeeTaxConfig;
1278 + } else {
1279 + unset($existingMeta['tax_config']);
1280 + }
1281 + } else {
1282 + unset($existingMeta['tax_config']);
1283 + }
1284 +
1285 + $updateData[] = [
1286 + 'id' => $signupFeeItem->id,
1287 + 'tax_amount' => $signupFeeTax,
1288 + 'line_meta' => json_encode($existingMeta),
1289 + ];
1290 + }
1291 +
1292 + if (!empty($updateData)) {
1293 + OrderItem::query()->batchUpdate($updateData);
1294 + }
1295 + }
1296 +
1297 + /**
1298 + * Patch subscription tax fields after admin order tax calculation.
1299 + *
1300 + * AdminOrderProcessor creates the subscription row before tax runs, with
1301 + * recurring_tax_total = 0 and recurring_total at the untaxed recurring price.
1302 + * Renewals read recurring_tax_total (and the parent item's
1303 + * other_info.recurring_tax for inclusive items) — without this patch every
1304 + * renewal of an admin-created subscription invoices zero tax.
1305 + *
1306 + * Mirrors CheckoutProcessor::prepareSubscriptionData(): the recurring tax is
1307 + * folded into recurring_total only when additive (exclusive store, or mixed
1308 + * cart with this line exclusive).
1309 + */
1310 + private static function patchSubscriptionTax($order, array $lineItemsFromTax, $taxBehavior)
1311 + {
1312 + $subscription = Subscription::query()->where('parent_order_id', $order->id)->first();
1313 + if (!$subscription) {
1314 + return;
1315 + }
1316 +
1317 + $subscriptionItem = OrderItem::query()
1318 + ->where('order_id', $order->id)
1319 + ->where('payment_type', 'subscription')
1320 + ->first();
1321 + if (!$subscriptionItem) {
1322 + return;
1323 + }
1324 +
1325 + $taxLine = null;
1326 + foreach ($lineItemsFromTax as $lineItem) {
1327 + if ((int) Arr::get($lineItem, 'post_id', 0) === (int) $subscriptionItem->post_id
1328 + && (int) Arr::get($lineItem, 'object_id', 0) === (int) $subscriptionItem->object_id
1329 + ) {
1330 + $taxLine = $lineItem;
1331 + break;
1332 + }
1333 + }
1334 + if ($taxLine === null) {
1335 + return;
1336 + }
1337 +
1338 + $recurringTax = (int) Arr::get($taxLine, 'recurring_tax', 0);
1339 + $signupFeeTax = (int) Arr::get($taxLine, 'signup_fee_tax', 0);
1340 +
1341 + // Renewals fall back to the parent item's other_info for inclusive items;
1342 + // checkout writes both keys on the cart line, mirror that here.
1343 + $otherInfo = $subscriptionItem->other_info ?: [];
1344 + if (!is_array($otherInfo)) {
1345 + $otherInfo = json_decode($otherInfo ?: '{}', true, 16) ?: [];
1346 + }
1347 + $otherInfo['recurring_tax'] = $recurringTax;
1348 + if ($signupFeeTax) {
1349 + $otherInfo['signup_fee_tax'] = $signupFeeTax;
1350 + }
1351 + $subscriptionItem->other_info = $otherInfo;
1352 + $subscriptionItem->save();
1353 +
1354 + $lineInclusive = (bool) Arr::get($taxLine, 'line_meta.tax_config.inclusive', false);
1355 + $isAdditive = (int) $taxBehavior === 1 || ((int) $taxBehavior === 3 && !$lineInclusive);
1356 +
1357 + // Runs once, at order creation, while recurring_tax_total is still the 0 that
1358 + // AdminOrderProcessor wrote. Guard against double-folding tax into
1359 + // recurring_total if a future caller ever invokes this on a patched row.
1360 + if ((int) $subscription->recurring_tax_total !== 0) {
1361 + return;
1362 + }
1363 +
1364 + $subscription->recurring_tax_total = $recurringTax;
1365 + if ($isAdditive && $recurringTax > 0) {
1366 + $subscription->recurring_total = (int) $subscription->recurring_total + $recurringTax;
1367 + }
1368 + $subscription->save();
1369 + }
1370 +
1371 + /**
1372 + * Whether the submitted coupon and item discounts match the calculated ones, within a cent of rounding.
1373 + *
1374 + * @param array $submittedItems
1375 + * @param array $submittedCoupons Applied-coupon map keyed by coupon code.
1376 + * @param array $couponCheck Result of CouponResource::validateOrderCoupons().
1377 + * @return bool
1378 + */
1379 + private static function couponDiscountsMatch(array $submittedItems, array $submittedCoupons, array $couponCheck): bool
1380 + {
1381 + foreach ($couponCheck['applied_coupons'] as $code => $calculated) {
1382 + $submitted = isset($submittedCoupons[$code]['discount']) ? (float) $submittedCoupons[$code]['discount'] : 0;
1383 + if (abs($submitted - (float) $calculated['discount']) > 1) {
1384 + return false;
1385 + }
1386 + }
1387 +
1388 + foreach ($couponCheck['items'] as $index => $calculatedItem) {
1389 + $submitted = (float) Arr::get($submittedItems, $index . '.discount_total', 0);
1390 + if (abs($submitted - (float) Arr::get($calculatedItem, 'discount_total', 0)) > 1) {
1391 + return false;
1392 + }
1393 + }
1394 +
1395 + return true;
1396 + }
1397 +
294 1398 private static function distributeManualDiscount(&$items, $manualDiscountTotal)
295 1399 {
296 1400 $totalSubtotal = array_reduce($items, function ($carry, $item) {
297 1401 return $carry + ((int)Arr::get($item, 'unit_price', 0) * (int)Arr::get($item, 'quantity', 1));
@@ -347,8 +1451,9 @@
347 1451 ]);
348 1452 }
349 1453
350 1454 if (!empty($shipping)) {
1455 + $shipping = is_array($shipping) ? static::resolveShippingTitle($shipping) : $shipping;
351 1456 static::addOrUpdateOrderMeta([
352 1457 'order_id' => $orderId,
353 1458 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
354 1459 'meta_key' => 'order_shipping',
@@ -402,18 +1507,20 @@
402 1507 }
403 1508
404 1509 }
405 1510
406 - private static function createOrderAddresses($orderId, $data)
1511 + private static function createOrderAddresses($orderId, $data, $customerId = 0)
407 1512 {
1513 + $billingAddressId = (int) Arr::get($data, 'billing_address_id', 0);
1514 + $shippingAddressId = (int) Arr::get($data, 'shipping_address_id', 0);
408 1515
409 - $billingAddress = CustomerAddresses::query()->find(
410 - Arr::get($data, 'billing_address_id')
411 - );
1516 + $billingAddress = $billingAddressId > 0
1517 + ? CustomerAddresses::query()->where('customer_id', $customerId)->find($billingAddressId)
1518 + : null;
412 1519
413 - $shippingAddress = CustomerAddresses::query()->find(
414 - Arr::get($data, 'shipping_address_id')
415 - );
1520 + $shippingAddress = $shippingAddressId > 0
1521 + ? CustomerAddresses::query()->where('customer_id', $customerId)->find($shippingAddressId)
1522 + : null;
416 1523
417 1524 if (!empty($billingAddress)) {
418 1525 static::createOrderAddress($billingAddress->toArray(), $orderId);
419 1526 }
@@ -526,14 +1633,36 @@
526 1633 __('Your order status is marked as %s and not eligible for any further modifications at this time.', 'fluent-cart'), $order->status)]
527 1634 ]);
528 1635 }
529 1636
530 - $orderData = $data['orderData'];
1637 + // Server-authoritative columns (tax_total, shipping_tax, tax_behavior,
1638 + // discount_tax, total_paid, total_refund, item tax_amount) must never
1639 + // come from the client — see stripClientTaxFields().
1640 + $orderData = static::stripClientTaxFields($data['orderData']);
531 1641 $deletedItems = $data['deletedItems'];
532 1642 $appliedCoupons = Arr::get($orderData, 'applied_coupon');
533 1643 $discount = $data['discount'];
534 1644 $shipping = $data['shipping'];
535 1645
1646 + if (!empty($appliedCoupons)) {
1647 + $submittedItems = Arr::except((array) Arr::get($orderData, 'order_items', []), ['*']);
1648 + $couponCheck = CouponResource::validateOrderCoupons(
1649 + $submittedItems,
1650 + (array) $appliedCoupons,
1651 + $order->customer ? $order->customer->email : ''
1652 + );
1653 + if (is_wp_error($couponCheck)) {
1654 + return $couponCheck;
1655 + }
1656 + // Update saves the submitted items and totals, so they must already carry the calculated discounts.
1657 + if (!static::couponDiscountsMatch($submittedItems, (array) $appliedCoupons, $couponCheck)) {
1658 + return static::makeErrorResponse([
1659 + ['code' => 'coupon_discount_changed', 'message' => __('Coupon discounts have changed. Please re-apply the coupons and save again.', 'fluent-cart')]
1660 + ], 422);
1661 + }
1662 + $appliedCoupons = $couponCheck['applied_coupons'];
1663 + }
1664 +
536 1665 $orderId = $order->id;
537 1666
538 1667 /**
539 1668 * First delete the deleted items
@@ -572,8 +1701,9 @@
572 1701 ]);
573 1702 }
574 1703 }
575 1704 if (!empty($shipping)) {
1705 + $shipping = is_array($shipping) ? static::resolveShippingTitle($shipping) : $shipping;
576 1706 static::addOrUpdateOrderMeta([
577 1707 'order_id' => $orderId,
578 1708 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
579 1709 'meta_key' => 'order_shipping',
@@ -587,8 +1717,9 @@
587 1717
588 1718 if ($isUpdatedOrderItems) {
589 1719 unset($orderData['order_items']);
590 1720 unset($orderData['customer']);
1721 + unset($orderData['tax_lines']);
591 1722
592 1723
593 1724 $orderData['currency'] = Helper::shopConfig('currency');
594 1725
@@ -626,8 +1757,12 @@
626 1757 // $getOrderNoActionableStatuses = ['unshippable'];
627 1758 // if(in_array($newOrder->shipping_status, $getOrderNoActionableStatuses)) {
628 1759 // $newOrder->shipping_status = OrderMetaResource::find($orderId, ['meta_key' => 'shipping_previous_status']);
629 1760 // }
1761 + static::reapplyTaxAfterUpdate($orderId, $newOrder);
1762 +
1763 + $newOrder = $newOrder->refresh();
1764 +
630 1765 (new OrderUpdated($newOrder, $oldOrder))->dispatch();
631 1766
632 1767 $oldOrderItems = json_decode(json_encode(Arr::get($oldOrder, 'order_items', [])), true);
633 1768 $newOrderItems = json_decode(json_encode(Arr::get($newOrder, 'order_items', [])), true);
@@ -651,8 +1786,63 @@
651 1786 ['code' => 400, 'message' => __('Order update failed.', 'fluent-cart')]
652 1787 ]);
653 1788 }
654 1789
1790 + /**
1791 + * Strip server-authoritative columns from a client-supplied order payload
1792 + * before it is persisted by update().
1793 + *
1794 + * The admin edit screen sends the whole order object back — including
1795 + * tax_total, shipping_tax, tax_behavior, discount_tax and per-item
1796 + * tax_amount. For normal orders reapplyTaxAfterUpdate() recalculates and
1797 + * overwrites these server-side right after the save, but subscription and
1798 + * refund-type orders skip that recalc — whatever the client sent would
1799 + * become final (stale values from a race, or forged values from a
1800 + * tampered request). These columns must therefore never be
1801 + * client-writable on this path: the existing DB values persist unless
1802 + * the server-side recalc changes them.
1803 + *
1804 + * total_paid / total_refund only move via payment & refund flows. The
1805 + * controller already drops them (OrderRequest::sanitize() is a whitelist
1806 + * and getSafe() only returns whitelisted keys), so stripping them here is
1807 + * defense in depth for direct OrderResource::update() callers.
1808 + *
1809 + * total_amount is intentionally NOT stripped: it is client-computed for
1810 + * legitimate item edits on subscription/refund orders, and for normal
1811 + * orders reapplyTaxAfterUpdate() recomputes it from scratch anyway.
1812 + *
1813 + * Removing the per-item tax_amount key (rather than zeroing it) makes
1814 + * OrderItemResource::updateOrInsertOrderItems() leave the existing DB
1815 + * value untouched on updated rows; inserted rows fall back to the column
1816 + * default (0) and normal orders get patched by patchOrderItemTaxMeta()
1817 + * after the recalc.
1818 + *
1819 + * @param array $orderData The 'orderData' payload consumed by update().
1820 + * @return array
1821 + */
1822 + private static function stripClientTaxFields($orderData)
1823 + {
1824 + $orderData = Arr::except((array) $orderData, [
1825 + 'tax_total',
1826 + 'shipping_tax',
1827 + 'tax_behavior',
1828 + 'discount_tax',
1829 + 'total_paid',
1830 + 'total_refund',
1831 + ]);
1832 +
1833 + $items = Arr::get($orderData, 'order_items');
1834 + if (is_array($items)) {
1835 + foreach ($items as $itemIndex => $item) {
1836 + if (is_array($item)) {
1837 + unset($orderData['order_items'][$itemIndex]['tax_amount']);
1838 + }
1839 + }
1840 + }
1841 +
1842 + return $orderData;
1843 + }
1844 +
655 1845 public static function updateOrderAddressId($data, Order $order)
656 1846 {
657 1847
658 1848 $addressType = Arr::get($data, 'address_type') ?? 'billing';
@@ -663,12 +1853,16 @@
663 1853 if (!empty($address)) {
664 1854 $order->load($addressRelation);
665 1855 $currentAddress = $order->{$addressRelation};
666 1856 if (empty($currentAddress)) {
667 - return static::createOrderAddress($address->toArray(), $order->id);
1857 + $result = static::createOrderAddress($address->toArray(), $order->id);
668 1858 } else {
669 - return static::mergeOrderAddress($currentAddress, $address->toArray());
1859 + $result = static::mergeOrderAddress($currentAddress, $address->toArray());
670 1860 }
1861 + if (!$order->isSubscription() && $order->type !== 'refund') {
1862 + static::reapplyTaxAfterUpdate($order->id, $order->refresh());
1863 + }
1864 + return $result;
671 1865 }
672 1866 }
673 1867
674 1868 public static function updateOrderAddress($data)
@@ -684,10 +1878,17 @@
684 1878
685 1879 $updateData = Arr::only($data, ['name', 'first_name', 'last_name', 'full_name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country']);
686 1880 // sanitize the data before updating
687 1881 $updateData = array_map('sanitize_text_field', $updateData);
688 - return $orderAddress->update($updateData);
1882 + $result = $orderAddress->update($updateData);
689 1883
1884 + $reloadedOrder = Order::find($orderId);
1885 + if ($reloadedOrder && !$reloadedOrder->isSubscription() && $reloadedOrder->type !== 'refund') {
1886 + static::reapplyTaxAfterUpdate($orderId, $reloadedOrder);
1887 + }
1888 +
1889 + return $result;
1890 +
690 1891 }
691 1892
692 1893 /**
693 1894 * Delete an order and associated data by ID.Including order meta, order items, transactions,
@@ -812,11 +2013,11 @@
812 2013 ->with(
813 2014 [
814 2015 'parentOrder' => function ($query) {
815 2016 return $query->select('id')
816 - ->with('subscriptions');
2017 + ->with('subscriptions.product');
817 2018 },
818 - 'subscriptions',
2019 + 'subscriptions.product',
819 2020 'activities.user',
820 2021 'labels',
821 2022 'customer',
822 2023 'children' => function ($query) {
@@ -822,11 +2023,15 @@
822 2023 'children' => function ($query) {
823 2024 return $query->select('id', 'parent_id', 'created_at');
824 2025 },
825 2026 //'order_items.variants.product_detail',
2027 + 'order_items' => function ($query) {
2028 + $query->addAppends(['coupon_discount']);
2029 + },
826 2030 'order_items.variants.media',
827 2031 'transactions',
828 2032 'order_addresses',
2033 + 'orderTaxRates.tax_rate',
829 2034 'billing_address',
830 2035 'shipping_address',
831 2036 'appliedCoupons' => function ($query) {
832 2037 $query->select('*');
@@ -831,9 +2036,10 @@
831 2036 'appliedCoupons' => function ($query) {
832 2037 $query->select('*');
833 2038 }
834 2039 ]
835 - );
2040 + )
2041 + ->addAppends(['business_info', 'customer_tax_number', 'is_b2b_order', 'display_tax_lines', 'display_shipping_tax_lines', 'is_reverse_charge_tax_order', 'tax_summary']);
836 2042 }
837 2043 );
838 2044
839 2045 if (empty($orders[0])) {
@@ -859,18 +2065,42 @@
859 2065 $order = $orders[0];
860 2066 $selectedLabels = Collection::make($order['labels'])->pluck('label_id');
861 2067 $order['custom_checkout_url'] = PaymentHelper::getCustomPaymentLink(Arr::get($order, 'uuid'));
862 2068
2069 + $orderModel = Order::find($id);
2070 + $rcMode = $orderModel ? $orderModel->getOrderRcMode() : 'fixed';
2071 +
2072 + //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
2073 + $shippingMeta = OrderMetaResource::find($order['id'], ['meta_key' => 'order_shipping']);
2074 +
2075 + $orderConfig = is_array($order['config']) ? $order['config'] : (array)json_decode((string)($order['config'] ?? ''), true);
2076 + $methodId = (int)Arr::get($orderConfig, 'shipping_method_id', 0);
2077 + $methodTitle = (string)Arr::get($orderConfig, 'shipping_method_title', '');
2078 +
2079 + if (!$methodId && is_array($shippingMeta) && isset($shippingMeta['id'], $shippingMeta['title'])) {
2080 + $methodId = (int)$shippingMeta['id'];
2081 + $methodTitle = (string)$shippingMeta['title'];
2082 + }
2083 +
2084 + // Gate on the title alone. Live-rate carriers use non-numeric method
2085 + // ids (e.g. "carrier:shippo:usps_priority") which (int) casts to 0,
2086 + // so requiring a truthy id silently hid the method name.
2087 + $checkoutShipping = $methodTitle ? [
2088 + 'method_id' => $methodId,
2089 + 'method_title' => $methodTitle,
2090 + 'shipping_total' => (int)Arr::get($order, 'shipping_total', 0),
2091 + ] : null;
2092 +
863 2093 $data = [
864 - 'order' => $order,
2094 + 'order' => $order,
865 2095 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
866 - 'discount_meta' => OrderMetaResource::find($order['id'], ['meta_key' => 'order_discount']),
867 - //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
868 - 'shipping_meta' => OrderMetaResource::find($order['id'], ['meta_key' => 'order_shipping']),
869 - 'order_settings' => [
870 - // 'has_vendor_refund' => PaymentMethodFactory::instance()->hasVendorRefund($order->payment_method)
2096 + 'discount_meta' => OrderMetaResource::find($order['id'], ['meta_key' => 'order_discount']),
2097 + 'shipping_meta' => $shippingMeta,
2098 + 'checkout_shipping' => $checkoutShipping,
2099 + 'order_settings' => [
2100 + 'reverse_charge_price_mode' => $rcMode,
871 2101 ],
872 - 'selected_labels' => $selectedLabels,
2102 + 'selected_labels' => $selectedLabels,
873 2103 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
874 2104 'tax_id' => OrderMetaResource::find($order['id'], ['meta_key' => 'tax_id'])
875 2105 ];
876 2106 }
@@ -908,8 +2138,11 @@
908 2138 * ]
909 2139 * ]
910 2140 *
911 2141 */
2142 + /**
2143 + * @deprecated since v1.4. Use OverviewReportController::getOverview() via GET reports/overview instead.
2144 + */
912 2145 public static function reportOverview($params = [])
913 2146 {
914 2147 return static::getQuery()->when(
915 2148 $params,
@@ -918,9 +2151,9 @@
918 2151 }
919 2152 )
920 2153 ->selectRaw('sum(total_amount) as total_sales')
921 2154 ->selectRaw('sum(total_amount - manual_discount_total - shipping_total - tax_total) as net_sales')
922 - ->selectRaw('sum(discount_total) as total_discounts')
2155 + ->selectRaw('sum(manual_discount_total + coupon_discount_total) as total_discounts')
923 2156 ->selectRaw('sum(shipping_total) as total_shipping_tax')
924 2157 ->selectRaw('avg(total_amount) as average_order_value')
925 2158 ->selectRaw('count(*) as customer_order_count')
926 2159 ->get()->first();
@@ -1019,8 +2252,19 @@
1019 2252 $order = static::getQuery()->with("order_items.variants.product_detail")->where('id', $orderId)->first();
1020 2253
1021 2254 $action = Arr::get($params, 'action');
1022 2255
2256 + // This endpoint's contract is order/shipping status only — payment-status
2257 + // transitions flow through their dedicated surfaces (mark-as-paid,
2258 + // transaction status updates, refunds, gateway webhooks) so money state
2259 + // stays consistent with transactions. Rejecting unknown actions up front
2260 + // also keeps them out of the order_status fallback below.
2261 + if (!in_array($action, ['change_order_status', 'change_shipping_status'], true)) {
2262 + return static::makeErrorResponse([
2263 + ['code' => 400, 'message' => __('Unsupported action — this endpoint changes order or shipping status only.', 'fluent-cart')]
2264 + ], 400);
2265 + }
2266 +
1023 2267 $changeType = $action === 'change_shipping_status' ? 'shipping_status' : 'order_status';
1024 2268 $actionActivity = [];
1025 2269
1026 2270 if ($action === 'change_shipping_status') {
@@ -1308,8 +2552,12 @@
1308 2552 foreach ($keysToInclude as $key) {
1309 2553 $address->{$key} = $addressData[$key];
1310 2554 }
1311 2555
2556 + if (array_key_exists('meta', $addressData)) {
2557 + $address->meta = $addressData['meta'];
2558 + }
2559 +
1312 2560 if ($address->save()) {
1313 2561 return $address;
1314 2562 }
1315 2563 return static::makeErrorResponse([
@@ -1318,9 +2566,9 @@
1318 2566 }
1319 2567
1320 2568 private static function createOrderAddress(array $address, $orderId)
1321 2569 {
1322 - $keysToInclude = ['order_id', 'type', 'name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country'];
2570 + $keysToInclude = ['order_id', 'type', 'name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'meta'];
1323 2571 $address = Arr::only($address, $keysToInclude);
1324 2572 $address['order_id'] = $orderId;
1325 2573
1326 2574 if (!empty($address)) {
@@ -1330,7 +2578,16 @@
1330 2578
1331 2579 public static function getOrderByHash($orderHash)
1332 2580 {
1333 2581 return (new Orders())->getByHash($orderHash);
2582 + }
2583 +
2584 + private static function resolveShippingTitle(array $shipping): array
2585 + {
2586 + if (isset($shipping['id']) && empty($shipping['title'])) {
2587 + $sm = ShippingMethod::find((int)$shipping['id']);
2588 + $shipping['title'] = $sm ? $sm->title : '';
2589 + }
2590 + return $shipping;
1334 2591 }
1335 2592
1336 2593 }