Skip to main content

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::LedgerEntry writes at least one Billing::EntitlementLotAllocation row — one per Billing::EntitlementLot it 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_at change
  • the Billing::LedgerEntry that 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.

ContextDetails
AggregateBilling::EntitlementLotAllocation has no children. Each row belongs to its Billing::LedgerEntry, inside the Billing::Account aggregate.
LayerEntitlement engine
Upstream dependenciesBilling::LedgerEntry and Billing::EntitlementLot — both exist in the same transaction that writes the row
Downstream dependentsThe per-lot split of a deliverable's reserved credits (D8), lot history screens, the audit schedules

What one row records​

ColumnMeaning
billing_ledger_entry_idThe Billing::LedgerEntry this row belongs to.
billing_entitlement_lot_idThe Billing::EntitlementLot the Billing::LedgerEntry applies to.
allocation_typeAlways equals entry_type on the row's Billing::LedgerEntry. Copied here so lot history reads need no join to filter.
units_allocatedHow many units the Billing::LedgerEntry moved on this Billing::EntitlementLot. 0 only on adjust rows.
recognised_revenue_centsThe 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 storyBilling::LedgerEntryBilling::EntitlementLotAllocationReserved credits (D8 — not a table)
what happens, in plain wordsentry_typesource_type / source_id point atrows writtenunits_allocatedrecognised_revenue_centseffect 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::InvoicePosting1 — the new Billing::EntitlementLot100NULL—
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_grant1 — the new Billing::EntitlementLot20NULL—
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 order50 — trial credits earn nothingnothing 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.reserveAds::CampaignPlacement or Gig::Shift1 per Billing::EntitlementLot drawn, in the spend order15 + 15NULLsets 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::Shift1 per reserved-from Billing::EntitlementLot, soonest expiry first15 + 150 and 7,500 — set per lottakes 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.releaseAds::CampaignPlacement or Gig::Shift1 per Billing::EntitlementLot — the same lots the reserve drew fromthe returned unitsNULLreturns 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.adjustBilling::CreditAction of type expiry_extension1 — the Billing::EntitlementLot whose date moves0NULL—
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.expirethe expiring Billing::EntitlementLot itself1 — the Billing::EntitlementLot the cleanup job closes85 — the leftover units42,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.refundthe commercial refund record (its model is not designed yet)1 per Billing::EntitlementLot — every lot gives up its available unitsall available unitsset — the fee keptmust be zero first

Notes on the table:

  • There is no allocation_type column. It always equals entry_type on the row's Billing::LedgerEntry — the column table above states the rule.

  • The source column states only the model name. The full source rules live in the Billing::LedgerEntry spec — The reference columns.

  • The spend order — earliest expiry first — is defined in the Billing::EntitlementLot spec — The spend order.

  • A refund is 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 adjust row 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 grant Billing::LedgerEntry has no direct link to the Billing::EntitlementLot it created
  • without the adjust row

    • Billing::CreditAction becomes 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:

RowBilling::LedgerEntryallocation_typeBilling::EntitlementLotunits_allocatedrecognised_revenue_cents
11 — grant from the invoicegrantP-1100NULL
22 — grant from the trialgrantP-220NULL
33 — consume for the Careers postconsumeP-250
44 — reserve for the campaignreserveP-215NULL
54 — reserve for the campaignreserveP-115NULL
65 — consume at campaign endconsumeP-2150
75 — consume at campaign endconsumeP-1157,500
86 — expire by the cleanup jobexpireP-18542,500

Three things the rows show:

  • Billing::LedgerEntry 4 and 5 each write two rows
    • account-level "30 credits" splits into 15 + 15 across two Billing::EntitlementLot rows.
  • Rows 3 and 6 have recognised_revenue_cents: 0, not NULL.
    • A consume always earns something, and
    • for a trial Billing::EntitlementLot that something is exactly zero.
    • NULL means "this movement type earns nothing", as on the reserve rows.
  • Filter the table by one Billing::EntitlementLot, and the Billing::LedgerEntry order tells that whole life.

The complete history of Billing::EntitlementLot P-1 is rows 1, 5, 7, and 8:

allocation_typeunits_allocatedrecognised_revenue_cents
grant100NULL
reserve15NULL
consume157,500
expire8542,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​

IDUse CaseTriggerActor
UC-1Record the per-lot rows of a Billing::LedgerEntryAny Billing::LedgerEntry is writtenSystem
UC-2View the complete history of one Billing::EntitlementLotSupport or finance reviews a Billing::EntitlementLotAdmin
UC-3View the per-lot split of one statement lineA reader opens the detail of a statement lineEmployer / Admin
UC-4Derive the per-lot split of a deliverable's reserved creditsA consume or release needs to know which lots the credits came fromSystem
UC-5Rebuild the counters of a Billing::EntitlementLot from its rowsA Billing::EntitlementLot row is suspected wrong, or the auditor tests oneSystem / Admin (finance)

UC-1: Record the per-lot rows of a Billing::LedgerEntry​

FieldDetails
ActorSystem
TriggerAny Billing::LedgerEntry is written

Preconditions:

  • The Billing::LedgerEntry and the touched Billing::EntitlementLot rows exist in the same open transaction.

System Behaviour:

  1. For each Billing::EntitlementLot the Billing::LedgerEntry applies to, write one Billing::EntitlementLotAllocation row:
    • billing_ledger_entry_id = the Billing::LedgerEntry
    • billing_entitlement_lot_id = the Billing::EntitlementLot
    • allocation_type = the Billing::LedgerEntry's entry_type
    • units_allocated = the units moved on this Billing::EntitlementLot — 0 for an adjust entry
    • recognised_revenue_cents = the earned amount from this Billing::EntitlementLot on consume, expire, and refund rows — NULL on the rest
  2. The write happens inside the one transaction described in the Billing::LedgerEntry spec's write path, under the Billing::Account balance row lock.

Business Rules:

  • The sum of units_allocated across the Billing::LedgerEntry's rows equals the units that Billing::LedgerEntry moved.
  • The earned amounts come from the one recognition formula, computed per Billing::EntitlementLot and copied here at write time. They are never recomputed later.
  • Rows are only added — never edited, never deleted.

Postconditions:

  • The Billing::LedgerEntry has at least one Billing::EntitlementLotAllocation row.
    • Every touched Billing::EntitlementLot's history is complete up to this entry.

UC-2: View the complete history of one Billing::EntitlementLot​

FieldDetails
ActorIdentities::Admin
TriggerSupport or finance reviews a Billing::EntitlementLot

Preconditions:

  • The Billing::EntitlementLot exists.

System Behaviour:

  1. Fetch the Billing::EntitlementLotAllocation rows of the Billing::EntitlementLot, joined to their Billing::LedgerEntry rows, ordered by Billing::LedgerEntry id.
  2. 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

Business Rules:

  • This one query is the whole history — including the grant that created the Billing::EntitlementLot and any expires_at change (D7). No second table is checked.

Postconditions:

  • Read-only operation — no data changes.

UC-3: View the per-lot split of one statement line​

FieldDetails
ActorOrg::Membership (employer) or Identities::Admin
TriggerA reader opens the detail of a statement line

Preconditions:

  • The statement line's Billing::LedgerEntry exists.

System Behaviour:

  1. Fetch the Billing::EntitlementLotAllocation rows of the line's Billing::LedgerEntry.
  2. Show one line per row:
    • which Billing::EntitlementLot, with its expires_at
    • the units taken from or returned to that Billing::EntitlementLot
    • the revenue earned from that Billing::EntitlementLot

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​

FieldDetails
ActorSystem
TriggerA consume or release needs to know which lots the credits came from

Preconditions:

  • A deliverable has reserved credits — the net of the Billing::LedgerEntry rows naming it as source (D8).

System Behaviour:

  1. Fetch the Billing::EntitlementLotAllocation rows of every Billing::LedgerEntry that names the deliverable as source.
  2. Per Billing::EntitlementLot, add and subtract:
    • reserve rows add units to the split
    • consume and release rows subtract units from it
  3. 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::EntitlementLot are protected.

Postconditions:

  • Read-only operation — no data changes.

UC-5: Rebuild the counters of a Billing::EntitlementLot from its rows​

FieldDetails
ActorSystem, or Identities::Admin (finance)
TriggerA Billing::EntitlementLot row is suspected wrong, or the auditor tests one

Preconditions:

  • None. The Billing::EntitlementLotAllocation rows are complete by construction (UC-1).

System Behaviour:

  1. Sum the Billing::EntitlementLot's rows by allocation_type:
    • grant rows give units_purchased
    • consume, expire, and refund rows give those three counters
    • reserve rows, minus the consume rows that took reserved units, minus release rows, give units_reserved
    • what remains gives units_available
  2. To tell the two consume patterns apart, read the row's Billing::LedgerEntry (D5):
    • reserved_delta on the Billing::LedgerEntry is negative — the consume took reserved units
    • available_delta on the Billing::LedgerEntry is negative — the consume took available units directly
  3. Sum recognised_revenue_cents to get the revenue the Billing::EntitlementLot has earned, and subtract it from the grant amount to get deferred_revenue_remaining_cents.
  4. Compare with the stored Billing::EntitlementLot row. The numbers must match.

Business Rules:

  • On a mismatch, the Billing::LedgerEntry rows and their Billing::EntitlementLotAllocation rows win. The Billing::EntitlementLot row is corrected to match — the same rule as rebuilding a balance projection in Billing::LedgerEntry UC-9.

Postconditions:

  • Read-only over the Billing::EntitlementLotAllocation rows. Only the Billing::EntitlementLot row may be corrected.

Invariants​

  1. Every Billing::LedgerEntry has at least one Billing::EntitlementLotAllocation row — one per Billing::EntitlementLot it applies to (D7).
  2. Rows are append-only. Each row is written in the same transaction as its Billing::LedgerEntry. No update, no delete.
  3. allocation_type always equals entry_type on the row's Billing::LedgerEntry.
  4. The sum of units_allocated across a Billing::LedgerEntry's rows equals the units that Billing::LedgerEntry moved. An adjust entry moves zero units, so its one row has units_allocated: 0.
  5. The check_lot_allocations_units_match_type constraint holds: an adjust row carries exactly zero units; every other row carries at least one.
  6. recognised_revenue_cents is set on consume, expire, and refund rows and NULL on all others. A movement that earns exactly zero — a trial Billing::EntitlementLot — has recognised_revenue_cents: 0, not NULL.
  7. Earned amounts are computed with the one recognition formula and copied at write time, never recomputed. Their sum across a Billing::LedgerEntry's rows equals recognised_revenue_cents on that Billing::LedgerEntry.
  8. Units never move between Billing::EntitlementLot rows. A release row returns units to the exact Billing::EntitlementLot the reserve row drew them from (D5).
  9. Replaying the rows of one Billing::EntitlementLot in Billing::LedgerEntry id order reproduces that Billing::EntitlementLot's counters and deferred revenue exactly (UC-5). On a mismatch, the ledger and its Billing::EntitlementLotAllocation rows win.

Model Interactions​

Related ModelRelationshipInteraction
Billing::LedgerEntryEach row belongs to oneThe Billing::LedgerEntry carries the account-level totals; its Billing::EntitlementLotAllocation rows carry the per-lot split. At least one row per entry.
Billing::EntitlementLotEach row applies to oneThe rows of one Billing::EntitlementLot, in Billing::LedgerEntry id order, are its complete history (D7).
A deliverable's reserved credits (D8)Split derived from rowsNot a table. The per-lot split derives from the rows of the Billing::LedgerEntry rows naming the deliverable (D5). Not stored twice.
Billing::AccountVia the Billing::LedgerEntryEvery row is written under the Billing::Account's balance row lock, because its Billing::LedgerEntry is.
Billing::CreditActionThe decision side of an adjust rowA 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.