Skip to main content

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.

Updated 2026-08-18

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_balances rows 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.md documents "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:

  1. The ledger is the source of truth. Billing::LedgerEntry is 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.
  2. 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.
  3. 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.
  4. 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:

  1. A paid invoice's credits become spendable the moment it posts.
  2. 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.
  3. An Ads campaign cannot submit without enough available credits.
  4. 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.
  5. Credits expire by default some months after purchase (set per product), without disrupting a campaign that's already running against them.
  6. Cancelling or rejecting a campaign returns unused credits immediately.
  7. 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).
  8. 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​

Phase 3 domain diagram

Relations in the diagram​

RelationMeaning
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::InvoicePosting record exists (created in Phase 2 UC-7 when the invoice reaches :paid), the system writes a grant Billing::LedgerEntry, creates a Billing::EntitlementLot for that purchase batch, and increments the company's units_available.
  • An admin can also grant credits with no invoice at all, through Billing::CreditAction (action_type: :trial_grant or :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_available across 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 reserve ledger 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 via Billing::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's units_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_available immediately — 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_at with unreserved units_available left, and writes an expire ledger entry for that remainder — available_delta goes 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_available expires. 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_notices and 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_based policy (they always did), but gig's actual grant/reserve/consume logic — invoices with a :platform_fee line, Gig::Shift as the source, and refunds (gig-only) — remains a later phase. This phase's Billing::EntitlementLot / EntitlementLotAllocation mechanics 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 future Careers::Job consumer 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::CreditAction is 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::OutletBudget only 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: 0 and 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​

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