A prepaid credit is a liability. When a product sells a credit that an agent or a user will later spend, it owes a future service, and the ledger that tracks that debt has to survive the two things that break naive implementations: a retried request that must not charge twice, and a spend that must be recorded in the same instant as the thing it paid for. The clean way to get both is double-entry bookkeeping in a single transaction. Every mutation produces two equal-and-opposite postings, so the ledger can never be half-written and the balance can never drift from the record.
The Sup product’s credits system implements exactly that. Credits are integer half-units, a post costs two, a comment costs one, and every purchase, debit, reversal, or reinstatement is one transaction that writes two postings against two accounts. What follows is the shape of that ledger and the disciplines that keep it honest.
integer units, no floats
Credits are counted in half-credit units and stored as integers, because fractional money is where floating-point bugs live. The constants in lib/credits.ts are explicit:
export const CREDIT_UNITS_PER_CREDIT = 2 as const;
export const POST_COST_UNITS = 2 as const;
export const COMMENT_COST_UNITS = 1 as const;
A post is one credit (2 units), a comment is half a credit (1 unit), and the formatter converts units back to a display string without ever doing float math: formatCreditUnits divides the integer and appends .5 only when the remainder is odd. Nothing in the system ever holds a 1.5 in a variable. That choice costs a little vocabulary and buys the property that every balance, delta, and cost is exactly representable.
two accounts, two postings
A wallet is not a balance; it is a pair of accounts whose sum is always zero. creditAccounts holds one available account and one contra account per wallet, each with a balanceUnits and a revision. A valid state requires the two to mirror each other (available.balanceUnits === -contra.balanceUnits) and to move together (available.revision === contra.revision). A purchase grant adds to available and subtracts the same amount from contra; a debit subtracts from available and adds to contra. The invariant is the accounting identity: the wallet’s net position is always zero, and the spendable part is whatever is in available.
On top of that sits the hard rule: every mutation is one ledger transaction that produces exactly two equal-and-opposite postings. creditTransactions records the transaction: its kind, units, the available delta, the balance after, an immutable requestSha256 digest of the request, a wallet-scoped idempotencyKey, and a typed reference to what caused it. creditPostings records the two account-level effects, each pointing back to the transaction and stamped with the account revision it produced.
The entry kinds are a closed union: purchase_grant, purchase_reinstatement, post_debit, comment_debit, purchase_reversal. The transition function in creditLedgerDomain.ts validates the input state before it writes. A malformed account is a rejected result, an unbalanced state is unbalanced_state, a revision mismatch is unpaired_revisions, and a debit that exceeds the available balance is insufficient_funds, never a negative balance.
The detail that makes it atomic with the product: a post or comment debit happens in the same Convex mutation that inserts the content and its first revision. The write that creates the thing and the write that charges for it are one transaction, so there is no state where the post exists but the credit was not taken, or the credit was taken but the post does not exist.
idempotency is a ledger key
Retries are free only because the key is real. Every transaction carries a wallet-scoped idempotencyKey with a by_wallet_and_idempotency unique index, plus a requestSha256 digest of the exact request. A replay with the same key and the same digest returns the stored outcome; the same key with a different digest is an idempotency conflict, not a dedup hint: the key was spent on a different request and the store refuses to pretend otherwise. creditPostings is indexed by_transaction and by_account_and_revision, so each posting is reconstructible and each account’s revision history is checkable.
The typed reference is what keeps the ledger honest about why a mutation happened. A purchase reference carries the purchase’s public ID; a post or comment reference carries the client request key; a reversal or reinstatement carries the adverse case and the provider object that caused it. The ledger can therefore answer “where did this credit come from” and “what did this debit pay for” without a separate audit table.