Skip to content
39 changes: 39 additions & 0 deletions modules/billing/config/billing.config.zod.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Module dependencies
*/
import { z } from 'zod';

/**
* @desc Zod schema for a single entry in config.billing.planDefinitions.
*
* Required fields:
* planId — logical plan identifier (e.g. "free", "growth", "pro")
* meterQuota — compute units per reset period (0 = no periodic quota)
* ratios — feature multiplier map (e.g. { default: 1, autofix: 2 })
*
* Optional fields (N2 signup-grant):
* signupGrant — one-time credit granted to fresh orgs at signup (positive integer).
* Must be present when oneShot is set.
* oneShot — when true the grant does not renew on weekly/monthly reset.
* Must be present when signupGrant is set.
* version — plan version string (YYYY.MM or v${N}); falls back to meter.ratioVersion
*/
const billingPlanDefinitionSchema = z
.object({
planId: z.string().min(1),
meterQuota: z.number().int().nonnegative(),
ratios: z.record(z.string(), z.number()).default(() => ({})),
version: z.string().optional(),
signupGrant: z.number().int().positive().optional(),
oneShot: z.boolean().optional(),
})
.refine(
(data) => (data.signupGrant !== undefined) === (data.oneShot !== undefined),
{
message:
'signupGrant and oneShot must be defined together — set both or neither',
path: ['signupGrant'],
},
);

export { billingPlanDefinitionSchema };
9 changes: 7 additions & 2 deletions modules/billing/config/billing.development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,20 @@ const config = {
/**
* Plan definitions — DOWNSTREAM-OVERRIDE-REQUIRED for meter mode.
* Used by BillingPlanService.ensureSeeded() at boot to upsert BillingPlan docs.
* Array of objects: { planId, meterQuota: units/week, ratios: { featureKey: multiplier }, version? }.
* Array of objects: { planId, meterQuota: units/week, ratios: { featureKey: multiplier }, version?, signupGrant?, oneShot? }.
* billing.plans enum is derived at boot from planDefinitions.map(p => p.planId) — do NOT
* declare billing.plans manually. This is the single source of truth for plan identifiers.
*
* version: optional — falls back to billing.meter.ratioVersion, then v${count + 1}.
* Downstream projects that use YYYY.MM versioning should set this (or set ratioVersion).
*/
planDefinitions: [
{ planId: 'free', meterQuota: 0, ratios: { default: 1 } },
/**
* signupGrant: one-time credit given to fresh orgs at signup (N2 feature).
* oneShot: true = grant does not renew on weekly/monthly reset.
* DOWNSTREAM-OVERRIDE: set meterQuota + signupGrant per project's actual unit economics.
*/
{ planId: 'free', meterQuota: 0, signupGrant: 500, oneShot: true, ratios: { default: 1 } },
{ planId: 'starter', meterQuota: 50000, ratios: { default: 1 } },
{ planId: 'pro', meterQuota: 500000, ratios: { default: 1 } },
{ planId: 'enterprise', meterQuota: 2000000, ratios: { default: 1 } },
Expand Down
39 changes: 37 additions & 2 deletions modules/billing/services/billing.plan.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
* Module dependencies
*/
import config from '../../../config/index.js';
import logger from '../../../lib/services/logger.js';

/**
* @desc Resolve a plan definition from config.billing.planDefinitions.
* Returns a plan-like object compatible with the previous DB-backed API.
*
* @param {string} planId - The logical plan identifier (e.g. "pro").
* @returns {Object|null} Plan object with meterQuota, ratios, version fields, or null.
* @returns {Object|null} Plan object with meterQuota, ratios, version, and optional
* signupGrant / oneShot fields (N2 signup-grant feature), or null.
*/
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
const getPlanFromConfig = (planId) => {
Expand All @@ -21,12 +23,30 @@ const getPlanFromConfig = (planId) => {
?? config?.billing?.meter?.ratioVersion
?? 'v1';

return {
const plan = {
planId: def.planId,
version,
meterQuota: def.meterQuota ?? 0,
ratios: def.ratios ?? {},
};

// N2 signup-grant fields — only present when configured (optional on the def).
// Guard: signupGrant and oneShot must be defined together (co-presence invariant).
// This mirrors the Zod schema refine() and catches misconfigured planDefinitions at runtime.
const hasSignupGrant = def.signupGrant !== undefined;
const hasOneShotFlag = def.oneShot !== undefined;
if (hasSignupGrant !== hasOneShotFlag) {
logger.warn(
'[billing.plan] signupGrant and oneShot must be defined together — check planDefinitions config',
{ planId: def.planId, signupGrant: def.signupGrant, oneShot: def.oneShot },
);
// Do not attach partial/broken grant fields — return plan without them.
return plan;
}
if (hasSignupGrant) plan.signupGrant = def.signupGrant;
if (hasOneShotFlag) plan.oneShot = def.oneShot;

return plan;
};

/**
Expand Down Expand Up @@ -57,7 +77,22 @@ const getPlanByVersion = (planId, version) => {
return plan;
};

/**
* @desc Get the signup grant amount for a given planId (N2 Free Tier Grant feature).
* Returns the signupGrant value if the plan definition has it, or undefined.
* Callers must handle undefined as "no grant configured for this plan".
*
* @param {string} planId - The logical plan identifier (e.g. "free").
* @returns {number|undefined} The signupGrant value, or undefined.
*/
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
const getSignupGrant = (planId) => {
const plan = getPlanFromConfig(planId);
return plan?.signupGrant;
};

export default {
getActivePlan,
getPlanByVersion,
getSignupGrant,
};
141 changes: 141 additions & 0 deletions modules/billing/tests/billing.config.planDefinitions.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Module dependencies.
*/
import { jest, describe, it, beforeEach, afterEach, expect } from '@jest/globals';

/**
* Unit tests for planDefinitions config shape — validates signupGrant / oneShot fields.
*/
describe('billing planDefinitions config:', () => {
let config;

beforeEach(async () => {
jest.resetModules();
const mod = await import('../../../config/index.js');
config = mod.default;
});
Comment on lines +12 to +16

afterEach(() => {
jest.restoreAllMocks();
});

describe('planDefinitions — Free signup grant', () => {
it('Free plan has signupGrant: 500 and oneShot: true', () => {
const definitions = config?.billing?.planDefinitions ?? [];
const free = definitions.find((p) => p.planId === 'free');
expect(free).toBeDefined();
expect(free.signupGrant).toBe(500);
expect(free.oneShot).toBe(true);
expect(free.meterQuota).toBe(0);
});

it('Paid plans (starter, pro, enterprise) do not have signupGrant', () => {
const definitions = config?.billing?.planDefinitions ?? [];
const paidPlanIds = ['starter', 'pro', 'enterprise'];
for (const planId of paidPlanIds) {
const plan = definitions.find((p) => p.planId === planId);
// Assert the plan exists so this test cannot pass vacuously if IDs change
expect(plan).toBeDefined();
Comment on lines +23 to +38
expect(plan.signupGrant).toBeUndefined();
expect(plan.oneShot).toBeUndefined();
}
});

it('Free plan signupGrant is a positive integer (> 0)', () => {
const definitions = config?.billing?.planDefinitions ?? [];
const free = definitions.find((p) => p.planId === 'free');
expect(free).toBeDefined();
expect(Number.isInteger(free.signupGrant)).toBe(true);
expect(free.signupGrant).toBeGreaterThan(0);
});

it('Free plan oneShot is a boolean', () => {
const definitions = config?.billing?.planDefinitions ?? [];
const free = definitions.find((p) => p.planId === 'free');
expect(free).toBeDefined();
expect(typeof free.oneShot).toBe('boolean');
});
});

describe('planDefinitions Zod schema validation', () => {
it('validates a plan entry with signupGrant and oneShot via billingPlanDefinitionSchema', async () => {
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
const result = billingPlanDefinitionSchema.safeParse({
planId: 'free',
meterQuota: 0,
signupGrant: 500,
oneShot: true,
ratios: { default: 1 },
});
expect(result.success).toBe(true);
});

it('validates a plan entry without signupGrant and oneShot (optional fields)', async () => {
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
const result = billingPlanDefinitionSchema.safeParse({
planId: 'growth',
meterQuota: 1600,
ratios: { default: 1 },
});
expect(result.success).toBe(true);
});

it('rejects negative signupGrant', async () => {
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
const result = billingPlanDefinitionSchema.safeParse({
planId: 'free',
meterQuota: 0,
signupGrant: -1,
oneShot: true,
ratios: {},
});
expect(result.success).toBe(false);
});

it('rejects zero signupGrant (meaningless no-op grant)', async () => {
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
const result = billingPlanDefinitionSchema.safeParse({
planId: 'free',
meterQuota: 0,
signupGrant: 0,
oneShot: true,
ratios: {},
});
expect(result.success).toBe(false);
});

it('rejects non-integer signupGrant', async () => {
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
const result = billingPlanDefinitionSchema.safeParse({
planId: 'free',
meterQuota: 0,
signupGrant: 500.5,
oneShot: true,
ratios: {},
});
expect(result.success).toBe(false);
});

it('rejects oneShot without signupGrant', async () => {
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
const result = billingPlanDefinitionSchema.safeParse({
planId: 'free',
meterQuota: 0,
oneShot: true,
ratios: {},
});
expect(result.success).toBe(false);
});

it('rejects signupGrant without oneShot', async () => {
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
const result = billingPlanDefinitionSchema.safeParse({
planId: 'free',
meterQuota: 0,
signupGrant: 500,
ratios: {},
});
expect(result.success).toBe(false);
});
});
});
Loading
Loading