Billing::EntitlementLotAllocation
Purpose
One Billing::EntitlementLotAllocation row = the effect of one Billing::LedgerEntry on one Billing::EntitlementLot.
A Billing::LedgerEntry carries an account-level total, for example "reserve 30 credits". Credits live in purchase batches (Billing::EntitlementLot), so one Billing::LedgerEntry can touch several batches at once. The Billing::EntitlementLotAllocation row records the per-batch share: which Billing::EntitlementLot, how many units, and how much revenue.
Decision D7 gives this table a second job:
- Every
Billing::LedgerEntrywrites at least oneBilling::EntitlementLotAllocationrow — one perBilling::EntitlementLotit applies to.
You get everything that ever happened to that Billing::EntitlementLot (i.e. history) by reading all its Billing::EntitlementLotAllocation rows in Billing::LedgerEntry id order.
- the ledger entry grant that created the EntitlementLot
- every ledger entry consume
- every entitlement_lot
expires_atchange - the
Billing::LedgerEntrythat emptied it. - No other table needs to be checked.
Schema truth: jodapp-api/docs/db/billing.dbml, table billing_entitlement_lot_allocations.
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 derivations; solid arrows are the two foreign keys.
| Context | Details |
|---|---|
| Aggregate | Billing::EntitlementLotAllocation has no children. Each row belongs to its Billing::LedgerEntry, inside the Billing::Account aggregate. |
| Layer | Entitlement engine |
| Upstream dependencies | Billing::LedgerEntry and Billing::EntitlementLot — both exist in the same transaction that writes the row |
| Downstream dependents | The per-lot split of a deliverable's reserved credits (D8), lot history screens, the audit schedules |
What one row records
| Column | Meaning |
|---|---|
billing_ledger_entry_id | The Billing::LedgerEntry this row belongs to. |
billing_entitlement_lot_id | The Billing::EntitlementLot the Billing::LedgerEntry applies to. |
allocation_type | Always equals entry_type on the row's Billing::LedgerEntry. Copied here so lot history reads need no join to filter. |
units_allocated | How many units the Billing::LedgerEntry moved on this Billing::EntitlementLot. 0 only on adjust rows. |
recognised_revenue_cents | The earned amount from this Billing::EntitlementLot, when the movement earns revenue. NULL when the movement type earns nothing. |
What each entry type writes — every combination in one table. Each row is one entry type, and its story cell retells the shared Acme example. Six rows are Acme's six real entries. A row for a path Acme did not take says so in its story cell. The value columns are grouped by the model they sit on, and each group has its own colour: blue for Billing::LedgerEntry, green for Billing::EntitlementLotAllocation, grey-blue for the deliverable's reserved credits (D8).
| The Acme story | Billing::LedgerEntry | Billing::EntitlementLotAllocation | Reserved credits (D8 — not a table) | |||
|---|---|---|---|---|---|---|
| what happens, in plain words | entry_type | source_type / source_id point at | rows written | units_allocated | recognised_revenue_cents | effect on the deliverable's reserved credits |
| 1 Feb 2027. Acme pays invoice SG-INV-0042: 100 credits for SGD 500. Lot P-1 is created, full. | grant (paid) | Billing::InvoicePosting | 1 — the new Billing::EntitlementLot | 100 | NULL | — |
| Same day. An admin gives Acme 20 free trial credits. Lot P-2 is created with SGD 0 attached, expiring 1 March. | grant (free) | Billing::CreditAction of type trial_grant or goodwill_grant | 1 — the new Billing::EntitlementLot | 20 | NULL | — |
| 10 Feb. Acme publishes a Careers post costing 5 credits. It is delivered the moment it is paid, so nothing is ever reserved. The spend order takes all 5 from P-2 — it expires soonest. | consume (direct) | Careers::Job, or the job boost record (its model is not designed yet) | 1 per Billing::EntitlementLot drawn, in the spend order | 5 | 0 — trial credits earn nothing | nothing is reserved |
| 15 Feb. Acme starts an ad campaign that needs 30 credits set aside: 15 from P-2, then 15 from P-1. Nothing is earned — nothing is delivered yet. | reserve | Ads::CampaignPlacement or Gig::Shift | 1 per Billing::EntitlementLot drawn, in the spend order | 15 + 15 | NULL | sets them aside |
| Campaign end. The ads ran, so the 30 reserved credits are used up. P-2's 15 earn SGD 0; P-1's 15 earn SGD 75. P-2 is settled. | consume (from reserved) | Ads::CampaignPlacement or Gig::Shift | 1 per reserved-from Billing::EntitlementLot, soonest expiry first | 15 + 15 | 0 and 7,500 — set per lot | takes from them |
| A path Acme did not take. If the campaign had been cancelled, the 30 credits would go back to the exact lots they came from. Nothing earned, nothing lost. | release | Ads::CampaignPlacement or Gig::Shift | 1 per Billing::EntitlementLot — the same lots the reserve drew from | the returned units | NULL | returns them |
| Also not taken. If an admin had extended P-1's expiry date, only the date would change. No units move, no money moves. Every warning email sends again for the new date. | adjust | Billing::CreditAction of type expiry_extension | 1 — the Billing::EntitlementLot whose date moves | 0 | NULL | — |
| 1 Feb 2028. P-1 passes its expiry date with 85 credits unused. The nightly cleanup job removes them; the remaining SGD 425 becomes breakage revenue. P-1 is settled. | expire | the expiring Billing::EntitlementLot itself | 1 — the Billing::EntitlementLot the cleanup job closes | 85 — the leftover units | 42,500 — the breakage | — |
| Gig only — not Acme's placement credits. A company leaves the gig platform. It gets its whole wage value back, from every lot at once. The platform fee is kept and recognised. | refund | the commercial refund record (its model is not designed yet) | 1 per Billing::EntitlementLot — every lot gives up its available units | all available units | set — the fee kept | must be zero first |
Notes on the table:
-
There is no
allocation_typecolumn. It always equalsentry_typeon the row'sBilling::LedgerEntry— the column table above states the rule. -
The source column states only the model name. The full source rules live in the
Billing::LedgerEntryspec — The reference columns. -
The spend order — earliest expiry first — is defined in the
Billing::EntitlementLotspec — The spend order. -
A
refundis always a full exit, and only gig is refundable (D4).
A database constraint, check_lot_allocations_units_match_type, enforces the units column per type:
- an
adjustrow carries exactly zero units - every other row carries at least one unit
Both Billing::EntitlementLot and Billing::LedgerEntry have their own source_type / source_id columns.
So why do grant and adjust entries still write Billing::EntitlementLotAllocation rows? The reasons are decision D7:
-
without the grant row
- a
grantBilling::LedgerEntryhas no direct link to theBilling::EntitlementLotit created
- a
-
without the adjust row
Billing::CreditActionbecomes a second place every lot-history read has to check.
State Machine
A Billing::EntitlementLotAllocation row has no states. It is written in the same transaction as its Billing::LedgerEntry and never changes after commit — the same rule as the Billing::LedgerEntry itself.
Worked example: Acme's rows, and the story of one lot
The same Acme Foods story as the Billing::EntitlementLot spec's worked example and the Billing::LedgerEntry spec's statement of account. The six Billing::LedgerEntry rows write eight Billing::EntitlementLotAllocation rows:
| Row | Billing::LedgerEntry | allocation_type | Billing::EntitlementLot | units_allocated | recognised_revenue_cents |
|---|---|---|---|---|---|
| 1 | 1 — grant from the invoice | grant | P-1 | 100 | NULL |
| 2 | 2 — grant from the trial | grant | P-2 | 20 | NULL |
| 3 | 3 — consume for the Careers post | consume | P-2 | 5 | 0 |
| 4 | 4 — reserve for the campaign | reserve | P-2 | 15 | NULL |
| 5 | 4 — reserve for the campaign | reserve | P-1 | 15 | NULL |
| 6 | 5 — consume at campaign end | consume | P-2 | 15 | 0 |
| 7 | 5 — consume at campaign end | consume | P-1 | 15 | 7,500 |
| 8 | 6 — expire by the cleanup job | expire | P-1 | 85 | 42,500 |
Three things the rows show:
Billing::LedgerEntry4 and 5 each write two rows- account-level "30 credits" splits into 15 + 15 across two
Billing::EntitlementLotrows.
- account-level "30 credits" splits into 15 + 15 across two
- Rows 3 and 6 have
recognised_revenue_cents: 0, not NULL.- A consume always earns something, and
- for a trial
Billing::EntitlementLotthat something is exactly zero. - NULL means "this movement type earns nothing", as on the reserve rows.
- Filter the table by one
Billing::EntitlementLot, and theBilling::LedgerEntryorder tells that whole life.
The complete history of Billing::EntitlementLot P-1 is rows 1, 5, 7, and 8:
allocation_type | units_allocated | recognised_revenue_cents |
|---|---|---|
grant | 100 | NULL |
reserve | 15 | NULL |
consume | 15 | 7,500 |
expire | 85 | 42,500 |
Check these rows against the Billing::EntitlementLot row's own counters: units_purchased 100 = 15 consumed + 85 expired. Recognised in total: 7,500 + 42,500 = 50,000 — the full deferred revenue of P-1. The Billing::EntitlementLotAllocation rows alone reproduce the Billing::EntitlementLot row. One query, one table, no other source.
Use Cases
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Record the per-lot rows of a Billing::LedgerEntry | Any Billing::LedgerEntry is written | System |
| UC-2 | View the complete history of one Billing::EntitlementLot | Support or finance reviews a Billing::EntitlementLot | Admin |
| UC-3 | View the per-lot split of one statement line | A reader opens the detail of a statement line | Employer / Admin |
| UC-4 | Derive the per-lot split of a deliverable's reserved credits | A consume or release needs to know which lots the credits came from | System |
| UC-5 | Rebuild the counters of a Billing::EntitlementLot from its rows | A Billing::EntitlementLot row is suspected wrong, or the auditor tests one | System / Admin (finance) |
UC-1: Record the per-lot rows of a Billing::LedgerEntry
| Field | Details |
|---|---|
| Actor | System |
| Trigger | Any Billing::LedgerEntry is written |
Preconditions:
- The
Billing::LedgerEntryand the touchedBilling::EntitlementLotrows exist in the same open transaction.
System Behaviour:
- For each
Billing::EntitlementLottheBilling::LedgerEntryapplies to, write oneBilling::EntitlementLotAllocationrow:billing_ledger_entry_id= theBilling::LedgerEntrybilling_entitlement_lot_id= theBilling::EntitlementLotallocation_type= theBilling::LedgerEntry'sentry_typeunits_allocated= the units moved on thisBilling::EntitlementLot— 0 for anadjustentryrecognised_revenue_cents= the earned amount from thisBilling::EntitlementLotonconsume,expire, andrefundrows — NULL on the rest
- The write happens inside the one transaction described in the
Billing::LedgerEntryspec's write path, under theBilling::Accountbalance row lock.
Business Rules:
- The sum of
units_allocatedacross theBilling::LedgerEntry's rows equals the units thatBilling::LedgerEntrymoved. - The earned amounts come from the one recognition formula, computed per
Billing::EntitlementLotand copied here at write time. They are never recomputed later. - Rows are only added — never edited, never deleted.
Postconditions:
- The
Billing::LedgerEntryhas at least oneBilling::EntitlementLotAllocationrow.- Every touched
Billing::EntitlementLot's history is complete up to this entry.
- Every touched
UC-2: View the complete history of one Billing::EntitlementLot
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Support or finance reviews a Billing::EntitlementLot |
Preconditions:
- The
Billing::EntitlementLotexists.
System Behaviour:
- Fetch the
Billing::EntitlementLotAllocationrows of theBilling::EntitlementLot, joined to theirBilling::LedgerEntryrows, ordered byBilling::LedgerEntryid. - Render each row as one line:
- the
Billing::LedgerEntry's date - the
allocation_type - the units moved on this
Billing::EntitlementLot - the revenue earned from this
Billing::EntitlementLot - the
Billing::LedgerEntry's source — the record that caused the movement
- the
Business Rules:
- This one query is the whole history — including the grant that created the
Billing::EntitlementLotand anyexpires_atchange (D7). No second table is checked.
Postconditions:
- Read-only operation — no data changes.
UC-3: View the per-lot split of one statement line
| Field | Details |
|---|---|
| Actor | Org::Membership (employer) or Identities::Admin |
| Trigger | A reader opens the detail of a statement line |
Preconditions:
- The statement line's
Billing::LedgerEntryexists.
System Behaviour:
- Fetch the
Billing::EntitlementLotAllocationrows of the line'sBilling::LedgerEntry. - Show one line per row:
- which
Billing::EntitlementLot, with itsexpires_at - the units taken from or returned to that
Billing::EntitlementLot - the revenue earned from that
Billing::EntitlementLot
- which
Business Rules:
- The split explains the spend order to the customer: "Reserved 30 credits" becomes "15 from the batch expiring 1 March, 15 from the batch expiring 1 February 2028".
Postconditions:
- Read-only operation — no data changes.
UC-4: Derive the per-lot split of a deliverable's reserved credits
| Field | Details |
|---|---|
| Actor | System |
| Trigger | A consume or release needs to know which lots the credits came from |
Preconditions:
- A deliverable has reserved credits — the net of the
Billing::LedgerEntryrows naming it as source (D8).
System Behaviour:
- Fetch the
Billing::EntitlementLotAllocationrows of everyBilling::LedgerEntrythat names the deliverable as source. - Per
Billing::EntitlementLot, add and subtract:reserverows add units to the splitconsumeandreleaserows subtract units from it
- The result is the deliverable's reserved units, per
Billing::EntitlementLot.
Business Rules:
- The split is derived, never stored a second time (D5).
- This derivation is what makes "reserved units never expire" (D2) checkable — it says exactly which units of which
Billing::EntitlementLotare protected.
Postconditions:
- Read-only operation — no data changes.
UC-5: Rebuild the counters of a Billing::EntitlementLot from its rows
| Field | Details |
|---|---|
| Actor | System, or Identities::Admin (finance) |
| Trigger | A Billing::EntitlementLot row is suspected wrong, or the auditor tests one |
Preconditions:
- None. The
Billing::EntitlementLotAllocationrows are complete by construction (UC-1).
System Behaviour:
- Sum the
Billing::EntitlementLot's rows byallocation_type:grantrows giveunits_purchasedconsume,expire, andrefundrows give those three countersreserverows, minus theconsumerows that took reserved units, minusreleaserows, giveunits_reserved- what remains gives
units_available
- To tell the two consume patterns apart, read the row's
Billing::LedgerEntry(D5):reserved_deltaon theBilling::LedgerEntryis negative — the consume took reserved unitsavailable_deltaon theBilling::LedgerEntryis negative — the consume took available units directly
- Sum
recognised_revenue_centsto get the revenue theBilling::EntitlementLothas earned, and subtract it from the grant amount to getdeferred_revenue_remaining_cents. - Compare with the stored
Billing::EntitlementLotrow. The numbers must match.
Business Rules:
- On a mismatch, the
Billing::LedgerEntryrows and theirBilling::EntitlementLotAllocationrows win. TheBilling::EntitlementLotrow is corrected to match — the same rule as rebuilding a balance projection inBilling::LedgerEntryUC-9.
Postconditions:
- Read-only over the
Billing::EntitlementLotAllocationrows. Only theBilling::EntitlementLotrow may be corrected.
Invariants
- Every
Billing::LedgerEntryhas at least oneBilling::EntitlementLotAllocationrow — one perBilling::EntitlementLotit applies to (D7). - Rows are append-only. Each row is written in the same transaction as its
Billing::LedgerEntry. No update, no delete. allocation_typealways equalsentry_typeon the row'sBilling::LedgerEntry.- The sum of
units_allocatedacross aBilling::LedgerEntry's rows equals the units thatBilling::LedgerEntrymoved. Anadjustentry moves zero units, so its one row hasunits_allocated: 0. - The
check_lot_allocations_units_match_typeconstraint holds: anadjustrow carries exactly zero units; every other row carries at least one. recognised_revenue_centsis set onconsume,expire, andrefundrows and NULL on all others. A movement that earns exactly zero — a trialBilling::EntitlementLot— hasrecognised_revenue_cents: 0, not NULL.- Earned amounts are computed with the one recognition formula and copied at write time, never recomputed. Their sum across a
Billing::LedgerEntry's rows equalsrecognised_revenue_centson thatBilling::LedgerEntry. - Units never move between
Billing::EntitlementLotrows. Areleaserow returns units to the exactBilling::EntitlementLotthereserverow drew them from (D5). - Replaying the rows of one
Billing::EntitlementLotinBilling::LedgerEntryid order reproduces thatBilling::EntitlementLot's counters and deferred revenue exactly (UC-5). On a mismatch, the ledger and itsBilling::EntitlementLotAllocationrows win.
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Billing::LedgerEntry | Each row belongs to one | The Billing::LedgerEntry carries the account-level totals; its Billing::EntitlementLotAllocation rows carry the per-lot split. At least one row per entry. |
Billing::EntitlementLot | Each row applies to one | The rows of one Billing::EntitlementLot, in Billing::LedgerEntry id order, are its complete history (D7). |
| A deliverable's reserved credits (D8) | Split derived from rows | Not a table. The per-lot split derives from the rows of the Billing::LedgerEntry rows naming the deliverable (D5). Not stored twice. |
Billing::Account | Via the Billing::LedgerEntry | Every row is written under the Billing::Account's balance row lock, because its Billing::LedgerEntry is. |
Billing::CreditAction | The decision side of an adjust row | A Billing::CreditAction of type expiry_extension records what the admin authorised; the adjust entry's Billing::EntitlementLotAllocation row records that it happened, in the Billing::EntitlementLot's history. |