Billing Domain Design (Catalog + Invoices + Single Ledger + Lots + Xero-Friendly Exports)
Billing Spec Notes
Fundamental concepts to understand first
-
Entitlement (units) vs Money (accounting)
-
Entitlements are what the customer can still use:
placement_credit(Placement/Visibility credits)gig_credit_cents(stored value credits measured in cents)
-
Money is what finance cares about:
- Deferred revenue (you owe service delivery)
- Principal liability (refundable stored value)
- Recognized revenue (earned)
-
-
Ledger vs Projection
- Ledger: an append-only journal of all balance-affecting actions (grant/reserve/release/consume/adjust).
- Projection: cached “current state” tables for fast reads (balances, active holds). Projections are rebuildable from ledger.
-
Reservation is not consumption
- Reserve moves units from
available → reserved. - Consume reduces units (usually from reserved, sometimes directly from available).
- Reserve moves units from
-
Why Gig is special
- Gig credits require FIFO lots and per-lot platform fee rates → you must track consumption against purchase batches.
- Placement credits do not tag units to purchase price → no lots required.
-
Xero-friendly exports
- Your finance process is lump-sum journaling (daily/monthly).
- Your internal system still needs customer-level statements and auditability.
- Design should support both without refactors.
Overview
We will implement ::Billing as a bounded context that manages:
- customer entitlements (units)
- deferred revenue / liability movements (money)
- reservations and consumption
- statements of account (SOA)
- finance exports compatible with your current journaling workflows and future Xero integration
Products / instruments supported
-
Placement Credits (marketed as “Visibility Credits”, pooled deferred revenue):
- Ads (display placements)
- Job Boost (featured placement for a duration)
- Careers Job Posting (per post / per application later)
-
Gig Credits (stored value + FIFO lots for platform fee):
- credits represent wage value
- platform fee deferred and recognized by FIFO lots
Future:
- Subscriptions (Workforce) will be introduced as another entitlement type (seat-days, seat-months) and/or as contract schedules—this design accommodates it.
Glossary
-
Entitlement Type: defines unit type and accounting policy (e.g.
placement_credit,gig_credit_cents) -
Ledger Entry: one atomic balance-changing record (append-only)
-
Balance: current available/reserved units and deferred money for a given entitlement type
-
Hold: an “active reservation” projection keyed by a reference (e.g.
Gig::Shift #123) -
Lot: gig purchase batch with its own platform fee rate; used for FIFO allocation
-
Allocation: mapping from a ledger entry to one or more lots (gig only)
-
Reference: the external domain object that caused the billing event (Ads campaign placement, Careers job, Gig shift)
-
Product: global definition of what is being sold (stable); used to derive entitlements granted
-
Offer: market-specific price list row for a product (currency, taxes, seller legal entity, gig fee terms)
-
Legal Entity: your seller-of-record for a jurisdiction (drives invoice numbering + tax treatment)
-
Bill-To Profile: customer billing recipient details (address/contact), owned by the customer company
-
Invoice: customer-facing commercial document generated from an offer snapshot
-
Payment: offline bank transfer record + verification status
-
Posting: the idempotent internal action that grants entitlements into the ledger once an invoice is fully paid
0) Billing System Layers (so engineers and finance can both navigate)
Billing gets confusing because it mixes commercial flows (invoices, bank transfers) and entitlement accounting (credits, reservations, revenue recognition). To keep the system understandable, we design it in layers:
Layer A — Catalog & Pricing (what we sell)
Tables: billing_products, billing_product_prices (plus billing_legal_entities)
- A Product defines what is being sold (e.g. “Gig Credits”, “Placement Credits” (Visibility Credits in UI)) and what it grants.
- An Offer defines how we sell that product in a specific market:
- currency (SGD, IDR, KRW…)
- price, tax rules
- seller-of-record (Legal Entity)
- gig platform fee rate terms (if applicable)
Analogy: Product is the menu item (“Latte”), Offer is the outlet-specific price (“$6.50 at Orchard, $7.20 at Marina Bay”).
Layer B — Commercial Documents (how customers buy)
Tables: billing_invoices, billing_invoice_items, billing_payments, billing_invoice_postings
- Invoices are customer-facing documents.
- Payments capture offline bank transfers (with proof).
- Posting is the internal step that grants entitlements only after payment is verified.
Analogy: invoice = “bill”, payment = “bank transfer receipt”, posting = “kitchen confirms payment then starts preparing food”.
Layer C — Entitlements Engine (what customers can spend)
Tables: billing_entitlement_types, billing_ledger_entries, billing_entitlement_balances, billing_entitlement_holds,
and gig-only billing_entitlement_lots, billing_entitlement_lot_allocations
- Single ledger is the source of truth for all movements.
- Balances / holds are projections for fast reads.
- Lots exist only for instruments that require FIFO & per-purchase terms (Gig credits).
Layer D — Reporting & Finance Exports (how we reconcile)
Outputs: SOA per company, finance export aggregates, Xero-friendly journals/invoice exports
- Statements of Account are derived from ledger entries (grouped by reference).
- Finance exports are produced from the same ledger, but summarized to match your operational workflow (daily lumps / month-end).
Why layering matters: As you add Workforce subscriptions later, you’ll add a new instrument/policy in Layer C and new products/offers in Layer A, but the invoice/payment posting flow remains the same.
1) High-Level Ramp-Up (Grok)
1.1 Why a single ledger table
What we need long-term
- Customer statements for Gig credits (like your legacy system)
- Similar statements for Placement Credits (Visibility Credits in UI)
- Easy to build: filtering, pagination, date-range statements
- Clean audit trail for finance and ops
- Consistent idempotency and concurrency behavior across all billing actions
Why multiple action tables is a tax
If you split into topups, reservations, usages, you’ll constantly:
- union or stitch streams for statements
- duplicate filters and pagination logic
- add more tables as you add subscriptions/refunds/rebates
Single ledger = one chronological stream per entitlement type. Statements become one query: filter by account + entitlement type + date range; order by time.
1.2 The canonical “financial primitives” we model
Inside Billing, we name things by stable finance primitives (not Ads/Gig/etc.):
- Grant: increase available entitlements (usually after payment verification)
- Reserve (Hold): move available to reserved (to prevent overspend)
- Release: move reserved back to available (campaign canceled, shift canceled)
- Consume: reduce reserved or available (service delivered)
- Adjust: manual corrections
These primitives apply to all instruments; only the policies differ per entitlement type.
1.3 Policies per entitlement type
Placement Credits (placement_credit)
-
Units are pooled; no per-purchase unit price tracking.
-
Money tracking: only
-
deferred_revenue_cents(remaining) -
revenue recognized proportionally at consumption time:
recognized = units_consumed * (deferred_revenue_before / units_before)
-
Gig Credits (gig_credit_cents)
-
Units represent stored value (in cents).
-
Requires FIFO lots because platform fee rate can differ per purchase.
-
Money tracking:
- principal liability (you owe stored value back / service)
- platform fee deferred and recognized based on FIFO lot allocations
-
Reservation may span multiple lots.
1.4 Projections (fast reads) vs ledger (truth)
We will store two main projections:
billing_entitlement_balances: fast “what’s available/reserved/deferred?”billing_entitlement_holds: fast “what active holds exist for a given reference?”
Both are updated in the same DB transaction as ledger entry insertion. Both are rebuildable from ledger (and lots for gig).
1.5 Don’t paint into a corner for Xero integration
We design for two export modes:
-
Journal mode (matches your current process)
-
Aggregate daily totals across the platform:
- movement in deferred revenue
- recognized revenue
- gig credits consumed (liability reduction)
- insurance deductions/sponsored amounts
-
Export as journal lines (CSV now; API later)
-
-
Invoice mode (future)
-
If you later want per-customer sales invoices in Xero:
- keep
billing_invoicesandbilling_paymentsas optional modules - the ledger remains the “delivery and recognition” system of record
- keep
-
Key design point: The ledger stores recognition snapshots at the moment of consumption. That ensures you can export accounting entries later without recomputing history and risking drift.
1.6 Catalog + Invoicing (Products, Offers, Invoices, Payments)
Why this is a separate concern from the ledger
Your entitlements ledger answers: “What does the customer still have, and what happened over time?”
Your invoicing answers: “What did we sell, under which legal entity, in what currency, and did we get paid?”
They must be separate because:
- Pricing changes over time (offers change), but old invoices must remain accurate and auditable.
- Multi-country expansion (SG → KR) introduces:
- different currencies
- different seller legal entities
- different tax rules
- different invoice numbering sequences
- Offline bank transfers require a “human verification” step before entitlements are granted.
Product vs Offer (so multi-country doesn’t explode your schema)
Billing::Product= global definition (e.g. “Placement Credits” (Visibility Credits in UI), “Gig Credits”)Billing::ProductPrice= market-specific sellable variant (e.g. “Placement Credits — Singapore price list”, “Gig Credits — Korea enterprise rate”)
An invoice item should store a snapshot of the offer terms at the time of invoice issue (price, currency, tax, fee rate terms) so later offer edits don’t mutate history.
Invoice lifecycle (minimal but robust)
Keep the invoice state machine small but correct for partial payments:
draft→ prepared internally (not sent)issued→ customer can pay (bank transfer initiated)partially_paid→ at least one verified payment, but sum(verified) < invoice totalpaid→ sum(verified payments) >= invoice totalvoid→ cancelled/invalidated (before paid)credited→ (future) credit note / refund workflow
Posting (granting entitlements) is separate from “paid” to guarantee idempotency:
- When invoice becomes
paid, we createbilling_invoice_postings(1:1, unique). - Posting writes to the ledger and creates lots (gig) in the same DB transaction.
- Unique constraint on postings prevents double-grants if finance clicks “verify” twice. g ensures invoice cannot be posted twice.
Payment lifecycle (offline bank transfer)
Keep payment state machine minimal too:
submitted→ someone recorded a bank transfer reference + proofverified→ business/finance confirms money receivedrejected→ proof invalid / not received
Allow multiple payments per invoice (partial payments) even if you don’t use it on day one — it prevents corner cases later (e.g., finance accidentally records 2 transfers).
Why we call this “Placement Credits” (Visibility Credits in UI) today (and “Actions” later)
Right now you’re selling “buy visibility”:
- sponsored placements (Ads inventory)
- job posting visibility (Careers)
- boosted listings (Job Boost)
So the UI / sales packaging can safely be called “Visibility Credits”.
Internally, we name the instrument by what it actually buys across domains:
- Entitlement Type code:
placement_credit - Meaning: a count of “visibility units” you can spend on placements/posts/boost days
Later, when you introduce a smarter ad system (targeting, relevance, performance), you may add a second instrument:
action_credit(orperformance_action_credit)
Both instruments can share the same accounting model:
- pooled units (no per-lot pricing)
- pooled “deferred revenue” money balance
- proportional revenue recognition at consumption time
- same commercial flow (offer snapshot → invoice → payments → posting → ledger)
The difference would be only in how other domains consume it (days/placements vs measurable actions).
2) High-Level Schema Design
2.1 Entity relationship overview (Billing)
Org::Company
|
v
billing_accounts
|
+-- billing_entitlement_balances (1 row per entitlement type)
|
+-- billing_ledger_entries (append-only; statements come from here)
|
+-- billing_entitlement_holds (active holds; projection)
|
+-- billing_entitlement_lots (gig only; FIFO batches)
|
+-- billing_entitlement_lot_allocations (gig only; map ledger entry -> lots)
2.2 Naming strategy (finance primitives first)
We will not name tables by Ads/Gig. We name them by primitives and type policies.
billing_entitlement_typesbilling_ledger_entriesbilling_entitlement_balancesbilling_entitlement_holdsbilling_entitlement_lots(gig-only, but still finance language)billing_entitlement_lot_allocations
This keeps Billing stable even if your Ads/Boost/Careers naming evolves.
2.3 Commercial layer schema (Catalog + Invoices)
This sits “above” the entitlements engine and stays stable even if you add new instruments later.
geo_countries
▲
│ (seller jurisdiction)
billing_legal_entities 1 ──── N billing_product_prices N ──── 1 billing_products
│ │
│ (seller on invoice) │ (chosen by buyer)
▼ ▼
billing_invoices 1 ──── N billing_invoice_items
│
│ 1 ──── N
▼
billing_payments
billing_invoices 1 ──── 1 billing_invoice_postings (idempotent “grant” step)
│
▼
billing_ledger_entries (+ gig lots)
Key rule: Entitlements are granted only via posting (not by invoice creation).
3) Model and Table Deep Dive
3.0 Commercial layer tables (Catalog + Invoices)
These tables exist to support:
- automated invoice generation (customer-facing)
- manual/offline bank transfer verification (ops/finance-facing)
- stable multi-country price lists (offers)
- a clean, idempotent trigger that grants entitlements into the ledger only after payment is verified and the invoice is fully paid
billing_legal_entities
Purpose: Represents us as the seller-of-record for a jurisdiction (e.g., Singapore entity vs Indonesia entity vs future Korea entity).
Minimal attributes
idcode(e.g.staffany_sg,staffany_id)display_name(printed on invoices)registered_address(or structured fields)tax_regime(enum/string:sg_gst,id_vat,kr_vat…)default_currency(e.g.SGD)country_id(FK →geo_countries)xero_organisation_id(optional, if you eventually manage multiple Xero orgs)invoice_number_prefix(e.g.SG-INV-)invoice_number_sequence(integer, locked/atomic increment)- timestamps
Invariants
- Invoice numbering must be unique per legal entity.
- A legal entity’s country/tax regime is stable over time; don’t mutate historical invoices.
Gotcha
- Multi-country usually implies multiple legal entities (for tax + compliance). Don’t encode “country” only on the invoice and ignore legal entity — you’ll regret this at audit time.
billing_bill_to_profiles
Purpose: Represents the customer billing recipient details for an Org::Company (address, attention line, email).
This is not the same as billing_legal_entities.
billing_legal_entities= who is sellingbilling_bill_to_profiles= who we are billing
Minimal attributes
idbilling_account_id(FK →billing_accounts)label(e.g.HQ,Finance Dept,Outlet Group A)company_nameattention(e.g. “Attn: Finance Team”)billing_emailbilling_address(or structured fields)country_id(FK →geo_countries, optional if address implies it)- timestamps
Invariants
- A company can have multiple bill-to profiles, but an invoice must snapshot bill-to fields at issuance time.
Gotcha
- Never “join back” to bill-to profile to render historical invoices. Copy the bill-to fields into the invoice record.
billing_products
Purpose: Global “thing we sell” definition (stable). Think: what entitlement instrument is granted, not the price.
Examples:
placement_credits(used for ads placements, job posting, job boost)gig_credits(stored value credits for wages)
Minimal attributes
idcode(unique, stable; e.g.placement_credits,gig_credits)name(human friendly)descriptionentitlement_type_id(FK →billing_entitlement_types)unit_name(e.g.credit,coin,cent)grants_units_per_quantity(integer; see below)is_lot_based(boolean, true for gig)- timestamps
How grants_units_per_quantity works
- For package-based products: quantity=1 grants 1,000 credits.
- For per-unit top-ups: quantity=500 grants 500 credits.
This keeps invoices flexible without creating infinite product rows.
billing_product_prices
Purpose: Market-specific sellable variant (your “price list row”). This is where we encode currency, price, taxes, and gig-specific platform fee terms.
Minimal attributes
idbilling_product_id(FK →billing_products)billing_legal_entity_id(FK →billing_legal_entities) (seller-of-record)country_id(FK →geo_countries) (market selector)- can be same as legal entity country, but keep it explicit
currency(e.g.SGD,IDR,KRW)pricing_model(enum:package,per_unit)unit_price_cents(integer; interpreted as cents for the currency minor unit)tax_code(string; maps to your finance system)tax_rate(decimal, optional; country rules vary)active_from,active_until(optional)metadata(jsonb; gig:platform_fee_rate_bps, promos, notes)- timestamps
Gig-specific (split pricing + split tax)
For Gig offers in Singapore you need to support:
- principal pricing (the stored value portion)
- platform fee rate (bps)
- tax policy: GST applies only to the platform fee
Pragmatically:
- store
platform_fee_rate_bpsinmetadata - store tax codes/rates either:
- as
tax_codefor taxable lines (platform fee), and treat principal as tax-exempt (0%), or - in
metadataasprincipal_tax_code+fee_tax_codeif your finance system requires explicit codes.
- as
Either way, the invoice items must snapshot the final tax rate/code per line.
Invariants
- Offers are append-only over time: if pricing changes, create a new offer row instead of mutating historical terms.
Gotcha
- Even if your
Org::Companyhascountry_id, you still want the offer to be explicit — it’s the contract of sale.
billing_invoices
Purpose: Customer-facing invoice (commercial document). Generated when company requests to top-up entitlements.
Minimal attributes
idbilling_account_id(FK)billing_legal_entity_id(FK) sellerbilling_bill_to_profile_id(FK, optional) source profileinvoice_no(unique per legal entity)status(draft,issued,partially_paid,paid,void,credited)currency- Snapshot bill-to fields
bill_to_company_namebill_to_attentionbill_to_emailbill_to_address
issued_at,due_at,settled_atsubtotal_cents,tax_cents,total_cents- timestamps
Invariants
- Once
issued, invoice amounts and items are immutable. - If you need corrections after issuance: use void + reissue (if unpaid) or credit note (future enhancement).
billing_invoice_items
Purpose: Line item snapshots. This is the bridge between commercial sale and entitlement grant.
Minimal attributes
idbilling_invoice_id(FK)billing_product_id(FK, optional but useful)billing_product_price_id(FK, optional but useful)description(snapshot)quantity(integer)unit_price_cents(integer snapshot)amount_cents(integer)tax_cents(integer)entitlement_type_id(FK →billing_entitlement_types)units_to_grant(integer) (computed at invoice creation time)metadata(jsonb snapshot)- gig:
platform_fee_rate_bps,principal_amount_cents,platform_fee_amount_cents - visibility: optional promo flags
- gig:
- timestamps
Tax rules (Singapore MVP)
You confirmed the tax base differs by instrument:
A) Placement Credits (placement_credit)
- GST applies to the full value of the placement credits purchase.
- If buying 100 placement credits for $200:
subtotal = 200tax = 200 × 0.09 = 18total = 218
So the single placement-credit invoice item will usually carry:
tax_rate = 9%tax_cents = amount_cents × 0.09
B) Gig Credits (gig_credit_cents)
- GST applies only to the platform fee line item (see Gig section above).
Critical design choice (Gig): Two invoice items are mandatory
For Gig credits in Singapore, you explicitly confirmed:
- The customer invoice must show two line items:
- Gig Credits (stored value / principal) — grants gig credit units
- Gig Platform Fee — grants no units (money-only line)
- GST applies only to the platform fee (not to principal).
That means we do not support the “single invoice item with split metadata” approach for Gig. It looks simpler in UI, but it makes tax handling and accounting exports fragile.
Invoice Item Tax Policy (SG, MVP)
- Gig Credits (principal): tax rate = 0, tax_cents = 0
- Gig Platform Fee: tax rate = 9% (or from LegalEntity tax regime), tax_cents = fee_amount × 0.09
The posting step uses these item snapshots to create the correct ledger entries and gig lots safely.
billing_payments
Purpose: Records offline bank transfers and proof.
Minimal attributes
idbilling_invoice_id(FK)method(bank_transfernow,cardlater)status(submitted,verified,rejected)amount_centsreceived_atbank_reference(string)proof_file_id(FK → your file storage table, oractive_storage_attachments)verified_by_admin_id(FK → admins/users table)verified_at- timestamps
Invariants
- Invoice becomes
paidwhen sum(verified payments) >= total (or when a single payment matches total if you enforce that policy).
Gotcha
-
You want to support multiple payments even if you don’t expose it in UI at first (finance reality beats product purity).
-
Policy: Allow partial payments, but grant entitlements only when the invoice is fully paid (
status = paid).
billing_invoice_postings
Purpose: Idempotent internal record that “this invoice was posted into entitlements”.
Minimal attributes
idbilling_invoice_id(FK, unique)posted_atposted_by_admin_ididempotency_key(optional)- timestamps
Invariants
- Unique constraint on
billing_invoice_idguarantees no double-granting if the “verify payment” action is clicked twice. - Only allowed when invoice status =
paid(sum of verified payments >= invoice total).
How these connect to the entitlement engine
When an invoice is posted:
-
For placement credits:
- one ledger entry granting units (
available_delta) - one ledger entry adding deferred revenue (
deferred_revenue_delta_cents) (can be merged into a single ledger entry with both deltas)
- one ledger entry granting units (
-
For gig credits:
- create a
billing_entitlement_lotper purchase (FIFO) - grant units via ledger
- record platform fee deferred on the lot + ledger snapshot for audit
- create a
3.1 billing_accounts
Purpose
One Billing account per Org::Company. This is the parent entity for all entitlements.
Minimal attributes
idorg_company_id(unique)currency(e.g.,SGD)status(active,suspended)- timestamps
Invariants
- exactly one account per company
- currency is stable per account (future: if you need multi-currency per company, create separate accounts per legal entity/currency)
3.2 billing_entitlement_types
Purpose
Defines each financial instrument:
- its unit type
- whether it uses lots (FIFO)
- which accounting policy applies
Minimal attributes
-
id -
code(unique string):placement_creditgig_credit_cents- future:
workforce_seat_day
-
unit_name(e.g.credit,cent) -
allocation_policyenum:pooledfifo_lots
-
recognition_policyenum:proportional_average(placement)lot_based(gig platform fee)
-
is_reservableboolean -
timestamps
Gotcha
Do not bake pricing into this table. Pricing belongs to product/catalog (Promotions, Careers), not Billing.
3.3 billing_entitlement_balances (projection)
Purpose
Fast reads for UI/ops:
- “How many units available/reserved?”
- “How much deferred revenue remains?”
- “How much platform fee deferred remains?” (gig cache)
One row per (billing_account_id, entitlement_type_id).
Minimal attributes
billing_account_idbilling_entitlement_type_idunits_available(BIGINT)units_reserved(BIGINT)
Money columns (nullable depending on entitlement type):
deferred_revenue_cents(BIGINT) — used byplacement_creditplatform_fee_deferred_cents(BIGINT) — used by gig as cache- timestamps
Invariants
- units never negative
units_available + units_reservedmust match underlying lots totals for gig (projection consistency)- deferred revenue never negative
Indexes
- unique
(billing_account_id, billing_entitlement_type_id)
3.4 billing_ledger_entries (single ledger)
Purpose
The statement-of-account source of truth. Every balance change inserts one or more ledger entries.
Key columns (minimal but complete)
Identity:
-
id -
billing_account_id -
billing_entitlement_type_id -
entry_typeenum:grant,reserve,release,consume,adjust
-
occurred_at(timestamp) -
idempotency_key(unique)
Balance deltas (BIGINT):
available_deltareserved_delta
Money fields (BIGINT cents; nullable depending on type + entry):
deferred_revenue_delta_cents(placement grants and consumes)recognized_revenue_cents(placement consumes)platform_fee_deferred_delta_cents(gig grants)platform_fee_recognized_cents(gig consumes)
References (for audit + grouping):
reference_type(string)reference_id(bigint)
Optional snapshot fields (strongly recommended for placement consumes):
pool_units_before(BIGINT)pool_deferred_revenue_before_cents(BIGINT)
Metadata:
metadatajsonb (insurance amounts, notes, admin reason, etc.)
Timestamps:
created_at
Why two deltas?
Because reservations must be auditable and SOA-friendly:
- Reserve:
available -X,reserved +X - Release:
available +X,reserved -X - Consume reserved:
reserved -X - Consume direct:
available -X
Without two deltas, your “available vs reserved” state becomes ambiguous or requires hacks.
Critical invariants
- A ledger entry must change something:
available_delta != 0 OR reserved_delta != 0 OR deferred_revenue_delta_cents != 0 OR platform_fee_deferred_delta_cents != 0 OR recognized_revenue_cents != 0 OR platform_fee_recognized_cents != 0
- Entry types should follow sign rules (enforced via check constraints; see migrations section)
Indexes (statement-friendly)
(billing_account_id, occurred_at DESC)(billing_account_id, billing_entitlement_type_id, occurred_at DESC)(reference_type, reference_id)- unique
(idempotency_key)(or scope it by account if preferred)
3.5 billing_entitlement_holds (projection)
Purpose
Operational convenience for “active reservations”:
- find hold by
(reference_type, reference_id) - quickly know remaining reserved amount
- enforce “only one active hold per reference” without expensive ledger scanning
This table is not a second ledger. It’s a cache like balances.
Minimal attributes
idbilling_account_idbilling_entitlement_type_idreference_type,reference_idstatusenum:active,released,consumed,expiredunits_held(BIGINT)opened_at,closed_atopened_ledger_entry_id(FK to ledger entry that created the hold)- timestamps
Invariants
- unique active hold per
(account, type, reference) - units_held >= 0
Indexes
- unique partial index on
(billing_account_id, billing_entitlement_type_id, reference_type, reference_id)wherestatus='active'
3.6 billing_entitlement_lots (gig only)
Purpose
Track FIFO purchase batches for gig credits with per-lot platform fee rate.
Minimal attributes
-
id -
billing_account_id -
billing_entitlement_type_id(must begig_credit_cents) -
purchased_at -
source_reference_type,source_reference_id(recommended:Billing::InvoiceItem+billing_invoice_items.idfor the gig principal line item; avoidpaymentbecause payments can cover multiple invoice items) -
Units:
units_purchased(BIGINT cents)units_available(BIGINT cents)units_reserved(BIGINT cents)
-
Fee:
platform_fee_rate_bps(integer, e.g. 2000 = 20.00%)platform_fee_total_centsplatform_fee_remaining_cents
Invariants
units_available >= 0,units_reserved >= 0units_available + units_reserved <= units_purchased(consumed removed elsewhere via allocations; see below)platform_fee_remaining_cents >= 0
FIFO rule
Consume/reserve from lots ordered by purchased_at ASC, id ASC.
Gotcha
Do not store decimals for credits or money. Use BIGINT cents.
3.7 billing_entitlement_lot_allocations (gig only)
Purpose
Record exactly how a ledger entry (reserve/consume/release/adjust) maps to lots.
This is how you:
- prove FIFO
- compute platform fee recognized correctly
- generate detailed gig SOA lines (“which batch funded this shift?”) if needed
Minimal attributes
-
id -
billing_ledger_entry_id(FK) -
billing_entitlement_lot_id(FK) -
units_allocated(BIGINT cents; positive number) -
allocation_typeenum:reserve,consume,release,adjust
-
platform_fee_recognized_cents(nullable; typically only for consume) -
timestamps
Invariants
units_allocated > 0
4) Concurrency, Idempotency, and Data Integrity
4.1 Transaction boundary (non-negotiable)
Every billing operation must run inside a single DB transaction:
- Lock the relevant
billing_entitlement_balancesrowFOR UPDATE - If gig: lock the relevant lots rows
FOR UPDATEin FIFO order - Insert ledger entry (or entries)
- Update balances and holds projections
- Update lots and allocations (gig)
If any step fails, rollback everything.
4.2 Idempotency
Every external-triggered call must provide an idempotency_key:
- Ads daily consume job retries → no double consumption
- Gig completion retries → no duplicate charges
- Payment verification callbacks → no duplicate grants
Enforce with a unique index on billing_ledger_entries.idempotency_key.
4.3 Check constraints (DB-level safety)
Use Postgres check constraints to prevent invalid ledger entries from being inserted.
Examples:
- reserve must move equal units between available and reserved
- release must move equal units back
- consume must reduce either available or reserved
- recognized revenue must be present for placement consumes
(See migrations section for concrete SQL constraints.)
5) 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.
7) Finance Exports (Xero-friendly, not cornered)
7.1 Design goals
-
Match your current finance workflow (lump-sum daily entries)
-
Maintain customer-level truth internally regardless of what Xero stores
-
Support future enhancements:
- per-invoice exports
- per-customer deferred breakdown (if finance ever wants it)
- multi-legal-entity expansion into Singapore / Indonesia
7.2 What the ledger must provide to exports
Exports need:
- recognized revenue amounts with timestamps
- deferred revenue movements (especially for placement credits)
- gig platform fee recognized (lot-based)
- gig credits consumed (principal liability reduction)
- insurance deductions/sponsorships (from metadata)
Key point: we store recognition snapshots at the moment of consume → exports don’t need to recompute or re-allocate later.
7.3 Suggested accounting mapping configuration (small but crucial)
Create a configuration layer (can be table-backed later) mapping:
- entitlement type → liability accounts
- revenue accounts
- tax codes if needed
Minimal mapping keys you will eventually need
-
Placement credits:
- Deferred revenue liability account
- Placement revenue account
-
Gig credits:
- Stored value liability account
- Platform fee deferred account
- Platform fee revenue account
- Insurance payable / insurance expense (depending on your treatment)
You can start with constants in code, but don’t hardcode everywhere—centralize it.
7.4 Export Mode A: Daily Journal Summary (recommended initial integration)
For each day:
- Sum recognized revenue for placement credits (from
ledger.consume) - Sum deferred revenue increases (from
ledger.grantplacement) - Sum gig platform fee recognized (from gig
consume) - Sum gig credits consumed (from gig
consumeunits) - Sum insurance fields from metadata
Then create journal lines:
- Debit/Credit deferred liability and revenue accordingly
Why daily journal mode is best first
- matches current finance rhythm
- avoids building customer contact sync in Xero immediately
- keeps volume manageable
7.5 Export Mode B: Invoice-based (later)
If you generate billing_invoices:
- Export sales invoices + payments
- Still use ledger consumption to recognize revenue over time This is a bigger accounting integration step and doesn’t block you now.
7.6 Idempotent export runs (avoid double-posting)
Even if you start with CSV export, you should track export runs to avoid re-exporting.
Add (optional but recommended):
-
billing_export_runs:export_type(daily_journal)date_from,date_tostatus- unique constraint on
(export_type, date_from, date_to)
-
Later when you use Xero API, store returned journal IDs.
This prevents “oops we imported the same day twice”.
8) Rails Implementation Guidance (service layer patterns)
8.1 Public Billing API (application services)
Keep Billing mutations behind a small set of commands:
Billing::GrantEntitlements.call(account:, entitlement_type:, units:, money:, reference:, idempotency_key:)Billing::ReserveEntitlements.call(account:, entitlement_type:, units:, reference:, idempotency_key:)Billing::ReleaseHold.call(account:, entitlement_type:, reference:, idempotency_key:)Billing::ConsumeEntitlements.call(account:, entitlement_type:, units:, reference:, idempotency_key:, metadata:)Billing::AdjustEntitlements.call(...)
Internally:
- lock balance row
- enforce invariants
- insert ledger entry
- update projections
- if gig: allocate and update lots
8.2 Locking strategy
BillingEntitlementBalance.lock("FOR UPDATE")- For gig: select lots
FOR UPDATEordered FIFO
8.3 Rounding policy (important)
For proportional recognition (placement credits):
-
choose one policy and document it:
- “round half up to cents”
- or “banker’s rounding”
-
store recognized cents on the ledger entry so the export matches statements exactly.
9) Migrations (Rails + Postgres constraints + indexes)
Below are migration-style examples. Treat these as templates (you can split into multiple migrations).
Notes:
- Use
bigintfor unit counts and cents.- Use Postgres enums if you prefer; strings are fine if constrained.
- Add check constraints using
execute.
9.1 billing_accounts
create_table :billing_accounts do |t|
t.references :org_company, null: false, foreign_key: true, index: { unique: true }
t.string :currency, null: false, default: "SGD"
t.string :status, null: false, default: "active"
t.timestamps
end
9.2 billing_entitlement_types
create_table :billing_entitlement_types do |t|
t.string :code, null: false
t.string :unit_name, null: false
t.string :allocation_policy, null: false # pooled, fifo_lots
t.string :recognition_policy, null: false # proportional_average, lot_based
t.boolean :is_reservable, null: false, default: true
t.timestamps
end
add_index :billing_entitlement_types, :code, unique: true
Seed examples:
- visibility_credit: pooled + proportional_average
- gig_credit_cents: fifo_lots + lot_based
9.3 billing_entitlement_balances
create_table :billing_entitlement_balances do |t|
t.references :billing_account, null: false, foreign_key: true
t.references :billing_entitlement_type, null: false, foreign_key: true
t.bigint :units_available, null: false, default: 0
t.bigint :units_reserved, null: false, default: 0
t.bigint :deferred_revenue_cents, null: false, default: 0
t.bigint :platform_fee_deferred_cents, null: false, default: 0
t.timestamps
end
add_index :billing_entitlement_balances,
[:billing_account_id, :billing_entitlement_type_id],
unique: true,
name: "idx_billing_balances_unique"
You can keep deferred_revenue_cents and platform_fee_deferred_cents both present (default 0).
They’ll be “unused” depending on entitlement type—but safer than nulls for arithmetic.
9.4 billing_ledger_entries
create_table :billing_ledger_entries do |t|
t.references :billing_account, null: false, foreign_key: true
t.references :billing_entitlement_type, null: false, foreign_key: true
t.string :entry_type, null: false # grant, reserve, release, consume, adjust
t.datetime :occurred_at, null: false
t.string :idempotency_key, null: false
t.bigint :available_delta, null: false, default: 0
t.bigint :reserved_delta, null: false, default: 0
t.bigint :deferred_revenue_delta_cents, null: false, default: 0
t.bigint :recognized_revenue_cents, null: false, default: 0
t.bigint :platform_fee_deferred_delta_cents, null: false, default: 0
t.bigint :platform_fee_recognized_cents, null: false, default: 0
t.bigint :pool_units_before
t.bigint :pool_deferred_revenue_before_cents
t.string :reference_type
t.bigint :reference_id
t.jsonb :metadata, null: false, default: {}
t.timestamps
end
add_index :billing_ledger_entries, :idempotency_key, unique: true
add_index :billing_ledger_entries, [:billing_account_id, :occurred_at], name: "idx_ledger_account_time"
add_index :billing_ledger_entries, [:billing_account_id, :billing_entitlement_type_id, :occurred_at], name: "idx_ledger_account_type_time"
add_index :billing_ledger_entries, [:reference_type, :reference_id], name: "idx_ledger_reference"
Check constraints (strongly recommended)
execute <<~SQL
ALTER TABLE billing_ledger_entries
ADD CONSTRAINT chk_ledger_nonzero_delta
CHECK (
available_delta <> 0
OR reserved_delta <> 0
OR deferred_revenue_delta_cents <> 0
OR recognized_revenue_cents <> 0
OR platform_fee_deferred_delta_cents <> 0
OR platform_fee_recognized_cents <> 0
);
SQL
execute <<~SQL
ALTER TABLE billing_ledger_entries
ADD CONSTRAINT chk_reserve_moves_equal
CHECK (
entry_type <> 'reserve'
OR (available_delta < 0 AND reserved_delta > 0 AND available_delta = -reserved_delta)
);
SQL
execute <<~SQL
ALTER TABLE billing_ledger_entries
ADD CONSTRAINT chk_release_moves_equal
CHECK (
entry_type <> 'release'
OR (available_delta > 0 AND reserved_delta < 0 AND available_delta = -reserved_delta)
);
SQL
execute <<~SQL
ALTER TABLE billing_ledger_entries
ADD CONSTRAINT chk_consume_reduces_units
CHECK (
entry_type <> 'consume'
OR (available_delta <= 0 AND reserved_delta <= 0 AND (available_delta <> 0 OR reserved_delta <> 0))
);
SQL
You can add a more specific constraint for placement consume requiring recognized revenue fields when entitlement type is placement. Cross-table checks are harder in pure SQL; enforce via application validations.
9.5 billing_entitlement_holds
create_table :billing_entitlement_holds do |t|
t.references :billing_account, null: false, foreign_key: true
t.references :billing_entitlement_type, null: false, foreign_key: true
t.string :reference_type, null: false
t.bigint :reference_id, null: false
t.string :status, null: false, default: "active" # active, released, consumed, expired
t.bigint :units_held, null: false, default: 0
t.references :opened_ledger_entry, null: false, foreign_key: { to_table: :billing_ledger_entries }
t.datetime :opened_at, null: false
t.datetime :closed_at
t.timestamps
end
add_index :billing_entitlement_holds,
[:billing_account_id, :billing_entitlement_type_id, :reference_type, :reference_id],
unique: true,
where: "status = 'active'",
name: "idx_holds_unique_active"
9.6 billing_entitlement_lots (gig)
create_table :billing_entitlement_lots do |t|
t.references :billing_account, null: false, foreign_key: true
t.references :billing_entitlement_type, null: false, foreign_key: true
t.datetime :purchased_at, null: false
t.string :source_reference_type
t.bigint :source_reference_id
t.bigint :units_purchased, null: false
t.bigint :units_available, null: false
t.bigint :units_reserved, null: false
t.integer :platform_fee_rate_bps, null: false
t.bigint :platform_fee_total_cents, null: false
t.bigint :platform_fee_remaining_cents, null: false
t.timestamps
end
add_index :billing_entitlement_lots, [:billing_account_id, :purchased_at, :id], name: "idx_lots_fifo"
add_index :billing_entitlement_lots, [:billing_account_id], name: "idx_lots_account"
9.7 billing_entitlement_lot_allocations (gig)
create_table :billing_entitlement_lot_allocations do |t|
t.references :billing_ledger_entry, null: false, foreign_key: true
t.references :billing_entitlement_lot, null: false, foreign_key: true
t.string :allocation_type, null: false # reserve, consume, release, adjust
t.bigint :units_allocated, null: false
t.bigint :platform_fee_recognized_cents, null: false, default: 0
t.timestamps
end
add_index :billing_entitlement_lot_allocations, [:billing_ledger_entry_id], name: "idx_alloc_entry"
add_index :billing_entitlement_lot_allocations, [:billing_entitlement_lot_id], name: "idx_alloc_lot"
execute <<~SQL
ALTER TABLE billing_entitlement_lot_allocations
ADD CONSTRAINT chk_alloc_positive_units
CHECK (units_allocated > 0);
SQL
10) Recommended file layout in Rails (for mid-level engineers)
app/domains/billing/
models/
billing_account.rb
billing_entitlement_type.rb
billing_entitlement_balance.rb
billing_ledger_entry.rb
billing_entitlement_hold.rb
billing_entitlement_lot.rb
billing_lot_allocation.rb
services/
grant_entitlements.rb
reserve_entitlements.rb
release_hold.rb
consume_entitlements.rb
gig/
fifo_allocator.rb
consume_reserved.rb
release_reserved.rb
reporting/
statement_of_account.rb
daily_finance_summary.rb
accounting/
mappings.rb
journal_builder.rb
export_run.rb
Keep the integration points from Ads/Careers/Gig calling Billing services explicitly.
11) Final recommendations (so you don’t regret this later)
-
Single ledger is the right choice for you
- It makes statements and reporting simpler forever.
-
Keep holds and balances as projections
- They avoid expensive scans and enforce “one active hold per reference”.
-
Gig requires lots + allocations
- Don’t fight it. This is the minimal complexity that preserves correctness and auditability.
-
Never store money as decimals
- Use BIGINT cents + basis points for rates.
-
Store recognition amounts at consumption time
- This is what prevents Xero exports from becoming a fragile “recompute history” nightmare.
If you want, in the next step I can also provide:
- the exact Ruby service object pseudocode for
ReserveEntitlements/ConsumeEntitlementsincluding row locking order (to avoid deadlocks), - and a sample “Daily Journal CSV export” format that matches your current finance workflow and is straightforward to import into Xero later.