PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.2
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.2
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.5.2, at app/Services/Renderer/Receipt/TaxSummaryHelper.php

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