Skip to main content

Billing::EntitlementBalance

Purpose​

One Billing::EntitlementBalance row holds one account's live totals for one instrument. Acme Foods has two rows: one for placement, one for gig.

The row is a projection of Billing::LedgerEntry rows. The ledger is the truth. This row is the running total, updated in the same transaction as every entry. The system never sums the ledger on a normal read — the dashboard and the spending checks read these three numbers directly.

The row has a second job: it is the lock. Every ledger write for an account starts by locking that account's balance row (D5). Two movements can never run at the same moment for one account. That guarantee is what makes the ledger replayable — and replay is what lets finance derive any number they ask for later.

So the row is not only a cache for fast reads. It is the serialisation point of the whole entitlement engine.

Schema truth: jodapp-api/docs/db/billing.dbml, table billing_entitlement_balances.

Model Context​

Legend: Lavender nodes belong to the Billing domain. Grey nodes are external. The yellow node is the subject of this spec. Dotted arrows are reads and consistency checks; solid arrows are structural writes and ownership.

ContextDetails
AggregateBilling::EntitlementBalance has no children. It is part of the Billing::Account aggregate.
LayerEntitlement engine — the projection and the per-account lock
Upstream dependenciesBilling::Account and Billing::Entitlement (the row exists per pair); Billing::LedgerEntry rows drive every change
Downstream dependentsThe employer dashboard, the admin account view, the spending checks, and the outlet budget pool math

The three numbers​

ColumnMeaningGoes up onGoes down on
units_availableCredits the company can spend right nowgrant, releasereserve, direct consume, expire, refund
units_reservedCredits set aside for pending workreserveconsume from reserved, release
deferred_revenue_centsMoney collected but not yet earned, for this instrumentgrant (paid)consume, expire, refund

What the deferred revenue is differs per instrument, the same rule as on lots:

  • Placement: the sale price of the credits.
  • Gig: the platform fee only. The wage value is a debt to the customer. It never appears on this row.
  • The row's number always equals the sum of deferred_revenue_remaining_cents across the instrument's Billing::EntitlementLot rows.

The balance row is the lock​

Every movement follows the one write path in the Billing::LedgerEntry spec. This row plays two parts in it:

  1. First step: the system locks this row. Any other movement for the same account waits.
  2. Fifth step: the system adds the entry's deltas to this row. The commit releases the lock.

Why the lock matters: because writes are serialised per account, replaying an account's entries in id order reproduces this row exactly (D5). On a mismatch the ledger wins, and the projection is rewritten (UC-4).

Worked example: Acme's placement row through one year​

The same Acme Foods story as the Billing::LedgerEntry spec's statement of account. The placement balance row after four of the six entries:

Momentunits_availableunits_reserveddeferred_revenue_cents
After both grants (1 Feb 2027)120050,000
After the campaign reserve (15 Feb)853050,000
After the campaign consume85042,500
After the expiry (1 Feb 2028)000

Acme's gig row was created at the same moment as the placement row and stays at zero all year — Acme never bought gig credits.

State Machine​

A Billing::EntitlementBalance row has no states. It is three running totals. It is created once at zero and changes only when a ledger entry changes it.

Use Cases​

IDUse CaseTriggerActor
UC-1Create zero balances when a billing account is createdAn Org::Company gets its Billing::AccountSystem
UC-2Apply a ledger entry's deltas under the balance row lockEvery ledger writeSystem
UC-3View balancesAn employer opens the dashboard, or an admin opens an accountEmployer / Admin
UC-4Rebuild a balance by replaying the account's ledger entriesA balance is suspected wrongSystem

UC-1: Create zero balances when a billing account is created​

FieldDetails
ActorSystem
TriggerAn Org::Company gets its Billing::Account

Preconditions:

  • The Billing::Account is being created in an open transaction.

System Behaviour:

  1. For every Billing::Entitlement row, create one balance row:
    • units_available: 0
    • units_reserved: 0
    • deferred_revenue_cents: 0

Business Rules:

  • One row per account per instrument. A database unique index enforces the pair.
  • A company that never buys an instrument keeps that zero row forever. The dashboard can always render.

Postconditions:

  • The account has one balance row per instrument, all zero.

Open Questions:

  • When a new instrument is seeded later (Billing::Entitlement UC-1), accounts created before it have no row for it. Backfill all accounts at seed time, or create the row on first use? Undecided.

UC-2: Apply a ledger entry's deltas under the balance row lock​

FieldDetails
ActorSystem
TriggerEvery ledger write

Preconditions:

System Behaviour:

  1. Lock this account's balance row for the entry's instrument. Other movements for the account wait here.
  2. Check the movement fits:
    • a reserve or direct consume must not push units_available below zero
    • a consume from reserved must not push units_reserved below zero
    • if it does not fit, the whole transaction rolls back — nothing is written anywhere
  3. Add the entry's deltas to the three numbers:
    • available_delta onto units_available
    • reserved_delta onto units_reserved
    • deferred_revenue_delta_cents onto deferred_revenue_cents
  4. Commit together with the entry, its allocation rows, and the lot counters. The commit releases the lock.

Business Rules:

  • An adjust entry changes nothing here — all its deltas are zero. It still takes the lock, like every ledger write (D5).
  • There is no partial write. The entry, the allocation rows, the lot counters, and this row change together or not at all.

Postconditions:

  • The row equals the replay of the account's entries, including this one.

UC-3: View balances​

FieldDetails
ActorOrg::Membership (employer) or Identities::Admin
TriggerAn employer opens the dashboard, or an admin opens an account

Preconditions:

  • The account exists.

System Behaviour:

  1. Read the account's balance rows directly — no ledger query, no summing:
    • available and reserved units per instrument
    • for admins, the deferred revenue per instrument
  2. An employer sees only their own company's rows.

Business Rules:

  • Balance reads never compute from the ledger. The projection exists so reads are one indexed row.
  • Per-outlet numbers are not here. Outlet budgets partition these totals — see Billing::OutletBudget.

Postconditions:

  • Read-only operation — no data changes.

UC-4: Rebuild a balance by replaying the account's ledger entries​

FieldDetails
ActorSystem
TriggerA balance is suspected wrong

Preconditions:

  • None. The ledger is complete by construction.

System Behaviour:

  1. Sum the account's entries for the instrument in id order, into the three totals.
  2. Compare with the stored row. The full rules live in Billing::LedgerEntry UC-9.
  3. On a mismatch: the ledger wins. Fix the projection code, rewrite this row.

Business Rules:

  • The ledger is never edited to match a projection.

Postconditions:

  • Read-only over the ledger. Only this row may be rewritten.

Invariants​

  1. One row per account per instrument. A database unique index enforces the pair.
  2. All three numbers are >= 0, always.
  3. The row changes only in the same transaction as exactly one Billing::LedgerEntry, under this row's lock (D5).
  4. Replaying the account's entries in id order reproduces the row exactly. On a mismatch the ledger wins (UC-4).
  5. Per instrument, the three numbers equal the sums across the instrument's Billing::EntitlementLot rows: available, reserved, and deferred_revenue_remaining_cents.
  6. Every reserved unit belongs to exactly one deliverable. Per deliverable, the reserved amount is the net of the Billing::LedgerEntry rows naming it as source. There is no hold table (D8).
  7. For a budget-controlled instrument, the sum of units across Billing::OutletBudget rows with status: :active never exceeds the balance's units. The company's unallocated pool is the difference.
  8. Rows are never deleted.

Model Interactions​

Related ModelRelationshipInteraction
Billing::AccountBalance belongs to AccountOne row per instrument, created with the account (UC-1). The row lock serialises the account's movements.
Billing::EntitlementBalance scoped to oneThe instrument whose totals this row holds.
Billing::LedgerEntryEntries drive every changeDeltas applied in the same transaction, under the lock (UC-2). Replay rebuilds the row (UC-4).
Billing::EntitlementLotSums must matchThe row is the account view; lots are the batch view. Invariant 5 ties them.
A deliverable's reserved credits (D8)Not a table — computed when askedThe net of the entries naming the deliverable's source. Invariant 6 ties it to units_reserved.
Billing::OutletBudgetPartitions the balanceOutlet budgets label parts of these totals per outlet. Transfers move the label, never the balance.

Schema Gaps​

GapImpactSuggested Resolution
The database has two nullable money columns: deferred_revenue_cents (commented "placement only") and platform_fee_deferred_cents (commented "gig only"). The DBML target is one deferred_revenue_cents, not null, for both instruments (D2).The projection cannot match the one-formula engine, and the column comments still teach the removed pooled design.Open. Merge into one deferred_revenue_cents, not null, default 0. Rewrite the column comments. Ship with the placement policy flip (Billing::Entitlement UC-2).
The database and code have a uuid column. The DBML does not list it.The schema truth file is missing a real column.Open. Add uuid to billing.dbml.