Decision: Invoice reference number — ownership and design
Decided: 2026-05-03
Status: Accepted
Affects: Billing::Invoice, Billing::LegalEntity, billing UCs around invoice creation and issuance
Context
Today, the Jod system does not yet support gig invoices, and the gig domain is not implemented. Until the gig work lands, the invoice flow is manual:
- Employer requests an invoice for gig credits via WhatsApp
- Sales asks finance to generate the invoice
- Finance allocates the next reference number from a manually maintained sequence and creates the invoice in Xero with that number
- Finance gives sales the invoice number
- Sales records the invoice in the Jod system, using that number as
Billing::Invoice.ref_number - Sales clicks "Send Invoice to Client" — Jod generates a PDF, attaches it to an email, and sends it to the bill-to email
- On accepted Mailgun send, the invoice transitions from
:draftto:issued
Finance currently uses two reference number patterns (depending on whether the invoice covers a single outlet or a whole company). The structure of the number is finance's concern — Jod treats ref_number as an opaque string.
Decision
1. Finance owns the reference number sequence today; Jod will own it later
For the manual flow, sales enters ref_number on Billing::Invoice at create time. Jod does not generate it.
The columns Billing::LegalEntity.invoice_number_prefix and invoice_number_sequence are retained on the model. They are not used today, but will become live when the gig domain is implemented and Jod interfaces with Xero programmatically. At that point, Billing::LegalEntity UC-4 ("Generate the next invoice number") becomes the canonical source.
2. ref_number mutability — Option B
Three options were considered:
- Option A —
ref_numberimmutable from the moment it is set (even in:draft) - Option B —
ref_numbereditable in:draft; immutable from:issuedonward (selected) - Option C — System uses a placeholder
ref_number; sales replaces it before send
Option B was selected. It is consistent with the general "draft is editable, issued is immutable" rule the rest of the invoice spec follows. Once the invoice is issued (Mailgun-accepted send), all fields including ref_number are locked.
Option A was rejected because the immutability cost (void + recreate to fix a typo in draft) is unnecessary: the rest of the invoice is editable in draft, so ref_number should follow the same rule.
Option C was rejected because placeholders are a known smell — they tend to leak into customer-facing artefacts when developers forget to swap them.
3. external_reference column dropped
The current schema has an external_reference column on billing_invoices intended to store Xero's invoice number separately from Jod's internal number. In the new flow, ref_number IS the Xero number, so external_reference has nothing to hold. It is removed.
It can be re-added when Jod begins making programmatic API calls to Xero — at that point, Xero's internal Invoice GUID (which is different from the human-readable invoice number) is genuinely useful to store. That work is part of the gig implementation.
4. Two state machines: status and delivery_status
The invoice now has two state machines that interact at exactly one point.
- Commercial
status::draft → :issued → :partially_paid → :paid → :void → :credited. Same as before. - Delivery
delivery_status(new)::not_attempted → :queued → :delivered, with branches to:failedand:bounced.
The coupling: delivery_status: :not_attempted → :queued (Mailgun accepted the send) is what causes status: :draft → :issued. All other delivery transitions update only delivery_status.
A single :email_failed state was considered and rejected. It would have mixed two axes (commercial state and delivery mechanism) into one enum, leading to transition explosion (every retry, every webhook event would need a new transition). Splitting them into two small machines keeps both clean.
5. Mailgun event mapping
The Mailgun webhook handler maps events to delivery_status as follows. Note that the webhook event names use temporary_fail and permanent_fail (not failed with a severity field — that shape is in the Events API, not the Webhooks API):
| Source signal | Maps to delivery_status |
|---|---|
Sync HTTP 200/202 from messages.send | :queued |
Sync HTTP 4xx/5xx from messages.send | :failed |
Webhook accepted | (no change — confirms sync accept) |
Webhook delivered | :delivered |
Webhook temporary_fail | (no change — Mailgun retries) |
Webhook permanent_fail | :bounced |
Webhook complained | (no change today; jsonb only) |
Webhook unsubscribed | (no change; jsonb only) |
Webhook opened, clicked | (no change; jsonb only) |
Mailgun's Events API also has a rejected event but it is not dispatched as a webhook. The synchronous 4xx response from messages.send covers that case.
Schema impact
On billing_invoices:
- Rename
invoice_number→ref_number(globally unique, indexed) - Add
file_url,file_generated_at(PDF path and generation time) - Add
delivery_status(rails_enum::not_attempted, :queued, :delivered, :failed, :bounced) - Add
email_sent_at,email_last_failed_at(projection columns for fast filtering) - Add
email_provider_response(jsonb, append-only Mailgun audit array including sync responses and webhook events) - Drop
external_reference - Add composite index on
(status, delivery_status)
On billing_legal_entities:
- No change.
invoice_number_prefixandinvoice_number_sequencecolumns retained for future use.
Open questions
These are flagged but not solved:
- Voided
ref_numberreuse: When an invoice is voided, the number is consumed. Finance expects to skip the voided number in their next allocation. Not yet handled in the spec. - Late bounce after
:issued: Apermanent_failwebhook can arrive after the invoice has been issued. Today, we updatedelivery_status: :delivered → :bouncedbut leave commercialstatusunchanged. Whetherstatusshould unwind is its own design. :complainedpromotion todelivery_statusenum: Today complaint events are stored in jsonb only. If complaints become common, promote to enum value for filterability.- Mailgun event identifier field name: The webhook handler dedupes on Mailgun's event identifier. The exact field name should be verified against Mailgun's payload schema at implementation time.
References
models/billing-invoice.md— full invoice spec including the new UCs and state machinesmodels/billing-legal-entity.md— legal entity spec; UC-4 marked as future use- Mailgun webhook docs: https://documentation.mailgun.com/docs/mailgun/user-manual/webhooks/webhooks
- Mailgun events list: https://documentation.mailgun.com/docs/mailgun/user-manual/events/events