Skip to main content

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:

  1. Employer requests an invoice for gig credits via WhatsApp
  2. Sales asks finance to generate the invoice
  3. Finance allocates the next reference number from a manually maintained sequence and creates the invoice in Xero with that number
  4. Finance gives sales the invoice number
  5. Sales records the invoice in the Jod system, using that number as Billing::Invoice.ref_number
  6. Sales clicks "Send Invoice to Client" — Jod generates a PDF, attaches it to an email, and sends it to the bill-to email
  7. On accepted Mailgun send, the invoice transitions from :draft to :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 Aref_number immutable from the moment it is set (even in :draft)
  • Option Bref_number editable in :draft; immutable from :issued onward (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 :failed and :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 signalMaps 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_numberref_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_prefix and invoice_number_sequence columns retained for future use.

Open questions

These are flagged but not solved:

  • Voided ref_number reuse: 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: A permanent_fail webhook can arrive after the invoice has been issued. Today, we update delivery_status: :delivered → :bounced but leave commercial status unchanged. Whether status should unwind is its own design.
  • :complained promotion to delivery_status enum: 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