Skip to main content

Phase 2 — Model Specifications

This section documents the four models introduced or significantly updated in Billing Integration Phase 2.

For full per-model documentation including all use cases and invariants, see the individual spec pages under Billing Model Specifications.


Invoice Aggregate Diagram

The four models below form a single aggregate rooted at Billing::Invoice.


Model Summary

Billing::Invoice

The customer-facing commercial document. An invoice captures what was sold, to whom, at what price, under which legal entity, and tracks the payment lifecycle.

Table: billing_invoices

Key fields (Phase 2 additions highlighted):

FieldTypeNotes
idbigint PK
uuidstringExternal-safe identifier
billing_account_idbigint FKThe company being invoiced
billing_legal_entity_idbigint FKSeller of record
billing_bill_to_profile_idbigint FKBuyer details source (snapshotted)
billing_agreement_idbigint FKPhase 2 addition — which agreement's terms were used
org_outlet_idbigint FK, nullableSet for outlet-specific purchases
ref_numberstringPhase 2 rename from invoice_number — finance-allocated reference number (e.g. SG-INV-000042). Globally unique across all Jod invoices. Immutable once issued.
statusenumdraft | issued | partially_paid | paid | void | credited
currencystringISO 4217
due_attimestamptz
issued_attimestamptzPhase 2 addition — when draft → issued
settled_attimestamptzPhase 2 addition — when invoice became paid
bill_to_*string snapshotsCopied from BillToProfile at creation — never joined back
subtotal_centsinteger
tax_centsinteger
total_centsinteger
file_pathstringPhase 2 addition — S3 path to generated PDF
file_generated_attimestamptzPhase 2 addition
delivery_statusenumPhase 2 addition — not_attempted | queued | delivered | failed | bounced
email_sent_attimestamptzPhase 2 addition
email_last_failed_attimestamptzPhase 2 addition
email_provider_responsejsonbPhase 2 addition — append-only Mailgun audit log
admin_created_bybigint FKAudit trail
admin_updated_bybigint FKAudit trail

State machine:

Design deviations from current DBML:

ChangeSource Differs FromRationale
Rename invoice_numberref_numberDBML, Ali's use casesFinance allocates this number in Xero before creating in Jod — ref_number signals external allocation. See Invoice ref_number ownership.
Drop external_referenceDBMLWas a Xero sync placeholder; ref_number itself is the Xero reference, making it redundant. See Invoice ref_number ownership.
Add billing_agreement_idDBML (column missing)Audit trail for which agreement terms were used in pricing
Add issued_at, settled_atDBML (missing)Record state transition timestamps for reporting
Add email delivery columnsDBML (missing)Required for Mailgun integration (UC-2b, UC-2c)

→ See full spec: Billing::Invoice


Billing::InvoiceLine

Renamed from Billing::InvoiceItem / billing_invoice_items.

An invoice line is a snapshot row on an invoice representing one sold item. Each line captures what was sold, at what price, the applicable tax, and how many credits to grant.

Table: billing_invoice_lines (renamed from billing_invoice_items)

FieldTypeNotes
idbigint PK
uuidstring
billing_invoice_idbigint FKParent invoice
billing_product_idbigint FKSnapshot reference to product
billing_product_price_idbigint FKSnapshot reference to price
descriptionstringSnapshot from billing_products.name
quantityintegerHow many units purchased
unit_price_centsintegerSnapshot from billing_product_prices.unit_price_cents
amount_centsintegerquantity × unit_price_cents
tax_centsintegeramount_cents × tax_rate (or 0 for gig principal)
units_to_grantintegerquantity × grants_units_per_quantity. Always 0 for :platform_fee lines
line_typeenumprincipal | platform_fee
platform_fee_rate_bpsinteger, nullableGig only — frozen fee rate on both gig lines

Line types explained:

line_typeWhat it representsUnits grantedTax
:principalThe thing the customer is buying (credits, stored value)Yes — units_to_grant > 0Placement: full GST. Gig: 0%
:platform_feeJod's platform fee (gig only)No — units_to_grant = 0Full GST on fee amount

A Placement Credits invoice has 1 line; a Gig Credits invoice has 2 lines.

Design deviation from current DBML / Ali's use cases:

ChangeSourceRationale
Rename billing_invoice_itemsbilling_invoice_linesDBML, Ali's use casesIndustry standard (Xero, Stripe, QuickBooks all call these "lines"); "item" implies a standalone entity rather than a row on a document; "line" is unambiguously a row on an invoice

No column-level changes are proposed — only the table and model name change.

→ See full spec: Billing::InvoiceLine


Billing::Payment

A payment represents one offline bank transfer submitted by ops as proof of payment. Multiple payments can exist per invoice (supporting partial payments and accidental duplicate recordings).

Table: billing_payments

FieldTypeNotes
idbigint PK
billing_invoice_idbigint FKThe invoice being paid
methodenumbank_transfer | card (card is future scope)
statusenumsubmitted | verified | rejected
amount_centsintegerTransfer amount
bank_referencestringReference number from bank statement
received_attimestamptz, nullableSet when verified
proof_s3_pathstring, nullableS3 path to proof document
verified_attimestamptz, nullableWhen finance confirmed receipt
verified_by_admin_idbigint FK, nullableWho verified

State machine:

Key rules:

  • A submitted payment has no effect on invoice status — verification is required
  • verified payments sum to verified_total; when verified_total >= invoice.total_cents, the invoice becomes paid
  • Multiple payments per invoice are allowed (partial payments)
  • Only submitted payments can transition to verified or rejected

No changes from current DBML — this table is already correctly designed.

→ See full spec: Billing::Payment


Billing::InvoicePosting

Phase 2 / Phase 3 split

Billing::InvoicePosting is created in Phase 2 as the idempotency guard: when a payment verify transitions an invoice to paid, a posting record is written in the same transaction (posted_at, posted_by_admin_id, idempotency_key). The entitlement grant logic — LedgerEntry writes and EntitlementBalance updates — is Phase 3 (UC-7). Phase 3 (UC-9) extends the posting flow rather than re-creating the posting record.

The idempotency guard and audit record for entitlement granting. When an invoice transitions to paid, the system creates exactly one InvoicePosting and then writes all grant ledger entries inside a single DB transaction.

Table: billing_invoice_postings

FieldTypeNotes
idbigint PK
billing_invoice_idbigint FK, uniqueOne posting per invoice — unique constraint prevents double-grants
posted_attimestamptzWhen posting occurred
posted_by_admin_idbigint FK, nullableWho triggered posting (null = system auto-post)
idempotency_keystring, uniquePrevents duplicate execution

Why the unique constraint on billing_invoice_id matters:

If a finance admin clicks "verify payment" twice (race condition or double-click), both requests will try to create a posting. The UNIQUE constraint on billing_invoice_id means the second attempt will fail with a database uniqueness error — entitlements are only ever granted once.

What posting does (in a single DB transaction):

  1. Lock the invoice row (SELECT ... FOR UPDATE)
  2. Create the InvoicePosting record
  3. For each InvoiceLine:
    • Placement :principal → write LedgerEntry(entry_type: :grant, available_delta: +units_to_grant, deferred_revenue_delta_cents: +amount_cents)
    • Gig :principal (Phase 4) → write LedgerEntry(entry_type: :grant) + create EntitlementLot
    • Gig :platform_fee (Phase 4) → write LedgerEntry(platform_fee_deferred_delta_cents: +amount_cents)
  4. Update EntitlementBalance projections

Design deviation from current DBML:

ChangeSourceRationale
Allow null on posted_by_admin_idDBML (no null annotation)System auto-posts when invoice → paid; no admin actor is present

→ See full spec: Billing::InvoicePosting