Phase 3 — Use Cases
This document is the authoritative version of Phase 3's use cases. UC-1 through UC-4 were originally written in Phase 2's use-cases doc as the handoff contract for this phase, numbered UC-9 through UC-12 there — they are relocated and renumbered here, starting at UC-1 since this is Phase 3's own use-cases page.
Updated 2026-08-18. The mechanics below were rewritten a second time to match the final design
that shipped to main (jodapp-api PR #1938,
docs PR #106), which carried the 2026-08-06 CTO-call
design (below, and Billing domain decisions D2)
further through design sessions on 2026-08-10, 2026-08-12, and 2026-08-14 — see
Billing domain decisions D2 through D12 for the
complete, current ledger of what changed and why. The mechanics on this page state current truth
only; they do not repeat that history. Where a rule comes from a specific decision, this page links
to it instead of re-explaining it.
For the domain-level use cases these Ads use cases build on (how a lot is created, how the spend
order picks lots, how revenue is recognised), see
Billing::EntitlementLot's Use Cases and
Billing::LedgerEntry's Use Cases.
This page states only what is specific to Ads: which model triggers each movement, and the Ads-side
fields (credit_cost_snapshot, Ads::CampaignPlacement, Ads::PlacementPrice.duration_days).
Scope reminder: every use case below is placement credits only. Gig credits use the same
lot mechanism (billing-decisions.md D2), but gig's own grant/reserve/consume flow is a separate
phase.
Use Case Map
| # | Use Case | Actor | Trigger |
|---|---|---|---|
| UC-1 | Grant Entitlements on a Paid Invoice | System (auto-triggered) | InvoicePosting record exists — write LedgerEntry + create a lot + balance write |
| UC-2 | Reserve Credits on Campaign Submit | System (via Ads domain) | Ads::Campaign submitted for review (status → pending_review) |
| UC-3 | Daily Consume Credits | System (background job) | Daily job runs for each Ads::CampaignPlacement in active status |
| UC-4 | Release Credits on Cancel / Reject / Auto-reject | System (via Ads domain) | Campaign cancelled by employer, rejected by admin, or auto-rejected after 14 days stuck |
| UC-6 | Expire Entitlement Lots | System (background job) | Daily job runs; a lot's expires_at has passed with unreserved units left |
Three things that look like they should be use cases on this page are not, on purpose — see the notes after UC-4 and after UC-6.
UC-1: Grant Entitlements on a Paid Invoice
| Field | Details |
|---|---|
| Actor | System (extends Phase 2 UC-5 verify transaction) |
| Trigger | Billing::InvoicePosting record exists (invoice is paid) |
| Source Use Case | Billing::EntitlementLot UC-1 — Create a lot when a paid invoice posts |
Preconditions:
Billing::InvoicePostingrecord exists for the invoice (created in Phase 2 by UC-7)- Invoice
status = :paid
Models Touched: Billing::InvoicePosting, Billing::LedgerEntry, Billing::EntitlementLot, Billing::EntitlementLotAllocation, Billing::EntitlementBalance
System Behaviour:
- Lock the invoice row (
SELECT ... FOR UPDATE). - Use the existing
Billing::InvoicePostingrecord created by Phase 2's UC-7. - For each placement
:principalBilling::InvoiceLineon the invoice:- Write one
grantBilling::LedgerEntry:available_delta: +line.units_to_grantdeferred_revenue_delta_cents: +line.amount_centssource_type: "Billing::InvoicePosting",source_id: posting.id
- Create one
Billing::EntitlementLot:purchased_at: posting.posted_atunits_purchased: line.units_to_grant,units_available: line.units_to_grantdeferred_revenue_total_cents: line.amount_cents,deferred_revenue_remaining_cents: line.amount_centssource_type: "Billing::InvoiceLine",source_id: line.id— not the posting. The entry's source and the lot's source are two different records (Billing D7).expires_at: from the invoice line's product —purchased_atplusbilling_products.validity_months, end of that day in the company's timezone. Emptyvalidity_monthsmeans the lot never expires.
- Write one
Billing::EntitlementLotAllocationrow joining the two:allocation_type: :grant,units_allocated: line.units_to_grant,recognised_revenue_cents: NULL— without this row, nothing links the grant entry (sourced from the posting) to the lot it created (sourced from the line) (Billing D7).
- Write one
- Update
Billing::EntitlementBalance:units_available += line.units_to_grantdeferred_revenue_cents += line.amount_cents
- All of the above in a single DB transaction (extended from Phase 2 UC-7's verify transaction).
Business Rules:
- The posting record already exists from Phase 2's UC-7 — Phase 3 does not create it.
- The unique constraint on
InvoicePosting.billing_invoice_idcontinues to prevent double-granting. Each invoice can only be posted once, enforced at DB level. - One invoice line creates exactly one lot — never splits or merges lots across lines.
- Free/trial grants with no invoice behind them go through
Billing::CreditActioninstead — see the note after UC-6.
Postconditions:
Billing::LedgerEntrygrant entry recorded against the existingInvoicePosting.Billing::EntitlementLotcreated for this purchase batch, with its own expiry date.Billing::EntitlementLotAllocationrow links the grant entry to the lot.Billing::EntitlementBalanceupdated — company can immediately spend credits.
UC-2: Reserve Placement Credits When Campaign Is Submitted
| Field | Details |
|---|---|
| Actor | System (via Ads domain) |
| Trigger | Ads::Campaign submitted for review (status → pending_review) |
| Source Use Case | Billing::EntitlementLot UC-4 — Pick lots for a reserve or a direct consume in the spend order |
Preconditions:
Billing::Accountexists for the company.Billing::EntitlementBalanceexists forplacement.units_available >= credit_cost_snapshot(sum across all placements in the campaign).- This
Ads::CampaignPlacementhas no reserved credits yet — see Business Rules.
Models Touched: Billing::LedgerEntry, Billing::EntitlementLot, Billing::EntitlementLotAllocation, Billing::EntitlementBalance
System Behaviour:
For each Ads::CampaignPlacement in the submitted campaign:
- Lock
Billing::EntitlementBalancefor(account, placement)FOR UPDATE. - Verify
units_available >= credit_cost_snapshot— if not, roll back and return an insufficient funds error. - Select lots to draw from, in the spend order:
expires_atascending with lots that never expire last, thenpurchased_atascending, thenid(billing_entitlement_lotsschema — the earliest-expiring lot is drawn first, regardless of whether it came from a paid invoice or a freeBilling::CreditActiongrant). Walk the list, skipping any lot with nounits_availableleft, drawing from each untilcredit_cost_snapshotis fully covered — a single reservation can span multiple lots. - Insert one
Billing::LedgerEntry:entry_type: :reserveavailable_delta: -credit_cost_snapshotreserved_delta: +credit_cost_snapshotsource_type: "Ads::CampaignPlacement",source_id: campaign_placement.id
- For each lot drawn from in step 3, insert one
Billing::EntitlementLotAllocationrow: the entry from step 4, this lot, the units drawn from it, andallocation_type: :reserve. Update that lot'sunits_available -= N,units_reserved += N. This set of allocation rows is this placement's locked lot composition — UC-3 and UC-4 draw from exactly these lots, in this proportion, never re-run through the spend order. - Update
Billing::EntitlementBalance:units_available -= credit_cost_snapshotunits_reserved += credit_cost_snapshot
Business Rules:
- There is no hold table. A placement's reserved credits are the net of the
Billing::LedgerEntryrows naming it as their source — reserves add, consumes and releases subtract (Billing D8). "One active reservation per placement" is an application rule inside this flow, checked under the account's balance row lock — there is no database constraint for it. - If balance is insufficient, the campaign cannot be submitted — return a clear error to the employer.
- All placements in a campaign are reserved atomically; if any placement fails, the whole reserve fails.
- Lot selection is first-expiry-first-out, not plain first-in-first-out by purchase date. A lot that never expires sorts after every lot that does, so a company's oldest never-expiring paid lot is only drawn from once every expiring lot (including free trial lots) is exhausted.
- A lot with
units_available = 0, whether because it is fully drawn down or because it has already expired, is never drawn from.
Postconditions:
LedgerEntry(entry_type: :reserve)recorded per placement.EntitlementLotAllocationrows recorded per lot drawn from, locking this placement's lot composition.EntitlementBalance.units_availabledecremented;EntitlementLot.units_available/units_reservedupdated per lot touched.- Campaign proceeds to
pending_review.
UC-3: Daily Consumption of Placement Credits (Campaign Running)
| Field | Details |
|---|---|
| Actor | System (background job) |
| Trigger | Daily job, for each Ads::CampaignPlacement in active status |
| Source Use Case | Billing::LedgerEntry — Money fields, Billing D5 |
Preconditions:
Ads::CampaignPlacement.status = :active.- This placement has reserved credits (per UC-2, computed from the ledger — see UC-2's Business Rules).
Models Touched: Billing::LedgerEntry, Billing::EntitlementLot, Billing::EntitlementLotAllocation, Billing::EntitlementBalance
System Behaviour:
- Lock
Billing::EntitlementBalancefor(account, placement)FOR UPDATE. - Compute today's
units_to_consumefrom the campaign total, not from a fixed daily rate (Billing D5):Readunits_to_consume = round(credit_cost_snapshot × days_elapsed ÷ duration_days) − consumed_so_farduration_daysfromcampaign_placement.placement_price.duration_days— it is not a column onAds::CampaignPlacementitself.consumed_so_faris the sum of this placement's pastconsumeledger entries. Whole numbers only; the days always sum to exactlycredit_cost_snapshot; a re-run the same day consumes zero; a missed day is caught up by the next run. A normal day, run once and on time, can also consume zero — the rounding tracks the running total since the placement started, not each day in isolation, so a low daily rate (e.g. 4 credits over 7 days) can go several real days between whole-unit jumps. This is expected, not a missed run: the shortfall carries forward inside the running total and is picked up on whichever later day pushes it past the next whole number. See Billing D5 for the full reasoning and a worked example. - Draw
units_to_consumefrom this placement's locked lot composition (theEntitlementLotAllocationrows from its:reserveentry, netted per lot against any prior:consumeallocations) — soonest-dying lot first, not re-run through the general spend order. For each lot drawn from:Multiplying before dividing keeps the rounding error under one cent per movement — dividing first would strand cents. The final unit consumed from a lot always settles that lot'srecognised_from_lot = units_from_lot × lot.deferred_revenue_remaining_cents ÷ (lot.units_available + lot.units_reserved)deferred_revenue_remaining_centsto exactly zero as a side effect of the formula, with no special-case code (Billing::EntitlementLot— The deferred revenue on a lot). - Insert one
Billing::LedgerEntry:entry_type: :consumereserved_delta: -units_to_consumerecognised_revenue_cents: sum of recognised_from_lot across lots drawn fromdeferred_revenue_delta_cents: -recognised_revenue_centssource_type: "Ads::CampaignPlacement",source_id: campaign_placement.id
- For each lot drawn from, insert one
Billing::EntitlementLotAllocationrow:allocation_type: :consume, the units drawn from this lot, and this lot'srecognised_from_lot. Update that lot'sunits_reserved -= N,units_consumed += N,deferred_revenue_remaining_cents -= recognised_from_lot. - Update
Billing::EntitlementBalance:units_reserved -= units_to_consumedeferred_revenue_cents -= recognised_revenue_cents
Business Rules:
- Revenue recognition uses one formula for every lot, computed fresh from what is still on the lot
at the moment of the movement — not a fixed per-unit rate fixed at lot creation
(
Billing::EntitlementLot— The deferred revenue on a lot). - Recognised revenue amounts are stored on the ledger entry and the per-lot allocation rows at consumption time — never recomputed later.
- A delivering campaign cannot be cancelled. Once a placement has started consuming, it always ends in a consume, never a release (Billing D12).
- If the placement completes (all days run), no separate "close" step exists — the placement simply has zero reserved credits left, computed the same way as any other point in its life (Billing D8).
Postconditions:
LedgerEntry(entry_type: :consume)recorded with recognised revenue.EntitlementLotAllocationrows recorded per lot drawn from.EntitlementBalanceupdated:units_reserveddown,deferred_revenue_centsdown.EntitlementLot.units_reserved/units_consumed/deferred_revenue_remaining_centsupdated per lot touched.
UC-4: Release Placement Credits on Campaign Cancel, Reject, or Auto-reject
| Field | Details |
|---|---|
| Actor | System (via Ads domain) |
| Trigger | Ads::Campaign cancelled by employer while pending, rejected by admin, or auto-rejected by Ads after sitting unfinished for 14 days (config value) |
| Source Use Case | Billing D5, Billing D12 |
Preconditions:
- This
Ads::CampaignPlacementhas reserved credits (computed from the ledger, per UC-2). - The placement has not started delivering — a delivering placement cannot be released, only consumed (UC-3's Business Rules).
Models Touched: Billing::LedgerEntry, Billing::EntitlementLot, Billing::EntitlementLotAllocation, Billing::EntitlementBalance
System Behaviour:
For each Ads::CampaignPlacement in the cancelled/rejected campaign:
- Compute
units_held— this placement's currently reserved credits, netted from its ledger entries. - Lock
Billing::EntitlementBalanceFOR UPDATE. - Insert one
Billing::LedgerEntry:entry_type: :releaseavailable_delta: +units_heldreserved_delta: -units_heldsource_type: "Ads::CampaignPlacement",source_id: campaign_placement.id
- Release back to this placement's locked lot composition — for each lot still holding reserved
units from this placement's
:reserveentry, return the remaining reserved units:units_reserved -= N. InsertBilling::EntitlementLotAllocation(allocation_type: :release, units_allocated: N)per lot. A lot that has since expired follows the normal rule, with no softening: units released into it expire immediately, in the same transaction, instead of returning tounits_available(Billing D2, Billing D12). For a lot that has not expired:units_available += N. - Update
Billing::EntitlementBalance:units_available += units_held(minus any portion that expired immediately per step 4)units_reserved -= units_held
Business Rules:
- Credits are released in full — a placement that has not started delivering never partially consumes, so there is no partial release.
- Release is idempotent: if this placement has no reserved credits left, the operation is a no-op.
- Released units return to the specific lots they were reserved from — never to a different lot, even if that original lot is now depleted by other activity.
- There is no waiting period and no automatic extension for units released into an expired lot
— the employer is not made whole automatically
(Billing D12).
Before rejecting a campaign whose release would hit an expired lot, an admin can extend that lot
first via a
Billing::CreditActionof typeexpiry_extension(Billing::CreditActionUC-2). After the release, an admin can instead give agoodwill_grant(Billing::CreditActionUC-1). The Ads rejection screen states this consequence when it applies, so an admin can choose before rejecting, not after — this rule ships with the Ads campaign spec. - Stuck campaigns are Ads's job, not billing's. A campaign that sits pending or unfinished for 14 days (a config value) is rejected automatically. Reserved credits never expire on their own clock — this auto-reject exists specifically so an employer cannot park expiring credits inside a reservation forever (Billing D5).
Postconditions:
LedgerEntry(entry_type: :release)recorded per placement.EntitlementBalance.units_availableincremented by remaining held units (less any expired-lot write-off).EntitlementBalance.units_reserveddecremented.EntitlementLot.units_available/units_reserved/units_expiredupdated per lot touched.- Credits are immediately available for the company to use on a new campaign, unless they expired on release.
A note on the Phase 2 posting backfill — no longer needed
An earlier revision of this page had a UC-5, "Backfill Entitlement Grants for Pre-Phase-3
Postings," that granted entitlements once for any Billing::InvoicePosting row created before
Phase 3's grant logic existed. That use case is dropped.
Billing D13
decided this differently: billing has not shipped to production yet, so any posting missing a
grant today is manual test data from development or QA, not a real gap. jodapp-api clears that
test data with bin/rails billing:reset_phase_1_3_test_data instead of catching it up.
A note on ads already running in production — no backfill
An earlier revision of this page had a UC-14, "Backfill Billing for Ads Already Live in
Production," that granted and reserved credits retroactively for Ads::CampaignPlacement rows that
ran with no billing check at all. That use case is dropped.
Billing D6
decided the placement launch instead: some days before Phase 3 goes live, business stops accepting
new free campaigns; campaigns already running finish naturally; launch day has nothing left in
flight. No migration code exists for it, or is needed — this is an operational drain, not a billing
use case.
UC-6: Expire Entitlement Lots
| Field | Details |
|---|---|
| Actor | System (background job) |
| Trigger | Nightly job runs, shortly after midnight in the company's timezone; a Billing::EntitlementLot has expires_at in the past and units_available > 0 |
| Source Use Case | Billing::EntitlementLot UC-7 — Expire a lot's leftover units after its expiry date, Billing D3 |
Preconditions:
- A lot exists with
expires_atin the past andunits_available > 0.
Models Touched: Billing::EntitlementLot, Billing::LedgerEntry, Billing::EntitlementLotAllocation, Billing::EntitlementBalance
System Behaviour:
The lot has no status column — "expired" is a state read off the row (expires_at in the past),
not a flag the job sets
(Billing::EntitlementLot — Derived states).
- Find every
Billing::EntitlementLotwhereexpires_athas passed andunits_available > 0. - For each, in its own transaction (one bad row cannot block other companies):
- Lock the account's
Billing::EntitlementBalanceFOR UPDATE. expired_units = lot.units_available— only the unreserved remainder expires. Units currently reserved for a running campaign (lot.units_reserved) are left untouched; that campaign keeps consuming normally even though the lot's purchase-date expiry has passed (Billing D2).- Insert one
Billing::LedgerEntry:entry_type: :expireavailable_delta: -expired_units,reserved_delta: 0recognised_revenue_cents: +recognisedanddeferred_revenue_delta_cents: -recognised, whererecognised = expired_units × lot.deferred_revenue_remaining_cents ÷ (lot.units_available + lot.units_reserved)— the same formula UC-3 uses. At this pointexpired_unitsis everything left inunits_available, so this recognises the lot's full remaining balance (Billing::EntitlementLot— The deferred revenue on a lot).source_type: "Billing::EntitlementLot",source_id: lot.id
- Insert one
Billing::EntitlementLotAllocation(allocation_type: :expire, units_allocated: expired_units, recognised_revenue_cents: recognised). - Update the lot:
units_available = 0,units_expired += expired_units,deferred_revenue_remaining_cents -= recognised. - Update
Billing::EntitlementBalance:units_available -= expired_units,deferred_revenue_cents -= recognised. - If the whole lot is currently reserved (
units_availablewas already0), there is nothing to do — the query in step 1 already excludes it, and it needs no ledger entry until whatever is reserved is released or consumed.
- Lock the account's
Business Rules:
- Reserved units are honoured, not clawed back. Only
units_availableon the lot expires — a campaign in progress is never disrupted by a purchase-date technicality (Billing D2). - Expired deferred revenue is recognised as breakage revenue, not written off with no revenue recognised — this follows SFRS(I) 15's treatment of unredeemed stored value once redemption is judged remote. Auditor confirmation is queued before the first Xero export — see Xero Integration.
- Once units already reserved from an expired lot are later consumed or released (UC-3/UC-4),
their deferred revenue was already recognised at expiry time — those later entries recognise $0
additional revenue for the portion coming from an expired lot, and a release from an expired lot
does not return units to
units_available. - This job is idempotent by construction — once
units_availablereaches0, the trigger condition in step 1 no longer matches this lot, so it is never swept twice. - Warning emails at 30/7/3/1 day(s) before expiry are sent by a separate job, so a mail outage
never delays breakage and a cleanup bug never stops warnings — see
billing_credit_expiry_noticesand Billing D3.
Postconditions:
- Every lot past its
expires_athasunits_available = 0. - Any unreserved remainder is removed from
units_availableand its deferred revenue recognised as breakage revenue. - Reserved units from expired lots continue their normal consume/release lifecycle uninterrupted.
A note on free credits without an invoice
An earlier revision of this page had a UC-16, "Grant Credits Without an Invoice," describing a
Billing::CreditGrant model scoped to this phase. That model does not exist — it was replaced
during design by Billing::CreditAction, a domain-level mechanism (not Ads-specific) covering
three admin instructions: trial_grant, goodwill_grant, and expiry_extension. Placement credits
are one of the instruments it can grant. The full use cases live on the model's own spec page: