Skip to main content

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:

  1. What just happened — the real-world event.
  2. What triggers the system — which status change or action fires the logic.
  3. What record(s) get created or changed — and what they actually look like.
  4. 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.

This describes the design, not shipped behaviour

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 null

posted_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:

DayLedgerEntry idLotAllocation idoccurred_atreserved_deltarecognised_revenue_cents
1 (11 Jan)#5003#90022026-01-11 00:05-11000
2 (12 Jan)#5004#90032026-01-12 00:05-11000
3 (13 Jan)#5005#90042026-01-13 00:05-11000

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):

DayEntitlementLot.units_reservedPlacement 8801's reserved credits (computed)Recognised so farDeferred still remaining
1 (11 Jan)66$10$990
2 (12 Jan)55$20$980
3 (13 Jan)44$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 availableCredits reservedRecognised revenueDeferred revenue
Before any of this00$0$0
After the invoice was paid1000$0$1,000
After the campaign was submitted937$0$1,000
After 3 days of running934$30$970
After cancelling970$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.