Billing::Invoice
This model receives several schema additions in Phase 2. All changes are additive (no breaking changes to existing columns) except for:
invoice_number→ renamed toref_numberexternal_reference→ dropped
Both decisions are documented in Invoice ref_number ownership.
The Schema Gaps section at the bottom of this document lists all required changes with migration guidance. See Phase 2 Overview for full context.
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::InvoiceLine (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::CompanyBilling::ProductandBilling::ProductPriceexist for the desired entitlement and marketBilling::LegalEntityexists for the seller jurisdictionBilling::BillToProfileexists for the buyer- A
Billing::Agreementis optional. When provided, it must belong to the billing account and be currently within itseffective_from/effective_towindow — enforced with a 422 if not. When omitted, or when the account has no agreement, pricing falls back to the catalogBilling::ProductPricerate (see step 3).
System Behavior:
- Admin selects the billing account, product, and quantity, and optionally an agreement
- 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)
- If an agreement was provided, system resolves it to obtain negotiated terms (
fee_rate,discount_rate— see business rules). Onlyfee_rateanddiscount_rateterms are read today; aunit_priceterm is not read by invoicing (pricing always comes from the catalogBilling::ProductPrice.unit_price_cents). - System generates the invoice in
draftstatus:- Snapshots bill-to fields from
Billing::BillToProfile(company name, attention, email, address) - Sets
currency(andbill_to_currency, which mirrors it) from the seller'sBilling::LegalEntity.default_currency— an invoice is denominated in the currency of the legal entity issuing it, not the payer's. Admin selects the legal entity earlier in the flow, and the frontend prefills the currency field from that selection before the invoice is created. - 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::InvoiceLinerows — exactly one line param may be submitted per invoice (a second product goes on a separate invoice). Each line has aline_type:- Placement credits: 1 line with
line_type = :principal— full value with GST, less any discount (see below) - Gig credits: 2 lines on the same invoice (expanded from the single submitted line param):
- 1 line with
line_type = :principal,description = "{product name}"— the wage value owed to the buyer/company (tax-exempt, grants units, never discounted) - 1 line with
line_type = :platform_fee,description = "Platform Fee"— Jod's platform margin (taxed, no units granted; carries any discount — see below)
- 1 line with
- Every line copies these fields at creation time:
description,quantity,unit_price_cents,amount_cents,discount_cents,discount_rate_bps,tax_cents,units_to_grant,line_type unit_price_centsalways comes from the catalogBilling::ProductPrice.unit_price_cents— agreements do not override it- For gig lines 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) discount_cents/discount_rate_bpsare resolved from the agreement'sdiscount_rateterm for the product's entitlement (discount_cents = discount_rate_bps / 10_000 × the discounted line's amount_cents), and land on whichever line represents Jod's own revenue:- Gig: the
:platform_feeline only — a discount reduces Jod's margin, never the wage value passed through to the buyer/company.discount_centsanddiscount_rate_bpsare always0/nilon the gig:principalline. The fee'stax_centsis computed on the post-discount fee amount; the fee'samount_centsstays the gross (pre-discount) fee. - Placement: the
:principalline (there is no:platform_feeline) — same mechanics,tax_centscomputed on the post-discount amount,amount_centsstays gross.
- Gig: the
- There is no catalog fallback for discount; no matching
discount_rateterm (or no agreement at all) meansdiscount_cents: 0on every line.
- Placement credits: 1 line with
- System computes and stores
subtotal_cents(sum of lineamount_centsminus sum of linediscount_cents),discount_cents(sum of linediscount_cents),tax_cents,total_centson the invoice - Admin audit trail recorded
Business Rules:
- Invoice lines snapshot all pricing and tax terms at creation time — later changes to products, prices, or agreements do not affect existing invoices
- An invoice can only have one product line — placement and gig products cannot be mixed, and two placement products cannot be split across lines on the same invoice; create a separate invoice instead
- Gig credit invoices must have exactly two line items: principal (tax_cents = 0, never discounted) and platform fee (tax applied, carries any discount)
- Placement credit invoices apply GST to the principal value net of any discount
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 always comes from
Billing::ProductPrice. Agreements do not override the unit price today (aunit_priceterm is not read by invoicing). currencyandbill_to_currencyboth come from the seller'sBilling::LegalEntity.default_currency, not the bill-to profile — an invoice is denominated in the issuing entity's currency regardless of who pays it.billing_bill_to_profileshas nocurrencycolumn, sobill_to_currencycurrently always mirrorscurrency; it carries no independent information today.
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 S3 key at
file_pathand 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 —
file_pathis an S3 key in a private bucket, not a URL. Admins must request a short-lived signed URL (GET .../signed_url) to open the file; the key itself is never directly openable.
Postconditions:
file_pathandfile_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_pathis 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 — every field is editable while
draft, includingbilling_account_id,billing_legal_entity_id,billing_bill_to_profile_id, andbilling_agreement_id(not just due date, bill-to overrides, and lines) - When
billing_bill_to_profile_idchanges, the system re-snapshotsbill_to_company_name/bill_to_attention/bill_to_billing_email/bill_to_billing_addressfrom the newly selected profile, then applies any explicitbill_to_*overrides on the request on top (unchanged manual-correction behavior) - When
billing_legal_entity_idchanges, the system re-resolvescurrency/bill_to_currencyfrom the newly selected legal entity - All submitted (or, for a partial update, existing) line items are destroyed and rebuilt via the same pricing/discount resolution as UC-1, against the newly selected agreement — not the invoice's prior agreement
- System recomputes
subtotal_cents,discount_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, exactly one line param, an effective agreement)
- This is a
PATCH— omitted fields keep the invoice's current value; omitting all four scoping fields leaves the account, legal entity, bill-to profile, and agreement unchanged - Reassigning
billing_account_idorbilling_legal_entity_idcan invalidate an existing line'sBilling::ProductPrice(prices are scoped to(billing_account_id, billing_legal_entity_id)) — the same price-scope validation as UC-1 catches this and returns a 422
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_s3_path= 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::InvoiceLine, 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::InvoiceLine, and can only ever have one product line — a second product goes on a separate invoice - Once
issued, invoice amounts and lines 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 lines 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, never discounted) and platform fee (taxed, carries any discount)
total_cents = subtotal_cents + tax_cents,subtotal_cents = sum(line.amount_cents) - sum(line.discount_cents),discount_cents = sum(line.discount_cents)- Admin audit trail (
created_by,updated_by) is always populated on invoices ref_numberis required at invoice create time.Billing::Agreementis optional at invoice create time. When provided, it must belong to the billing account and be currently effective — creation is rejected with a 422 if not. When omitted, or when the account has no agreement,fee_ratefalls back toBilling::ProductPrice.platform_fee_rate_bpsand no discount applies.- While the invoice is in
:draft, all attributes are editable, includingbilling_account_id,billing_legal_entity_id,billing_bill_to_profile_id, andbilling_agreement_id— aPATCHthat omits these fields leaves them unchanged - 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. - Constrain legal entity selection by buyer/company country: Today the admin can pick any
Billing::LegalEntitywhen creating an invoice, andcurrencyfollows that choice (see Business Rules above). A future improvement worth considering: only offer legal entities whose country matches the billing account'sOrg::Companycountry (or the bill-to profile's country) in the admin UI/API, so the currency is effectively constrained by the buyer's market instead of relying on the admin to pick correctly. Not yet designed or implemented.
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 | Optional. When provided, must belong to the account and be currently effective. Its fee_rate/discount_rate terms inform pricing at invoice creation (a unit_price term is not read); invoice lines snapshot the resolved values. When omitted, pricing falls back entirely to the catalog. |
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::InvoiceLine | Invoice has_many InvoiceLines | Line created with the invoice. Snapshots product, pricing, discount, 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 | InvoiceLine references Product | What was sold (placement credits, gig credits). Provides grants_units_per_quantity. |
Billing::ProductPrice | InvoiceLine references ProductPrice | Market-specific pricing used at issuance. Never overridden by an agreement. |
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 |
No file_path column on billing_invoices | Cannot persist the generated invoice file path | Add file_path 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: '[]'] |
No composite index on (status, delivery_status) | List filter "draft invoices that need attention" is slow | Add composite index |
Rename invoice_number → ref_number on billing_invoices | Phase 2 term change — finance allocates this number in Xero before creating in Jod; invoice_number implies system-generated, ref_number reflects external allocation. See Invoice ref_number ownership. | Migration to rename column; globally unique index replaces old (invoice_number, billing_account_id) unique index |
Drop external_reference from billing_invoices | Was a placeholder for future Xero sync. Now redundant — ref_number itself is the Xero-allocated reference. See Invoice ref_number ownership. | Migration to drop column |
Replace (invoice_number, billing_account_id) unique index with global ref_number unique index | GST compliance requires globally unique invoice numbers (not per-account unique). The old per-account index was insufficient. | Drop old index, add UNIQUE (ref_number) |