Billing History
A four-state invoice list — per-currency money through Intl, a six-state lifecycle with partial-refund arithmetic, real filters, pagination, and PDF links that never render as dead buttons.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/billing-history.jsonPrompt
Build a React + TypeScript + Tailwind "BillingHistory" block (lucide-react
icons) with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
items: { id, number, description?, issuedAt, dueAt?, currency,
total, refundedAmount?, status, pdf? }[] }.
- Money is an INTEGER in the currency's minor unit (9900 = $99.00,
12800 = JPY 12,800 — the yen has no minor unit). Never floats: a billing
row one cent off is a support ticket. Each row carries its OWN ISO-4217
currency, because an account that changed region has a genuinely mixed
list.
- issuedAt / dueAt are absolute instants (ISO 8601 with Z or a numeric
offset), never a bare calendar date — which day an instant falls on is a
time-zone decision.
- refundedAmount is a POSITIVE magnitude, and the schema refines it:
refundedAmount <= total, and refundedAmount > 0 <=> status "refunded".
Partial vs full is derived (refundedAmount < total), never stored twice.
- pdf is a discriminated union — { state: "ready", url } |
{ state: "pending", reason } — with the url refined to reject "" and "#".
Omit pdf entirely when no document exists. Three-way, so "no document"
can never be confused with "document not ready".
- Props = z.infer of the schema plus locale ("en-US"), timeZone ("UTC"),
pageSize (10), heading (ReactNode | null), onRetry?, className, and the
rest spread onto the root section; forwardRef to that section.
Behavior
- Four first-class branches: loading (skeleton rows mirroring the five real
columns), empty, error (message + "Try again" only when onRetry exists),
ready. status "ready" with zero items degrades to empty instead of
rendering a table with a "0 of 0" paginator.
- Money: one Intl.NumberFormat per currency, cached. Take the minor-unit
exponent from resolvedOptions().maximumFractionDigits — never hard-code a
/100 divisor, or a zero-decimal currency renders 100x wrong. Never build
a symbol + toFixed(2) string: in de-DE the symbol trails the number, the
separators swap, and where the minus sign of a refund goes is a locale
decision. Normalise -0 so a zero prints "$0.00", never "-$0.00". A
malformed locale or currency code makes Intl throw — fall back instead of
taking the tree down.
- Multi-currency: subtotal per currency (invoiced / refunded / net) and
never add two currencies together; say so in the footer. Converting would
need a rate and the date it was taken, neither of which is in the
contract. Draft and void invoices are excluded from those subtotals —
neither was ever owed — and the footer states that too.
- Lifecycle: draft to open to paid to refunded; open to past_due to paid;
draft/open to void. Six badges whose treatments differ without hue (solid,
tint, destructive tint, neutral fill, dashed outline, struck through); the
wording is the primary channel. The status cell also carries the evidence:
"due <date>" while open, "was due <date>" once past due, and
"-<refund> · net <kept>" once refunded — which keeps the money column
clean while still answering "how much came back?".
- Dates render as a time element with dateTime={iso}, formatted by
Intl.DateTimeFormat in the explicit timeZone prop. Never getDate() on the
visitor's clock: 2026-01-01T00:30+09:00 is Dec 31 in UTC and Jan 1 in
Tokyo, and a support agent and a customer must not read different days off
one invoice.
- Filters: a search box (number + description) and a status select. Both
really narrow the result set, both reset to page 1, and the option counts
are computed from the data. All six statuses are always offered, including
the ones with zero rows — otherwise a refresh into data lacking that
status leaves a controlled select with no matching option and an invisible
active filter. Filtering to zero shows its OWN panel ("no invoices match
these filters" plus a Clear filters button), never the same panel as an
account that has no invoices.
- Pagination: pageSize is clamped to at least 1 with no upper cap; the
current page is clamped during render, so filtering while on page 6 lands
on a page that exists in the same commit. Prev/Next use aria-disabled plus
a guard in the handler, NOT the disabled attribute — the browser blurs a
natively disabled element the instant it becomes disabled, so paging to
the last page with the keyboard would drop focus to the body.
- PDF column: a real anchor when the document exists; the pending reason as
plain text while it is being generated; an em dash when there is none.
Nothing that cannot act is drawn as a control.
Rendering & styling
- A real table with th scope="col" headers and the invoice number as
th scope="row", named by aria-labelledby pointing at the visible card
heading (no caption element, so the copy stays clean).
- Every cell must serialise to ONE line of the clipboard's text/plain
flavour: a display:block child or a br inside a cell makes Chromium emit
that fragment as its own line, which turns one invoice into six lines when
pasted into a spreadsheet and shifts every column after it. Keep secondary
text inline. Put NOTHING sr-only inside the table either — hidden helper
text is copied too, glued to the visible value ("Paidstatus: paid").
Accessible names for the icon links go on aria-label, which is not copied.
- Amounts are right-aligned, tabular-nums and whitespace-nowrap; a column of
money is read by comparing digit positions.
- The invoice column uses wrap-anywhere (not break-words — only `anywhere`
lowers the column's min-content width) with a min-w floor, so a long
reverse-charge number wraps instead of widening the table while the column
still cannot collapse to one glyph per line. The table sits in an
overflow-x-auto wrapper: five columns of money and dates have a real
minimum width, so on a phone it scrolls sideways instead of being clipped.
- Semantic tokens only: bg-card, border, text-muted-foreground, primary for
the paid/open treatments, destructive for past due. Skeletons carry
motion-reduce:animate-none.
Customization levers
- Columns: drop `description` for a denser table, or split the refund detail
into its own column — keep every cell single-line if the copy-to-
spreadsheet property matters to you.
- Sorting: rows render in the order supplied, so the host owns ordering; add
a controlled sort prop if you need column sorting (or reach for
display/data-table instead).
- Density: pageSize is a prop, and py-2.5 on the cells is the row rhythm.
- Money policy: change the excluded-status rule (draft/void) if your billing
system means something else by them, or drop the subtotal block entirely
for a pure list.
- Localisation: locale and timeZone are props — pass the account's own zone
instead of UTC when you store one, and switch dateStyle to "long" for a
wider date column.
- Lifecycle: the six-status label map and its badge treatments are two
Records at the top of the file; add or rename states there.Concepts
- Minor-unit integers — every amount is a whole number of the currency's smallest unit, and how many digits that is comes from
Intl, not from a hard-coded/100. Divide by 100 unconditionally and a 12,800-yen invoice renders as 128 — a 100× error in the one place a product cannot afford one. - Per-currency subtotals — a mixed list is subtotalled once per currency and never added across them, because converting needs a rate and the date it was taken. The footer says so, so nobody reads the missing grand total as an oversight.
- Partial-refund arithmetic — the badge says "Partially refunded" only when
refundedAmount < total, and the row shows both what came back and what stayed; the two always add up to the figure in the money column. - Absolute instant + explicit zone — the contract takes an instant, the component renders it in a zone you name.
2026-01-01T00:30+09:00is December 31 in UTC and January 1 in Tokyo; deriving the day from the visitor's clock makes a support agent and a customer read different dates off one invoice. - Clipboard-clean cells — every cell renders on a single line and nothing inside the table is
sr-only, so selecting the table and pasting into a spreadsheet gives clean tab-separated rows instead of one invoice smeared over six lines with hidden helper text glued to the values. - No control that cannot act — an invoice whose PDF is still rendering prints the reason; one with no document shows an em dash. A disabled-looking download button that silently does nothing is the defect the three-way
pdfshape exists to prevent. - Filtered-empty is not empty — "you have no invoices" and "your filters match none of your invoices" are different facts and get different panels; only the second one owes the reader a way back.
API Keys Panel
A four-state API key manager built around show-the-secret-once — the reveal cannot be closed until the user confirms they saved it, revoking repeats the key's name, and every key says whether it has ever been used.
Notification Preferences
An events × channels notification matrix that saves per switch, with locked always-on rows, a disconnected channel column, bulk row/column actions and midnight-spanning quiet hours.