Skip to main content

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 CaseActorTrigger
UC-1Grant Entitlements on a Paid InvoiceSystem (auto-triggered)InvoicePosting record exists — write LedgerEntry + create a lot + balance write
UC-2Reserve Credits on Campaign SubmitSystem (via Ads domain)Ads::Campaign submitted for review (status → pending_review)
UC-3Daily Consume CreditsSystem (background job)Daily job runs for each Ads::CampaignPlacement in active status
UC-4Release Credits on Cancel / Reject / Auto-rejectSystem (via Ads domain)Campaign cancelled by employer, rejected by admin, or auto-rejected after 14 days stuck
UC-6Expire Entitlement LotsSystem (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​

FieldDetails
ActorSystem (extends Phase 2 UC-5 verify transaction)
TriggerBilling::InvoicePosting record exists (invoice is paid)
Source Use CaseBilling::EntitlementLot UC-1 — Create a lot when a paid invoice posts

Preconditions:

  • Billing::InvoicePosting record 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:

  1. Lock the invoice row (SELECT ... FOR UPDATE).
  2. Use the existing Billing::InvoicePosting record created by Phase 2's UC-7.
  3. For each placement :principal Billing::InvoiceLine on the invoice:
    1. Write one grant Billing::LedgerEntry:
      • available_delta: +line.units_to_grant
      • deferred_revenue_delta_cents: +line.amount_cents
      • source_type: "Billing::InvoicePosting", source_id: posting.id
    2. Create one Billing::EntitlementLot:
      • purchased_at: posting.posted_at
      • units_purchased: line.units_to_grant, units_available: line.units_to_grant
      • deferred_revenue_total_cents: line.amount_cents, deferred_revenue_remaining_cents: line.amount_cents
      • source_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_at plus billing_products.validity_months, end of that day in the company's timezone. Empty validity_months means the lot never expires.
    3. Write one Billing::EntitlementLotAllocation row 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).
  4. Update Billing::EntitlementBalance:
    • units_available += line.units_to_grant
    • deferred_revenue_cents += line.amount_cents
  5. 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_id continues 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::CreditAction instead — see the note after UC-6.

Postconditions:

  • Billing::LedgerEntry grant entry recorded against the existing InvoicePosting.
  • Billing::EntitlementLot created for this purchase batch, with its own expiry date.
  • Billing::EntitlementLotAllocation row links the grant entry to the lot.
  • Billing::EntitlementBalance updated — company can immediately spend credits.

UC-2: Reserve Placement Credits When Campaign Is Submitted​

FieldDetails
ActorSystem (via Ads domain)
TriggerAds::Campaign submitted for review (status → pending_review)
Source Use CaseBilling::EntitlementLot UC-4 — Pick lots for a reserve or a direct consume in the spend order

Preconditions:

  • Billing::Account exists for the company.
  • Billing::EntitlementBalance exists for placement.
  • units_available >= credit_cost_snapshot (sum across all placements in the campaign).
  • This Ads::CampaignPlacement has 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:

  1. Lock Billing::EntitlementBalance for (account, placement) FOR UPDATE.
  2. Verify units_available >= credit_cost_snapshot — if not, roll back and return an insufficient funds error.
  3. Select lots to draw from, in the spend order: expires_at ascending with lots that never expire last, then purchased_at ascending, then id (billing_entitlement_lots schema — the earliest-expiring lot is drawn first, regardless of whether it came from a paid invoice or a free Billing::CreditAction grant). Walk the list, skipping any lot with no units_available left, drawing from each until credit_cost_snapshot is fully covered — a single reservation can span multiple lots.
  4. Insert one Billing::LedgerEntry:
    • entry_type: :reserve
    • available_delta: -credit_cost_snapshot
    • reserved_delta: +credit_cost_snapshot
    • source_type: "Ads::CampaignPlacement", source_id: campaign_placement.id
  5. For each lot drawn from in step 3, insert one Billing::EntitlementLotAllocation row: the entry from step 4, this lot, the units drawn from it, and allocation_type: :reserve. Update that lot's units_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.
  6. Update Billing::EntitlementBalance:
    • units_available -= credit_cost_snapshot
    • units_reserved += credit_cost_snapshot

Business Rules:

  • There is no hold table. A placement's reserved credits are the net of the Billing::LedgerEntry rows 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.
  • EntitlementLotAllocation rows recorded per lot drawn from, locking this placement's lot composition.
  • EntitlementBalance.units_available decremented; EntitlementLot.units_available / units_reserved updated per lot touched.
  • Campaign proceeds to pending_review.

UC-3: Daily Consumption of Placement Credits (Campaign Running)​

FieldDetails
ActorSystem (background job)
TriggerDaily job, for each Ads::CampaignPlacement in active status
Source Use CaseBilling::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:

  1. Lock Billing::EntitlementBalance for (account, placement) FOR UPDATE.
  2. Compute today's units_to_consume from the campaign total, not from a fixed daily rate (Billing D5):
    units_to_consume = round(credit_cost_snapshot × days_elapsed ÷ duration_days) − consumed_so_far
    Read duration_days from campaign_placement.placement_price.duration_days — it is not a column on Ads::CampaignPlacement itself. consumed_so_far is the sum of this placement's past consume ledger entries. Whole numbers only; the days always sum to exactly credit_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.
  3. Draw units_to_consume from this placement's locked lot composition (the EntitlementLotAllocation rows from its :reserve entry, netted per lot against any prior :consume allocations) — soonest-dying lot first, not re-run through the general spend order. For each lot drawn from:
    recognised_from_lot = units_from_lot × lot.deferred_revenue_remaining_cents ÷ (lot.units_available + lot.units_reserved)
    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's deferred_revenue_remaining_cents to exactly zero as a side effect of the formula, with no special-case code (Billing::EntitlementLot — The deferred revenue on a lot).
  4. Insert one Billing::LedgerEntry:
    • entry_type: :consume
    • reserved_delta: -units_to_consume
    • recognised_revenue_cents: sum of recognised_from_lot across lots drawn from
    • deferred_revenue_delta_cents: -recognised_revenue_cents
    • source_type: "Ads::CampaignPlacement", source_id: campaign_placement.id
  5. For each lot drawn from, insert one Billing::EntitlementLotAllocation row: allocation_type: :consume, the units drawn from this lot, and this lot's recognised_from_lot. Update that lot's units_reserved -= N, units_consumed += N, deferred_revenue_remaining_cents -= recognised_from_lot.
  6. Update Billing::EntitlementBalance:
    • units_reserved -= units_to_consume
    • deferred_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.
  • EntitlementLotAllocation rows recorded per lot drawn from.
  • EntitlementBalance updated: units_reserved down, deferred_revenue_cents down.
  • EntitlementLot.units_reserved / units_consumed / deferred_revenue_remaining_cents updated per lot touched.

UC-4: Release Placement Credits on Campaign Cancel, Reject, or Auto-reject​

FieldDetails
ActorSystem (via Ads domain)
TriggerAds::Campaign cancelled by employer while pending, rejected by admin, or auto-rejected by Ads after sitting unfinished for 14 days (config value)
Source Use CaseBilling D5, Billing D12

Preconditions:

  • This Ads::CampaignPlacement has 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:

  1. Compute units_held — this placement's currently reserved credits, netted from its ledger entries.
  2. Lock Billing::EntitlementBalance FOR UPDATE.
  3. Insert one Billing::LedgerEntry:
    • entry_type: :release
    • available_delta: +units_held
    • reserved_delta: -units_held
    • source_type: "Ads::CampaignPlacement", source_id: campaign_placement.id
  4. Release back to this placement's locked lot composition — for each lot still holding reserved units from this placement's :reserve entry, return the remaining reserved units: units_reserved -= N. Insert Billing::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 to units_available (Billing D2, Billing D12). For a lot that has not expired: units_available += N.
  5. 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::CreditAction of type expiry_extension (Billing::CreditAction UC-2). After the release, an admin can instead give a goodwill_grant (Billing::CreditAction UC-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_available incremented by remaining held units (less any expired-lot write-off).
  • EntitlementBalance.units_reserved decremented.
  • EntitlementLot.units_available / units_reserved / units_expired updated 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​

FieldDetails
ActorSystem (background job)
TriggerNightly 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 CaseBilling::EntitlementLot UC-7 — Expire a lot's leftover units after its expiry date, Billing D3

Preconditions:

  • A lot exists with expires_at in the past and units_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).

  1. Find every Billing::EntitlementLot where expires_at has passed and units_available > 0.
  2. For each, in its own transaction (one bad row cannot block other companies):
    • Lock the account's Billing::EntitlementBalance FOR 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: :expire
      • available_delta: -expired_units, reserved_delta: 0
      • recognised_revenue_cents: +recognised and deferred_revenue_delta_cents: -recognised, where recognised = expired_units × lot.deferred_revenue_remaining_cents ÷ (lot.units_available + lot.units_reserved) — the same formula UC-3 uses. At this point expired_units is everything left in units_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_available was already 0), 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.

Business Rules:

  • Reserved units are honoured, not clawed back. Only units_available on 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_available reaches 0, 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_notices and Billing D3.

Postconditions:

  • Every lot past its expires_at has units_available = 0.
  • Any unreserved remainder is removed from units_available and 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: