From a480679a2d7800fd3ab1784592108a5677cad2ab Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 3 Jun 2026 22:45:18 -0700 Subject: [PATCH] vis: make metering service alarm only go off on actual abuse (#3208) --- .../services/metering/MeteringService.test.ts | 73 +++++++++++++++++++ .../services/metering/MeteringService.ts | 22 +++--- 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/src/backend/services/metering/MeteringService.test.ts b/src/backend/services/metering/MeteringService.test.ts index 0f3674af7..7e8c7b40c 100644 --- a/src/backend/services/metering/MeteringService.test.ts +++ b/src/backend/services/metering/MeteringService.test.ts @@ -321,6 +321,79 @@ describe('MeteringService', () => { }); }); + // ── overuse alarm ──────────────────────────────────────────────── + + describe('overuse alarm', () => { + const wasOveruseAlarmed = (alarmSpy: ReturnType) => + alarmSpy.mock.calls.some( + (call) => + typeof call[0] === 'string' && + call[0].includes('usage exceeded'), + ); + + it('does not alarm when a single large request crosses the limit in one shot', async () => { + const bigActor: Actor = { user: makeUser() }; + const sub = await target.getActorSubscription(bigActor); + const alarmSpy = vi.spyOn(server.clients.alarm, 'create'); + + // Previous usage was 0 (under the allowance) — one big request that + // blows past the limit is legitimate and shouldn't page. + await target.incrementUsage( + bigActor, + 'ai:chat', + 1, + sub.monthUsageAllowance * 5, + ); + + expect(wasOveruseAlarmed(alarmSpy)).toBe(false); + alarmSpy.mockRestore(); + }); + + it('alarms on the next expense once already at or past the limit', async () => { + const overActor: Actor = { user: makeUser() }; + const sub = await target.getActorSubscription(overActor); + + // First expense takes them exactly to the limit — no alarm yet. + await target.incrementUsage( + overActor, + 'ai:chat', + 1, + sub.monthUsageAllowance, + ); + + // Spy only on the *second* expense, which lands while already at + // the limit — this is the one that should flag. + const alarmSpy = vi.spyOn(server.clients.alarm, 'create'); + await target.incrementUsage(overActor, 'ai:chat', 1, 1_000); + + expect(alarmSpy).toHaveBeenCalledWith( + expect.stringContaining('usage exceeded'), + expect.stringContaining('exceeded their usage allowance'), + expect.objectContaining({ totalUsage: expect.any(Number) }), + ); + alarmSpy.mockRestore(); + }); + + it('does not alarm while purchased credits still cover the overage', async () => { + const creditActor: Actor = { user: makeUser() }; + const sub = await target.getActorSubscription(creditActor); + await target.updateAddonCredit(creditActor.user.uuid, 5_000_000); + + await target.incrementUsage( + creditActor, + 'ai:chat', + 1, + sub.monthUsageAllowance, + ); + + const alarmSpy = vi.spyOn(server.clients.alarm, 'create'); + await target.incrementUsage(creditActor, 'ai:chat', 1, 1_000); + + expect(wasOveruseAlarmed(alarmSpy)).toBe(false); + alarmSpy.mockRestore(); + }); + }); + // ── batchIncrementUsages ───────────────────────────────────────── describe('batchIncrementUsages', () => { diff --git a/src/backend/services/metering/MeteringService.ts b/src/backend/services/metering/MeteringService.ts index 2d88dc098..623b3ed0a 100644 --- a/src/backend/services/metering/MeteringService.ts +++ b/src/backend/services/metering/MeteringService.ts @@ -893,20 +893,22 @@ export class MeteringService extends PuterService { incrementCost, } = ctx; - const allowedMultiple = Math.floor( - actorUsages.total / actorSubscription.monthUsageAllowance, - ); - const previousMultiple = Math.floor( - (actorUsages.total - incrementCost) / - actorSubscription.monthUsageAllowance, - ); - const isOver2x = allowedMultiple >= 2; - const crossedThreshold = previousMultiple < allowedMultiple; + const allowance = actorSubscription.monthUsageAllowance; + // No metered allowance to exceed (e.g. unlimited policies) — nothing to flag. + if (!(allowance > 0)) return; + + // Only alarm if the actor was ALREADY at or past their allowance before + // this expense arrived. A single large request that crosses the limit in + // one shot (previous usage still under the allowance) is legitimate and + // shouldn't page — we only flag sustained overuse, i.e. the next expense + // that lands while they're already over. + const previousUsage = actorUsages.total - incrementCost; + const wasAlreadyOverLimit = previousUsage >= allowance; const hasNoAddonCredit = (actorAddons.purchasedCredits || 0) <= (actorAddons.consumedPurchaseCredits || 0); - if (!(isOver2x && crossedThreshold && hasNoAddonCredit)) return; + if (!(wasAlreadyOverLimit && hasNoAddonCredit)) return; this.clients.alarm.create( `metering usage exceeded by user: ${actor.user?.username}`,