From 1811d75c367ee5f88a4a59fe95cdbf8e1c2844c4 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 12 May 2026 10:58:47 +0200 Subject: [PATCH] fix(billing): derive meterQuota from live plan config, not stale DB snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of trawl_node#1174 to keep devkit Node parity and prevent the next /update-stack from wiping the downstream patch (per feedback memory update_stack_theirs_wipes_patches). Bug: getUsage controller returns `meterQuota: meter?.meterQuota ?? 0`. The DB doc's `meterQuota` is a snapshot baked at last `incrementMeter` time, so it stays at the old plan's quota (e.g. 10 for free) after the user upgrades, until the next scrap run. UI reads the stale snapshot instead of the live config. Fix: derive meterQuota from BillingPlanService.getActivePlan(plan)?.meterQuota with a fallback to the DB snapshot if the live config returns null (unknown plan id) and 0 as last resort. Tests cover: - growth plan returns 1600 (live) even when DB snapshot shows 10 - unknown plan falls back to DB snapshot - both unavailable → 0 --- .../billing/controllers/billing.controller.js | 8 +- .../billing.usage.endpoint.unit.tests.js | 125 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/modules/billing/controllers/billing.controller.js b/modules/billing/controllers/billing.controller.js index b2f01358f..13244987a 100644 --- a/modules/billing/controllers/billing.controller.js +++ b/modules/billing/controllers/billing.controller.js @@ -8,6 +8,7 @@ import logger from '../../../lib/services/logger.js'; import BillingService from '../services/billing.service.js'; import BillingUsageService from '../services/billing.usage.service.js'; import BillingExtraService from '../services/billing.extra.service.js'; +import BillingPlanService from '../services/billing.plan.service.js'; /** * @desc Endpoint to create a Stripe Checkout session @@ -93,13 +94,18 @@ const getUsage = async (req, res) => { const extrasRemaining = await BillingExtraService.getOrgBalanceContext(req.organization._id.toString()); const packsAvailable = config.billing?.packs ?? []; + // Derive meterQuota from the live plan config — DB snapshot is stale after a plan upgrade + // (free → growth) until the next incrementMeter call. Live config is authoritative. + const livePlan = BillingPlanService.getActivePlan(plan); + const liveQuota = livePlan?.meterQuota ?? meter?.meterQuota ?? 0; + return responses.success(res, 'billing usage')({ plan, planVersion: meter?.planVersion ?? null, weekKey: meter?.weekKey ?? BillingUsageService.currentWeekKey(), weekResetAt: meter?.resetAt ?? null, meterUsed: meter?.meterUsed ?? 0, - meterQuota: meter?.meterQuota ?? 0, + meterQuota: liveQuota, meterBreakdown: meter?.meterBreakdown ?? {}, extrasRemaining, packsAvailable, diff --git a/modules/billing/tests/billing.usage.endpoint.unit.tests.js b/modules/billing/tests/billing.usage.endpoint.unit.tests.js index 4dd22bd94..78407cdff 100644 --- a/modules/billing/tests/billing.usage.endpoint.unit.tests.js +++ b/modules/billing/tests/billing.usage.endpoint.unit.tests.js @@ -49,6 +49,10 @@ describe('Billing usage endpoint unit tests:', () => { default: { getOrgBalanceContext: jest.fn().mockResolvedValue(0) }, })); + jest.unstable_mockModule('../services/billing.plan.service.js', () => ({ + default: { getActivePlan: jest.fn().mockReturnValue(null) }, + })); + jest.unstable_mockModule('../../../config/index.js', () => ({ default: mockConfig, })); @@ -232,4 +236,125 @@ describe('Billing usage endpoint unit tests:', () => { message: 'Internal Server Error', })); }); + + describe('meterMode — meterQuota live override', () => { + let mockBillingPlanService; + let mockMeterUsageService; + + beforeEach(async () => { + jest.resetModules(); + + mockBillingService = { + getLocalSubscription: jest.fn(), + getSubscription: jest.fn(), + }; + + mockMeterUsageService = { + getMeter: jest.fn(), + currentWeekKey: jest.fn().mockReturnValue('2026-W20'), + }; + + mockBillingPlanService = { + getActivePlan: jest.fn(), + }; + + jest.unstable_mockModule('../services/billing.service.js', () => ({ + default: mockBillingService, + })); + + jest.unstable_mockModule('../services/billing.usage.service.js', () => ({ + default: mockMeterUsageService, + })); + + jest.unstable_mockModule('../services/billing.extra.service.js', () => ({ + default: { getOrgBalanceContext: jest.fn().mockResolvedValue(0) }, + })); + + jest.unstable_mockModule('../services/billing.plan.service.js', () => ({ + default: mockBillingPlanService, + })); + + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + jest.unstable_mockModule('../lib/events.js', () => ({ + default: { emit: jest.fn() }, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { + billing: { + meterMode: true, + packs: [], + }, + }, + })); + + const mod = await import('../controllers/billing.controller.js'); + billingController = mod.default; + + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + }); + + test('returns growth plan quota (1600) from live config when DB snapshot shows old free quota (10)', async () => { + // DB snapshot baked when user was on free (meterQuota = 10) + mockBillingService.getLocalSubscription.mockResolvedValue({ plan: 'growth', status: 'active' }); + mockMeterUsageService.getMeter.mockResolvedValue({ + meterUsed: 46, + meterQuota: 10, + meterBreakdown: {}, + planVersion: 'v1', + weekKey: '2026-W20', + resetAt: null, + }); + // Live config knows growth = 1600 + mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 1600 }); + + const req = { organization: { _id: orgId } }; + await billingController.getUsage(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + const payload = res.json.mock.calls[0][0].data; + expect(payload.meterQuota).toBe(1600); // live plan config, not stale DB snapshot + expect(payload.meterUsed).toBe(46); + expect(payload.plan).toBe('growth'); + }); + + test('falls back to DB snapshot quota when live plan config returns null (unknown plan)', async () => { + mockBillingService.getLocalSubscription.mockResolvedValue({ plan: 'legacy', status: 'active' }); + mockMeterUsageService.getMeter.mockResolvedValue({ + meterUsed: 5, + meterQuota: 50, + meterBreakdown: {}, + planVersion: 'v1', + weekKey: '2026-W20', + resetAt: null, + }); + mockBillingPlanService.getActivePlan.mockReturnValue(null); + + const req = { organization: { _id: orgId } }; + await billingController.getUsage(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + const payload = res.json.mock.calls[0][0].data; + expect(payload.meterQuota).toBe(50); // falls back to DB snapshot + }); + + test('returns 0 meterQuota when no DB snapshot and no live config plan', async () => { + mockBillingService.getLocalSubscription.mockResolvedValue(null); + mockMeterUsageService.getMeter.mockResolvedValue(null); + mockBillingPlanService.getActivePlan.mockReturnValue(null); + + const req = { organization: { _id: orgId } }; + await billingController.getUsage(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + const payload = res.json.mock.calls[0][0].data; + expect(payload.meterQuota).toBe(0); + expect(payload.meterUsed).toBe(0); + }); + }); });