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

1.0 Account Provisioning (Billing Account created when Company is created)

Scenario

A new Org::Company is created (e.g. via team onboarding). The billing system must be ready before any commercial activity can happen.

What happens automatically

When Org::Companies::Shared::CreateService creates a company, it calls Billing::Accounts::Shared::CreateService in the same transaction:

  1. Create billing_accounts row:

    • org_company_id = new company
    • status = active
    • uuid = generated
  2. Seed billing_entitlement_balances — one row per Billing::Entitlement (e.g. placement_credit, gig_credit_cents):

    • units_available = 0
    • units_reserved = 0

Tables touched

  • billing_accounts (insert)
  • billing_entitlement_balances (insert × N entitlement types)

Invariants

  • Exactly one billing_account per Org::Company (enforced by unique index on org_company_id).
  • Balance rows are seeded with zeros — no entitlements exist until a grant (via invoice posting).
  • No ledger entries are created at this stage.

Result

The company now has a billing account and zero-balance entitlement rows. Sales can proceed to create an agreement.


2.0 Service Agreement Creation (Sales creates a Billing Agreement with commercial terms)

Scenario

Before invoicing a company, the sales/business team establishes a service agreement that captures the negotiated commercial terms. For example:

  • Gig platform fee rate of 20% (2000 bps)
  • Placement credit unit price of $5.00 (500 cents)
  • A discount rate for enterprise clients

Prerequisites

  • billing_accounts must exist for the company (created in step 1.0).
  • An admin user (sales/ops) initiates the agreement via the team dashboard.

Step-by-step

Step 1 — Sales prepares the agreement

Sales uploads the signed service agreement document (stored in S3) and enters:

  • code (e.g. SG-SA-0001) — a human-readable agreement identifier
    • {Country Code}-SA-{Running Number}
  • document_url — path to the signed agreement PDF
  • effective_from / effective_to — optional, for time-limited agreements

Step 2 — Sales defines agreement terms

Each term is tied to a specific Billing::Entitlement (instrument) and captures one negotiated value:

term_keyterm_valueterm_unitExample meaning
fee_rate2000bpsGig platform fee = 20.00%
unit_price500centsPlacement credit = $5.00 each
discount_rate1000bps10% discount off list price

Step 3 — Agreement is created (single transaction)

Billing::Agreements::TeamCreateManager runs inside a DB transaction:

  1. Validate inputs (via TeamCreateValidator)
  2. Create billing_agreements row with admin audit trail (admin_created_by_id)
  3. Create billing_agreement_terms rows (nested attributes, one per instrument × term_key)

Tables touched

  • billing_agreements (insert)
  • billing_agreement_terms (insert × N terms)

Invariants

  • Each (agreement, entitlement, term_key) combination is unique (enforced by idx_agreement_terms_unique_per_instrument).
  • Agreement terms capture negotiated commercial values only (fee rates, prices, discounts) — not tax rates. Tax is determined by the seller's legal entity and product pricing at invoice time.
  • A company can have multiple agreements (e.g. renewed annually, or separate agreements per product).

What this does NOT do

  • Does not grant any entitlements — that only happens via invoice posting.
  • Does not determine tax — tax comes from billing_legal_entities.tax_regime + billing_product_prices.tax_code/tax_rate at invoice creation time.

Result

The company now has a signed agreement on file with negotiated terms. When sales creates an invoice, the agreement terms inform pricing (e.g. the gig platform fee rate from the agreement is used to compute the platform fee invoice item).


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

4.0 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"


4.1 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"


4.2 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)"


4.3 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"


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


4.5 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.0 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.1 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.2 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.3 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.