Skip to content

Commit 9d15821

Browse files
fix(billing): derive meterQuota from live plan config, not stale DB snapshot (#3667)
* 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 * fix(billing): warn on getUsage meterQuota fallback when live plan config missing 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.
1 parent 16f611c commit 9d15821

2 files changed

Lines changed: 152 additions & 1 deletion

File tree

modules/billing/controllers/billing.controller.js

Lines changed: 19 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,30 @@ 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 — the DB snapshot is stale after a
98+
// plan upgrade (free → growth) until the next incrementMeter call, so live config
99+
// is authoritative when present. When it is absent (legacy plan dropped from config,
100+
// or seeding / version bump in progress) this is a read-only display endpoint, not a
101+
// gate: billing.requireQuota already fails safe with 503 on the work path, so here we
102+
// degrade gracefully to the DB snapshot (or 0) and warn for ops visibility rather than
103+
// failing the usage page.
104+
const livePlan = BillingPlanService.getActivePlan(plan);
105+
if (!livePlan) {
106+
logger.warn('[billing.getUsage] no live plan definition for planId — falling back to DB snapshot quota', {
107+
planId: plan,
108+
organizationId: req.organization._id.toString(),
109+
snapshotQuota: meter?.meterQuota ?? null,
110+
});
111+
}
112+
const liveQuota = livePlan?.meterQuota ?? meter?.meterQuota ?? 0;
113+
96114
return responses.success(res, 'billing usage')({
97115
plan,
98116
planVersion: meter?.planVersion ?? null,
99117
weekKey: meter?.weekKey ?? BillingUsageService.currentWeekKey(),
100118
weekResetAt: meter?.resetAt ?? null,
101119
meterUsed: meter?.meterUsed ?? 0,
102-
meterQuota: meter?.meterQuota ?? 0,
120+
meterQuota: liveQuota,
103121
meterBreakdown: meter?.meterBreakdown ?? {},
104122
extrasRemaining,
105123
packsAvailable,

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

Lines changed: 133 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,133 @@ 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+
let mockLogger;
244+
245+
beforeEach(async () => {
246+
jest.resetModules();
247+
248+
mockBillingService = {
249+
getLocalSubscription: jest.fn(),
250+
getSubscription: jest.fn(),
251+
};
252+
253+
mockMeterUsageService = {
254+
getMeter: jest.fn(),
255+
currentWeekKey: jest.fn().mockReturnValue('2026-W20'),
256+
};
257+
258+
mockBillingPlanService = {
259+
getActivePlan: jest.fn(),
260+
};
261+
262+
jest.unstable_mockModule('../services/billing.service.js', () => ({
263+
default: mockBillingService,
264+
}));
265+
266+
jest.unstable_mockModule('../services/billing.usage.service.js', () => ({
267+
default: mockMeterUsageService,
268+
}));
269+
270+
jest.unstable_mockModule('../services/billing.extra.service.js', () => ({
271+
default: { getOrgBalanceContext: jest.fn().mockResolvedValue(0) },
272+
}));
273+
274+
jest.unstable_mockModule('../services/billing.plan.service.js', () => ({
275+
default: mockBillingPlanService,
276+
}));
277+
278+
mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() };
279+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
280+
default: mockLogger,
281+
}));
282+
jest.unstable_mockModule('../lib/events.js', () => ({
283+
default: { emit: jest.fn() },
284+
}));
285+
286+
jest.unstable_mockModule('../../../config/index.js', () => ({
287+
default: {
288+
billing: {
289+
meterMode: true,
290+
packs: [],
291+
},
292+
},
293+
}));
294+
295+
const mod = await import('../controllers/billing.controller.js');
296+
billingController = mod.default;
297+
298+
res = {
299+
status: jest.fn().mockReturnThis(),
300+
json: jest.fn().mockReturnThis(),
301+
};
302+
});
303+
304+
test('returns growth plan quota (1600) from live config when DB snapshot shows old free quota (10)', async () => {
305+
// DB snapshot baked when user was on free (meterQuota = 10)
306+
mockBillingService.getLocalSubscription.mockResolvedValue({ plan: 'growth', status: 'active' });
307+
mockMeterUsageService.getMeter.mockResolvedValue({
308+
meterUsed: 46,
309+
meterQuota: 10,
310+
meterBreakdown: {},
311+
planVersion: 'v1',
312+
weekKey: '2026-W20',
313+
resetAt: null,
314+
});
315+
// Live config knows growth = 1600
316+
mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 1600 });
317+
318+
const req = { organization: { _id: orgId } };
319+
await billingController.getUsage(req, res);
320+
321+
expect(res.status).toHaveBeenCalledWith(200);
322+
const payload = res.json.mock.calls[0][0].data;
323+
expect(payload.meterQuota).toBe(1600); // live plan config, not stale DB snapshot
324+
expect(payload.meterUsed).toBe(46);
325+
expect(payload.plan).toBe('growth');
326+
expect(mockLogger.warn).not.toHaveBeenCalled(); // live plan present — no fallback warn
327+
});
328+
329+
test('falls back to DB snapshot quota when live plan config returns null (unknown plan)', async () => {
330+
mockBillingService.getLocalSubscription.mockResolvedValue({ plan: 'legacy', status: 'active' });
331+
mockMeterUsageService.getMeter.mockResolvedValue({
332+
meterUsed: 5,
333+
meterQuota: 50,
334+
meterBreakdown: {},
335+
planVersion: 'v1',
336+
weekKey: '2026-W20',
337+
resetAt: null,
338+
});
339+
mockBillingPlanService.getActivePlan.mockReturnValue(null);
340+
341+
const req = { organization: { _id: orgId } };
342+
await billingController.getUsage(req, res);
343+
344+
expect(res.status).toHaveBeenCalledWith(200);
345+
const payload = res.json.mock.calls[0][0].data;
346+
expect(payload.meterQuota).toBe(50); // falls back to DB snapshot
347+
// Fallback path masks a plan/config mismatch — warn for ops visibility.
348+
expect(mockLogger.warn).toHaveBeenCalledWith(
349+
expect.stringContaining('no live plan definition'),
350+
expect.objectContaining({ planId: 'legacy', snapshotQuota: 50 }),
351+
);
352+
});
353+
354+
test('returns 0 meterQuota when no DB snapshot and no live config plan', async () => {
355+
mockBillingService.getLocalSubscription.mockResolvedValue(null);
356+
mockMeterUsageService.getMeter.mockResolvedValue(null);
357+
mockBillingPlanService.getActivePlan.mockReturnValue(null);
358+
359+
const req = { organization: { _id: orgId } };
360+
await billingController.getUsage(req, res);
361+
362+
expect(res.status).toHaveBeenCalledWith(200);
363+
const payload = res.json.mock.calls[0][0].data;
364+
expect(payload.meterQuota).toBe(0);
365+
expect(payload.meterUsed).toBe(0);
366+
});
367+
});
235368
});

0 commit comments

Comments
 (0)