Billing::Invoice
Purpose
An Invoice is a customer-facing commercial document generated when an Org::Company wants to purchase entitlements (placement credits, gig credits). It captures what was sold, at what price, under which legal entity, and tracks the payment lifecycle through to entitlement granting.
Invoices serve as the bridge between commercial transactions and entitlement accounting. The commercial layer (what we sold, for how much) is deliberately separated from the entitlements layer (what the customer can spend) — the invoice posting mechanism is what connects them.
Model Context
| Context | Details |
|---|---|
| Aggregate | Billing::Invoice (root) + Billing::InvoiceItem (child) + Billing::Payment (child) + Billing::InvoicePosting (child) |
| Layer | Commercial Documents |
| Upstream dependencies | Billing::Account, Billing::Agreement (terms inform pricing), Billing::LegalEntity (seller), Billing::BillToProfile (buyer) |
| Downstream dependents | Billing::LedgerEntry (posting writes grants), Billing::EntitlementLot (gig posting creates lots), Billing::EntitlementBalance (updated on posting) |
State Machine
Invoice status
| From | To | Trigger | Notes |
|---|---|---|---|
| (new) | draft | UC-1: Invoice created | Default status on creation |
draft | issued | UC-2b: Mailgun accepts the send | Invoice becomes immutable |
draft | void | UC-8: Admin voids before issuing | Created in error |
issued | partially_paid | UC-5: First verified payment, < total | Awaiting remaining balance |
issued | paid | UC-5: Verified payments >= total | Triggers posting (UC-7) |
issued | void | UC-8: Admin voids before payment | Customer cancels |
partially_paid | paid | UC-5: Cumulative verified payments >= total | Triggers posting (UC-7) |
paid | credited | (future) Credit note / refund workflow | Post-payment correction |
Payment status
| From | To | Trigger | Notes |
|---|---|---|---|
| (new) | submitted | UC-4: Admin records payment | Proof provided, awaiting review |
submitted | verified | UC-5: Finance confirms receipt | Updates invoice status |
submitted | rejected | UC-6: Proof invalid | No effect on invoice |
Invoice delivery_status
| From | To | Trigger | Notes |
|---|---|---|---|
| (new) | not_attempted | UC-1: Invoice created | Default |
not_attempted | queued | UC-2b: Mailgun accepts the send | Drives status: :draft → :issued |
not_attempted | failed | UC-2b: Mailgun rejects the send | Status stays :draft |
queued | delivered | UC-2c: delivered webhook | Recipient mail server accepted |
queued | bounced | UC-2c: permanent_fail webhook | Mailgun gave up; recipient unreachable |
delivered | bounced | UC-2c: late permanent_fail webhook | Rare; see Open Question on post-issuance bounce |
failed | queued | UC-2b: admin retries, Mailgun accepts | Recovery path |
bounced | queued | UC-2b: admin fixes email and retries | Recovery path |
Use Cases
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Create an invoice for a company's credit purchase | Company wants to purchase credits | Admin (sales) |
| UC-2a | Generate the invoice file (PDF) for preview | Admin clicks "Generate Invoice File" | Admin (sales) |
| UC-2b | Send the invoice to the client | Admin clicks "Send Invoice to Client" | Admin (sales) |
| UC-2c | Process a Mailgun webhook event | Mailgun POSTs a delivery event | System |
| UC-3 | Edit a draft invoice before issuance | Admin needs to correct details before issuing | Admin (sales) |
| UC-4 | Record a bank transfer payment against an invoice | Customer provides proof of bank transfer | Admin (ops) |
| UC-5 | Verify a payment and settle the invoice | Finance confirms money received in bank account | Admin (finance) |
| UC-6 | Reject an invalid payment submission | Proof is invalid or money not received | Admin (finance) |
| UC-7 | Post a paid invoice to grant entitlements | Invoice transitions to paid | System |
| UC-8 | Void an unpaid invoice | Invoice created in error or customer cancels | Admin |
| UC-9 | View invoice details with line items and payments | Admin needs to review a specific invoice | Admin |
| UC-10 | View all invoices for a billing account | Admin needs to see all invoices for a company | Admin |
UC-1: Create an invoice for a company's credit purchase
| Field | Details |
|---|---|
| Actor | Identities::Admin (sales/ops) |
| Trigger | Company wants to purchase entitlements (placement credits or gig credits) |
Preconditions:
Billing::Accountexists for theOrg::Company- An active
Billing::Agreementexists with relevant terms Billing::ProductandBilling::ProductPriceexist for the desired entitlement and marketBilling::LegalEntityexists for the seller jurisdictionBilling::BillToProfileexists for the buyer
System Behavior:
- Admin selects the billing account, product, and quantity
- System resolves the applicable
Billing::ProductPricebased on:country_idmatching the company's countrystatus = :active(and parent productstatus = :active)billing_account_id IS NULL(standard) ORbilling_account_idmatches the company's billing account (private price overrides standard)
- System resolves the active
Billing::Agreementto obtain negotiated terms (fee rates, unit prices, discounts) - System generates the invoice in
draftstatus:- Snapshots bill-to fields from
Billing::BillToProfile(company name, attention, email, address) - Sets
currencyfrom the bill-to profile - Associates the seller
Billing::LegalEntity - Admin enters
ref_numberprovided by finance from Xero. System validates it is globally unique across all Jod invoices.
- Snapshots bill-to fields from
- System creates
Billing::InvoiceItemrows. Each item has aline_type:- Placement credits: 1 item with
line_type = :principal— full value with GST - Gig credits: 2 items on the same invoice:
- 1 item with
line_type = :principal— the wage value (tax-exempt, grants units) - 1 item with
line_type = :platform_fee— the platform fee (taxed, no units granted)
- 1 item with
- Every item copies these fields at creation time:
description,quantity,unit_price_cents,amount_cents,tax_cents,units_to_grant,line_type - For gig items only: both lines also copy
platform_fee_rate_bps(taken from the customer'sBilling::Agreementfee_rate term if they have one, otherwise fromBilling::ProductPrice.platform_fee_rate_bpsas the list rate)
- Placement credits: 1 item with
- System computes and stores
subtotal_cents,tax_cents,total_centson the invoice - Admin audit trail recorded
Business Rules:
- Invoice items snapshot all pricing and tax terms at creation time — later changes to products, prices, or agreements do not affect existing invoices
- Gig credit invoices must have exactly two line items: principal (tax_cents = 0) and platform fee (tax applied)
- Placement credit invoices apply GST to the full value
ref_numberis globally unique across all Jod invoices (GST requirement: running reference numbers).ref_numberis required at create time. The format is opaque to Jod — finance owns the format (today, manually allocated and entered into Xero before sales records the invoice in Jod).- Pricing is never hard-coded — it comes from
Billing::ProductPrice
Postconditions:
- Invoice exists in
draftstatus with computed totals - No payments or postings exist yet
- No entitlements are granted
UC-2a: Generate the invoice file (PDF) for preview
| Field | Details |
|---|---|
| Actor | Identities::Admin (sales) — initiates; System — performs |
| Trigger | Admin clicks "Generate Invoice File" on a draft invoice |
Preconditions:
- Invoice is in
:draftstatus - Invoice has at least one line item
- All required snapshot fields are populated (bill-to, currency, totals)
System Behaviour:
- Async background job renders the invoice file (PDF) from the snapshotted invoice fields
- Job stores the file at
file_urland stampsfile_generated_at - Status remains
:draft.delivery_statusremains:not_attempted. - Admin can re-generate the file as long as the invoice is in
:draft(each generation overwrites the previous file)
Business Rules:
- File generation is idempotent against draft edits: if the draft changes after generation, admin must re-generate before sending
- Generation does not change
statusordelivery_status - The file is access-controlled — only admins with the right permission can open
file_url
Postconditions:
file_urlandfile_generated_atare populated- Status unchanged
UC-2b: Send the invoice to the client
| Field | Details |
|---|---|
| Actor | Identities::Admin (sales) |
| Trigger | Admin clicks "Send Invoice to Client" |
Preconditions:
- Invoice is in
:draftstatus file_urlis populated (UC-2a already ran)delivery_statusis:not_attempted,:failed, or:bounced(retries allowed from these states)- Bill-to email is set on the snapshotted bill-to fields
System Behaviour:
- Manager calls Mailgun's
messages.sendAPI with the file attached, the bill-to email as recipient, and Mailgun custom variablesv:email_type=billing_invoice_issuedandv:invoice_uuid=<invoice.uuid>. The custom variables let the webhook handler (UC-2c) identify invoice-related events and ignore events for other email types (welcome, password reset, etc.). The UUID (not the primary key) is used because webhook payloads are stored externally and may appear in logs. - Manager appends the synchronous Mailgun response to
email_provider_response(kind:send_attempt, with timestamp and outcome) - Branch on Mailgun's response, in the same DB transaction:
- HTTP 200/202 (accepted): set
delivery_status: :queued; setemail_sent_at = now; transitionstatus: :draft → :issued; setissued_at = now - HTTP 4xx or 5xx (rejected): set
delivery_status: :failed; setemail_last_failed_at = now; status stays:draft
- HTTP 200/202 (accepted): set
- After commit, downstream Mailgun webhook events (delivered, bounced, etc.) are handled by UC-2c
Business Rules:
- A successful synchronous send (Mailgun queued) is the moment of issuance. Issuance is not a separate click.
ref_numberis locked from this point — like all other invoice fields, it becomes immutable on:issued- Idempotency: if
email_sent_atis already set within the last 5 seconds, treat the second click as a no-op (prevents accidental double-sends from double-clicks) - The Mailgun webhook signature must be verified before any webhook payload is processed (security baseline)
Postconditions:
- On accepted send: invoice is
:issued,delivery_status: :queued, customer will receive the email shortly - On rejected send: invoice stays
:draft,delivery_status: :failed, admin sees the failure reason fromemail_provider_responseand can retry
UC-2c: Process a Mailgun webhook event
| Field | Details |
|---|---|
| Actor | System (Mailgun → Jod webhook handler) |
| Trigger | Mailgun POSTs a webhook event for an invoice's email delivery |
Preconditions:
- The webhook payload signature is verified (HMAC) — invalid signatures are rejected with 401
- The webhook event's
event-data.user-variables.email_typeisbilling_invoice_issued. Ifemail_typeis missing or any other value, the handler returns 200 OK without further processing — the event is for a non-invoice email and should be ignored. - The webhook event references a known Jod invoice via
event-data.user-variables.invoice_uuid(set at send time in UC-2b). Ifinvoice_uuidis missing, the handler returns 200 OK without further processing.
System Behavior:
- Verify HMAC signature on the webhook payload — reject with 401 if invalid
- Read
event-data.user-variables.email_typeandevent-data.user-variables.invoice_uuid. Ifemail_type != "billing_invoice_issued"orinvoice_uuidis missing, return 200 OK and stop. Otherwise resolve the target invoice byinvoice_uuid. - Look up Mailgun's unique event identifier in the existing
email_provider_responsearray. If already present (duplicate webhook), respond 200 and stop (idempotency) - Compare the new event's timestamp against the most recent applied event for this invoice. If the new event is older, append it to the audit array but do NOT change
delivery_status(stale event guard) - Otherwise, append the event to
email_provider_response(kind:webhook_event, with event name, timestamp, and the full Mailgun payload) - Map the Mailgun event name to a
delivery_statuschange per the table below. Updatedelivery_status,email_last_failed_at(if applicable) in the same transaction.
Mailgun event → delivery_status mapping:
| Mailgun event | Effect on delivery_status | Effect on email_last_failed_at |
|---|---|---|
accepted | (no change — already set on sync accept) | (no change) |
delivered | :delivered | (no change) |
temporary_fail | (no change — Mailgun is retrying) | (no change) |
permanent_fail | :bounced | set to now |
complained | (no enum change today — append to jsonb only) | (no change) |
unsubscribed | (no change — append to jsonb only) | (no change) |
opened | (no change — engagement) | (no change) |
clicked | (no change — engagement) | (no change) |
Business Rules:
- Webhook signature verification is mandatory — never trust an unverified webhook
- Webhook handler is idempotent against duplicate Mailgun events (deduped by Mailgun's event identifier)
- Stale webhook events do not change
delivery_statusbut ARE appended to the audit array delivery_statusupdates triggered by webhooks today do NOT change commercialstatus(the post-issuance bounce unwind is an Open Question)
Postconditions:
email_provider_responsehas one new entrydelivery_statusmay have changed per the mapping- Commercial
statusis unchanged (today)
UC-3: Edit a draft invoice before issuance
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Admin needs to correct details before issuing |
Preconditions:
- Invoice exists in
draftstatus
System Behavior:
- Admin modifies invoice fields (due date, bill-to details) and/or adds, removes, or updates line items
- System recomputes
subtotal_cents,tax_cents,total_cents - Admin audit trail recorded
Business Rules:
- Editing is only allowed while status is
draft - All creation-time validations still apply (e.g., gig invoices need two line items)
Postconditions:
- Invoice reflects updated values with recomputed totals
UC-4: Record a bank transfer payment against an invoice
| Field | Details |
|---|---|
| Actor | Identities::Admin (business/ops) |
| Trigger | Customer makes a bank transfer and provides proof of payment |
Preconditions:
- Invoice exists in
issuedorpartially_paidstatus
System Behavior:
- Admin creates a
Billing::Paymentrecord with:method=bank_transferstatus=submittedamount_cents= transfer amountbank_reference= reference number from bank statementproof_url= S3 path to proof document (screenshot, receipt)
Business Rules:
- Multiple payments per invoice are allowed (supports partial payments and accidental duplicate recordings)
- A payment in
submittedstatus does not affect the invoice status — it requires verification first
Postconditions:
- Payment record exists in
submittedstatus - Invoice status unchanged
UC-5: Verify a payment and settle the invoice
| Field | Details |
|---|---|
| Actor | Identities::Admin (finance) |
| Trigger | Finance confirms money has been received in the bank account |
Preconditions:
- Payment exists in
submittedstatus - Invoice is in
issuedorpartially_paidstatus
System Behavior:
- Finance admin marks the payment as
verified - System records
verified_at,verified_by_admin_id, andreceived_at - System recomputes
verified_total = sum(amount_cents)across all verified payments for this invoice - System updates the invoice status:
- If
verified_total == 0→ staysissued - If
0 < verified_total < total_cents→partially_paid - If
verified_total >= total_cents→paid, andsettled_at = now
- If
- If invoice becomes
paid, the system automatically triggers posting (UC-7)
Business Rules:
- Only
submittedpayments can be verified - Verification records who verified and when (audit trail)
- Entitlements are granted only when the invoice reaches
paid— partial payments do not grant partial credits
Postconditions:
- Payment status =
verified - Invoice status updated based on cumulative verified total
- If invoice became
paid: posting is triggered (UC-7)
UC-6: Reject an invalid payment submission
| Field | Details |
|---|---|
| Actor | Identities::Admin (finance) |
| Trigger | Proof is invalid or money was not received |
Preconditions:
- Payment exists in
submittedstatus
System Behavior:
- Finance admin marks the payment as
rejected - System records the rejection (no recomputation of invoice status needed — rejected payments don't count)
Business Rules:
- Only
submittedpayments can be rejected - Rejecting a payment does not change the invoice status
- The customer or ops team can record a new payment to try again
Postconditions:
- Payment status =
rejected - Invoice status unchanged
UC-7: Post a paid invoice to grant entitlements
| Field | Details |
|---|---|
| Actor | System (triggered automatically when invoice becomes paid) |
| Trigger | Invoice transitions to paid status |
Preconditions:
- Invoice status =
paid - No
Billing::InvoicePostingexists for this invoice (idempotency guard)
System Behavior:
- Lock the invoice row (
SELECT ... FOR UPDATE) - Create
Billing::InvoicePostingwithposted_atandidempotency_key - For each
Billing::InvoiceItem, look at itsline_typeand the product's entitlement to decide what to do:- Placement principal (
line_type = :principal, entitlement =:placement):- Write a
Billing::LedgerEntrywithentry_type = :grant available_delta = +units_to_grantdeferred_revenue_delta_cents = +amount_cents
- Write a
- Gig principal (
line_type = :principal, entitlement =:gig):- Write a
Billing::LedgerEntrywithentry_type = :grant - Create a
Billing::EntitlementLot:platform_fee_rate_bps= copied from this item'splatform_fee_rate_bpscolumnunits_purchased= this item'sunits_to_grantplatform_fee_total_cents= copied from the matching:platform_feeitem'samount_cents
- Write a
- Gig platform fee (
line_type = :platform_fee, entitlement =:gig):- Write a
Billing::LedgerEntrywithplatform_fee_deferred_delta_cents = +amount_cents - No units are granted (this is a money-only line)
- Write a
- Placement principal (
- Update
Billing::EntitlementBalanceprojections:units_available += units_to_grantdeferred_revenue_cents += amount(placement)platform_fee_deferred_cents += fee(gig)
- All of the above happens in a single DB transaction
Business Rules:
- Unique constraint on
Billing::InvoicePosting.billing_invoice_idprevents double-granting (idempotency) - Posting is atomic — if any step fails, the entire transaction rolls back
- Each invoice can only be posted once
Postconditions:
Billing::InvoicePostingrecord exists- Ledger entries recorded (grants)
- Entitlement balances updated — company can immediately spend credits
- For gig: lots created with FIFO ordering
UC-8: Void an unpaid invoice
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Invoice was created in error, or customer cancels before paying |
Preconditions:
- Invoice is in
draftorissuedstatus - No verified payments exist for this invoice
System Behavior:
- Admin voids the invoice
- System transitions the invoice to
voidstatus
Business Rules:
- Cannot void an invoice that has verified payments — those must be dealt with first
- Cannot void a
paidorcreditedinvoice - A voided invoice is kept for audit purposes (never deleted)
- To correct a voided invoice: create a new one
Postconditions:
- Invoice status =
void - No entitlements are affected (posting never happened)
Open Questions:
- Should voiding require a reason field for audit purposes?
- Should submitted (unverified) payments be automatically rejected when an invoice is voided?
- Voided
ref_numberreuse: When an invoice is voided, theref_numberis consumed (the unique constraint blocks reuse, matching GST practice that allows gaps but not duplicates). Finance currently expects to skip over the voided number when allocating the next one in Xero. This case is not yet handled in the spec — needs design when finance defines a void-and-renumber policy.
UC-9: View invoice details with line items and payments
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Admin needs to review a specific invoice |
Preconditions:
- Invoice exists
System Behavior:
- Admin navigates to the invoice detail page
- System displays:
- Invoice header: number, status, dates, currency, totals
- Seller details: legal entity name and address
- Buyer details: snapshotted bill-to fields
- Line items: description, quantity, unit price, amount, tax, units to grant
- Payments: list of all payments with status, amount, bank reference, proof link
- Posting status: whether the invoice has been posted, and when
Postconditions:
- Read-only operation — no data changes
UC-10: View all invoices for a billing account
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Admin needs to see all invoices for a company's billing account |
Preconditions:
Billing::Accountexists
System Behavior:
- Admin navigates to the billing account → Invoices tab
- System displays all invoices ordered by
created_atdescending - Each invoice shows: number, status, currency, total, due date, payment status
Business Rules:
- Voided invoices remain visible (audit requirement)
- Filterable by status
Postconditions:
- Read-only operation — no data changes
Invariants
- An invoice must belong to exactly one
Billing::Account - An invoice must have at least one
Billing::InvoiceItem - Once
issued, invoice amounts and items are immutable — corrections require void + reissue - Invoices are never deleted — only voided or credited
ref_numberis globally unique across all Jod invoices (GST requirement).- Entitlements are granted only after the invoice reaches
paidstatus — partial payments do not grant partial credits - Each invoice can be posted at most once (enforced by unique constraint on
Billing::InvoicePosting.billing_invoice_id) - Invoice items snapshot all pricing, tax, and terms at creation time — later changes to products or agreements do not affect existing invoices
- Gig credit invoices must have two line items: principal (tax-exempt) and platform fee (taxed)
total_cents = subtotal_cents + tax_centsand must equal the sum of all item(amount_cents + tax_cents)- Admin audit trail (
created_by,updated_by) is always populated on invoices ref_numberis required at invoice create time.- While the invoice is in
:draft, all attributes (includingref_number, line items, due date, bill-to snapshot) are editable. - Once the invoice transitions to
:issued, all attributes are immutable. Corrections require void and recreate. delivery_statusstarts at:not_attemptedand progresses per the delivery state machine.- The transition
delivery_status: :not_attempted → :queuedhappens in the same DB transaction asstatus: :draft → :issued. The two cannot diverge. - The Mailgun webhook handler is idempotent against duplicate events (deduped by Mailgun's event identifier) and ignores stale events for
delivery_statusupdates (but always appends to the audit array).
Open Questions
- Late bounce after
:issued: Apermanent_failwebhook can arrive after the invoice has already transitioned to:issued. Today, we updatedelivery_status: :delivered → :bouncedbut leave commercialstatusunchanged. Shouldstatusunwind (e.g., back to:draftor to a new:undeliverablestate)? Needs design. :complainedpromotion to enum: Mailgun firescomplainedevents when a recipient marks the email as spam. Today we append toemail_provider_responsebut do not changedelivery_status. If complaints become common, promote:complainedto adelivery_statusenum value for filterability.- Mailgun event identifier field name: The webhook handler dedupes on Mailgun's event identifier. The exact field name (likely
event-idoridinsideevent-data) should be verified against Mailgun's payload schema at implementation time. - What counts as "successful send": Today we treat Mailgun's HTTP 200/202 (accepted into Mailgun's queue) as the moment of issuance. An alternative is to wait for the
deliveredwebhook — more honest, but introduces an unbounded delay. Current choice favours user feedback latency.
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Billing::Account | Invoice belongs_to Account | Account must exist. One account has many invoices over time. |
Billing::Agreement | Invoice references Agreement | Agreement's terms inform pricing at invoice creation. Invoice snapshots the relevant values. |
Billing::LegalEntity | Invoice belongs_to LegalEntity | Seller-of-record. Determines invoice numbering, tax regime, and currency. |
Billing::BillToProfile | Invoice belongs_to BillToProfile | Buyer details source. Invoice snapshots bill-to fields at creation (never joins back for historical invoices). |
Billing::InvoiceItem | Invoice has_many InvoiceItems | Line items created with the invoice. Snapshot product, pricing, tax, and units to grant. |
Billing::Payment | Invoice has_many Payments | Tracks offline bank transfers. Multiple payments supported for partial payments. |
Billing::InvoicePosting | Invoice has_one InvoicePosting | Idempotent record of entitlement granting. Created once when invoice becomes paid. |
Billing::Product | InvoiceItem references Product | What was sold (placement credits, gig credits). Provides grants_units_per_quantity. |
Billing::ProductPrice | InvoiceItem references ProductPrice | Market-specific pricing used at issuance. Values snapshotted into item columns. |
Billing::LedgerEntry | Posting creates LedgerEntries | Grant entries written to the append-only ledger during posting. |
Billing::EntitlementLot | Posting creates Lots (gig only) | FIFO purchase batch created during gig posting with per-lot platform fee rate. |
Billing::EntitlementBalance | Posting updates Balances | units_available and deferred revenue/fee projections updated during posting. |
Identities::Admin | Invoice tracks created_by / updated_by | Audit trail. Payment verification also records verified_by_admin_id. |
Schema Gaps
Items identified by comparing use cases against the current DBML schema (billing.dbml):
| Gap | Impact | Suggested Resolution |
|---|---|---|
No billing_agreement_id on billing_invoices | Cannot trace which agreement's terms were used for pricing | Add FK to billing_agreements |
No issued_at column on billing_invoices | Cannot record when invoice transitioned from draft to issued | Add issued_at timestamp column |
No settled_at column on billing_invoices | Cannot record when invoice became fully paid | Add settled_at timestamp column |
Rename invoice_number to ref_number on billing_invoices | Reflect new finance-owned numbering scheme | Migration to rename column |
No file_url column on billing_invoices | Cannot persist the generated invoice file path | Add file_url string |
No file_generated_at column on billing_invoices | Cannot record when the file was last generated | Add file_generated_at timestamptz |
No delivery_status column on billing_invoices | Cannot track Mailgun delivery state for filtering | Add delivery_status string [not null, default: 'not_attempted', note: 'rails_enum(:not_attempted, :queued, :delivered, :failed, :bounced)'] |
No email_sent_at column on billing_invoices | Cannot record successful send timestamp | Add email_sent_at timestamptz |
No email_last_failed_at column on billing_invoices | Cannot record latest failure timestamp | Add email_last_failed_at timestamptz |
No email_provider_response column on billing_invoices | Cannot store the append-only Mailgun audit trail | Add email_provider_response jsonb [not null, default: '[]'] |
Remove external_reference from billing_invoices | Column is unused now; was placeholder for Xero number which is now ref_number itself | Migration to drop column. Re-add when Jod interfaces with Xero programmatically (post-gig implementation) |
No composite index on (status, delivery_status) | List filter "draft invoices that need attention" is slow | Add composite index |
No unique index on ref_number (global) | Duplicate ref_number could be inserted | Add unique index |