PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
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 1.3.20 All 50 releases
← All changes | app/Services/Renderer/Receipt/TaxSummaryHelper.php +243 -8 1.5.2 → 1.7.0 View file →
@@ -1,8 +1,9 @@
1 1 <?php
2 2
3 3 namespace FluentCart\App\Services\Renderer\Receipt;
4 4
5 +use FluentCart\App\Helpers\Helper;
5 6 use FluentCart\App\Models\Order;
6 7 use FluentCart\Framework\Support\Arr;
7 8
8 9 class TaxSummaryHelper
@@ -162,8 +163,10 @@
162 163 'taxRateLines' => $taxRateLines,
163 164 'shippingTaxLines' => $shippingTaxLines,
164 165 'foldedRateLines' => [],
165 166 'includedInPrices' => 0,
167 + 'displayMode' => self::getTaxDisplayMode(),
168 + 'simpleLine' => null,
166 169 ];
167 170 }
168 171
169 172 $shouldRender = apply_filters('fluent_cart/tax_summary_should_render', true, $order);
@@ -171,8 +174,9 @@
171 174 $reversedTaxTotal = 0;
172 175 $reversedShippingTax = 0;
173 176 $rcPriceMode = '';
174 177 $rcShippingAdjustment = 0;
178 + $rcTotalAdjustment = 0;
175 179 $shippingNetStored = false;
176 180 if ($isReverseCharge) {
177 181 $primaryRate = $order->orderTaxRates ? $order->orderTaxRates->first() : null;
178 182 if ($primaryRate) {
@@ -180,8 +184,18 @@
180 184 $reversedTaxTotal = (int) Arr::get($meta, 'reverse_charge_original_tax_total', 0);
181 185 $reversedShippingTax = (int) Arr::get($meta, 'reverse_charge_original_shipping_tax', 0);
182 186 $rcPriceMode = (string) Arr::get($meta, 'reverse_charge_price_mode', 'fixed');
183 187 $shippingNetStored = !empty($meta['shipping_net_stored']);
188 + } else {
189 + // No orderTaxRate row at all — a MoR gateway (Paddle, or any future one)
190 + // applied the reverse charge and never ran the tax module, so there's no
191 + // rate row to read. order.total_amount is still the gross catalog price
192 + // (never lowered — see PaddleReconciler::coverReverseCharge), so the full
193 + // removed VAT has to come from business_info instead of the shipping-only
194 + // adjustment used for core-handled reverse charge orders below.
195 + $businessInfo = $order->getBusinessInfo();
196 + $reversedTaxTotal = (int) Arr::get($businessInfo, 'mor_vat_removed', 0);
197 + $rcTotalAdjustment = $reversedTaxTotal;
184 198 }
185 199 // Only apply the display adjustment for orders where shipping_total in DB is still gross.
186 200 // New orders (shipping_net_stored = true) already have net shipping in DB — no adjustment.
187 201 if ($rcPriceMode === 'dynamic' && $isShippingInclusive && $reversedShippingTax > 0 && !$shippingNetStored) {
@@ -192,9 +206,9 @@
192 206 // For inclusive shipping it either already reduced the price (dynamic) or didn't change
193 207 // it at all (fixed), so the strikethrough is misleading in both cases.
194 208 $showRcShippingRow = $isReverseCharge && !$isShippingInclusive && $reversedShippingTax > 0;
195 209
196 - return [
210 + $summary = [
197 211 'shouldRender' => (bool) $shouldRender,
198 212 'isReverseCharge' => $isReverseCharge,
199 213 'inclusiveTax' => $inclusiveTax,
200 214 'exclusiveTax' => $exclusiveTax,
@@ -210,11 +224,22 @@
210 224 'reversedTaxTotal' => $reversedTaxTotal,
211 225 'reversedShippingTax' => $reversedShippingTax,
212 226 'rcPriceMode' => $rcPriceMode,
213 227 'rcShippingAdjustment' => $rcShippingAdjustment,
214 - 'rcTotalAdjustment' => $rcShippingAdjustment,
228 + 'rcTotalAdjustment' => $rcTotalAdjustment ?: $rcShippingAdjustment,
215 229 'showRcShippingRow' => $showRcShippingRow,
216 - 'foldedRateLines' => self::buildFoldedRateRows($taxRateLines, $shippingTaxLines, 'order_tax', $isShippingInclusive),
230 + // Under reverse charge the stored rate/shipping lines are zeroed, so the
231 + // per-rate rows are rebuilt from item-level line_meta instead. Empty rows
232 + // there mean "not recoverable" — surfaces fall back to the simplified box.
233 + 'foldedRateLines' => $isReverseCharge
234 + ? self::buildReverseChargeRateRows($order)
235 + : self::buildFoldedRateRows(
236 + $taxRateLines,
237 + $shippingTaxLines,
238 + 'order_tax',
239 + $isShippingInclusive,
240 + self::computeRateBaseMap(self::getOrderItemsForBaseMap($order))
241 + ),
217 242 // Inclusive shipping tax follows the store global tax mode: when shipping is
218 243 // priced inclusive its tax is already baked into the shipping price, so it
219 244 // belongs in "of which included in prices". This keeps
220 245 // includedInPrices + payableTax === totalOrderTax on every surface.
@@ -219,11 +244,26 @@
219 244 // belongs in "of which included in prices". This keeps
220 245 // includedInPrices + payableTax === totalOrderTax on every surface.
221 246 'includedInPrices' => $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0),
222 247 ];
248 +
249 + $summary['displayMode'] = self::getTaxDisplayMode();
250 + $summary['simpleLine'] = self::buildSimpleLine($summary);
251 +
252 + return $summary;
223 253 }
224 254
225 255 /**
256 + * Order items used to build the per-rate base map for the tax breakdown table.
257 + */
258 + private static function getOrderItemsForBaseMap(Order $order)
259 + {
260 + $order->loadMissing(['order_items']);
261 +
262 + return $order->order_items ? $order->order_items->all() : [];
263 + }
264 +
265 + /**
226 266 * For a mixed-inclusive rate (same rate used inclusively on some items and exclusively on others),
227 267 * read each order item's line_meta to produce the correct inclusive/exclusive split.
228 268 * Handles both item shapes:
229 269 * - Current (all item types): line_meta.tax_config.rates[] + line_meta.tax_config.inclusive
@@ -397,8 +437,62 @@
397 437 return $rows;
398 438 }
399 439
400 440 /**
441 + * Aggregate the real taxable base per rate from item-level tax data.
442 + *
443 + * For inclusive lines the stored taxable_amount is gross (base + tax), so the
444 + * rate's own tax is subtracted to get the net base. Amounts can be fractional
445 + * cents when the store uses subtotal tax rounding — callers round for display.
446 + *
447 + * @param iterable $items Order items (models or arrays) or cart line arrays,
448 + * each carrying line_meta.tax_config.
449 + * @return array [rate_id => ['base' => float, 'tax' => float]]
450 + */
451 + public static function computeRateBaseMap($items, $excludeInclusive = false)
452 + {
453 + $map = [];
454 + foreach ($items as $item) {
455 + if (is_array($item)) {
456 + $lineMeta = Arr::get($item, 'line_meta', []);
457 + } else {
458 + $lineMeta = is_object($item) ? $item->line_meta : [];
459 + }
460 + if (!is_array($lineMeta)) {
461 + continue;
462 + }
463 + $taxConfig = Arr::get($lineMeta, 'tax_config');
464 + if (is_array($taxConfig)) {
465 + $rates = Arr::get($taxConfig, 'rates', []);
466 + $inclusive = (bool) Arr::get($taxConfig, 'inclusive', false);
467 + } else {
468 + // Legacy signup-fee shape: rates at the line_meta root.
469 + $rates = Arr::get($lineMeta, 'rates', []);
470 + $inclusive = (bool) Arr::get($lineMeta, 'inclusive', false);
471 + }
472 + if (!$rates || !is_array($rates)) {
473 + continue;
474 + }
475 + // Fixed-mode reverse charge keeps tax-inclusive prices untouched — those
476 + // lines carry no reversible VAT, so callers can exclude them from the map.
477 + if ($excludeInclusive && $inclusive) {
478 + continue;
479 + }
480 + foreach ($rates as $rate) {
481 + $rateId = (int) Arr::get($rate, 'rate_id', 0);
482 + $tax = (float) Arr::get($rate, 'tax_amount', 0);
483 + $taxable = (float) Arr::get($rate, 'taxable_amount', 0);
484 + if (!isset($map[$rateId])) {
485 + $map[$rateId] = ['base' => 0.0, 'tax' => 0.0];
486 + }
487 + $map[$rateId]['base'] += $inclusive ? max(0, $taxable - $tax) : $taxable;
488 + $map[$rateId]['tax'] += $tax;
489 + }
490 + }
491 + return $map;
492 + }
493 +
494 + /**
401 495 * Build a folded per-rate row array for the 3-column tax breakdown table.
402 496 *
403 497 * Merges order-tax rate lines with shipping-tax lines by rate_id so each rate
404 498 * appears once with its combined tax and computed taxable base.
@@ -406,11 +500,15 @@
406 500 * @param array $rateLines Output of Order::getDisplayTaxLines().
407 501 * @param array $shippingLines Output of Order::getDisplayShippingTaxLines().
408 502 * @param string $taxAmountKey Key holding the order tax amount in each $rateLine ('order_tax').
409 503 * @param bool $isShippingInclusive Whether shipping tax is inclusive.
504 + * @param array $rateBaseMap Output of computeRateBaseMap() — exact per-rate bases
505 + * from item data. A rate entry is only trusted when its
506 + * item tax sum matches the rate row's tax (guards fee
507 + * items and legacy rows missing from the item data).
410 508 * @return array Each row: ['label'=>string,'base'=>int,'tax'=>int,'inclusive'=>bool]
411 509 */
412 - public static function buildFoldedRateRows($rateLines, $shippingLines, $taxAmountKey, $isShippingInclusive)
510 + public static function buildFoldedRateRows($rateLines, $shippingLines, $taxAmountKey, $isShippingInclusive, $rateBaseMap = [])
413 511 {
414 512 $rateLines = is_array($rateLines) ? $rateLines : [];
415 513 $shippingLines = is_array($shippingLines) ? $shippingLines : [];
416 514 if (empty($rateLines) && empty($shippingLines)) {
@@ -425,12 +523,25 @@
425 523 $rid = (int) Arr::get($rateLine, 'rate_id', $rateKey);
426 524 $ratePercent = (float) Arr::get($rateLine, 'rate_percent', 0);
427 525 $shipForRate = isset($shippingByRate[$rid]) ? (int) $shippingByRate[$rid] : 0;
428 526 unset($shippingByRate[$rid]);
429 - $combinedTax = (int) Arr::get($rateLine, $taxAmountKey, 0) + $shipForRate;
430 - $base = $ratePercent > 0
431 - ? (int) round($combinedTax * 100 / $ratePercent)
432 - : (int) Arr::get($rateLine, 'taxable_amount', 0);
527 + $productTax = (int) Arr::get($rateLine, $taxAmountKey, 0);
528 + $combinedTax = $productTax + $shipForRate;
529 + $mapEntry = isset($rateBaseMap[$rid]) ? $rateBaseMap[$rid] : null;
530 + if ($mapEntry && abs($mapEntry['tax'] - $productTax) <= 1) {
531 + // Exact net base from item-level data. Shipping has no stored per-rate
532 + // base, so its (small) contribution is still derived from its tax amount.
533 + $base = (int) round($mapEntry['base']);
534 + if ($shipForRate > 0 && $ratePercent > 0) {
535 + $base += (int) round($shipForRate * 100 / $ratePercent);
536 + }
537 + } elseif ($ratePercent > 0) {
538 + // No trustworthy item data (legacy order / fee tax folded into the row):
539 + // approximate the base from the rounded tax.
540 + $base = (int) round($combinedTax * 100 / $ratePercent);
541 + } else {
542 + $base = (int) Arr::get($rateLine, 'taxable_amount', 0);
543 + }
433 544 $label = (string) Arr::get($rateLine, 'rate_label', Arr::get($rateLine, 'label', ''));
434 545 $rows[] = [
435 546 'label' => $label,
436 547 'base' => $base,
@@ -461,8 +572,75 @@
461 572 return $rows;
462 573 }
463 574
464 575 /**
576 + * Rebuild the per-rate breakdown rows for a reverse-charge order.
577 + *
578 + * Under reverse charge the stored tax lines (and shipping tax lines) are zeroed
579 + * at order placement, but the original per-rate amounts survive in item-level
580 + * `line_meta.tax_config.rates`, and the pre-zeroing shipping lines survive in the
581 + * rate-meta snapshot (`vat_reverse.reverse_charge_shipping_tax_lines`). This
582 + * restores both and folds shipping into the rate rows so post-order surfaces can
583 + * render the same "Tax breakdown by rate" table as a normal order.
584 + *
585 + * Returns [] when no per-rate data is recoverable (old orders without line-level
586 + * tax data) — callers must fall back to the simplified reverse-charge box.
587 + *
588 + * @return array Same row shape as buildFoldedRateRows().
589 + */
590 + public static function buildReverseChargeRateRows(Order $order)
591 + {
592 + $order->loadMissing(['orderTaxRates', 'order_items']);
593 +
594 + $primaryRate = $order->orderTaxRates ? $order->orderTaxRates->first() : null;
595 + $meta = ($primaryRate && is_array($primaryRate->meta)) ? $primaryRate->meta : [];
596 +
597 + // Fixed-mode reverse charge leaves tax-inclusive prices (and their embedded
598 + // VAT) untouched — only dynamic mode reverses the inclusive portion. Exclude
599 + // inclusive lines in fixed mode so the rate rows sum to the reversed total.
600 + $rcNonDynamic = Arr::get($meta, 'reverse_charge_price_mode', 'fixed') !== 'dynamic';
601 +
602 + $items = [];
603 + if ($order->order_items) {
604 + foreach ($order->order_items as $item) {
605 + if ($item->payment_type === 'fee') {
606 + continue;
607 + }
608 + $items[] = $item;
609 + }
610 + }
611 + $rateBaseMap = self::computeRateBaseMap($items, $rcNonDynamic);
612 +
613 + // Pre-zeroing shipping tax lines snapshot (may be [] on older orders).
614 + $shippingLines = (array) Arr::get($meta, 'vat_reverse.reverse_charge_shipping_tax_lines', []);
615 +
616 + // Stored rate lines are zeroed under reverse charge — restore each rate's
617 + // amount from the item-level map. Rates without a map entry have nothing
618 + // reversible (fixed-mode inclusive-only rates / legacy rows) and are dropped.
619 + $restoredLines = [];
620 + foreach ($order->getDisplayTaxLines() as $rateKey => $rateLine) {
621 + $rid = (int) Arr::get($rateLine, 'rate_id', $rateKey);
622 + if (!isset($rateBaseMap[$rid])) {
623 + continue;
624 + }
625 + $rateLine['order_tax'] = (int) round($rateBaseMap[$rid]['tax']);
626 + $restoredLines[] = $rateLine;
627 + }
628 +
629 + if (empty($restoredLines) && empty($shippingLines)) {
630 + return [];
631 + }
632 +
633 + return self::buildFoldedRateRows(
634 + $restoredLines,
635 + $shippingLines,
636 + 'order_tax',
637 + self::isShippingTaxInclusive($order),
638 + $rateBaseMap
639 + );
640 + }
641 +
642 + /**
465 643 * Checkout-side variant: determines whether the shipping tax is inclusive of the
466 644 * shipping price. Shipping always follows the store-level tax mode, so this returns
467 645 * true only when store_tax_behavior === 2 (inclusive). Per-product inclusive flags
468 646 * are intentionally NOT consulted — they do not govern shipping.
@@ -471,6 +649,63 @@
471 649 {
472 650 $storeBehavior = (int) Arr::get($taxData, 'store_tax_behavior', Arr::get($taxData, 'tax_behavior', 2));
473 651
474 652 return $storeBehavior === 2;
653 + }
654 +
655 + protected static function getTaxSettings(): array
656 + {
657 + return (array) get_option('fluent_cart_tax_configuration_settings', []);
658 + }
659 +
660 + public static function getTaxDisplayMode(): string
661 + {
662 + // Backward compat: legacy stored values ('both', 'label', 'tooltip', or anything
663 + // else) all collapse to 'itemized'. Only an explicit 'simplified' stays simplified.
664 + $mode = Arr::get(self::getTaxSettings(), 'checkout_tax_breakdown_display', 'itemized');
665 + return $mode === 'simplified' ? 'simplified' : 'itemized';
666 + }
667 +
668 + protected static function getTaxDisplayLabel(): string
669 + {
670 + $label = trim((string) Arr::get(self::getTaxSettings(), 'tax_display_label', ''));
671 + return $label !== '' ? $label : __('Tax', 'fluent-cart');
672 + }
673 +
674 + protected static function getPriceSuffixIncluded(): string
675 + {
676 + return (string) Arr::get(self::getTaxSettings(), 'price_suffix_included', '');
677 + }
678 +
679 + public static function buildSimpleLine(array $summary): array
680 + {
681 + $label = self::getTaxDisplayLabel();
682 + $isRc = !empty($summary['isReverseCharge']);
683 + $total = (int) Arr::get($summary, 'totalOrderTax', 0);
684 + $payable = (int) Arr::get($summary, 'payableTax', 0);
685 + $folded = (array) Arr::get($summary, 'foldedRateLines', []);
686 + $hasDetails = !empty($folded) || $total > 0 || $isRc;
687 +
688 + if ($isRc) {
689 + $valueType = 'reverse_charge';
690 + $value = __('Reverse charge', 'fluent-cart');
691 + } elseif ($payable === 0 && $total > 0) {
692 + $valueType = 'included';
693 + $suffix = self::getPriceSuffixIncluded();
694 + if ($suffix === '') {
695 + $suffix = __('(incl.)', 'fluent-cart');
696 + }
697 + /* translators: %1$s: formatted tax amount, %2$s: inclusive suffix */
698 + $value = sprintf(__('%1$s %2$s', 'fluent-cart'), html_entity_decode(Helper::toDecimal($total), ENT_QUOTES, 'UTF-8'), $suffix);
699 + } else {
700 + $valueType = 'amount';
701 + $value = html_entity_decode(Helper::toDecimal($payable), ENT_QUOTES, 'UTF-8');
702 + }
703 +
704 + return [
705 + 'label' => $label,
706 + 'value' => $value,
707 + 'valueType' => $valueType,
708 + 'hasDetails' => $hasDetails,
709 + ];
475 710 }
476 711 }