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

398 lines 17.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
157 if ($inclusiveTax === 0 && $inclusiveFeeTax === 0 && $payableTax === 0 && !$isReverseCharge) {
158 return ['shouldRender' => false];
159 }
160
161 $shouldRender = apply_filters('fluent_cart/tax_summary_should_render', true, $order);
162
163 $reversedTaxTotal = 0;
164 $reversedShippingTax = 0;
165 $rcPriceMode = '';
166 $rcShippingAdjustment = 0;
167 $shippingNetStored = false;
168 if ($isReverseCharge) {
169 $primaryRate = $order->orderTaxRates ? $order->orderTaxRates->first() : null;
170 if ($primaryRate) {
171 $meta = is_array($primaryRate->meta) ? $primaryRate->meta : [];
172 $reversedTaxTotal = (int) Arr::get($meta, 'reverse_charge_original_tax_total', 0);
173 $reversedShippingTax = (int) Arr::get($meta, 'reverse_charge_original_shipping_tax', 0);
174 $rcPriceMode = (string) Arr::get($meta, 'reverse_charge_price_mode', 'fixed');
175 $shippingNetStored = !empty($meta['shipping_net_stored']);
176 }
177 // Only apply the display adjustment for orders where shipping_total in DB is still gross.
178 // New orders (shipping_net_stored = true) already have net shipping in DB — no adjustment.
179 if ($rcPriceMode === 'dynamic' && $isShippingInclusive && $reversedShippingTax > 0 && !$shippingNetStored) {
180 $rcShippingAdjustment = $reversedShippingTax;
181 }
182 }
183 // Show RC shipping strikethrough row only for exclusive shipping tax that was reversed.
184 // For inclusive shipping it either already reduced the price (dynamic) or didn't change
185 // it at all (fixed), so the strikethrough is misleading in both cases.
186 $showRcShippingRow = $isReverseCharge && !$isShippingInclusive && $reversedShippingTax > 0;
187
188 return [
189 'shouldRender' => (bool) $shouldRender,
190 'isReverseCharge' => $isReverseCharge,
191 'inclusiveTax' => $inclusiveTax,
192 'exclusiveTax' => $exclusiveTax,
193 'feeTaxLines' => $feeTaxLines,
194 'feeTaxLineRows' => self::buildFeeTaxLineRows($feeTaxLines),
195 'inclusiveFeeTax' => $inclusiveFeeTax,
196 'shippingTax' => $shippingTax,
197 'shippingTaxLines' => $order->getDisplayShippingTaxLines(),
198 'payableTax' => $payableTax,
199 'totalOrderTax' => $totalOrderTax,
200 'isShippingInclusive' => $isShippingInclusive,
201 'reversedTaxTotal' => $reversedTaxTotal,
202 'reversedShippingTax' => $reversedShippingTax,
203 'rcPriceMode' => $rcPriceMode,
204 'rcShippingAdjustment' => $rcShippingAdjustment,
205 'rcTotalAdjustment' => $rcShippingAdjustment,
206 'showRcShippingRow' => $showRcShippingRow,
207 ];
208 }
209
210 /**
211 * For a mixed-inclusive rate (same rate used inclusively on some items and exclusively on others),
212 * read each order item's line_meta to produce the correct inclusive/exclusive split.
213 * Handles both item shapes:
214 * - Current (all item types): line_meta.tax_config.rates[] + line_meta.tax_config.inclusive
215 * - Legacy signup-fee: line_meta.rates[] + line_meta.inclusive (no tax_config wrapper)
216 * Returns [inclusiveTax, exclusiveTax] in cents.
217 */
218 private static function splitMixedRateTax(Order $order, $rateId)
219 {
220 $order->loadMissing(['order_items']);
221 $incl = 0;
222 $excl = 0;
223 if (!$order->order_items) {
224 return [0, 0];
225 }
226 foreach ($order->order_items as $item) {
227 $lineMeta = $item->line_meta;
228 $taxConfig = Arr::get($lineMeta, 'tax_config');
229 if (is_array($taxConfig)) {
230 $rates = Arr::get($taxConfig, 'rates', []);
231 $lineInclusive = (bool) Arr::get($taxConfig, 'inclusive', false);
232 } else {
233 $rates = Arr::get($lineMeta, 'rates', []);
234 $lineInclusive = (bool) Arr::get($lineMeta, 'inclusive', false);
235 }
236 foreach ($rates as $rate) {
237 if ((int) Arr::get($rate, 'rate_id', 0) !== $rateId) {
238 continue;
239 }
240 $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
241 if ($taxAmount <= 0) {
242 continue;
243 }
244 if ($lineInclusive) {
245 $incl += $taxAmount;
246 } else {
247 $excl += $taxAmount;
248 }
249 }
250 }
251 return [$incl, $excl];
252 }
253
254 /**
255 * Extract per-rate tax breakdown from a single order item.
256 *
257 * Reads `line_meta.tax_config.rates`, filters out zero-amount entries, and
258 * returns a flat array of rate rows. Returns [] for old items without
259 * tax_config — callers must check for empty before looping.
260 */
261 public static function getItemTaxRates(array $item)
262 {
263 // Current items (all types): line_meta.tax_config.rates
264 // Legacy signup_fee items: line_meta.rates (no tax_config wrapper — set directly from signup_fee_tax_config)
265 $taxConfig = Arr::get($item, 'line_meta.tax_config');
266 if (is_array($taxConfig)) {
267 $rates = Arr::get($taxConfig, 'rates', []);
268 $inclusive = (bool) Arr::get($taxConfig, 'inclusive', false);
269 } else {
270 $rates = Arr::get($item, 'line_meta.rates', []);
271 $inclusive = (bool) Arr::get($item, 'line_meta.inclusive', false);
272 }
273
274 if (empty($rates)) {
275 return [];
276 }
277
278 $result = [];
279 foreach ($rates as $rate) {
280 $taxAmount = (int) Arr::get($rate, 'tax_amount', 0);
281 if ($taxAmount <= 0) {
282 continue;
283 }
284 $result[] = [
285 'label' => Arr::get($rate, 'label') ?: __('Tax', 'fluent-cart'),
286 'tax_amount' => $taxAmount,
287 'rate_percent' => max(0.0, (float) Arr::get($rate, 'rate_percent', 0)),
288 'inclusive' => $inclusive,
289 ];
290 }
291
292 return $result;
293 }
294
295 /**
296 * Determine whether the primary tax type for an order is inclusive.
297 * Used for per-item pill display (fct_order_tax_rate has no order_item_id).
298 */
299 public static function isPrimaryTaxInclusive(Order $order)
300 {
301 $order->loadMissing(['orderTaxRates']);
302
303 if ($order->orderTaxRates && $order->orderTaxRates->count() === 1) {
304 $rate = $order->orderTaxRates->first();
305 $meta = is_array($rate->meta) ? $rate->meta : [];
306 if (isset($meta['inclusive'])) {
307 return (bool) $meta['inclusive'];
308 }
309 }
310
311 return (int) $order->tax_behavior === 2;
312 }
313
314 /**
315 * Determine whether the shipping tax on an order was charged inclusive of the
316 * shipping price (vs. added on top). Reads `meta.shipping_inclusive` (written
317 * from the store-level tax mode at order placement) from each rate row that
318 * contributed shipping_tax. Falls back to $order->tax_behavior === 2.
319 *
320 * Shipping always follows the store-level tax mode — per-product `meta.inclusive`
321 * is intentionally NOT consulted here.
322 */
323 public static function isShippingTaxInclusive(Order $order)
324 {
325 $order->loadMissing(['orderTaxRates']);
326
327 if ($order->orderTaxRates && $order->orderTaxRates->count()) {
328 $shippingRatesInclusive = null;
329 foreach ($order->orderTaxRates as $rate) {
330 $meta = is_array($rate->meta) ? $rate->meta : [];
331 // On reverse-charge orders shipping_tax is zeroed; detect via the
332 // pre-zeroed snapshot stored in meta before falling back.
333 $hasShippingContrib = (int) $rate->shipping_tax > 0
334 || (int) Arr::get($meta, 'reverse_charge_original_shipping_tax', 0) > 0;
335 if (!$hasShippingContrib) {
336 continue;
337 }
338 if (!isset($meta['shipping_inclusive'])) {
339 continue;
340 }
341 $rateInclusive = (bool) $meta['shipping_inclusive'];
342 if ($shippingRatesInclusive === null) {
343 $shippingRatesInclusive = $rateInclusive;
344 } elseif ($shippingRatesInclusive !== $rateInclusive) {
345 return (int) $order->tax_behavior === 2;
346 }
347 }
348 if ($shippingRatesInclusive !== null) {
349 return $shippingRatesInclusive;
350 }
351 }
352
353 return (int) $order->tax_behavior === 2;
354 }
355
356 /**
357 * Returns display-ready fee tax line rows, filtering out zero-amount entries
358 * and pre-computing the translated label for each surface to render.
359 * Each entry: ['label' => string, 'tax_amount' => int, 'inclusive' => bool, 'display_label' => string]
360 */
361 public static function buildFeeTaxLineRows(array $feeTaxLines)
362 {
363 $rows = [];
364 foreach ($feeTaxLines as $ftl) {
365 $taxAmount = (int) Arr::get($ftl, 'tax_amount', 0);
366 if ($taxAmount <= 0) {
367 continue;
368 }
369 $inclusive = !empty($ftl['inclusive']);
370 $feeLabel = Arr::get($ftl, 'label', __('fee', 'fluent-cart'));
371 /* translators: %1$s: fee label */
372 $displayLabel = $inclusive
373 ? sprintf(__('Included in %1$s', 'fluent-cart'), $feeLabel)
374 : sprintf(__('Added on %1$s', 'fluent-cart'), $feeLabel);
375 $rows[] = [
376 'label' => $feeLabel,
377 'tax_amount' => $taxAmount,
378 'inclusive' => $inclusive,
379 'display_label' => $displayLabel,
380 ];
381 }
382 return $rows;
383 }
384
385 /**
386 * Checkout-side variant: determines whether the shipping tax is inclusive of the
387 * shipping price. Shipping always follows the store-level tax mode, so this returns
388 * true only when store_tax_behavior === 2 (inclusive). Per-product inclusive flags
389 * are intentionally NOT consulted — they do not govern shipping.
390 */
391 public static function isShippingTaxInclusiveFromTaxData(array $taxData)
392 {
393 $storeBehavior = (int) Arr::get($taxData, 'store_tax_behavior', Arr::get($taxData, 'tax_behavior', 2));
394
395 return $storeBehavior === 2;
396 }
397 }
398