Skip to main content

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

ContextDetails
AggregateBilling::Invoice (root) + Billing::InvoiceItem (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
  • An active Billing::Agreement exists with relevant terms
  • 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

System Behavior:

  1. Admin selects the billing account, product, and quantity
  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. System resolves the active Billing::Agreement to obtain negotiated terms (fee rates, unit prices, discounts)
  4. System generates the invoice in draft status:
    • Snapshots bill-to fields from Billing::BillToProfile (company name, attention, email, address)
    • Sets currency from the bill-to profile
    • 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::InvoiceItem rows. Each item has a line_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)
    • 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's Billing::Agreement fee_rate term if they have one, otherwise from Billing::ProductPrice.platform_fee_rate_bps as the list rate)
  6. System computes and stores subtotal_cents, tax_cents, total_cents on the invoice
  7. 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_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 comes from Billing::ProductPrice

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 file at file_url 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 — only admins with the right permission can open file_url

Postconditions:

  • file_url 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_url 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 (due date, bill-to details) and/or adds, removes, or updates line items
  2. System recomputes subtotal_cents, tax_cents, total_cents
  3. 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

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_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 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::InvoiceItem, 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::InvoiceItem
  3. Once issued, invoice amounts and items 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 items 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) and platform fee (taxed)
  10. total_cents = subtotal_cents + tax_cents and must equal the sum of all item (amount_cents + tax_cents)
  11. Admin audit trail (created_by, updated_by) is always populated on invoices
  12. ref_number is required at invoice create time.
  13. While the invoice is in :draft, all attributes (including ref_number, line items, due date, bill-to snapshot) are editable.
  14. Once the invoice transitions to :issued, all attributes are immutable. Corrections require void and recreate.
  15. delivery_status starts at :not_attempted and progresses per the delivery state machine.
  16. The transition delivery_status: :not_attempted → :queued happens in the same DB transaction as status: :draft → :issued. The two cannot diverge.
  17. 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.

Model Interactions

Related ModelRelationshipInteraction
Billing::AccountInvoice belongs_to AccountAccount must exist. One account has many invoices over time.
Billing::AgreementInvoice references AgreementAgreement's terms inform pricing at invoice creation. Invoice snapshots the relevant values.
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::InvoiceItemInvoice has_many InvoiceItemsLine items created with the invoice. Snapshot product, pricing, 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::ProductInvoiceItem references ProductWhat was sold (placement credits, gig credits). Provides grants_units_per_quantity.
Billing::ProductPriceInvoiceItem references ProductPriceMarket-specific pricing used at issuance. Values snapshotted into item columns.
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
Rename invoice_number to ref_number on billing_invoicesReflect new finance-owned numbering schemeMigration to rename column
No file_url column on billing_invoicesCannot persist the generated invoice file pathAdd file_url 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: '[]']
Remove external_reference from billing_invoicesColumn is unused now; was placeholder for Xero number which is now ref_number itselfMigration 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 slowAdd composite index
No unique index on ref_number (global)Duplicate ref_number could be insertedAdd unique index