diff --git a/modules/billing/config/billing.config.zod.js b/modules/billing/config/billing.config.zod.js new file mode 100644 index 000000000..bc619d38d --- /dev/null +++ b/modules/billing/config/billing.config.zod.js @@ -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 }; diff --git a/modules/billing/config/billing.development.config.js b/modules/billing/config/billing.development.config.js index a7233358e..964b65ffe 100644 --- a/modules/billing/config/billing.development.config.js +++ b/modules/billing/config/billing.development.config.js @@ -47,7 +47,7 @@ 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. * @@ -55,7 +55,12 @@ const config = { * 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 } }, diff --git a/modules/billing/services/billing.plan.service.js b/modules/billing/services/billing.plan.service.js index a85a9e2cb..0dca51331 100644 --- a/modules/billing/services/billing.plan.service.js +++ b/modules/billing/services/billing.plan.service.js @@ -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) => { @@ -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; }; /** @@ -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, }; diff --git a/modules/billing/tests/billing.config.planDefinitions.unit.tests.js b/modules/billing/tests/billing.config.planDefinitions.unit.tests.js new file mode 100644 index 000000000..b4c1daa61 --- /dev/null +++ b/modules/billing/tests/billing.config.planDefinitions.unit.tests.js @@ -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; + }); + + 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(); + 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); + }); + }); +}); diff --git a/modules/billing/tests/billing.plan.service.unit.tests.js b/modules/billing/tests/billing.plan.service.unit.tests.js index 7d14fd8ec..ffb312e2f 100644 --- a/modules/billing/tests/billing.plan.service.unit.tests.js +++ b/modules/billing/tests/billing.plan.service.unit.tests.js @@ -9,6 +9,7 @@ import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globa describe('BillingPlanService unit tests:', () => { let BillingPlanService; let mockConfig; + let mockLogger; beforeEach(async () => { jest.resetModules(); @@ -27,9 +28,14 @@ describe('BillingPlanService unit tests:', () => { }, }; + mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + jest.unstable_mockModule('../../../config/index.js', () => ({ default: mockConfig, })); + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: mockLogger, + })); const mod = await import('../services/billing.plan.service.js'); BillingPlanService = mod.default; @@ -83,6 +89,103 @@ describe('BillingPlanService unit tests:', () => { expect(result).not.toBeInstanceOf(Promise); expect(typeof result).toBe('object'); }); + + test('passes through signupGrant when present on plan definition', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, signupGrant: 500, oneShot: true, ratios: {} }, + ]; + const result = BillingPlanService.getActivePlan('free'); + expect(result.signupGrant).toBe(500); + expect(result.oneShot).toBe(true); + }); + + test('does not include signupGrant on plans without the field', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'growth', meterQuota: 1600, ratios: {} }, + ]; + const result = BillingPlanService.getActivePlan('growth'); + expect(result.signupGrant).toBeUndefined(); + expect(result.oneShot).toBeUndefined(); + }); + + test('warns when signupGrant is set without oneShot (co-presence invariant)', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, signupGrant: 500, ratios: {} }, + ]; + BillingPlanService.getActivePlan('free'); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('signupGrant and oneShot must be defined together'), + expect.objectContaining({ planId: 'free' }), + ); + }); + + test('warns when oneShot is set without signupGrant (co-presence invariant)', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, oneShot: true, ratios: {} }, + ]; + BillingPlanService.getActivePlan('free'); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('signupGrant and oneShot must be defined together'), + expect.objectContaining({ planId: 'free' }), + ); + }); + + test('does not warn when both signupGrant and oneShot are set', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, signupGrant: 500, oneShot: true, ratios: {} }, + ]; + BillingPlanService.getActivePlan('free'); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + test('does not warn when neither signupGrant nor oneShot are set', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'pro', meterQuota: 8000, ratios: {} }, + ]; + BillingPlanService.getActivePlan('pro'); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + test('does not attach lone signupGrant when oneShot is missing (co-presence guard early return)', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, signupGrant: 500, ratios: {} }, + ]; + const result = BillingPlanService.getActivePlan('free'); + expect(result.signupGrant).toBeUndefined(); + expect(result.oneShot).toBeUndefined(); + }); + + test('does not attach lone oneShot when signupGrant is missing (co-presence guard early return)', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, oneShot: true, ratios: {} }, + ]; + const result = BillingPlanService.getActivePlan('free'); + expect(result.signupGrant).toBeUndefined(); + expect(result.oneShot).toBeUndefined(); + }); + }); + + describe('getSignupGrant', () => { + test('returns signupGrant for a plan with both signupGrant and oneShot set', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, signupGrant: 500, oneShot: true, ratios: {} }, + ]; + const grant = BillingPlanService.getSignupGrant('free'); + expect(grant).toBe(500); + }); + + test('returns undefined for plans without signupGrant', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'pro', meterQuota: 8000, ratios: {} }, + ]; + const grant = BillingPlanService.getSignupGrant('pro'); + expect(grant).toBeUndefined(); + }); + + test('returns undefined for unknown planId', () => { + const grant = BillingPlanService.getSignupGrant('nonexistent'); + expect(grant).toBeUndefined(); + }); }); describe('getPlanByVersion', () => { diff --git a/modules/billing/tests/billing.quota.unit.tests.js b/modules/billing/tests/billing.quota.unit.tests.js index 3fa0c54e0..fa17b2828 100644 --- a/modules/billing/tests/billing.quota.unit.tests.js +++ b/modules/billing/tests/billing.quota.unit.tests.js @@ -48,6 +48,17 @@ describe('requireQuota middleware:', () => { default: mockConfig, })); + // billing.plan.service.js imports logger at module load time; the logger reads + // config.log.fileLogger which is absent from this suite's minimal config fixture. + // Mock the service to avoid loading the real logger with an incomplete config. + jest.unstable_mockModule('../services/billing.plan.service.js', () => ({ + default: { + getActivePlan: jest.fn().mockReturnValue(null), + getPlanByVersion: jest.fn().mockReturnValue(null), + getSignupGrant: jest.fn().mockReturnValue(undefined), + }, + })); + const mod = await import('../middlewares/billing.requireQuota.js'); requireQuota = mod.default;