{"slug": "i-stored-ai-saas-credits-as-a-single-integer-then-the-refunds-started", "title": "I stored AI SaaS credits as a single integer. Then the refunds started.", "summary": "An engineer at an AI SaaS company rebuilt their billing system after discovering that a single integer column for user credits made it impossible to trace refunds, duplicate charges, and usage history. The original design stored only the current balance, overwriting all transaction details. The replacement uses an append-only ledger table that records every credit change with a reason, reference ID, and idempotency key, enabling full auditability and resolving customer disputes with transaction history.", "body_md": "*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.*\n\nThe 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.\n\nUsage-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.\n\nMy first credit system was one column on the users table: `credits`\n\n. 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.\n\nIt 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:\n\n\"Why do I have 37 credits? I bought 500 and I've barely touched this.\"\n\nI opened a psql shell and ran the only query I had:\n\n```\nselect credits from users where id = '...'; -- 37\n```\n\nThirty-seven. That was the whole story my database could tell me.\n\nNot where the other 463 went. Not when. Not why. Every `credits = credits - 1`\n\nhad 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.\n\nThat 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.\n\nWith 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:\n\nA single number answers none of them. Once refunds exist, you don't want the balance. You want the list of things that produced it.\n\nThe 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.\n\n```\ncreate table credit_entries (\n  id              bigint generated always as identity primary key,\n  user_id         uuid not null references users(id),\n  amount          integer not null, -- +grant, -spend, never zero\n  reason          text not null,    -- 'purchase' | 'generation' | 'refund' | 'signup_bonus' | 'clawback'\n  ref_type        text,             -- 'stripe_payment' | 'job' | 'credit_entry'\n  ref_id          text,             -- the thing that caused this row\n  idempotency_key text,             -- dedupe retries\n  created_at      timestamptz not null default now()\n);\n\ncreate unique index on credit_entries (idempotency_key)\n  where idempotency_key is not null;\n```\n\nNo `updated_at`\n\n. Rows are written once and never touched again. The balance is a sum:\n\n```\nselect coalesce(sum(amount), 0) as balance\nfrom credit_entries\nwhere user_id = $1;\n```\n\nRemember the 37-credit guy? On the ledger, his mystery is just his history, in order:\n\n```\n+500   purchase                    2026-05-02 09:14\n -463  generation (x463 entries)   2026-05-02 -> 05-09\n-----\n   37  balance\n```\n\nFour 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.\"\n\nClosed in one message, because the data could finally speak for itself.\n\nThis 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.\n\nOne afternoon Stripe fired `checkout.session.completed`\n\n, 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_`\n\nid again. Then a third time.\n\nThe buyer pinged me an hour later:\n\n\"lol why do I suddenly have 1,500 credits\"\n\nSame event, same signature, processed three times, and I had paid for it.\n\nThe tempting fix is to check before you write. Don't. That's a race, and two deliveries can both pass the check:\n\n``` js\n// DON'T: two concurrent deliveries both read \"not seen\" and both grant\nconst seen = await db.query.creditEntries.findFirst({\n  where: eq(idempotencyKey, eventId),\n});\n\nif (!seen) await grant(userId, 500);\n```\n\nLet the database be the referee. Derive a key from the source event and let a unique index throw the duplicate away:\n\n```\nawait db.insert(creditEntries)\n  .values({\n    userId,\n    amount: 500,\n    reason: \"purchase\",\n    refType: \"stripe_payment\",\n    refId: paymentId,\n    idempotencyKey: stripeEventId,\n  })\n  .onConflictDoNothing({\n    target: creditEntries.idempotencyKey,\n  });\n```\n\nThe 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.\n\nPicture a user on hotel wifi mashing \"Generate\" twice, or someone with two tabs open. They have one credit left.\n\nRequest 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.\n\nThe 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:\n\n``` js\nawait db.transaction(async (tx) => {\n  const { balance } = await tx\n    .select({ balance: sql`coalesce(sum(amount), 0)` })\n    .from(creditEntries)\n    .where(eq(creditEntries.userId, userId));\n\n  if (balance < cost) throw new InsufficientCredits();\n\n  await tx.insert(creditEntries).values({\n    userId,\n    amount: -cost,\n    reason: \"generation\",\n    refType: \"job\",\n    refId: jobId,\n    idempotencyKey: jobId,\n  });\n});\n```\n\nRun 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`\n\nas the key also means a retried job won't get billed twice.\n\nForty 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.\n\nOn a ledger you append:\n\n```\ninsert into credit_entries (user_id, amount, reason, ref_type, ref_id)\nvalues ($user, -500, 'refund', 'credit_entry', $original_entry_id);\n```\n\nThe balance drops honestly, goes negative if it has to, and the new row points straight at the purchase it cancels.\n\nSame 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.\n\nBoring on purpose, and boring is exactly what you want anywhere near money.\n\nAt 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.\n\nThe 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.\n\nThe 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)`\n\ncarries you further than you'd guess.\n\nOne 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.\n\n`0.000001`\n\nthat drift over time.`DELETE`\n\nbreaks the audit trail for good.The honest tradeoff: this is more code than `credits -= 1`\n\n. 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.\n\nWe 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).\n\nIf 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.\n\nIf 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.", "url": "https://wpnews.pro/news/i-stored-ai-saas-credits-as-a-single-integer-then-the-refunds-started", "canonical_source": "https://dev.to/velobasex/i-stored-ai-saas-credits-as-a-single-integer-then-the-refunds-started-2hg", "published_at": "2026-06-25 02:53:09+00:00", "updated_at": "2026-06-25 03:13:21.187144+00:00", "lang": "en", "topics": ["ai-products", "developer-tools"], "entities": ["Stripe"], "alternates": {"html": "https://wpnews.pro/news/i-stored-ai-saas-credits-as-a-single-integer-then-the-refunds-started", "markdown": "https://wpnews.pro/news/i-stored-ai-saas-credits-as-a-single-integer-then-the-refunds-started.md", "text": "https://wpnews.pro/news/i-stored-ai-saas-credits-as-a-single-integer-then-the-refunds-started.txt", "jsonld": "https://wpnews.pro/news/i-stored-ai-saas-credits-as-a-single-integer-then-the-refunds-started.jsonld"}}