From eda0ac98d94666705aefa63c988403f13169f7a9 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Mon, 6 Jul 2026 13:02:36 +0200 Subject: [PATCH] fix(server): pre-check credit balance before health-check AI analysis The health-check endpoint runs the paid OpenAI analysis before charging (failed analyses are intentionally not charged). Without a balance pre-check, a user below HEALTH_CHECK_COST could trigger unlimited free analyses with fresh idempotency keys. Co-Authored-By: Claude Fable 5 --- server/index.js | 5 +++++ server/lib/billing.js | 10 ++++++++++ server/test/billing.test.js | 16 ++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/server/index.js b/server/index.js index 01ba886..83fa341 100644 --- a/server/index.js +++ b/server/index.js @@ -51,6 +51,7 @@ const { getAccountSnapshot, getBillingSummary, getEndpointResponse, + ensureSufficientCredits, isInsufficientCreditsError, claimNotificationOnce, simulatePurchase, @@ -943,6 +944,10 @@ app.post('/v1/health-check', async (request, response) => { const accountSnapshot = await getAccountSnapshot(db, userId); ensureNotGuest(userId, HEALTH_CHECK_COST); + // Balance pre-check: the paid AI analysis below runs BEFORE the charge + // (failed analyses are intentionally not charged), so without this check a + // user with insufficient credits could trigger unlimited free analyses. + ensureSufficientCredits(accountSnapshot, HEALTH_CHECK_COST); if (!isOpenAiConfigured()) { const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.'); diff --git a/server/lib/billing.js b/server/lib/billing.js index 9558d99..8e46186 100644 --- a/server/lib/billing.js +++ b/server/lib/billing.js @@ -259,6 +259,15 @@ const getAvailableCredits = (account) => { return monthlyRemaining + Math.max(0, account.topupBalance); }; +// Pre-flight balance check for endpoints that must not run paid work +// (e.g. OpenAI analyses) before the actual charge happens. +const ensureSufficientCredits = (account, cost) => { + const available = getAvailableCredits(account); + if (available < cost) { + throw createInsufficientCreditsError(cost, available); + } +}; + const buildBillingSummary = (account) => { return { entitlement: { @@ -817,6 +826,7 @@ module.exports = { getBillingSummary, getEndpointResponse, getMonthlyAllowanceForPlan, + ensureSufficientCredits, isInsufficientCreditsError, runInTransaction, simulatePurchase, diff --git a/server/test/billing.test.js b/server/test/billing.test.js index 7af7a42..d27f088 100644 --- a/server/test/billing.test.js +++ b/server/test/billing.test.js @@ -5,6 +5,7 @@ const { alignAccountToCurrentCycle, getAvailableCredits, consumeCredits, + ensureSufficientCredits, getMonthlyAllowanceForPlan, } = require('../lib/billing'); @@ -72,3 +73,18 @@ test('monthly cycle rollover resets free usage', () => { assert.equal(aligned.monthlyAllowance, 3); assert.equal(getAvailableCredits(aligned), 3); }); + +test('ensureSufficientCredits throws 402 when balance is below cost', () => { + const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 2, topupBalance: 0 }); + assert.throws(() => ensureSufficientCredits(account, 2), (error) => { + assert.equal(error.code, 'INSUFFICIENT_CREDITS'); + assert.equal(error.status, 402); + assert.deepEqual(error.metadata, { required: 2, available: 1 }); + return true; + }); +}); + +test('ensureSufficientCredits passes when balance covers cost', () => { + const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1, topupBalance: 0 }); + assert.doesNotThrow(() => ensureSufficientCredits(account, 2)); +});