Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
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