'use client'; import type { TaxBreakdown } from 'brainerce'; import { formatPrice } from 'brainerce'; import { useTranslations } from '@/core/lib/translations'; import { useCurrency } from '@/core/lib/use-currency'; import { cn } from '@/core/lib/utils'; interface TaxDisplayProps { /** Whether shipping address has been set */ addressSet: boolean; /** Tax amount string from checkout (only available after address is set) */ taxAmount?: string; /** Detailed tax breakdown (optional) */ taxBreakdown?: TaxBreakdown | null; className?: string; } export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: TaxDisplayProps) { const t = useTranslations('checkout'); const tc = useTranslations('common'); const currency = useCurrency(); // Before address is set if (!addressSet) { return (
{tc('tax')} {t('calculatedAfterAddress')}
); } // Inclusive-pricing stores back the tax out of the displayed price, so // `checkout.taxAmount` is stored as 0 and the real VAT lives on the // aggregated breakdown. Surface whichever is present. const explicitTax = taxAmount ? parseFloat(taxAmount) : 0; const tax = explicitTax > 0 ? explicitTax : typeof taxBreakdown?.totalTax === 'number' ? taxBreakdown.totalTax : 0; // Inclusive (VAT) stores already have this tax INSIDE the line prices, so it // has to read as informational and never as an amount added on top. Without // it the summary prints Subtotal + Shipping + GST + QST above a Total that is // not their sum, and the shopper reads that as a mistake. Same treatment, and // deliberately the same wording, as the order history summary. const taxIncluded = !!taxBreakdown?.pricesIncludeTax; // When there's a per-rate breakdown, prefer per-row display ("VAT 18% ₪3.05"). // Otherwise show a single aggregated tax line. // // `breakdown` holds ONE ROW PER TAX and is routinely more than one: a Quebec // order carries GST 5% and QST 9.975%, a British Columbia one GST 5% and // PST 7%, and both have to be shown separately. Rendering every row is not an // edge case to handle later. // // Read every field defensively. The breakdown is a FROZEN SNAPSHOT: a row // written by older code can reach us with no rate, no name, or a non-numeric // amount, and `item.rate * 100` on such a row prints "NaN%" on something the // buyer treats as a receipt. This mirrors `resolveDisplayTax()` in // @brainerce/types, the platform's canonical filter — keep a row only when it // has a printable name AND a finite amount, and turn a missing rate into null // instead of dropping the row, because the name and the amount are the parts // the shopper actually needs. const taxRows: Array<{ name: string; rate: number | null; amount: number }> = ( taxBreakdown?.breakdown ?? [] ) .filter( (row) => !!row && typeof row.name === 'string' && row.name.length > 0 && typeof row.amount === 'number' && Number.isFinite(row.amount) ) .map((row) => ({ name: row.name, rate: typeof row.rate === 'number' && Number.isFinite(row.rate) ? row.rate : null, amount: row.amount, })); // Driven by the rows that SURVIVED the filter, not by the raw array: if every // row was unprintable, fall through to the single aggregated line below // rather than render an empty box where the tax used to be. const hasBreakdown = taxRows.length > 0; if (hasBreakdown) { return (
{taxRows.map((item, index) => { // Parenthetical parts, in order: the rate when the snapshot carried // one, then the inclusive marker when prices already contain the tax. // Neither present leaves the bare name. // // Joined with a SPACE, not a comma. In Hebrew this renders // `מע"מ (18% כלול)`, and a comma sitting between a Latin numeral and // an RTL word is a bidi neutral that can reorder on screen. The cart's // tax estimate line already ships the same ` %` // space-joined construction, so this follows a proven pattern. // // Rate is printed to 3 decimals with trailing zeros trimmed. // `toFixed(1)` prints Quebec's QST 9.975% as "10.0%", which is the // wrong number on something a buyer treats as a receipt. const parts: string[] = []; if (item.rate !== null) parts.push(`${Number((item.rate * 100).toFixed(3))}%`); if (taxIncluded) parts.push(tc('inclSuffix')); return (
{item.name} {parts.length > 0 ? ` (${parts.join(' ')})` : ''} {formatPrice(item.amount, { currency }) as string}
); })}
); } return (
{/* An inclusive store with no per-rate breakdown lands here — it persists taxAmount=0 and keeps the VAT on `totalTax` — so the label has to say "included" on this line too, or the total still fails to add up. */} {taxIncluded ? tc('taxIncl') : tc('tax')} {tax > 0 ? (formatPrice(tax, { currency }) as string) : t('noTax')}
); }