High-Level Ramp-Up
1. Why a single ledger table?
A ledger table is a specialised, tamper-evident database table used to store immutable verified transaction records.
- Append-only (only can insert rows)
- Cannot update existing rows
- we will add a transaction to "undo" or "change" a previously stored transaction
In Jod, the table name is billing_ledger_entries.
What we need long-term
- Gig Credits
- customer statements (i.e. SOA)
- Placement Credits
- customer statements (i.e. SOA)
- Subscriptions
- workforce management (managing shifts for full time staff)
- private jodgig community (send invites to a group of Talents)
- browser Talent profiles (like LinkedIn Recruiter)
- Easy to filter, paginate, query via date-ranges across different entitlements from a single table (similar to Listings::Job)
- Clean audit trail for Finance and Ops
- Consistent idempotency and concurrency behaviour across all billing actions
Why multiple action tables is more work?
If we split into topups, reservations, usages, we'll constantly:
- union or join 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:
Single
billing_accountslinked to anOrg::Company& &billing_entitlements&date range&order bytime
2. The canonical “financial primitives” we model
Inside Billing, we name things by stable finance primitives (not Ads/Gig/etc.):
These primitives apply to all instruments; only the policies differ per entitlement type.
| Financial Primitive | Description |
|---|---|
| Grant | increase available entitlements (usually after payment verification) |
| Reserve | move available to reserved (to prevent overspend) |
| Release | move reserved back to available (campaign canceled, shift canceled) |
| Consume | reduce reserved or available (service delivered) |
| Expire | remove leftover units after a lot passes its expiry date; the leftover money becomes revenue (breakage) |
| Refund | pay back the whole remaining principal on a full gig exit; the fee is kept |
| Adjust | the record of an admin extending a lot's expires_at; moves no units and no money |
3. Policies per entitlement type
The billing_entitlements table defines three instruments. Two are live today:
instrument: :placement- ad campaign
- career job posting
- boost job
instrument: :gig- stored value of wages
instrument: :workforceis reserved in the enum for a future subscription service
Placement Credits (instrument: :placement)
- 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 stored value (in cents).
- Each purchase creates one
EntitlementLot, because the platform fee rate can differ per purchase. - Money tracking:
- principal liability (the wage value owed back to the customer; never revenue)
- the platform fee sits on the lot as deferred revenue, recognised per lot as units are consumed
- Gig lots have no expiry date, so the spend order is plain first-in-first-out.
- A reservation may span multiple lots.
4. Projections (fast reads) vs ledger (truth)
We store one main projection:
billing_entitlement_balances: fast “what’s available/reserved/deferred?”
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. Lot counters are rebuildable from allocation rows the same way.
5. Considerations for Xero integration
We design for two export modes:
- Journal mode (current process)
- 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)
- 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
- If you later want per-customer sales invoices in Xero:
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.
6. Commercial Layer: Catalog + Invoicing (Products, Prices, Invoices, Payments)
Think of billing in terms of layers. The top most layer is called the Commercial Layer.
It is the "entry point" or the "start" of any billing process.
- Products must exist before being able to create an invoice
- Invoice must exist before a payment can be made
- Payments must exist before we can grant credits.
Why this is a separate concern from the ledger?
Entitlements ledger answers:
- “What does the customer still have, and what happened over time?”
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 (prices are archived and recreated), 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 Price (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 line stores a copy of the price terms at invoice creation (price, currency, tax, fee rate terms). Later catalog changes do not change history.
Invoice lifecycle (minimal but robust)
Keep the invoice state machine small but correct for partial payments:
| invoice states | description |
|---|---|
draft | prepared internally (not sent) |
issued | customer can pay (bank transfer initiated) |
partially_paid | at least one verified payment, but sum(verified) < invoice total |
paid | sum(verified payments) >= invoice total |
void | 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 in the same DB transaction.
- Unique constraint on postings prevents double-grants if finance clicks “verify” twice.
- 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:
- Instrument:
instrument: :placement(or:gig) - 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:
- a new
instrumentvalue, for example:action
Both instruments can share the same accounting model:
- entitlement lots (Billing D2 — all stored-value credits use entitlement lots)
- deferred revenue carried per lot
- revenue recognised per lot at consumption time
- same commercial flow (price copy → invoice → payments → posting → ledger)
The difference would be only in how other domains consume it (days/placements vs measurable actions).