# I stored AI SaaS credits as a single integer. Then the refunds started.

> Source: <https://dev.to/velobasex/i-stored-ai-saas-credits-as-a-single-integer-then-the-refunds-started-2hg>
> Published: 2026-06-25 02:53:09+00:00

*Once in a while someone on the team writes up a mistake worth keeping. This one is about billing, from the engineer who lived it.*

The scary part of launching an AI SaaS is not the empty launch day. It's the first week when real people start paying, burning credits, retrying requests, asking for refunds, running cron jobs, and finding every shortcut you left in the billing code.

Usage-based billing sounds simple when it's a pricing page. It gets messy when every AI action costs money, every retry can double-charge someone, and every support ticket starts with a number your database cannot explain.

My first credit system was one column on the users table: `credits`

. Buy a pack, it goes up. Run a generation, it goes down. I built it in an afternoon and felt clever about how little code it took.

It held up right until real money showed up. One Tuesday I opened the billing dashboard to a refund, a chargeback from a name I didn't recognize, and a buyer who'd been charged twice because his request timed out and retried. Then an email landed that I still remember word for word:

"Why do I have 37 credits? I bought 500 and I've barely touched this."

I opened a psql shell and ran the only query I had:

```
select credits from users where id = '...'; -- 37
```

Thirty-seven. That was the whole story my database could tell me.

Not where the other 463 went. Not when. Not why. Every `credits = credits - 1`

had overwritten the last number and dropped the reason on the floor. I started a reply with "let me look into this" and realized there was nothing to look into.

That email is why I tore the whole thing out and rebuilt it as a ledger. Here's the model, and the exact moment that taught me each part of it.

With a counter, a customer's entire financial history with your product is one mutable integer that any code path can stomp on. The grant, the spend, the refund, the bug, the fix for the bug. They all collapse into a single number, and the moment they do, you've thrown away every question worth asking:

A single number answers none of them. Once refunds exist, you don't want the balance. You want the list of things that produced it.

The fix is boring, which turns out to be the point. You never update a balance again. You write one signed row per change, and you add them up when you need the number.

```
create table credit_entries (
  id              bigint generated always as identity primary key,
  user_id         uuid not null references users(id),
  amount          integer not null, -- +grant, -spend, never zero
  reason          text not null,    -- 'purchase' | 'generation' | 'refund' | 'signup_bonus' | 'clawback'
  ref_type        text,             -- 'stripe_payment' | 'job' | 'credit_entry'
  ref_id          text,             -- the thing that caused this row
  idempotency_key text,             -- dedupe retries
  created_at      timestamptz not null default now()
);

create unique index on credit_entries (idempotency_key)
  where idempotency_key is not null;
```

No `updated_at`

. Rows are written once and never touched again. The balance is a sum:

```
select coalesce(sum(amount), 0) as balance
from credit_entries
where user_id = $1;
```

Remember the 37-credit guy? On the ledger, his mystery is just his history, in order:

```
+500   purchase                    2026-05-02 09:14
 -463  generation (x463 entries)   2026-05-02 -> 05-09
-----
   37  balance
```

Four hundred and sixty-three generations in a week from someone who'd "barely touched it." I pasted the history into my reply. He wrote back: "oh no. that's my cron job."

Closed in one message, because the data could finally speak for itself.

This is the one that actually cost me money. Stripe's webhook delivery is at-least-once, not exactly-once. Duplicate deliveries are the contract, not a bug.

One afternoon Stripe fired `checkout.session.completed`

, my handler granted 500 credits, my server was slow to send back its 200, so Stripe assumed the delivery failed and fired the same `evt_`

id again. Then a third time.

The buyer pinged me an hour later:

"lol why do I suddenly have 1,500 credits"

Same event, same signature, processed three times, and I had paid for it.

The tempting fix is to check before you write. Don't. That's a race, and two deliveries can both pass the check:

``` js
// DON'T: two concurrent deliveries both read "not seen" and both grant
const seen = await db.query.creditEntries.findFirst({
  where: eq(idempotencyKey, eventId),
});

if (!seen) await grant(userId, 500);
```

Let the database be the referee. Derive a key from the source event and let a unique index throw the duplicate away:

```
await db.insert(creditEntries)
  .values({
    userId,
    amount: 500,
    reason: "purchase",
    refType: "stripe_payment",
    refId: paymentId,
    idempotencyKey: stripeEventId,
  })
  .onConflictDoNothing({
    target: creditEntries.idempotencyKey,
  });
```

The second and third deliveries hit the index and do nothing. The grant happens once. You didn't have to make your webhook perfectly exactly-once. The data model swallowed the retry for you.

Picture a user on hotel wifi mashing "Generate" twice, or someone with two tabs open. They have one credit left.

Request A reads the balance: 1. Ten milliseconds later, before A has written anything, Request B reads the balance: 1. Both decide they're fine. Both generate. The balance lands at -1, and they got two images for the price of less than one.

The bug is reading the balance in app code and then writing based on a number that's already stale. Do the check and the write in one transaction, so the read only sees committed rows:

``` js
await db.transaction(async (tx) => {
  const { balance } = await tx
    .select({ balance: sql`coalesce(sum(amount), 0)` })
    .from(creditEntries)
    .where(eq(creditEntries.userId, userId));

  if (balance < cost) throw new InsufficientCredits();

  await tx.insert(creditEntries).values({
    userId,
    amount: -cost,
    reason: "generation",
    refType: "job",
    refId: jobId,
    idempotencyKey: jobId,
  });
});
```

Run it at serializable, or take a per-user advisory lock, so two transactions can't both clear the check against the same starting balance. Using `jobId`

as the key also means a retried job won't get billed twice.

Forty days after a sale, a chargeback rolls in. The credits it paid for were spent two weeks ago. With a counter you're stuck choosing between editing a history you no longer have and pretending it didn't happen.

On a ledger you append:

```
insert into credit_entries (user_id, amount, reason, ref_type, ref_id)
values ($user, -500, 'refund', 'credit_entry', $original_entry_id);
```

The balance drops honestly, goes negative if it has to, and the new row points straight at the purchase it cancels.

Same move when you catch a farmed signup: append a negative clawback instead of reaching into a column and hoping you did the arithmetic right. Refund, chargeback, fraud, a manual fix from support. All the same operation. Write one more row.

Boring on purpose, and boring is exactly what you want anywhere near money.

At launch, that balance query was 2ms and I never thought about it. Somewhere around four million rows it was 200ms, and the p95 on every endpoint that checked a balance started to crawl.

The fix isn't to abandon the ledger. You periodically write a checkpoint row that snapshots the balance up to a point in time, then sum only what came after it.

The ledger stays the source of truth, and the checkpoint is a cache you can rebuild any time you stop trusting it. Don't build it before a profiler asks you to. An index on `(user_id, created_at)`

carries you further than you'd guess.

One more habit worth starting early: the day you pay affiliates or hand out referral bonuses, make the movement double-entry. Every credit out of one account is a credit into another, so the books always sum to zero. The first time mine didn't, it was off by a single cent, and double-entry meant I caught it that afternoon instead of six weeks later in a reconciliation nobody had scheduled.

`0.000001`

that drift over time.`DELETE`

breaks the audit trail for good.The honest tradeoff: this is more code than `credits -= 1`

. It's also the difference between reading a customer his own history back and typing "let me look into this" with nothing to look into.

We kept rebuilding this same stack for AI products: Stripe checkout, usage-based billing, usage metering, customer credits, refunds, idempotent webhooks, and a ledger that support can actually explain. So we open-sourced it as Harness, MIT-licensed: [github.com/velobase/velobase-harness](https://github.com/velobase/velobase-harness).

If you're building an AI SaaS and don't want your first real users to become your billing test suite, it may save you a few painful weeks.

If you've run this in production: how do you handle the SUM-vs-snapshot tradeoff? Running totals, checkpoint rows, a cached balance you reconcile on a cron? I'd genuinely like to hear what held up.
