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, we 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_entitlement_types&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 (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 |
3. Policies per entitlement type
We have 3 types of entitlement types:
placement_credits- ad campaign
- career job posting
- boost job
gig_credits_cents- stored value of wages
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.
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).
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
- recognized 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, Offers, 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 (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 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 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:
| 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 (gig) 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:
- Entitlement Type code:
placement_credit(orgig_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).