'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')}
);
}