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 and 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, :gig):

    • 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
  • starts_at / ends_at — 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 line).


3.0 Admin-initiated Purchase Flow (Invoice → Payment → Posting)​

This section describes the admin-initiated purchase flow where a sales or ops team member creates the invoice on behalf of a company. This is used for:

  • Enterprise deals and custom pricing that require sales involvement
  • Purchases above the self-serve threshold (e.g., SGD 3,000)
  • Any scenario where the admin needs to review or customise the invoice before issuing

For employer self-serve purchase flows (where the employer browses packages and the system generates the invoice automatically), see sections 3.1–3.3.

Scenario​

Actor: Identities::Admin (sales/ops)

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.

The admin creates and reviews the invoice before issuing it to the customer.

Step-by-step (models interacting)​

Step 1 — Admin selects product and resolves pricing​

Admin selects the billing account, product, and quantity. The system resolves the applicable price:

  • Find billing_products by their Billing::Entitlement.instrument (e.g. :placement, :gig) and admin-selected name
  • Find a matching billing_product_prices for:
    • billing_legal_entity.country_id = org_company.country_id (joined via billing_legal_entity_id)
    • status = :active (and parent product status = :active)
    • If billing_account_id is set on a price, it is a private price for that specific client — admin can choose it for that account (private overrides standard during resolution)
  • If the company has an active Billing::Agreement, the system retrieves the relevant terms (fee rates, discounts, unit prices)

Pricing always comes from ProductPrice — never hard-code prices into application code.

Step 2 — Admin generates invoice in draft​

Admin creates billing_invoices in draft status and billing_invoice_lines.

The admin can review and edit the draft before issuing (see Invoice UC-2, UC-3).

Invoice lines must copy these values at creation time:

  • unit price, tax, total
  • entitlement type
  • units_to_grant
  • gig: platform fee rate (from agreement) and principal vs platform fee breakdown

Once the admin is satisfied, they issue the invoice (draft → issued). The invoice is now immutable.

Key difference from self-serve (3.1–3.3): Admin-initiated invoices start as draft for review. Self-serve invoices go straight to issued because the system generates them automatically with no admin review needed.

Step 3 — Customer pays via bank transfer (offline)​

The customer receives the issued invoice and pays via bank transfer. The business team records the payment when they receive proof:

  • Create billing_payments with status = submitted, amount_cents, bank_reference, and proof_s3_path

The invoice stays issued until payment is verified.

Step 4 — Finance verifies bank transfer(s)​

When finance confirms money received in the bank account:

  • Update 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 → billing_invoices.status = partially_paid
    • If verified_total >= invoice.total_cents → billing_invoices.status = paid and 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 — prevents double-granting)
  3. For each principal invoice line:

Then update projections:

  • billing_entitlement_balances
  • the new lot's counters and deferred revenue remaining

Result: The company can immediately spend their entitlements.

Why the posting record is important​

  • Idempotency — the unique constraint on billing_invoice_postings.billing_invoice_id prevents double-granting if someone clicks verify twice
  • Audit trail — records who posted the invoice and when
  • Finance exports — clean pivot for "entitlements posted today" reports

SOA impact​

Because posting writes to the ledger, Statement of Account generation stays simple:

  • "Top-ups" are ledger entries with entry_type = grant
  • "Usage" is ledger entries with entry_type = consume
  • "Reservations" are ledger entries with entry_type = reserve / release

Note: Steps 3–5 above (payment, verification, and posting) are shared by both admin-initiated and self-serve flows. Sections 3.1–3.3 reference these steps rather than repeating them.


3.1 Self-serve Gig Credits Purchase (new company, no agreement)​

Scenario​

A new Org::Company in Singapore wants to purchase gig credits for the first time. They do not have a Billing::Agreement yet. The employer browses available packages in their dashboard, accepts the service terms, and the system auto-generates both the agreement and the invoice.

Prerequisites​

  • billing_accounts must exist for the company (created in step 1.0).
  • The company does NOT have an active Billing::Agreement with a fee_rate term for gig.
  • Active Billing::ProductPrice rows exist for gig products in the company's country.
  • A Billing::LegalEntity exists for the company's country (determines currency, tax rules, and the Jod entity selling the service).

Step-by-step​

Step 1 — Employer browses gig credit packages​

The employer navigates to the Gig section in their dashboard. The system shows available gig credit packages:

  1. Resolve the company's country via Org::Company.country_id
  2. Find active standard Billing::ProductPrice rows where:
    • billing_product.billing_entitlement.instrument = :gig
    • billing_account_id IS NULL (standard catalog prices only — private client-specific prices are excluded from the employer dashboard)
    • billing_legal_entity.country_id matches the company's country (joined via billing_legal_entity_id)
    • status = :active
    • Parent product status = :active
  3. For each package, display:
    • Product name and credit amount (e.g., "100 Gig Credits")
    • Credit price (from unit_price_cents)
    • Platform fee (calculated from Billing::ProductPrice.platform_fee_rate_bps — the list rate, e.g. 30%)
    • Tax (from tax_code / tax_rate via Billing::LegalEntity.tax_regime)
    • Total price
  4. If the package total exceeds the self-serve threshold for that currency (e.g., SGD 3,000), it is not shown as self-serve — instead show a "Contact Sales" / "Enterprise" option

Note: The SGD 3,000 threshold is a current business decision and may change. The threshold is currency-specific — different markets may have different thresholds. The Billing::LegalEntity linked to the ProductPrice determines the currency.

Example display for Singapore (SGD, 30% list platform fee, 9% GST on platform fee):

PackageCreditsPricePlatform Fee (30%)GST (9% on fee)Total
A100 Gig Credits ($1.00)$100$30$2.70$132.70
B1,000 Gig Credits ($10.00)$1,000$300$27.00$1,327.00
EnterpriseCustomContact Sales———

Step 2 — Employer selects a package​

  • If employer selects a self-serve package (A or B): proceed to Step 3
  • If employer selects "Contact Sales" / "Enterprise": sales team is notified (e.g. via email or internal notification), and the flow ends here. Sales will follow up manually.

Step 3 — Employer accepts service terms​

After the employer selects a package (this is an indicator of purchase interest):

  1. System displays the service agreement terms, including:
    • The platform fee rate (e.g., "Jod charges a 30% platform fee on gig credit usage")
    • Terms of service for using the gig platform
    • A downloadable copy of the agreement document
  2. Employer checks the "I agree to the terms" checkbox
  3. If the employer does not accept, the flow ends — no agreement or invoice is created

Future enhancement: Replace the checkbox with legally binding digital signatures (e.g., DocuSign or Google Docs eSignature API).

Step 4 — System auto-generates agreement​

Once the employer accepts terms, the system creates a Billing::Agreement in a single transaction:

  1. Create billing_agreements row:
    • code — auto-generated (e.g., SG-SA-AUTO-{running_number})
    • document_url — path to the generated agreement document (PDF stored in S3)
    • starts_at = now
    • ends_at = null (open-ended)
  2. Create billing_agreement_terms row:
    • billing_entitlement_id = gig entitlement
    • term_key = fee_rate
    • term_value = the list platform fee rate from ProductPrice.platform_fee_rate_bps (e.g., 3000 for 30%)
    • term_unit = bps
  3. Agreement document is emailed to the employer

Step 5 — System generates invoice​

Immediately after the agreement is created, the system generates the invoice:

  1. Create billing_invoices with status = issued (not draft — no admin review needed for self-serve):
    • billing_legal_entity_id from the ProductPrice's legal entity
    • billing_account_id for the company
    • invoice_number generated from Billing::LegalEntity.invoice_number_sequence
    • currency from the legal entity / bill-to profile
    • Snapshot bill-to fields from Billing::BillToProfile
  2. Create billing_invoice_lines:
    • Line 1 (Gig Credits): quantity, unit_price_cents, amount_cents, tax_cents = 0 (principal is tax-exempt for gig), units_to_grant
    • Line 2 (Platform Fee): platform fee amount calculated from the agreement's fee_rate, tax_cents = fee × tax_rate (e.g., 9% GST), units_to_grant = 0
  3. Compute invoice totals: subtotal_cents, tax_cents, total_cents
  4. Invoice is emailed to the currently logged-in employer user

Step 6 onwards — Payment and posting (same as 3.0 Steps 3–5)​

  • Employer downloads the invoice and sends it to their finance team for payment
  • Finance pays via bank transfer
  • Jod business team records payment proof (billing_payments, status = submitted)
  • Jod finance verifies the payment → invoice becomes paid
  • System posts the invoice → entitlements granted (gig credits available, one lot created)

Tables touched​

  • billing_agreements (insert)
  • billing_agreement_terms (insert)
  • billing_invoices (insert)
  • billing_invoice_lines (insert × 2: principal + platform fee)

(Payment, verification, and posting tables are same as 3.0)

Invariants​

  • Self-serve is only available for packages at or below the self-serve threshold for that currency (e.g., SGD 3,000). Above that threshold, the employer must contact sales.
  • The agreement must be created and accepted BEFORE the invoice is generated. If the employer rejects the terms, no invoice is created.
  • The auto-generated agreement uses the list platform fee rate from ProductPrice.platform_fee_rate_bps — not a negotiated rate.
  • The invoice goes directly to issued status (not draft). There is no admin review step for self-serve purchases.
  • The agreement document and invoice are emailed separately.

Result​

The company now has:

  • A Billing::Agreement with the list platform fee rate
  • An issued invoice ready for payment
  • Once paid and posted: gig credits available in their balance

3.2 Self-serve Gig Credits Top-up (existing company with agreement)​

Scenario​

An existing Org::Company that already has a Billing::Agreement (with a negotiated or list platform fee rate) wants to top up their gig credits. They can enter a custom amount of gig cents they want to purchase.

Prerequisites​

  • billing_accounts must exist for the company.
  • An active Billing::Agreement exists with a fee_rate term for gig.
  • Active Billing::ProductPrice rows exist for gig products in the company's country.

Step-by-step​

Step 1 — Employer enters top-up amount​

  1. Employer navigates to the Gig Credits section in their dashboard
  2. System displays current gig credit balance
  3. System optionally shows a recommended top-up amount based on the company's last 30 days of gig credit usage (e.g., "Based on your recent usage, we recommend topping up $500")
  4. Employer enters a custom gig cents amount, or selects a suggested package / listed ProductPrice
  5. If the total (credits + platform fee + tax) exceeds the self-serve threshold (e.g., SGD 3,000): show "Contact Sales" instead — the employer cannot self-serve above this amount

Note: Sales can also create private ProductPrice rows (with billing_account_id set) to offer special pricing to specific companies, encouraging larger top-ups. These private prices are only visible to admins when creating invoices, not in the employer dashboard.

Step 2 — System calculates and displays total​

  1. System looks up the company's active Billing::Agreement to get the negotiated fee_rate (e.g., 2000 bps = 20%)
  2. System resolves the Billing::ProductPrice for gig credits in the company's country
  3. System calculates:
    • Credits: custom amount × unit_price_cents
    • Platform fee: credit amount × fee_rate / 10,000
    • Tax: platform fee × tax_rate (e.g., 9% GST — tax is only on the platform fee for gig)
    • Total: credits + platform fee + tax
  4. Employer reviews the breakdown and confirms

Example (existing agreement with 20% fee rate):

ItemAmount
Gig Credits (500 credits = $5.00)$500.00
Platform Fee (20%)$100.00
GST (9% on platform fee)$9.00
Total$609.00

Step 3 — System generates invoice​

Same as 3.1 Step 5, but using the existing agreement's fee rate (not the list rate):

  1. Create billing_invoices with status = issued
  2. Create billing_invoice_lines:
    • Line 1: Gig credits (tax-exempt)
    • Line 2: Platform fee at the agreement's negotiated rate (with GST)
  3. Invoice emailed to the currently logged-in employer user

Step 4 onwards — Payment and posting (same as 3.0 Steps 3–5)​

Tables touched​

  • billing_invoices (insert)
  • billing_invoice_lines (insert × 2)

(No agreement changes — the existing agreement is reused)

Invariants​

  • The platform fee rate comes from the company's active Billing::Agreement, NOT from ProductPrice.platform_fee_rate_bps. The agreement rate may differ from the list rate (e.g., 20% negotiated vs 30% list).
  • Self-serve top-ups are limited to the self-serve threshold for that currency.
  • The recommended top-up amount is a suggestion only — the employer can enter any custom amount within the threshold.
  • Invoice goes directly to issued (no admin review).

Result​

The company has a new issued invoice for their gig credit top-up.

  • Once paid and posted, their gig credit balance increases and a new lot is created with the agreement's platform fee rate.

3.3 Self-serve Placement Credits Purchase​

Scenario​

An Org::Company wants to purchase placement credits (shown as "Visibility Credits" in the UI) to run sponsored placements, job postings, or boosts. Placement credits do not have a platform fee — the full price goes toward the credit value.

Prerequisites​

  • billing_accounts must exist for the company.
  • Active Billing::ProductPrice rows exist for placement products in the company's country.

Step-by-step​

Step 1 — Employer browses placement credit packages​

  1. Resolve the company's country via Org::Company.country_id
  2. Find active standard Billing::ProductPrice rows for placement products (same resolution as 3.1 Step 1, but for placement entitlement)
  3. Display packages with: product name, credit amount, price, tax (GST on full value), total
  4. Packages above the self-serve threshold: show "Contact Sales"

Example for Singapore (SGD, 9% GST on full value):

PackageCreditsPriceGST (9%)Total
A50 Placement Credits$250$22.50$272.50
B100 Placement Credits$500$45.00$545.00
EnterpriseCustomContact Sales——

Step 2 — Employer accepts terms and conditions​

  1. System displays the terms and conditions for using the ads/placement service
  2. Employer checks the "I agree to the terms and conditions" checkbox
  3. If the employer does not accept, the flow ends

Note: For placement credits, a formal Billing::Agreement document is not generated in the initial version. The checkbox acceptance covers the T&C for service usage. Future enhancement: If enterprise clients need formal agreements for placement credits (e.g., custom unit pricing or volume discounts), generate an agreement document and store it under Billing::Agreement, following the same pattern as gig (3.1 Step 4).

Step 3 — System generates invoice​

  1. Create billing_invoices with status = issued:
    • Associated with the company's Billing::LegalEntity for that country
    • Invoice number generated from legal entity sequence
    • Snapshot bill-to fields
  2. Create billing_invoice_lines, single line placement credits
    • quantity
    • unit_price_cents,
    • amount_cents,
    • tax_cents = amount × tax_rate (GST on full value),
    • units_to_grant
  3. Invoice emailed to the currently logged-in employer user

Step 4 onwards — Payment and posting (same as 3.0 Steps 3–5)​

Tables touched​

  • billing_invoices (insert)
  • billing_invoice_lines (insert × 1)

Invariants​

  • Placement credit invoices have a single line (unlike gig which has principal + platform fee).
  • GST applies to the full value of placement credits (not just a fee portion).
  • No Billing::Agreement is required for standard placement credit purchases.
  • Invoice goes directly to issued (no admin review).
  • Self-serve is limited to the self-serve threshold for that currency.

Result​

The company has an issued invoice for placement credits.


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 credits
  • +$500 deferred revenue

Steps​

  1. Billing service called: GrantEntitlements

  2. Lock billing_entitlement_balances for the account's placement instrument

  3. Insert ledger entry:

    • entry_type = grant
    • available_delta = +100
    • deferred_revenue_delta_cents = +50000
  4. Create one billing_entitlement_lots row:

  5. Insert one billing_entitlement_lot_allocations row linking the entry to the new lot (Billing D7 — allocation rows are the complete history of a lot)

  6. 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(source: 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
    • source_type='Ads::CampaignPlacement', source_id=999
  6. Insert one billing_entitlement_lot_allocations row per lot drawn, in the spend order (Billing D5 — reservations name their lots)

  7. Update balance:

    • available -= 14
    • reserved += 14

Statement lines​

"Reserved 14 Visibility Credits for CampaignPlacement #999"


4.2 Placement Credits: Daily consumption (deliver service + recognise revenue)​

Scenario​

Each day the campaign runs, it consumes part of its reserved credits. At the same time, revenue is recognised per lot.

The daily amount and the recognised revenue​

  • The daily amount is computed from the campaign's total, not stored as a plan: consume today = round(cost × days elapsed ÷ total days) − consumed so far (Billing D5 — how a campaign consumes a daily amount). Safe to run twice: the same day computes zero the second time, and a missed day is caught up by the next run.
  • The recognised revenue is computed per lot with the one recognition formula, and copied onto the entry and its allocation rows at that moment.

Steps (daily job)​

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

  2. Lock balance row

  3. Confirm the campaign's reserved credits cover the amount — the net of the ledger entries naming it as source (Billing D8 — reserved credits are computed from the ledger)

  4. Take the units from the lots the campaign reserved, soonest-dying first

  5. Compute the recognised revenue per lot with the one recognition formula

  6. Insert ledger entry:

    • entry_type = consume
    • reserved_delta = -1
    • recognised_revenue_cents = recognised
    • deferred_revenue_delta_cents = -recognised
    • source_type='Ads::CampaignPlacement', source_id=999
  7. Insert one billing_entitlement_lot_allocations row per lot consumed, each with its lot's recognised amount

  8. Update the lot counters and each lot's deferred_revenue_remaining_cents

  9. Update balance projection:

    • reserved -= 1
    • deferred_revenue -= recognised

Statement line​

"Consumed 1 Placement Credit for CampaignPlacement #999 (recognised $X.XX)"


4.3 Placement Credits: Cancel campaign and release remaining reservation​

Scenario​

Campaign canceled with 5 days remaining. Release the reserved credits back to available.

Steps​

  1. Ads calls Billing: ReleaseEntitlements(source: Ads::CampaignPlacement#999)

  2. Compute the campaign's reserved credits from the ledger — 5 units (Billing D8 — reserved credits are computed from the ledger)

  3. Lock balance row

  4. Insert ledger entry:

    • entry_type = release
    • available_delta = +5
    • reserved_delta = -5
  5. Insert one billing_entitlement_lot_allocations row per lot — the units return to the exact lots they were reserved from

  6. Update balance and lot counters

If a lot's expires_at passed while the units were reserved, the returned units expire immediately, in the same transaction (Billing D12 — a running service cannot be cancelled, and released credits get no extra time).

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:

Ledger entry:

  • consume with available_delta=-X
  • source: Careers::Job

Pricing mode B: per application​

At application creation:

  • consume 1 credit per application
  • source: Careers::JobApplication

This is exactly why we keep the ledger generic: pricing changes don't require schema changes.


4.5 Job Boost: consume directly (same as Careers posts)​

Boost is a "visibility placement" product. A boost delivers the moment it is paid, so it consumes directly — no reservation (Billing D5 — instant delivery consumes directly).

The entry's source is the job boost record. That record's model is not designed yet, so no class name exists for it.


5.0 Gig: Grant credits (top-up) and create the 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)
    • deferred_revenue_delta_cents=+2000 — the platform fee is the gig lot's deferred revenue
  3. Create lot:

    • units_purchased=10000
    • units_available=10000
    • units_reserved=0
    • platform_fee_rate_bps=2000
    • deferred_revenue_total_cents=2000, deferred_revenue_remaining_cents=2000
  4. Insert one billing_entitlement_lot_allocations row linking the entry to the new lot (Billing D7 — allocation rows are the complete history of a lot)

  5. Update balance projection:

    • available += 10000
    • deferred_revenue_cents += 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(source: Gig::Shift#123, units: 1800)

  2. Lock gig balance row

  3. Outlet budget check (if outlet is budget-controlled):

    • Look up Billing::OutletBudget for the shift's outlet
    • If exists and active: verify OutletBudget.units_available >= 1800
    • If no budget exists: verify unallocated pool >= 1800 (i.e. EntitlementBalance.units_available - SUM(active OutletBudgets.units_available) >= 1800)
  4. Lock lots with units_available > 0 in the spend order — gig lots have no expiry date, so this is plain first-in-first-out

  5. Allocate 1800 across lots in the spend order:

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

    • entry_type=reserve
    • available_delta=-1800
    • reserved_delta=+1800
    • org_outlet_id = shift's outlet (copied here for outlet statements)
    • source_type='Gig::Shift', source_id=123
  7. Insert allocations:

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

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

    • available -=1800, reserved +=1800
  10. Update outlet budget (if outlet is budget-controlled):

  • OutletBudget.units_available -= 1800
  • OutletBudget.units_reserved += 1800
  1. The shift's reserved credits are not stored anywhere. They are the net of the ledger entries naming the shift as source (Billing D8 — reserved credits are computed from the ledger)

Note: Steps 3 and 10 only apply when the outlet has an active Billing::OutletBudget. See Billing::OutletBudget model spec for details.

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(source: Gig::Shift#123, actual_units: 1750, metadata: insurance...)

  2. Compute the shift's reserved credits from the ledger — 1800 cents (Billing D8 — reserved credits are computed from the ledger)

  3. Lock gig balance + related lots (based on the reserve entries' allocation rows)

  4. Consume from the lots that were reserved, soonest-dying first — for gig lots this is plain first-in-first-out:

    • Lot A consume 1000
    • Lot B consume 750 (of its 800 reserved)
  5. Compute the recognised revenue per lot with the one recognition formula — a gig lot's deferred revenue is its platform fee

  6. Insert ledger entry (consume):

    • entry_type=consume
    • reserved_delta=-1750
    • org_outlet_id = shift's outlet (copied here for outlet statements)
    • recognised_revenue_cents=<sum recognised>, deferred_revenue_delta_cents=-<sum recognised>
  7. Insert allocation rows (consume):

    • lot A consume 1000, with its recognised amount
    • lot B consume 750, with its recognised amount
  8. Update lots:

    • lot A units_reserved -=1000
    • lot B units_reserved -=750
    • reduce each lot's deferred_revenue_remaining_cents by its recognised amount
  9. Update balance:

    • reserved -=1750
    • deferred_revenue_cents -= recognised (same transaction — never optional)
  10. Update outlet budget (if outlet is budget-controlled):

  • OutletBudget.units_reserved -= 1750
  1. Insert ledger entry (release remainder 50):
  • entry_type=release
  • available_delta=+50, reserved_delta=-50
  • org_outlet_id = shift's outlet
  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. Update outlet budget for release remainder (if outlet is budget-controlled):
  • OutletBudget.units_reserved -= 50
  • OutletBudget.units_available += 50
  • Released credits return to the outlet budget (not the unallocated pool), because the outlet originally reserved them.
  1. Nothing to close — the shift's reserved credits are now zero (Billing D8 — reserved credits are computed from the ledger)

Note: Steps 10 and 13 only apply when the outlet has an active Billing::OutletBudget. See Billing::OutletBudget model spec for details.

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. Compute the shift's reserved credits from the ledger (Billing D8 — reserved credits are computed from the ledger)

  2. Insert ledger entry release:

    • available_delta=+held, reserved_delta=-held
    • org_outlet_id = shift's outlet (copied here for outlet statements)
  3. Update allocations: release units back into lots (reverse of reserve allocations)

  4. Update lots: reserved → available

  5. Update outlet budget (if outlet is budget-controlled):

    • OutletBudget.units_available += held
    • OutletBudget.units_reserved -= held
    • Released credits return to the outlet budget, not the unallocated pool.

Note: Step 5 only applies when the outlet has an active Billing::OutletBudget. See Billing::OutletBudget model spec for details.


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
  • instrument = :gig
  • date range

Order by occurred_at ASC, id ASC.

Placement Credits usage report​

Same, with instrument = :placement.

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/expire/refund/adjust)
  • units change
  • money change (recognised revenue / deferred changes)
  • reference label (Shift #123, CampaignPlacement #999)
  • metadata (insurance, notes)

This is why ledger is the canonical statement source.