Use Cases and How Models Interact
Below, each use case shows:
- which models/tables are touched
- the ledger entries inserted
- how balances/holds/lots change
5.0 Purchase flow (Invoice → Bank Transfer → Verification → Posting → Entitlements Granted)
Scenario
An Org::Company (Singapore) wants to buy:
- 1,000 Placement Credits (sold as “Visibility Credits” in UI) to run sponsored placements / job posting / boosts, or
- 1,000 Gig Credits (stored value) with a negotiated platform fee rate.
Step-by-step (models interacting)
Step 1 — Choose an offer (pricing row)
Input: org_company.country_id (you already store this), desired product, desired quantity.
Billing query:
- Find
billing_productsby code (e.g.placement_credits) - Find a matching
billing_product_pricesfor:country_id = org_company.country_idactive_from/active_until(if used)- optional: tier / negotiated pricing
Gotcha: Do not hard-code pricing into code. ProductPrice has the data.
Step 2 — Generate invoice
Create billing_invoices (draft → issued) and billing_invoice_items.
Invoice item must snapshot:
- unit price, tax, total
- entitlement type
- units_to_grant
- gig: platform fee rate terms (bps) and principal vs platform fee breakdown
Step 3 — Customer pays via bank transfer (offline)
Option A (recommended operationally): business team records a payment row when they receive proof:
- create
billing_paymentswithstatus=submittedand proof.
Option B: create billing_payments immediately upon invoice issuance (pending), then update.
Either is fine as long as:
- invoice stays
issueduntil payment is verified.
Step 4 — Business team verifies bank transfer(s)
When finance confirms money received (one or more transfers):
- update the relevant
billing_payments.status = verified - recompute
verified_total = sum(billing_payments.amount_cents where status=verified)- if
verified_total == 0→ invoice staysissued - if
0 < verified_total < invoice.total_cents→ setbilling_invoices.status = partially_paid - if
verified_total >= invoice.total_cents→ setbilling_invoices.status = paidand setsettled_at = now
- if
Policy: Credits are granted only after the invoice is paid. Partial payments do not grant partial credits.
Step 5 — Post the invoice (grant entitlements; idempotent)
In the same DB transaction:
- Lock the invoice row (
SELECT ... FOR UPDATE) - Create
billing_invoice_postings(unique by invoice id) - For each invoice item:
- write one or more
billing_ledger_entriesto grant units + deferred revenue deltas - if entitlement policy is lot-based (gig): create
billing_entitlement_lotsusing snapshot terms
- write one or more
Then update projections:
billing_entitlement_balances- (gig) lots remaining / fee deferred remaining
Result: the company can immediately spend entitlements.
Why the posting record is important
It gives you:
- hard idempotency (cannot double-grant if admin clicks verify twice)
- an audit hook (“who posted this invoice, when?”)
- a clean pivot for finance exports (“posted entitlements today”)
SOA impact
Because the posting writes to the ledger, the Statement of Account generation remains simple:
- “Top-ups” are just ledger entries with
entry_kind=grant - “Usage” is ledger entries with
entry_kind=consume - Reservations are ledger entries with
entry_kind=reserve/release
5.1 Placement Credits: Grant (top-up) after payment verified
Scenario
Company buys a Placement Credits package (shown as “Visibility Credits” in UI):
- +100
placement_credit - +$500 deferred revenue
Steps
-
Billing service called:
GrantEntitlements -
Lock
billing_entitlement_balancesfor(account, visibility_credit) -
Insert ledger entry:
entry_type = grantavailable_delta = +100deferred_revenue_delta_cents = +50000
-
Update balance projection:
units_available += 100deferred_revenue_cents += 50000
Statement line
“Purchased Visibility Credits +100”
5.2 Placement Credits: Reserve for an Ads campaign placement (visibility promise)
Scenario
Ads books a homepage placement for 14 days. You reserve upfront so they can’t spend the credits elsewhere.
Steps
-
Ads creates campaign line item (e.g.
ads_campaign_placements.id = 999) -
Ads calls Billing:
ReserveEntitlements(reference: Ads::CampaignPlacement#999, units: 14) -
Billing locks placement balance row
-
Ensure
units_available >= 14 -
Insert ledger entry:
entry_type = reserveavailable_delta = -14reserved_delta = +14reference_type='Ads::CampaignPlacement',reference_id=999
-
Upsert hold projection:
- status
active units_held = 14opened_ledger_entry_id = <reserve_entry_id>
- status
-
Update balance:
- available -= 14
- reserved += 14
Statement lines
“Reserved 14 Visibility Credits for CampaignPlacement #999”
5.3 Placement Credits: Daily consumption (deliver service + recognize revenue)
Scenario
Each day the campaign runs, consume 1 credit from reserved. At the same time, recognize revenue proportionally.
Revenue recognition rule (pooled proportional)
At consume time:
recognized = units_consumed × (deferred_revenue_before / pool_units_before)
Where:
pool_units_before = balance.units_available + balance.units_reserveddeferred_revenue_before = balance.deferred_revenue_cents
Steps (daily job)
-
Scheduler triggers:
ConsumeEntitlements(reference: Ads::CampaignPlacement#999, units: 1) -
Lock balance row
-
Confirm hold is active and has units held
-
Compute snapshots:
pool_units_before = available + reserveddeferred_revenue_before_cents = deferred_revenue
-
Compute recognized:
recognized = 1 * deferred_revenue_before / pool_units_before- use integer math with rounding rule you choose (recommend: round half up to cents)
-
Insert ledger entry:
entry_type = consumereserved_delta = -1recognized_revenue_cents = recognizeddeferred_revenue_delta_cents = -recognized- snapshot fields:
pool_units_before,pool_deferred_revenue_before_cents - reference to campaign placement
-
Update balance projection:
- reserved -= 1
- deferred_revenue -= recognized
-
Update hold projection:
- units_held -= 1
- if units_held becomes 0: mark hold consumed, closed_at
Statement line
“Consumed 1 Placement Credit for CampaignPlacement #999 (recognized $X.XX)”
5.4 Placement Credits: Cancel campaign and release remaining reservation
Scenario
Campaign canceled with 5 days remaining. Release held credits back to available.
Steps
-
Ads calls Billing:
ReleaseHold(reference: Ads::CampaignPlacement#999) -
Find active hold with units_held=5
-
Lock balance row
-
Insert ledger entry:
entry_type = releaseavailable_delta = +5reserved_delta = -5
-
Update balance
-
Mark hold as released
Statement line
“Released 5 Visibility Credits for CampaignPlacement #999”
5.5 Careers Job Posting: consume credits (two pricing modes)
Pricing mode A: per job posting
At job publish:
- reserve (optional) then consume immediately
Recommended: consume immediately (no need to reserve unless you have multi-step approvals).
Ledger entry:
consumewithavailable_delta=-Xorreserved_delta=-X- reference:
Careers::Job
Pricing mode B: per application
At application creation:
- consume 1 credit per application
- reference:
Careers::JobApplication
This is exactly why we keep the ledger generic: pricing changes don’t require schema changes.
5.6 Job Boost: reserve then daily consume (same as Ads)
Boost is a “visibility placement” product. Treat the boosted listing as a reference:
reference_type='Listings::Boost',reference_id=<boost_id>
Reserve N days upfront, consume 1/day.
5.7 Gig: Grant credits (top-up) and create FIFO lot
Scenario
Company buys:
- $100.00 gig credits (10000 cents)
- platform fee rate 20% → $20.00 deferred platform fee
Steps
-
Billing locks gig balance
-
Insert ledger entry:
entry_type=grantavailable_delta=+10000(cents)platform_fee_deferred_delta_cents=+2000
-
Create lot:
units_purchased=10000units_available=10000units_reserved=0platform_fee_rate_bps=2000platform_fee_remaining_cents=2000
-
Update balance projection:
- available += 10000
- platform_fee_deferred += 2000
Statement line
“Purchased Gig Credits $100.00 (+ platform fee deferred $20.00)”
5.8 Gig: Reserve credits when posting a shift (may span lots)
Scenario
Posting a gig shift reserves estimated wage value, e.g. 1800 cents ($18.00).
Steps
-
Gig calls Billing:
ReserveEntitlements(reference: Gig::Shift#123, units: 1800) -
Lock gig balance row
-
Lock lots with
units_available > 0FIFO order -
Allocate 1800 across lots FIFO:
- Lot A take 1000
- Lot B take 800
-
Insert ledger entry:
entry_type=reserveavailable_delta=-1800reserved_delta=+1800- reference shift
-
Insert allocations:
- (reserve, lot A, 1000)
- (reserve, lot B, 800)
-
Update lots:
- lot A
units_available -=1000,units_reserved +=1000 - lot B
units_available -=800,units_reserved +=800
- lot A
-
Update balance:
- available -=1800, reserved +=1800
-
Create hold projection:
- units_held=1800, status active
Statement line
“Reserved $18.00 Gig Credits for Shift #123”
5.9 Gig: Complete shift (consume actual wage, handle differences vs reserved)
Scenario
Reserved 1800 cents earlier. Actual wage payable becomes 1750 cents due to deductions/adjustments.
Recommended billing behavior
- Consume actual amount (1750) from reserved
- Release remainder (50) back to available This keeps statements clean and mirrors reality.
Steps
-
Gig calls Billing:
CompleteShift(reference: Gig::Shift#123, actual_units: 1750, metadata: insurance...) -
Find active hold units_held=1800
-
Lock gig balance + related lots (based on hold allocations)
-
Consume from lots that were reserved (proportionally to reserved allocations FIFO):
- Lot A consume 1000
- Lot B consume 750 (of its 800 reserved)
-
Compute platform fee recognized per allocation:
- per lot:
fee_recognized = (consumed_cents * rate_bps) / 10_000
- per lot:
-
Insert ledger entry (consume):
entry_type=consumereserved_delta=-1750platform_fee_recognized_cents=<sum fee recognized>
-
Insert allocation rows (consume):
- lot A consume 1000, fee recognized
- lot B consume 750, fee recognized
-
Update lots:
- lot A
units_reserved -=1000 - lot B
units_reserved -=750 - reduce lot
platform_fee_remaining_centsby recognized portion (based on consumed)
- lot A
-
Update balance:
- reserved -=1750
- platform_fee_deferred -= fee_recognized (optional cache update)
-
Insert ledger entry (release remainder 50):
entry_type=releaseavailable_delta=+50,reserved_delta=-50
- Update lots for release remainder:
-
release remaining 50 back to the lot it was reserved from (lot B):
- lot B
units_reserved -=50,units_available +=50
- lot B
- Close hold: consumed/released
Statement lines
- “Consumed $17.50 Gig Credits for Shift #123”
- “Released $0.50 Gig Credits for Shift #123”
5.10 Gig: Cancel shift before completion (release reservation)
Steps
-
Find active hold for shift
-
Insert ledger entry release:
available_delta=+held,reserved_delta=-held
-
Update allocations: release units back into lots (reverse of reserve allocations)
-
Update lots: reserved → available
-
Close hold
6) Statements of Account (SOA)
6.1 The SOA contract (what we want)
For a given company:
- Show a chronological list of ledger entries
- Group by reference when needed (e.g. shift/campaign)
- Provide running balances
- Provide totals within period
6.2 SOA query pattern (single ledger = simple)
Gig SOA
Filter by:
- account_id
- entitlement_type =
gig_credit_cents - date range
Order by occurred_at ASC, id ASC.
Placement Credits usage report
Same, with entitlement type = placement_credit.
Running balance calculation
Option A (fast): use projections + reconstruct running changes from ledger lines in memory. Option B (SQL window): use window sums if you really need it in SQL.
Recommendation for MVP: do it in Ruby:
- fetch rows ordered
- running_available += available_delta
- running_reserved += reserved_delta
6.3 SOA formatting guidance
Ledger entry should render as:
- timestamp
- action label (grant/reserve/consume/release/adjust)
- units change
- money change (recognized revenue / deferred changes)
- reference label (Shift #123, CampaignPlacement #999)
- metadata (insurance, notes)
This is why ledger is the canonical statement source.