Skip to main content

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.

ContextDetails
AggregateBilling::LedgerEntry has no children. It is part of the Billing::Account aggregate. Allocation rows belong to their entry.
LayerEntitlement engine
Upstream dependenciesBilling::Account, Billing::Entitlement, and one source record per entry (posting, credit action, campaign, shift, post, boost, lot, refund record)
Downstream dependentsBilling::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_typeMeaningavailable_deltareserved_deltaDeferred revenueRecognised revenue
grantCredits enter the account. Creates one lot.+0+ (0 for free)0
reserveCredits set aside for pending work.−+00
releaseReserved credits go back. Work cancelled or finished under budget.+−00
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−+
expireA lot passed its date; leftover units removed, breakage earned.−0−+
refundFull gig exit. The fee is kept and recognised (D4).−0−+
adjustThe 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).0000

Two rules sit behind the two revenue columns:

  • On consume, expire, and refund: recognised_revenue_cents always equals the negative of deferred_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 typeThe source is
grant (paid)The Billing::InvoicePosting
grant (free)The Billing::CreditAction of type trial_grant or goodwill_grant
reserve / consume / releaseThe spending deliverable — Ads::CampaignPlacement, Gig::Shift, a Careers job, a boost
expireThe Billing::EntitlementLot that passed its date
refundThe 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).
adjustThe 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::EntitlementLotAllocation rows 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.

Entry123456
Billing::LedgerEntry — stored on each entry
When1 Feb 20271 Feb 202710 Feb 202715 Feb 2027campaign end1 Feb 2028
entry_typegrantgrantconsumereserveconsumeexpire
available_delta+100+20−5−300−85
reserved_delta000+30−300
Deferred revenue Δ+50,000000−7,500−42,500
Recognised revenue0000+7,500+42,500
Billing::EntitlementBalance — the projection after each entry
Available10012011585850
Reserved0003000
Deferred revenue50,00050,00050,00050,00042,5000

Read what the table shows:

  • The Billing::EntitlementBalance rows 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​

IDUse CaseTriggerActor
UC-1Record a grant when credits enter an accountInvoice posting runs, or an admin credit actionSystem
UC-2Record a reserve when credits are set aside for pending workA campaign or shift needs reserved creditsSystem
UC-3Record a release when reserved credits go backWork cancelled, or finished under the reserved amountSystem
UC-4Record a consume when service is deliveredA campaign day runs, a shift completes, a post publishesSystem
UC-5Record an expire when the cleanup job closes a dead lotThe nightly cleanup jobSystem
UC-6Record a refund for a full gig exitFinance executes the exitSystem
UC-7Record an adjust when an admin extends a lot's expiryA Billing::CreditAction of type expiry_extensionSystem
UC-8View a statement of accountAn employer or admin opens the statement pageEmployer / Admin
UC-9Rebuild a balance projection by replaying entriesA projection is suspected wrong, or a new projection is addedSystem
UC-10Export one day of entries to XeroThe daily finance export runsSystem

UC-1: Record a grant when credits enter an account​

FieldDetails
ActorSystem (invoice posting, or executing a Billing::CreditAction)
TriggerAn 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:

  1. Write one grant entry per granted line:
    • available_delta = the granted units, positive
    • deferred_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::EntitlementLot spec defines this deferred revenue per instrument in The deferred revenue on a lot
  2. The same transaction creates the lot — see Billing::EntitlementLot UC-1 to UC-3.
  3. Write one allocation row linking the entry to the new lot (D7):
    • units_allocated = the granted units
    • allocation_type: :grant
    • recognised_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 the Billing::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​

FieldDetails
ActorSystem
TriggerAds, 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:

  1. Write one reserve entry:
    • available_delta = the reserved units, negative
    • reserved_delta = the same units, positive
    • both revenue columns: 0
  2. Allocation rows name the exact lots, in the spend order.
  3. 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​

FieldDetails
ActorSystem
TriggerThe 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:

  1. Write one release entry:
    • available_delta = the returned units, positive
    • reserved_delta = the same units, negative
    • both revenue columns: 0
  2. Allocation rows return the units to the same lots they came from.
  3. If a lot expired while its units were reserved, an expire entry follows in the same transaction (Billing::EntitlementLot UC-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​

FieldDetails
ActorSystem
TriggerA 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:

  1. Write one consume entry:
    • from reserved: reserved_delta = the consumed units, negative
    • direct: available_delta = the consumed units, negative
  2. Allocation rows carry the per-lot split and per-lot recognised amounts (Billing::EntitlementLot UC-5).
  3. The entry's two revenue columns hold the totals:
    • deferred_revenue_delta_cents goes down by the recognised amount
    • recognised_revenue_cents goes 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​

FieldDetails
ActorSystem (nightly cleanup job)
TriggerA lot passed expires_at with units left

Preconditions:

  • The lot has available units and its date has passed.

System Behaviour:

  1. Write one expire entry per lot, in its own transaction. Source = the expiring Billing::EntitlementLot itself.
  2. On the entry:
    • available_delta = the leftover units, negative
    • deferred_revenue_delta_cents = the lot's remaining deferred revenue, negative
    • recognised_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​

FieldDetails
ActorSystem (executing finance's decision)
TriggerA 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:

  1. Write one refund entry for the whole exit. Source = the commercial refund record (its model class is not named yet — see the preconditions above).
  2. In the same transaction (Billing::EntitlementLot UC-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​

FieldDetails
ActorSystem (executing a Billing::CreditAction of type expiry_extension)
TriggerAn admin gives a lot a later expiry date

Preconditions:

System Behaviour:

  1. Write one adjust entry with every delta at zero. Source = the Billing::CreditAction.
  2. Write one allocation row for the lot the Billing::CreditAction extends (D7):
    • units_allocated: 0 — a date change moves nothing
    • allocation_type: :adjust
    • recognised_revenue_cents: NULL
  3. 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::EntitlementLot UC-8.

Business Rules:

  • This is the only adjust use 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_actions has 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​

FieldDetails
ActorOrg::Membership (employer) or Identities::Admin
TriggerThe statement page or a statement export

Preconditions:

  • The account exists.

System Behaviour:

  1. Fetch the account's entries for the period, one instrument at a time, ordered by occurred_at, then id.
  2. 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
  3. 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​

FieldDetails
ActorSystem
TriggerA projection is suspected wrong, or a new projection type is introduced

Preconditions:

  • None. The ledger is complete by construction.

System Behaviour:

  1. Sum the account's entries in id order into three totals:
    • available units
    • reserved units
    • deferred revenue
  2. Compare with the stored projection. They must match — the balance-row lock guarantees the replay order is the write order.
  3. 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​

FieldDetails
ActorSystem (daily finance export)
TriggerThe export runs for one day, one legal entity

Preconditions:

  • The day is complete in the company's timezone.

System Behaviour:

  1. Select the day's entries.
  2. Group them by entry type and instrument.
  3. Sum the two revenue columns per group.
  4. 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​

  1. 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).
  2. Every write holds the account's balance row lock. One account, one movement at a time (D5).
  3. Replaying an account's entries in id order reproduces the balance projection exactly. On mismatch, the ledger wins.
  4. The deltas follow the sign table in The entry types. The only specified adjust entry is UC-7's zero-movement record of an admin moving a lot's expires_at later.
  5. On consume, expire, and refund: recognised_revenue_cents = −deferred_revenue_delta_cents.
  6. Revenue amounts are copied onto the entry when it is written and never recomputed.
  7. Every entry has a source. The source is the record at the root of why the entry exists — the same naming rule as Billing::EntitlementLot and Billing::OutletBudgetTransfer. Those three tables are the only ones with source_type / source_id columns.
  8. idempotency_key is unique. Every writer supplies one derived from its source, so retries cannot double-write.
  9. 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.
  10. Free credits produce entries with zero revenue. Reserves and releases always carry zero revenue.
  11. org_outlet_id is set on gig spend entries at write time and cannot change.
  12. Ledger history makes entitlement policies permanent: once an instrument has entries, its policies cannot change (D2).

Model Interactions​

Related ModelRelationshipInteraction
Billing::AccountEntry belongs to AccountThe account's balance row lock serialises all writes.
Billing::EntitlementEntry scoped to an instrumentGroups statements, exports, and policies per instrument.
Billing::EntitlementLotMoved via allocationsLot counters change only together with an entry.
Billing::EntitlementLotAllocationEntry has at least one allocation rowOne row per lot the entry applies to — together, the complete history of each lot (D7).
Billing::EntitlementBalanceProjection of entriesUpdated in the same transaction; rebuildable by replay (UC-9).
A deliverable's reserved credits (D8)Not a table — computed when askedThe net of the entries naming the deliverable's source. The per-lot split derives from their allocation rows.
Billing::InvoicePostingSource of paid grantsPosting idempotency and grant idempotency are the same guarantee.
Billing::CreditActionSource of free grants and adjustsThe human decision behind entries with no commercial document.
Ads::CampaignPlacement, Gig::Shift, Careers posts, boostsSource of spendsThe reference a statement line names.
Commercial refund record (to spec)Source of refundsHolds the bank amount and proof; the ledger holds the unit and fee movements.