Accounts & double-entry
Every operation is a balanced double-entry posting across a fixed chart of accounts: spendable, earned, promo, and the system accounts.
A “chart of accounts” is simply the fixed, named set of buckets money is allowed to sit in. Nothing in the system holds value outside that list — every credit and every dollar lives in one of these named accounts, and moving money just means shifting it from one to another.
Source src/accounts.ts#L65src/ledger.ts#L98
Every balance lives in a named account. Every operation is a balanced double-entry Posting: value moves between accounts as paired debit and credit lines, and those lines always net to zero per currency.
You never add to one account without an equal, opposite line somewhere else. A user’s accounts hold credits. The platform’s own “house” accounts hold its cash, its revenue, and obligations.
Why it exists
Balances aren’t stored and mutated in place — they’re derived: the ledger is an append-only book of postings, and an account’s balance is the running sum of its lines.
No posting can create or destroy value: its lines cancel within each currency, so the books as a whole always sum to zero. Folding the postings back re-derives the same balance every time.
Without the balanced-posting rule, a single edited or dropped line would mint or burn credits silently. A topUp could issue credits no dollars stand behind. A spend could pay a seller more than the buyer paid.
The solvency guarantee rests on this: the cash set aside on a purchase and the credits issued are two legs of one balanced posting, so they can’t drift apart.
The chart of accounts
The set of account kinds is fixed. A user has accounts keyed by AccountKind: spendable, earned, and promo, built from a user id with the spendable(userId), earned(userId), and promo(userId) helpers — plus, when a prefunded fast lane is in play, a per-session escrow account from sessionEscrow(userId, sessionId).
| Account | Currency | Holds |
|---|---|---|
spendable | CREDIT | Credits bought and ready to spend: the only always-present user balance backed by trust cash. |
earned | CREDIT | Revenue owed to the user as a seller, waiting to be paid out. |
promo | CREDIT | A marketing grant that expires if unspent. |
escrow | CREDIT | Matured credits parked for one netting session; refunded to spendable at settle. Backed, like the spendable they came from. |
The platform’s house accounts are the fixed SYSTEM map, each id prefixed platform:. They hold three things: the platform’s cash, its revenue, and the offsetting (contra) entries that keep a one-sided user move balanced.
SYSTEM account | Currency | Holds |
|---|---|---|
TRUST_CASH | USD | Real dollars held in trust, backing users’ spendable credits. |
REVENUE_USD | USD | The platform’s dollar margin from the buy-vs-par spread, recognized at top-up. |
USD_CLEARING | USD | A mirror of cash that has cleared in or out of trust. |
REVENUE | CREDIT | Platform fee income: the marketplace cut, plus rounding leftover. |
STORED_VALUE | CREDIT | The offsetting entry for every credit ever issued on a top-up. |
PAYOUT_RESERVE | CREDIT | Earned credits set aside for a payout in flight. |
RECEIVABLE | CREDIT | A shortfall a user owes back (e.g. a clawback that went negative). |
PROMO_FLOAT | CREDIT | The offsetting entry for credits granted as promos. |
OPENING_EQUITY | CREDIT | The offset used once, to seed balances on a cold start. |
SETTLEMENT_ACCRUAL | CREDIT | Sellers’ shares parked between a charge and the drain sweep, under the accrual split. |
NETTING_CLEARING | CREDIT | The pass-through a netting session’s settle posts across; nets to zero within the posting. |
One wrinkle on the table above: with PLATFORM_SHARDS set past 1, each hot SYSTEM account splits into shard rows, the bare id plus platform:revenue#1 and so on. A shard row behaves like its parent in every way that matters here (identity checks strip the # suffix first, so currency, class, and normal side all come from the base account), and the logical balance is the sum over the rows. Platform sharding explains why the split exists and how a posting picks its shard.
Currency is a property of the account, not the posting. Everything is CREDIT except TRUST_CASH, USD_CLEARING, and REVENUE_USD, which are USD (see the money model for Amount and Currency). If a leg’s amount currency doesn’t match its account, the posting is rejected with CURRENCY_MISMATCH.
Debit-normal and credit-normal
Different accounts grow on opposite sides, and the ledger has to know which so a balance reads right-way-up. A user’s spendable rises when you credit it, because the platform owes them more. TRUST_CASH rises when you debit it, because more cash is in.
isDebitNormal records the side per account. The USD accounts, STORED_VALUE, RECEIVABLE, PROMO_FLOAT, and OPENING_EQUITY grow on a debit; the rest grow on a credit.
Internally, leg amounts are stored debit-positive, credit-negative. debit(account, amount) stores the amount as given, and credit(account, amount) stores its negation, so a posting balances exactly when its leg amounts sum to zero.
To turn a leg back into a balance change, balanceDelta flips the sign for credit-normal accounts and leaves debit-normal ones as-is. Normalized this way, the no-overdraft check can measure every account, of either polarity, against zero.
Both rules are visible on any committed transaction — the legs net to zero, and balanceDelta reads each one as the account holder would. This one runs here; edit the price and the legs rebalance around your number:
import { credits, spend, spendable, userActor } from '@pwngh/economy-lab';
import { balanceDelta } from '@pwngh/economy-lab/store-kit';
import type { Economy } from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';
// One committed sale, read leg by leg: the lines always net to zero, and balanceDelta
// turns the buyer's line back into the change they saw. Edit the price — the legs
// rebalance around your number, every time.
export async function run(economy: Economy): Promise<SnippetReport> {
const orderId = `ord_${crypto.randomUUID().slice(0, 8)}`;
const outcome = await economy.submit(
spend({
idempotencyKey: orderId,
actor: userActor('usr_alice'),
orderId,
buyerId: 'usr_alice',
sku: 'Ledger Demo Pass',
price: credits(10),
recipients: [{ sellerId: 'usr_nova', shareBps: 10_000 }],
}),
);
if (outcome.status !== 'rejected') {
const legs = outcome.transaction.legs;
const sum = legs.reduce((total, leg) => total + leg.amount.minor, 0n);
const buyerLeg = legs.find((leg) => leg.account === spendable('usr_alice'));
return {
lines: [
`${legs.length} legs, summing to ${sum}n — the posting balances`,
buyerLeg
? `buyer's leg via balanceDelta: ${balanceDelta(buyerLeg).minor / 100n} credits`
: 'no buyer leg found',
],
txnId: outcome.transaction.id,
};
}
return { lines: [`spend: rejected (${outcome.detail.reason}) — no posting to read`] };
}The invariant: every posting balances, no user account goes negative
Two rules guard every write. A posting is rejected unless its leg amounts sum to zero in each currency. And no posting may drive a user account (or the PAYOUT_RESERVE escrow) below zero.
postEntry runs four checks before any write:
- Each leg’s currency matches its account.
- The leg amounts balance per currency (
assertBalanced). - Every named account already exists.
- No guarded account goes negative (
assertNoOverdraft).
It drops zero-amount legs first — a share that rounds to zero is a no-op the row-level schema would reject.
These app-side checks aren’t the real enforcer. They’re a courtesy that hands you a clear fault first: LEDGER_UNBALANCED, OVERDRAFT. The balance and no-overdraft invariants live in the database itself: Postgres constraint triggers and MySQL stored procedures. So a row written around the app is still refused.
The prover goes further still, re-checking conservation and overdraft after every committed operation.
What relies on it
spendand every other operation post throughpostEntry, so each commits a single balanced transaction or none at all; the worked example of how a sale’s legs split lives on the spend page.- Solvency reads
classifyto total onlycustodialcredits againstTRUST_CASH; the backing line and the credit it backs are legs of one posting. - Integrity hash-chains each account’s legs and re-checks conservation, no-overdraft, and balance consistency after every committed posting.