Theme
Design your own
On this page (12)
Open full screen PDFThe first example printed: three A4 pages, the header row on each, the totals and the payment code on the last.
Component Skill — components/invoice/component-skill.md

Native basis

A <section class="invoice">: the invoice data in a <dl>, the line items in a <table> whose body rows carry data-quantity, data-price and data-vat and mark the cells to fill with data-invoice, and an empty <table class="invoice-totals"> the runtime fills.

Built from

Table for the line items, a QR Code for the payment code, inside a Letter for the sheet.

Web Platform APIs

Intl.NumberFormatBigIntMutationObserver<thead>

Classes

.invoice.invoice-title.invoice-meta.invoice-items.invoice-totals.invoice-net.invoice-tax.invoice-total.invoice-note.invoice-payment

Data attributes

On a line row: data-quantity, data-price (net unit price), data-vat (rate in percent) - machine values with a dot. On its cells: data-invoice="position|quantity|price|vat|amount". On the section: data-currency (ISO 4217, default EUR), lang (or an ancestor's) for the locale; the runtime writes data-locale. A value that is no number (data-price="12,50", a comma) never counts as 0: the runtime marks its row with data-invalid and puts the reason where the totals were. The payment code: data-iban, data-payee, optional data-bic and data-remittance on the section, and a QR Code in .invoice-payment - the runtime writes the EPC transfer of the amount due into its data-value (error level M) and hides it when there is no transfer to scan (another currency than the euro, nothing due). Bank data the code cannot carry - an IBAN whose check digits fail, a name over 70 characters - is named under the totals.

Print

The items table follows Table's print rules: the header row on every page, rows whole, data-keep-last keeps the last three rows with the totals, which never start a page alone. In a Letter, page 1 carries the letterhead and the address field and later pages "Seite 2 von 3".

§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-currency on 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:

StateTypeValuesDefaultDescription
localeenumde-DE, de-CH, en-US, en-GBde-DEThe locale numbers and the totals' words are formatted in - setState('default', { locale: 'en-US' }); mirrored as data-locale.
currencyenumEUR, USD, CHF, GBP, JPYEURThe 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.

StateDescription
default
The invoice formatted and added up.
Config fieldTypeDescription
locale?stringthe locale to format in (a BCP 47 tag); default: the nearest lang attribute, else en-US
currency?stringthe ISO 4217 currency code; default: the invoice's data-currency, else EUR

Every element

MemberDescription
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.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?InvoiceStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

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 { name: InvoiceState; config: InvoiceStateConfigs[InvoiceState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state?{ name: InvoiceState; config: InvoiceStateConfigs[InvoiceState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

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

MemberDescription
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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?InvoiceStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

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.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: InvoiceState; config: InvoiceStateConfigs[InvoiceState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state{ name: InvoiceState; config: InvoiceStateConfigs[InvoiceState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.invoiceApi.store(el: HTMLElement): Store<{ name: InvoiceState; config: InvoiceStateConfigs[InvoiceState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: InvoiceState; config: InvoiceStateConfigs[InvoiceState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?InvoiceStateConfigs[S]its config
df$.shadcn.invoiceStates: InvoiceState[]The declared states, 'default' first: default.

df$.shadcn.invoice

MemberDescription
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.
ArgumentTypeDescription
linesInvoiceLine[]the lines: quantity, net unit price and tax rate in percent (decimal strings with a dot, or numbers)
currency?stringthe ISO 4217 code whose minor unit amounts round to (default EUR)

Returns InvoiceTotals - 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

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.
ArgumentTypeDescription
transferEpcTransferthe beneficiary's IBAN and name, the amount in euro, optionally the BIC and the remittance text

Returns string - 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

totals(target: string | HTMLElement): InvoiceTotals | null
The totals an invoice element shows now.
ArgumentTypeDescription
targetstring | HTMLElementthe .invoice element or its selector

Returns InvoiceTotals | null - its totals as decimal strings, or null before it initialized and while a line holds a value that is no number

labels(lang: string, labels: Partial<InvoiceLabels>): void
Add or change the words of the totals for a language; invoices in that language re-render.
ArgumentTypeDescription
langstringa language subtag ("fr") or a tag whose subtag counts ("fr-CH")
labelsPartial<InvoiceLabels>the words to set; the rest keep their current value (English for a new language)

Events

EventDescription
invoice-totals
Fires after the invoice was formatted and added up - at init, on setState, and when a line row or its values change.

detail: InvoiceTotalsDetail

FieldTypeDescription
localestringthe locale the invoice is formatted in (a BCP 47 tag)
totalsInvoiceTotals | nullthe totals as decimal strings - see InvoiceTotals; null while a line holds a value that is no number
errorstringwhy there are no totals ("line 3: price "12,50" is no number ..."), '' when there are
paymentstringthe text of the payment code - an EPC QR code for the amount due (see epc()); '' when the invoice has none
paymentErrorstringwhy there is no payment code although the section carries data-iban ("payment code: nothing is due"), '' otherwise

Types

TypeDescription
EpcTransfer
A SEPA credit transfer as an EPC QR code ("GiroCode") carries it - EPC069-12, version 002.
FieldTypeDescription
ibanstringthe beneficiary's IBAN; spaces and lower case are allowed
namestringthe beneficiary's name, at most 70 characters
amountstring | numberthe amount in euro - a decimal string with a dot ("3502.62") or a number, 0.01 to 999999999.99, at most two decimals
bic?stringthe BIC of the beneficiary's bank, 8 or 11 characters - optional inside the EEA
remittance?stringthe remittance information (Verwendungszweck), at most 140 characters
InvoiceLabels
The words the runtime writes into the totals; {rate} and {base} are filled in.
FieldTypeDescription
netstringthe net sum row ("Summe netto", "Subtotal")
taxstringone row per tax rate ("zzgl. {rate} USt auf {base}", "Tax {rate} on {base}")
totalstringthe amount due ("Rechnungsbetrag", "Total")
InvoiceLine
One line of an invoice: decimal strings with a dot ("1.5", "95.00", "19"), or numbers.
FieldTypeDescription
quantitystring | numberthe quantity (may be fractional, negative for a credit)
pricestring | numberthe net unit price in the invoice's currency
vatstring | numberthe tax rate in percent (19, 7, 0, 8.875)
InvoiceTax
The tax of one rate.
FieldTypeDescription
ratestringthe rate in percent, as written ("19")
basestringthe net sum of the lines at this rate (a decimal string in the currency's minor unit: "1800.00")
taxstringthe tax on that sum, rounded half away from zero ("342.00")
InvoiceTotals
What an invoice adds up to - every amount a decimal string in the currency's minor unit.
FieldTypeDescription
currencystringthe ISO 4217 currency code
digitsnumberthe currency's minor-unit digits (EUR 2, JPY 0)
linesstring[]each line's net amount, in line order, rounded half away from zero
netstringthe net sum of all lines
taxesInvoiceTax[]one entry per tax rate above zero, in order of first appearance
totalstringthe amount due: net sum plus every tax
InvoiceTotalsDetail
What invoice-totals carries.
FieldTypeDescription
localestringthe locale the invoice is formatted in (a BCP 47 tag)
totalsInvoiceTotals | nullthe totals as decimal strings - see InvoiceTotals; null while a line holds a value that is no number
errorstringwhy there are no totals ("line 3: price "12,50" is no number ..."), '' when there are
paymentstringthe text of the payment code - an EPC QR code for the amount due (see epc()); '' when the invoice has none
paymentErrorstringwhy there is no payment code although the section carries data-iban ("payment code: nothing is due"), '' otherwise

§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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/**
 * 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