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):
| Field | Type | Notes |
|---|---|---|
id | bigint PK | |
uuid | string | External-safe identifier |
billing_account_id | bigint FK | The company being invoiced |
billing_legal_entity_id | bigint FK | Seller of record |
billing_bill_to_profile_id | bigint FK | Buyer details source (snapshotted) |
billing_agreement_id | bigint FK | Phase 2 addition — which agreement's terms were used |
org_outlet_id | bigint FK, nullable | Set for outlet-specific purchases |
ref_number | string | Phase 2 rename from invoice_number — finance-allocated reference number (e.g. SG-INV-000042). Globally unique across all Jod invoices. Immutable once issued. |
status | enum | draft | issued | partially_paid | paid | void | credited |
currency | string | ISO 4217 |
due_at | timestamptz | |
issued_at | timestamptz | Phase 2 addition — when draft → issued |
settled_at | timestamptz | Phase 2 addition — when invoice became paid |
bill_to_* | string snapshots | Copied from BillToProfile at creation — never joined back |
subtotal_cents | integer | |
tax_cents | integer | |
total_cents | integer | |
file_path | string | Phase 2 addition — S3 path to generated PDF |
file_generated_at | timestamptz | Phase 2 addition |
delivery_status | enum | Phase 2 addition — not_attempted | queued | delivered | failed | bounced |
email_sent_at | timestamptz | Phase 2 addition |
email_last_failed_at | timestamptz | Phase 2 addition |
email_provider_response | jsonb | Phase 2 addition — append-only Mailgun audit log |
admin_created_by | bigint FK | Audit trail |
admin_updated_by | bigint FK | Audit trail |
State machine:
Design deviations from current DBML:
| Change | Source Differs From | Rationale |
|---|---|---|
Rename invoice_number → ref_number | DBML, Ali's use cases | Finance allocates this number in Xero before creating in Jod — ref_number signals external allocation. See Invoice ref_number ownership. |
Drop external_reference | DBML | Was a Xero sync placeholder; ref_number itself is the Xero reference, making it redundant. See Invoice ref_number ownership. |
Add billing_agreement_id | DBML (column missing) | Audit trail for which agreement terms were used in pricing |
Add issued_at, settled_at | DBML (missing) | Record state transition timestamps for reporting |
| Add email delivery columns | DBML (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)
| Field | Type | Notes |
|---|---|---|
id | bigint PK | |
uuid | string | |
billing_invoice_id | bigint FK | Parent invoice |
billing_product_id | bigint FK | Snapshot reference to product |
billing_product_price_id | bigint FK | Snapshot reference to price |
description | string | Snapshot from billing_products.name |
quantity | integer | How many units purchased |
unit_price_cents | integer | Snapshot from billing_product_prices.unit_price_cents |
amount_cents | integer | quantity × unit_price_cents |
tax_cents | integer | amount_cents × tax_rate (or 0 for gig principal) |
units_to_grant | integer | quantity × grants_units_per_quantity. Always 0 for :platform_fee lines |
line_type | enum | principal | platform_fee |
platform_fee_rate_bps | integer, nullable | Gig only — frozen fee rate on both gig lines |
Line types explained:
line_type | What it represents | Units granted | Tax |
|---|---|---|---|
:principal | The thing the customer is buying (credits, stored value) | Yes — units_to_grant > 0 | Placement: full GST. Gig: 0% |
:platform_fee | Jod's platform fee (gig only) | No — units_to_grant = 0 | Full 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:
| Change | Source | Rationale |
|---|---|---|
Rename billing_invoice_items → billing_invoice_lines | DBML, Ali's use cases | Industry 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
| Field | Type | Notes |
|---|---|---|
id | bigint PK | |
billing_invoice_id | bigint FK | The invoice being paid |
method | enum | bank_transfer | card (card is future scope) |
status | enum | submitted | verified | rejected |
amount_cents | integer | Transfer amount |
bank_reference | string | Reference number from bank statement |
received_at | timestamptz, nullable | Set when verified |
proof_s3_path | string, nullable | S3 path to proof document |
verified_at | timestamptz, nullable | When finance confirmed receipt |
verified_by_admin_id | bigint FK, nullable | Who verified |
State machine:
Key rules:
- A
submittedpayment has no effect on invoice status — verification is required verifiedpayments sum toverified_total; whenverified_total >= invoice.total_cents, the invoice becomespaid- Multiple payments per invoice are allowed (partial payments)
- Only
submittedpayments can transition toverifiedorrejected
No changes from current DBML — this table is already correctly designed.
→ See full spec: Billing::Payment
Billing::InvoicePosting
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
| Field | Type | Notes |
|---|---|---|
id | bigint PK | |
billing_invoice_id | bigint FK, unique | One posting per invoice — unique constraint prevents double-grants |
posted_at | timestamptz | When posting occurred |
posted_by_admin_id | bigint FK, nullable | Who triggered posting (null = system auto-post) |
idempotency_key | string, unique | Prevents 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):
- Lock the invoice row (
SELECT ... FOR UPDATE) - Create the
InvoicePostingrecord - For each
InvoiceLine:- Placement
:principal→ writeLedgerEntry(entry_type: :grant, available_delta: +units_to_grant, deferred_revenue_delta_cents: +amount_cents) - Gig
:principal(Phase 4) → writeLedgerEntry(entry_type: :grant)+ createEntitlementLot - Gig
:platform_fee(Phase 4) → writeLedgerEntry(platform_fee_deferred_delta_cents: +amount_cents)
- Placement
- Update
EntitlementBalanceprojections
Design deviation from current DBML:
| Change | Source | Rationale |
|---|---|---|
Allow null on posted_by_admin_id | DBML (no null annotation) | System auto-posts when invoice → paid; no admin actor is present |
→ See full spec: Billing::InvoicePosting