Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion modules/billing/controllers/billing.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Comment on lines +97 to +101
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,
Expand Down
125 changes: 125 additions & 0 deletions modules/billing/tests/billing.usage.endpoint.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));
Expand Down Expand Up @@ -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);
});
});
});
Loading