Phase 3 — End-to-End Walkthrough
This page tells the Entitlement Engine as a story: one company, one purchase, one ad campaign, start to end. At each moment, it answers four questions in plain language before showing any data:
- What just happened — the real-world event.
- What triggers the system — which status change or action fires the logic.
- What record(s) get created or changed — and what they actually look like.
- What flows — how the company's credits and dollars move as a result.
Every record block below uses the real column names from the
Model Specifications and
lists every column on that table, not just the ones that change — so the shape of the record is
never hidden, even when a column is null or not relevant to this example. Class names link to that
model's full spec page; UC-N references link to the matching use case.
Phase 3 is not built yet — see the Design Plan for status. This page shows what each step will do once implemented, grounded in the Model Specifications and Use Cases. IDs, names, and dates below are made up for illustration.
Updated 2026-08-18 to match the design that shipped to main
(jodapp-api PR #1938,
docs PR #106) — see
Billing domain decisions D2 through D12 for the
full record of what changed since this page was first written.
The story
Acme Pte Ltd wants to advertise on Jod. They buy 100 Placement Credits for $1,000, then run a 7-day homepage placement that costs 7 credits (1 credit per day) — but cancel it after 3 days.
Scene 1 — Finance verifies Acme's payment
What happened: It's 1 Jan 2026. Acme sent a bank transfer covering their invoice
SG-INV-000042 — 100 Placement Credits for $1,000 (
Billing::Invoice #42). Priya, a
Finance admin (identities_admins#58), opens the Team Portal, finds the invoice, and records the
verified transfer against it. The invoice's total is now fully covered.
What triggers the system: Priya clicking "Verify Payment" calls
Billing::Payments::TeamVerifyManager.execute with her admin identity — this is
Phase 2 UC-5,
already built. Inside that same transaction, the manager sees the invoice is now fully paid, so
Billing::Invoice#status flips from partially_paid to paid, and — per
Phase 2 UC-7 —
the manager creates the
Billing::InvoicePosting row below:
the system's flag that this invoice's credits are owed and safe to grant exactly once.
What Phase 2 creates (recap, not Phase 3):
Billing::InvoicePosting #77
id: 77
uuid: (system-generated)
billing_invoice_id: 42
posted_at: 2026-01-01 09:00
posted_by_admin_id: 58 # Priya — the admin who verified the payment
idempotency_key: "invoice_posting_42"
created_at: 2026-01-01 09:00
updated_at: 2026-01-01 09:00
posted_by_admin_id is not always nullposted_by_admin_id is nullable in the schema for a future system/script-triggered posting
path (an invoice that transitions to :paid with no admin clicking anything). That path isn't
built yet — today, Billing::Payments::TeamVerifyManager is the only code that creates a
Billing::InvoicePosting row, and it always sets posted_by_admin_id to the verifying admin's ID.
What flows: nothing yet — Acme still has 0 credits. This posting record is the handoff into Phase 3.
Scene 2 — Phase 3 grants the credits
What happened: From Acme's point of view, nothing — this is invisible, automatic backend work that happens in the same instant as Scene 1.
What triggers the system: the Billing::InvoicePosting row from Scene 1 being created. This
is UC-1,
and it runs inside the same database transaction as Scene 1 — if anything in Scene 2 fails,
Scene 1 rolls back too, so Acme is never left in a half-paid, half-granted state.
What gets created: the system reads Acme's one
Billing::InvoiceLine (line_type: principal, units_to_grant: 100, amount_cents: 100000) and writes four things — a new
purchase batch ("lot") for Acme, one ledger entry recording the grant forever, one
allocation row linking the two (they have different sources, so nothing else connects them),
and (since this is Acme's first-ever placement activity) a fresh balance row:
Billing::EntitlementLot #301
id: 301
uuid: (system-generated)
billing_account_id: 501
billing_entitlement_id: 1 # the "placement" row in billing_entitlements
purchased_at: 2026-01-01 09:00
expires_at: 2027-01-01 09:00 # purchased_at + the product's validity_months (12)
source_type: "Billing::InvoiceLine"
source_id: 901
units_purchased: 100
units_available: 100
units_reserved: 0
units_consumed: 0
units_expired: 0
units_refunded: 0
deferred_revenue_total_cents: 100000 # $1,000
deferred_revenue_remaining_cents: 100000
platform_fee_rate_bps: null # gig only
created_at: 2026-01-01 09:00
updated_at: 2026-01-01 09:00
Billing::LedgerEntry #5001
id: 5001
uuid: (system-generated)
billing_account_id: 501
billing_entitlement_id: 1
entry_type: grant
occurred_at: 2026-01-01 09:00
available_delta: 100
reserved_delta: 0
deferred_revenue_delta_cents: 100000
recognised_revenue_cents: 0
source_type: "Billing::InvoicePosting"
source_id: 77
org_outlet_id: null # not an outlet-scoped operation
idempotency_key: "ledger_grant_posting_77"
metadata: null
created_at: 2026-01-01 09:00
updated_at: 2026-01-01 09:00
Billing::EntitlementLotAllocation #9000
id: 9000
billing_ledger_entry_id: 5001
billing_entitlement_lot_id: 301
units_allocated: 100
allocation_type: grant
recognised_revenue_cents: null # nothing recognised on a grant
created_at: 2026-01-01 09:00
updated_at: 2026-01-01 09:00
Without this row, the grant entry (sourced from the InvoicePosting) and the lot it created
(sourced from the InvoiceLine) would share no key at all — this is why grants write an
allocation row too, not only reserve/consume/release
(Billing D7).
Billing::EntitlementBalance #900
id: 900
uuid: (system-generated)
billing_account_id: 501
billing_entitlement_id: 1
units_available: 100
units_reserved: 0
deferred_revenue_cents: 100000
created_at: 2026-01-01 09:00
updated_at: 2026-01-01 09:00
What flows: Acme's balance goes from 0 → 100 credits available. $1,000 sits as deferred revenue — collected, but not yet earned, because Acme hasn't used any credits yet.
Scene 3 — Acme submits a 7-day ad campaign
What happened: On 10 Jan, Acme's marketing person sets up a homepage placement running 7 days
(Ads::CampaignPlacement #8801). The system priced it at 7 credits (1 credit per day). They click
Submit for Review.
What triggers the system: Ads::Campaign#status moves to pending_review. This is
UC-2.
What the system checks first: does Acme have 7 credits available, right now? Yes — all 100 are still sitting in lot 301, untouched since Scene 2. (If Acme didn't have enough, submission would be blocked here with a clear error — no reservation happens on a failed check.)
What gets created: one reserve
Billing::LedgerEntry, and a
Billing::EntitlementLotAllocation
recording exactly which lot supplied the credits. There is no separate table tracking "this
placement has 7 credits held" — that is computed by reading the ledger, not stored
(Billing D8):
Billing::LedgerEntry #5002
id: 5002
uuid: (system-generated)
billing_account_id: 501
billing_entitlement_id: 1
entry_type: reserve
occurred_at: 2026-01-10 10:00
available_delta: -7
reserved_delta: 7
deferred_revenue_delta_cents: 0
recognised_revenue_cents: 0
source_type: "Ads::CampaignPlacement"
source_id: 8801
org_outlet_id: null
idempotency_key: "ledger_reserve_placement_8801"
metadata: null
created_at: 2026-01-10 10:00
updated_at: 2026-01-10 10:00
Billing::EntitlementLotAllocation #9001
id: 9001
billing_ledger_entry_id: 5002
billing_entitlement_lot_id: 301
units_allocated: 7
allocation_type: reserve
recognised_revenue_cents: null # nothing recognised on a reserve
created_at: 2026-01-10 10:00
updated_at: 2026-01-10 10:00
Lot 301 and balance 900 are updated in place, not replaced — only these columns change:
Billing::EntitlementLot #301 Billing::EntitlementBalance #900
units_available: 100 → 93 units_available: 100 → 93
units_reserved: 0 → 7 units_reserved: 0 → 7
Placement 8801's reserved credits right now — the net of its own ledger entries, not a stored
row — is +7 (one reserve entry, nothing consumed or released yet). This is what UC-11 and UC-12
read to know how much this placement still has held.
What flows: Acme's balance moves from 100 available / 0 reserved to 93 available / 7 reserved. Nothing is spent yet — reserving just locks the 7 credits so Acme can't accidentally oversell them on a second campaign.
Scene 4 — The ad runs, one day at a time
What happened: The campaign goes live. Each day it runs, it earns a little more of Jod's revenue — the placement is being delivered gradually, not all at once.
What triggers the system: a daily background job ( UC-3) checks every active placement and consumes its share for that day — here, 1 of the 7 credits per day.
What gets created, day 1: one consume
Billing::LedgerEntry, plus a
Billing::EntitlementLotAllocation
recording which lot the credit came from and how much revenue it earns. Revenue is recomputed
fresh from what remains on the lot at this exact moment, not a rate fixed back at Scene 2:
units_moved × deferred_revenue_remaining_cents ÷ (units_available + units_reserved) — right now
that's 1 × 100000 ÷ (93 + 7) = 1000 cents (
the full formula):
Billing::LedgerEntry #5003
id: 5003
uuid: (system-generated)
billing_account_id: 501
billing_entitlement_id: 1
entry_type: consume
occurred_at: 2026-01-11 00:05
available_delta: 0
reserved_delta: -1
deferred_revenue_delta_cents: -1000
recognised_revenue_cents: 1000
source_type: "Ads::CampaignPlacement"
source_id: 8801
org_outlet_id: null
idempotency_key: "ledger_consume_placement_8801_2026-01-11"
metadata: null
created_at: 2026-01-11 00:05
updated_at: 2026-01-11 00:05
Billing::EntitlementLotAllocation #9002
id: 9002
billing_ledger_entry_id: 5003
billing_entitlement_lot_id: 301
units_allocated: 1
allocation_type: consume
recognised_revenue_cents: 1000
created_at: 2026-01-11 00:05
updated_at: 2026-01-11 00:05
Days 2 and 3 are the exact same shape — only the ID, occurred_at, and idempotency_key differ.
The recognised amount stays $10 each day here because lot 301 has one uniform rate ($10.00/credit)
with nothing left over to round — that will not always be true once a lot's price doesn't divide
evenly, which is exactly why the formula recomputes from what remains instead of trusting a fixed
rate:
| Day | LedgerEntry id | LotAllocation id | occurred_at | reserved_delta | recognised_revenue_cents |
|---|---|---|---|---|---|
| 1 (11 Jan) | #5003 | #9002 | 2026-01-11 00:05 | -1 | 1000 |
| 2 (12 Jan) | #5004 | #9003 | 2026-01-12 00:05 | -1 | 1000 |
| 3 (13 Jan) | #5005 | #9004 | 2026-01-13 00:05 | -1 | 1000 |
What flows, day by day (lot 301 and balance 900 updated in place; "reserved" for placement 8801 is the same number on both, since it is the only thing reserving from lot 301 so far):
| Day | EntitlementLot.units_reserved | Placement 8801's reserved credits (computed) | Recognised so far | Deferred still remaining |
|---|---|---|---|---|
| 1 (11 Jan) | 6 | 6 | $10 | $990 |
| 2 (12 Jan) | 5 | 5 | $20 | $980 |
| 3 (13 Jan) | 4 | 4 | $30 | $970 |
units_available on the balance and lot doesn't move during this — only units_reserved goes down
as units_consumed and recognised revenue go up. This is the whole point of reserving before
consuming: the 7 credits left Acme's available pool back on 10 Jan; from day 1 onward they're
just being converted, one at a time, from "held" into "actually earned."
Scene 5 — Acme cancels the campaign early
What happened: On 14 Jan, after 3 of the 7 days have run, Acme decides to cancel the campaign.
What triggers the system: the campaign's status moves to cancelled. This is
UC-4.
A campaign that had already started delivering could not be cancelled this way — it would run to
completion instead — but this one is stopped after day 3, still mid-flight.
What gets created: one release
Billing::LedgerEntry, returning
whatever's still held — computed from placement 8801's ledger entries so far (+7 reserved,
-3 consumed = 4 still reserved) — not the original 7, only the 4 days that never ran:
Billing::LedgerEntry #5006
id: 5006
uuid: (system-generated)
billing_account_id: 501
billing_entitlement_id: 1
entry_type: release
occurred_at: 2026-01-14 09:00
available_delta: 4
reserved_delta: -4
deferred_revenue_delta_cents: 0
recognised_revenue_cents: 0
source_type: "Ads::CampaignPlacement"
source_id: 8801
org_outlet_id: null
idempotency_key: "ledger_release_placement_8801"
metadata: null
created_at: 2026-01-14 09:00
updated_at: 2026-01-14 09:00
Billing::EntitlementLotAllocation #9005
id: 9005
billing_ledger_entry_id: 5006
billing_entitlement_lot_id: 301
units_allocated: 4
allocation_type: release
recognised_revenue_cents: null # nothing recognised on a release
created_at: 2026-01-14 09:00
updated_at: 2026-01-14 09:00
Billing::EntitlementLot #301 Billing::EntitlementBalance #900
units_available: 93 → 97 units_available: 93 → 97
units_reserved: 4 → 0 units_reserved: 4 → 0
Lot 301 has not expired (its expires_at is still a year away), so all 4 units return straight to
units_available with no write-off. If it had already expired, those units would expire
immediately in the same transaction instead — see the "A lot expires" side-story below and
Billing D12.
Placement 8801's reserved credits right now: +7 - 3 - 4 = 0 — fully settled.
What flows: Acme's balance moves from 93 available / 4 reserved to 97 available / 0 reserved. Those 4 credits are immediately spendable again — Acme could launch a new campaign with them right away.
How the story ends
| Credits available | Credits reserved | Recognised revenue | Deferred revenue | |
|---|---|---|---|---|
| Before any of this | 0 | 0 | $0 | $0 |
| After the invoice was paid | 100 | 0 | $0 | $1,000 |
| After the campaign was submitted | 93 | 7 | $0 | $1,000 |
| After 3 days of running | 93 | 4 | $30 | $970 |
| After cancelling | 97 | 0 | $30 | $970 |
Acme paid $1,000. Jod has earned $30 of it so far — exactly 3 days' worth of a 7-day placement, no more, no less. The other $970 stays deferred, waiting in lot 301 for whatever Acme spends its remaining 97 credits on next.
Two shorter side-stories
A free credit grant
What happens: Sales wants to give Acme 20 goodwill credits, no invoice involved — maybe an
apology for an onboarding delay. An admin (identities_admins#44) opens the Team Portal and grants
20 credits with a reason. This is not an Ads-specific mechanism — it is
Billing::CreditAction, which any
instrument (including gig, in a later phase) can use.
What triggers it: an admin submitting the grant form —
Billing::CreditAction UC-1:
Billing::CreditAction #15
id: 15
uuid: (system-generated)
billing_account_id: 501
billing_entitlement_id: 1
billing_entitlement_lot_id: null # empty for a grant — the lot doesn't exist yet
idempotency_key: "credit_action_15"
action_type: goodwill_grant
units: 20
expires_at: 2027-06-01 00:00 # the admin's own choice — there is no product behind a free grant
reason: "Apology for onboarding delay — approved by Sales"
admin_created_by: 44 # the Sales admin who submitted the grant
created_at: 2026-03-01 14:00
updated_at: 2026-03-01 14:00
Creating this row deliberately mirrors Scene 2 exactly — it creates a second
Billing::EntitlementLot for Acme,
one grant Billing::LedgerEntry, and one allocation row, except the money side is $0:
Billing::EntitlementLot #302
id: 302
uuid: (system-generated)
billing_account_id: 501
billing_entitlement_id: 1
purchased_at: 2026-03-01 14:00 # when the admin acted
expires_at: 2027-06-01 00:00 # copied from the CreditAction — this lot expires later than lot 301
source_type: "Billing::CreditAction"
source_id: 15
units_purchased: 20
units_available: 20
units_reserved: 0
units_consumed: 0
units_expired: 0
units_refunded: 0
deferred_revenue_total_cents: 0 # free — nothing to earn later
deferred_revenue_remaining_cents: 0
platform_fee_rate_bps: null # gig only
created_at: 2026-03-01 14:00
updated_at: 2026-03-01 14:00
What flows: lot 301 expires 2027-01-01; lot 302 expires 2027-06-01. Acme's next campaign
still draws from lot 301 first, but not because it's older — spend order is soonest-expiry-first
(FEFO), and 301 simply expires sooner than 302. If the admin had instead given lot 302 an
earlier expiry than 301's, 302 would be drawn from first, even though it was granted two months
later (Billing::EntitlementLot spend order).
Every credit that comes from lot 302 recognises $0, forever, no matter how much money sits in
lot 301 at the same time.
A lot expires
What happens: it's now 1 Jan 2027 — lot 301's expires_at. Say Acme still has 30 unspent,
unreserved credits sitting in it at that point. The lot has no status column — "expired" isn't a
flag the system sets, it's read straight off the row: expires_at is in the past
(Billing::EntitlementLot — Derived states).
What triggers it: the nightly cleanup job (
UC-6)
finds every lot past its expires_at with units_available > 0. It writes an :expire
Billing::LedgerEntry and one
allocation row, and sweeps the lot:
Billing::LedgerEntry #5201 Billing::EntitlementLot #301
entry_type: expire units_available: 30 → 0
available_delta: -30 units_expired: 0 → 30
deferred_revenue_delta_cents: -<remaining> deferred_revenue_remaining_cents: <remaining> → 0
recognised_revenue_cents: <remaining>
source_type: "Billing::EntitlementLot"
source_id: 301
Those 30 credits disappear from Acme's available balance, and whatever deferred revenue was still sitting against them gets recognised as revenue right then — Jod treats money that's never coming back to the customer as earned, not just written off. Auditor sign-off on this accounting treatment is queued before the first Xero export, not still being decided — see Xero Integration. Important: if any of those 30 credits had been reserved for a still-running campaign at that exact moment, expiry would leave them alone — only unspent, unreserved credits are swept, and the job simply has nothing to do for this lot until whatever's reserved is released or consumed.
What this page doesn't cover
- No backfill runs for old Phase 2 invoice postings. Billing has not shipped to production yet, so a posting missing a ledger entry is manual test data — cleared by resetting Phase 1-3 test data, not caught up. See Billing D13.
- Ads placements already live in production before Phase 3 ships are handled by draining them before launch, not by a billing backfill — see Billing D6 and the Use Cases page's note on the topic.
- Gig credits, refunds, and expiry warning emails — this story is placement-only, matching Phase 3's scope. Refunds are gig-only; warning emails are sent by a separate job not walked through here.
For the full column-by-column schema behind every record shown above, see Model Specifications and Use Cases.