Skip to main content

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.

ContextDetails
AggregateBilling::EntitlementLot has no children. It is part of the Billing::Account aggregate. Allocation rows belong to the ledger entry that writes them.
LayerEntitlement engine
Upstream dependenciesBilling::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 dependentsBilling::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_available four ways: reserve, direct consume, expire, refund.
  • Units leave units_reserved two 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:

PatternUsed byPath
Consume from reservedAd campaigns, gig shiftsavailable → reserved → consumed
Consume directlyCareers job posts, boostsavailable → consumed

Derived states​

The lot has no status column. The state is read from the row:

StateHow to read it from the row
Openexpires_at is empty or in the future, and the lot still has available or reserved units
Expiredexpires_at is in the past
Settledunits_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.

FromToTriggerNotes
(new)openUC-1, UC-2, UC-3 — a grantThe lot starts full
openopenUC-8 — admin extends expires_atZero-movement adjust entry; every warning email sends again for the new date
openexpiredThe clock passes expires_atNo write happens at this moment. Spending code refuses the lot from here on.
opensettledUC-5 or UC-9 empties the lotAll counters and deferred revenue reach zero without any expiry
expiredopenUC-8 — admin extends before the cleanup job runsA lot the cleanup job has already closed is never extended — use a goodwill grant instead (D3)
expiredsettledUC-7 — the nightly cleanup jobOne 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:

PriorityRule
1Earliest expires_at first. Lots with no expiry come last.
2Then earliest purchased_at.
3Then smallest id.

Two kinds of lots are skipped:

  • lots with no available units
  • lots whose expires_at has 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:

InstrumentThe lot's deferred revenue isWhat it is not
PlacementThe sale price of the creditsGST — tax is never revenue
GigThe platform fee for this purchaseThe principal amount — the wage value the customer paid us in advance. A debt to the customer; it never becomes revenue.
Trial / goodwillExactly 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 as units_allocated on 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 operationsResult
Price first: (100 ÷ 3) × 3 = 33 × 399 cents — 1 cent is stranded
Multiply first: 3 × 100 ÷ 3100 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_grant credit 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:

Lotpurchasedavailablereservedconsumedexpiredrefundeddeferred revenue totaldeferred revenue remainingrecognised in total
P-1100001585050,000050,000 (75 + 425)
P-220002000000

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​

IDUse CaseTriggerActor
UC-1Create a lot when a paid invoice postsThe invoice reaches status: :paid and posting runsSystem
UC-2Create a free lot from an admin credit decisionAdmin records a trial or goodwill grantAdmin
UC-3Create an opening lot when an instrument goes liveThe gig flip migration runsSystem
UC-4Pick lots for a reserve or a direct consume in the spend orderA campaign, shift, post, or boost spends creditsSystem
UC-5Consume units from a lot and recognise revenueService is deliveredSystem
UC-6Release reserved units back into their lotsA campaign or shift is cancelled, or finishes under budgetSystem
UC-7Expire a lot's leftover units after its expiry dateThe nightly cleanup jobSystem
UC-8Extend a lot's expiry dateAdmin gives a customer more timeAdmin
UC-9Refund every lot in a full gig exitA company leaves the gig platformAdmin (finance)
UC-10View an account's lotsSupport or finance reviews a customer's creditsAdmin
UC-11Derive an average credit price for financeFinance or the auditor asks for schedule figuresAdmin (finance)

UC-1: Create a lot when a paid invoice posts​

FieldDetails
ActorSystem (invoice posting — see Billing::InvoicePosting)
TriggerThe 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:

  1. The posting writes one grant ledger entry per principal invoice line.
  2. In the same transaction, the system creates one lot per principal line:
    • units_purchased = the line's units_to_grant. units_available starts equal to it.
    • purchased_at = the posting time.
    • source_type / source_id = the Billing::InvoiceLine.
  3. 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.
  4. The expiry date comes from the product:
    • billing_products.validity_months empty → 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_purchased and deferred_revenue_total_cents never 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​

FieldDetails
ActorIdentities::Admin
TriggerAdmin records a trial_grant or goodwill_grant on Billing::CreditAction

Preconditions:

  • A Billing::CreditAction row exists with the units and a required reason. One admin is enough — there is no second approver (D10).

System Behaviour:

  1. The system writes one grant ledger entry and creates one lot.
  2. deferred_revenue_total_cents = 0. Consuming or expiring these credits recognises nothing.
  3. expires_at comes from the credit action — the admin sets it directly. There is no product behind a free grant.
  4. source_type / source_id = the Billing::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​

FieldDetails
ActorSystem (the gig flip migration)
TriggerThe 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:

  1. 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.
  2. 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_id is 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​

FieldDetails
ActorSystem
TriggerA 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:

  1. The system locks the account's balance row. Every ledger write happens under this lock.
  2. It walks the lots in the spend order, skipping empty and expired lots.
  3. It takes units from each lot until the requested amount is covered.
  4. It writes one ledger entry, plus one allocation row per lot touched.
  5. 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 (to units_reserved, or straight to units_consumed for a direct consume).

UC-5: Consume units from a lot and recognise revenue​

FieldDetails
ActorSystem
TriggerService 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:

  1. A consume from reserved takes from the lots the deliverable reserved, soonest-dying first.
  2. For each touched lot, the system computes recognised revenue with the one formula.
  3. It writes one consume ledger entry, plus one allocation row per lot. Each allocation row carries its lot's recognised amount.
  4. It reduces each lot's deferred_revenue_remaining_cents by 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​

FieldDetails
ActorSystem
TriggerA campaign or shift is cancelled, or finishes under the reserved amount

Preconditions:

  • The deliverable has reserved units.

System Behaviour:

  1. The system returns units to the same lots they were reserved from, using the allocation rows of the deliverable's reserve entries.
  2. It writes one release ledger entry, plus one allocation row per lot.
  3. If a lot's expires_at passed while the units were reserved, the returned units expire immediately, in the same transaction — a expire entry 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 in units_expired, if the lot died while they were reserved.

UC-7: Expire a lot's leftover units after its expiry date​

FieldDetails
ActorSystem (nightly cleanup job)
TriggerThe job runs shortly after midnight in the company's timezone

Preconditions:

  • The lot's expires_at has passed, and units_available > 0.

System Behaviour:

  1. For each such lot, in its own transaction:
    • Write one expire ledger entry with an idempotency key. Source = the expiring Billing::EntitlementLot itself.
    • 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).
  2. 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​

FieldDetails
ActorIdentities::Admin
TriggerAdmin 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:

  1. The admin records a Billing::CreditAction with action_type: :expiry_extension, the target lot, the new date, and a reason.
  2. The system updates the lot's expires_at and writes a zero-movement adjust ledger entry, so the date change shows in the audit trail.
  3. The entry carries one allocation row for this lot:
    • units_allocated: 0 — this use case only changes expires_at; no units move
    • recognised_revenue_cents: NULL — nothing is recognised
    • the row exists so the expires_at change shows in the lot's own history (D7)
  4. 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_at is 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​

FieldDetails
ActorIdentities::Admin (finance)
TriggerA company leaves the gig platform and asks for its money back

Preconditions:

  • The instrument is refundable (gig). Placement is not.
  • units_reserved is zero on every lot — shifts finished or cancelled first.
  • All outlet budgets are deallocated back to the company pool first.

System Behaviour:

  1. The system writes one refund ledger entry for the whole exit, plus one allocation row per lot.
  2. Every lot gives up all of its available units. There is no draw order, because every lot pays out (D4).
  3. Per lot, the remaining fee is recognised with the one formula. The fee is not paid back.
  4. The bank repayment itself is not a ledger amount. A commercial-layer refund record holds the amount and the bank proof, and is the source of the refund entry.

Small example — a company exits with two lots:

LotAvailable unitsFee remainingCustomer gets back (principal amount)Fee recognised
G-14,000 (SGD 40)SGD 8.00SGD 40.00SGD 8.00
G-26,000 (SGD 60)SGD 15.00SGD 60.00SGD 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​

FieldDetails
ActorIdentities::Admin
TriggerSupport or finance reviews a customer's credits

Preconditions:

  • The billing account exists.

System Behaviour:

  1. 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​

FieldDetails
ActorIdentities::Admin (finance)
TriggerFinance or the auditor asks for schedule figures

Preconditions:

  • None. Any date in the past works.

System Behaviour:

  1. 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.
  2. The history behind the number comes from replaying the account's ledger entries in id order. The replay is valid because every write held the account's balance row lock (D5).
  3. 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​

  1. 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.
  2. units_purchased > 0, and it cannot change. All other counters are >= 0.
  3. The lot has no status column. States are derived from the row (Lifecycle). Events live in the ledger.
  4. Every counter change happens in the same transaction as exactly one ledger entry and its allocation rows.
  5. 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 id order valid (D5).
  6. Reserved units never expire (D2).
  7. Units released into a lot whose expiry date has passed expire immediately, in the same transaction.
  8. Spending refuses expired lots at reserve and consume time. Enforcement never depends on the cleanup job.
  9. deferred_revenue_remaining_cents starts equal to the total, only goes down, and is exactly 0 when the lot settles.
  10. Recognition always uses the one formula. The final movement takes exactly what is left.
  11. expires_at is set at creation. Only UC-8 — an admin acting through a Billing::CreditAction of type expiry_extension — can change it, always to a later date, always with an adjust ledger entry.
  12. Gig lots keep expires_at empty until a legal review says expiring refundable stored value is allowed (D2).
  13. platform_fee_rate_bps exists only on gig lots, for audit. Recognition never reads it.
  14. Trial and goodwill lots carry exactly 0 deferred revenue. Free credits never blend with paid credits.
  15. Lot rows are never edited by hand and never deleted. Only ledger movements change the counters.

Model Interactions​

Related ModelRelationshipInteraction
Billing::AccountLot belongs to AccountThe account is the aggregate root. The balance row lock serialises all lot movements.
Billing::EntitlementLot scoped to an instrumentallocation_policy: :lots says this instrument uses lots. Policies cannot change once ledger history exists.
Billing::InvoiceLineSource of paid lotsThe line's units, discounted amount, and (gig) fee rate become the lot's starting values.
Billing::InvoicePostingCreates paid lotsPosting writes the grant entry and the lot in one transaction. Safe to run twice.
Billing::CreditActionSource of free lots; extends expiryA 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::LedgerEntryEvery movement is one entryThe entry carries the account-level deltas and the recognised revenue.
Billing::EntitlementLotAllocationThe lot's complete historyOne 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 lotsNot a table. The per-lot split derives from the allocation rows of the entries naming the deliverable.
Billing::EntitlementBalanceAccount totalsThe balance row's units and deferred revenue must equal the sums across the instrument's lots.
Billing::CreditExpiryNoticeWarning send logOne 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::OutletBudgetNo direct relationshipOutlet budgets label company credits by outlet. Lots track purchase batches. A lot never belongs to an outlet.