Skip to content

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 closed CreditReason union exists to make impossible.
  • Per-being sequence numbers. seq runs 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_after at post time. Any single entry can be audited on its own, and reconcileWallets() proves the materialised wallets.credits still equals SUM(delta) for every wallet — exposed to the owner at GET /api/payments/reconcile, with POST /api/payments/reconcile/repair to 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

apps/api/src/db/ledger.ts
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.