DEV Community

Cover image for Your AI will fail. Your billing system needs to know that.
Carlos M.
Carlos M.

Posted on

Your AI will fail. Your billing system needs to know that.

I run Photo AI Studio, which turns a selfie into professional photos across ~49 themes. Users buy credits; one generation costs 100 of them.

Simple product. The billing was the hardest part — and not for the reason I expected.

Here's the thing nobody tells you when you put a price tag on a model call: the model fails. Not often, but reliably often enough to matter. Provider timeouts. Safety filters tripping on a perfectly normal selfie. A GPU node dying mid-batch. Rate limits during a traffic spike.

When a SaaS API call fails, you retry and nobody notices. When a paid generation fails, someone just watched 100 credits disappear and got nothing. That's a refund request, a support ticket, and a chargeback — in that order, if you handle it badly.

So the real question isn't "how do I charge for AI." It's: how do I charge for something that is allowed to fail?

Attempt 1: charge on request

await deductCredits(userId, 100);
const image = await generateImage(params); // 💥
Enter fullscreen mode Exit fullscreen mode

Fastest to build, and it's how a shocking number of AI products still work. The failure mode is obvious: the model throws, the credits are gone, and your catch block is now responsible for making the user whole. Which means your refund logic is a second, parallel accounting system — one that runs only in the unhappy path, so it's the least-tested code you own.

Every bug in it costs you real money or real trust. Usually both.

Attempt 2: charge on success

const image = await generateImage(params);
await deductCredits(userId, 100);
Enter fullscreen mode Exit fullscreen mode

Now the user is never wrongly charged. Instead you've built a free-generation machine.

Nothing stops someone firing 50 concurrent requests with a 100-credit balance. Every one passes the balance check — the deduction hasn't happened yet on any of them. You eat 50 generations, bill them for one. I found this the fun way, on a Tuesday.

You also can't answer "how many credits does this user have right now?" while jobs are in flight. Your balance is a lie for the entire duration of the work.

What actually works: reserve, then settle

The pattern is older than AI and it's sitting in your card terminal — authorize and capture. Your card gets held at the pump before anyone knows what you'll pump.

Three states, not two:

  1. Reserve — atomically move 100 credits from available into held, before the model is touched.
  2. Settle — generation succeeded: the hold converts to a spend.
  3. Release — generation failed: the hold reverses, credits are available again.

The critical property: step 1 is the only place a concurrency check happens, and it's a single atomic write. Steps 2 and 3 can't fail in a way that loses money, because they're just resolving something already recorded.

Don't store a balance. Store a ledger.

The mistake I'd make again if I weren't careful is a users.credits integer column. It's one UPDATE away from being wrong forever, and when a user asks "where did my credits go?" you have no answer.

Append-only ledger instead. The balance is a derived value:

create table credit_entries (
  id            bigserial primary key,
  user_id       uuid not null,
  amount        int  not null,           -- signed: -100 hold, +100 release
  kind          text not null,           -- purchase|hold|settle|release|grant
  job_id        uuid,                    -- the generation this belongs to
  idempotency_key text unique,           -- see below
  created_at    timestamptz not null default now()
);

create index on credit_entries (user_id, created_at desc);
Enter fullscreen mode Exit fullscreen mode

Reserving becomes one statement that either wins or does nothing:

insert into credit_entries (user_id, amount, kind, job_id, idempotency_key)
select $1, -100, 'hold', $2, $3
where (select coalesce(sum(amount), 0)
       from credit_entries where user_id = $1) >= 100
returning id;
Enter fullscreen mode Exit fullscreen mode

Zero rows back means insufficient funds. No read-then-write race, no row lock held across a 30-second model call, and the sum is computed inside the same statement that writes. (Once your ledger gets big, keep a periodically-rolled-up snapshot row and sum only entries after it — don't scan a million rows to render a header.)

Idempotency is not optional here

Users double-click. Mobile clients retry on flaky connections. Provider webhooks fire twice — that's documented behaviour at most of them, not a bug.

Every write carries a caller-supplied key, unique per intent:

const key = `hold:${jobId}`;      // one hold per job, forever
const settleKey = `settle:${jobId}`;
Enter fullscreen mode Exit fullscreen mode

The unique constraint does the enforcement. A duplicate insert throws, you catch the conflict, and you return the existing result rather than doing the work again. This one constraint eliminated an entire genre of support ticket for me.

The genuinely hard part isn't technical

Once holds work, you have to answer a policy question, and no amount of Postgres helps: what counts as a failure?

  • Provider 500, timeout, node death → obviously refund. Not the user's fault.
  • Safety filter rejects the upload → refund, but explain why, or you'll get the ticket anyway.
  • The photo generated fine and the user just doesn't like their jawline → not a failure.

That last one is most of your inbound. And "no" is technically correct and commercially stupid, because the person is telling you your product didn't do what they hoped.

What I landed on: full refunds are automatic and invisible for infra failures — the user often never learns a generation failed, because the retry lands before they notice. Taste complaints get a cheap regeneration instead. It costs me a fraction of a full refund, it converts a disappointed user into an engaged one, and it stopped the argument about whether AI output is "correct."

Sweep your stuck holds

Processes die between reserve and settle. Do it enough times and users have credits locked in limbo, which reads exactly like theft to the person holding the account.

A cron that releases holds with no terminal state after N minutes:

-- any hold whose job never settled or released
select e.job_id
from credit_entries e
where e.kind = 'hold'
  and e.created_at < now() - interval '15 minutes'
  and not exists (
    select 1 from credit_entries s
    where s.job_id = e.job_id and s.kind in ('settle', 'release')
  );
Enter fullscreen mode Exit fullscreen mode

Release each one, idempotently, with release:${jobId}. That query is also the best health metric I have — if stuck holds spike, something upstream is broken and I know before anyone emails me.

What I'd tell past me

  • Model the money as events, not as a number. Every "where did my credits go" question becomes a SELECT instead of an apology.
  • Put the concurrency check in the write. Read-then-write is a free-credits exploit with extra steps.
  • Assume every mutation runs twice. Because it will.
  • Decide your refund policy before you write the refund code. The code is a day. The policy is the actual product decision.

Non-determinism isn't an edge case in an AI product — it's the substrate. The billing layer is where that stops being a philosophical observation and starts costing you money.


I'm building Photo AI Studio solo — happy to answer anything about the stack, the economics, or what generation costs actually look like at volume. Ask below.

Top comments (0)