DebaterXDebaterX

A Credit System in Supabase That Doesn't Get Gamed

How DebaterX debits credits atomically — and why 'decrement' is a lie.

·4 min read

Any app that sells credits will eventually be attacked by people trying to spend more credits than they paid for. This is a solved problem in banking and a frequently-unsolved problem in indie SaaS. Here's how DebaterX handles it.

The naive approach (which fails)

The obvious way to implement credits is with a column on the user table. When a user starts a job, you decrement the column by the job cost. When the job completes, the credit is spent.

This fails in several predictable ways:

Race conditions. If a user fires two requests within a few milliseconds, both read the balance before either decrements it. Both see enough credit. Both pass. The user gets two jobs for the price of one.

Failure without refund. If a job fails after the decrement but before completion, the user has lost a credit they didn't get value from. You have to implement a refund flow, which creates new attack surface.

Audit confusion. When a user asks "why am I out of credits," you have no history. Just a balance number. Customer support becomes a guessing game.

The ledger approach (which works)

Instead of a balance column, implement a ledger. Every credit action is a row in a transactions table. Balance is computed by summing all rows for a user.

Credit purchases are positive rows. Credit spends are negative rows. "Holds" — credit reservations during in-flight jobs — are a special row type that gets converted or reversed depending on job outcome.

The balance view is a SQL query:

This gives you atomicity, audit trail, and refund logic for free.

The job flow

When a user starts a debate:

  1. Insert a hold row for the expected cost. (e.g., "-1 credit, type=hold, job_id=xxx, status=active")
  2. Start the Inngest pipeline.
  3. On pipeline success, update the hold to "type=spend, status=completed." The credit is now debited permanently.
  4. On pipeline failure, update the hold to "status=cancelled." The hold no longer counts against balance. Credit is effectively refunded.

The balance view ignores cancelled holds and counts completed spends. If the pipeline fails, the balance naturally returns to what it was before.

Race conditions are impossible

Because balance is computed on read, concurrent job starts can't double-spend. Each job inserts its own hold row atomically. If the balance check fails (because another job has already reserved the credits), the insert fails and the user sees an error.

I enforce this with a database check constraint and a conditional insert:

INSERT INTO credit_transactions (user_id, amount, type, status) SELECT :user_id, -1, 'hold', 'active' WHERE (SELECT balance_view FROM user_credits WHERE user_id = :user_id) >= 1;

If the user doesn't have the credit, the insert returns zero rows and the application rejects the job. No race, no partial state.

The audit benefits

Every credit movement is a row with a timestamp, an amount, a type, and a job reference. Customer support can see exactly what happened:

"You purchased 10 credits on March 3. You spent 6 on debates. You have 1 hold currently active for job abc123. Balance: 3."

This is enormously more useful than a single balance number. It also makes tax reporting trivial (sum all purchase rows per user per year), and lets me detect fraud (users with anomalous spend patterns show up in standard ledger queries).

The Supabase specifics

Supabase supports this pattern natively:

The whole system is about 150 lines of SQL and 200 lines of application code. It's not that much work. It just requires thinking about credits as events, not as state.

The takeaway

If you're building a credit system, use a ledger, not a balance column. The ledger is the source of truth. The balance is a query. Concurrency, refunds, and audit all become trivial.

Decrement-based systems are fast to build and broken from day one. Ledger-based systems are slower to build and correct forever. Correct forever is the better trade.

← Back to all posts