Billing Overview
High Level Domain
Fundamental Concepts
Service Entitlement vs money
Entitlement: what the customer can still use:
instrument: :placement— credits for sponsored placements- 16 placement credits left
instrument: :gig— stored value counted in cents- 23,717 cents left (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
Careers::Jobpost forXplacement credits - creating a
Gig::Jobwith shifts forYgig credits - creating an
Ads::CampaignPlacementover 7 days forZplacement credits
Ledger vs Projection
Ledger
- append-only journal of all balance-affecting actions
- grant, reserve, release, consume, expire, refund, adjust
Projection
- Cached "current state" tables for fast reads
billing_entitlement_balances— one row per account and instrument
- 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
One lot mechanism for every stored-value credit
All stored-value credits use entitlement lots — placement and gig both. Every grant creates one EntitlementLot (a purchase batch). Decided in Billing D2 — all stored-value credits use entitlement lots.
Purchase batches exist because three product rules need them:
- Credit expiry — credits from different purchases die on different dates.
- Free trial credits — a trial lot carries SGD 0 and never blends with paid credits.
- Gig fee rates — each gig purchase can carry its own platform fee rate.
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, deferred revenue on each lot)
- Ads (display placements)
- Job Boost (featured placement for a duration)
- Careers Job Postings (per post/per application later)
Gig Credits (stored value, lots carry the platform fee):
- credit represents wage value
- the platform fee sits on each lot as deferred revenue, recognised as the lot's units are consumed
Subscription (Workforce, future)
- introduce later as another entitlement type
- seat-days
- seat-months
- or contract schedules
Glossary
| term | description | examples |
|---|---|---|
| Entitlement Type | defines the unit and the accounting policies for one kind of credit | instrument: :placement (unit credit), instrument: :gig (unit cent) |
| Ledger Entry | one atomic balance-changing record (append only) | - |
| Balance | current available/reserved units and deferred money for a given entitlement type | 23,717 cents of gig credits available; 10 placement credits reserved for a 7-day campaign |
| Deliverable | one service the platform owes and must deliver: an Ads::CampaignPlacement, a Gig::Shift, a Careers::Job post, or a job boost. A deliverable reserves credits while pending, consumes them as it delivers, and releases what it does not deliver | Ads::CampaignPlacement#456 reserves 30 credits, then consumes them over 14 days |
| Deliverable's reserved credits | not a table — the credits set aside for one deliverable, computed from the ledger entries naming it as source (Billing D8 — reserved credits are computed from the ledger) | the credits reserved for Gig::Shift#123 |
| Lot | one purchase batch of credits, for every stored-value instrument. Carries units, the money attached to them, and an optional expiry date (Billing D2 — all stored-value credits use entitlement lots) | Company A has 3 EntitlementLot rows after buying gig credits 3 times. Each lot stores its own platform fee rate. |
| Allocation | one row per lot a ledger entry applies to. A lot's rows are its complete history (Billing D7 — allocation rows are the complete history of a lot) | a reserve that draws from 2 lots writes 2 EntitlementLotAllocation rows |
| Source | source_type / source_id — the record at the root of why a row exists | Ads::CampaignPlacement (a campaign spends), Gig::Shift (a shift spends), Careers::Job (a post consumes) |
| 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. |
| Product Price | market-specific price list row for a product (currency, taxes, seller legal entity, gig fee terms) — table billing_product_prices | 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". |
| Credit Action | the way an admin writes a ledger entry when no commercial document exists: a trial grant, a goodwill grant, or an expiry extension | Billing::CreditAction with action_type: :trial_grant |
| Credit Expiry Notice | the send log of the credit expiry warning emails. One row = one email actually sent | Billing::CreditExpiryNotice with days_before: 30 |
| Outlet Budget | partitions a company's balance into per-outlet spending buckets | NTUC funds each outlet's hiring separately |
| Outlet Budget Transfer | append-only log of credits moved between the unallocated pool and an outlet budget | - |
| Unallocated pool | the part of a company's balance not labelled for any outlet. Derived, never stored | company available units minus the sum of its outlet budgets |
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 | move available to reserved (to prevent overspend) | 100 credits reserved after company posts a Gig::Shift, 20 credits reserved after company creates an Ads::CampaignPlacement |
| 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) | A Gig::Shift completes; a campaign consumes its daily amount; a Careers::Job post publishes |
| Expire | remove leftover units after a lot passes its expiry date; the leftover money becomes revenue (breakage) | The nightly cleanup job closes a dead lot |
| Refund | pay back the whole remaining principal on a full gig exit; the fee is kept | A company leaves the gig platform |
| Adjust | the record of an admin extending a lot's expires_at. Moves no units and no money | A Billing::CreditAction of type expiry_extension runs |
Entitlement Types
Placement Credits (instrument: :placement)
Services granted:
- Ads (display placements)
- Job Boost (featured placement for a duration)
- Careers Job Posting (per post / per application)
Finance Policy:
- Every grant creates one
EntitlementLot. The sale price sits on the lot as deferred revenue. - Spending takes units from lots in the spend order: earliest expiry date first.
- Revenue is recognised per lot when units are consumed or expire.
- The formula lives in the deferred revenue on a lot.
- Decided in Billing D2 — all stored-value credits use entitlement lots.
Gig Credits (instrument: :gig)
- Units represent the wage value (in cents)
- Each purchase creates one
EntitlementLot, because the platform fee rate can differ per purchase. - Money tracking:
- principal liability
- the wage value Jod owes back to the customer. It never becomes revenue.
- the platform fee sits on the lot as deferred revenue, recognised as the lot's units are consumed
- principal liability
- Gig lots have no expiry date, so the spend order is plain first-in-first-out
- A 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)
One main projection:
billing_entitlement_balances- what is available, per instrument?
- what is reserved, per instrument?
- what deferred revenue exists, per instrument?
There is no table for the reserved credits of a deliverable. They are computed from the ledger entries naming that deliverable as source — see Billing D8 — reserved credits are computed from the ledger.
The balance row is updated in the same DB transaction as each ledger entry, and is rebuildable by replaying the ledger. The lot counters are rebuildable from allocation rows the same way.
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.