Billing Overview
High Level Domain
Fundamental Concepts
Service Entitlement vs money
Entitlement: what the customer can still use:
- placement_credit (sponsored placements)
- 16 Sponsored Placement coins left
- gig_credit_cents (stored value credits measured in cents)
- 23717 gig credits left (cents value of $237.17)
Money: is what finance cares about:
- deferred revenue (you owe since delivery)
- principal liability (refundable stored value)
- recognised revenue (earned)
The term "Entitlement" is important to memorise and understand. It will be used extensively throughout the documentation. It is an umbrella term to represent "what users CAN do on the platform", which can be anything that requires a "stored value" (i.e. credits):
- creating a
Career::Jobpost forXplacement credits - creating a
Gig::Jobwith shifts forYgig credits - creating a
Ad::Campaginover 7 days forZplacement credits
Ledger vs Projection
Ledger
- append-only journal of all balance-affecting actions
- grant, reserve, release, consume, adjust
Projection
- Cached "current state" tables for fast reads (e.g. balances, active holds)
- Projections are rebuildable from the ledger
Deferred Revenue vs Principal liability
Deferred Revenue
- money received in the bank account, but service is not yet provided (i.e.
SFRS(I) 15)
Principal Liability
- money/credits refundable to the customer
- behaves like a "stored value" that we owe back (not revenue)
Reservation is not consumption
- Reserve moves units from
available -> reserved - Consume reduces units
- usually from reserved, sometimes directly from available
- Ensures company cannot overspend by:
- multiple gig job postings
- multi-day campaigns where credits cost more than what they have
Why Gig is special?
Gig Credits require:
- FIFO (first in first out)
- Per-lot platform fee rates
- We MUST track consumption against purchase batches
Sponsored Placements (Ads, Career Job Post, Boost):
- does not need per-batch tracking like Gig
- track proportionally
Xero-friendly exports
- Finance process is lump-sum journaling (daily or monthly)
- Internal system still needs customer-level statements and auditability (SOA for companies)
- Design should support both without refactors
Rate Unit Convention
We store rates in two different ways. Which one we use depends on who sets the rate and whether it is used with external systems.
| Rate type | Who sets it | How we store it | Example column |
|---|---|---|---|
| Platform fee, discount, negotiated fee | Jod (sales / ops) | integer basis points | platform_fee_rate_bps |
| Agreement term rates | Jod (sales / ops) | integer basis points | term_value + term_unit = :bps |
| Tax rate | Government | decimal | tax_rate |
| FX rate (future) | Market | decimal | — |
First, a common misunderstanding
PostgreSQL decimal is not floating point. In Rails, a t.decimal column returns BigDecimal. BigDecimal math is exact — BigDecimal("0.09") * 10_000 gives exactly 900. Floating-point precision errors only happen if a developer casts the value to Float (.to_f) or a JSON serializer turns it into a JS Number.
So both formats can be exact. The choice between them is not "exact vs not exact."
The rule
- If Jod sets the rate AND we never send it to an external system → store it as an integer in basis points. The column name ends with
_bps. - If the rate comes from outside Jod OR we exchange it with an external system → store it as
decimal.
Why basis points for Jod-set internal rates?
It is "defence in depth." An integer column has no float to fall into, no precision trap, and no way for a JSON serializer to lose precision on the way out. Even if a developer misuses the value, the math stays exact.
We also apply these rates to money values stored in cents (integers). Keeping the rate as an integer means the whole calculation stays in integer math:
fee_cents = amount_cents * rate_bps / 10_000
This is exact, simple, and safe to ship through any serializer or external library.
Why decimal for outside rates?
When the rate comes from outside Jod (tax, FX), we want it in the same format as the source:
- Tax authorities publish rates as percent. IRAS says "GST is 9%". We store
0.09. - Xero and other accounting tools use decimal percent.
If we stored these as bps, we would need to convert back to decimal every time we send the rate to an external system. That is ongoing work for no real benefit, because tax math happens in fewer places (one calculation per invoice line) and tax rates are simple round numbers (0.07, 0.09) where the risk of accidental float errors is very low.
Before adding a new rate column
Ask:
- Who sets this rate? (Jod or someone outside Jod?)
- Will we send this rate to an external system?
If Jod sets it AND it stays inside our system → use _bps (integer).
If anything else → use decimal.
Overview
::Billing will be the bounded context that manages:
- customer entitlements (units)
- deferred revenue / liability movements (money)
- reservations and consumption
- statement of accounts (SOA)
- finance exports compatible with current journaling workflow and future Xero Integration
Products/Instruments supported
Placement Credits (visibility-driven, pool deferred revenue)
- Ads (display placements)
- Job Boost (featured placement for a duration)
- Careers Job Postings (per post/per application later)
Gig Credits (stored value + FIFO lots for platform fee):
- credit represents wage value
- platform fee deferred and recognised by FIFO lots
Subscription (Workforce, future)
- introduce later as another entitlement type
- seat-days
- seat-months
- or contract schedules
Glossary
| term | description | examples |
|---|---|---|
| Entitlement Type | defines unit type and accounting policy | 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 | 10030cents available gig credits, 10 placement credits reserved for 7 day campaign |
| Hold | active reservation projection keyed by a reference | Gig::Shift#123, Ad::CampaignPlacement#456 |
| Lot | gig purchase batch with its own platform fee rate. Used for FIFO allocation | Company A would have 3 Billing::Lot if they purchased gig credits 3 different times. Each lot would store the platform fee margin. |
| Allocation | mapping from a ledger entry to one or more lots (gig only) | - |
| Reference | external domain object that caused the billing event | Ads::Campaign, Careers::Job, Gig::Shift (reserve), Gig::ShiftSettlement (consume) |
| Product | global definition of what is being sold (stable); used to derive entitlements granted | 1000 Placement Credit Pack without the price attached to it. We can sell the same pack across different countries. |
| Offer | market-specific price list row for a product (currency, taxes, seller legal entity, gig fee terms) | 1000 Placement Credit Pack in SG costs $100, while in ID it might cost IDR 1,000,000. Tax calculation also is different in each country |
| Legal Entity | your seller-of-record for a jurisdiction (drives invoice numbering + tax treatment) | Represents Jod legal entities in different countries. Possible to have multiple entities in a single country. |
| Bill-To Profile | customer billing recipient details (address/contact), owned by the customer company | Company A might use the platform, but it's sister company might be the one that makes payments on behalf of Company A. |
| Invoice | customer-facing commercial document generated from an offer snapshot | - |
| Payment | offline bank transfer record + verification status | Each invoice tied to one or more payments recorded manually by business/finance team |
| Posting | the idempotent internal action that grants entitlements into the ledger once an invoice is settled | An accounting action and not a "product use-case". |
Modelling canonical "financial primitives"
Stable finance primitives we can use instead of using domains (Ads/Gig/etc.).
These primitives apply to all instruments listed in billing_entitlements (e.g. gig, placement, workforce, etc.)
- Only the policies differ per entitlement type.
| term | description | example |
|---|---|---|
| Grant | increase available entitlements (usually after payment verification) | Company made payment via bank transfer. Business marks invoice as paid and credits will be "granted" to the company's billing account |
| Reserve (Hold) | move available to reserved (to prevent overspend) | 100 credits reserved after company posts a Gig::Shift, 20 credits reserved after company creates an Ad::Campaign |
| Release | move reserved back to available (campaign cancelled, shift cancelled) | Previously reserved 100 credits moved back into usable credits cause Gig::Shift was cancelled. |
| Consume | reduce reserved or available (service delivered) | Gig::ShiftSettlement created, Daily for a 7 day ad campaign. |
| Adjust | manual corrections | Think CAFS |
Entitlement TYpes
Placement Credits (placement_credits)
Services granted:
- Ads (display placements)
- Job Boost (featured placement for a duration)
- Careers Job Posting (per post / per application)
Finance Policy:
- Units are pooled regardless of price and time of purchase
- no per-purchase unit price tracking like in Gig
- Money tracking:
deferred_revenue_cents(remaining)- revenue recognised proportionally at consumption time:
recognised = units_consumed * (deferred_revenue_before / units_before)
Gig Credits (gig_credit_cents)
- Units represents the wage value (in cents)
- Requires FIFO lots because platform fee rate can differ per purchase/invoice.
- Money tracking:
- principal liability
- Jod owes stored value back or owes to provide a service like finding applicants for Gig or placing an ad on the website.
- platform fee deferred and recognised based on FIFO lot allocations
- principal liability
- Reservation may span multiple lots
Subscriptions (future)
Subscriptions (e.g. for Workforce management) will be introduced as another entitlement type (seat-days, seat-months) and/or based on the contract from sales.
Projections (fast reads) vs ledger (truth)
Two main projections:
billing_entitlement_balances- what is the available
gig_credits_cents/placement_credits? - what is the reserved
gig_credits_cents/placement_credits? - what is the deferred revenue existing on
gig_credits_cents/placement_credits?
- what is the available
billing_entitlement_holds- what active holds (
gig_credits_cents/placement_credits) exist for a given reference (e.g.Gig::Shift,Ad::Campaign,Boost::Job)
- what active holds (
Both should be updated in the same DB transaction as when creating a ledger entry record. Both are rebuildable from ledger (and lots for gig)
Considering Xero Integration
We design for two export modes for Xero automation:
- Journal mode
- Aggregate daily totals across the platform
- movement in deferred revenue
- recognised revenue
- gig credits consumed (liability reduction)
- insurance deductions/sponsored amounts
- Export as journal lines (CSV now, API later)
- Aggregate daily totals across the platform
- Invoice mode (future)
- Later we want per-customer sales invoices in Xero
- Keep
billing_invoicesandbilling_payments - Ledger remains the "delivery and recognition" system of record
- Keep
- Later we want per-customer sales invoices in Xero
Ledger stores recognition snapshots at the moment of consumption.
- ensure can export accounting entries later without recomputing history and risking drift.