Billing::EntitlementLot
Purpose
A lot is one purchase batch of credits. Every grant creates exactly one lot. The lot carries three things:
- the units that were granted
- the deferred revenue attached to those units
- an optional expiry date
All stored-value credits use lots — placement and gig both. This is decision D2. The lot exists as its own record because three product rules need purchase batches:
- Credit expiry. Credits from different purchases die on different dates. A single pool cannot say which credits die when.
- Free trial credits. A trial lot carries exactly SGD 0. In a pooled average, free credits would distort the price of every paid credit.
- Gig fee rates. Each gig purchase can carry its own platform fee rate. The lot keeps each purchase's fee separate.
The lot table stores counters, not states. There is no status column. Whether a lot is expired or settled is derived from its columns. Events live in the ledger; the lot row is the running total of those events.
Schema truth: jodapp-api/docs/db/billing.dbml, table billing_entitlement_lots.
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 decisions and corrections; solid arrows are the structural flow.
| Context | Details |
|---|---|
| Aggregate | Billing::EntitlementLot has no children. It is part of the Billing::Account aggregate. Allocation rows belong to the ledger entry that writes them. |
| Layer | Entitlement engine |
| Upstream dependencies | Billing::Account, Billing::Entitlement, and one source per lot: a Billing::InvoiceLine (paid) or a Billing::CreditAction (free). The source of an opening lot is designed in Phase 4 (UC-3). |
| Downstream dependents | Billing::EntitlementLotAllocation (per-lot proof of every movement), Billing::CreditExpiryNotice (warning emails), Billing::EntitlementBalance (account totals must equal lot sums), the Xero export |
Lifecycle
How units move through a lot
Each lot has five unit counters. units_purchased is the size of the batch and never changes. The other four only move when a ledger entry moves them.
Legend: The yellow node is the grant that creates the lot. Lavender counters are live — units there can still move. Grey counters are final — units there never move again. Every arrow is one ledger entry type.
Rules the diagram shows:
- Units enter a lot exactly once, at the grant. Nothing tops a lot up later.
- Units leave
units_availablefour ways: reserve, direct consume, expire, refund. - Units leave
units_reservedtwo ways: consume from reserved, or release back to available. - The three grey counters are one-way. A consumed, expired, or refunded unit never comes back.
- The three arrows into the grey counters also move deferred revenue into recognised revenue, using the one formula.
The two consume arrows are the two spending patterns from D5:
| Pattern | Used by | Path |
|---|---|---|
| Consume from reserved | Ad campaigns, gig shifts | available → reserved → consumed |
| Consume directly | Careers job posts, boosts | available → consumed |
Derived states
The lot has no status column. The state is read from the row:
| State | How to read it from the row |
|---|---|
| Open | expires_at is empty or in the future, and the lot still has available or reserved units |
| Expired | expires_at is in the past |
| Settled | units_available = 0, units_reserved = 0, and deferred_revenue_remaining_cents = 0 |
Settled is final. An expired lot becomes settled when the nightly cleanup job (UC-7) removes its leftover units. A lot can also settle without ever expiring — fully consumed, or fully refunded.
The short labels are the trigger only. The full rule for each arrow is in the table below.
| From | To | Trigger | Notes |
|---|---|---|---|
| (new) | open | UC-1, UC-2, UC-3 — a grant | The lot starts full |
open | open | UC-8 — admin extends expires_at | Zero-movement adjust entry; every warning email sends again for the new date |
open | expired | The clock passes expires_at | No write happens at this moment. Spending code refuses the lot from here on. |
open | settled | UC-5 or UC-9 empties the lot | All counters and deferred revenue reach zero without any expiry |
expired | open | UC-8 — admin extends before the cleanup job runs | A lot the cleanup job has already closed is never extended — use a goodwill grant instead (D3) |
expired | settled | UC-7 — the nightly cleanup job | One expire entry removes the leftover units and recognises the leftover deferred revenue |
The spend order
When the system reserves or consumes, it takes units from lots in a fixed order:
| Priority | Rule |
|---|---|
| 1 | Earliest expires_at first. Lots with no expiry come last. |
| 2 | Then earliest purchased_at. |
| 3 | Then smallest id. |
Two kinds of lots are skipped:
- lots with no available units
- lots whose
expires_athas passed
Why this order: credits that die soonest are spent first. That protects the customer from losing credits they could have used. Gig lots have no expiry date, so for gig this is plain first-in-first-out. Decided in D2.
The deferred revenue on a lot
Plain definitions for the three accounting words this section uses (full word list in Xero Integration):
- Deferred revenue — money we already collected, for a service we have not delivered yet.
- Recognise — move money from deferred revenue into earned revenue, at the moment we deliver.
- Breakage — revenue we earn when credits expire unused.
Each lot carries two revenue columns:
deferred_revenue_total_cents— the deferred revenue attached at creation. Cannot change.deferred_revenue_remaining_cents— starts equal to the total. Only goes down.
What the deferred revenue is differs per instrument:
| Instrument | The lot's deferred revenue is | What it is not |
|---|---|---|
| Placement | The sale price of the credits | GST — tax is never revenue |
| Gig | The platform fee for this purchase | The principal amount — the wage value the customer paid us in advance. A debt to the customer; it never becomes revenue. |
| Trial / goodwill | Exactly 0 | — |
One formula recognises revenue for every movement — consume, expire, and refund:
recognised = units_moved × deferred_revenue_remaining_cents ÷ (units_available + units_reserved)
What each term is:
units_moved— how many units this movement takes from this lot. It is not a lot column. It is the movement's input, and it is stored asunits_allocatedon the allocation row.deferred_revenue_remaining_cents— the deferred revenue still on the lot, read just before the movement.units_available + units_reserved— the units still on the lot, read just before the movement. The remaining deferred revenue belongs to these units. Consumed, expired, and refunded units are gone and carry no revenue.
The idea behind the formula: price per unit × units moved, where the price per unit is the deferred revenue still on the lot ÷ the units still on the lot.
The formula multiplies first instead of computing the price per unit first, because the math is integer math. Dividing first rounds the price per unit down, and that rounding error then multiplies by units_moved. Multiplying first keeps the error under one cent per movement. Example — a lot holds 100 cents on 3 units, and all 3 are consumed:
| Order of operations | Result |
|---|---|
| Price first: (100 ÷ 3) × 3 = 33 × 3 | 99 cents — 1 cent is stranded |
| Multiply first: 3 × 100 ÷ 3 | 100 cents — exact |
On top of that, the final movement takes exactly what is left, so every lot settles to exactly zero — no stray cent survives rounding.
Why the same formula works for gig: a gig lot's deferred revenue is only the platform fee (see the table above). So the deferred revenue still on the lot ÷ the units still on the lot is the platform fee per credit. The lot also copies platform_fee_rate_bps from the invoice for audit, but recognition never uses it.
Worked example: two placement lots
Acme Foods, Singapore. Two grants on 1 February 2027:
- Lot P-1 (paid): 100 credits for SGD 500. Product validity 12 months, so it expires at the end of 1 February 2028.
- Lot P-2 (trial): 20 free credits from a
trial_grantcredit action. SGD 0 attached. The admin set expiry to 1 March 2027.
Step 1 — 10 February. A Careers post costs 5 credits. Careers posts deliver instantly, so this is a direct consume — nothing is reserved. The spend order picks P-2 first, because it expires sooner.
- P-2 gives 5 units. Recognised: 5 × 0 ÷ 20 = SGD 0. Nobody paid for these credits, so nothing is earned.
Step 2 — 15 February. An ad campaign reserves 30 credits. The spend order takes the remaining 15 from P-2, then 15 from P-1. The campaign's reserved credits now come from both lots.
Step 3 — 1 March. P-2 passes its expiry date. Its units_available is 0, so the nightly cleanup job has nothing to remove. The 15 reserved trial units are protected — reserved units never expire (D2).
Step 4 — the campaign runs and consumes all 30 reserved credits. A consume from reserved takes the soonest-dying lot first:
- 15 from P-2: 15 × 0 ÷ 15 = SGD 0 recognised. P-2 is now settled.
- 15 from P-1: 15 × 50,000 ÷ 100 = 7,500 cents = SGD 75 recognised. P-1 has SGD 425 left.
Step 5 — 1 February 2028. P-1 expires with 85 credits unused. The nightly cleanup job writes one expire entry:
- 85 × 42,500 ÷ 85 = 42,500 cents = SGD 425 recognised as breakage. P-1 is now settled.
The final rows:
| Lot | purchased | available | reserved | consumed | expired | refunded | deferred revenue total | deferred revenue remaining | recognised in total |
|---|---|---|---|---|---|---|---|---|---|
| P-1 | 100 | 0 | 0 | 15 | 85 | 0 | 50,000 | 0 | 50,000 (75 + 425) |
| P-2 | 20 | 0 | 0 | 20 | 0 | 0 | 0 | 0 | 0 |
Check the conservation rule on each row: 100 = 0 + 0 + 15 + 85 + 0, and 20 = 0 + 0 + 20 + 0 + 0. Every row proves itself.
This example is the reason lots exist. In one shared pool, the 20 free credits would have dragged the average price of Acme's paid credits from SGD 5.00 down to SGD 4.17, and every paid consume would have under-recognised.
Use Cases
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Create a lot when a paid invoice posts | The invoice reaches status: :paid and posting runs | System |
| UC-2 | Create a free lot from an admin credit decision | Admin records a trial or goodwill grant | Admin |
| UC-3 | Create an opening lot when an instrument goes live | The gig flip migration runs | System |
| UC-4 | Pick lots for a reserve or a direct consume in the spend order | A campaign, shift, post, or boost spends credits | System |
| UC-5 | Consume units from a lot and recognise revenue | Service is delivered | System |
| UC-6 | Release reserved units back into their lots | A campaign or shift is cancelled, or finishes under budget | System |
| UC-7 | Expire a lot's leftover units after its expiry date | The nightly cleanup job | System |
| UC-8 | Extend a lot's expiry date | Admin gives a customer more time | Admin |
| UC-9 | Refund every lot in a full gig exit | A company leaves the gig platform | Admin (finance) |
| UC-10 | View an account's lots | Support or finance reviews a customer's credits | Admin |
| UC-11 | Derive an average credit price for finance | Finance or the auditor asks for schedule figures | Admin (finance) |
UC-1: Create a lot when a paid invoice posts
| Field | Details |
|---|---|
| Actor | System (invoice posting — see Billing::InvoicePosting) |
| Trigger | The invoice reaches status: :paid and posting runs |
Preconditions:
- The invoice has
status: :paid, and the posting record exists (posting is safe to run twice). - The invoice line's product points at an entitlement with
allocation_policy: :lots.
System Behaviour:
- The posting writes one
grantledger entry per principal invoice line. - In the same transaction, the system creates one lot per principal line:
units_purchased= the line'sunits_to_grant.units_availablestarts equal to it.purchased_at= the posting time.source_type/source_id= theBilling::InvoiceLine.
- The deferred revenue attached depends on the instrument:
- Placement: the line amount the customer paid for the credits — after discount, before GST.
- Gig: the platform fee amount, and the lot copies
platform_fee_rate_bps. The principal amount (the wage value) never sits on the lot.
- The expiry date comes from the product:
billing_products.validity_monthsempty → the lot never expires.- Otherwise: add the months to the purchase date in the company's timezone, store the end of that day. Same civil-date rule as agreement dates (D3).
Business Rules:
- One grant, one lot. Always.
units_purchasedanddeferred_revenue_total_centsnever change after creation.
Postconditions:
- The lot is open and full. The account balance shows the new units.
UC-2: Create a free lot from an admin credit decision
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Admin records a trial_grant or goodwill_grant on Billing::CreditAction |
Preconditions:
- A
Billing::CreditActionrow exists with the units and a required reason. One admin is enough — there is no second approver (D10).
System Behaviour:
- The system writes one
grantledger entry and creates one lot. deferred_revenue_total_cents = 0. Consuming or expiring these credits recognises nothing.expires_atcomes from the credit action — the admin sets it directly. There is no product behind a free grant.source_type/source_id= theBilling::CreditAction.
Business Rules:
- Free lots and paid lots never blend. The SGD 0 stays on this lot forever.
- Trial credits normally get a shorter validity than paid credits. That is admin judgement, not a system rule.
Postconditions:
- The lot is open and full. The Xero export will produce no lines for it (nobody paid us).
UC-3: Create an opening lot when an instrument goes live
| Field | Details |
|---|---|
| Actor | System (the gig flip migration) |
| Trigger | The Phase 4 migration carries jodgig balances into billing |
Preconditions:
- The migration totals are reconciled against Xero first, and ops and finance have signed the dry-run report (D6).
- A source record exists for the opening balance. Which record that is has not been designed yet — see Open Questions below.
System Behaviour:
- Same mechanics as UC-2, with two differences:
- The deferred revenue is real: finance's stated share of the Xero "Deferred income" balance for this company.
- Gig opening lots have no expiry date.
- The Xero export posts nothing for opening lots — the money already sits inside the Xero balances.
Business Rules:
- At most one opening lot per company per instrument, ever.
- Placement launches at zero, so placement opening lots are rare or none (D6).
Postconditions:
- The company's day-one position in billing equals its reconciled jodgig statement.
Open Questions:
- The record this lot names as its
source_type/source_idis not designed yet. That design belongs to Phase 4, with the rest of the gig flip (Billing D9 — The gig flip's opening balances are not credit actions). It has to hold two things that no per-company row can carry:- the migration run's two Xero reconciliation totals
- the ops and finance sign-off
UC-4: Pick lots for a reserve or a direct consume in the spend order
| Field | Details |
|---|---|
| Actor | System |
| Trigger | A campaign or shift reserves credits, or a post or boost consumes directly |
Preconditions:
- The account has enough available units across non-expired lots.
System Behaviour:
- The system locks the account's balance row. Every ledger write happens under this lock.
- It walks the lots in the spend order, skipping empty and expired lots.
- It takes units from each lot until the requested amount is covered.
- It writes one ledger entry, plus one allocation row per lot touched.
- For a reserve: these allocation rows are the deliverable's per-lot reserved split. Nothing is stored twice (D5).
Business Rules:
- Expired lots are refused here, at spend time. This is the enforcement — the nightly cleanup job is only bookkeeping (D3).
- If available units do not cover the request, nothing is written. There is no partial reserve.
Postconditions:
- Each touched lot moved units from
units_available(tounits_reserved, or straight tounits_consumedfor a direct consume).
UC-5: Consume units from a lot and recognise revenue
| Field | Details |
|---|---|
| Actor | System |
| Trigger | Service is delivered — a shift completes, a campaign day runs, a post is published |
Preconditions:
- From reserved: the deliverable's reserved credits cover the amount.
- Direct: enough available units exist (UC-4 picks the lots).
System Behaviour:
- A consume from reserved takes from the lots the deliverable reserved, soonest-dying first.
- For each touched lot, the system computes recognised revenue with the one formula.
- It writes one
consumeledger entry, plus one allocation row per lot. Each allocation row carries its lot's recognised amount. - It reduces each lot's
deferred_revenue_remaining_centsby the recognised amount.
Business Rules:
- The recognised amounts are copied onto the ledger entry and allocation rows at that moment, and never recomputed later. History does not drift.
- A consume never touches an expired lot's available units — but it may consume reserved units of an expired lot. Reserved units never expire.
Postconditions:
- Units sit in
units_consumed. The recognised amount left deferred revenue and became recognised revenue.
UC-6: Release reserved units back into their lots
| Field | Details |
|---|---|
| Actor | System |
| Trigger | A campaign or shift is cancelled, or finishes under the reserved amount |
Preconditions:
- The deliverable has reserved units.
System Behaviour:
- The system returns units to the same lots they were reserved from, using the allocation rows of the deliverable's reserve entries.
- It writes one
releaseledger entry, plus one allocation row per lot. - If a lot's
expires_atpassed while the units were reserved, the returned units expire immediately, in the same transaction — aexpireentry follows the release.
Business Rules:
- A release moves no revenue. Nothing was delivered, so nothing is recognised.
- Units never move between lots. Release is the exact reverse of the reserve.
Postconditions:
- The units are back in
units_available— or inunits_expired, if the lot died while they were reserved.
UC-7: Expire a lot's leftover units after its expiry date
| Field | Details |
|---|---|
| Actor | System (nightly cleanup job) |
| Trigger | The job runs shortly after midnight in the company's timezone |
Preconditions:
- The lot's
expires_athas passed, andunits_available > 0.
System Behaviour:
- For each such lot, in its own transaction:
- Write one
expireledger entry with an idempotency key. Source = the expiringBilling::EntitlementLotitself. - Move the available units to
units_expired. - Recognise the lot's remaining deferred revenue with the one formula. At this point that is everything left — this is breakage, recognised at the expiry date (D2).
- Write one
- One lot per transaction. One bad row cannot block other companies.
Business Rules:
- The job is bookkeeping, not enforcement. A late or broken job can never let dead credits be spent — UC-4 refuses them (D3).
- Safe to re-run: a lot the job has already closed has no available units left.
- Warning emails are a separate job with its own send log — see
Billing::CreditExpiryNotice.
Postconditions:
- The lot is settled. Its remaining deferred revenue became breakage revenue on the expiry date.
UC-8: Extend a lot's expiry date
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Admin gives a customer more time to use their credits |
Preconditions:
- The nightly cleanup job (UC-7) has not closed the lot yet — the leftover units are still in
units_available, and the remaining deferred revenue has not been recognised as breakage. - The new date is later than the current one. Extensions only lengthen.
System Behaviour:
- The admin records a
Billing::CreditActionwithaction_type: :expiry_extension, the target lot, the new date, and a reason. - The system updates the lot's
expires_atand writes a zero-movementadjustledger entry, so the date change shows in the audit trail. - The entry carries one allocation row for this lot:
units_allocated: 0— this use case only changesexpires_at; no units moverecognised_revenue_cents: NULL— nothing is recognised- the row exists so the
expires_atchange shows in the lot's own history (D7)
- The warning emails start over for the new date without any extra step: a warning counts as sent only when its logged date matches the lot's current
expires_at, and the old rows no longer match (D3).
Business Rules:
- A lot the cleanup job has already closed is never extended. Its deferred revenue already became breakage revenue. Use a goodwill grant (UC-2) instead.
expires_atis the only lot column an admin action can change.
Postconditions:
- The lot is open until the new date. Every warning email stage will send again for the new date.
UC-9: Refund every lot in a full gig exit
| Field | Details |
|---|---|
| Actor | Identities::Admin (finance) |
| Trigger | A company leaves the gig platform and asks for its money back |
Preconditions:
- The instrument is refundable (gig). Placement is not.
units_reservedis zero on every lot — shifts finished or cancelled first.- All outlet budgets are deallocated back to the company pool first.
System Behaviour:
- The system writes one
refundledger entry for the whole exit, plus one allocation row per lot. - Every lot gives up all of its available units. There is no draw order, because every lot pays out (D4).
- Per lot, the remaining fee is recognised with the one formula. The fee is not paid back.
- The bank repayment itself is not a ledger amount. A commercial-layer refund record holds the amount and the bank proof, and is the
sourceof the refund entry.
Small example — a company exits with two lots:
| Lot | Available units | Fee remaining | Customer gets back (principal amount) | Fee recognised |
|---|---|---|---|---|
| G-1 | 4,000 (SGD 40) | SGD 8.00 | SGD 40.00 | SGD 8.00 |
| G-2 | 6,000 (SGD 60) | SGD 15.00 | SGD 60.00 | SGD 15.00 |
The customer receives SGD 100. Jod recognises SGD 23. Each lot's own fee rate settles exactly — no averaging, no choice to defend.
Business Rules:
- Partial refunds are not offered. Use your credits, or leave and get the principal amount back.
- Refunds move no GST — tax was charged on the fee only, and the fee is kept (D4).
- "The outlet closed" is a budget transfer, never a refund.
Postconditions:
- Every lot is settled. The balance is zero. The company can buy credits again later — nothing is closed.
UC-10: View an account's lots
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Support or finance reviews a customer's credits |
Preconditions:
- The billing account exists.
System Behaviour:
- The system lists the account's lots per instrument, in the spend order, showing:
- units purchased, available, reserved, consumed, expired, refunded
- the expiry date, and days left
- deferred revenue attached and remaining
- the source — which invoice line or credit action created the lot
Business Rules:
- The list must make the next expiry obvious. This is the screen support opens when a customer calls about a warning email.
Postconditions:
- Read-only operation — no data changes.
UC-11: Derive an average credit price for finance
| Field | Details |
|---|---|
| Actor | Identities::Admin (finance) |
| Trigger | Finance or the auditor asks for schedule figures |
Preconditions:
- None. Any date in the past works.
System Behaviour:
- The average price per credit at any date is: the deferred revenue remaining on the lots ÷ the units still on the lots, as of that date.
- The history behind the number comes from replaying the account's ledger entries in
idorder. The replay is valid because every write held the account's balance row lock (D5). - This produces audit schedule 3 — see the audit schedules.
Business Rules:
- We recognise at actual batch price. The average is a derived report, never the recognition method. Lot data can produce any average with proof; pooled-only data could never recover lot answers (D2).
Postconditions:
- Read-only operation — no data changes.
Invariants
- Conservation:
units_purchased = units_available + units_reserved + units_consumed + units_expired + units_refunded. A database CHECK constraint enforces this. Every row proves itself without reading the ledger. units_purchased > 0, and it cannot change. All other counters are>= 0.- The lot has no status column. States are derived from the row (Lifecycle). Events live in the ledger.
- Every counter change happens in the same transaction as exactly one ledger entry and its allocation rows.
- Every ledger write holds the account's balance row lock. Two movements can never run at the same moment for one account. This is what makes per-account replay in
idorder valid (D5). - Reserved units never expire (D2).
- Units released into a lot whose expiry date has passed expire immediately, in the same transaction.
- Spending refuses expired lots at reserve and consume time. Enforcement never depends on the cleanup job.
deferred_revenue_remaining_centsstarts equal to the total, only goes down, and is exactly 0 when the lot settles.- Recognition always uses the one formula. The final movement takes exactly what is left.
expires_atis set at creation. Only UC-8 — an admin acting through aBilling::CreditActionof typeexpiry_extension— can change it, always to a later date, always with anadjustledger entry.- Gig lots keep
expires_atempty until a legal review says expiring refundable stored value is allowed (D2). platform_fee_rate_bpsexists only on gig lots, for audit. Recognition never reads it.- Trial and goodwill lots carry exactly 0 deferred revenue. Free credits never blend with paid credits.
- Lot rows are never edited by hand and never deleted. Only ledger movements change the counters.
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Billing::Account | Lot belongs to Account | The account is the aggregate root. The balance row lock serialises all lot movements. |
Billing::Entitlement | Lot scoped to an instrument | allocation_policy: :lots says this instrument uses lots. Policies cannot change once ledger history exists. |
Billing::InvoiceLine | Source of paid lots | The line's units, discounted amount, and (gig) fee rate become the lot's starting values. |
Billing::InvoicePosting | Creates paid lots | Posting writes the grant entry and the lot in one transaction. Safe to run twice. |
Billing::CreditAction | Source of free lots; extends expiry | A Billing::CreditAction of type trial_grant or goodwill_grant creates a lot. One of type expiry_extension points at the lot it extends. Opening lots are not created this way (D9). |
Billing::LedgerEntry | Every movement is one entry | The entry carries the account-level deltas and the recognised revenue. |
Billing::EntitlementLotAllocation | The lot's complete history | One row per lot an entry applies to — including the grant that creates the lot and the zero-unit row written when an admin extends expires_at (D7). |
| A deliverable's reserved credits (D8) | Reserved credits name their lots | Not a table. The per-lot split derives from the allocation rows of the entries naming the deliverable. |
Billing::EntitlementBalance | Account totals | The balance row's units and deferred revenue must equal the sums across the instrument's lots. |
Billing::CreditExpiryNotice | Warning send log | One row per warning email per lot. Because matching is on the warned date, a lot with a new expires_at gets every warning email again for the new date. |
Billing::OutletBudget | No direct relationship | Outlet budgets label company credits by outlet. Lots track purchase batches. A lot never belongs to an outlet. |