Skip to content

Commit 1811d75

Browse files
fix(billing): derive meterQuota from live plan config, not stale DB snapshot
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
1 parent bc3ea12 commit 1811d75

2 files changed

Lines changed: 132 additions & 1 deletion

File tree

modules/billing/controllers/billing.controller.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import logger from '../../../lib/services/logger.js';
88
import BillingService from '../services/billing.service.js';
99
import BillingUsageService from '../services/billing.usage.service.js';
1010
import BillingExtraService from '../services/billing.extra.service.js';
11+
import BillingPlanService from '../services/billing.plan.service.js';
1112

1213
/**
1314
* @desc Endpoint to create a Stripe Checkout session
@@ -93,13 +94,18 @@ const getUsage = async (req, res) => {
9394
const extrasRemaining = await BillingExtraService.getOrgBalanceContext(req.organization._id.toString());
9495
const packsAvailable = config.billing?.packs ?? [];
9596

97+
// Derive meterQuota from the live plan config — DB snapshot is stale after a plan upgrade
98+
// (free → growth) until the next incrementMeter call. Live config is authoritative.
99+
const livePlan = BillingPlanService.getActivePlan(plan);
100+
const liveQuota = livePlan?.meterQuota ?? meter?.meterQuota ?? 0;
101+
96102
return responses.success(res, 'billing usage')({
97103
plan,
98104
planVersion: meter?.planVersion ?? null,
99105
weekKey: meter?.weekKey ?? BillingUsageService.currentWeekKey(),
100106
weekResetAt: meter?.resetAt ?? null,
101107
meterUsed: meter?.meterUsed ?? 0,
102-
meterQuota: meter?.meterQuota ?? 0,
108+
meterQuota: liveQuota,
103109
meterBreakdown: meter?.meterBreakdown ?? {},
104110
extrasRemaining,
105111
packsAvailable,

modules/billing/tests/billing.usage.endpoint.unit.tests.js

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ describe('Billing usage endpoint unit tests:', () => {
4949
default: { getOrgBalanceContext: jest.fn().mockResolvedValue(0) },
5050
}));
5151

52+
jest.unstable_mockModule('../services/billing.plan.service.js', () => ({
53+
default: { getActivePlan: jest.fn().mockReturnValue(null) },
54+
}));
55+
5256
jest.unstable_mockModule('../../../config/index.js', () => ({
5357
default: mockConfig,
5458
}));
@@ -232,4 +236,125 @@ describe('Billing usage endpoint unit tests:', () => {
232236
message: 'Internal Server Error',
233237
}));
234238
});
239+
240+
describe('meterMode — meterQuota live override', () => {
241+
let mockBillingPlanService;
242+
let mockMeterUsageService;
243+
244+
beforeEach(async () => {
245+
jest.resetModules();
246+
247+
mockBillingService = {
248+
getLocalSubscription: jest.fn(),
249+
getSubscription: jest.fn(),
250+
};
251+
252+
mockMeterUsageService = {
253+
getMeter: jest.fn(),
254+
currentWeekKey: jest.fn().mockReturnValue('2026-W20'),
255+
};
256+
257+
mockBillingPlanService = {
258+
getActivePlan: jest.fn(),
259+
};
260+
261+
jest.unstable_mockModule('../services/billing.service.js', () => ({
262+
default: mockBillingService,
263+
}));
264+
265+
jest.unstable_mockModule('../services/billing.usage.service.js', () => ({
266+
default: mockMeterUsageService,
267+
}));
268+
269+
jest.unstable_mockModule('../services/billing.extra.service.js', () => ({
270+
default: { getOrgBalanceContext: jest.fn().mockResolvedValue(0) },
271+
}));
272+
273+
jest.unstable_mockModule('../services/billing.plan.service.js', () => ({
274+
default: mockBillingPlanService,
275+
}));
276+
277+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
278+
default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
279+
}));
280+
jest.unstable_mockModule('../lib/events.js', () => ({
281+
default: { emit: jest.fn() },
282+
}));
283+
284+
jest.unstable_mockModule('../../../config/index.js', () => ({
285+
default: {
286+
billing: {
287+
meterMode: true,
288+
packs: [],
289+
},
290+
},
291+
}));
292+
293+
const mod = await import('../controllers/billing.controller.js');
294+
billingController = mod.default;
295+
296+
res = {
297+
status: jest.fn().mockReturnThis(),
298+
json: jest.fn().mockReturnThis(),
299+
};
300+
});
301+
302+
test('returns growth plan quota (1600) from live config when DB snapshot shows old free quota (10)', async () => {
303+
// DB snapshot baked when user was on free (meterQuota = 10)
304+
mockBillingService.getLocalSubscription.mockResolvedValue({ plan: 'growth', status: 'active' });
305+
mockMeterUsageService.getMeter.mockResolvedValue({
306+
meterUsed: 46,
307+
meterQuota: 10,
308+
meterBreakdown: {},
309+
planVersion: 'v1',
310+
weekKey: '2026-W20',
311+
resetAt: null,
312+
});
313+
// Live config knows growth = 1600
314+
mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 1600 });
315+
316+
const req = { organization: { _id: orgId } };
317+
await billingController.getUsage(req, res);
318+
319+
expect(res.status).toHaveBeenCalledWith(200);
320+
const payload = res.json.mock.calls[0][0].data;
321+
expect(payload.meterQuota).toBe(1600); // live plan config, not stale DB snapshot
322+
expect(payload.meterUsed).toBe(46);
323+
expect(payload.plan).toBe('growth');
324+
});
325+
326+
test('falls back to DB snapshot quota when live plan config returns null (unknown plan)', async () => {
327+
mockBillingService.getLocalSubscription.mockResolvedValue({ plan: 'legacy', status: 'active' });
328+
mockMeterUsageService.getMeter.mockResolvedValue({
329+
meterUsed: 5,
330+
meterQuota: 50,
331+
meterBreakdown: {},
332+
planVersion: 'v1',
333+
weekKey: '2026-W20',
334+
resetAt: null,
335+
});
336+
mockBillingPlanService.getActivePlan.mockReturnValue(null);
337+
338+
const req = { organization: { _id: orgId } };
339+
await billingController.getUsage(req, res);
340+
341+
expect(res.status).toHaveBeenCalledWith(200);
342+
const payload = res.json.mock.calls[0][0].data;
343+
expect(payload.meterQuota).toBe(50); // falls back to DB snapshot
344+
});
345+
346+
test('returns 0 meterQuota when no DB snapshot and no live config plan', async () => {
347+
mockBillingService.getLocalSubscription.mockResolvedValue(null);
348+
mockMeterUsageService.getMeter.mockResolvedValue(null);
349+
mockBillingPlanService.getActivePlan.mockReturnValue(null);
350+
351+
const req = { organization: { _id: orgId } };
352+
await billingController.getUsage(req, res);
353+
354+
expect(res.status).toHaveBeenCalledWith(200);
355+
const payload = res.json.mock.calls[0][0].data;
356+
expect(payload.meterQuota).toBe(0);
357+
expect(payload.meterUsed).toBe(0);
358+
});
359+
});
235360
});

0 commit comments

Comments
 (0)