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.
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
| Column | Meaning |
|---|---|
voided_at | Terminal. Set = voided, NULL = active. |
posted_transaction_id | Set = the invoice has journal entries in the ledger ("in the books"). |
sent_at | Set = the invoice has been sent externally (email, Peppol). |
payment_status | Independent of the above; tracks unpaid / partially paid / paid / overpaid. |
source_type === "stripe" | Immutable origin lock. Stripe owns the lifecycle of these. |
direction | inbound (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
- Add
canDoTheThing(invoice): booleantosrc/lib/invoices/state.tsnext to the others. - 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). - Add a
describeblock insrc/lib/invoices/__tests__/state.test.tscovering the gate matrix — true for the happy path, false for each blocking signal independently. - 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.sqland20260502233438_invoice_voided_at_in_sql_functions.sql. Postgres functions filter byvoided_at IS NULLdirectly; 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 explicitinvoice.voided/invoice.unvoided/invoice.postedevents. - The badge component itself is in
src/app/books/_components/invoices/InvoiceTableClient.tsx. It just maps the label returned byresolveInvoiceBadgeLabelto a shadcn<Badge>variant; the labelling logic lives instate.ts.