From 97ec5d115c58e7bcc7c195f0b4da528ad771b568 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 8 Jul 2026 00:43:09 -0400 Subject: [PATCH] fix: double counting purchased credit usage (#3359) --- .../services/metering/MeteringService.test.ts | 39 +++++++++++++++++++ .../services/metering/MeteringService.ts | 16 ++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/backend/services/metering/MeteringService.test.ts b/src/backend/services/metering/MeteringService.test.ts index f2732a68f..20b2139fc 100644 --- a/src/backend/services/metering/MeteringService.test.ts +++ b/src/backend/services/metering/MeteringService.test.ts @@ -787,6 +787,45 @@ describe('MeteringService', () => { expect(await target.hasAnyUsage(actor)).toBe(false); }); + it('does not double-charge same-month overage against remaining (usage total + consumed credits)', async () => { + const sub = await target.getActorSubscription(actor); + await target.updateAddonCredit(actor.user.uuid, 5_000_000); + + // Exhaust the allowance, then overspend by 1_000_000 — the overage + // is consumed from purchased credits. + await target.incrementUsage( + actor, + 'kv:read', + 1, + sub.monthUsageAllowance, + ); + await target.incrementUsage(actor, 'kv:read', 1, 1_000_000); + await waitFor(async () => { + const addons = await target.getActorAddons(actor); + expect(addons.consumedPurchaseCredits).toBe(1_000_000); + }); + + // The overage already lives in both this month's usage total and + // consumedPurchaseCredits; remaining must only be reduced once. + const allowed = await target.getAllowedUsage(actor); + expect(allowed.remaining).toBe(4_000_000); + }); + + it('counts consumed credits from prior months against the credit pool only', async () => { + // Simulate a prior-month overage: consumed credits exist but the + // current month has no usage (monthly usage keys roll over). + await target.updateAddonCredit(actor.user.uuid, 5_000_000); + await server.stores.kv.incr({ + key: `${POLICY_PREFIX}:actor:${actor.user.uuid}:addons`, + pathAndAmountMap: { consumedPurchaseCredits: 2_000_000 }, + }); + + const allowed = await target.getAllowedUsage(actor); + expect(allowed.remaining).toBe( + allowed.monthUsageAllowance + 3_000_000, + ); + }); + it('hasEnoughCredits compares remaining against the requested amount', async () => { await target.updateAddonCredit(actor.user.uuid, 1_000); expect(await target.hasEnoughCredits(actor, 100)).toBe(true); diff --git a/src/backend/services/metering/MeteringService.ts b/src/backend/services/metering/MeteringService.ts index 41d67a7bc..d6aced461 100644 --- a/src/backend/services/metering/MeteringService.ts +++ b/src/backend/services/metering/MeteringService.ts @@ -627,13 +627,21 @@ export class MeteringService extends PuterService { ], ); - const remaining = Math.max( + // Overage past the allowance is already charged to purchased credits + // via consumedPurchaseCredits, so the allowance and the credit pool + // must be netted separately — subtracting month usage AND consumed + // credits from one combined pool would charge the overage twice. + const remainingAllowance = Math.max( 0, - (userSubscription.monthUsageAllowance || 0) + - (addons?.purchasedCredits || 0) - - (currentMonthUsage.usage.total || 0) - + (userSubscription.monthUsageAllowance || 0) - + (currentMonthUsage.usage.total || 0), + ); + const remainingPurchasedCredits = Math.max( + 0, + (addons?.purchasedCredits || 0) - (addons?.consumedPurchaseCredits || 0), ); + const remaining = remainingAllowance + remainingPurchasedCredits; return { remaining,