Skip to content
Merged
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
20 changes: 19 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,30 @@ 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 — the DB snapshot is stale after a
// plan upgrade (free → growth) until the next incrementMeter call, so live config
// is authoritative when present. When it is absent (legacy plan dropped from config,
// or seeding / version bump in progress) this is a read-only display endpoint, not a
// gate: billing.requireQuota already fails safe with 503 on the work path, so here we
// degrade gracefully to the DB snapshot (or 0) and warn for ops visibility rather than
// failing the usage page.
const livePlan = BillingPlanService.getActivePlan(plan);
if (!livePlan) {
logger.warn('[billing.getUsage] no live plan definition for planId — falling back to DB snapshot quota', {
planId: plan,
organizationId: req.organization._id.toString(),
snapshotQuota: meter?.meterQuota ?? null,
});
}
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,
Expand Down
133 changes: 133 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,133 @@ describe('Billing usage endpoint unit tests:', () => {
message: 'Internal Server Error',
}));
});

describe('meterMode — meterQuota live override', () => {
let mockBillingPlanService;
let mockMeterUsageService;
let mockLogger;

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

mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() };
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
default: mockLogger,
}));
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');
expect(mockLogger.warn).not.toHaveBeenCalled(); // live plan present — no fallback warn
});

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
// Fallback path masks a plan/config mismatch — warn for ops visibility.
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('no live plan definition'),
expect.objectContaining({ planId: 'legacy', snapshotQuota: 50 }),
);
});

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