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); + }); + }); });