All docs

Development

Invoice state model

How the codebase represents invoice state — the orthogonal signals on the row, the display helpers, and the operation predicates that gate every action.

4 min read

The invoice table doesn't carry a single status enum. The user-visible state is derived from a small set of orthogonal signals on the row, and every "what state is this in" question — display label, filter category, operation eligibility — has a single canonical helper in src/lib/invoices/state.ts.

If you find yourself reading two or three of voided_at / posted_transaction_id / sent_at together to decide whether to render a button or call an API, add a predicate in state.ts instead of inlining the check. That is the smell this module is meant to absorb.

The signals on the row

ColumnMeaning
voided_atTerminal. Set = voided, NULL = active.
posted_transaction_idSet = the invoice has journal entries in the ledger ("in the books").
sent_atSet = the invoice has been sent externally (email, Peppol).
payment_statusIndependent of the above; tracks unpaid / partially paid / paid / overpaid.
source_type === "stripe"Immutable origin lock. Stripe owns the lifecycle of these.
directioninbound (expense) vs outbound (revenue).

The DB no longer has a workflow_status column — it was redundant once the timestamp signals were in place. Reach for those columns directly when querying; reach for the helpers here when gating UI or API actions.

Two kinds of helper

There are two distinct kinds of helper, and they should not be confused:

Display helpers — for badges and filters

getInvoiceState(invoice) // "draft" | "issued" | "received" | "approved" | "voided"
resolveInvoiceBadgeLabel(invoice, "revenue" | "expense")

getInvoiceState returns the value used in the approval-status filter dropdown. resolveInvoiceBadgeLabel returns the user-visible label on the table row badge. They overlap but are not the same: the badge has additional rungs ("Sent", "Paid", "No charge") that aren't filter values.

These are pure UI. Never gate an action on them.

Operation predicates — for action eligibility

One predicate per real action. Each is the sole source of truth for "is this allowed". UI gates, bulk-action filters, and API last-mile guards all call these.

canEditInvoice(invoice)        // not voided, not finalized (sent/posted) for outbound
canMarkInvoiceAsSent(invoice)  // outbound, non-Stripe, fresh draft
canPostInvoiceToBooks(invoice) // not voided, not already posted
canLinkInvoicePayment(invoice) // not voided, balance_due > 0
canVoidInvoice(invoice)        // not voided
canUnvoidInvoice(invoice)      // voided
canDeleteInvoice(invoice)      // Stripe outbound locks once finalized; everything else deletable
canDeleteInvoiceAttachment(attachment, { totalAttachments }) // user-uploaded only; never the last one

When the API needs to surface a specific error message (e.g. "Sent revenue invoices are read-only" vs "Cannot edit a voided invoice") use checkInvoiceEditability(invoice), which returns a discriminated { allowed, reason } result.

Worked example: the linking-card bug

This pattern was introduced after a regression where the "Linked transactions" card on the revenue detail page was hidden whenever a now-removed isInvoiceDraft helper returned true. That helper conflated display state ("this invoice has the Draft badge") with operation eligibility ("can a payment be linked"). The two are not the same — a sent-but-unposted invoice is "draft-ish" for display purposes but absolutely should accept a payment, because the linking flow auto-posts on apply.

With the predicates split out, the card guard is just canLinkInvoicePayment(invoice), which is true for any non-voided invoice with a balance. The bug becomes impossible to reintroduce because there is no isDraft to misuse.

Adding a new predicate

  1. Add canDoTheThing(invoice): boolean to src/lib/invoices/state.ts next to the others.
  2. Type the input narrowly to the columns it actually reads, not Invoice. This makes the predicate usable from contexts that don't have the full row (a row from a .select("voided_at, balance_due") query, a partial Stripe sync object, a test fixture).
  3. Add a describe block in src/lib/invoices/__tests__/state.test.ts covering the gate matrix — true for the happy path, false for each blocking signal independently.
  4. Replace any existing inline checks at call sites. Don't leave both the inline check and the helper in place — the goal is to make the predicate the only place that reads those columns together.

What lives elsewhere

  • Database-level enforcement is in supabase/migrations/20260424011802_security_definer_authz_checks.sql and 20260502233438_invoice_voided_at_in_sql_functions.sql. Postgres functions filter by voided_at IS NULL directly; they don't import the TS predicates, but the rules they enforce should match.
  • Audit logging is in src/lib/audit/payloads.ts. State transitions are recorded as explicit invoice.voided / invoice.unvoided / invoice.posted events.
  • The badge component itself is in src/app/books/_components/invoices/InvoiceTableClient.tsx. It just maps the label returned by resolveInvoiceBadgeLabel to a shadcn <Badge> variant; the labelling logic lives in state.ts.