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

TaxSummaryHelper.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Services/Renderer/Receipt/TaxSummaryHelper.php

712 lines 32.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Renderer\Receipt;
4
5 use FluentCart\App\Helpers\Helper;
6 use FluentCart\App\Models\Order;
7 use FluentCart\Framework\Support\Arr;
8
9 class TaxSummaryHelper
10 {
11 /**
12 * Compute inclusive/exclusive tax split from order data.
13 *
14 * Returns ['shouldRender' => false] when there is nothing to show.
15 * Otherwise returns shouldRender, isReverseCharge, inclusiveTax, exclusiveTax,
16 * shippingTax, payableTax, totalOrderTax — all amounts in cents.
17 */
18 public static function computeTaxSummary(Order $order)
19 {
20 $order->loadMissing(['orderTaxRates']);
21
22 $isReverseCharge = $order->isReverseChargeTaxOrder();
23 $inclusiveTax = 0;
24 $exclusiveTax = 0;
25
26 // Per-fee tax breakdown — stored at order placement, empty for old/fee-less orders.
27 $feeTaxLines = (array) $order->getMeta('fee_tax_lines', []);
28 $inclusiveFeeTax = 0;
29 $exclusiveFeeTax = 0;
30 foreach ($feeTaxLines as $ftl) {
31 if (!empty($ftl['inclusive'])) {
32 $inclusiveFeeTax += (int) Arr::get($ftl, 'tax_amount', 0);
33 } else {
34 $exclusiveFeeTax += (int) Arr::get($ftl, 'tax_amount', 0);
35 }
36 }
37 // Backward-compat: old orders persist fee_tax as a scalar with no fee_tax_lines array.
38 if (empty($feeTaxLines)) {
39 $legacyFeeTax = (int) $order->getMeta('fee_tax', 0);
40 if ($legacyFeeTax > 0) {
41 $exclusiveFeeTax = $legacyFeeTax;
42 $feeTaxLines = [
43 [
44 'label' => __('Fee', 'fluent-cart'),
45 'tax_amount' => $legacyFeeTax,
46 'inclusive' => false,
47 ],
48 ];
49 }
50 }
51 $totalFeeTax = $inclusiveFeeTax + $exclusiveFeeTax;
52
53 if ($order->orderTaxRates && $order->orderTaxRates->count()) {
54 foreach ($order->orderTaxRates as $rate) {
55 $meta = is_array($rate->meta) ? $rate->meta : [];
56 $isMixedInclusive = (bool) Arr::get($meta, 'is_mixed_inclusive', false);
57 if ($isMixedInclusive) {
58 list($rateIncl, $rateExcl) = self::splitMixedRateTax($order, (int) $rate->tax_rate_id);
59 if ($rateIncl === 0 && $rateExcl === 0 && (int) $rate->order_tax > 0) {
60 // No per-item breakdown (legacy order) — fall back to rate meta or tax_behavior.
61 if (isset($meta['inclusive'])) {
62 if ((bool) $meta['inclusive']) {
63 $inclusiveTax += (int) $rate->order_tax;
64 } else {
65 $exclusiveTax += (int) $rate->order_tax;
66 }
67 } elseif ((int) $order->tax_behavior === 2) {
68 $inclusiveTax += (int) $rate->order_tax;
69 } else {
70 $exclusiveTax += (int) $rate->order_tax;
71 }
72 } else {
73 $inclusiveTax += $rateIncl;
74 $exclusiveTax += $rateExcl;
75 }
76 } elseif (isset($meta['inclusive'])) {
77 if ((bool) $meta['inclusive']) {
78 $inclusiveTax += (int) $rate->order_tax;
79 } else {
80 $exclusiveTax += (int) $rate->order_tax;
81 }
82 } else {
83 if ((int) $order->tax_behavior === 2) {
84 $inclusiveTax += (int) $rate->order_tax;
85 } else {
86 $exclusiveTax += (int) $rate->order_tax;
87 }
88 }
89 }
90
91 // Guard for orders where fct_order_tax_rate.order_tax was stored
92 // incorrectly by an old bug. order.tax_total is the authoritative
93 // value; if the rate-row sum differs, reset so the item-level
94 // fallback below sums from order_items.tax_amount instead.
95 // For mixed-inclusive orders, splitMixedRateTax() returns product-only
96 // tax (fee items have empty line_meta and are skipped). Accept the sum
97 // as valid when it equals order.tax_total minus the known fee tax.
98 $orderTaxTotal = (int) $order->tax_total;
99 $productTaxFromRates = $inclusiveTax + $exclusiveTax;
100 $productTaxExpected = $orderTaxTotal - $totalFeeTax;
101 if ($orderTaxTotal > 0
102 && $productTaxFromRates !== $orderTaxTotal
103 && $productTaxFromRates !== $productTaxExpected
104 ) {
105 $inclusiveTax = 0;
106 $exclusiveTax = 0;
107 }
108 // For non-mixed rate rows the full order_tax (product+fee) is in exclusiveTax
109 // or inclusiveTax — strip the fee portion so product tax is isolated.
110 if ($exclusiveFeeTax > 0 && $exclusiveTax >= $exclusiveFeeTax) {
111 $exclusiveTax -= $exclusiveFeeTax;
112 }
113 if ($inclusiveFeeTax > 0 && $inclusiveTax >= $inclusiveFeeTax) {
114 $inclusiveTax -= $inclusiveFeeTax;
115 }
116 } else {
117 $isInclusive = (int) $order->tax_behavior === 2;
118 $orderTaxTotal = max(0, (int) $order->tax_total - $totalFeeTax);
119 $inclusiveTax = $isInclusive ? $orderTaxTotal : 0;
120 $exclusiveTax = $isInclusive ? 0 : $orderTaxTotal;
121 }
122
123 // Fallback: sum item-level tax_amount when order.tax_total was never written (e.g. admin-created orders).
124 if ($inclusiveTax === 0 && $exclusiveTax === 0) {
125 $order->loadMissing(['order_items']);
126 if ($order->order_items) {
127 $taxBehavior = (int) $order->tax_behavior;
128 foreach ($order->order_items as $item) {
129 if ($item->payment_type === 'fee') {
130 continue;
131 }
132 $itemTax = (int) round($item->tax_amount);
133 if ($itemTax <= 0) {
134 continue;
135 }
136 if ($taxBehavior === 3) {
137 $lineMeta = $item->line_meta;
138 $lineInclusive = (bool) Arr::get($lineMeta, 'tax_config.inclusive', false);
139 if ($lineInclusive) {
140 $inclusiveTax += $itemTax;
141 } else {
142 $exclusiveTax += $itemTax;
143 }
144 } elseif ($taxBehavior === 1) {
145 $exclusiveTax += $itemTax;
146 } else {
147 $inclusiveTax += $itemTax;
148 }
149 }
150 }
151 }
152
153 $shippingTax = (int) $order->shipping_tax;
154 $isShippingInclusive = self::isShippingTaxInclusive($order);
155 $payableTax = $exclusiveTax + $exclusiveFeeTax + ($isShippingInclusive ? 0 : $shippingTax);
156 $totalOrderTax = $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0) + $payableTax;
157 $taxRateLines = $order->getDisplayTaxLines();
158 $shippingTaxLines = $order->getDisplayShippingTaxLines();
159
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 ];
170 }
171
172 $shouldRender = apply_filters('fluent_cart/tax_summary_should_render', true, $order);
173
174 $reversedTaxTotal = 0;
175 $reversedShippingTax = 0;
176 $rcPriceMode = '';
177 $rcShippingAdjustment = 0;
178 $rcTotalAdjustment = 0;
179 $shippingNetStored = false;
180 if ($isReverseCharge) {
181 $primaryRate = $order->orderTaxRates ? $order->orderTaxRates->first() : null;
182 if ($primaryRate) {
183 $meta = is_array($primaryRate->meta) ? $primaryRate->meta : [];
184 $reversedTaxTotal = (int) Arr::get($meta, 'reverse_charge_original_tax_total', 0);
185 $reversedShippingTax = (int) Arr::get($meta, 'reverse_charge_original_shipping_tax', 0);
186 $rcPriceMode = (string) Arr::get($meta, 'reverse_charge_price_mode', 'fixed');
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;
198 }
199 // Only apply the display adjustment for orders where shipping_total in DB is still gross.
200 // New orders (shipping_net_stored = true) already have net shipping in DB — no adjustment.
201 if ($rcPriceMode === 'dynamic' && $isShippingInclusive && $reversedShippingTax > 0 && !$shippingNetStored) {
202 $rcShippingAdjustment = $reversedShippingTax;
203 }
204 }
205 // Show RC shipping strikethrough row only for exclusive shipping tax that was reversed.
206 // For inclusive shipping it either already reduced the price (dynamic) or didn't change
207 // it at all (fixed), so the strikethrough is misleading in both cases.
208 $showRcShippingRow = $isReverseCharge && !$isShippingInclusive && $reversedShippingTax > 0;
209
210 $summary = [
211 'shouldRender' => (bool) $shouldRender,
212 'isReverseCharge' => $isReverseCharge,
213 'inclusiveTax' => $inclusiveTax,
214 'exclusiveTax' => $exclusiveTax,
215 'taxRateLines' => $taxRateLines,
216 'feeTaxLines' => $feeTaxLines,
217 'feeTaxLineRows' => self::buildFeeTaxLineRows($feeTaxLines),
218 'inclusiveFeeTax' => $inclusiveFeeTax,
219 'shippingTax' => $shippingTax,
220 'shippingTaxLines' => $shippingTaxLines,
221 'payableTax' => $payableTax,
222 'totalOrderTax' => $totalOrderTax,
223 'isShippingInclusive' => $isShippingInclusive,
224 'reversedTaxTotal' => $reversedTaxTotal,
225 'reversedShippingTax' => $reversedShippingTax,
226 'rcPriceMode' => $rcPriceMode,
227 'rcShippingAdjustment' => $rcShippingAdjustment,
228 'rcTotalAdjustment' => $rcTotalAdjustment ?: $rcShippingAdjustment,
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),
247 ];
248
249 $summary['displayMode'] = self::getTaxDisplayMode();
250 $summary['simpleLine'] = self::buildSimpleLine($summary);
251
252 return $summary;
253 }
254
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 /**
266 * For a mixed-inclusive rate (same rate used inclusively on some items and exclusively on others),
267 * read each order item's line_meta to produce the correct inclusive/exclusive split.
268 * Handles both item shapes:
269 * - Current (all item types): line_meta.tax_config.rates[] + line_meta.tax_config.inclusive
270 * - Legacy signup-fee: line_meta.rates[] + line_meta.inclusive (no tax_config wrapper)
271 * Returns [inclusiveTax, exclusiveTax] in cents.
272 */
273 private static function splitMixedRateTax(Order $order, $rateId)
274 {
275 $order->loadMissing(['order_items']);
276 $incl = 0;
277 $excl = 0;
278 if (!$order->order_items) {
279 return [0, 0];
280 }
281 foreach ($order->order_items as $item) {
282 $lineMeta = $item->line_meta;
283 $taxConfig = Arr::get($lineMeta, 'tax_config');
284 if (is_array($taxConfig)) {
285 $rates = Arr::get($taxConfig, 'rates', []);
286 $lineInclusive = (bool) Arr::get($taxConfig, 'inclusive', false);
287 } else {
288 $rates = Arr::get($lineMeta, 'rates', []);
289 $lineInclusive = (bool) Arr::get($lineMeta, 'inclusive', false);
290 }
291 foreach ($rates as $rate) {
292 if ((int) Arr::get($rate, 'rate_id', 0) !== $rateId) {
293 continue;
294 }
295 $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
296 if ($taxAmount <= 0) {
297 continue;
298 }
299 if ($lineInclusive) {
300 $incl += $taxAmount;
301 } else {
302 $excl += $taxAmount;
303 }
304 }
305 }
306 return [$incl, $excl];
307 }
308
309 /**
310 * Extract per-rate tax breakdown from a single order item.
311 *
312 * Reads `line_meta.tax_config.rates`, filters out zero-amount entries, and
313 * returns a flat array of rate rows. Returns [] for old items without
314 * tax_config — callers must check for empty before looping.
315 */
316 public static function getItemTaxRates(array $item)
317 {
318 // Current items (all types): line_meta.tax_config.rates
319 // Legacy signup_fee items: line_meta.rates (no tax_config wrapper — set directly from signup_fee_tax_config)
320 $taxConfig = Arr::get($item, 'line_meta.tax_config');
321 if (is_array($taxConfig)) {
322 $rates = Arr::get($taxConfig, 'rates', []);
323 $inclusive = (bool) Arr::get($taxConfig, 'inclusive', false);
324 } else {
325 $rates = Arr::get($item, 'line_meta.rates', []);
326 $inclusive = (bool) Arr::get($item, 'line_meta.inclusive', false);
327 }
328
329 if (empty($rates)) {
330 return [];
331 }
332
333 $result = [];
334 foreach ($rates as $rate) {
335 $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
336 if ($taxAmount <= 0) {
337 continue;
338 }
339 $result[] = [
340 'label' => Arr::get($rate, 'label') ?: __('Tax', 'fluent-cart'),
341 'tax_amount' => $taxAmount,
342 'rate_percent' => max(0.0, (float) Arr::get($rate, 'rate_percent', 0)),
343 'inclusive' => $inclusive,
344 ];
345 }
346
347 return $result;
348 }
349
350 /**
351 * Determine whether the primary tax type for an order is inclusive.
352 * Used for per-item pill display (fct_order_tax_rate has no order_item_id).
353 */
354 public static function isPrimaryTaxInclusive(Order $order)
355 {
356 $order->loadMissing(['orderTaxRates']);
357
358 if ($order->orderTaxRates && $order->orderTaxRates->count() === 1) {
359 $rate = $order->orderTaxRates->first();
360 $meta = is_array($rate->meta) ? $rate->meta : [];
361 if (isset($meta['inclusive'])) {
362 return (bool) $meta['inclusive'];
363 }
364 }
365
366 return (int) $order->tax_behavior === 2;
367 }
368
369 /**
370 * Determine whether the shipping tax on an order was charged inclusive of the
371 * shipping price (vs. added on top). Reads `meta.shipping_inclusive` (written
372 * from the store-level tax mode at order placement) from each rate row that
373 * contributed shipping_tax. Falls back to $order->tax_behavior === 2.
374 *
375 * Shipping always follows the store-level tax mode — per-product `meta.inclusive`
376 * is intentionally NOT consulted here.
377 */
378 public static function isShippingTaxInclusive(Order $order)
379 {
380 $order->loadMissing(['orderTaxRates']);
381
382 if ($order->orderTaxRates && $order->orderTaxRates->count()) {
383 $shippingRatesInclusive = null;
384 foreach ($order->orderTaxRates as $rate) {
385 $meta = is_array($rate->meta) ? $rate->meta : [];
386 // On reverse-charge orders shipping_tax is zeroed; detect via the
387 // pre-zeroed snapshot stored in meta before falling back.
388 $hasShippingContrib = (int) $rate->shipping_tax > 0
389 || (int) Arr::get($meta, 'reverse_charge_original_shipping_tax', 0) > 0;
390 if (!$hasShippingContrib) {
391 continue;
392 }
393 if (!isset($meta['shipping_inclusive'])) {
394 continue;
395 }
396 $rateInclusive = (bool) $meta['shipping_inclusive'];
397 if ($shippingRatesInclusive === null) {
398 $shippingRatesInclusive = $rateInclusive;
399 } elseif ($shippingRatesInclusive !== $rateInclusive) {
400 return (int) $order->tax_behavior === 2;
401 }
402 }
403 if ($shippingRatesInclusive !== null) {
404 return $shippingRatesInclusive;
405 }
406 }
407
408 return (int) $order->tax_behavior === 2;
409 }
410
411 /**
412 * Returns display-ready fee tax line rows, filtering out zero-amount entries
413 * and pre-computing the translated label for each surface to render.
414 * Each entry: ['label' => string, 'tax_amount' => int, 'inclusive' => bool, 'display_label' => string]
415 */
416 public static function buildFeeTaxLineRows(array $feeTaxLines)
417 {
418 $rows = [];
419 foreach ($feeTaxLines as $ftl) {
420 $taxAmount = (int) Arr::get($ftl, 'tax_amount', 0);
421 if ($taxAmount <= 0) {
422 continue;
423 }
424 $inclusive = !empty($ftl['inclusive']);
425 $feeLabel = Arr::get($ftl, 'label', __('fee', 'fluent-cart'));
426 /* translators: %1$s: fee label */
427 $displayLabel = $inclusive
428 ? sprintf(__('Included in %1$s', 'fluent-cart'), $feeLabel)
429 : sprintf(__('Added on %1$s', 'fluent-cart'), $feeLabel);
430 $rows[] = [
431 'label' => $feeLabel,
432 'tax_amount' => $taxAmount,
433 'inclusive' => $inclusive,
434 'display_label' => $displayLabel,
435 ];
436 }
437 return $rows;
438 }
439
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 /**
643 * Checkout-side variant: determines whether the shipping tax is inclusive of the
644 * shipping price. Shipping always follows the store-level tax mode, so this returns
645 * true only when store_tax_behavior === 2 (inclusive). Per-product inclusive flags
646 * are intentionally NOT consulted — they do not govern shipping.
647 */
648 public static function isShippingTaxInclusiveFromTaxData(array $taxData)
649 {
650 $storeBehavior = (int) Arr::get($taxData, 'store_tax_behavior', Arr::get($taxData, 'tax_behavior', 2));
651
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 ];
710 }
711 }
712