From 1811d75c367ee5f88a4a59fe95cdbf8e1c2844c4 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 12 May 2026 10:58:47 +0200 Subject: [PATCH 1/2] 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); + }); + }); }); From 1b1001a7de799e83b521d38cd6cad81f98d23745 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 22 May 2026 11:49:59 +0200 Subject: [PATCH 2/2] fix(billing): warn on getUsage meterQuota fallback when live plan config missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review: getUsage silently fell back to the DB snapshot/0 when getActivePlan returned null, masking plan/config mismatches. getUsage is a read-only display endpoint (not a gate — billing.requireQuota already fails safe with 503 on the work path), so it degrades gracefully but now emits a warn log for ops visibility. Comment updated to document the fallback. --- .../billing/controllers/billing.controller.js | 16 ++++++++++++++-- .../tests/billing.usage.endpoint.unit.tests.js | 10 +++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/modules/billing/controllers/billing.controller.js b/modules/billing/controllers/billing.controller.js index 13244987a..ff48afd8f 100644 --- a/modules/billing/controllers/billing.controller.js +++ b/modules/billing/controllers/billing.controller.js @@ -94,9 +94,21 @@ 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. + // Derive meterQuota from the live plan config — the DB snapshot is stale after a + // plan upgrade (free → growth) until the next incrementMeter call, so live config + // is authoritative when present. When it is absent (legacy plan dropped from config, + // or seeding / version bump in progress) this is a read-only display endpoint, not a + // gate: billing.requireQuota already fails safe with 503 on the work path, so here we + // degrade gracefully to the DB snapshot (or 0) and warn for ops visibility rather than + // failing the usage page. const livePlan = BillingPlanService.getActivePlan(plan); + if (!livePlan) { + logger.warn('[billing.getUsage] no live plan definition for planId — falling back to DB snapshot quota', { + planId: plan, + organizationId: req.organization._id.toString(), + snapshotQuota: meter?.meterQuota ?? null, + }); + } const liveQuota = livePlan?.meterQuota ?? meter?.meterQuota ?? 0; return responses.success(res, 'billing usage')({ diff --git a/modules/billing/tests/billing.usage.endpoint.unit.tests.js b/modules/billing/tests/billing.usage.endpoint.unit.tests.js index 78407cdff..73e23e1e8 100644 --- a/modules/billing/tests/billing.usage.endpoint.unit.tests.js +++ b/modules/billing/tests/billing.usage.endpoint.unit.tests.js @@ -240,6 +240,7 @@ describe('Billing usage endpoint unit tests:', () => { describe('meterMode — meterQuota live override', () => { let mockBillingPlanService; let mockMeterUsageService; + let mockLogger; beforeEach(async () => { jest.resetModules(); @@ -274,8 +275,9 @@ describe('Billing usage endpoint unit tests:', () => { default: mockBillingPlanService, })); + mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() }; jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ - default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + default: mockLogger, })); jest.unstable_mockModule('../lib/events.js', () => ({ default: { emit: jest.fn() }, @@ -321,6 +323,7 @@ describe('Billing usage endpoint unit tests:', () => { expect(payload.meterQuota).toBe(1600); // live plan config, not stale DB snapshot expect(payload.meterUsed).toBe(46); expect(payload.plan).toBe('growth'); + expect(mockLogger.warn).not.toHaveBeenCalled(); // live plan present — no fallback warn }); test('falls back to DB snapshot quota when live plan config returns null (unknown plan)', async () => { @@ -341,6 +344,11 @@ describe('Billing usage endpoint unit tests:', () => { expect(res.status).toHaveBeenCalledWith(200); const payload = res.json.mock.calls[0][0].data; expect(payload.meterQuota).toBe(50); // falls back to DB snapshot + // Fallback path masks a plan/config mismatch — warn for ops visibility. + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('no live plan definition'), + expect.objectContaining({ planId: 'legacy', snapshotQuota: 50 }), + ); }); test('returns 0 meterQuota when no DB snapshot and no live config plan', async () => {