DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Write the ledger row first: the ordering that makes a credit balance auditable

Users buy credits and spend them on interview practice runs. One credit, one run. It is the simplest possible economy and it is still the part of the system where a bug costs real money in both directions: give away a run you were paid for, or take a credit and fail to deliver.

The data model is two tables:

  • interview_credits, one row per user, one balance integer.
  • interview_credit_transactions, an append only log of every change with a type and a signed amount.

The balance column is redundant. It is SUM(amount) over the log. That redundancy is the design, not a shortcut, and it only stays safe because of two rules about how the two tables are written.

Rule one: the log row goes first

await db.transaction(async (tx) => {
  // 1. Write transaction row first
  await tx.insert(interviewCreditTransactionsTable).values({
    id: crypto.randomUUID(),
    user_id: userId,
    type: 'spend',
    amount: -1,
    stripe_payment_intent_id: null,
    pack_id: interviewId,
    created_at: new Date(),
  });

  // 2. Decrement balance
  await tx
    .update(interviewCreditsTable)
    .set({ balance: sql`${interviewCreditsTable.balance} - 1`, updated_at: new Date() })
    .where(eq(interviewCreditsTable.user_id, userId));
});
Enter fullscreen mode Exit fullscreen mode

Both statements are in one database transaction, so ordering cannot matter for atomicity. Either both land or neither does.

It matters for reasoning. The invariant is written as an ordering, so every function that touches credits reads the same way, and a reviewer can check compliance by looking at the shape of the code rather than by reconstructing the semantics. Anything that changes a balance without an accompanying log row is visibly wrong at a glance.

Ordering also survives refactoring that atomicity does not. The day someone extracts step two into a helper, or the day a retry wrapper appears, the code that writes the audit trail first is the code that still has an audit trail.

The other detail: the decrement is balance - 1 in SQL, not balance: currentBalance - 1 computed in JavaScript. Read-modify-write in application code is a lost update waiting for concurrency. Let the database do the arithmetic on the value it currently holds.

pack_id: interviewId is a small dishonesty I will admit to. The column was named for purchases and is reused to record which interview a spend paid for. The comment says so. Renaming a column across a live table for naming purity is a worse trade than a comment, but the comment is not optional.

Rule two: lock the row before you check it

This is the part that is genuinely a bug if you leave it out, and it is invisible until you have concurrent users.

const [creditRow] = await tx
  .select({ balance: interviewCreditsTable.balance })
  .from(interviewCreditsTable)
  .where(eq(interviewCreditsTable.user_id, userId))
  .limit(1)
  .for('update');

const currentBalance = creditRow?.balance ?? 0;

if (currentBalance <= 0) {
  throw new InterviewError(/* INSUFFICIENT_CREDITS */);
}
Enter fullscreen mode Exit fullscreen mode

.for('update') is SELECT ... FOR UPDATE. Without it:

two concurrent submissions could both read balance = 1, both pass the check below, and both decrement, driving the balance negative and letting one credit pay for two interviews.

This is the check-then-act race, and the default transaction isolation level does not save you from it. Under READ COMMITTED, which is the Postgres default, two transactions can both read the same balance, both pass the check, and both write. Nothing conflicts, because they are updating a row, not inserting a duplicate.

The row lock serialises them. The second transaction blocks on the SELECT, waits for the first to commit, then re-reads the already decremented balance and correctly fails with INSUFFICIENT_CREDITS.

Note where the lock is taken: at the start, on the row about to be modified, inside the same transaction that will modify it. A lock taken after the check is a lock taken after the race.

And the edge case that is easy to get wrong:

If no row exists yet the balance is 0, so there is nothing to lock and nothing to overspend.

A FOR UPDATE on a matching zero rows locks nothing. That sounds like a hole until you notice that a user with no credits row has a balance of zero, so the check rejects them anyway. There is no state in which the missing lock permits an overspend. Worth writing down, because "what if the row does not exist" is the first thing a reviewer asks and the answer is not obvious.

Grants compose with the transaction that caused them

addCredits handles every positive movement: grants, purchases, refunds. Its signature has one parameter that earns its place:

export async function addCredits(
  userId: string,
  amount: number,
  type: Exclude<TransactionType, 'spend'>,
  metadata?: AddCreditsMetadata,
  executor: DbExecutor = db
): Promise<void> {
Enter fullscreen mode Exit fullscreen mode

executor defaults to the database but accepts an open transaction:

// When `executor` is an open transaction (e.g. the Stripe webhook's atomic
// marker+grant), this nests as a savepoint and commits/rolls back with it.
await executor.transaction(async (tx) => { /* ... */ });
Enter fullscreen mode Exit fullscreen mode

The Stripe webhook needs to write its idempotency marker and grant the credits as one unit. If those can separate, you get the two classic failures: marker without credits means the customer paid and got nothing and a retry will refuse to help, or credits without a marker means a retried webhook grants twice.

Passing the executor down lets the caller decide the transaction boundary without the credit module knowing anything about webhooks. In Postgres a nested transaction becomes a savepoint, so it commits or rolls back with its parent, which is exactly the semantics you want.

Exclude<TransactionType, 'spend'> is the type doing documentation work. You cannot call addCredits(userId, -1, 'spend'), because the argument will not type check. Spending has its own function with the lock and the balance check, and the type system routes you there instead of leaving a comment about it.

The guard underneath is not redundant with the type, because types do not survive the network boundary:

if (amount <= 0) {
  throw new Error(`addCredits: amount must be a positive integer, got ${amount}`);
}
Enter fullscreen mode Exit fullscreen mode

The upsert then handles first purchase and top up in one statement, creating the balance row with amount if it does not exist and incrementing it if it does.

What the redundancy buys

Keeping a balance column at all is a denormalisation. It is worth it here because the balance is read on nearly every page that mentions credits, and SUM over a growing log for a read that frequent is the wrong shape.

What you get in exchange for the discipline above is that the cache is checkable. At any point you can run the sum over the log and compare it to the stored balance, per user, and the two either agree or you have found a bug. That reconciliation query is only meaningful because every single mutation writes the log row, inside the same transaction, before touching the balance.

A balance with no log is a number you have to believe. A balance with a complete log is a number you can verify, and the difference shows up the first time somebody emails asking where their credit went.

The product on the other end

Credits are spent on recorded practice answers that come back with structured feedback. The interview practice page covers what a run actually involves, which is the part that has to be worth one credit.

If you have a balance column anywhere in your own schema, the two questions worth asking today: is every mutation accompanied by a log row in the same transaction, and does anything take a row lock before checking whether the balance is sufficient? If the answer to the second is no, the bug is already there, you just have not had enough concurrent users to see it.

Top comments (0)