Billing::LedgerEntry
Purpose
The ledger is the source of truth for everything a company's credits do. One row = one movement of units or revenue, for one account and one instrument. Rows are only added — never edited, never deleted.
Everything else is derived from these rows:
- the customer's statement of account is the rows themselves, in order
- the balance projection is the running total of the rows
- the finance export reads the revenue columns of one day's rows
- the auditor's schedules are the rows grouped by entry type
The ledger exists as its own table because billing needs one place where history cannot be rewritten. A balance can be recomputed. A statement line, once written, must read the same forever.
Schema truth: jodapp-api/docs/db/billing.dbml, table billing_ledger_entries.
Model Context
Legend: Lavender nodes belong to the Billing domain. Grey nodes are external. The yellow node is the subject of this spec.
| Context | Details |
|---|---|
| Aggregate | Billing::LedgerEntry has no children. It is part of the Billing::Account aggregate. Allocation rows belong to their entry. |
| Layer | Entitlement engine |
| Upstream dependencies | Billing::Account, Billing::Entitlement, and one source record per entry (posting, credit action, campaign, shift, post, boost, lot, refund record) |
| Downstream dependents | Billing::EntitlementLotAllocation, Billing::EntitlementBalance, statements of account, the Xero export |
The entry types
Seven entry types cover every movement. The two delta columns say what each type does to the account's units:
entry_type | Meaning | available_delta | reserved_delta | Deferred revenue | Recognised revenue |
|---|---|---|---|---|---|
grant | Credits enter the account. Creates one lot. | + | 0 | + (0 for free) | 0 |
reserve | Credits set aside for pending work. | − | + | 0 | 0 |
release | Reserved credits go back. Work cancelled or finished under budget. | + | − | 0 | 0 |
consume (from reserved) | Service delivered from reserved credits — ad campaigns, gig shifts. | 0 | − | − | + |
consume (direct) | Instant delivery from available credits — Careers posts, boosts (D5). | − | 0 | − | + |
expire | A lot passed its date; leftover units removed, breakage earned. | − | 0 | − | + |
refund | Full gig exit. The fee is kept and recognised (D4). | − | 0 | − | + |
adjust | The record of an admin moving a lot's expires_at later. It moves no units and no revenue — it only makes the date change visible in the statement. No other adjust use is designed yet (see UC-7). | 0 | 0 | 0 | 0 |
Two rules sit behind the two revenue columns:
- On
consume,expire, andrefund:recognised_revenue_centsalways equals the negative ofdeferred_revenue_delta_cents. The amount leaves deferred revenue and becomes recognised revenue — the same cents, both sides written down. - The amounts are computed per lot with the one recognition formula and copied onto the entry at that moment. They are never recomputed later. Re-running any report over old rows gives the same numbers, forever.
How one movement is written
Every movement — any type, any instrument — is one database transaction with the same shape:
Legend: One box = one step. All six steps happen inside one database transaction — all of it commits, or none of it.
The first step is the important one. Every ledger write holds the account's balance row lock. Two movements can never run at the same moment for one account. This is the replay rule from D5: because writes are serialised per account, replaying an account's entries in id order reproduces every balance exactly. That replay is what lets finance derive any number they ask for — including the pooled averages we chose not to store (D2).
The reference columns
source_type / source_id has one uniform meaning everywhere: the record at the root of why this row exists.
| Entry type | The source is |
|---|---|
grant (paid) | The Billing::InvoicePosting |
grant (free) | The Billing::CreditAction of type trial_grant or goodwill_grant |
reserve / consume / release | The spending deliverable — Ads::CampaignPlacement, Gig::Shift, a Careers job, a boost |
expire | The Billing::EntitlementLot that passed its date |
refund | The commercial refund record — the planned record of the bank repayment (amount, bank proof, verification, admin approval), mirroring Billing::Payment but for money going out. Its model is not designed yet (D4). |
adjust | The Billing::CreditAction of type expiry_extension that moved the lot's expires_at |
One grant is missing from this table on purpose. The grant that creates an opening lot carries a company's balance into billing on the day an instrument goes live (Billing::EntitlementLot UC-3). Its source record is not designed yet. That design belongs to Phase 4, with the rest of the gig flip (Billing D9 — The gig flip's opening balances are not credit actions).
The combination table in Billing::EntitlementLotAllocation — What one row records shows, per entry type:
- the source from this table
- the
Billing::EntitlementLotAllocationrows the entry writes - the effect on the deliverable's reserved credits (D8)
- one moment of the shared Acme example
Three more columns complete the envelope:
occurred_at— when the movement happened. Day grouping for exports uses the company's timezone.idempotency_key— unique. Makes every write safe to run twice: a retry finds the existing row instead of writing a second one.org_outlet_id— copied onto gig reserve, consume, and release entries so outlet-level statements need no cross-domain joins. Empty for grants and non-outlet movements. Cannot change once written.
Worked example: Acme's statement of account
The same Acme Foods story as the Billing::EntitlementLot spec's worked example, seen from the ledger. Six entries, in id order:
One column per entry, in id order. Read a column top to bottom to see one event. Read the Billing::EntitlementBalance rows left to right to watch the balances move through the year.
| Entry | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
Billing::LedgerEntry — stored on each entry | ||||||
| When | 1 Feb 2027 | 1 Feb 2027 | 10 Feb 2027 | 15 Feb 2027 | campaign end | 1 Feb 2028 |
entry_type | grant | grant | consume | reserve | consume | expire |
available_delta | +100 | +20 | −5 | −30 | 0 | −85 |
reserved_delta | 0 | 0 | 0 | +30 | −30 | 0 |
| Deferred revenue Δ | +50,000 | 0 | 0 | 0 | −7,500 | −42,500 |
| Recognised revenue | 0 | 0 | 0 | 0 | +7,500 | +42,500 |
Billing::EntitlementBalance — the projection after each entry | ||||||
| Available | 100 | 120 | 115 | 85 | 85 | 0 |
| Reserved | 0 | 0 | 0 | 30 | 0 | 0 |
| Deferred revenue | 50,000 | 50,000 | 50,000 | 50,000 | 42,500 | 0 |
Read what the table shows:
- The
Billing::EntitlementBalancerows are not stored on any entry. They are the running totals the projection caches — updated in the same transaction as each entry, and reproducible by replay. - Entry 3 recognises 0 because the spend order took trial credits first. Entry 5 recognises SGD 75 and entry 6 recognises SGD 425 — which allocation rows split per lot.
- The customer's statement page renders these rows with labels ("Purchased 100 credits", "Reserved 30 credits for campaign #999"). The statement is not built from the ledger. It is the ledger.
State Machine
A ledger entry has no states. It is written once, inside the transaction above, and never changes after commit. There is nothing to transition — the interesting lifecycle is the write path, not the row.
Use Cases
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Record a grant when credits enter an account | Invoice posting runs, or an admin credit action | System |
| UC-2 | Record a reserve when credits are set aside for pending work | A campaign or shift needs reserved credits | System |
| UC-3 | Record a release when reserved credits go back | Work cancelled, or finished under the reserved amount | System |
| UC-4 | Record a consume when service is delivered | A campaign day runs, a shift completes, a post publishes | System |
| UC-5 | Record an expire when the cleanup job closes a dead lot | The nightly cleanup job | System |
| UC-6 | Record a refund for a full gig exit | Finance executes the exit | System |
| UC-7 | Record an adjust when an admin extends a lot's expiry | A Billing::CreditAction of type expiry_extension | System |
| UC-8 | View a statement of account | An employer or admin opens the statement page | Employer / Admin |
| UC-9 | Rebuild a balance projection by replaying entries | A projection is suspected wrong, or a new projection is added | System |
| UC-10 | Export one day of entries to Xero | The daily finance export runs | System |
UC-1: Record a grant when credits enter an account
| Field | Details |
|---|---|
| Actor | System (invoice posting, or executing a Billing::CreditAction) |
| Trigger | An invoice reaches status: :paid and posts, or an admin grant is executed |
Preconditions:
- A source record exists: the posting or the credit action.
System Behaviour:
- Write one
grantentry per granted line:available_delta= the granted units, positivedeferred_revenue_delta_cents= the amount that becomes deferred revenue for this purchase:- placement: the credit sale price (after discount, before GST)
- gig: the platform fee only
- free credits: 0
- the
Billing::EntitlementLotspec defines this deferred revenue per instrument in The deferred revenue on a lot
- The same transaction creates the lot — see
Billing::EntitlementLotUC-1 to UC-3. - Write one allocation row linking the entry to the new lot (D7):
units_allocated= the granted unitsallocation_type: :grantrecognised_revenue_cents: NULL— a grant earns nothing
Business Rules:
- A grant never touches reserved units.
- The idempotency key comes from the source, so re-posting an invoice cannot grant twice.
- The allocation row is the only direct link between a grant entry and its lot. The entry's source is the
Billing::InvoicePosting; the lot's source is theBilling::InvoiceLine(D7).
Postconditions:
- The account's available units and deferred revenue both grew by the entry's deltas.
UC-2: Record a reserve when credits are set aside for pending work
| Field | Details |
|---|---|
| Actor | System |
| Trigger | Ads, Gig, or another spender asks billing to reserve credits |
Preconditions:
- Available units across non-expired lots cover the request. Otherwise nothing is written.
System Behaviour:
- Write one
reserveentry:available_delta= the reserved units, negativereserved_delta= the same units, positive- both revenue columns: 0
- Allocation rows name the exact lots, in the spend order.
- The credits are now set aside for this deliverable. The deliverable's reserved credits are not stored as a row: they are the net of the entries naming it (D8).
Business Rules:
- A reservation moves no revenue. Nothing was delivered, so nothing is recognised and nothing reaches Xero.
- A deliverable that still has reserved credits cannot reserve again. If the net of the entries naming this source is above zero, the reserve is refused. The check runs under the balance row lock, so two racing reserves cannot both pass (D8).
Postconditions:
- Units moved from available to reserved. The deliverable now has reserved credits.
UC-3: Record a release when reserved credits go back
| Field | Details |
|---|---|
| Actor | System |
| Trigger | The work is cancelled, or completes using less than reserved |
Preconditions:
- The source has reserved credits — the net of the entries naming it is above zero.
System Behaviour:
- Write one
releaseentry:available_delta= the returned units, positivereserved_delta= the same units, negative- both revenue columns: 0
- Allocation rows return the units to the same lots they came from.
- If a lot expired while its units were reserved, an
expireentry follows in the same transaction (Billing::EntitlementLotUC-6).
Business Rules:
- Billing never writes a release on its own clock. The trigger always comes from the source domain — for example Ads auto-rejecting a stuck campaign after 14 days (D5).
Postconditions:
- The deliverable's reserved credits shrank, or reached zero. Units are available again — or expired, if their lot died meanwhile.
UC-4: Record a consume when service is delivered
| Field | Details |
|---|---|
| Actor | System |
| Trigger | A campaign day runs, a shift completes, a Careers post or boost publishes |
Preconditions:
- From reserved: the source's reserved credits cover the amount. Direct: available units cover it.
System Behaviour:
- Write one
consumeentry:- from reserved:
reserved_delta= the consumed units, negative - direct:
available_delta= the consumed units, negative
- from reserved:
- Allocation rows carry the per-lot split and per-lot recognised amounts (
Billing::EntitlementLotUC-5). - The entry's two revenue columns hold the totals:
deferred_revenue_delta_centsgoes down by the recognised amountrecognised_revenue_centsgoes up by the same amount
Business Rules:
- For time-spread campaigns, the daily amount is computed from the campaign's total: consume today = round(cost × days elapsed ÷ total days) − consumed so far (D5). Safe to re-run: the same day computes zero twice.
Postconditions:
- Units are gone, revenue is earned, and the statement shows the delivery.
UC-5: Record an expire when the cleanup job closes a dead lot
| Field | Details |
|---|---|
| Actor | System (nightly cleanup job) |
| Trigger | A lot passed expires_at with units left |
Preconditions:
- The lot has available units and its date has passed.
System Behaviour:
- Write one
expireentry per lot, in its own transaction. Source = the expiringBilling::EntitlementLotitself. - On the entry:
available_delta= the leftover units, negativedeferred_revenue_delta_cents= the lot's remaining deferred revenue, negativerecognised_revenue_cents= the same amount, positive — this is the breakage
Business Rules:
- One lot per transaction — one bad row cannot block other companies (D3).
- The idempotency key makes a re-run harmless.
Postconditions:
- The lot is settled. Breakage revenue carries the expiry date, not the day the job ran.
UC-6: Record a refund for a full gig exit
| Field | Details |
|---|---|
| Actor | System (executing finance's decision) |
| Trigger | A company exits gig |
Preconditions:
- Reserved units are zero.
- All outlet budgets are deallocated back to the company pool.
- The commercial refund record exists. This is the planned record of the bank repayment — the amount paid back, the bank proof, the verification, and the admin approval. It mirrors
Billing::Payment, but for money going out. Its model is not designed yet (D4).
System Behaviour:
- Write one
refundentry for the whole exit. Source = the commercial refund record (its model class is not named yet — see the preconditions above). - In the same transaction (
Billing::EntitlementLotUC-9):- one allocation row per lot
- every lot gives up all of its available units
- each lot's remaining fee is recognised
Business Rules:
- The bank repayment is not a ledger amount. The ledger records units leaving and fee recognised; the bank amount paid back lives on the refund record.
- Refund entries move no GST (D4).
Postconditions:
- The account's gig balance is zero. Every lot is settled.
UC-7: Record an adjust when an admin extends a lot's expiry
| Field | Details |
|---|---|
| Actor | System (executing a Billing::CreditAction of type expiry_extension) |
| Trigger | An admin gives a lot a later expiry date |
Preconditions:
- The
Billing::CreditActionof typeexpiry_extensionexists, with its reason. One admin is enough — there is no second approver (Billing D10 — A credit action needs one admin, not two).
System Behaviour:
- Write one
adjustentry with every delta at zero. Source = theBilling::CreditAction. - Write one allocation row for the lot the
Billing::CreditActionextends (D7):units_allocated: 0— a date change moves nothingallocation_type: :adjustrecognised_revenue_cents: NULL
- The entry and its allocation row exist so the date change shows in the statement, the audit trail, and the lot's own history. The lot-side rules live in
Billing::EntitlementLotUC-8.
Business Rules:
- This is the only
adjustuse the system supports today.
Postconditions:
- The audit trail shows what changed and who decided it.
Open Questions:
- Finance corrections through the ledger are not designed. Undecided: which correction scenarios are legal at all, what record authorises one (
billing_credit_actionshas no correction action type today), whether a second approver is required, and whether a correction may touch recognised revenue. The known big cases already have other answers — a wrong invoice is voided and recreated, and the gig-flip drift is a finance journal in Xero (D6).
UC-8: View a statement of account
| Field | Details |
|---|---|
| Actor | Org::Membership (employer) or Identities::Admin |
| Trigger | The statement page or a statement export |
Preconditions:
- The account exists.
System Behaviour:
- Fetch the account's entries for the period, one instrument at a time, ordered by
occurred_at, thenid. - Render each entry as one statement line:
- the date
- a plain label built from the entry type and the source
- the units moved
- the revenue recognised
- Compute running balances by summing deltas in order.
Business Rules:
- Outlet statements filter on
org_outlet_id— no joins into the Gig domain. - The statement must add up: the last running balance equals the balance projection, always.
Postconditions:
- Read-only operation — no data changes.
UC-9: Rebuild a balance projection by replaying entries
| Field | Details |
|---|---|
| Actor | System |
| Trigger | A projection is suspected wrong, or a new projection type is introduced |
Preconditions:
- None. The ledger is complete by construction.
System Behaviour:
- Sum the account's entries in
idorder into three totals:- available units
- reserved units
- deferred revenue
- Compare with the stored projection. They must match — the balance-row lock guarantees the replay order is the write order.
- On mismatch: the projection is wrong, never the ledger. Fix the projection code, rewrite the projection row.
Business Rules:
- The ledger is never "corrected" to match a projection.
Postconditions:
- Read-only over the ledger. Only projection rows may be rewritten.
UC-10: Export one day of entries to Xero
| Field | Details |
|---|---|
| Actor | System (daily finance export) |
| Trigger | The export runs for one day, one legal entity |
Preconditions:
- The day is complete in the company's timezone.
System Behaviour:
- Select the day's entries.
- Group them by entry type and instrument.
- Sum the two revenue columns per group.
- Map each group to journal lines, using the mapping table in Xero Integration. That page also defines:
- which rows the export skips (reserves, releases, free credits, and the grants that create opening lots)
- the verification report finance approves before posting
Business Rules:
- The export reads stored amounts only. It never recomputes from balances (why).
Postconditions:
- Read-only operation — no data changes. The export run record is written outside this model.
Invariants
- Entries are append-only. No update, no delete, no exceptions. A wrong row is never edited — any future correction mechanism must add new rows, and that mechanism is not designed yet (UC-7).
- Every write holds the account's balance row lock. One account, one movement at a time (D5).
- Replaying an account's entries in
idorder reproduces the balance projection exactly. On mismatch, the ledger wins. - The deltas follow the sign table in The entry types. The only specified
adjustentry is UC-7's zero-movement record of an admin moving a lot'sexpires_atlater. - On
consume,expire, andrefund:recognised_revenue_cents = −deferred_revenue_delta_cents. - Revenue amounts are copied onto the entry when it is written and never recomputed.
- Every entry has a source. The source is the record at the root of why the entry exists — the same naming rule as
Billing::EntitlementLotandBilling::OutletBudgetTransfer. Those three tables are the only ones withsource_type/source_idcolumns. idempotency_keyis unique. Every writer supplies one derived from its source, so retries cannot double-write.- Every entry is written in the same transaction as its allocation rows — at least one, one per lot the entry applies to (D7) — together with the lot counter and balance updates.
- Free credits produce entries with zero revenue. Reserves and releases always carry zero revenue.
org_outlet_idis set on gig spend entries at write time and cannot change.- Ledger history makes entitlement policies permanent: once an instrument has entries, its policies cannot change (D2).
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Billing::Account | Entry belongs to Account | The account's balance row lock serialises all writes. |
Billing::Entitlement | Entry scoped to an instrument | Groups statements, exports, and policies per instrument. |
Billing::EntitlementLot | Moved via allocations | Lot counters change only together with an entry. |
Billing::EntitlementLotAllocation | Entry has at least one allocation row | One row per lot the entry applies to — together, the complete history of each lot (D7). |
Billing::EntitlementBalance | Projection of entries | Updated in the same transaction; rebuildable by replay (UC-9). |
| A deliverable's reserved credits (D8) | Not a table — computed when asked | The net of the entries naming the deliverable's source. The per-lot split derives from their allocation rows. |
Billing::InvoicePosting | Source of paid grants | Posting idempotency and grant idempotency are the same guarantee. |
Billing::CreditAction | Source of free grants and adjusts | The human decision behind entries with no commercial document. |
Ads::CampaignPlacement, Gig::Shift, Careers posts, boosts | Source of spends | The reference a statement line names. |
| Commercial refund record (to spec) | Source of refunds | Holds the bank amount and proof; the ledger holds the unit and fee movements. |