PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.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 1.3.19 All 49 releases
fluent-cart / app / Services / Renderer / Receipt / TaxSummaryHelper.php

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

701 lines 31.3 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 $shippingNetStored = false;
179 if ($isReverseCharge) {
180 $primaryRate = $order->orderTaxRates ? $order->orderTaxRates->first() : null;
181 if ($primaryRate) {
182 $meta = is_array($primaryRate->meta) ? $primaryRate->meta : [];
183 $reversedTaxTotal = (int) Arr::get($meta, 'reverse_charge_original_tax_total', 0);
184 $reversedShippingTax = (int) Arr::get($meta, 'reverse_charge_original_shipping_tax', 0);
185 $rcPriceMode = (string) Arr::get($meta, 'reverse_charge_price_mode', 'fixed');
186 $shippingNetStored = !empty($meta['shipping_net_stored']);
187 }
188 // Only apply the display adjustment for orders where shipping_total in DB is still gross.
189 // New orders (shipping_net_stored = true) already have net shipping in DB — no adjustment.
190 if ($rcPriceMode === 'dynamic' && $isShippingInclusive && $reversedShippingTax > 0 && !$shippingNetStored) {
191 $rcShippingAdjustment = $reversedShippingTax;
192 }
193 }
194 // Show RC shipping strikethrough row only for exclusive shipping tax that was reversed.
195 // For inclusive shipping it either already reduced the price (dynamic) or didn't change
196 // it at all (fixed), so the strikethrough is misleading in both cases.
197 $showRcShippingRow = $isReverseCharge && !$isShippingInclusive && $reversedShippingTax > 0;
198
199 $summary = [
200 'shouldRender' => (bool) $shouldRender,
201 'isReverseCharge' => $isReverseCharge,
202 'inclusiveTax' => $inclusiveTax,
203 'exclusiveTax' => $exclusiveTax,
204 'taxRateLines' => $taxRateLines,
205 'feeTaxLines' => $feeTaxLines,
206 'feeTaxLineRows' => self::buildFeeTaxLineRows($feeTaxLines),
207 'inclusiveFeeTax' => $inclusiveFeeTax,
208 'shippingTax' => $shippingTax,
209 'shippingTaxLines' => $shippingTaxLines,
210 'payableTax' => $payableTax,
211 'totalOrderTax' => $totalOrderTax,
212 'isShippingInclusive' => $isShippingInclusive,
213 'reversedTaxTotal' => $reversedTaxTotal,
214 'reversedShippingTax' => $reversedShippingTax,
215 'rcPriceMode' => $rcPriceMode,
216 'rcShippingAdjustment' => $rcShippingAdjustment,
217 'rcTotalAdjustment' => $rcShippingAdjustment,
218 'showRcShippingRow' => $showRcShippingRow,
219 // Under reverse charge the stored rate/shipping lines are zeroed, so the
220 // per-rate rows are rebuilt from item-level line_meta instead. Empty rows
221 // there mean "not recoverable" — surfaces fall back to the simplified box.
222 'foldedRateLines' => $isReverseCharge
223 ? self::buildReverseChargeRateRows($order)
224 : self::buildFoldedRateRows(
225 $taxRateLines,
226 $shippingTaxLines,
227 'order_tax',
228 $isShippingInclusive,
229 self::computeRateBaseMap(self::getOrderItemsForBaseMap($order))
230 ),
231 // Inclusive shipping tax follows the store global tax mode: when shipping is
232 // priced inclusive its tax is already baked into the shipping price, so it
233 // belongs in "of which included in prices". This keeps
234 // includedInPrices + payableTax === totalOrderTax on every surface.
235 'includedInPrices' => $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0),
236 ];
237
238 $summary['displayMode'] = self::getTaxDisplayMode();
239 $summary['simpleLine'] = self::buildSimpleLine($summary);
240
241 return $summary;
242 }
243
244 /**
245 * Order items used to build the per-rate base map for the tax breakdown table.
246 */
247 private static function getOrderItemsForBaseMap(Order $order)
248 {
249 $order->loadMissing(['order_items']);
250
251 return $order->order_items ? $order->order_items->all() : [];
252 }
253
254 /**
255 * For a mixed-inclusive rate (same rate used inclusively on some items and exclusively on others),
256 * read each order item's line_meta to produce the correct inclusive/exclusive split.
257 * Handles both item shapes:
258 * - Current (all item types): line_meta.tax_config.rates[] + line_meta.tax_config.inclusive
259 * - Legacy signup-fee: line_meta.rates[] + line_meta.inclusive (no tax_config wrapper)
260 * Returns [inclusiveTax, exclusiveTax] in cents.
261 */
262 private static function splitMixedRateTax(Order $order, $rateId)
263 {
264 $order->loadMissing(['order_items']);
265 $incl = 0;
266 $excl = 0;
267 if (!$order->order_items) {
268 return [0, 0];
269 }
270 foreach ($order->order_items as $item) {
271 $lineMeta = $item->line_meta;
272 $taxConfig = Arr::get($lineMeta, 'tax_config');
273 if (is_array($taxConfig)) {
274 $rates = Arr::get($taxConfig, 'rates', []);
275 $lineInclusive = (bool) Arr::get($taxConfig, 'inclusive', false);
276 } else {
277 $rates = Arr::get($lineMeta, 'rates', []);
278 $lineInclusive = (bool) Arr::get($lineMeta, 'inclusive', false);
279 }
280 foreach ($rates as $rate) {
281 if ((int) Arr::get($rate, 'rate_id', 0) !== $rateId) {
282 continue;
283 }
284 $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
285 if ($taxAmount <= 0) {
286 continue;
287 }
288 if ($lineInclusive) {
289 $incl += $taxAmount;
290 } else {
291 $excl += $taxAmount;
292 }
293 }
294 }
295 return [$incl, $excl];
296 }
297
298 /**
299 * Extract per-rate tax breakdown from a single order item.
300 *
301 * Reads `line_meta.tax_config.rates`, filters out zero-amount entries, and
302 * returns a flat array of rate rows. Returns [] for old items without
303 * tax_config — callers must check for empty before looping.
304 */
305 public static function getItemTaxRates(array $item)
306 {
307 // Current items (all types): line_meta.tax_config.rates
308 // Legacy signup_fee items: line_meta.rates (no tax_config wrapper — set directly from signup_fee_tax_config)
309 $taxConfig = Arr::get($item, 'line_meta.tax_config');
310 if (is_array($taxConfig)) {
311 $rates = Arr::get($taxConfig, 'rates', []);
312 $inclusive = (bool) Arr::get($taxConfig, 'inclusive', false);
313 } else {
314 $rates = Arr::get($item, 'line_meta.rates', []);
315 $inclusive = (bool) Arr::get($item, 'line_meta.inclusive', false);
316 }
317
318 if (empty($rates)) {
319 return [];
320 }
321
322 $result = [];
323 foreach ($rates as $rate) {
324 $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
325 if ($taxAmount <= 0) {
326 continue;
327 }
328 $result[] = [
329 'label' => Arr::get($rate, 'label') ?: __('Tax', 'fluent-cart'),
330 'tax_amount' => $taxAmount,
331 'rate_percent' => max(0.0, (float) Arr::get($rate, 'rate_percent', 0)),
332 'inclusive' => $inclusive,
333 ];
334 }
335
336 return $result;
337 }
338
339 /**
340 * Determine whether the primary tax type for an order is inclusive.
341 * Used for per-item pill display (fct_order_tax_rate has no order_item_id).
342 */
343 public static function isPrimaryTaxInclusive(Order $order)
344 {
345 $order->loadMissing(['orderTaxRates']);
346
347 if ($order->orderTaxRates && $order->orderTaxRates->count() === 1) {
348 $rate = $order->orderTaxRates->first();
349 $meta = is_array($rate->meta) ? $rate->meta : [];
350 if (isset($meta['inclusive'])) {
351 return (bool) $meta['inclusive'];
352 }
353 }
354
355 return (int) $order->tax_behavior === 2;
356 }
357
358 /**
359 * Determine whether the shipping tax on an order was charged inclusive of the
360 * shipping price (vs. added on top). Reads `meta.shipping_inclusive` (written
361 * from the store-level tax mode at order placement) from each rate row that
362 * contributed shipping_tax. Falls back to $order->tax_behavior === 2.
363 *
364 * Shipping always follows the store-level tax mode — per-product `meta.inclusive`
365 * is intentionally NOT consulted here.
366 */
367 public static function isShippingTaxInclusive(Order $order)
368 {
369 $order->loadMissing(['orderTaxRates']);
370
371 if ($order->orderTaxRates && $order->orderTaxRates->count()) {
372 $shippingRatesInclusive = null;
373 foreach ($order->orderTaxRates as $rate) {
374 $meta = is_array($rate->meta) ? $rate->meta : [];
375 // On reverse-charge orders shipping_tax is zeroed; detect via the
376 // pre-zeroed snapshot stored in meta before falling back.
377 $hasShippingContrib = (int) $rate->shipping_tax > 0
378 || (int) Arr::get($meta, 'reverse_charge_original_shipping_tax', 0) > 0;
379 if (!$hasShippingContrib) {
380 continue;
381 }
382 if (!isset($meta['shipping_inclusive'])) {
383 continue;
384 }
385 $rateInclusive = (bool) $meta['shipping_inclusive'];
386 if ($shippingRatesInclusive === null) {
387 $shippingRatesInclusive = $rateInclusive;
388 } elseif ($shippingRatesInclusive !== $rateInclusive) {
389 return (int) $order->tax_behavior === 2;
390 }
391 }
392 if ($shippingRatesInclusive !== null) {
393 return $shippingRatesInclusive;
394 }
395 }
396
397 return (int) $order->tax_behavior === 2;
398 }
399
400 /**
401 * Returns display-ready fee tax line rows, filtering out zero-amount entries
402 * and pre-computing the translated label for each surface to render.
403 * Each entry: ['label' => string, 'tax_amount' => int, 'inclusive' => bool, 'display_label' => string]
404 */
405 public static function buildFeeTaxLineRows(array $feeTaxLines)
406 {
407 $rows = [];
408 foreach ($feeTaxLines as $ftl) {
409 $taxAmount = (int) Arr::get($ftl, 'tax_amount', 0);
410 if ($taxAmount <= 0) {
411 continue;
412 }
413 $inclusive = !empty($ftl['inclusive']);
414 $feeLabel = Arr::get($ftl, 'label', __('fee', 'fluent-cart'));
415 /* translators: %1$s: fee label */
416 $displayLabel = $inclusive
417 ? sprintf(__('Included in %1$s', 'fluent-cart'), $feeLabel)
418 : sprintf(__('Added on %1$s', 'fluent-cart'), $feeLabel);
419 $rows[] = [
420 'label' => $feeLabel,
421 'tax_amount' => $taxAmount,
422 'inclusive' => $inclusive,
423 'display_label' => $displayLabel,
424 ];
425 }
426 return $rows;
427 }
428
429 /**
430 * Aggregate the real taxable base per rate from item-level tax data.
431 *
432 * For inclusive lines the stored taxable_amount is gross (base + tax), so the
433 * rate's own tax is subtracted to get the net base. Amounts can be fractional
434 * cents when the store uses subtotal tax rounding — callers round for display.
435 *
436 * @param iterable $items Order items (models or arrays) or cart line arrays,
437 * each carrying line_meta.tax_config.
438 * @return array [rate_id => ['base' => float, 'tax' => float]]
439 */
440 public static function computeRateBaseMap($items, $excludeInclusive = false)
441 {
442 $map = [];
443 foreach ($items as $item) {
444 if (is_array($item)) {
445 $lineMeta = Arr::get($item, 'line_meta', []);
446 } else {
447 $lineMeta = is_object($item) ? $item->line_meta : [];
448 }
449 if (!is_array($lineMeta)) {
450 continue;
451 }
452 $taxConfig = Arr::get($lineMeta, 'tax_config');
453 if (is_array($taxConfig)) {
454 $rates = Arr::get($taxConfig, 'rates', []);
455 $inclusive = (bool) Arr::get($taxConfig, 'inclusive', false);
456 } else {
457 // Legacy signup-fee shape: rates at the line_meta root.
458 $rates = Arr::get($lineMeta, 'rates', []);
459 $inclusive = (bool) Arr::get($lineMeta, 'inclusive', false);
460 }
461 if (!$rates || !is_array($rates)) {
462 continue;
463 }
464 // Fixed-mode reverse charge keeps tax-inclusive prices untouched — those
465 // lines carry no reversible VAT, so callers can exclude them from the map.
466 if ($excludeInclusive && $inclusive) {
467 continue;
468 }
469 foreach ($rates as $rate) {
470 $rateId = (int) Arr::get($rate, 'rate_id', 0);
471 $tax = (float) Arr::get($rate, 'tax_amount', 0);
472 $taxable = (float) Arr::get($rate, 'taxable_amount', 0);
473 if (!isset($map[$rateId])) {
474 $map[$rateId] = ['base' => 0.0, 'tax' => 0.0];
475 }
476 $map[$rateId]['base'] += $inclusive ? max(0, $taxable - $tax) : $taxable;
477 $map[$rateId]['tax'] += $tax;
478 }
479 }
480 return $map;
481 }
482
483 /**
484 * Build a folded per-rate row array for the 3-column tax breakdown table.
485 *
486 * Merges order-tax rate lines with shipping-tax lines by rate_id so each rate
487 * appears once with its combined tax and computed taxable base.
488 *
489 * @param array $rateLines Output of Order::getDisplayTaxLines().
490 * @param array $shippingLines Output of Order::getDisplayShippingTaxLines().
491 * @param string $taxAmountKey Key holding the order tax amount in each $rateLine ('order_tax').
492 * @param bool $isShippingInclusive Whether shipping tax is inclusive.
493 * @param array $rateBaseMap Output of computeRateBaseMap() — exact per-rate bases
494 * from item data. A rate entry is only trusted when its
495 * item tax sum matches the rate row's tax (guards fee
496 * items and legacy rows missing from the item data).
497 * @return array Each row: ['label'=>string,'base'=>int,'tax'=>int,'inclusive'=>bool]
498 */
499 public static function buildFoldedRateRows($rateLines, $shippingLines, $taxAmountKey, $isShippingInclusive, $rateBaseMap = [])
500 {
501 $rateLines = is_array($rateLines) ? $rateLines : [];
502 $shippingLines = is_array($shippingLines) ? $shippingLines : [];
503 if (empty($rateLines) && empty($shippingLines)) {
504 return [];
505 }
506 $shippingByRate = [];
507 foreach ($shippingLines as $shLine) {
508 $shippingByRate[(int) Arr::get($shLine, 'rate_id', 0)] = (int) Arr::get($shLine, 'shipping_tax', 0);
509 }
510 $rows = [];
511 foreach ($rateLines as $rateKey => $rateLine) {
512 $rid = (int) Arr::get($rateLine, 'rate_id', $rateKey);
513 $ratePercent = (float) Arr::get($rateLine, 'rate_percent', 0);
514 $shipForRate = isset($shippingByRate[$rid]) ? (int) $shippingByRate[$rid] : 0;
515 unset($shippingByRate[$rid]);
516 $productTax = (int) Arr::get($rateLine, $taxAmountKey, 0);
517 $combinedTax = $productTax + $shipForRate;
518 $mapEntry = isset($rateBaseMap[$rid]) ? $rateBaseMap[$rid] : null;
519 if ($mapEntry && abs($mapEntry['tax'] - $productTax) <= 1) {
520 // Exact net base from item-level data. Shipping has no stored per-rate
521 // base, so its (small) contribution is still derived from its tax amount.
522 $base = (int) round($mapEntry['base']);
523 if ($shipForRate > 0 && $ratePercent > 0) {
524 $base += (int) round($shipForRate * 100 / $ratePercent);
525 }
526 } elseif ($ratePercent > 0) {
527 // No trustworthy item data (legacy order / fee tax folded into the row):
528 // approximate the base from the rounded tax.
529 $base = (int) round($combinedTax * 100 / $ratePercent);
530 } else {
531 $base = (int) Arr::get($rateLine, 'taxable_amount', 0);
532 }
533 $label = (string) Arr::get($rateLine, 'rate_label', Arr::get($rateLine, 'label', ''));
534 $rows[] = [
535 'label' => $label,
536 'base' => $base,
537 'tax' => $combinedTax,
538 'inclusive' => !empty($rateLine['inclusive']),
539 ];
540 }
541 foreach ($shippingByRate as $sid => $shAmount) { // shipping-only rates
542 if ($shAmount <= 0) {
543 continue;
544 }
545 $shLine = null;
546 foreach ($shippingLines as $cand) {
547 if ((int) Arr::get($cand, 'rate_id', 0) === (int) $sid) {
548 $shLine = $cand;
549 break;
550 }
551 }
552 $ratePercent = (float) Arr::get($shLine, 'rate_percent', 0);
553 $base = $ratePercent > 0 ? (int) round($shAmount * 100 / $ratePercent) : 0;
554 $rows[] = [
555 'label' => (string) Arr::get($shLine, 'rate_label', Arr::get($shLine, 'label', '')),
556 'base' => $base,
557 'tax' => $shAmount,
558 'inclusive' => (bool) $isShippingInclusive,
559 ];
560 }
561 return $rows;
562 }
563
564 /**
565 * Rebuild the per-rate breakdown rows for a reverse-charge order.
566 *
567 * Under reverse charge the stored tax lines (and shipping tax lines) are zeroed
568 * at order placement, but the original per-rate amounts survive in item-level
569 * `line_meta.tax_config.rates`, and the pre-zeroing shipping lines survive in the
570 * rate-meta snapshot (`vat_reverse.reverse_charge_shipping_tax_lines`). This
571 * restores both and folds shipping into the rate rows so post-order surfaces can
572 * render the same "Tax breakdown by rate" table as a normal order.
573 *
574 * Returns [] when no per-rate data is recoverable (old orders without line-level
575 * tax data) — callers must fall back to the simplified reverse-charge box.
576 *
577 * @return array Same row shape as buildFoldedRateRows().
578 */
579 public static function buildReverseChargeRateRows(Order $order)
580 {
581 $order->loadMissing(['orderTaxRates', 'order_items']);
582
583 $primaryRate = $order->orderTaxRates ? $order->orderTaxRates->first() : null;
584 $meta = ($primaryRate && is_array($primaryRate->meta)) ? $primaryRate->meta : [];
585
586 // Fixed-mode reverse charge leaves tax-inclusive prices (and their embedded
587 // VAT) untouched — only dynamic mode reverses the inclusive portion. Exclude
588 // inclusive lines in fixed mode so the rate rows sum to the reversed total.
589 $rcNonDynamic = Arr::get($meta, 'reverse_charge_price_mode', 'fixed') !== 'dynamic';
590
591 $items = [];
592 if ($order->order_items) {
593 foreach ($order->order_items as $item) {
594 if ($item->payment_type === 'fee') {
595 continue;
596 }
597 $items[] = $item;
598 }
599 }
600 $rateBaseMap = self::computeRateBaseMap($items, $rcNonDynamic);
601
602 // Pre-zeroing shipping tax lines snapshot (may be [] on older orders).
603 $shippingLines = (array) Arr::get($meta, 'vat_reverse.reverse_charge_shipping_tax_lines', []);
604
605 // Stored rate lines are zeroed under reverse charge — restore each rate's
606 // amount from the item-level map. Rates without a map entry have nothing
607 // reversible (fixed-mode inclusive-only rates / legacy rows) and are dropped.
608 $restoredLines = [];
609 foreach ($order->getDisplayTaxLines() as $rateKey => $rateLine) {
610 $rid = (int) Arr::get($rateLine, 'rate_id', $rateKey);
611 if (!isset($rateBaseMap[$rid])) {
612 continue;
613 }
614 $rateLine['order_tax'] = (int) round($rateBaseMap[$rid]['tax']);
615 $restoredLines[] = $rateLine;
616 }
617
618 if (empty($restoredLines) && empty($shippingLines)) {
619 return [];
620 }
621
622 return self::buildFoldedRateRows(
623 $restoredLines,
624 $shippingLines,
625 'order_tax',
626 self::isShippingTaxInclusive($order),
627 $rateBaseMap
628 );
629 }
630
631 /**
632 * Checkout-side variant: determines whether the shipping tax is inclusive of the
633 * shipping price. Shipping always follows the store-level tax mode, so this returns
634 * true only when store_tax_behavior === 2 (inclusive). Per-product inclusive flags
635 * are intentionally NOT consulted — they do not govern shipping.
636 */
637 public static function isShippingTaxInclusiveFromTaxData(array $taxData)
638 {
639 $storeBehavior = (int) Arr::get($taxData, 'store_tax_behavior', Arr::get($taxData, 'tax_behavior', 2));
640
641 return $storeBehavior === 2;
642 }
643
644 protected static function getTaxSettings(): array
645 {
646 return (array) get_option('fluent_cart_tax_configuration_settings', []);
647 }
648
649 public static function getTaxDisplayMode(): string
650 {
651 // Backward compat: legacy stored values ('both', 'label', 'tooltip', or anything
652 // else) all collapse to 'itemized'. Only an explicit 'simplified' stays simplified.
653 $mode = Arr::get(self::getTaxSettings(), 'checkout_tax_breakdown_display', 'itemized');
654 return $mode === 'simplified' ? 'simplified' : 'itemized';
655 }
656
657 protected static function getTaxDisplayLabel(): string
658 {
659 $label = trim((string) Arr::get(self::getTaxSettings(), 'tax_display_label', ''));
660 return $label !== '' ? $label : __('Tax', 'fluent-cart');
661 }
662
663 protected static function getPriceSuffixIncluded(): string
664 {
665 return (string) Arr::get(self::getTaxSettings(), 'price_suffix_included', '');
666 }
667
668 public static function buildSimpleLine(array $summary): array
669 {
670 $label = self::getTaxDisplayLabel();
671 $isRc = !empty($summary['isReverseCharge']);
672 $total = (int) Arr::get($summary, 'totalOrderTax', 0);
673 $payable = (int) Arr::get($summary, 'payableTax', 0);
674 $folded = (array) Arr::get($summary, 'foldedRateLines', []);
675 $hasDetails = !empty($folded) || $total > 0 || $isRc;
676
677 if ($isRc) {
678 $valueType = 'reverse_charge';
679 $value = __('Reverse charge', 'fluent-cart');
680 } elseif ($payable === 0 && $total > 0) {
681 $valueType = 'included';
682 $suffix = self::getPriceSuffixIncluded();
683 if ($suffix === '') {
684 $suffix = __('(incl.)', 'fluent-cart');
685 }
686 /* translators: %1$s: formatted tax amount, %2$s: inclusive suffix */
687 $value = sprintf(__('%1$s %2$s', 'fluent-cart'), html_entity_decode(Helper::toDecimal($total), ENT_QUOTES, 'UTF-8'), $suffix);
688 } else {
689 $valueType = 'amount';
690 $value = html_entity_decode(Helper::toDecimal($payable), ENT_QUOTES, 'UTF-8');
691 }
692
693 return [
694 'label' => $label,
695 'value' => $value,
696 'valueType' => $valueType,
697 'hasDetails' => $hasDetails,
698 ];
699 }
700 }
701