Billing::InvoicePosting
Purpose
A Billing::InvoicePosting is the idempotency guard and atomic execution record for entitlement granting. It is created once, exactly when an invoice transitions to paid, and records that the associated credits have been written to the entitlement ledger.
The unique constraint on billing_invoice_id is the primary safety mechanism: if any code path attempts to post the same invoice twice (race condition, retry, double-click), the database uniqueness violation will prevent the duplicate grant.
Responsibilities
- Prevent double-granting of entitlements (idempotency via unique constraint)
- Record who triggered posting and when (audit trail)
- Act as the boundary between "invoice paid" and "credits in ledger" — nothing should write grant ledger entries for an invoice without first creating a posting record
- Provide a clean pivot for finance exports ("how many credits were posted today?")
Model Context
| Context | Details |
|---|---|
| Aggregate | Child of Billing::Invoice (one-to-one) |
| Layer | Commercial Documents / Entitlement Engine boundary |
| Upstream | Billing::Invoice (parent, must be paid), Billing::InvoiceLine (reads grant data) |
| Downstream | Billing::LedgerEntry (writes grant entries), Billing::EntitlementBalance (updated on grant) |
Table Schema
Table name: billing_invoice_postings
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | bigint | PK, auto-increment | |
uuid | string | NOT NULL, UNIQUE | External-safe identifier |
billing_invoice_id | bigint | NOT NULL, FK → billing_invoices.id, UNIQUE | The unique constraint is the core idempotency mechanism |
posted_at | timestamptz | NOT NULL | When posting occurred |
posted_by_admin_id | bigint | FK → identities_admins.id, nullable | Who triggered posting. NULL when system auto-posts (invoice transitions to paid automatically) |
idempotency_key | string | UNIQUE | Secondary idempotency guard — prevents duplicate execution at the application layer before the DB constraint fires |
Key Indexes
| Index | Columns | Type | Purpose |
|---|---|---|---|
| unique | billing_invoice_id | UNIQUE | Core idempotency: one posting per invoice, ever |
| unique | idempotency_key | UNIQUE | Application-layer dedup guard |
| — | posted_at | — | Finance exports: "postings today" |
| — | posted_by_admin_id | — | Audit: "who posted what" |
What Posting Does (Single DB Transaction)
The posting service runs entirely inside a single DB transaction. If any step fails, the entire transaction rolls back — no partial grants are possible.
BEGIN TRANSACTION
1. SELECT billing_invoices WHERE id = X FOR UPDATE
-- Lock invoice row to prevent concurrent posts
2. INSERT billing_invoice_postings
-- If another request is racing: unique constraint fires here, whole tx rolls back
-- This is the idempotency gate
3. FOR each billing_invoice_line WHERE billing_invoice_id = X:
CASE line_type + entitlement:
WHEN :principal + :placement:
INSERT billing_ledger_entries (
entry_type: :grant,
available_delta: +line.units_to_grant,
deferred_revenue_delta_cents: +line.amount_cents,
reference_type: "Billing::InvoicePosting",
reference_id: posting.id
)
WHEN :principal + :gig: [Phase 3]
INSERT billing_ledger_entries (entry_type: :grant, ...)
INSERT billing_entitlement_lots (
platform_fee_rate_bps: line.platform_fee_rate_bps,
units_purchased: line.units_to_grant,
platform_fee_total_cents: [matching :platform_fee line].amount_cents
)
WHEN :platform_fee + :gig: [Phase 3]
INSERT billing_ledger_entries (
platform_fee_deferred_delta_cents: +line.amount_cents
)
-- No units granted on platform fee lines
4. UPDATE billing_entitlement_balances (account, entitlement):
units_available += units_to_grant -- from :principal lines
deferred_revenue_cents += amount_cents -- placement
platform_fee_deferred_cents += fee -- gig [Phase 3]
COMMIT
Why the Unique Constraint Is the Primary Guard
The application-level idempotency_key is a best-effort guard. The database unique constraint on billing_invoice_id is the authoritative guard because:
- It is enforced at the database level — no application code path can bypass it
- It fires for any concurrent requests, including those that bypass application-layer checks
- It is cheap: the unique index is consulted on every INSERT
- On conflict: the whole transaction rolls back cleanly, and the caller receives a database error that can be handled gracefully
Invariants
- At most one
InvoicePostingcan ever exist perBilling::Invoice(enforced by unique constraint) - An
InvoicePostingcan only be created when the invoicestatus = :paid - The posting must be created in the same DB transaction as all associated
LedgerEntrywrites posted_by_admin_idis nullable — system auto-posting (when invoice auto-transitions topaid) does not require an admin- A posted invoice cannot be re-posted —
InvoicePostingis immutable once created - No entitlement grants are written to the ledger without a corresponding
InvoicePostingrecord
Design Deviation from Current DBML
| What Changed | Source | Rationale |
|---|---|---|
Allow null on posted_by_admin_id | DBML (no null annotation) | System-triggered auto-posting when invoice becomes paid does not have an admin actor. The current DBML has no explicit NOT NULL annotation but the intent from the use case spec is that system posting is valid without an admin. Making this explicit avoids application-layer workarounds. |
Open Questions
- Phase 2 postings with no grant: Phase 2 creates an
InvoicePostingrow the moment an invoice becomespaid(seeBilling::Payments::TeamVerifyManager), but entitlement granting (writingLedgerEntryrows, creatingEntitlementLots) doesn't ship until Phase 3 — Phase 2 only creates the posting record with aTODOfor the grant step. Because invariant 1 (unique constraint onbilling_invoice_id) blocks any code path from posting the same invoice twice, an invoice paid during Phase 2 will never be eligible for posting again once Phase 3 ships — the guard that exists to prevent double-granting will also prevent first-time granting for these invoices. Two ways out, neither implemented yet: (a) don't createInvoicePostingrows until granting exists — Phase 3 creates posting + grants together, with a backfill that posts (and grants for) the invoices paid during Phase 2, or (b) keep creating postings now, and Phase 3 ships a backfill that grants for any posting with no corresponding ledger entries. Needs a decision — and a tracked issue — before Phase 3 scoping.