Skip to contentVibraUI
Shared pagesinstalls at /billing/invoices

Invoices and payment methods

A billing history for every workspace an organisation runs: what is outstanding, what has been paid, one row per invoice with its download control, and the wallet the next invoice is charged to.

Open the live page

The page is a server component inside AppShell, with nav.ts describing the navigation as plain data. The organisation is Northwind and db.customers holds one account row per workspace it runs, so db.invoices filtered to those accounts is the history and db.paymentMethods filtered the same way is the wallet — the totals, the overdue count and whether a card has expired are all read off those rows against REFERENCE_DATE. actions.ts holds the mutation, a 'use server' file whose every export is an async function: addCard is a server action returning Result, and it never asks for a card number, because a wallet stores what the payment provider hands back — the brand, the last four digits and the expiry — which is all the form collects. A rejected value is reported on the field that holds it and the input is marked invalid; a filed card replaces the form with a confirmation whose heading takes focus. The download controls carry aria-disabled and point at the note under the table saying why nothing downloads: this registry serves no PDFs. Every day it prints goes through an Intl.DateTimeFormat pinned to UTC, because an invoice date is an instant and the reader's own zone would move some of them to the day before. Composes AppShell, PageHeader, StatCardGroup, StatCard, FormSection, SimpleTable, StatusBadge, CurrencyCell, SelectableCardGroup, Callout, Dialog, FormRow, Input, NativeSelect and AsyncButton.

Preview

Install

npx shadcn@latest add @vibra/billing-invoices

Needs the @vibra registry in your components.json — set it up once.

Source

app/billing/invoices/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"

import { addCard, signOut } from "./actions"
import { InvoiceHistory } from "./components/invoice-history"
import { InvoiceSummary } from "./components/invoice-summary"
import { PaymentMethods } from "./components/payment-methods"
import {
  cards,
  currentUser,
  defaultCardId,
  expiryYears,
  invoiceHistory,
  organisation,
  outstandingCents,
  overdueCount,
  paidLastYearCents,
  seatsSummary,
  shellNotifications,
} from "./data"
import { NAV, ROUTE } from "./nav"

/**
 * Billing history and the wallet behind it. The page is a server component
 * inside the shell: it reads the invoices and the cards through `db` and hands
 * the wallet the rows plus the server action that files another card, so the
 * only client state on the page is which card is charged and the dialog.
 */
export default function BillingInvoicesPage() {
  const rows = invoiceHistory()

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTE}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Invoices"
        description="What has been charged, what is still owed, and the card the next invoice goes to."
        meta={`${organisation().name} · ${seatsSummary()} · dates in UTC`}
      />

      <InvoiceSummary
        outstandingCents={outstandingCents()}
        paidLastYearCents={paidLastYearCents()}
        invoiceCount={rows.length}
        overdueCount={overdueCount()}
      />

      <InvoiceHistory rows={rows} />

      <PaymentMethods
        cards={cards()}
        defaultCardId={defaultCardId()}
        years={expiryYears()}
        onAdd={addCard}
      />
    </AppShell>
  )
}
app/billing/invoices/nav.ts
import { type NavConfig } from "@/lib/nav-config"

/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/billing/invoices"

/**
 * This product's navigation, as plain data. AppShell resolves the icon names,
 * and the page hands it `ROUTE` as `activeHref` — so which item is current is
 * settled by the route the page is installed at, not by the URL it happens to
 * be framed under. Nothing here is a component, and nothing here says "I am the
 * page you are on".
 *
 * Billing and Team are disclosures rather than links: an item with `items` is
 * a button, so the leaf below it is what carries `aria-current`. `/billing` is
 * not a page of its own, so it has no leaf for itself; `/people/directory` is, so it does.
 */
export const NAV: NavConfig = {
  brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
  groups: [
    {
      label: "Workspace",
      items: [
        { title: "Overview", href: "/saas", icon: "layout-dashboard" },
        { title: "Analytics", href: "/analytics", icon: "chart-line" },
        { title: "Reports", href: "/reports", icon: "file-text", badge: 4 },
      ],
    },
    {
      label: "Account",
      items: [
        {
          title: "Billing",
          href: "/billing",
          icon: "credit-card",
          items: [
            { title: "Plans", href: "/billing/plans" },
            { title: "Invoices", href: "/billing/invoices" },
            { title: "Usage", href: "/billing/usage" },
          ],
        },
        {
          title: "Team",
          href: "/people/directory",
          icon: "users",
          items: [
            { title: "Members", href: "/people/directory" },
            { title: "Roles", href: "/people/roles" },
            { title: "Invitations", href: "/people/invitations" },
          ],
        },
      ],
    },
  ],
  // Pinned under the groups, the way the secondary links were.
  footer: [
    { title: "Settings", href: "/settings", icon: "settings" },
    { title: "Support", href: "/support", icon: "life-buoy" },
  ],
}
app/billing/invoices/data.ts
/**
 * What this page reads, and the one thing it changes.
 *
 * The organisation behind these pages is Northwind, and `db.customers` holds
 * one account row per workspace it runs — the rows sharing the Northwind name
 * are its own, and the ones it has not closed are the ones it still runs. That
 * is what gives this page real invoices and real cards to read: `db.invoices` filtered to those accounts is the billing history, and
 * `db.paymentMethods` filtered the same way is the wallet. Totals are summed
 * off those rows, and "now" is `REFERENCE_DATE` — which is what decides
 * whether a card has expired and what "the last twelve months" covers.
 *
 * `addCard` is the mutation, a server action returning `Result`. It never asks
 * for a card number: a real wallet stores what the payment provider hands back,
 * which is the brand, the last four digits and the expiry, and that is all this
 * form collects.
 */
import { getInitials } from "@/lib/format"
import {
  db,
  REFERENCE_DATE,
  type Invoice,
  type Member,
  type PaymentMethod,
} from "@/lib/sample-data"

const YEAR_MS = 365 * 86_400_000

// Every read is per call, never held at module scope: a workspace opened or
// closed since the server started is what the next render counts.

/** The organisation's workspaces: the accounts sharing the Northwind name. */
const workspaces = () => db.customers.all().filter((customer) => customer.company.startsWith("Northwind "))
const workspaceNames = () => new Map(workspaces().map((customer) => [customer.id, customer.company]))

/** The workspaces the organisation still runs: a closed account fills no seats. */
const liveWorkspaces = () => workspaces().filter((customer) => customer.status !== "churned")

/** The account a new card is filed against — the workspace these pages are for. */
export function billingAccount() {
  const rows = workspaces()
  return rows.find((customer) => customer.company === "Northwind Analytics") ?? rows[0] ?? db.customers.all()[0]
}

/** The organisation, as both `/billing` and `/people/directory` state it, counted per call. */
export function organisation(): { name: string; workspaces: number; seatsFilled: number } {
  const live = liveWorkspaces()
  return {
    name: "Northwind",
    workspaces: live.length,
    seatsFilled: live.reduce((total, customer) => total + customer.seats, 0),
  }
}

/**
 * The seats sentence, written once. `/billing` and `/people/directory` both render it, so
 * neither can drift from the other about how many seats are filled.
 */
export function seatsSummary(): string {
  const { seatsFilled, workspaces } = organisation()
  return `${seatsFilled} seats filled across the organisation's ${workspaces} workspaces`
}

export type InvoiceRow = {
  id: string
  number: string
  workspace: string
  status: Invoice["status"]
  amountCents: number
  issuedAt: Date
  dueAt: Date
  paidAt?: Date
}

/** Every invoice raised against the organisation's workspaces, newest first. */
export function invoiceHistory(): InvoiceRow[] {
  const names = workspaceNames()
  return db.invoices
    .all()
    .filter((row) => names.has(row.customerId))
    .sort((a, b) => b.issuedAt.getTime() - a.issuedAt.getTime())
    .map((row) => ({
      id: row.id,
      number: row.number,
      workspace: names.get(row.customerId) ?? row.customerId,
      status: row.status,
      amountCents: row.amountCents,
      issuedAt: row.issuedAt,
      dueAt: row.dueAt,
      ...(row.paidAt ? { paidAt: row.paidAt } : {}),
    }))
}

/** What is still owed: every invoice sent out and not yet settled or voided. */
export function outstandingCents(): number {
  return invoiceHistory()
    .filter((row) => row.status === "open" || row.status === "overdue")
    .reduce((total, row) => total + row.amountCents, 0)
}

/** What has been settled in the twelve months to REFERENCE_DATE. */
export function paidLastYearCents(): number {
  const from = REFERENCE_DATE.getTime() - YEAR_MS
  return invoiceHistory()
    .filter((row) => row.paidAt !== undefined && row.paidAt.getTime() >= from)
    .reduce((total, row) => total + row.amountCents, 0)
}

/** How many invoices are past their due date and still unpaid. */
export function overdueCount(): number {
  return invoiceHistory().filter((row) => row.status === "overdue").length
}

export type CardRow = {
  id: string
  workspace: string
  brand: PaymentMethod["brand"]
  last4: string
  expMonth: number
  expYear: number
  isDefault: boolean
  expired: boolean
}

/** A card is spent once REFERENCE_DATE is past the end of its expiry month. */
export function hasExpired(expMonth: number, expYear: number): boolean {
  const year = REFERENCE_DATE.getUTCFullYear()
  const month = REFERENCE_DATE.getUTCMonth() + 1
  return expYear < year || (expYear === year && expMonth < month)
}

/** The cards on file across the organisation, the default one first. */
export function cards(): CardRow[] {
  const names = workspaceNames()
  return db.paymentMethods
    .all()
    .filter((row) => names.has(row.customerId))
    .map((row) => ({
      id: row.id,
      workspace: names.get(row.customerId) ?? row.customerId,
      brand: row.brand,
      last4: row.last4,
      expMonth: row.expMonth,
      expYear: row.expYear,
      isDefault: row.default,
      expired: hasExpired(row.expMonth, row.expYear),
    }))
    .sort((a, b) => Number(b.isDefault) - Number(a.isDefault))
}

/** The card the next invoice is charged to. */
export function defaultCardId(): string {
  const rows = cards()
  return (rows.find((row) => row.isDefault) ?? rows[0])?.id ?? ""
}

/** The years an expiry can be set to: this one and the eight after it. */
export function expiryYears(): number[] {
  const year = REFERENCE_DATE.getUTCFullYear()
  return Array.from({ length: 9 }, (_, index) => year + index)
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/billing/invoices/actions.ts
"use server"

/**
 * Everything this page changes. A `"use server"` file rather than a directive
 * inside each function: an inline one is only legal in a module the bundler
 * knows is server-only, and `data.ts` is imported for its types by components
 * that are not. Every export here is an async function returning `Result`.
 */
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { REFERENCE_YEAR, db, fields, isWhole, type PaymentMethod, type Result } from "@/lib/sample-data"

import { billingAccount, hasExpired, type CardRow } from "./data"

const BRANDS: PaymentMethod["brand"][] = ["visa", "mastercard", "amex"]

/** How far ahead a card's expiry can be: issuers print no more than twenty years out. */
const EXPIRY_YEARS = 20

/**
 * File another card against this workspace. The provider's own form is what
 * takes the number; this only records what comes back from it, so a bad value
 * here is a bad *reference*, and it is reported against the field that holds it.
 */
export async function addCard(input: {
  brand: string
  last4: string
  expMonth: number
  expYear: number
}): Promise<Result<CardRow>> {
  const { brand: sentBrand, last4, expMonth, expYear } = fields(input)
  const brand = BRANDS.find((row) => row === sentBrand)
  if (!brand) {
    return {
      ok: false,
      error: { code: "unknown_brand", message: "Pick the card's brand.", field: "brand" },
    }
  }

  if (typeof last4 !== "string" || !/^\d{4}$/.test(last4)) {
    return {
      ok: false,
      error: {
        code: "invalid_last4",
        message: "The last four digits are four numbers, e.g. 4242.",
        field: "last4",
      },
    }
  }

  // A month and a year as the provider reads them off a card: whole numbers,
  // the month 1–12 and the year within twenty of this one. Anything else was
  // stored as sent and printed as "expires [object Object]/2030".
  if (!isWhole(expMonth, 1, 12) || !isWhole(expYear, REFERENCE_YEAR - EXPIRY_YEARS, REFERENCE_YEAR + EXPIRY_YEARS)) {
    return {
      ok: false,
      error: { code: "invalid_expiry", message: "Enter the expiry as it is printed on the card: a month and a year.", field: "expiry" },
    }
  }

  if (hasExpired(expMonth, expYear)) {
    return {
      ok: false,
      error: { code: "card_expired", message: "That expiry has already passed.", field: "expiry" },
    }
  }

  const account = billingAccount()
  const created = await db.paymentMethods.create({
    customerId: account.id,
    brand,
    last4,
    expMonth,
    expYear,
    default: false,
  })
  if (!created.ok) return created

  return {
    ok: true,
    data: {
      id: created.data.id,
      workspace: account.company,
      brand,
      last4: created.data.last4,
      expMonth: created.data.expMonth,
      expYear: created.data.expYear,
      isDefault: false,
      expired: false,
    },
  }
}

/** Signing out is the shell's one action, and a server action for the same reason. */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/billing/invoices/components/add-card-dialog.tsx
"use client"

import * as React from "react"

import { AsyncButton } from "@/components/ui/async-button"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"
import { FormRow } from "@/components/ui/form-section"
import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"

import type { CardRow } from "../data"

export type CardAdded =
  | { ok: true; data: CardRow }
  | { ok: false; error: { code: string; message: string; field?: string } }

const BRANDS = [
  { value: "visa", label: "Visa" },
  { value: "mastercard", label: "Mastercard" },
  { value: "amex", label: "American Express" },
]

const MONTHS = Array.from({ length: 12 }, (_, index) => index + 1)

/**
 * Files another card. The number never comes near this form — a payment
 * provider's own hosted field takes that and hands back the brand, the last
 * four digits and the expiry, which is exactly what a wallet stores and
 * exactly what this collects.
 */
export function AddCardDialog({
  open,
  onOpenChange,
  years,
  onAdd,
  onAdded,
}: {
  open: boolean
  onOpenChange: (open: boolean) => void
  years: number[]
  onAdd: (input: {
    brand: string
    last4: string
    expMonth: number
    expYear: number
  }) => Promise<CardAdded>
  onAdded: (card: CardRow) => void
}) {
  const id = React.useId()
  // Mounted by the wallet only while it is open, so closing unmounts the form
  // and every field below is re-armed by construction.
  const [brand, setBrand] = React.useState("visa")
  const [last4, setLast4] = React.useState("")
  const [expMonth, setExpMonth] = React.useState("12")
  const [expYear, setExpYear] = React.useState(String(years[1] ?? years[0]))
  const [pending, setPending] = React.useState(false)
  const [error, setError] = React.useState<{ message: string; field?: string } | null>(null)
  const [done, setDone] = React.useState<CardRow | null>(null)
  const headingRef = React.useRef<HTMLHeadingElement>(null)

  // The form is gone once the card is filed; the heading that replaced it is
  // where a reader — and a screen reader — should be.
  React.useEffect(() => {
    if (done) headingRef.current?.focus()
  }, [done])

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setError(null)
    setPending(true)
    const result = await onAdd({
      brand,
      last4,
      expMonth: Number(expMonth),
      expYear: Number(expYear),
    })
    setPending(false)
    if (!result.ok) {
      setError(result.error)
      return
    }
    setDone(result.data)
    onAdded(result.data)
  }

  const fieldError = (field: string) => (error?.field === field ? error.message : undefined)

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle ref={headingRef} tabIndex={-1} className="outline-none">
            {done ? "Card added" : "Add a card"}
          </DialogTitle>
          <DialogDescription>
            {done
              ? `The card ending ${done.last4} is on file for ${done.workspace}. Choose it below to charge the next invoice to it.`
              : "Your payment provider takes the number on its own hosted form. Record what it hands back."}
          </DialogDescription>
        </DialogHeader>

        {done ? (
          <DialogFooter>
            <Button onClick={() => onOpenChange(false)}>Done</Button>
          </DialogFooter>
        ) : (
          <form onSubmit={handleSubmit} className="flex flex-col gap-4">
            <FormRow label="Brand" htmlFor={`${id}-brand`} error={fieldError("brand")}>
              <NativeSelect
                id={`${id}-brand`}
                className="w-full"
                value={brand}
                onChange={(event) => setBrand(event.target.value)}
              >
                {BRANDS.map((option) => (
                  <NativeSelectOption key={option.value} value={option.value}>
                    {option.label}
                  </NativeSelectOption>
                ))}
              </NativeSelect>
            </FormRow>

            <FormRow
              label="Last four digits"
              htmlFor={`${id}-last4`}
              description="The four digits your provider shows on the receipt — never the full number."
              error={fieldError("last4")}
            >
              <Input
                id={`${id}-last4`}
                value={last4}
                onChange={(event) => setLast4(event.target.value)}
                inputMode="numeric"
                maxLength={4}
                autoComplete="off"
                placeholder="4242"
                className="font-mono"
              />
            </FormRow>

            {/* Two controls, one message: the row cannot wire an error into a
                wrapper it does not own, so it hands the field props out and
                both selects take them — the month by spreading, the year by
                mirroring, since only one element may carry the row's id. */}
            <FormRow label="Expires" htmlFor={`${id}-month`} error={fieldError("expiry")}>
              {(field) => (
                <div className="flex gap-2">
                  <NativeSelect
                    {...field}
                    value={expMonth}
                    onChange={(event) => setExpMonth(event.target.value)}
                  >
                    {MONTHS.map((month) => (
                      <NativeSelectOption key={month} value={String(month)}>
                        {String(month).padStart(2, "0")}
                      </NativeSelectOption>
                    ))}
                  </NativeSelect>
                  <NativeSelect
                    aria-label="Expiry year"
                    aria-describedby={field["aria-describedby"]}
                    aria-invalid={field["aria-invalid"]}
                    value={expYear}
                    onChange={(event) => setExpYear(event.target.value)}
                  >
                    {years.map((year) => (
                      <NativeSelectOption key={year} value={String(year)}>
                        {year}
                      </NativeSelectOption>
                    ))}
                  </NativeSelect>
                </div>
              )}
            </FormRow>

            {error && !error.field ? (
              <p role="alert" className="text-sm text-danger">
                {error.message}
              </p>
            ) : null}

            <DialogFooter>
              <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
                Cancel
              </Button>
              <AsyncButton type="submit" loading={pending}>
                Add card
              </AsyncButton>
            </DialogFooter>
          </form>
        )}
      </DialogContent>
    </Dialog>
  )
}
app/billing/invoices/components/invoice-history.tsx
import { DownloadIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import { FormSection } from "@/components/ui/form-section"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { CurrencyCell } from "@/components/ui/table-cells"

import type { InvoiceRow } from "../data"

// An invoice date is an instant, not a calendar day, and roughly a third of
// them fall late enough in the day to read as the day before for anyone west of
// UTC. The section says the dates are UTC; this is what makes that true.
const DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })

// "void" is the one invoice state StatusBadge has no word for; the other four
// are already in its vocabulary.
const STATUS_MAP = { void: "neutral" } as const

const NOTE_ID = "invoice-pdf-note"

const COLUMNS: SimpleTableColumn<InvoiceRow>[] = [
  {
    key: "number",
    header: "Invoice",
    cell: (row) => <span className="font-mono text-xs">{row.number}</span>,
  },
  { key: "workspace", header: "Workspace" },
  {
    key: "status",
    header: "Status",
    cell: (row) => <StatusBadge status={row.status} map={STATUS_MAP} />,
  },
  {
    key: "issuedAt",
    header: "Issued",
    cell: (row) => (
      <span className="text-muted-foreground tabular-nums">{DAY.format(row.issuedAt)}</span>
    ),
  },
  {
    key: "dueAt",
    header: "Due",
    cell: (row) => (
      <span className="text-muted-foreground tabular-nums">{DAY.format(row.dueAt)}</span>
    ),
  },
  {
    key: "amountCents",
    header: "Amount",
    align: "right",
    cell: (row) => <CurrencyCell value={row.amountCents / 100} />,
  },
  {
    key: "download",
    header: <span className="sr-only">Download</span>,
    align: "right",
    width: "120px",
    cell: (row) => (
      // A link with nothing behind it would be worse than a control that says
      // so: the reason is under the table, and every one of these points at it.
      <Button
        variant="ghost"
        size="sm"
        aria-disabled="true"
        aria-label={`Download ${row.number}`}
        aria-describedby={NOTE_ID}
      >
        <DownloadIcon data-icon="inline-start" />
        Download
      </Button>
    ),
  },
]

/** Nothing on this panel holds state, so it stays a plain function component. */
export function InvoiceHistory({ rows }: { rows: InvoiceRow[] }) {
  return (
    <FormSection
      title="Billing history"
      description="Every invoice raised against this organisation's workspaces, newest first. Dates are UTC."
    >
      <SimpleTable
        size="sm"
        columns={COLUMNS}
        rows={rows}
        rowKey="id"
        hoverable
        emptyMessage="No invoices yet."
      />
      <p id={NOTE_ID} className="text-xs text-muted-foreground">
        Invoice PDFs are served by your payment provider; this sample registry ships none, so the
        download controls above are inert.
      </p>
    </FormSection>
  )
}
app/billing/invoices/components/invoice-summary.tsx
import { formatCurrency } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })

/** Nothing on this row holds state, so it stays a plain function component. */
export function InvoiceSummary({
  outstandingCents,
  paidLastYearCents,
  invoiceCount,
  overdueCount,
}: {
  outstandingCents: number
  paidLastYearCents: number
  invoiceCount: number
  overdueCount: number
}) {
  return (
    <StatCardGroup columns={3} divided>
      <StatCard
        label="Outstanding"
        value={money(outstandingCents)}
        description={
          overdueCount > 0 ? `${overdueCount} past their due date` : "Nothing past its due date"
        }
      />
      <StatCard
        label="Paid in the last 12 months"
        value={money(paidLastYearCents)}
        description="Across every workspace"
      />
      <StatCard label="Invoices" value={String(invoiceCount)} description="Since the first charge" />
    </StatCardGroup>
  )
}
app/billing/invoices/components/payment-methods.tsx
"use client"

import * as React from "react"
import { CreditCardIcon, PlusIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { FormSection } from "@/components/ui/form-section"
import { SelectableCardGroup, type SelectableCardOption } from "@/components/ui/selectable-card"
import { StatusBadge } from "@/components/ui/status-badge"

import type { CardRow } from "../data"
import { AddCardDialog, type CardAdded } from "./add-card-dialog"

const expiry = (card: CardRow) => `${String(card.expMonth).padStart(2, "0")}/${card.expYear}`

/** The wallet: which card the next invoice is charged to, and how to add one. */
export function PaymentMethods({
  cards,
  defaultCardId,
  years,
  onAdd,
}: {
  cards: CardRow[]
  defaultCardId: string
  years: number[]
  onAdd: (input: {
    brand: string
    last4: string
    expMonth: number
    expYear: number
  }) => Promise<CardAdded>
}) {
  const [rows, setRows] = React.useState(cards)
  const [chargedTo, setChargedTo] = React.useState<string | null>(defaultCardId)
  const [open, setOpen] = React.useState(false)

  const expiredCount = rows.filter((card) => card.expired).length

  const options: SelectableCardOption[] = rows.map((card) => ({
    value: card.id,
    icon: <CreditCardIcon />,
    title: (
      <>
        <span className="capitalize">{card.brand}</span>{" "}
        <span className="font-mono">•••• {card.last4}</span>
      </>
    ),
    description: `${card.workspace} · expires ${expiry(card)}`,
    // The library's own danger tone rather than the generic destructive badge:
    // `--destructive` on a 10% wash of itself is 4.08:1 at 12px, under the bar.
    badge: card.expired ? <StatusBadge status="expired" variant="danger" dot={false} /> : undefined,
    disabled: card.expired,
  }))

  return (
    <FormSection
      title="Payment methods"
      description="One card is charged for the whole organisation; the rest stay on file, including on workspaces it has since closed."
      actions={
        <Button size="sm" variant="outline" onClick={() => setOpen(true)}>
          <PlusIcon data-icon="inline-start" />
          Add card
        </Button>
      }
    >
      {expiredCount > 0 ? (
        <Callout variant="warning" title="A card on file has expired">
          {expiredCount === 1 ? "One card" : `${expiredCount} cards`} can no longer be charged.
          Replace {expiredCount === 1 ? "it" : "them"} before the next invoice is raised.
        </Callout>
      ) : null}

      <SelectableCardGroup
        type="single"
        columns={2}
        aria-label="Card charged for the next invoice"
        value={chargedTo}
        onValueChange={setChargedTo}
        options={options}
      />

      {open ? (
        <AddCardDialog
          open
          onOpenChange={setOpen}
          years={years}
          onAdd={onAdd}
          onAdded={(card) => setRows((current) => [...current, card])}
        />
      ) : null}
    </FormSection>
  )
}