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.
| Context | Details |
|---|---|
| Aggregate | Billing::EntitlementBalance has no children. It is part of the Billing::Account aggregate. |
| Layer | Entitlement engine — the projection and the per-account lock |
| Upstream dependencies | Billing::Account and Billing::Entitlement (the row exists per pair); Billing::LedgerEntry rows drive every change |
| Downstream dependents | The employer dashboard, the admin account view, the spending checks, and the outlet budget pool math |
The three numbers
| Column | Meaning | Goes up on | Goes down on |
|---|---|---|---|
units_available | Credits the company can spend right now | grant, release | reserve, direct consume, expire, refund |
units_reserved | Credits set aside for pending work | reserve | consume from reserved, release |
deferred_revenue_cents | Money collected but not yet earned, for this instrument | grant (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_centsacross the instrument'sBilling::EntitlementLotrows.
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:
- First step: the system locks this row. Any other movement for the same account waits.
- 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:
| Moment | units_available | units_reserved | deferred_revenue_cents |
|---|---|---|---|
| After both grants (1 Feb 2027) | 120 | 0 | 50,000 |
| After the campaign reserve (15 Feb) | 85 | 30 | 50,000 |
| After the campaign consume | 85 | 0 | 42,500 |
| After the expiry (1 Feb 2028) | 0 | 0 | 0 |
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
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Create zero balances when a billing account is created | An Org::Company gets its Billing::Account | System |
| UC-2 | Apply a ledger entry's deltas under the balance row lock | Every ledger write | System |
| UC-3 | View balances | An employer opens the dashboard, or an admin opens an account | Employer / Admin |
| UC-4 | Rebuild a balance by replaying the account's ledger entries | A balance is suspected wrong | System |
UC-1: Create zero balances when a billing account is created
| Field | Details |
|---|---|
| Actor | System |
| Trigger | An Org::Company gets its Billing::Account |
Preconditions:
- The
Billing::Accountis being created in an open transaction.
System Behaviour:
- For every
Billing::Entitlementrow, create one balance row:units_available: 0units_reserved: 0deferred_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::EntitlementUC-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
| Field | Details |
|---|---|
| Actor | System |
| Trigger | Every ledger write |
Preconditions:
- A movement is being written — the transaction from the
Billing::LedgerEntryspec's write path is open.
System Behaviour:
- Lock this account's balance row for the entry's instrument. Other movements for the account wait here.
- Check the movement fits:
- a reserve or direct consume must not push
units_availablebelow zero - a consume from reserved must not push
units_reservedbelow zero - if it does not fit, the whole transaction rolls back — nothing is written anywhere
- a reserve or direct consume must not push
- Add the entry's deltas to the three numbers:
available_deltaontounits_availablereserved_deltaontounits_reserveddeferred_revenue_delta_centsontodeferred_revenue_cents
- Commit together with the entry, its allocation rows, and the lot counters. The commit releases the lock.
Business Rules:
- An
adjustentry 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
| Field | Details |
|---|---|
| Actor | Org::Membership (employer) or Identities::Admin |
| Trigger | An employer opens the dashboard, or an admin opens an account |
Preconditions:
- The account exists.
System Behaviour:
- 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
- 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
| Field | Details |
|---|---|
| Actor | System |
| Trigger | A balance is suspected wrong |
Preconditions:
- None. The ledger is complete by construction.
System Behaviour:
- Sum the account's entries for the instrument in
idorder, into the three totals. - Compare with the stored row. The full rules live in
Billing::LedgerEntryUC-9. - 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
- One row per account per instrument. A database unique index enforces the pair.
- All three numbers are
>=0, always. - The row changes only in the same transaction as exactly one
Billing::LedgerEntry, under this row's lock (D5). - Replaying the account's entries in
idorder reproduces the row exactly. On a mismatch the ledger wins (UC-4). - Per instrument, the three numbers equal the sums across the instrument's
Billing::EntitlementLotrows: available, reserved, anddeferred_revenue_remaining_cents. - Every reserved unit belongs to exactly one deliverable. Per deliverable, the reserved amount is the net of the
Billing::LedgerEntryrows naming it as source. There is no hold table (D8). - For a budget-controlled instrument, the sum of units across
Billing::OutletBudgetrows withstatus: :activenever exceeds the balance's units. The company's unallocated pool is the difference. - Rows are never deleted.
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Billing::Account | Balance belongs to Account | One row per instrument, created with the account (UC-1). The row lock serialises the account's movements. |
Billing::Entitlement | Balance scoped to one | The instrument whose totals this row holds. |
Billing::LedgerEntry | Entries drive every change | Deltas applied in the same transaction, under the lock (UC-2). Replay rebuilds the row (UC-4). |
Billing::EntitlementLot | Sums must match | The 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 asked | The net of the entries naming the deliverable's source. Invariant 6 ties it to units_reserved. |
Billing::OutletBudget | Partitions the balance | Outlet budgets label parts of these totals per outlet. Transfers move the label, never the balance. |
Schema Gaps
| Gap | Impact | Suggested 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. |