ADR-009: Unified Invoice Attention Surface
Date: 2026-04-30 Status: Accepted
Status
Section titled “Status”Accepted
2026-04-30
Summary
Section titled “Summary”Every invoice in an API response carries one attention field that says whether the operator needs to act, and why. It holds a severity, a typed reason, an open dotted code, a message safe to show, and a short list of prescribed actions. The server computes it from the invoice’s stored fields on each read, never stores it, and omits it when there is nothing to act on. The reason list has grown since: the current set is in internal/domain/invoice_attention.go (overdue is no longer one), and doc_url stays empty until error pages are published. Status: active.
Context
Section titled “Context”When an invoice gets stuck — tax calculation failed, payment declined, payment outcome ambiguous, past due — the operator’s first question is “why, and what do I do about it?”. Until now, Hardcount surfaced these signals piecemeal:
tax_status+ free-formtax_pending_reasonfor tax deferralspayment_status='failed'+last_payment_errorfor payment declinespayment_status='unknown'for ambiguous Stripe outcomespayment_overdueboolean for past-due lifecycle- An
OperatorContextCardcomponent onInvoiceDetail.tsxthat hand-coded a free-text diagnosis line per cause
The dashboard had to know which fields meant what, and the per-cause copy and CTAs lived inside the React component. SDK consumers had to re-implement the same reduction. There was no single signal an operator could filter on (“show me everything that needs attention”), no typed reason taxonomy webhook consumers could route on, and no machine-readable affordance for “what to do next”.
A 2026-04-30 industry survey across Stripe (last_finalization_error / last_payment_error typed envelopes, per-cause invoice.* events), Chargebee (dunning_status), Recurly (lifecycle states with prescribed action verbs), Lago (payment_status + per-cause webhooks), Maxio, and Zuora found a consistent pattern at the four mature platforms (Stripe, Chargebee, Recurly, Maxio):
- A typed reason / state code on the resource
- A human message safe to render verbatim
- A
doc_urldeep-link to “how to fix this” docs - 2–4 prescribed action verbs (Retry / Edit / Mark Paid / Void / Stop Dunning)
- Per-cause webhook events (no aggregate “attention_changed”)
The two platforms that diverged (Lago, Zuora) underinvest in operator UX — Lago’s dashboard is thinner because they expect external tooling; Zuora hands the operator a BillRun job result and lets enterprise tooling teams build the resolution flows. Hardcount’s positioning (“AI-native billing engine you can self-host”) matches the first cluster.
Decision
Section titled “Decision”Ship a unified attention field on every Invoice payload, computed server-side from the durable fields on each read.
Wire shape:
"attention": { "severity": "critical", "reason": "tax_location_required", "message": "The customer's billing profile has missing or invalid data the tax provider requires.", "actions": [ { "code": "edit_billing_profile", "label": "Edit billing profile" }, { "code": "retry_tax", "label": "Retry tax" } ], "code": "tax.customer_data_invalid", "doc_url": "https://docs.hardcount.dev/errors/tax-location-required", "param": "customer.address.postal_code", "detail": "stripe tax: {\"message\":\"...\",\"code\":\"customer_tax_location_invalid\"}", "since": "2026-04-30T18:22:11Z"}Healthy invoices return no attention key (omitempty). Terminal-state invoices (paid, voided) suppress attention even when underlying signals would otherwise trigger it.
Field rationale
Section titled “Field rationale”| Field | Source | Rationale |
|---|---|---|
severity |
info / warning / critical closed enum |
Drives chip colour, queue ordering. Stripe doesn’t ship this; we do because it’s cheap and design partners notice. |
reason |
Closed enum (5 codes today) | What the dashboard switches on for icon/copy/CTA layout. Stable contract — codes never repurposed. |
code |
Open dotted string | Programmatic clients route on this. Stripe parity (code is open). New codes ship without a major version bump. |
message |
Free text | Human-readable headline. Safe to render verbatim. |
actions |
Closed enum codes + labels | Prescribed CTAs. Closed because every code maps to a server endpoint or frontend route, and audit logs key off the code. |
doc_url |
Computed docs.hardcount.dev/errors/<reason> |
Stripe parity. Required so operators get a “Learn more” path, not silence. |
param |
Dotted-path field pointer | Stripe parity. Lets the dashboard’s Edit CTA deep-link to the broken field. |
detail |
Raw provider payload | Stripe Tax JSON envelope, last payment error message. Disclosed in collapsible section. |
since |
tax_deferred_at / due_at / updated_at |
Operators triage by age. |
Reason taxonomy — five codes today
Section titled “Reason taxonomy — five codes today”tax_calculation_failed — generic tax computation failure (jurisdiction, outage, auth, unknown)tax_location_required — specific case: customer address missing/invalidpayment_failed — provider declined the chargepayment_unconfirmed — Stripe returned an ambiguous outcome (5xx/timeout)overdue — past due_at, still unpaidReserved-but-not-yet-emitted (planned, code reserved): finalization_failed, payment_action_required, payment_method_required, dunning_exhausted, dispute_lost. Adding these later is a non-breaking extension — the enum is documented as open-for-extension in the OpenAPI spec. (2026-10-19: dunning_exhausted shipped 2026-07-22, and dispute_lost shipped with #979: a dispute on one of the invoice’s payments closed as lost. It is Critical and shows on paid invoices.)
Action codes — closed enum
Section titled “Action codes — closed enum”edit_billing_profile, retry_tax, retry_payment, wait_provider,rotate_api_key, reconcile_payment, review_registrationMirrors the Stripe / Recurly / Chargebee verb cluster identified in the research. Closed because the audit log persists action codes verbatim.
Implementation
Section titled “Implementation”internal/domain/invoice_attention.go— types +ClassifyInvoiceAttention(Invoice) *Attention. Pure function, no I/O, deterministic on input fields.internal/invoice/service.go—attachAttention()helper called byGet,List,GetWithLineItems,GetByPublicToken,ApplyCoupon,RetryTax. Internal callers (engine, scheduler) reading directly fromStoreskip it.internal/billing/engine.go—RetryTaxForInvoice(ctx, tenantID, invoiceID)re-runsApplyTaxToLineItemsand persists atomically viaUpdateTaxAtomic. Backs theretry_taxaction.POST /v1/invoices/{id}/retry-tax— operator-triggered tax recompute. Audit-logged with before/after attention reason.migration 0067addstax_error_codecolumn with CHECK constraint; the engine deferral path populates it viatax.Classify(err).
What we deliberately did NOT do
Section titled “What we deliberately did NOT do”Persist attention as a column. Tempting (would enable SQL-level filtering “WHERE attention.reason = ‘tax_failed’”), but creates a stale-attention class of bug — every state transition that touches a source field would have to also rewrite attention or risk drift. Derive-on-read is the safer default; we add a persisted projection only when SQL list filtering becomes load-bearing.
Aggregate invoice.attention_changed webhook event. Zero precedent across the six platforms researched. Per-cause events (invoice.tax_failed, invoice.payment_failed, etc.) match Stripe and let consumers subscribe selectively. Webhook event work is deferred to a follow-up commit.
Subscription-scoped attention (Maxio pattern). Hardcount is invoice-scoped per the existing data model; the subscription page can summarize by aggregating its invoices’ attention.
Free-form action codes. Audit logging requires stable codes; closed enum.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Single component (
InvoiceAttention.tsx) renders the banner across detail, list, and (eventually) hosted-invoice pages. - One typed contract for SDK consumers — they switch on
reasonfor UX, route oncodefor alerting. - Adding a new failure mode (e.g.,
dispute_lost) requires: enum entry on server, action codes if new verbs needed, doc page. No frontend changes for the long tail — the component renders any reason via the default-label map. - Operators see prescriptive next steps inline (Stripe parity), not a free-text reason they have to interpret.
Negative
Section titled “Negative”- The classifier is centralized — every read path that wants attention populated has to go through
service.attachAttention. Internal callers (billing engine, scheduler) reading the store directly don’t get attention; this is intentional but easy to forget. - Reason enum is a public contract — adding wrong codes early is expensive to walk back.
- Severity is an opinionated three-value enum; Stripe doesn’t ship it and a future design partner could push back. The
codefield gives them an escape hatch (route oncode, ignoreseverity).
Open follow-ups
Section titled “Open follow-ups”- Per-cause webhook events (deferred — research-recommended over aggregate).
- SQL-level “needs attention” filter on
GET /v1/invoices— promoteattention.reasonto a persisted projection when list volume justifies it. - Doc site pages at
docs.hardcount.dev/errors/<reason>— currently linked but the URLs return 404. - Background tax retry worker — today the only tax retry is operator-triggered. A worker that runs every N minutes against
tax_status='pending'invoices closes the loop. - Rate limiting on the
retry-taxendpoint —tax_retry_countalready exists; cap at e.g. 5 manual retries per invoice per hour once volume warrants.