{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "billing-history",
  "title": "Billing History",
  "description": "A four-state invoice list — per-currency money through Intl, a six-state lifecycle with partial-refund arithmetic, real filters and pagination, and PDF links that never render as dead buttons.",
  "dependencies": [
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/billing-history.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AlertCircle, ChevronDown, ChevronLeft, ChevronRight, Download, ReceiptText, Search, SearchX } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { INVOICE_STATUS_ORDER } from \"./billing-history.contract\"\nimport type { BillingHistoryData, BillingHistoryItem, InvoiceStatus } from \"./billing-history.contract\"\n\n/* ------------------------------------------------------------------ constants */\n\n/** \"All\" sentinel for the status `<select>`: an empty string is indistinguishable from \"nothing selected\". */\nconst ALL = \"__all__\"\n\nconst STATUS_LABEL: Record<InvoiceStatus, string> = {\n  draft: \"Draft\",\n  open: \"Open\",\n  past_due: \"Past due\",\n  paid: \"Paid\",\n  refunded: \"Refunded\",\n  void: \"Void\",\n}\n\n/**\n * Six treatments that stay apart without hue: solid, tint, destructive tint,\n * neutral fill, dashed outline, struck-through. The wording of the badge is the\n * primary channel; the fill is only the second one, so the table still reads on a\n * monochrome palette or in grayscale print.\n */\nconst STATUS_STYLE: Record<InvoiceStatus, string> = {\n  draft: \"bg-muted text-muted-foreground\",\n  open: \"bg-primary/10 text-primary\",\n  past_due: \"bg-destructive/10 text-destructive\",\n  paid: \"bg-primary text-primary-foreground\",\n  refunded: \"border border-dashed text-foreground\",\n  void: \"bg-muted text-muted-foreground line-through\",\n}\n\nconst CONTROL_CLASS =\n  \"h-9 w-full min-w-0 rounded-lg border bg-background text-sm transition-colors motion-reduce:transition-none \" +\n  \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n\nconst BUTTON_CLASS =\n  \"inline-flex cursor-pointer items-center gap-1 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors \" +\n  \"hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n\n/* ------------------------------------------------------------------ formatting */\n\n/** Epoch ms, or null when the value can't be parsed — a bad instant must not render \"Invalid Date\". */\nfunction toMs(iso: string | undefined): number | null {\n  if (!iso) return null\n  const ms = Date.parse(iso)\n  return Number.isFinite(ms) ? ms : null\n}\n\ntype MoneyFormatter = (minorUnits: number, currency: string) => string\n\n/**\n * One currency's formatter. Two things this must never do: hard-code a `/100`\n * divisor (JPY has no minor unit, KWD has three digits), and hand-build the\n * string from a symbol plus `toFixed(2)` (in `de-DE` the symbol trails the\n * number, and where the minus sign goes on a refund is a locale decision).\n */\nfunction buildMoney(locale: string, currency: string): (minorUnits: number) => string {\n  for (const tag of [locale, \"en-US\"]) {\n    try {\n      const format = new Intl.NumberFormat(tag, { currency, style: \"currency\" })\n      const divisor = 10 ** (format.resolvedOptions().maximumFractionDigits ?? 2)\n      return minorUnits => format.format(minorUnits / divisor)\n    } catch {\n      // Malformed BCP-47 tag or currency code — `Intl` throws RangeError; try the next fallback.\n    }\n  }\n  // Neither the tag nor the code was usable. Show digits plus the raw code rather\n  // than silently relabelling the money as some other currency.\n  const format = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 2, minimumFractionDigits: 2 })\n  return minorUnits => `${format.format(minorUnits / 100)} ${currency}`\n}\n\n/** A row's currency is its own, so formatters are cached per code instead of per component. */\nfunction makeMoney(locale: string): MoneyFormatter {\n  const cache = new Map<string, (minorUnits: number) => string>()\n  return (minorUnits, currency) => {\n    let format = cache.get(currency)\n    if (!format) {\n      format = buildMoney(locale, currency)\n      cache.set(currency, format)\n    }\n    // Normalising -0 keeps a zero refund printing \"$0.00\" instead of \"-$0.00\".\n    return format(minorUnits === 0 ? 0 : minorUnits)\n  }\n}\n\n/**\n * Dates are formatted in an explicit zone, never the visitor's. Reading a day off\n * an instant is a time-zone decision: 2026-01-01T00:30+09:00 is December 31 in\n * UTC and January 1 in Tokyo, so `getDate()` on the browser's clock would print a\n * different day for the server render, the buyer's support agent and the customer.\n */\nfunction buildDateFormat(locale: string, timeZone: string): Intl.DateTimeFormat {\n  for (const tag of [locale, \"en-US\"]) {\n    try {\n      return new Intl.DateTimeFormat(tag, { dateStyle: \"medium\", timeZone })\n    } catch {\n      // Bad tag or unknown zone — `Intl` throws RangeError; fall through to UTC.\n    }\n  }\n  return new Intl.DateTimeFormat(\"en-US\", { dateStyle: \"medium\", timeZone: \"UTC\" })\n}\n\nfunction buildNumberFormat(locale: string): Intl.NumberFormat {\n  try {\n    return new Intl.NumberFormat(locale)\n  } catch {\n    return new Intl.NumberFormat(\"en-US\")\n  }\n}\n\nfunction plural(count: number, noun: string, format: Intl.NumberFormat) {\n  return `${format.format(count)} ${noun}${count === 1 ? \"\" : \"s\"}`\n}\n\n/* ------------------------------------------------------------------ row pieces */\n\n/**\n * Every cell renders on ONE line of the clipboard's `text/plain` flavour. A\n * `display:block` child (or a `<br>`) inside a `<td>` makes Chromium serialise\n * that fragment as its own line, which turns one invoice into six rows when it is\n * pasted into a spreadsheet and shifts every column after it. Secondary text\n * therefore stays inline, and nothing inside the table is `sr-only` — hidden\n * helper text is copied too, glued to the visible value (\"Paidstatus: paid\").\n * Accessible names for the icon-bearing links go on `aria-label`, which is not\n * copied.\n */\nfunction statusDetail(\n  item: BillingHistoryItem,\n  money: MoneyFormatter,\n  dateFormat: Intl.DateTimeFormat,\n): string | null {\n  const refunded = item.refundedAmount ?? 0\n  if (refunded > 0) {\n    // The badge already says \"refunded\"; this is the arithmetic behind it — what came\n    // back and what stayed, so the row answers \"how much of it?\" without a click.\n    return `${money(-refunded, item.currency)} · net ${money(item.total - refunded, item.currency)}`\n  }\n  if (item.status === \"open\" || item.status === \"past_due\") {\n    const due = toMs(item.dueAt)\n    if (due !== null) return `${item.status === \"past_due\" ? \"was due\" : \"due\"} ${dateFormat.format(due)}`\n  }\n  return null\n}\n\nfunction DocumentCell({ item }: { item: BillingHistoryItem }) {\n  // No document at all (a draft that was never rendered, a voided row): an em dash,\n  // not a control. Nothing here looks clickable, so nothing here can be a dead click.\n  if (!item.pdf) return <span aria-hidden=\"true\">—</span>\n\n  // Still being generated. The reason is printed instead of a disabled-looking\n  // button: a control that can't do its job shouldn't be drawn as a control.\n  if (item.pdf.state === \"pending\") {\n    return <span className=\"text-xs text-muted-foreground\">{item.pdf.reason}</span>\n  }\n\n  return (\n    <a\n      aria-label={`Download invoice ${item.number} as PDF`}\n      className={cn(\n        \"inline-flex items-center gap-1.5 rounded-md font-medium underline-offset-4\",\n        \"hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n      )}\n      href={item.pdf.url}\n      rel=\"noreferrer\"\n      target=\"_blank\"\n    >\n      <Download aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n      PDF\n    </a>\n  )\n}\n\nfunction InvoiceRow({\n  dateFormat,\n  item,\n  money,\n}: {\n  dateFormat: Intl.DateTimeFormat\n  item: BillingHistoryItem\n  money: MoneyFormatter\n}) {\n  const refunded = item.refundedAmount ?? 0\n  // \"Partially refunded\" is derived from the amounts, never stored twice: a badge\n  // that said \"Refunded\" on a $25-of-$99 refund would be the expensive kind of wrong.\n  const label = refunded > 0 && refunded < item.total ? \"Partially refunded\" : STATUS_LABEL[item.status]\n  const detail = statusDetail(item, money, dateFormat)\n  const issued = toMs(item.issuedAt)\n\n  return (\n    <tr className=\"border-b last:border-0 even:bg-muted/30\">\n      {/* wrap-anywhere, not break-words: only `anywhere` lowers the column's\n          min-content width, so a long unbreakable invoice number cannot widen the\n          whole table (measured: break-words takes it from 659px to 1224px at a 375px\n          viewport). It is inherited, so the description wraps too — and\n          both stay inline, because a block child would split the cell across two\n          clipboard lines. */}\n      <th className=\"px-4 py-2.5 text-left align-top font-medium wrap-anywhere\" scope=\"row\">\n        {item.number}\n        {item.description && <span className=\"font-normal text-muted-foreground\">{` · ${item.description}`}</span>}\n      </th>\n      <td className=\"px-3 py-2.5 align-top whitespace-nowrap text-muted-foreground\">\n        {issued === null ? (\n          item.issuedAt\n        ) : (\n          <time dateTime={item.issuedAt}>{dateFormat.format(issued)}</time>\n        )}\n      </td>\n      {/* Right-aligned + tabular-nums: a column of money is read by comparing digit\n          positions, and proportional digits make that impossible. */}\n      <td className=\"px-3 py-2.5 text-right align-top font-medium whitespace-nowrap tabular-nums\">\n        {money(item.total, item.currency)}\n      </td>\n      <td className=\"px-3 py-2.5 align-top\">\n        <span\n          className={cn(\n            \"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap\",\n            STATUS_STYLE[item.status],\n          )}\n        >\n          {label}\n        </span>\n        {detail && <span className=\"text-xs text-muted-foreground\">{` · ${detail}`}</span>}\n      </td>\n      <td className=\"px-4 py-2.5 align-top whitespace-nowrap\">\n        <DocumentCell item={item} />\n      </td>\n    </tr>\n  )\n}\n\nfunction SkeletonBar({ className }: { className?: string }) {\n  return <div className={cn(\"h-3 animate-pulse rounded bg-muted motion-reduce:animate-none\", className)} />\n}\n\n/* ------------------------------------------------------------------ component */\n\nexport interface BillingHistoryProps\n  extends BillingHistoryData,\n    Omit<React.HTMLAttributes<HTMLElement>, \"children\" | \"title\"> {\n  /** BCP-47 tag for every `Intl` formatter — explicit so a server render and a client render agree. */\n  locale?: string\n  /** IANA zone the instants are read in. The visitor's zone is never guessed. */\n  timeZone?: string\n  /** Rows per page. Non-finite or below 1 is clamped to at least 1; there is no upper cap. */\n  pageSize?: number\n  /** Card heading; doubles as the table's accessible name. Pass `null` for a bare table. */\n  heading?: React.ReactNode\n  /** Shown next to the error message; omit to drop the retry affordance entirely. */\n  onRetry?: () => void\n}\n\nexport const BillingHistory = React.forwardRef<HTMLElement, BillingHistoryProps>(function BillingHistory(\n  {\n    className,\n    heading = \"Billing history\",\n    items,\n    locale = \"en-US\",\n    onRetry,\n    pageSize = 10,\n    status,\n    timeZone = \"UTC\",\n    ...props\n  },\n  ref,\n) {\n  const baseId = React.useId()\n  const headingId = `${baseId}-heading`\n  const searchId = `${baseId}-search`\n  const statusId = `${baseId}-status`\n\n  const [query, setQuery] = React.useState(\"\")\n  const [statusFilter, setStatusFilter] = React.useState<string>(ALL)\n  const [page, setPage] = React.useState(1)\n\n  const money = React.useMemo(() => makeMoney(locale), [locale])\n  const dateFormat = React.useMemo(() => buildDateFormat(locale, timeZone), [locale, timeZone])\n  const numberFormat = React.useMemo(() => buildNumberFormat(locale), [locale])\n\n  // \"ready\" with nothing to show would render an empty table plus a paginator that\n  // says \"0 of 0\". A race between status and data degrades to the empty branch.\n  const branch = status === \"ready\" && items.length === 0 ? \"empty\" : status\n\n  const statusCounts = React.useMemo(() => {\n    const counts = new Map<InvoiceStatus, number>()\n    for (const item of items) counts.set(item.status, (counts.get(item.status) ?? 0) + 1)\n    return counts\n  }, [items])\n\n  const needle = query.trim().toLowerCase()\n  const filtered = React.useMemo(\n    () =>\n      items.filter(item => {\n        if (statusFilter !== ALL && item.status !== statusFilter) return false\n        if (!needle) return true\n        return (\n          item.number.toLowerCase().includes(needle) ||\n          (item.description?.toLowerCase().includes(needle) ?? false)\n        )\n      }),\n    [items, needle, statusFilter],\n  )\n\n  // Clamped during render rather than repaired in an effect: filtering down to two\n  // rows while sitting on page 6 must show page 1 in the same commit, not after a\n  // flash of an out-of-range page.\n  const size = Number.isFinite(pageSize) ? Math.max(1, Math.floor(pageSize)) : 10\n  const pageCount = Math.max(1, Math.ceil(filtered.length / size))\n  const currentPage = Math.min(Math.max(1, Math.floor(page) || 1), pageCount)\n  const firstIndex = (currentPage - 1) * size\n  const visible = filtered.slice(firstIndex, firstIndex + size)\n\n  /**\n   * Subtotals are grouped by currency and never summed across them: adding USD to\n   * JPY needs a rate and the date it was taken, and neither is in the contract.\n   * Drafts and voided invoices are excluded because neither was ever owed.\n   */\n  const subtotals = React.useMemo(() => {\n    const buckets = new Map<string, { charged: number; count: number; currency: string; refunded: number }>()\n    for (const item of filtered) {\n      if (item.status === \"draft\" || item.status === \"void\") continue\n      const currency = item.currency.toUpperCase()\n      const bucket = buckets.get(currency) ?? { charged: 0, count: 0, currency, refunded: 0 }\n      bucket.charged += item.total\n      bucket.count += 1\n      bucket.refunded += item.refundedAmount ?? 0\n      buckets.set(currency, bucket)\n    }\n    return [...buckets.values()].sort((a, b) => a.currency.localeCompare(b.currency, \"en\"))\n  }, [filtered])\n\n  const isFiltered = statusFilter !== ALL || needle.length > 0\n  const clearFilters = () => {\n    setQuery(\"\")\n    setStatusFilter(ALL)\n    setPage(1)\n  }\n  const goToPage = (next: number) => setPage(Math.min(Math.max(1, next), pageCount))\n\n  const rangeLabel = isFiltered\n    ? `Showing ${numberFormat.format(firstIndex + 1)}–${numberFormat.format(firstIndex + visible.length)} of ${plural(filtered.length, \"matching invoice\", numberFormat)} · ${numberFormat.format(items.length)} total`\n    : `Showing ${numberFormat.format(firstIndex + 1)}–${numberFormat.format(firstIndex + visible.length)} of ${plural(filtered.length, \"invoice\", numberFormat)}`\n\n  return (\n    <section\n      aria-busy={branch === \"loading\" || undefined}\n      className={cn(\"w-full rounded-xl border bg-card text-card-foreground\", className)}\n      ref={ref}\n      {...props}\n    >\n      {heading !== null && (\n        <div className=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b px-4 py-3\">\n          <h3 className=\"text-sm font-medium\" id={headingId}>\n            {heading}\n          </h3>\n          {branch === \"ready\" && (\n            <p className=\"text-xs text-muted-foreground\">{plural(items.length, \"invoice\", numberFormat)}</p>\n          )}\n        </div>\n      )}\n\n      {branch === \"ready\" && (\n        <div className=\"flex flex-wrap items-center gap-2 border-b px-4 py-3\">\n          <div className=\"relative min-w-0 flex-1 sm:max-w-64\">\n            <label className=\"sr-only\" htmlFor={searchId}>\n              Search invoices\n            </label>\n            <Search\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground\"\n            />\n            <input\n              className={cn(CONTROL_CLASS, \"pr-3 pl-9 placeholder:text-muted-foreground\")}\n              id={searchId}\n              onChange={event => {\n                setQuery(event.target.value)\n                setPage(1)\n              }}\n              placeholder=\"Search invoice or plan\"\n              type=\"search\"\n              value={query}\n            />\n          </div>\n\n          <div className=\"relative min-w-0\">\n            <label className=\"sr-only\" htmlFor={statusId}>\n              Filter by status\n            </label>\n            <select\n              className={cn(\n                CONTROL_CLASS,\n                \"cursor-pointer appearance-none py-0 pr-9 pl-3\",\n                // The popup list is painted by the OS; without a color-scheme it\n                // stays white in dark mode and the options disappear.\n                \"scheme-light dark:scheme-dark\",\n              )}\n              id={statusId}\n              onChange={event => {\n                setStatusFilter(event.target.value)\n                setPage(1)\n              }}\n              value={statusFilter}\n            >\n              <option value={ALL}>{`All statuses (${numberFormat.format(items.length)})`}</option>\n              {/* All six lifecycle states are always offered, including the empty ones.\n                  Rendering only the statuses present would leave a selected option with\n                  no match the moment the data refreshes into a set that no longer has\n                  it — a controlled <select> then shows a blank control and the list\n                  stays filtered by something invisible. The \"(0)\" is also the answer to\n                  \"do I have any past-due invoices?\", which is why people open this menu. */}\n              {INVOICE_STATUS_ORDER.map(value => (\n                <option key={value} value={value}>\n                  {`${STATUS_LABEL[value]} (${numberFormat.format(statusCounts.get(value) ?? 0)})`}\n                </option>\n              ))}\n            </select>\n            <ChevronDown\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground\"\n            />\n          </div>\n\n          {isFiltered && (\n            <button className={cn(BUTTON_CLASS, \"h-9\")} onClick={clearFilters} type=\"button\">\n              Clear filters\n            </button>\n          )}\n        </div>\n      )}\n\n      {branch === \"loading\" && (\n        // Five bars per row, mirroring the five real columns, so the layout does not\n        // jump when the invoices arrive.\n        <div aria-hidden=\"true\" className=\"flex flex-col\">\n          {Array.from({ length: Math.min(size, 5) + 1 }, (_, index) => (\n            <div className=\"flex items-center gap-4 border-b px-4 py-3 last:border-0\" key={index}>\n              <SkeletonBar className=\"min-w-24 flex-1\" />\n              <SkeletonBar className=\"w-20 shrink-0\" />\n              <SkeletonBar className=\"w-14 shrink-0\" />\n              <SkeletonBar className=\"w-20 shrink-0\" />\n              <SkeletonBar className=\"w-10 shrink-0\" />\n            </div>\n          ))}\n        </div>\n      )}\n\n      {branch === \"empty\" && (\n        <div className=\"flex flex-col items-center gap-2 px-4 py-14 text-center\">\n          <ReceiptText aria-hidden=\"true\" className=\"size-8 text-muted-foreground/50\" />\n          <p className=\"text-sm font-medium\">No invoices yet</p>\n          <p className=\"text-sm text-muted-foreground\">\n            Invoices show up here once the first billing period closes.\n          </p>\n        </div>\n      )}\n\n      {branch === \"error\" && (\n        <div className=\"flex flex-col items-center gap-3 px-4 py-14 text-center\">\n          <AlertCircle aria-hidden=\"true\" className=\"size-8 text-destructive\" />\n          <div className=\"flex flex-col gap-1\">\n            <p className=\"text-sm font-medium\">Couldn&apos;t load billing history</p>\n            <p className=\"text-sm text-muted-foreground\">The billing service didn&apos;t respond.</p>\n          </div>\n          {onRetry && (\n            <button className={cn(BUTTON_CLASS, \"px-3 py-1.5 text-sm\")} onClick={onRetry} type=\"button\">\n              Try again\n            </button>\n          )}\n        </div>\n      )}\n\n      {branch === \"ready\" && filtered.length === 0 && (\n        // Deliberately not the same panel as \"no invoices yet\": one says the account\n        // has no history, the other says the history is intact but hidden — and only\n        // the second one owes the visitor a way back.\n        <div className=\"flex flex-col items-center gap-2 px-4 py-14 text-center\" role=\"status\">\n          <SearchX aria-hidden=\"true\" className=\"size-8 text-muted-foreground/50\" />\n          <p className=\"text-sm font-medium\">No invoices match these filters</p>\n          <p className=\"text-sm text-muted-foreground\">\n            {`All ${plural(items.length, \"invoice\", numberFormat)} are still here — the current filter just doesn't select any of them.`}\n          </p>\n          <button className={cn(BUTTON_CLASS, \"mt-1\")} onClick={clearFilters} type=\"button\">\n            Clear filters\n          </button>\n        </div>\n      )}\n\n      {branch === \"ready\" && filtered.length > 0 && (\n        <>\n          {/* The escape hatch for narrow viewports: five columns of money and dates\n              have a real minimum width, so the table scrolls sideways inside this\n              wrapper instead of overflowing the page or being clipped. */}\n          <div className=\"overflow-x-auto\">\n            <table\n              aria-label={heading === null ? \"Billing history\" : undefined}\n              aria-labelledby={heading === null ? undefined : headingId}\n              className=\"w-full border-collapse text-sm\"\n            >\n              <thead>\n                <tr className=\"border-b\">\n                  {/* min-w gives the wrapping column a floor: with wrap-anywhere its\n                      min-content is a single character, so without one it would hand\n                      all its width to the money columns and render one glyph per line.\n                      No w-full here — a greedy first column takes every spare pixel and\n                      squeezes the status detail into three lines on a wide screen. */}\n                  <th className=\"min-w-40 px-4 py-2 text-left font-medium text-muted-foreground\" scope=\"col\">\n                    Invoice\n                  </th>\n                  <th\n                    className=\"px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground\"\n                    scope=\"col\"\n                  >\n                    Date\n                  </th>\n                  <th\n                    className=\"px-3 py-2 text-right font-medium whitespace-nowrap text-muted-foreground\"\n                    scope=\"col\"\n                  >\n                    Amount\n                  </th>\n                  <th className=\"px-3 py-2 text-left font-medium text-muted-foreground\" scope=\"col\">\n                    Status\n                  </th>\n                  <th\n                    className=\"px-4 py-2 text-left font-medium whitespace-nowrap text-muted-foreground\"\n                    scope=\"col\"\n                  >\n                    Document\n                  </th>\n                </tr>\n              </thead>\n              <tbody>\n                {visible.map(item => (\n                  <InvoiceRow dateFormat={dateFormat} item={item} key={item.id} money={money} />\n                ))}\n              </tbody>\n            </table>\n          </div>\n\n          <div className=\"flex flex-col gap-3 border-t px-4 py-3\">\n            {subtotals.length > 0 && (\n              <div className=\"flex flex-col gap-1.5\">\n                <ul className=\"flex flex-col gap-1\">\n                  {subtotals.map(bucket => (\n                    <li className=\"flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-xs\" key={bucket.currency}>\n                      <span className=\"font-medium\">{bucket.currency}</span>\n                      <span className=\"text-muted-foreground\">\n                        {plural(bucket.count, \"invoice\", numberFormat)}\n                      </span>\n                      <span className=\"tabular-nums\">{`invoiced ${money(bucket.charged, bucket.currency)}`}</span>\n                      {bucket.refunded > 0 && (\n                        <span className=\"text-muted-foreground tabular-nums\">\n                          {`refunded ${money(-bucket.refunded, bucket.currency)}`}\n                        </span>\n                      )}\n                      {bucket.refunded > 0 && (\n                        <span className=\"font-medium tabular-nums\">\n                          {`net ${money(bucket.charged - bucket.refunded, bucket.currency)}`}\n                        </span>\n                      )}\n                    </li>\n                  ))}\n                </ul>\n                <p className=\"text-xs text-muted-foreground\">\n                  {subtotals.length > 1\n                    ? \"Subtotalled per currency — amounts in different currencies are never converted or added together. Draft and void invoices are excluded.\"\n                    : \"Draft and void invoices are excluded.\"}\n                </p>\n              </div>\n            )}\n\n            <div className=\"flex flex-wrap items-center justify-between gap-3\">\n              <p className=\"text-xs text-muted-foreground\" role=\"status\">\n                {rangeLabel}\n              </p>\n\n              {pageCount > 1 && (\n                <nav aria-label=\"Invoice pages\" className=\"flex items-center gap-1.5\">\n                  {/* aria-disabled, not the disabled attribute: the browser blurs a\n                      natively disabled element the instant it becomes disabled, so\n                      paging to the last page with the keyboard would drop focus to\n                      <body>. The handler is what actually refuses the move. */}\n                  <button\n                    aria-disabled={currentPage === 1 || undefined}\n                    aria-label=\"Previous page\"\n                    className={cn(BUTTON_CLASS, currentPage === 1 && \"cursor-not-allowed opacity-50 hover:bg-transparent\")}\n                    onClick={() => {\n                      if (currentPage > 1) goToPage(currentPage - 1)\n                    }}\n                    type=\"button\"\n                  >\n                    <ChevronLeft aria-hidden=\"true\" className=\"size-3.5\" />\n                    <span className=\"hidden sm:inline\">Previous</span>\n                  </button>\n                  <span className=\"px-1 text-xs whitespace-nowrap text-muted-foreground tabular-nums\">\n                    {`Page ${numberFormat.format(currentPage)} of ${numberFormat.format(pageCount)}`}\n                  </span>\n                  <button\n                    aria-disabled={currentPage === pageCount || undefined}\n                    aria-label=\"Next page\"\n                    className={cn(\n                      BUTTON_CLASS,\n                      currentPage === pageCount && \"cursor-not-allowed opacity-50 hover:bg-transparent\",\n                    )}\n                    onClick={() => {\n                      if (currentPage < pageCount) goToPage(currentPage + 1)\n                    }}\n                    type=\"button\"\n                  >\n                    <span className=\"hidden sm:inline\">Next</span>\n                    <ChevronRight aria-hidden=\"true\" className=\"size-3.5\" />\n                  </button>\n                </nav>\n              )}\n            </div>\n          </div>\n        </>\n      )}\n    </section>\n  )\n})\n\nBillingHistory.displayName = \"BillingHistory\"\n\nexport default BillingHistory\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/billing-history.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * Money rule for this whole contract: **every amount is an integer in the\n * currency's MINOR unit** — 1999 = $19.99, 128000 = ¥128,000 (JPY has no minor\n * unit), 12500 = 12.500 KWD (three digits). Never floats: 0.1 + 0.2 !== 0.3, and\n * a billing row that is one cent off is a support ticket. How many digits a minor\n * unit has is decided by `currency` through `Intl`, never hard-coded to /100.\n *\n * Each row carries its **own** currency: an account that switched plans mid-year,\n * or a reseller billed per region, has a genuinely mixed list. The component\n * therefore never adds two currencies together — it subtotals per currency and\n * says so. Converting would need a rate and the date it was taken, and neither is\n * in this contract.\n */\n\n/**\n * Invoice lifecycle, in the order it is walked:\n *\n * ```\n * draft ──issue──▶ open ──pay──▶ paid ──refund──▶ refunded\n *   │               │\n *   │               └──due date passes──▶ past_due ──pay──▶ paid\n *   └──cancel──▶ void ◀──cancel──┘\n * ```\n *\n * `past_due` is `open` past its `dueAt`; deciding that is the billing system's\n * job (it knows the grace period and the retry schedule), so the status arrives\n * already computed instead of being derived in the browser from the visitor's\n * clock. `void` was cancelled and never owed — it is not a failed payment.\n */\nexport const invoiceStatusSchema = z.enum([\"draft\", \"open\", \"past_due\", \"paid\", \"refunded\", \"void\"])\n\n/** Lifecycle order — drives the filter option order, not the row order. */\nexport const INVOICE_STATUS_ORDER = [\"draft\", \"open\", \"past_due\", \"paid\", \"refunded\", \"void\"] as const\n\n/**\n * A hosted PDF the visitor can actually open: an absolute `http(s)` URL, or a\n * root-relative path served by the host app. `\"#\"` and `\"\"` are rejected on\n * purpose — a download control that goes nowhere is the exact defect this shape\n * exists to prevent.\n */\nexport const invoicePdfHrefSchema = z\n  .string()\n  .refine(value => /^https?:\\/\\/\\S+$/.test(value) || /^\\/\\S*$/.test(value), {\n    message: 'pdf.url must be an absolute http(s) URL or a root-relative path — \"#\" and \"\" are dead links',\n  })\n\n/**\n * Three-way, so \"no document\" can never be confused with \"document not ready\".\n * Omit `pdf` entirely when the invoice has no document at all (drafts, voided\n * rows); use `pending` while the billing provider is still rendering one, and\n * pass the reason a human should read — the component prints that reason instead\n * of rendering a disabled-looking button that does nothing.\n */\nexport const invoicePdfSchema = z.discriminatedUnion(\"state\", [\n  z.object({ state: z.literal(\"ready\"), url: invoicePdfHrefSchema }),\n  z.object({ state: z.literal(\"pending\"), reason: z.string().min(1) }),\n])\n\nexport const billingHistoryItemSchema = z\n  .object({\n    /** Stable across refetches — used as the React key and by the paginator. */\n    id: z.string().min(1),\n    /** Human-facing invoice number. May be long (VAT / reverse-charge suffixes); the cell wraps it. */\n    number: z.string().min(1),\n    /** What was billed — plan, seat count, period. Rendered next to the number, optional. */\n    description: z.string().optional(),\n    /**\n     * The **absolute instant** the invoice was issued, ISO 8601 with a `Z` or a\n     * numeric offset. Never a bare \"2026-03-01\": which calendar day that is\n     * depends on a time zone, and the component must not guess one from the\n     * visitor's machine — it formats every instant in the explicit `timeZone`\n     * prop so a server render and a client render agree.\n     */\n    issuedAt: z.iso.datetime({ offset: true }),\n    /** When payment is/was due. Same absolute-instant rule. Shown on open and past_due rows. */\n    dueAt: z.iso.datetime({ offset: true }).optional(),\n    /** ISO 4217 alpha-3 (\"USD\", \"EUR\", \"JPY\"). Drives symbol, symbol placement AND minor-unit digits. */\n    currency: z.string().regex(/^[A-Za-z]{3}$/),\n    /** Invoice total in minor units, as the billing system charged it. */\n    total: z.number().int().nonnegative(),\n    /**\n     * How much of `total` came back, as a **positive** magnitude in minor units.\n     * Partial refunds are the common case, so the amount is the source of truth\n     * and \"partial vs full\" is derived (`refundedAmount < total`). The component\n     * renders it through `Intl` as a negative number — how a minus sign is drawn\n     * is a locale decision, not a string-concatenation decision.\n     */\n    refundedAmount: z.number().int().nonnegative().optional(),\n    status: invoiceStatusSchema,\n    /** Omit when there is no document to fetch at all. */\n    pdf: invoicePdfSchema.optional(),\n  })\n  .superRefine((invoice, ctx) => {\n    const refunded = invoice.refundedAmount ?? 0\n    if (refunded > invoice.total) {\n      ctx.addIssue({\n        code: \"custom\",\n        message: \"refundedAmount cannot exceed total — a refund never returns more than was charged\",\n        path: [\"refundedAmount\"],\n      })\n    }\n    // Keeps the badge and the money in agreement: a row that says \"Paid\" while\n    // carrying a refund, or \"Refunded\" with nothing returned, is a lie either way.\n    if (refunded > 0 && invoice.status !== \"refunded\") {\n      ctx.addIssue({\n        code: \"custom\",\n        message: 'a row with refundedAmount > 0 must have status \"refunded\"',\n        path: [\"status\"],\n      })\n    }\n    if (invoice.status === \"refunded\" && refunded <= 0) {\n      ctx.addIssue({\n        code: \"custom\",\n        message: 'status \"refunded\" requires a positive refundedAmount',\n        path: [\"refundedAmount\"],\n      })\n    }\n  })\n\nexport const billingHistorySchema = z.object({\n  /**\n   * Render state — \"is there a history to show at all\", independent of each\n   * invoice's own lifecycle `status`.\n   */\n  status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n  items: z.array(billingHistoryItemSchema),\n})\n\nexport type InvoiceStatus = z.infer<typeof invoiceStatusSchema>\nexport type InvoicePdf = z.infer<typeof invoicePdfSchema>\nexport type BillingHistoryItem = z.infer<typeof billingHistoryItemSchema>\nexport type BillingHistoryData = z.infer<typeof billingHistorySchema>\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}