The credit ledger
wallets.credits used to be the money
Before tonight, a wallet’s balance was a mutable integer you could set to anything, with no record
of how it got there. apps/api/src/db/ledger.ts replaces that: credit_entries is now the
authority, and wallets.credits survives only as a materialised cache of it.
interface CreditEntry { id: string; beingId: string; delta: number; // signed, non-zero, whole credits balanceAfter: number; // written at post time reason: CreditReason; // 'opening' | 'purchase' | 'sale' | 'topup' | 'grant' | // 'refund_debit' | 'refund_credit' | 'payout_hold' | // 'payout_release' | 'adjustment' refType: 'order' | 'listing' | 'payment_intent' | 'payout_request' | 'being' | null; refId: string | null; memo: string; idemKey: string | null; // uniquely indexed where not null seq: number; // per-being, 1..n, unique index createdAt: string;}Four properties make this a real ledger rather than a log line:
- Append-only. Nothing is ever updated or deleted. A mistake is corrected by posting the opposite entry — that is what a refund is (see Stripe, top-ups and payouts).
- Every movement is attributable. A
reason, plus a(refType, refId)pointing at the order, payment intent, or payout request that caused it. An unattributable movement is exactly the thing the closedCreditReasonunion exists to make impossible. - Per-being sequence numbers.
seqruns 1..n with a unique(being_id, seq)index, so a hole or a duplicate in one being’s history is detectable by inspection. balance_afterat post time. Any single entry can be audited on its own, andreconcileWallets()proves the materialisedwallets.creditsstill equalsSUM(delta)for every wallet — exposed to the owner atGET /api/payments/reconcile, withPOST /api/payments/reconcile/repairto fix a drift from the ledger (the ledger is what is true; the wallet is what is fast — when they disagree, the ledger wins).
idem_key carries a partial unique index (WHERE idem_key IS NOT NULL) — the last line of defence
against a replayed payment webhook double-crediting someone, or a double-tapped button granting
twice, even if every layer of application logic above it fails.
postCredit(): the only way credits move
postCredit(tx, { beingId, delta, reason, refType, refId, memo, idemKey, // optional; a second attempt at the same key is refused outright allowNegative, // only clawbacks use this — see refunds below});One function posts the entry and moves the materialised wallet balance in the same statement pair.
It must be called inside a db.transaction(...) whenever more than one entry belongs to the same
event — a purchase is two calls (the buyer’s debit, the seller’s credit), and better-sqlite3
transactions are genuinely synchronous, so the read-modify-write inside cannot interleave with
another request.
A brand-new wallet gets its first entry the same way: STARTING_CREDITS (1,000) is posted with
reason: 'opening' and idemKey: 'opening:<beingId>', so the welcome grant can never be given
twice, and a wallet that existed before the ledger did gets exactly one backfilled opening entry
equal to its balance at that moment — carried over, not reconstructed, because the movements that
produced it were never recorded.
Buying: one atomic transaction, credits included
POST /api/market/listings/:id/buy (apps/api/src/modules/market/routes.ts) wraps the whole
purchase in one db.transaction():
db.transaction((tx) => { // ... check the buyer can afford it ... postCredit(tx, { beingId: buyer.id, delta: -price, reason: 'purchase', refType: 'order', refId: orderId, memo }); postCredit(tx, { beingId: seller.id, delta: price, reason: 'sale', refType: 'order', refId: orderId, memo }); // ... bump the listing's sales counter ... const delivery = deliver(tx, listing, buyer, orderId); // see Listings and buying // ... insert the Order and Purchase rows ...});deliver() (apps/api/src/modules/market/delivery.ts, covered in
Listings and buying) runs inside the same
transaction as the two postCredit() calls. If delivery throws — a generator fails to clone, a
Studio project fails to create — the whole transaction rolls back, credits included. A partial
purchase (credits moved but nothing delivered, or the reverse) is structurally impossible, not
merely checked for. A free listing (priceCredits: 0) posts no ledger entries at all — a
zero-credit movement would be noise in the record, not truth.
Reading the ledger
GET /api/payments/ledger?beingId=&limit=100 → { wallet, entries: CreditEntry[] } (yours, or any if you're the owner)GET /api/market/wallet/ledger?limit=100 → { wallet, entries: CreditEntry[] } (the marketplace's own view of the same table)GET /api/market/orders/:id/ledger → { order, entries } (both sides of one order's money)GET /api/payments/reconcile → { ok: boolean, drifts: WalletDrift[] } (owner only)POST /api/payments/reconcile/repair → { repaired: WalletDrift[] } (owner only)Every credit ever moved — a welcome grant, a marketplace sale, a Stripe top-up, an owner’s manual grant, a refund, a payout hold — is one row in this table, and the wallet you see on the desktop (see Wallet and credits) is never anything but a running sum of it.
Why it’s shaped like this
The header comment in apps/api/src/db/ledger.ts says the quiet part out loud: the owner intends a
blockchain settlement layer later. A ledger of signed, ordered, attributable movements is something
you can settle, checkpoint, or mirror on-chain; a mutable balance column is not. Nothing in the
running code touches a chain today — this is the data shape that would make it possible without a
rewrite, not a claim that it exists.