Skip to main content

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

ContextDetails
AggregateChild of Billing::Invoice (one-to-one)
LayerCommercial Documents / Entitlement Engine boundary
UpstreamBilling::Invoice (parent, must be paid), Billing::InvoiceLine (reads grant data)
DownstreamBilling::LedgerEntry (writes grant entries), Billing::EntitlementBalance (updated on grant)

Table Schema

Table name: billing_invoice_postings

ColumnTypeConstraintsNotes
idbigintPK, auto-increment
uuidstringNOT NULL, UNIQUEExternal-safe identifier
billing_invoice_idbigintNOT NULL, FK → billing_invoices.id, UNIQUEThe unique constraint is the core idempotency mechanism
posted_attimestamptzNOT NULLWhen posting occurred
posted_by_admin_idbigintFK → identities_admins.id, nullableWho triggered posting. NULL when system auto-posts (invoice transitions to paid automatically)
idempotency_keystringUNIQUESecondary idempotency guard — prevents duplicate execution at the application layer before the DB constraint fires

Key Indexes

IndexColumnsTypePurpose
uniquebilling_invoice_idUNIQUECore idempotency: one posting per invoice, ever
uniqueidempotency_keyUNIQUEApplication-layer dedup guard
posted_atFinance exports: "postings today"
posted_by_admin_idAudit: "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

  1. At most one InvoicePosting can ever exist per Billing::Invoice (enforced by unique constraint)
  2. An InvoicePosting can only be created when the invoice status = :paid
  3. The posting must be created in the same DB transaction as all associated LedgerEntry writes
  4. posted_by_admin_id is nullable — system auto-posting (when invoice auto-transitions to paid) does not require an admin
  5. A posted invoice cannot be re-posted — InvoicePosting is immutable once created
  6. No entitlement grants are written to the ledger without a corresponding InvoicePosting record

Design Deviation from Current DBML

What ChangedSourceRationale
Allow null on posted_by_admin_idDBML (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 InvoicePosting row the moment an invoice becomes paid (see Billing::Payments::TeamVerifyManager), but entitlement granting (writing LedgerEntry rows, creating EntitlementLots) doesn't ship until Phase 3 — Phase 2 only creates the posting record with a TODO for the grant step. Because invariant 1 (unique constraint on billing_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 create InvoicePosting rows 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.