Skip to main content

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_products by code (e.g. placement_credits)
  • Find a matching billing_product_prices for:
    • country_id = org_company.country_id
    • active_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 (draftissued) 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_payments with status=submitted and proof.

Option B: create billing_payments immediately upon invoice issuance (pending), then update.

Either is fine as long as:

  • invoice stays issued until 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 stays issued
    • if 0 < verified_total < invoice.total_cents → set billing_invoices.status = partially_paid
    • if verified_total >= invoice.total_cents → set billing_invoices.status = paid and set settled_at = now

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:

  1. Lock the invoice row (SELECT ... FOR UPDATE)
  2. Create billing_invoice_postings (unique by invoice id)
  3. For each invoice item:
    • write one or more billing_ledger_entries to grant units + deferred revenue deltas
    • if entitlement policy is lot-based (gig): create billing_entitlement_lots using snapshot terms

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

  1. Billing service called: GrantEntitlements

  2. Lock billing_entitlement_balances for (account, visibility_credit)

  3. Insert ledger entry:

    • entry_type = grant
    • available_delta = +100
    • deferred_revenue_delta_cents = +50000
  4. Update balance projection:

    • units_available += 100
    • deferred_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

  1. Ads creates campaign line item (e.g. ads_campaign_placements.id = 999)

  2. Ads calls Billing: ReserveEntitlements(reference: Ads::CampaignPlacement#999, units: 14)

  3. Billing locks placement balance row

  4. Ensure units_available >= 14

  5. Insert ledger entry:

    • entry_type = reserve
    • available_delta = -14
    • reserved_delta = +14
    • reference_type='Ads::CampaignPlacement', reference_id=999
  6. Upsert hold projection:

    • status active
    • units_held = 14
    • opened_ledger_entry_id = <reserve_entry_id>
  7. 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_reserved
  • deferred_revenue_before = balance.deferred_revenue_cents

Steps (daily job)

  1. Scheduler triggers: ConsumeEntitlements(reference: Ads::CampaignPlacement#999, units: 1)

  2. Lock balance row

  3. Confirm hold is active and has units held

  4. Compute snapshots:

    • pool_units_before = available + reserved
    • deferred_revenue_before_cents = deferred_revenue
  5. Compute recognized:

    • recognized = 1 * deferred_revenue_before / pool_units_before
    • use integer math with rounding rule you choose (recommend: round half up to cents)
  6. Insert ledger entry:

    • entry_type = consume
    • reserved_delta = -1
    • recognized_revenue_cents = recognized
    • deferred_revenue_delta_cents = -recognized
    • snapshot fields: pool_units_before, pool_deferred_revenue_before_cents
    • reference to campaign placement
  7. Update balance projection:

    • reserved -= 1
    • deferred_revenue -= recognized
  8. 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

  1. Ads calls Billing: ReleaseHold(reference: Ads::CampaignPlacement#999)

  2. Find active hold with units_held=5

  3. Lock balance row

  4. Insert ledger entry:

    • entry_type = release
    • available_delta = +5
    • reserved_delta = -5
  5. Update balance

  6. 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:

  • consume with available_delta=-X or reserved_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

  1. Billing locks gig balance

  2. Insert ledger entry:

    • entry_type=grant
    • available_delta=+10000 (cents)
    • platform_fee_deferred_delta_cents=+2000
  3. Create lot:

    • units_purchased=10000
    • units_available=10000
    • units_reserved=0
    • platform_fee_rate_bps=2000
    • platform_fee_remaining_cents=2000
  4. 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

  1. Gig calls Billing: ReserveEntitlements(reference: Gig::Shift#123, units: 1800)

  2. Lock gig balance row

  3. Lock lots with units_available > 0 FIFO order

  4. Allocate 1800 across lots FIFO:

    • Lot A take 1000
    • Lot B take 800
  5. Insert ledger entry:

    • entry_type=reserve
    • available_delta=-1800
    • reserved_delta=+1800
    • reference shift
  6. Insert allocations:

    • (reserve, lot A, 1000)
    • (reserve, lot B, 800)
  7. Update lots:

    • lot A units_available -=1000, units_reserved +=1000
    • lot B units_available -=800, units_reserved +=800
  8. Update balance:

    • available -=1800, reserved +=1800
  9. 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.

  • Consume actual amount (1750) from reserved
  • Release remainder (50) back to available This keeps statements clean and mirrors reality.

Steps

  1. Gig calls Billing: CompleteShift(reference: Gig::Shift#123, actual_units: 1750, metadata: insurance...)

  2. Find active hold units_held=1800

  3. Lock gig balance + related lots (based on hold allocations)

  4. Consume from lots that were reserved (proportionally to reserved allocations FIFO):

    • Lot A consume 1000
    • Lot B consume 750 (of its 800 reserved)
  5. Compute platform fee recognized per allocation:

    • per lot: fee_recognized = (consumed_cents * rate_bps) / 10_000
  6. Insert ledger entry (consume):

    • entry_type=consume
    • reserved_delta=-1750
    • platform_fee_recognized_cents=<sum fee recognized>
  7. Insert allocation rows (consume):

    • lot A consume 1000, fee recognized
    • lot B consume 750, fee recognized
  8. Update lots:

    • lot A units_reserved -=1000
    • lot B units_reserved -=750
    • reduce lot platform_fee_remaining_cents by recognized portion (based on consumed)
  9. Update balance:

    • reserved -=1750
    • platform_fee_deferred -= fee_recognized (optional cache update)
  10. Insert ledger entry (release remainder 50):

  • entry_type=release
  • available_delta=+50, reserved_delta=-50
  1. 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
  1. 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

  1. Find active hold for shift

  2. Insert ledger entry release:

    • available_delta=+held, reserved_delta=-held
  3. Update allocations: release units back into lots (reverse of reserve allocations)

  4. Update lots: reserved → available

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