Phase 3 — Overview
Phase 3 builds the Entitlement Engine — the layer that turns a paid invoice (or a free credit grant) into credits a company can actually spend, and turns an Ads campaign's activity into a record of exactly how many of those credits were used, and when.
New to this engine? Read the End-to-End Walkthrough first — it traces one company through the whole flow with every table row shown, before diving into the formal model specs and use cases below.
This page was first written after a CTO call on 2026-08-05 (see the
Design Plan for that call's
record). It is now rewritten a second time to match the design that shipped to main
(jodapp-api PR #1938,
docs PR #106) — the 2026-08-05 direction carried
further through design sessions on 2026-08-10, 2026-08-12, and 2026-08-14. The full, current
ledger of what changed and why is
Billing domain decisions D2 through D12. See
Main Branch Alignment — 2026-08-18
for a summary of what changed in this Ads-scoped folder specifically. This page states current
truth only; it links to those entries instead of repeating them.
Why we're building this
Phase 2 gets an invoice all the way
to :paid and leaves a Billing::InvoicePosting guard record behind — but nothing yet writes to
the ledger, and nothing yet stops an Ads campaign from running without enough credits. Without
Phase 3:
- The
billing_entitlement_balancesrows seeded in Phase 1/2 stay at zero forever — a company can pay an invoice and never receive spendable credits. - Ads campaigns submit and run with no real billing enforcement — credits are never reserved,
consumed, or released. This is true today in production (
38-ads/use-cases/index.mddocuments "No Billing Required (Early Access)"). - There's no record of how revenue was earned over time — recognising 100% of an invoice's revenue the moment it's paid would misstate a multi-day placement as fully earned on day one.
- There's no way to give a company credits it didn't pay for — every credit-granting path requires
a
Billing::Invoice, and a $0 invoice does not make financial sense. This blocks giving new companies free trial credits. - Credits never expire — there's no way to say "these credits are only good for a limited time from purchase."
Phase 3 closes this with four ideas that hold the whole engine together:
- The ledger is the source of truth.
Billing::LedgerEntryis an append-only log — every grant, reserve, consume, release, and expire is one immutable row. Nothing is ever computed by re-reading old state; every entry stands on its own. - The balance is a projection, not a second source of truth.
Billing::EntitlementBalance(units_available,units_reserved) is a fast-read cache derived from the ledger, updated in the same transaction as every ledger write. If it ever disagreed with the ledger, the ledger would win. - Credits are tracked in purchase batches (lots), not one pooled number. Every grant — paid
invoice or free — creates a
Billing::EntitlementLot. This is what makes purchase-date-relative expiry possible, and what keeps free credit revenue (always $0) from diluting paid credit revenue, by construction rather than by a shared average. - Credits move through a reserve → consume → release lifecycle, not a single grant → spend step. A campaign reserves credits when submitted (so it can't oversell what the company doesn't have), consumes them a little at a time as the placement actually runs (so revenue is recognised proportionally to work done), and releases whatever's left if the campaign is cancelled or rejected. There is no separate table tracking a reservation — a placement's reserved credits are a computed read over the ledger (Billing D8).
Goal
Deliver the Entitlement Engine so that:
- A paid invoice's credits become spendable the moment it posts.
- A company can also receive credits without an invoice (free/trial/goodwill), fully recorded on
the ledger like any other grant, through the domain-level
Billing::CreditAction. - An Ads campaign cannot submit without enough available credits.
- Revenue is recognised day-by-day as a placement actually runs, not all at once at purchase time — computed per purchase batch (lot), never as one blended account-wide average.
- Credits expire by default some months after purchase (set per product), without disrupting a campaign that's already running against them.
- Cancelling or rejecting a campaign returns unused credits immediately.
- Invoices that were already paid and posted during Phase 2 — before this engine existed — need no backfill. Billing has not shipped to production yet, so any such posting is manual test data, cleared by resetting Phase 1-3 test data rather than caught up (see Billing D13).
- Ads that are already live in production — created and running with no billing check at all — are handled by draining them before launch, not by a billing backfill (Billing D6).
High-level domain diagram
Relations in the diagram
| Relation | Meaning |
|---|---|
Billing::InvoicePosting → Billing::LedgerEntry (grant) | Once Phase 2 UC-7 posts a paid invoice, the system writes a grant ledger entry and creates a Billing::EntitlementLot for it — this is UC-1's trigger. |
Billing::CreditAction → Billing::LedgerEntry (grant or adjust) | An admin instructs a free grant (trial_grant / goodwill_grant, writes a grant entry and creates a lot) or an expiry extension (expiry_extension, writes a zero-movement adjust entry and changes an existing lot's expires_at). |
Ads::CampaignPlacement → Billing::LedgerEntry (reserve / consume / release) | A placement's lifecycle drives the other three entry types: submission writes reserve (UC-2), the daily job writes consume (UC-3), and cancellation/rejection/auto-reject writes release (UC-4). |
Billing::Account → Billing::LedgerEntry (scopes account, dashed) | Every ledger entry belongs to exactly one account. Dashed because this is a reference/scoping relationship, not a write triggered by the account itself. |
Billing::Entitlement → Billing::LedgerEntry (defines policy for) | Every ledger entry references an Entitlement — the seeded row identifying which instrument type (e.g. Placement Credits) it's tracking, and confirming it uses lots / lot_based. |
Billing::LedgerEntry → Billing::EntitlementBalance (updates projection, same transaction) | Every ledger write — grant, reserve, consume, release, or expire — updates the account's balance projection (units_available, units_reserved) atomically. The balance is never the source of truth; it's always derived from the ledger. |
Billing::LedgerEntry → Billing::EntitlementLot (allocates, via EntitlementLotAllocation) | Every entry — grant, reserve, consume, release, expire, or adjust — records exactly which lot(s) it touched, and how many units, via one allocation row per lot. |
Billing::EntitlementLot → Billing::LedgerEntry (expire) | A daily job finds lots past their expires_at and writes an expire entry for whatever's left unreserved (UC-6). |
There is no hold node in this diagram. A placement's reserved credits are the net of its own
reserve/consume/release ledger entries, computed on read, not a stored row
(Billing D8).
What gets built
Credits are granted when an invoice is paid, or when an admin grants them free
- The moment a
Billing::InvoicePostingrecord exists (created in Phase 2 UC-7 when the invoice reaches:paid), the system writes agrantBilling::LedgerEntry, creates aBilling::EntitlementLotfor that purchase batch, and increments the company'sunits_available. - An admin can also grant credits with no invoice at all, through
Billing::CreditAction(action_type: :trial_grantor:goodwill_grant) — a domain-level mechanism, not Ads-specific. Same grant mechanics, $0 deferred revenue. It is not wired into company signup (see Assumptions). - No backfill grants credits for old Phase 2 postings. Billing has not shipped to production yet, so any invoice posted during Phase 2 with no matching ledger entry is manual test data — cleared, not caught up (see Billing D13).
- Ads already live in production before launch are handled by draining, not a backfill — see Assumptions.
Example: Company A's SG-INV-000042 (100 Placement Credits) is verified paid. A grant ledger
entry adds available_delta: +100; a new lot is created (units_purchased: 100,
deferred_revenue_total_cents = the invoice line's amount, expires_at = the purchase date plus
the product's validity_months); the balance's units_available goes from 0 to 100.
Ads campaign submission reserves credits, drawn from specific lots
- When a campaign is submitted for review, the system checks
units_availableacross all its placements. If there isn't enough, submission is blocked with a clear error. - If there's enough, the system walks the account's placement lots in spend order —
soonest-expiring lot first, lots with no expiry last, then oldest purchase first — and reserves
against as many as needed to cover the placement's cost. It writes a
reserveledger entry — credits move from available to reserved, and stay earmarked for that specific placement, with no separate hold row. Which lots (and how many units from each) is recorded viaBilling::EntitlementLotAllocation, and this composition is locked for the life of the reservation.
Example: A 7-day placement costing 7 credits reserves 7 credits: units_available -= 7,
units_reserved += 7. If the account's soonest-expiring lot only has 4 units left, the reserve
draws 4 from that lot and 3 from the next lot in spend order — two EntitlementLotAllocation rows,
one ledger entry.
Credits are consumed daily as the placement runs, recognised fresh each time
- A daily background job finds every active placement and consumes a day's worth of credit
computed from the campaign's total, not a flat rate —
round(cost × days elapsed ÷ duration) − consumed so far— decrementing the balance'sunits_reserved(Billing D5). - Revenue is recognised per lot, computed fresh from what remains on the lot at the moment of the
movement —
units_moved × deferred_revenue_remaining_cents ÷ (units_available + units_reserved)— not a rate fixed once at creation. This settles every lot to exactly zero with no rounding drift, by construction (Billing::EntitlementLot— The deferred revenue on a lot).
Example: Day 3 of the 7-day placement consumes 1 more credit, drawn from whichever lot(s) were
locked in at reserve time, soonest-expiring first: units_reserved -= 1, and that lot's current
remaining-revenue-per-unit becomes recognised revenue on that day's ledger entry.
Credits are released on cancel, rejection, or a 14-day stuck-campaign auto-reject, back to the lots they came from
- If the campaign is cancelled by the employer or rejected by an admin while it has not yet started
delivering, whatever's still reserved is released back to
units_availableimmediately — and returns to the specific lots it was drawn from. A campaign that has already started delivering cannot be cancelled; it always finishes in a consume, never a release ([Billing D12](/docs/30-49-domains/billing/billing-decisions #d12--a-running-service-cannot-be-cancelled-and-released-credits-get-no-extra-time-2026-08-14)). - A campaign that sits pending or unfinished for 14 days is auto-rejected by Ads and released the same way — this exists so an employer cannot park expiring credits inside a reservation forever (Billing D5).
- Units released into a lot that has since expired expire immediately, in the same transaction —
there is no waiting period or automatic extension. An admin can extend the lot beforehand, or
give a goodwill grant afterward, through
Billing::CreditAction(Billing D12).
Example: The placement above is rejected before it starts delivering, with all 7 credits still
reserved. Those 7 credits return to units_available, split back to the same lots recorded on the
original reserve.
Credits expire by default some months after purchase, without disrupting a running campaign
- A daily job finds lots past their
expires_atwith unreservedunits_availableleft, and writes anexpireledger entry for that remainder —available_deltagoes down, and the lot's remaining deferred revenue is recognised as breakage revenue. This is the shipped design (SFRS(I) 15's treatment of unredeemed stored value once redemption is judged remote); formal auditor sign-off on the accounting treatment is queued before the first Xero export, not a design question still being decided — see Xero Integration. - Units already reserved for a running campaign are not clawed back when their lot expires —
only the lot's still-unreserved
units_availableexpires. The campaign keeps consuming normally (Billing D2). - Warning emails go out at 30/7/3/1 day(s) before a lot expires, from a separate job so a mail
outage never delays breakage and a cleanup bug never stops warnings — see
billing_credit_expiry_noticesand Billing D3.
Example: A lot with 7 units left unreserved passes its expires_at. The daily expiry job
writes expire (available_delta: -7), recognises that lot's remaining deferred revenue as
breakage revenue, and the balance's units_available drops by 7.
End-to-End Flow
Assumptions
- Placement credits are the only instrument exercised in Phase 3. Gig credits already use the
same
lots/lot_basedpolicy (they always did), but gig's actual grant/reserve/consume logic — invoices with a:platform_feeline,Gig::Shiftas the source, and refunds (gig-only) — remains a later phase. This phase'sBilling::EntitlementLot/EntitlementLotAllocationmechanics are shared, generic infrastructure, not placement-specific code that gig will need to duplicate. Careers::Job(job posting) consuming placement credits is out of scope. Today's day-based trial period is untouched. The lot-based consume mechanics built here are designed so a futureCareers::Jobconsumer just calls consume once for the full amount instead of daily — no schema change anticipated, but it is not built or specified in this phase.- Free credit grants are admin-triggered only, not wired to signup.
Billing::CreditActionis usable from the Team Portal. Automatically granting free credits during company signup — replacing or supplementing today's day-based trial — is a later phase. - Outlet Budget is out of scope.
Billing::OutletBudgetonly partitions balances for outlet-scoped consumption, which today means gig credits only — placement credits are consumed by company-level campaigns, not outlet-scoped events. See Billing::OutletBudget § Entitlement Type Considerations. - Blending free and paid credit revenue is not a live design question. The lot mechanism itself
is the non-blended answer — a free lot always carries
deferred_revenue_total_cents: 0and never shares a rate with a paid lot. Reviving a blended, account-wide average would be new design work, not a pending decision this phase is waiting on. - SOA reporting views remain out of scope entirely, deferred to Phase 6 (carried over from Phase 2's assumptions).
- The daily consumption and expiry jobs' scheduling mechanism (cron cadence, retry behaviour) is an implementation detail — this doc specifies what the jobs must do, not how they're scheduled.
Open Questions
- Auditor sign-off on breakage revenue accounting treatment — queued before the first Xero export, not a design decision still open. See Xero Integration.
Where the full design record lives
This page no longer keeps its own "design deviations from DBML" log. main's billing.dbml is
now the current schema, not a deviation from anything documented here — and every change that
produced it is dated and reasoned on
Billing domain decisions D2 through D12. Read
that page for the full history; this page states current truth only.