diff --git a/modules/billing/middlewares/billing.attachUsageContext.js b/modules/billing/middlewares/billing.attachUsageContext.js index f4a97df04..140a781a9 100644 --- a/modules/billing/middlewares/billing.attachUsageContext.js +++ b/modules/billing/middlewares/billing.attachUsageContext.js @@ -42,7 +42,9 @@ const attachUsageContext = async (req, res, next) => { const meterUsed = meter?.meterUsed ?? 0; const meterQuota = meter?.meterQuota ?? 0; - const remaining = (meterQuota - meterUsed) + extrasBalance; + // Clamp plan headroom to 0 so overflow past quota is not double-counted against + // the extras balance (mirrors billing.quota.service enforcement). + const remaining = Math.max(0, meterQuota - meterUsed) + extrasBalance; req.meterContext = { used: meterUsed, diff --git a/modules/billing/services/billing.quota.service.js b/modules/billing/services/billing.quota.service.js index 90d3c5654..e561c3cf4 100644 --- a/modules/billing/services/billing.quota.service.js +++ b/modules/billing/services/billing.quota.service.js @@ -138,7 +138,10 @@ async function assertCanExecute({ orgId, organization, user, resource, action }) meterQuota = usage.meterQuota ?? 0; } - const remaining = (meterQuota - meterUsed) + extrasBalance; + // Clamp plan headroom to 0: meterUsed is $inc'd uncapped past meterQuota, and the + // overflow units are also debited from extrasBalance. Without the clamp the same + // overflow is subtracted twice, denying paying orgs that still hold extras. + const remaining = Math.max(0, meterQuota - meterUsed) + extrasBalance; if (remaining <= 0) { throw new AppError('Meter exhausted', { status: 402, diff --git a/modules/billing/tests/billing.attachUsageContext.unit.tests.js b/modules/billing/tests/billing.attachUsageContext.unit.tests.js index 8e71c84d6..3f7aa6727 100644 --- a/modules/billing/tests/billing.attachUsageContext.unit.tests.js +++ b/modules/billing/tests/billing.attachUsageContext.unit.tests.js @@ -107,6 +107,21 @@ describe('billing.attachUsageContext middleware unit tests:', () => { expect(next).toHaveBeenCalled(); }); + test('should clamp plan headroom to 0 when meterUsed exceeds meterQuota', async () => { + mockBillingUsageService.getMeter.mockResolvedValue({ + meterUsed: 6000, + meterQuota: 5000, + meterBreakdown: {}, + }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(500); + + await attachUsageContext(req, res, next); + + // remaining = Math.max(0, 5000 - 6000) + 500 = 500 (extras only), NOT -500 + expect(res.setHeader).toHaveBeenCalledWith('X-Meter-Remaining', '500'); + expect(next).toHaveBeenCalled(); + }); + test('should be a no-op when meterMode is false', async () => { mockConfig.billing.meterMode = false; diff --git a/modules/billing/tests/billing.quota.service.unit.tests.js b/modules/billing/tests/billing.quota.service.unit.tests.js index fa8727532..8aa518f73 100644 --- a/modules/billing/tests/billing.quota.service.unit.tests.js +++ b/modules/billing/tests/billing.quota.service.unit.tests.js @@ -160,6 +160,21 @@ describe('assertCanExecute — meter mode (meterMode: true)', () => { })).resolves.toEqual({ degraded: false }); }); + test('resolves when meterUsed > meterQuota but extras cover the overflow (headroom clamps to 0)', async () => { + const assertCanExecute = await setupMocks(meterConfig); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ status: 'active', pastDueSince: null }); + // meterUsed is $inc'd past meterQuota; the overflow (6000 - 5000 = 1000) is already + // debited from extras. remaining must be Math.max(0, 5000 - 6000) + 500 = 500 (extras), + // NOT (5000 - 6000) + 500 = -500 which would wrongly deny with a positive extras balance. + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 6000, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(500); + + await expect(assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + })).resolves.toEqual({ degraded: false }); + }); + test('throws AppError status 402 METER_EXHAUSTED when meter is exhausted', async () => { const assertCanExecute = await setupMocks(meterConfig); mockSubscriptionRepository.findByOrganization.mockResolvedValue({ status: 'active', pastDueSince: null });