Skip to main content

Billing::Invoice

Phase 2 Schema Updates

This model receives several schema additions in Phase 2. All changes are additive (no breaking changes to existing columns) except for:

  • invoice_number → renamed to ref_number
  • external_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

ContextDetails
AggregateBilling::Invoice (root) + Billing::InvoiceLine (child) + Billing::Payment (child) + Billing::InvoicePosting (child)
LayerCommercial Documents
Upstream dependenciesBilling::Account, Billing::Agreement (terms inform pricing), Billing::LegalEntity (seller), Billing::BillToProfile (buyer)
Downstream dependentsBilling::LedgerEntry (posting writes grants), Billing::EntitlementLot (gig posting creates lots), Billing::EntitlementBalance (updated on posting)

State Machine

Invoice status

FromToTriggerNotes
(new)draftUC-1: Invoice createdDefault status on creation
draftissuedUC-2b: Mailgun accepts the sendInvoice becomes immutable
draftvoidUC-8: Admin voids before issuingCreated in error
issuedpartially_paidUC-5: First verified payment, < totalAwaiting remaining balance
issuedpaidUC-5: Verified payments >= totalTriggers posting (UC-7)
issuedvoidUC-8: Admin voids before paymentCustomer cancels
partially_paidpaidUC-5: Cumulative verified payments >= totalTriggers posting (UC-7)
paidcredited(future) Credit note / refund workflowPost-payment correction

Payment status

FromToTriggerNotes
(new)submittedUC-4: Admin records paymentProof provided, awaiting review
submittedverifiedUC-5: Finance confirms receiptUpdates invoice status
submittedrejectedUC-6: Proof invalidNo effect on invoice

Invoice delivery_status

FromToTriggerNotes
(new)not_attemptedUC-1: Invoice createdDefault
not_attemptedqueuedUC-2b: Mailgun accepts the sendDrives status: :draft → :issued
not_attemptedfailedUC-2b: Mailgun rejects the sendStatus stays :draft
queueddeliveredUC-2c: delivered webhookRecipient mail server accepted
queuedbouncedUC-2c: permanent_fail webhookMailgun gave up; recipient unreachable
deliveredbouncedUC-2c: late permanent_fail webhookRare; see Open Question on post-issuance bounce
failedqueuedUC-2b: admin retries, Mailgun acceptsRecovery path
bouncedqueuedUC-2b: admin fixes email and retriesRecovery path

Use Cases

IDUse CaseTriggerActor
UC-1Create an invoice for a company's credit purchaseCompany wants to purchase creditsAdmin (sales)
UC-2aGenerate the invoice file (PDF) for previewAdmin clicks "Generate Invoice File"Admin (sales)
UC-2bSend the invoice to the clientAdmin clicks "Send Invoice to Client"Admin (sales)
UC-2cProcess a Mailgun webhook eventMailgun POSTs a delivery eventSystem
UC-3Edit a draft invoice before issuanceAdmin needs to correct details before issuingAdmin (sales)
UC-4Record a bank transfer payment against an invoiceCustomer provides proof of bank transferAdmin (ops)
UC-5Verify a payment and settle the invoiceFinance confirms money received in bank accountAdmin (finance)
UC-6Reject an invalid payment submissionProof is invalid or money not receivedAdmin (finance)
UC-7Post a paid invoice to grant entitlementsInvoice transitions to paidSystem
UC-8Void an unpaid invoiceInvoice created in error or customer cancelsAdmin
UC-9View invoice details with line items and paymentsAdmin needs to review a specific invoiceAdmin
UC-10View all invoices for a billing accountAdmin needs to see all invoices for a companyAdmin

UC-1: Create an invoice for a company's credit purchase

FieldDetails
ActorIdentities::Admin (sales/ops)
TriggerCompany wants to purchase entitlements (placement credits or gig credits)

Preconditions:

  • Billing::Account exists for the Org::Company
  • Billing::Product and Billing::ProductPrice exist for the desired entitlement and market
  • Billing::LegalEntity exists for the seller jurisdiction
  • Billing::BillToProfile exists for the buyer
  • A Billing::Agreement is optional. When provided, it must belong to the billing account and be currently within its effective_from/effective_to window — enforced with a 422 if not. When omitted, or when the account has no agreement, pricing falls back to the catalog Billing::ProductPrice rate (see step 3).

System Behavior:

  1. Admin selects the billing account, product, and quantity, and optionally an agreement
  2. System resolves the applicable Billing::ProductPrice based on:
    • country_id matching the company's country
    • status = :active (and parent product status = :active)
    • billing_account_id IS NULL (standard) OR billing_account_id matches the company's billing account (private price overrides standard)
  3. If an agreement was provided, system resolves it to obtain negotiated terms (fee_rate, discount_rate — see business rules). Only fee_rate and discount_rate terms are read today; a unit_price term is not read by invoicing (pricing always comes from the catalog Billing::ProductPrice.unit_price_cents).
  4. System generates the invoice in draft status:
    • Snapshots bill-to fields from Billing::BillToProfile (company name, attention, email, address)
    • Sets currency (and bill_to_currency, which mirrors it) from the seller's Billing::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_number provided by finance from Xero. System validates it is globally unique across all Jod invoices.
  5. System creates Billing::InvoiceLine rows — exactly one line param may be submitted per invoice (a second product goes on a separate invoice). Each line has a line_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)
    • 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_cents always comes from the catalog Billing::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's Billing::Agreement fee_rate term if they have one, otherwise from Billing::ProductPrice.platform_fee_rate_bps as the list rate)
    • discount_cents/discount_rate_bps are resolved from the agreement's discount_rate term 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_fee line only — a discount reduces Jod's margin, never the wage value passed through to the buyer/company. discount_cents and discount_rate_bps are always 0/nil on the gig :principal line. The fee's tax_cents is computed on the post-discount fee amount; the fee's amount_cents stays the gross (pre-discount) fee.
      • Placement: the :principal line (there is no :platform_fee line) — same mechanics, tax_cents computed on the post-discount amount, amount_cents stays gross.
    • There is no catalog fallback for discount; no matching discount_rate term (or no agreement at all) means discount_cents: 0 on every line.
  6. System computes and stores subtotal_cents (sum of line amount_cents minus sum of line discount_cents), discount_cents (sum of line discount_cents), tax_cents, total_cents on the invoice
  7. 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_number is globally unique across all Jod invoices (GST requirement: running reference numbers).
  • ref_number is 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 (a unit_price term is not read by invoicing).
  • currency and bill_to_currency both come from the seller's Billing::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_profiles has no currency column, so bill_to_currency currently always mirrors currency; it carries no independent information today.

Postconditions:

  • Invoice exists in draft status with computed totals
  • No payments or postings exist yet
  • No entitlements are granted

UC-2a: Generate the invoice file (PDF) for preview

FieldDetails
ActorIdentities::Admin (sales) — initiates; System — performs
TriggerAdmin clicks "Generate Invoice File" on a draft invoice

Preconditions:

  • Invoice is in :draft status
  • Invoice has at least one line item
  • All required snapshot fields are populated (bill-to, currency, totals)

System Behaviour:

  1. Async background job renders the invoice file (PDF) from the snapshotted invoice fields
  2. Job stores the S3 key at file_path and stamps file_generated_at
  3. Status remains :draft. delivery_status remains :not_attempted.
  4. 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 status or delivery_status
  • The file is access-controlled — file_path is 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_path and file_generated_at are populated
  • Status unchanged

UC-2b: Send the invoice to the client

FieldDetails
ActorIdentities::Admin (sales)
TriggerAdmin clicks "Send Invoice to Client"

Preconditions:

  • Invoice is in :draft status
  • file_path is populated (UC-2a already ran)
  • delivery_status is :not_attempted, :failed, or :bounced (retries allowed from these states)
  • Bill-to email is set on the snapshotted bill-to fields

System Behaviour:

  1. Manager calls Mailgun's messages.send API with the file attached, the bill-to email as recipient, and Mailgun custom variables v:email_type=billing_invoice_issued and v: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.
  2. Manager appends the synchronous Mailgun response to email_provider_response (kind: send_attempt, with timestamp and outcome)
  3. Branch on Mailgun's response, in the same DB transaction:
    • HTTP 200/202 (accepted): set delivery_status: :queued; set email_sent_at = now; transition status: :draft → :issued; set issued_at = now
    • HTTP 4xx or 5xx (rejected): set delivery_status: :failed; set email_last_failed_at = now; status stays :draft
  4. 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_number is locked from this point — like all other invoice fields, it becomes immutable on :issued
  • Idempotency: if email_sent_at is 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 from email_provider_response and can retry

UC-2c: Process a Mailgun webhook event

FieldDetails
ActorSystem (Mailgun → Jod webhook handler)
TriggerMailgun 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_type is billing_invoice_issued. If email_type is 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). If invoice_uuid is missing, the handler returns 200 OK without further processing.

System Behavior:

  1. Verify HMAC signature on the webhook payload — reject with 401 if invalid
  2. Read event-data.user-variables.email_type and event-data.user-variables.invoice_uuid. If email_type != "billing_invoice_issued" or invoice_uuid is missing, return 200 OK and stop. Otherwise resolve the target invoice by invoice_uuid.
  3. Look up Mailgun's unique event identifier in the existing email_provider_response array. If already present (duplicate webhook), respond 200 and stop (idempotency)
  4. 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)
  5. Otherwise, append the event to email_provider_response (kind: webhook_event, with event name, timestamp, and the full Mailgun payload)
  6. Map the Mailgun event name to a delivery_status change per the table below. Update delivery_status, email_last_failed_at (if applicable) in the same transaction.

Mailgun event → delivery_status mapping:

Mailgun eventEffect on delivery_statusEffect 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:bouncedset 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_status but ARE appended to the audit array
  • delivery_status updates triggered by webhooks today do NOT change commercial status (the post-issuance bounce unwind is an Open Question)

Postconditions:

  • email_provider_response has one new entry
  • delivery_status may have changed per the mapping
  • Commercial status is unchanged (today)

UC-3: Edit a draft invoice before issuance

FieldDetails
ActorIdentities::Admin
TriggerAdmin needs to correct details before issuing

Preconditions:

  • Invoice exists in draft status

System Behavior:

  1. Admin modifies invoice fields — every field is editable while draft, including billing_account_id, billing_legal_entity_id, billing_bill_to_profile_id, and billing_agreement_id (not just due date, bill-to overrides, and lines)
  2. When billing_bill_to_profile_id changes, the system re-snapshots bill_to_company_name/bill_to_attention/bill_to_billing_email/bill_to_billing_address from the newly selected profile, then applies any explicit bill_to_* overrides on the request on top (unchanged manual-correction behavior)
  3. When billing_legal_entity_id changes, the system re-resolves currency/bill_to_currency from the newly selected legal entity
  4. 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
  5. System recomputes subtotal_cents, discount_cents, tax_cents, total_cents
  6. 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_id or billing_legal_entity_id can invalidate an existing line's Billing::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

FieldDetails
ActorIdentities::Admin (business/ops)
TriggerCustomer makes a bank transfer and provides proof of payment

Preconditions:

  • Invoice exists in issued or partially_paid status

System Behavior:

  1. Admin creates a Billing::Payment record with:
    • method = bank_transfer
    • status = submitted
    • amount_cents = transfer amount
    • bank_reference = reference number from bank statement
    • proof_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 submitted status does not affect the invoice status — it requires verification first

Postconditions:

  • Payment record exists in submitted status
  • Invoice status unchanged

UC-5: Verify a payment and settle the invoice

FieldDetails
ActorIdentities::Admin (finance)
TriggerFinance confirms money has been received in the bank account

Preconditions:

  • Payment exists in submitted status
  • Invoice is in issued or partially_paid status

System Behavior:

  1. Finance admin marks the payment as verified
  2. System records verified_at, verified_by_admin_id, and received_at
  3. System recomputes verified_total = sum(amount_cents) across all verified payments for this invoice
  4. System updates the invoice status:
    • If verified_total == 0 → stays issued
    • If 0 < verified_total < total_centspartially_paid
    • If verified_total >= total_centspaid, and settled_at = now
  5. If invoice becomes paid, the system automatically triggers posting (UC-7)

Business Rules:

  • Only submitted payments 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

FieldDetails
ActorIdentities::Admin (finance)
TriggerProof is invalid or money was not received

Preconditions:

  • Payment exists in submitted status

System Behavior:

  1. Finance admin marks the payment as rejected
  2. System records the rejection (no recomputation of invoice status needed — rejected payments don't count)

Business Rules:

  • Only submitted payments 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

FieldDetails
ActorSystem (triggered automatically when invoice becomes paid)
TriggerInvoice transitions to paid status

Preconditions:

  • Invoice status = paid
  • No Billing::InvoicePosting exists for this invoice (idempotency guard)

System Behavior:

  1. Lock the invoice row (SELECT ... FOR UPDATE)
  2. Create Billing::InvoicePosting with posted_at and idempotency_key
  3. For each Billing::InvoiceLine, look at its line_type and the product's entitlement to decide what to do:
    • Placement principal (line_type = :principal, entitlement = :placement):
      • Write a Billing::LedgerEntry with entry_type = :grant
      • available_delta = +units_to_grant
      • deferred_revenue_delta_cents = +amount_cents
    • Gig principal (line_type = :principal, entitlement = :gig):
      • Write a Billing::LedgerEntry with entry_type = :grant
      • Create a Billing::EntitlementLot:
        • platform_fee_rate_bps = copied from this item's platform_fee_rate_bps column
        • units_purchased = this item's units_to_grant
        • platform_fee_total_cents = copied from the matching :platform_fee item's amount_cents
    • Gig platform fee (line_type = :platform_fee, entitlement = :gig):
      • Write a Billing::LedgerEntry with platform_fee_deferred_delta_cents = +amount_cents
      • No units are granted (this is a money-only line)
  4. Update Billing::EntitlementBalance projections:
    • units_available += units_to_grant
    • deferred_revenue_cents += amount (placement)
    • platform_fee_deferred_cents += fee (gig)
  5. All of the above happens in a single DB transaction

Business Rules:

  • Unique constraint on Billing::InvoicePosting.billing_invoice_id prevents double-granting (idempotency)
  • Posting is atomic — if any step fails, the entire transaction rolls back
  • Each invoice can only be posted once

Postconditions:

  • Billing::InvoicePosting record 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

FieldDetails
ActorIdentities::Admin
TriggerInvoice was created in error, or customer cancels before paying

Preconditions:

  • Invoice is in draft or issued status
  • No verified payments exist for this invoice

System Behavior:

  1. Admin voids the invoice
  2. System transitions the invoice to void status

Business Rules:

  • Cannot void an invoice that has verified payments — those must be dealt with first
  • Cannot void a paid or credited invoice
  • 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_number reuse: When an invoice is voided, the ref_number is 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

FieldDetails
ActorIdentities::Admin
TriggerAdmin needs to review a specific invoice

Preconditions:

  • Invoice exists

System Behavior:

  1. Admin navigates to the invoice detail page
  2. 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

FieldDetails
ActorIdentities::Admin
TriggerAdmin needs to see all invoices for a company's billing account

Preconditions:

  • Billing::Account exists

System Behavior:

  1. Admin navigates to the billing account → Invoices tab
  2. System displays all invoices ordered by created_at descending
  3. 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

  1. An invoice must belong to exactly one Billing::Account
  2. 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
  3. Once issued, invoice amounts and lines are immutable — corrections require void + reissue
  4. Invoices are never deleted — only voided or credited
  5. ref_number is globally unique across all Jod invoices (GST requirement).
  6. Entitlements are granted only after the invoice reaches paid status — partial payments do not grant partial credits
  7. Each invoice can be posted at most once (enforced by unique constraint on Billing::InvoicePosting.billing_invoice_id)
  8. Invoice lines snapshot all pricing, tax, and terms at creation time — later changes to products or agreements do not affect existing invoices
  9. Gig credit invoices must have two line items: principal (tax-exempt, never discounted) and platform fee (taxed, carries any discount)
  10. total_cents = subtotal_cents + tax_cents, subtotal_cents = sum(line.amount_cents) - sum(line.discount_cents), discount_cents = sum(line.discount_cents)
  11. Admin audit trail (created_by, updated_by) is always populated on invoices
  12. ref_number is required at invoice create time.
  13. Billing::Agreement is 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_rate falls back to Billing::ProductPrice.platform_fee_rate_bps and no discount applies.
  14. While the invoice is in :draft, all attributes are editable, including billing_account_id, billing_legal_entity_id, billing_bill_to_profile_id, and billing_agreement_id — a PATCH that omits these fields leaves them unchanged
  15. Once the invoice transitions to :issued, all attributes are immutable. Corrections require void and recreate.
  16. delivery_status starts at :not_attempted and progresses per the delivery state machine.
  17. The transition delivery_status: :not_attempted → :queued happens in the same DB transaction as status: :draft → :issued. The two cannot diverge.
  18. The Mailgun webhook handler is idempotent against duplicate events (deduped by Mailgun's event identifier) and ignores stale events for delivery_status updates (but always appends to the audit array).

Open Questions

  • Late bounce after :issued: A permanent_fail webhook can arrive after the invoice has already transitioned to :issued. Today, we update delivery_status: :delivered → :bounced but leave commercial status unchanged. Should status unwind (e.g., back to :draft or to a new :undeliverable state)? Needs design.
  • :complained promotion to enum: Mailgun fires complained events when a recipient marks the email as spam. Today we append to email_provider_response but do not change delivery_status. If complaints become common, promote :complained to a delivery_status enum value for filterability.
  • Mailgun event identifier field name: The webhook handler dedupes on Mailgun's event identifier. The exact field name (likely event-id or id inside event-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 delivered webhook — 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::LegalEntity when creating an invoice, and currency follows that choice (see Business Rules above). A future improvement worth considering: only offer legal entities whose country matches the billing account's Org::Company country (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 ModelRelationshipInteraction
Billing::AccountInvoice belongs_to AccountAccount must exist. One account has many invoices over time.
Billing::AgreementInvoice references AgreementOptional. 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::LegalEntityInvoice belongs_to LegalEntitySeller-of-record. Determines invoice numbering, tax regime, and currency.
Billing::BillToProfileInvoice belongs_to BillToProfileBuyer details source. Invoice snapshots bill-to fields at creation (never joins back for historical invoices).
Billing::InvoiceLineInvoice has_many InvoiceLinesLine created with the invoice. Snapshots product, pricing, discount, tax, and units to grant.
Billing::PaymentInvoice has_many PaymentsTracks offline bank transfers. Multiple payments supported for partial payments.
Billing::InvoicePostingInvoice has_one InvoicePostingIdempotent record of entitlement granting. Created once when invoice becomes paid.
Billing::ProductInvoiceLine references ProductWhat was sold (placement credits, gig credits). Provides grants_units_per_quantity.
Billing::ProductPriceInvoiceLine references ProductPriceMarket-specific pricing used at issuance. Never overridden by an agreement.
Billing::LedgerEntryPosting creates LedgerEntriesGrant entries written to the append-only ledger during posting.
Billing::EntitlementLotPosting creates Lots (gig only)FIFO purchase batch created during gig posting with per-lot platform fee rate.
Billing::EntitlementBalancePosting updates Balancesunits_available and deferred revenue/fee projections updated during posting.
Identities::AdminInvoice tracks created_by / updated_byAudit trail. Payment verification also records verified_by_admin_id.

Schema Gaps

Items identified by comparing use cases against the current DBML schema (billing.dbml):

GapImpactSuggested Resolution
No billing_agreement_id on billing_invoicesCannot trace which agreement's terms were used for pricingAdd FK to billing_agreements
No issued_at column on billing_invoicesCannot record when invoice transitioned from draft to issuedAdd issued_at timestamp column
No settled_at column on billing_invoicesCannot record when invoice became fully paidAdd settled_at timestamp column
No file_path column on billing_invoicesCannot persist the generated invoice file pathAdd file_path string
No file_generated_at column on billing_invoicesCannot record when the file was last generatedAdd file_generated_at timestamptz
No delivery_status column on billing_invoicesCannot track Mailgun delivery state for filteringAdd delivery_status string [not null, default: 'not_attempted', note: 'rails_enum(:not_attempted, :queued, :delivered, :failed, :bounced)']
No email_sent_at column on billing_invoicesCannot record successful send timestampAdd email_sent_at timestamptz
No email_last_failed_at column on billing_invoicesCannot record latest failure timestampAdd email_last_failed_at timestamptz
No email_provider_response column on billing_invoicesCannot store the append-only Mailgun audit trailAdd email_provider_response jsonb [not null, default: '[]']
No composite index on (status, delivery_status)List filter "draft invoices that need attention" is slowAdd composite index
Rename invoice_numberref_number on billing_invoicesPhase 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_invoicesWas 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 indexGST compliance requires globally unique invoice numbers (not per-account unique). The old per-account index was insufficient.Drop old index, add UNIQUE (ref_number)