InvoiceBLK
The body of an invoice. The line items are a Table whose rows carry their values - quantity, net unit price, tax rate - and the runtime numbers them, formats every amount in the invoice's locale and currency (Intl.NumberFormat) and writes the totals: the net sum, the tax per rate and the amount due. The arithmetic is exact and rounds as usual in Germany - each line to the cent, the tax once per rate on that rate's net sum, half away from zero. Put it into a Letter for the letterhead and the window envelope: printed, the items run over pages with the header row on every page, and the totals never start a page alone. German comes first, English second; df$.shadcn.invoice.labels() adds a language.
On this page (12)
§Rounded the German way
§Exact amounts
Three SMS at 0,335 € are 1,005 € and round to 1,01 € (floating point would give 1,00 €). The 19 % tax is computed once on the 19 % lines' net sum - 2.898,51 € x 19 % = 550,7169 € → 550,72 € -, the 7 % tax on the book's 49,90 € (3,49 €); the amount due is 3.502,62 €. Rounding is half away from zero, credits included.
§US invoice
§Invoice 1087 in US dollars
lang='en-US' and data-currency='USD': $1,200.00, 8.875%, the English totals (Subtotal, Tax 8.875% on $1,439.88, Total). The consulting line has a rate of 0: it counts toward the subtotal and gets no tax row.
§In a letter, over three pages
§Rechnung 2026-0142: 30 lines in a DIN 5008 letter
An invoice inside a Letter (form B). The rows carry their values; the runtime numbers them, formats every amount in German (1.140,00 €) and writes the totals: the net sum, 19 % and 7 % tax on their own net sums, the amount due. Open it full screen and print: the 30 lines run over three pages, the header row on each, the totals with the last three rows. The bank data on the section (data-iban, data-bic, data-payee, data-remittance) becomes a GiroCode beside the payment terms: an EPC QR code that hands the transfer of the amount due to a banking app. The State tab switches the locale and the currency - the column headers stay your text, and in another currency than the euro the code hides (a SEPA transfer is in euro).
§The totals follow the lines
§Live: add a line, switch the language
Add a line appends a row with its values (df$): the invoice adds it up again and fires invoice-totals. Deutsch / English set the state's locale - the numbers and the totals' words follow; the column headers are the page's text.
§States
Named states via the shared State API, bound on each invoice:
default- the invoice formatted and added up in the locale and currency of its config (data-locale/data-currencyon the section)
The config is { locale, currency }; a setState merges it, so a locale alone keeps the currency. The first example's invoice carries data-state-demo.
Machine contract - verified against invoice.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
locale | enum | de-DE, de-CH, en-US, en-GB | de-DE | The locale numbers and the totals' words are formatted in - setState('default', { locale: 'en-US' }); mirrored as data-locale. |
currency | enum | EUR, USD, CHF, GBP, JPY | EUR | The currency amounts are shown and rounded in - setState('default', { currency: 'USD' }); mirrored as data-currency. |
§API
Generated from invoice.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type InvoiceState = 'default' - setState(name, config) takes the config of the state it names.
| State | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
default | The invoice formatted and added up.
|
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends InvoiceState>(name: S, config?: InvoiceStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: InvoiceState; config: InvoiceStateConfigs[InvoiceState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: InvoiceState; config: InvoiceStateConfigs[InvoiceState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: InvoiceState; config: InvoiceStateConfigs[InvoiceState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.invoiceApi.setState<S extends InvoiceState>(el: HTMLElement, name: S, config?: InvoiceStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.invoiceApi.getState(el: HTMLElement): { name: InvoiceState; config: InvoiceStateConfigs[InvoiceState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.invoiceApi.render(state: { name: InvoiceState; config: InvoiceStateConfigs[InvoiceState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.invoiceApi.store(el: HTMLElement): Store<{ name: InvoiceState; config: InvoiceStateConfigs[InvoiceState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.invoiceApi.commit<S extends InvoiceState>(el: HTMLElement, name: S, config?: InvoiceStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.invoiceStates: InvoiceState[] | The declared states, 'default' first: default. |
df$.shadcn.invoice
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
compute(lines: InvoiceLine[], currency?: string): InvoiceTotals | Add up invoice lines the way the invoice does - each amount rounded to the currency's minor unit, the tax once per rate on that rate's net sum, half away from zero.
Returns | |||||||||
epc(transfer: EpcTransfer): string | The text of an EPC QR code ("GiroCode") for a SEPA credit transfer - what an invoice's payment code carries for its amount due; put it into a QR Code's data-value with data-ecc="M" for a code of your own.
Returns | |||||||||
totals(target: string | HTMLElement): InvoiceTotals | null | The totals an invoice element shows now.
Returns | |||||||||
labels(lang: string, labels: Partial<InvoiceLabels>): void | Add or change the words of the totals for a language; invoices in that language re-render.
|
Events
| Event | Description | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
invoice-totals | Fires after the invoice was formatted and added up - at init, on setState, and when a line row or its values change.
|
Types
| Type | Description | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
EpcTransfer | A SEPA credit transfer as an EPC QR code ("GiroCode") carries it - EPC069-12, version 002.
| |||||||||||||||||||||
InvoiceLabels | The words the runtime writes into the totals; {rate} and {base} are filled in.
| |||||||||||||||||||||
InvoiceLine | One line of an invoice: decimal strings with a dot ("1.5", "95.00", "19"), or numbers.
| |||||||||||||||||||||
InvoiceTax | The tax of one rate.
| |||||||||||||||||||||
InvoiceTotals | What an invoice adds up to - every amount a decimal string in the currency's minor unit.
| |||||||||||||||||||||
InvoiceTotalsDetail | What invoice-totals carries.
|
§CSS view file
@layer components { /* -- Invoice: the body of an invoice, usually inside a Letter's .letter-body - the title, the invoice data (.invoice-meta), the line items (a Table, .invoice-items) and the totals the runtime writes (.invoice-totals: net sum, tax per rate, amount due), then payment terms as plain text. Numbers are tabular and end-aligned; in print the items table runs over pages with its header repeated (Table's print rules) and the totals stay with the last rows. */ /* a block, not a grid: Chromium sizes grid rows before it paginates, so the header row the items table repeats on later pages pushed the table past the totals, which then overlapped its last rows */ .invoice { display: block; font-variant-numeric: tabular-nums; & > * + * { margin-block-start: 1rem; } } /* zero specificity: the parts lose their own block margins (a dl's, a p's), the 1rem above wins */ :where(.invoice) > :where(*) { margin-block: 0; } .invoice-title { font-size: 1.25em; font-weight: 650; letter-spacing: -0.01em; } .invoice-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.5rem 1.25rem; & dt { font-size: 0.8em; color: var(--muted-foreground); } & dd { margin: 0; font-weight: 500; } } .invoice-items { font-size: 0.9em; /* compact rows: an A4 page holds about twice the lines of a default Table */ & :is(.table-head, .table-cell) { padding: 0.4rem 0.6rem; } & :is(.table-head, .table-cell)[data-invoice="description"], & .table-cell:not([data-invoice]) { white-space: normal; } & [data-invoice] { white-space: nowrap; } } .invoice-totals { inline-size: min(100%, 22rem); margin-inline-start: auto; border-collapse: collapse; font-size: 0.9em; & :is(th, td) { padding: 0.3rem 0; font-weight: 400; text-align: start; } & td { padding-inline-start: 1.5rem; text-align: end; white-space: nowrap; } & .invoice-tax :is(th, td) { color: var(--muted-foreground); } & .invoice-total :is(th, td) { padding-block-start: 0.5rem; border-block-start: 2px solid var(--foreground); font-weight: 650; font-size: 1.1em; } } /* a value that is no number (data-price="12,50"): the row is marked, the reason replaces the totals */ .invoice-items tr[data-invalid] > td { background-color: color-mix(in oklch, var(--destructive) 12%, transparent); } .invoice-totals .invoice-error th { color: var(--destructive); font-weight: 600; } .invoice-note { font-size: 0.9em; color: var(--muted-foreground); } /* the payment code (a QR Code the runtime fills with an EPC transfer) beside the payment terms; the runtime hides the code when there is no transfer to scan (not in euro, nothing due) */ .invoice-payment { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem 1.25rem; break-inside: avoid; & > .qr-code[hidden] { display: none; } & > .invoice-note { flex: 1 1 16rem; margin: 0; } } @media print { /* the totals never start a page alone: with the items table's data-keep-last they take its last three rows along (VERIFIED: tmp/print-table-probe2.ts - break-before: avoid on the block after the table pulls rows over) */ .invoice-totals { break-before: avoid; break-inside: avoid; } .invoice-meta { break-inside: avoid; } .invoice :is(.invoice-tax :is(th, td), .invoice-meta dt, .invoice-note) { color: #444; } .invoice .invoice-total :is(th, td) { border-block-start-color: #000; } }}@media (forced-colors: active) { @layer components { .invoice-totals .invoice-total :is(th, td) { border-block-start-color: CanvasText; } }}@media (prefers-contrast: more) { @layer components { .invoice :is(.invoice-meta dt, .invoice-note, .invoice-totals .invoice-tax :is(th, td)) { color: var(--foreground); } }}§JS view file
// -- Invoice ---------------------------------------------------// The body of an invoice: the line items are a Table whose rows carry their values (data-quantity, data-price,// data-vat); the runtime numbers the positions, formats quantity, unit price, tax rate and amount in the// invoice's locale and currency (Intl.NumberFormat) and writes the totals - the net sum, the tax per rate and// the amount due - into .invoice-totals. The arithmetic is exact (decimal strings, BigInt) and rounds the// German way: each line amount to the cent, the tax once per rate on that rate's net sum, both half away from// zero (kaufmännisch). With bank data on the section (data-iban, data-payee ...) it fills the QR Code in// .invoice-payment with an EPC transfer of the amount due (GiroCode, EPC069-12), composed through the QR Code's// data-value. State API (AGENTS.md "State API"): one state, 'default'; the locale and the currency are// its config ({ locale, currency }); render() reproduces the authored markup 1:1.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type// this file's API docs state - arguments, return values, event details - holds// against its code: a wrong one is a new type error and fails the build./** One line of an invoice: decimal strings with a dot ("1.5", "95.00", "19"), or numbers. */interface InvoiceLine { /** the quantity (may be fractional, negative for a credit) */ quantity: string | number; /** the net unit price in the invoice's currency */ price: string | number; /** the tax rate in percent (19, 7, 0, 8.875) */ vat: string | number;}/** The tax of one rate. */interface InvoiceTax { /** the rate in percent, as written ("19") */ rate: string; /** the net sum of the lines at this rate (a decimal string in the currency's minor unit: "1800.00") */ base: string; /** the tax on that sum, rounded half away from zero ("342.00") */ tax: string;}/** What an invoice adds up to - every amount a decimal string in the currency's minor unit. */interface InvoiceTotals { /** the ISO 4217 currency code */ currency: string; /** the currency's minor-unit digits (EUR 2, JPY 0) */ digits: number; /** each line's net amount, in line order, rounded half away from zero */ lines: string[]; /** the net sum of all lines */ net: string; /** one entry per tax rate above zero, in order of first appearance */ taxes: InvoiceTax[]; /** the amount due: net sum plus every tax */ total: string;}/** The words the runtime writes into the totals; {rate} and {base} are filled in. */interface InvoiceLabels { /** the net sum row ("Summe netto", "Subtotal") */ net: string; /** one row per tax rate ("zzgl. {rate} USt auf {base}", "Tax {rate} on {base}") */ tax: string; /** the amount due ("Rechnungsbetrag", "Total") */ total: string;}/** What invoice-totals carries. */interface InvoiceTotalsDetail { /** the locale the invoice is formatted in (a BCP 47 tag) */ locale: string; /** the totals as decimal strings - see InvoiceTotals; null while a line holds a value that is no number */ totals: InvoiceTotals | null; /** why there are no totals ("line 3: price "12,50" is no number ..."), '' when there are */ error: string; /** the text of the payment code - an EPC QR code for the amount due (see epc()); '' when the invoice has none */ payment: string; /** why there is no payment code although the section carries data-iban ("payment code: nothing is due"), '' otherwise */ paymentError: string;}/** A SEPA credit transfer as an EPC QR code ("GiroCode") carries it - EPC069-12, version 002. */interface EpcTransfer { /** the beneficiary's IBAN; spaces and lower case are allowed */ iban: string; /** the beneficiary's name, at most 70 characters */ name: string; /** the amount in euro - a decimal string with a dot ("3502.62") or a number, 0.01 to 999999999.99, at most two decimals */ amount: string | number; /** the BIC of the beneficiary's bank, 8 or 11 characters - optional inside the EEA */ bic?: string; /** the remittance information (Verwendungszweck), at most 140 characters */ remittance?: string;}const invoiceStates = ['default'];/** setState() configs per state (getState() reports the locale and currency in use). */export interface InvoiceStateConfigs { /** The invoice formatted and added up. */ default: { /** the locale to format in (a BCP 47 tag); default: the nearest lang attribute, else en-US */ locale?: string; /** the ISO 4217 currency code; default: the invoice's data-currency, else EUR */ currency?: string; };}/** the built-in words of the totals, by language subtag; df$.shadcn.invoice.labels() adds or changes a language */const LABELS: Record<string, InvoiceLabels> = { de: { net: 'Summe netto', tax: 'zzgl. {rate} USt auf {base}', total: 'Rechnungsbetrag' }, en: { net: 'Subtotal', tax: 'Tax {rate} on {base}', total: 'Total' },};// -- exact decimals ------------------------------------------------------------------------------/** a decimal as an integer and a scale: n / 10^s */interface Dec { n: bigint; s: number;}const pow10 = (k: number): bigint => 10n ** BigInt(k);/** parse "1.5", "-95.00", 19 - null for anything else (a comma is no decimal point in an attribute) */function dec(value: string | number | null | undefined): Dec | null { const m = /^\s*([+-]?)(\d*)(?:\.(\d+))?\s*$/.exec(String(value ?? '')); if (!m || (!m[2] && !m[3])) return null; const frac = m[3] ?? ''; return { n: BigInt((m[2] || '0') + frac) * (m[1] === '-' ? -1n : 1n), s: frac.length };}/** n at scale `from` rounded to scale `to`, half away from zero */function round(n: bigint, from: number, to: number): bigint { if (from <= to) return n * pow10(to - from); const d = pow10(from - to); const q = n / d; const r = n % d; return 2n * (r < 0n ? -r : r) >= d ? q + (n < 0n ? -1n : 1n) : q;}/** an integer at scale s as a decimal string: 265300n, 2 → "2653.00" */function str(n: bigint, s: number): string { const neg = n < 0n; const digits = (neg ? -n : n).toString().padStart(s + 1, '0'); const body = s ? `${digits.slice(0, -s)}.${digits.slice(-s)}` : digits; return neg ? `-${body}` : body;}/** the minor-unit digits of a currency (EUR 2, JPY 0, BHD 3) */const minorDigits = (currency: string): number => new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits ?? 2;/** a line's value, or a RangeError naming it - a wrong number must never count as 0 on an invoice */function need(value: string | number, field: string, index: number): Dec { const d = dec(value); if (!d) throw new RangeError(`line ${index + 1}: ${field} "${value}" is no number (write a decimal with a dot)`); return d;}/** add up lines: each amount rounded to the minor unit, the tax per rate on that rate's sum */function compute(lines: InvoiceLine[], currency = 'EUR'): InvoiceTotals { const digits = minorDigits(currency); const amounts: bigint[] = []; const bases = new Map<string, { rate: Dec; base: bigint }>(); for (const [i, line] of lines.entries()) { const q = need(line.quantity, 'quantity', i); const p = need(line.price, 'price', i); const amount = round(q.n * p.n, q.s + p.s, digits); amounts.push(amount); const rate = need(line.vat, 'vat', i); const key = str(rate.n, rate.s); const entry = bases.get(key) ?? { rate, base: 0n }; entry.base += amount; bases.set(key, entry); } const net = amounts.reduce((a, b) => a + b, 0n); const taxes: InvoiceTax[] = []; let tax = 0n; for (const [key, { rate, base }] of bases) { if (rate.n === 0n) continue; // base (scale digits) × rate (scale rate.s) / 100 → scale digits + rate.s + 2 const t = round(base * rate.n, digits + rate.s + 2, digits); tax += t; taxes.push({ rate: key, base: str(base, digits), tax: str(t, digits) }); } return { currency, digits, lines: amounts.map((a) => str(a, digits)), net: str(net, digits), taxes, total: str(net + tax, digits), };}// -- the payment code (EPC069-12) ----------------------------------------------------------------/** an IBAN in its electronic form (no spaces, upper case), or null when its form or its check digits are wrong * (ISO 13616: the first four characters moved to the end, letters as 10..35, mod 97 = 1) */function ibanOf(value: string): string | null { const v = value.replace(/\s+/g, '').toUpperCase(); if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(v)) return null; const digits = (v.slice(4) + v.slice(0, 4)).replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55)); return BigInt(digits) % 97n === 1n ? v : null;}/** a text element of the code: trimmed, at most `max` characters, without a line break (the element separator) */function textElement(value: string | undefined, field: string, max: number): string { const v = String(value ?? '').trim(); if (/[\r\n]/.test(v)) throw new RangeError(`payment code: the ${field} holds a line break`); const length = [...v].length; if (length > max) throw new RangeError(`payment code: the ${field} has ${length} characters, at most ${max}`); return v;}/** * The text of an EPC QR code for a SEPA credit transfer: BCD, version 002, character set 1 (UTF-8), SCT, BIC, * name, IBAN, EUR and the amount, purpose, structured reference, remittance text - one element per line (LF), * the last filled element without a separator. A value the code cannot carry throws instead of being cut. * VERIFIED: (EPC069-12 v3.0, section 2) the elements, their lengths, the amount range, LF or CRLF separators, * the 331-byte limit; error level M and at most version 13 are the QR Code's settings (the invoice writes * data-ecc="M"; 331 bytes fit version 13 at M). */function epc(transfer: EpcTransfer): string { const iban = ibanOf(String(transfer.iban ?? '')); if (!iban) throw new RangeError(`payment code: IBAN "${transfer.iban}" is no IBAN (its form or its check digits)`); const bic = String(transfer.bic ?? '') .replace(/\s+/g, '') .toUpperCase(); if (bic && !/^[A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?$/.test(bic)) throw new RangeError(`payment code: BIC "${transfer.bic}" is no BIC (8 or 11 characters)`); const name = textElement(transfer.name, 'name', 70); if (!name) throw new RangeError('payment code: the beneficiary name is missing'); const a = dec(transfer.amount); if (!a || (a.s > 2 && a.n % pow10(a.s - 2) !== 0n)) throw new RangeError( `payment code: amount "${transfer.amount}" is no amount in euro (a decimal with a dot, two decimals at most)`, ); const cents = round(a.n, a.s, 2); if (cents < 1n || cents > 99_999_999_999n) throw new RangeError(`payment code: amount ${str(cents, 2)} is outside 0.01 to 999999999.99 euro`); const remittance = textElement(transfer.remittance, 'remittance', 140); const lines = ['BCD', '002', '1', 'SCT', bic, name, iban, `EUR${str(cents, 2)}`, '', '', remittance]; while (lines[lines.length - 1] === '') lines.pop(); const text = lines.join('\n'); const bytes = new TextEncoder().encode(text).length; if (bytes > 331) throw new RangeError(`payment code: ${bytes} bytes, at most 331`); return text;}/** * The payment code of an invoice: an EPC transfer of the amount due to the bank data on the section * (data-iban, data-bic, data-payee, data-remittance). None - and why - when the section names no IBAN, the * totals are missing, the currency is not the euro or nothing is due; `authored` marks a reason the author * has to fix (a wrong IBAN, a name too long), which the invoice shows where the totals are. */function paymentOf( el, totals: InvoiceTotals | null, currency: string,): { text: string; error: string; authored: boolean } { const $el = dfDollar(el); const none = (error: string, authored = false) => ({ text: '', error, authored }); if ($el.attr('data-iban') == null) return none(''); if (!totals) return none('payment code: no totals'); if (currency !== 'EUR') return none(`payment code: a SEPA credit transfer is in euro, the invoice in ${currency}`); if (dec(totals.total)!.n <= 0n) return none('payment code: nothing is due'); try { const text = epc({ iban: String($el.attr('data-iban')), bic: $el.attr('data-bic') ?? '', name: $el.attr('data-payee') ?? '', amount: totals.total, remittance: $el.attr('data-remittance') ?? '', }); return { text, error: '', authored: false }; } catch (e) { return none(e instanceof Error ? e.message : String(e), true); }}// -- the element ---------------------------------------------------------------------------------/** the line rows: every body row of the items table that carries a price */const rowsOf = (root) => dfDollar(root) .find('.invoice-items > tbody > tr') .toArray() .filter((tr) => dfDollar(tr).attr('data-price') != null);/** a row's values as written */const lineOf = (tr): InvoiceLine => ({ quantity: dfDollar(tr).attr('data-quantity') ?? '1', price: dfDollar(tr).attr('data-price') ?? '0', vat: dfDollar(tr).attr('data-vat') ?? '0',});/** the locale and the currency a state formats in: its config, else the markup, else en-US / EUR */function settingsOf(el, state) { const lang = dfDollar(el).closest('[lang]').attr('lang'); return { locale: String(state?.config?.locale || dfDollar(el).attr('data-locale') || lang || 'en-US'), currency: String(state?.config?.currency || dfDollar(el).attr('data-currency') || 'EUR').toUpperCase(), };}const labelsFor = (locale: string): InvoiceLabels => LABELS[locale.toLowerCase().split('-')[0]] ?? LABELS.en;const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');/** * The markup of a state - the one place a state becomes markup: the locale and currency attributes, every * formatted cell, the totals and the payment code's value. setState runs it on the live element, render() on a detached copy (whose * state config names the locale - a copy has no ancestors to read a lang from). */function applyMarkup( el, state,): { totals: InvoiceTotals | null; error: string; payment: string; paymentError: string } { const { locale, currency } = settingsOf(el, state); const rows = rowsOf(el); let totals: InvoiceTotals | null = null; let error = ''; try { totals = compute(rows.map(lineOf), currency); } catch (e) { error = e instanceof Error ? e.message : String(e); } const digits = minorDigits(currency); const money = (d: number) => new Intl.NumberFormat(locale, { style: 'currency', currency, minimumFractionDigits: d, maximumFractionDigits: d }); const amount = money(digits); const quantity = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 }); const percent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 3 }); const whole = new Intl.NumberFormat(locale); dfDollar(el).attr('data-locale', locale).attr('data-currency', currency); rows.forEach((tr, i) => { const line = lineOf(tr); const [q, p, v] = [dec(line.quantity), dec(line.price), dec(line.vat)]; // a row whose values are no numbers is marked; its cells stay empty dfDollar(tr).attr('data-invalid', q && p && v ? null : ''); // a unit price keeps its own decimals (0.125 €), at least the currency's const priceDigits = Math.min(Math.max(digits, p?.s ?? 0), 6); const cell = (role: string, text: string) => dfDollar(tr).children(`[data-invoice="${role}"]`).text(text); cell('position', whole.format(i + 1)); cell('quantity', q ? quantity.format(Number(line.quantity)) : ''); cell('price', p ? money(priceDigits).format(Number(line.price)) : ''); cell('vat', v ? percent.format(Number(line.vat) / 100) : ''); cell('amount', totals ? amount.format(Number(totals.lines[i])) : ''); }); const words = labelsFor(locale); const row = (cls: string, label: string, value: string, rate?: string) => `<tr class="${cls}"${rate ? ` data-rate="${esc(rate)}"` : ''}><th scope="row">${esc(label)}</th><td>${esc(value)}</td></tr>`; const markup = totals ? row('invoice-net', words.net, amount.format(Number(totals.net))) + totals.taxes .map((t) => row( 'invoice-tax', words.tax .replace('{rate}', percent.format(Number(t.rate) / 100)) .replace('{base}', amount.format(Number(t.base))), amount.format(Number(t.tax)), t.rate, ), ) .join('') + row('invoice-total', words.total, amount.format(Number(totals.total))) : // no totals from a wrong number: the reason in their place row('invoice-error', error, ''); // the payment code: the QR Code in .invoice-payment carries the transfer of the amount due (error level M, as // EPC069-12 requires) or hides when there is none; bank data the code cannot carry is named under the totals const payment = paymentOf(el, totals, currency); dfDollar(el) .find('.invoice-payment > .qr-code') .each((_i, code) => { dfDollar(code) .attr('data-ecc', 'M') .attr('data-value', payment.text) .attr('hidden', payment.text ? null : ''); }); const reason = payment.authored ? row('invoice-error', payment.error, '') : ''; dfDollar(el).find('.invoice-totals').html(`<tbody>${markup}${reason}</tbody>`); return { totals, error, payment: payment.text, paymentError: payment.error };}/** UI side of setState (AGENTS.md "State API"): format, add up, announce */function triggerStateChange(el, state) { const { totals, error, payment, paymentError } = applyMarkup(el, state); el._totals = totals; // Fires after the invoice was formatted and added up - at init, on setState, and when a line row or its values change. el.dispatchEvent( new CustomEvent<InvoiceTotalsDetail>('invoice-totals', { bubbles: true, detail: { locale: String(dfDollar(el).attr('data-locale')), totals, error, payment, paymentError }, }), );}/** Registry-level API; pass the element explicitly. Unknown names throw. */export const invoiceApi = componentState({ component: 'invoice', states: invoiceStates, // a locale alone keeps the currency, and the other way round mergeConfig: true, apply: (el, state) => triggerStateChange(el, state), // getState reports the locale and the currency the invoice is formatted in read: (el, state) => ({ name: 'default', config: { ...state.config, locale: dfDollar(el).attr('data-locale'), currency: dfDollar(el).attr('data-currency'), }, }), markup: (el, state) => { applyMarkup(el, state); },});df$.invoiceApi = invoiceApi;df$.invoiceStates = invoiceStates;const resolve = (target) => (typeof target === 'string' ? dfDollar(target).get(0) : target);df$.invoice = { /** * Add up invoice lines the way the invoice does - each amount rounded to the currency's minor unit, the tax * once per rate on that rate's net sum, half away from zero. * @param lines - the lines: quantity, net unit price and tax rate in percent (decimal strings with a dot, or numbers) * @param currency - the ISO 4217 code whose minor unit amounts round to (default EUR) * @returns every line amount, the net sum, the tax per rate and the total, as decimal strings - a value that is * no number throws a RangeError naming its line and field */ compute: (lines: InvoiceLine[], currency?: string): InvoiceTotals => compute(lines, currency), /** * The text of an EPC QR code ("GiroCode") for a SEPA credit transfer - what an invoice's payment code carries * for its amount due; put it into a QR Code's data-value with data-ecc="M" for a code of your own. * @param transfer - the beneficiary's IBAN and name, the amount in euro, optionally the BIC and the remittance text * @returns the code's text (EPC069-12 version 002, UTF-8, one element per line) - a value the code cannot * carry (an IBAN with wrong check digits, a name over 70 characters, an amount outside 0.01 to 999999999.99, * more than 331 bytes) throws a RangeError naming it */ epc: (transfer: EpcTransfer): string => epc(transfer), /** * The totals an invoice element shows now. * @param target - the .invoice element or its selector * @returns its totals as decimal strings, or null before it initialized and while a line holds a value that is no number */ totals: (target: string | HTMLElement): InvoiceTotals | null => resolve(target)?._totals ? structuredClone(resolve(target)._totals) : null, /** * Add or change the words of the totals for a language; invoices in that language re-render. * @param lang - a language subtag ("fr") or a tag whose subtag counts ("fr-CH") * @param labels - the words to set; the rest keep their current value (English for a new language) */ labels: (lang: string, labels: Partial<InvoiceLabels>): void => { const key = lang.toLowerCase().split('-')[0]; LABELS[key] = { ...(LABELS[key] ?? LABELS.en), ...labels }; dfDollar('.invoice[data-init]').each((_i, el) => { if (String(dfDollar(el).attr('data-locale')).toLowerCase().split('-')[0] === key) el.api.setState('default'); }); },};function init() { dfDollar('.invoice:not([data-init])').each((_i, el) => { dfDollar(el).data('init', ''); // el.store + el.api: `$('#invoice').api.setState('default', { locale: 'en-US', currency: 'USD' })` bindComponent(el, invoiceApi, { name: 'default', config: settingsOf(el, null) }); triggerStateChange(el, el.store.value); // a line added, removed or changed (its quantity, price or rate) is added up again; the runtime's own // writes (cell texts, the totals) touch neither the rows' attributes nor tbody's children // bank data changed on the section: the payment code follows (a stale code would pay the old account); the // runtime never writes these four itself, so this cannot loop new MutationObserver(() => el.isConnected && el.api.setState('default')).observe(el, { attributes: true, attributeFilter: ['data-iban', 'data-bic', 'data-payee', 'data-remittance'], }); const tbody = dfDollar(el).find('.invoice-items > tbody').get(0); if (tbody) { new MutationObserver((records) => { const lines = records.some( (r) => (r.type === 'childList' && r.target === tbody) || (r.type === 'attributes' && r.target !== tbody), ); if (lines && el.isConnected) el.api.setState('default'); }).observe(tbody, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-quantity', 'data-price', 'data-vat'], }); } });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub