PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | api/Resource/OrderResource.php +1231 -27 1.3.20 → 1.6.5 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');
@@ -252,9 +290,15 @@
252 290 $adminOrderProcessor = new AdminOrderProcessor($items, [
253 291 'customer_id' => $customer->id,
254 292 'payment_method' => $paymentMethod,
255 293 'applied_coupons' => Arr::get($data, 'applied_coupon', []),
256 - 'shipping_total' => Arr::get($data, 'shipping_total', []),
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'))),
257 301 'billing_address' => Arr::get($customer, 'billing_address', []),
258 302 'shipping_address' => Arr::get($customer, 'shipping_address', []),
259 303 'user_tz' => Arr::get($data, 'user_tz', ''),
260 304 ]);
@@ -268,14 +312,23 @@
268 312 static::addOrderMeta($order->id, $discount, $shipping, $newLabelIds);
269 313
270 314 static::commitEvents($order);
271 315
272 - static::createOrderAddresses($order->id, $data);
316 + static::createOrderAddresses($order->id, $data, $order->customer_id);
273 317
274 318 static::triggerStockChangedEvents($order);
275 319
320 + // Calculate and persist tax for admin-created orders
321 + static::applyAdminOrderTax($order, $items, $customer, $data);
322 +
276 323 if ($gateway = App::gateway($paymentMethod)) {
277 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 +
278 331 $gateway->makePaymentFromPaymentInstance($paymentInstance);
279 332 }
280 333
281 334 return $order;
@@ -290,8 +343,1025 @@
290 343 ]);
291 344 }
292 345 }
293 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 +
294 1364 private static function distributeManualDiscount(&$items, $manualDiscountTotal)
295 1365 {
296 1366 $totalSubtotal = array_reduce($items, function ($carry, $item) {
297 1367 return $carry + ((int)Arr::get($item, 'unit_price', 0) * (int)Arr::get($item, 'quantity', 1));
@@ -347,8 +1417,9 @@
347 1417 ]);
348 1418 }
349 1419
350 1420 if (!empty($shipping)) {
1421 + $shipping = is_array($shipping) ? static::resolveShippingTitle($shipping) : $shipping;
351 1422 static::addOrUpdateOrderMeta([
352 1423 'order_id' => $orderId,
353 1424 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
354 1425 'meta_key' => 'order_shipping',
@@ -402,18 +1473,20 @@
402 1473 }
403 1474
404 1475 }
405 1476
406 - private static function createOrderAddresses($orderId, $data)
1477 + private static function createOrderAddresses($orderId, $data, $customerId = 0)
407 1478 {
1479 + $billingAddressId = (int) Arr::get($data, 'billing_address_id', 0);
1480 + $shippingAddressId = (int) Arr::get($data, 'shipping_address_id', 0);
408 1481
409 - $billingAddress = CustomerAddresses::query()->find(
410 - Arr::get($data, 'billing_address_id')
411 - );
1482 + $billingAddress = $billingAddressId > 0
1483 + ? CustomerAddresses::query()->where('customer_id', $customerId)->find($billingAddressId)
1484 + : null;
412 1485
413 - $shippingAddress = CustomerAddresses::query()->find(
414 - Arr::get($data, 'shipping_address_id')
415 - );
1486 + $shippingAddress = $shippingAddressId > 0
1487 + ? CustomerAddresses::query()->where('customer_id', $customerId)->find($shippingAddressId)
1488 + : null;
416 1489
417 1490 if (!empty($billingAddress)) {
418 1491 static::createOrderAddress($billingAddress->toArray(), $orderId);
419 1492 }
@@ -526,9 +1599,12 @@
526 1599 __('Your order status is marked as %s and not eligible for any further modifications at this time.', 'fluent-cart'), $order->status)]
527 1600 ]);
528 1601 }
529 1602
530 - $orderData = $data['orderData'];
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']);
531 1607 $deletedItems = $data['deletedItems'];
532 1608 $appliedCoupons = Arr::get($orderData, 'applied_coupon');
533 1609 $discount = $data['discount'];
534 1610 $shipping = $data['shipping'];
@@ -572,8 +1648,9 @@
572 1648 ]);
573 1649 }
574 1650 }
575 1651 if (!empty($shipping)) {
1652 + $shipping = is_array($shipping) ? static::resolveShippingTitle($shipping) : $shipping;
576 1653 static::addOrUpdateOrderMeta([
577 1654 'order_id' => $orderId,
578 1655 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
579 1656 'meta_key' => 'order_shipping',
@@ -587,8 +1664,9 @@
587 1664
588 1665 if ($isUpdatedOrderItems) {
589 1666 unset($orderData['order_items']);
590 1667 unset($orderData['customer']);
1668 + unset($orderData['tax_lines']);
591 1669
592 1670
593 1671 $orderData['currency'] = Helper::shopConfig('currency');
594 1672
@@ -626,8 +1704,12 @@
626 1704 // $getOrderNoActionableStatuses = ['unshippable'];
627 1705 // if(in_array($newOrder->shipping_status, $getOrderNoActionableStatuses)) {
628 1706 // $newOrder->shipping_status = OrderMetaResource::find($orderId, ['meta_key' => 'shipping_previous_status']);
629 1707 // }
1708 + static::reapplyTaxAfterUpdate($orderId, $newOrder);
1709 +
1710 + $newOrder = $newOrder->refresh();
1711 +
630 1712 (new OrderUpdated($newOrder, $oldOrder))->dispatch();
631 1713
632 1714 $oldOrderItems = json_decode(json_encode(Arr::get($oldOrder, 'order_items', [])), true);
633 1715 $newOrderItems = json_decode(json_encode(Arr::get($newOrder, 'order_items', [])), true);
@@ -651,8 +1733,63 @@
651 1733 ['code' => 400, 'message' => __('Order update failed.', 'fluent-cart')]
652 1734 ]);
653 1735 }
654 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 +
655 1792 public static function updateOrderAddressId($data, Order $order)
656 1793 {
657 1794
658 1795 $addressType = Arr::get($data, 'address_type') ?? 'billing';
@@ -663,12 +1800,16 @@
663 1800 if (!empty($address)) {
664 1801 $order->load($addressRelation);
665 1802 $currentAddress = $order->{$addressRelation};
666 1803 if (empty($currentAddress)) {
667 - return static::createOrderAddress($address->toArray(), $order->id);
1804 + $result = static::createOrderAddress($address->toArray(), $order->id);
668 1805 } else {
669 - return static::mergeOrderAddress($currentAddress, $address->toArray());
1806 + $result = static::mergeOrderAddress($currentAddress, $address->toArray());
670 1807 }
1808 + if (!$order->isSubscription() && $order->type !== 'refund') {
1809 + static::reapplyTaxAfterUpdate($order->id, $order->refresh());
1810 + }
1811 + return $result;
671 1812 }
672 1813 }
673 1814
674 1815 public static function updateOrderAddress($data)
@@ -684,10 +1825,17 @@
684 1825
685 1826 $updateData = Arr::only($data, ['name', 'first_name', 'last_name', 'full_name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country']);
686 1827 // sanitize the data before updating
687 1828 $updateData = array_map('sanitize_text_field', $updateData);
688 - return $orderAddress->update($updateData);
1829 + $result = $orderAddress->update($updateData);
689 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 +
690 1838 }
691 1839
692 1840 /**
693 1841 * Delete an order and associated data by ID.Including order meta, order items, transactions,
@@ -812,11 +1960,11 @@
812 1960 ->with(
813 1961 [
814 1962 'parentOrder' => function ($query) {
815 1963 return $query->select('id')
816 - ->with('subscriptions');
1964 + ->with('subscriptions.product');
817 1965 },
818 - 'subscriptions',
1966 + 'subscriptions.product',
819 1967 'activities.user',
820 1968 'labels',
821 1969 'customer',
822 1970 'children' => function ($query) {
@@ -822,11 +1970,15 @@
822 1970 'children' => function ($query) {
823 1971 return $query->select('id', 'parent_id', 'created_at');
824 1972 },
825 1973 //'order_items.variants.product_detail',
1974 + 'order_items' => function ($query) {
1975 + $query->addAppends(['coupon_discount']);
1976 + },
826 1977 'order_items.variants.media',
827 1978 'transactions',
828 1979 'order_addresses',
1980 + 'orderTaxRates.tax_rate',
829 1981 'billing_address',
830 1982 'shipping_address',
831 1983 'appliedCoupons' => function ($query) {
832 1984 $query->select('*');
@@ -831,9 +1983,10 @@
831 1983 'appliedCoupons' => function ($query) {
832 1984 $query->select('*');
833 1985 }
834 1986 ]
835 - );
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']);
836 1989 }
837 1990 );
838 1991
839 1992 if (empty($orders[0])) {
@@ -859,18 +2012,42 @@
859 2012 $order = $orders[0];
860 2013 $selectedLabels = Collection::make($order['labels'])->pluck('label_id');
861 2014 $order['custom_checkout_url'] = PaymentHelper::getCustomPaymentLink(Arr::get($order, 'uuid'));
862 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 +
863 2040 $data = [
864 - 'order' => $order,
2041 + 'order' => $order,
865 2042 //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)
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,
871 2048 ],
872 - 'selected_labels' => $selectedLabels,
2049 + 'selected_labels' => $selectedLabels,
873 2050 //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
874 2051 'tax_id' => OrderMetaResource::find($order['id'], ['meta_key' => 'tax_id'])
875 2052 ];
876 2053 }
@@ -908,8 +2085,11 @@
908 2085 * ]
909 2086 * ]
910 2087 *
911 2088 */
2089 + /**
2090 + * @deprecated since v1.4. Use OverviewReportController::getOverview() via GET reports/overview instead.
2091 + */
912 2092 public static function reportOverview($params = [])
913 2093 {
914 2094 return static::getQuery()->when(
915 2095 $params,
@@ -918,9 +2098,9 @@
918 2098 }
919 2099 )
920 2100 ->selectRaw('sum(total_amount) as total_sales')
921 2101 ->selectRaw('sum(total_amount - manual_discount_total - shipping_total - tax_total) as net_sales')
922 - ->selectRaw('sum(discount_total) as total_discounts')
2102 + ->selectRaw('sum(manual_discount_total + coupon_discount_total) as total_discounts')
923 2103 ->selectRaw('sum(shipping_total) as total_shipping_tax')
924 2104 ->selectRaw('avg(total_amount) as average_order_value')
925 2105 ->selectRaw('count(*) as customer_order_count')
926 2106 ->get()->first();
@@ -1019,8 +2199,19 @@
1019 2199 $order = static::getQuery()->with("order_items.variants.product_detail")->where('id', $orderId)->first();
1020 2200
1021 2201 $action = Arr::get($params, 'action');
1022 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 +
1023 2214 $changeType = $action === 'change_shipping_status' ? 'shipping_status' : 'order_status';
1024 2215 $actionActivity = [];
1025 2216
1026 2217 if ($action === 'change_shipping_status') {
@@ -1308,8 +2499,12 @@
1308 2499 foreach ($keysToInclude as $key) {
1309 2500 $address->{$key} = $addressData[$key];
1310 2501 }
1311 2502
2503 + if (array_key_exists('meta', $addressData)) {
2504 + $address->meta = $addressData['meta'];
2505 + }
2506 +
1312 2507 if ($address->save()) {
1313 2508 return $address;
1314 2509 }
1315 2510 return static::makeErrorResponse([
@@ -1318,9 +2513,9 @@
1318 2513 }
1319 2514
1320 2515 private static function createOrderAddress(array $address, $orderId)
1321 2516 {
1322 - $keysToInclude = ['order_id', 'type', 'name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country'];
2517 + $keysToInclude = ['order_id', 'type', 'name', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'meta'];
1323 2518 $address = Arr::only($address, $keysToInclude);
1324 2519 $address['order_id'] = $orderId;
1325 2520
1326 2521 if (!empty($address)) {
@@ -1330,7 +2525,16 @@
1330 2525
1331 2526 public static function getOrderByHash($orderHash)
1332 2527 {
1333 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;
1334 2538 }
1335 2539
1336 2540 }