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 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,7 @@ const {
|
|||||||
getAccountSnapshot,
|
getAccountSnapshot,
|
||||||
getBillingSummary,
|
getBillingSummary,
|
||||||
getEndpointResponse,
|
getEndpointResponse,
|
||||||
|
ensureSufficientCredits,
|
||||||
isInsufficientCreditsError,
|
isInsufficientCreditsError,
|
||||||
claimNotificationOnce,
|
claimNotificationOnce,
|
||||||
simulatePurchase,
|
simulatePurchase,
|
||||||
@@ -943,6 +944,10 @@ app.post('/v1/health-check', async (request, response) => {
|
|||||||
|
|
||||||
const accountSnapshot = await getAccountSnapshot(db, userId);
|
const accountSnapshot = await getAccountSnapshot(db, userId);
|
||||||
ensureNotGuest(userId, HEALTH_CHECK_COST);
|
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()) {
|
if (!isOpenAiConfigured()) {
|
||||||
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.');
|
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.');
|
||||||
|
|||||||
@@ -259,6 +259,15 @@ const getAvailableCredits = (account) => {
|
|||||||
return monthlyRemaining + Math.max(0, account.topupBalance);
|
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) => {
|
const buildBillingSummary = (account) => {
|
||||||
return {
|
return {
|
||||||
entitlement: {
|
entitlement: {
|
||||||
@@ -817,6 +826,7 @@ module.exports = {
|
|||||||
getBillingSummary,
|
getBillingSummary,
|
||||||
getEndpointResponse,
|
getEndpointResponse,
|
||||||
getMonthlyAllowanceForPlan,
|
getMonthlyAllowanceForPlan,
|
||||||
|
ensureSufficientCredits,
|
||||||
isInsufficientCreditsError,
|
isInsufficientCreditsError,
|
||||||
runInTransaction,
|
runInTransaction,
|
||||||
simulatePurchase,
|
simulatePurchase,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const {
|
|||||||
alignAccountToCurrentCycle,
|
alignAccountToCurrentCycle,
|
||||||
getAvailableCredits,
|
getAvailableCredits,
|
||||||
consumeCredits,
|
consumeCredits,
|
||||||
|
ensureSufficientCredits,
|
||||||
getMonthlyAllowanceForPlan,
|
getMonthlyAllowanceForPlan,
|
||||||
} = require('../lib/billing');
|
} = require('../lib/billing');
|
||||||
|
|
||||||
@@ -72,3 +73,18 @@ test('monthly cycle rollover resets free usage', () => {
|
|||||||
assert.equal(aligned.monthlyAllowance, 3);
|
assert.equal(aligned.monthlyAllowance, 3);
|
||||||
assert.equal(getAvailableCredits(aligned), 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));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user