← All changes
|
app/Services/Renderer/Receipt/TaxSummaryHelper.php
+243
-8
1.5.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 |
| @@ -162,8 +163,10 @@ | ||
| 162 | 163 | 'taxRateLines' => $taxRateLines, |
| 163 | 164 | 'shippingTaxLines' => $shippingTaxLines, |
| 164 | 165 | 'foldedRateLines' => [], |
| 165 | 166 | 'includedInPrices' => 0, |
| 167 | + 'displayMode' => self::getTaxDisplayMode(), | |
| 168 | + 'simpleLine' => null, | |
| 166 | 169 | ]; |
| 167 | 170 | } |
| 168 | 171 | |
| 169 | 172 | $shouldRender = apply_filters('fluent_cart/tax_summary_should_render', true, $order); |
| @@ -171,8 +174,9 @@ | ||
| 171 | 174 | $reversedTaxTotal = 0; |
| 172 | 175 | $reversedShippingTax = 0; |
| 173 | 176 | $rcPriceMode = ''; |
| 174 | 177 | $rcShippingAdjustment = 0; |
| 178 | + $rcTotalAdjustment = 0; | |
| 175 | 179 | $shippingNetStored = false; |
| 176 | 180 | if ($isReverseCharge) { |
| 177 | 181 | $primaryRate = $order->orderTaxRates ? $order->orderTaxRates->first() : null; |
| 178 | 182 | if ($primaryRate) { |
| @@ -180,8 +184,18 @@ | ||
| 180 | 184 | $reversedTaxTotal = (int) Arr::get($meta, 'reverse_charge_original_tax_total', 0); |
| 181 | 185 | $reversedShippingTax = (int) Arr::get($meta, 'reverse_charge_original_shipping_tax', 0); |
| 182 | 186 | $rcPriceMode = (string) Arr::get($meta, 'reverse_charge_price_mode', 'fixed'); |
| 183 | 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; | |
| 184 | 198 | } |
| 185 | 199 | // Only apply the display adjustment for orders where shipping_total in DB is still gross. |
| 186 | 200 | // New orders (shipping_net_stored = true) already have net shipping in DB — no adjustment. |
| 187 | 201 | if ($rcPriceMode === 'dynamic' && $isShippingInclusive && $reversedShippingTax > 0 && !$shippingNetStored) { |
| @@ -192,9 +206,9 @@ | ||
| 192 | 206 | // For inclusive shipping it either already reduced the price (dynamic) or didn't change |
| 193 | 207 | // it at all (fixed), so the strikethrough is misleading in both cases. |
| 194 | 208 | $showRcShippingRow = $isReverseCharge && !$isShippingInclusive && $reversedShippingTax > 0; |
| 195 | 209 | |
| 196 | - return [ | |
| 210 | + $summary = [ | |
| 197 | 211 | 'shouldRender' => (bool) $shouldRender, |
| 198 | 212 | 'isReverseCharge' => $isReverseCharge, |
| 199 | 213 | 'inclusiveTax' => $inclusiveTax, |
| 200 | 214 | 'exclusiveTax' => $exclusiveTax, |
| @@ -210,11 +224,22 @@ | ||
| 210 | 224 | 'reversedTaxTotal' => $reversedTaxTotal, |
| 211 | 225 | 'reversedShippingTax' => $reversedShippingTax, |
| 212 | 226 | 'rcPriceMode' => $rcPriceMode, |
| 213 | 227 | 'rcShippingAdjustment' => $rcShippingAdjustment, |
| 214 | - 'rcTotalAdjustment' => $rcShippingAdjustment, | |
| 228 | + 'rcTotalAdjustment' => $rcTotalAdjustment ?: $rcShippingAdjustment, | |
| 215 | 229 | 'showRcShippingRow' => $showRcShippingRow, |
| 216 | - 'foldedRateLines' => self::buildFoldedRateRows($taxRateLines, $shippingTaxLines, 'order_tax', $isShippingInclusive), | |
| 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 | + ), | |
| 217 | 242 | // Inclusive shipping tax follows the store global tax mode: when shipping is |
| 218 | 243 | // priced inclusive its tax is already baked into the shipping price, so it |
| 219 | 244 | // belongs in "of which included in prices". This keeps |
| 220 | 245 | // includedInPrices + payableTax === totalOrderTax on every surface. |
| @@ -219,11 +244,26 @@ | ||
| 219 | 244 | // belongs in "of which included in prices". This keeps |
| 220 | 245 | // includedInPrices + payableTax === totalOrderTax on every surface. |
| 221 | 246 | 'includedInPrices' => $inclusiveTax + $inclusiveFeeTax + ($isShippingInclusive ? $shippingTax : 0), |
| 222 | 247 | ]; |
| 248 | + | |
| 249 | + $summary['displayMode'] = self::getTaxDisplayMode(); | |
| 250 | + $summary['simpleLine'] = self::buildSimpleLine($summary); | |
| 251 | + | |
| 252 | + return $summary; | |
| 223 | 253 | } |
| 224 | 254 | |
| 225 | 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 | + /** | |
| 226 | 266 | * For a mixed-inclusive rate (same rate used inclusively on some items and exclusively on others), |
| 227 | 267 | * read each order item's line_meta to produce the correct inclusive/exclusive split. |
| 228 | 268 | * Handles both item shapes: |
| 229 | 269 | * - Current (all item types): line_meta.tax_config.rates[] + line_meta.tax_config.inclusive |
| @@ -397,8 +437,62 @@ | ||
| 397 | 437 | return $rows; |
| 398 | 438 | } |
| 399 | 439 | |
| 400 | 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 | + /** | |
| 401 | 495 | * Build a folded per-rate row array for the 3-column tax breakdown table. |
| 402 | 496 | * |
| 403 | 497 | * Merges order-tax rate lines with shipping-tax lines by rate_id so each rate |
| 404 | 498 | * appears once with its combined tax and computed taxable base. |
| @@ -406,11 +500,15 @@ | ||
| 406 | 500 | * @param array $rateLines Output of Order::getDisplayTaxLines(). |
| 407 | 501 | * @param array $shippingLines Output of Order::getDisplayShippingTaxLines(). |
| 408 | 502 | * @param string $taxAmountKey Key holding the order tax amount in each $rateLine ('order_tax'). |
| 409 | 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). | |
| 410 | 508 | * @return array Each row: ['label'=>string,'base'=>int,'tax'=>int,'inclusive'=>bool] |
| 411 | 509 | */ |
| 412 | - public static function buildFoldedRateRows($rateLines, $shippingLines, $taxAmountKey, $isShippingInclusive) | |
| 510 | + public static function buildFoldedRateRows($rateLines, $shippingLines, $taxAmountKey, $isShippingInclusive, $rateBaseMap = []) | |
| 413 | 511 | { |
| 414 | 512 | $rateLines = is_array($rateLines) ? $rateLines : []; |
| 415 | 513 | $shippingLines = is_array($shippingLines) ? $shippingLines : []; |
| 416 | 514 | if (empty($rateLines) && empty($shippingLines)) { |
| @@ -425,12 +523,25 @@ | ||
| 425 | 523 | $rid = (int) Arr::get($rateLine, 'rate_id', $rateKey); |
| 426 | 524 | $ratePercent = (float) Arr::get($rateLine, 'rate_percent', 0); |
| 427 | 525 | $shipForRate = isset($shippingByRate[$rid]) ? (int) $shippingByRate[$rid] : 0; |
| 428 | 526 | 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); | |
| 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 | + } | |
| 433 | 544 | $label = (string) Arr::get($rateLine, 'rate_label', Arr::get($rateLine, 'label', '')); |
| 434 | 545 | $rows[] = [ |
| 435 | 546 | 'label' => $label, |
| 436 | 547 | 'base' => $base, |
| @@ -461,8 +572,75 @@ | ||
| 461 | 572 | return $rows; |
| 462 | 573 | } |
| 463 | 574 | |
| 464 | 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 | + /** | |
| 465 | 643 | * Checkout-side variant: determines whether the shipping tax is inclusive of the |
| 466 | 644 | * shipping price. Shipping always follows the store-level tax mode, so this returns |
| 467 | 645 | * true only when store_tax_behavior === 2 (inclusive). Per-product inclusive flags |
| 468 | 646 | * are intentionally NOT consulted — they do not govern shipping. |
| @@ -471,6 +649,63 @@ | ||
| 471 | 649 | { |
| 472 | 650 | $storeBehavior = (int) Arr::get($taxData, 'store_tax_behavior', Arr::get($taxData, 'tax_behavior', 2)); |
| 473 | 651 | |
| 474 | 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 | + ]; | |
| 475 | 710 | } |
| 476 | 711 | } |