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 +319 -5 1.4.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
@@ -152,11 +153,21 @@
152 153 $shippingTax = (int) $order->shipping_tax;
153 154 $isShippingInclusive = self::isShippingTaxInclusive($order);
154 155 $payableTax = $exclusiveTax + $exclusiveFeeTax + ($isShippingInclusive ? 0 : $shippingTax);
155 156 $totalOrderTax = $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0) + $payableTax;
157 + $taxRateLines = $order->getDisplayTaxLines();
158 + $shippingTaxLines = $order->getDisplayShippingTaxLines();
156 159
157 - if ($inclusiveTax === 0 && $inclusiveFeeTax === 0 && $payableTax === 0 && !$isReverseCharge) {
158 - return ['shouldRender' => false];
160 + if ($inclusiveTax === 0 && $inclusiveFeeTax === 0 && $payableTax === 0 && $shippingTax === 0 && empty($taxRateLines) && !$isReverseCharge) {
161 + return [
162 + 'shouldRender' => false,
163 + 'taxRateLines' => $taxRateLines,
164 + 'shippingTaxLines' => $shippingTaxLines,
165 + 'foldedRateLines' => [],
166 + 'includedInPrices' => 0,
167 + 'displayMode' => self::getTaxDisplayMode(),
168 + 'simpleLine' => null,
169 + ];
159 170 }
160 171
161 172 $shouldRender = apply_filters('fluent_cart/tax_summary_should_render', true, $order);
162 173
@@ -163,8 +174,9 @@
163 174 $reversedTaxTotal = 0;
164 175 $reversedShippingTax = 0;
165 176 $rcPriceMode = '';
166 177 $rcShippingAdjustment = 0;
178 + $rcTotalAdjustment = 0;
167 179 $shippingNetStored = false;
168 180 if ($isReverseCharge) {
169 181 $primaryRate = $order->orderTaxRates ? $order->orderTaxRates->first() : null;
170 182 if ($primaryRate) {
@@ -172,8 +184,18 @@
172 184 $reversedTaxTotal = (int) Arr::get($meta, 'reverse_charge_original_tax_total', 0);
173 185 $reversedShippingTax = (int) Arr::get($meta, 'reverse_charge_original_shipping_tax', 0);
174 186 $rcPriceMode = (string) Arr::get($meta, 'reverse_charge_price_mode', 'fixed');
175 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;
176 198 }
177 199 // Only apply the display adjustment for orders where shipping_total in DB is still gross.
178 200 // New orders (shipping_net_stored = true) already have net shipping in DB — no adjustment.
179 201 if ($rcPriceMode === 'dynamic' && $isShippingInclusive && $reversedShippingTax > 0 && !$shippingNetStored) {
@@ -184,18 +206,19 @@
184 206 // For inclusive shipping it either already reduced the price (dynamic) or didn't change
185 207 // it at all (fixed), so the strikethrough is misleading in both cases.
186 208 $showRcShippingRow = $isReverseCharge && !$isShippingInclusive && $reversedShippingTax > 0;
187 209
188 - return [
210 + $summary = [
189 211 'shouldRender' => (bool) $shouldRender,
190 212 'isReverseCharge' => $isReverseCharge,
191 213 'inclusiveTax' => $inclusiveTax,
192 214 'exclusiveTax' => $exclusiveTax,
215 + 'taxRateLines' => $taxRateLines,
193 216 'feeTaxLines' => $feeTaxLines,
194 217 'feeTaxLineRows' => self::buildFeeTaxLineRows($feeTaxLines),
195 218 'inclusiveFeeTax' => $inclusiveFeeTax,
196 219 'shippingTax' => $shippingTax,
197 - 'shippingTaxLines' => $order->getDisplayShippingTaxLines(),
220 + 'shippingTaxLines' => $shippingTaxLines,
198 221 'payableTax' => $payableTax,
199 222 'totalOrderTax' => $totalOrderTax,
200 223 'isShippingInclusive' => $isShippingInclusive,
201 224 'reversedTaxTotal' => $reversedTaxTotal,
@@ -201,14 +224,46 @@
201 224 'reversedTaxTotal' => $reversedTaxTotal,
202 225 'reversedShippingTax' => $reversedShippingTax,
203 226 'rcPriceMode' => $rcPriceMode,
204 227 'rcShippingAdjustment' => $rcShippingAdjustment,
205 - 'rcTotalAdjustment' => $rcShippingAdjustment,
228 + 'rcTotalAdjustment' => $rcTotalAdjustment ?: $rcShippingAdjustment,
206 229 'showRcShippingRow' => $showRcShippingRow,
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 + ),
242 + // Inclusive shipping tax follows the store global tax mode: when shipping is
243 + // priced inclusive its tax is already baked into the shipping price, so it
244 + // belongs in "of which included in prices". This keeps
245 + // includedInPrices + payableTax === totalOrderTax on every surface.
246 + 'includedInPrices' => $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0),
207 247 ];
248 +
249 + $summary['displayMode'] = self::getTaxDisplayMode();
250 + $summary['simpleLine'] = self::buildSimpleLine($summary);
251 +
252 + return $summary;
208 253 }
209 254
210 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 + /**
211 266 * For a mixed-inclusive rate (same rate used inclusively on some items and exclusively on others),
212 267 * read each order item's line_meta to produce the correct inclusive/exclusive split.
213 268 * Handles both item shapes:
214 269 * - Current (all item types): line_meta.tax_config.rates[] + line_meta.tax_config.inclusive
@@ -382,8 +437,210 @@
382 437 return $rows;
383 438 }
384 439
385 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 + /**
495 + * Build a folded per-rate row array for the 3-column tax breakdown table.
496 + *
497 + * Merges order-tax rate lines with shipping-tax lines by rate_id so each rate
498 + * appears once with its combined tax and computed taxable base.
499 + *
500 + * @param array $rateLines Output of Order::getDisplayTaxLines().
501 + * @param array $shippingLines Output of Order::getDisplayShippingTaxLines().
502 + * @param string $taxAmountKey Key holding the order tax amount in each $rateLine ('order_tax').
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).
508 + * @return array Each row: ['label'=>string,'base'=>int,'tax'=>int,'inclusive'=>bool]
509 + */
510 + public static function buildFoldedRateRows($rateLines, $shippingLines, $taxAmountKey, $isShippingInclusive, $rateBaseMap = [])
511 + {
512 + $rateLines = is_array($rateLines) ? $rateLines : [];
513 + $shippingLines = is_array($shippingLines) ? $shippingLines : [];
514 + if (empty($rateLines) && empty($shippingLines)) {
515 + return [];
516 + }
517 + $shippingByRate = [];
518 + foreach ($shippingLines as $shLine) {
519 + $shippingByRate[(int) Arr::get($shLine, 'rate_id', 0)] = (int) Arr::get($shLine, 'shipping_tax', 0);
520 + }
521 + $rows = [];
522 + foreach ($rateLines as $rateKey => $rateLine) {
523 + $rid = (int) Arr::get($rateLine, 'rate_id', $rateKey);
524 + $ratePercent = (float) Arr::get($rateLine, 'rate_percent', 0);
525 + $shipForRate = isset($shippingByRate[$rid]) ? (int) $shippingByRate[$rid] : 0;
526 + unset($shippingByRate[$rid]);
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 + }
544 + $label = (string) Arr::get($rateLine, 'rate_label', Arr::get($rateLine, 'label', ''));
545 + $rows[] = [
546 + 'label' => $label,
547 + 'base' => $base,
548 + 'tax' => $combinedTax,
549 + 'inclusive' => !empty($rateLine['inclusive']),
550 + ];
551 + }
552 + foreach ($shippingByRate as $sid => $shAmount) { // shipping-only rates
553 + if ($shAmount <= 0) {
554 + continue;
555 + }
556 + $shLine = null;
557 + foreach ($shippingLines as $cand) {
558 + if ((int) Arr::get($cand, 'rate_id', 0) === (int) $sid) {
559 + $shLine = $cand;
560 + break;
561 + }
562 + }
563 + $ratePercent = (float) Arr::get($shLine, 'rate_percent', 0);
564 + $base = $ratePercent > 0 ? (int) round($shAmount * 100 / $ratePercent) : 0;
565 + $rows[] = [
566 + 'label' => (string) Arr::get($shLine, 'rate_label', Arr::get($shLine, 'label', '')),
567 + 'base' => $base,
568 + 'tax' => $shAmount,
569 + 'inclusive' => (bool) $isShippingInclusive,
570 + ];
571 + }
572 + return $rows;
573 + }
574 +
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 + /**
386 643 * Checkout-side variant: determines whether the shipping tax is inclusive of the
387 644 * shipping price. Shipping always follows the store-level tax mode, so this returns
388 645 * true only when store_tax_behavior === 2 (inclusive). Per-product inclusive flags
389 646 * are intentionally NOT consulted — they do not govern shipping.
@@ -392,6 +649,63 @@
392 649 {
393 650 $storeBehavior = (int) Arr::get($taxData, 'store_tax_behavior', Arr::get($taxData, 'tax_behavior', 2));
394 651
395 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 + ];
396 710 }
397 711 }