From a616f641dcaedbe006a12c8f6bf1fe36ea61bb70 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Wed, 29 Apr 2026 22:20:37 +0200 Subject: [PATCH] feat(billing): extras routes + middleware + meter quota gate (PR-N4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add extras pack purchase flow and meter-mode quota gating. Routes (billing.routes.js): - POST /api/billing/extras/checkout — ExtrasCheckoutRequest validator - GET /api/billing/extras/balance - GET /api/billing/extras/ledger (paginated ?page&limit) Controllers (billing.controller.js): - extrasCheckout, extrasBalance, extrasLedger handlers - getUsage extended: meterMode returns weekKey/meterUsed/Quota/breakdown/extras Service (billing.service.js): - createExtrasCheckout: mode=payment, automatic_tax, payment_intent_data.metadata - idempotencyKey on both createCheckout and createExtrasCheckout - fix createCheckout: add missing automatic_tax + idempotencyKey Schema: ExtrasCheckoutRequest Zod (strict, packId/successUrl/cancelUrl) Middlewares: - requireQuota: dual mode (legacy 429 / meter 402 METER_EXHAUSTED with pack info) - attachUsageContext: non-blocking req.meterContext + X-Meter-Remaining header Policy: BillingExtrasCheckout/Balance/Ledger subjects + member abilities Tests: 866 total (+45 from 821 baseline) --- .../billing/controllers/billing.controller.js | 87 ++++- .../middlewares/billing.attachUsageContext.js | 68 ++++ .../middlewares/billing.requireQuota.js | 45 ++- .../models/billing.subscription.schema.js | 12 + modules/billing/policies/billing.policy.js | 8 + modules/billing/routes/billing.routes.js | 18 ++ modules/billing/services/billing.service.js | 127 +++++++- .../billing.attachUsageContext.unit.tests.js | 165 ++++++++++ .../tests/billing.checkout.unit.tests.js | 24 +- .../billing.extras.controller.unit.tests.js | 296 ++++++++++++++++++ .../billing/tests/billing.quota.unit.tests.js | 118 +++++++ .../tests/billing.routes.integration.tests.js | 193 ++++++++++++ .../billing.service.extras.unit.tests.js | 263 ++++++++++++++++ modules/billing/tests/billing.unit.tests.js | 76 +++++ 14 files changed, 1469 insertions(+), 31 deletions(-) create mode 100644 modules/billing/middlewares/billing.attachUsageContext.js create mode 100644 modules/billing/tests/billing.attachUsageContext.unit.tests.js create mode 100644 modules/billing/tests/billing.extras.controller.unit.tests.js create mode 100644 modules/billing/tests/billing.routes.integration.tests.js create mode 100644 modules/billing/tests/billing.service.extras.unit.tests.js diff --git a/modules/billing/controllers/billing.controller.js b/modules/billing/controllers/billing.controller.js index 38e46bf86..1f647df04 100644 --- a/modules/billing/controllers/billing.controller.js +++ b/modules/billing/controllers/billing.controller.js @@ -6,6 +6,8 @@ import config from '../../../config/index.js'; import responses from '../../../lib/helpers/responses.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 BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js'; /** * @desc Endpoint to create a Stripe Checkout session @@ -59,7 +61,10 @@ const getSubscription = async (req, res) => { }; /** - * @desc Endpoint to get billing usage for the current organization + * @desc Endpoint to get billing usage for the current organization. + * When meterMode is enabled, returns meter fields (meterUsed, meterQuota, + * meterBreakdown, extrasRemaining, weekKey, weekResetAt, planVersion). + * Legacy mode (meterMode=false) returns the existing counters/limits shape — unchanged. * @param {Object} req - Express request object * @param {Object} res - Express response object * @returns {Promise} @@ -70,7 +75,26 @@ const getUsage = async (req, res) => { const subscription = await BillingService.getSubscription(req.organization._id); const plan = (!subscription || !activeStatuses.includes(subscription.status)) ? 'free' : (subscription.plan || 'free'); - // Get usage counters (includes month field) + if (config.billing?.meterMode) { + // Meter mode — return compute fields + const meter = await BillingUsageService.getMeter(req.organization._id.toString()); + const extrasRemaining = await BillingExtraBalanceRepository.getBalance(req.organization._id.toString()); + const packsAvailable = config.billing?.packs ?? []; + + 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, + meterBreakdown: meter?.meterBreakdown ?? {}, + extrasRemaining, + packsAvailable, + }); + } + + // Legacy mode — counters/limits shape unchanged const usage = await BillingUsageService.get(req.organization._id.toString()); // Flatten quotas config into { "resource_action": limit } format @@ -87,7 +111,7 @@ const getUsage = async (req, res) => { } } - responses.success(res, 'billing usage')({ + return responses.success(res, 'billing usage')({ plan, period: usage.month, usage: usage.counters || {}, @@ -98,9 +122,66 @@ const getUsage = async (req, res) => { } }; +/** + * @desc Endpoint to create a Stripe Checkout Session for an extras pack purchase + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {Promise} + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js controller, not Qwik +const extrasCheckout = async (req, res) => { + try { + const { packId, successUrl, cancelUrl } = req.body; + const result = await BillingService.createExtrasCheckout(req.organization, packId, successUrl, cancelUrl); + responses.success(res, 'extras checkout session created')(result); + } catch (err) { + const status = err.message?.startsWith('Invalid') || err.message?.includes('not found') ? 422 : 502; + const title = status === 422 ? 'Unprocessable Entity' : 'Bad Gateway'; + responses.error(res, status, title, 'Failed to create extras checkout session')(err); + } +}; + +/** + * @desc Endpoint to get the current extras balance for the organization + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {Promise} + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js controller, not Qwik +const extrasBalance = async (req, res) => { + try { + const balance = await BillingExtraBalanceRepository.getBalance(req.organization._id.toString()); + const packsAvailable = config.billing?.packs ?? []; + responses.success(res, 'extras balance')({ balance, packsAvailable }); + } catch (err) { + responses.error(res, 500, 'Internal Server Error', 'Failed to retrieve extras balance')(err); + } +}; + +/** + * @desc Endpoint to get paginated extras ledger for the organization + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {Promise} + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js controller, not Qwik +const extrasLedger = async (req, res) => { + try { + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20)); + const result = await BillingExtraService.listLedger(req.organization._id.toString(), { page, limit }); + responses.success(res, 'extras ledger')(result); + } catch (err) { + responses.error(res, 500, 'Internal Server Error', 'Failed to retrieve extras ledger')(err); + } +}; + export default { checkout, portal, getSubscription, getUsage, + extrasCheckout, + extrasBalance, + extrasLedger, }; diff --git a/modules/billing/middlewares/billing.attachUsageContext.js b/modules/billing/middlewares/billing.attachUsageContext.js new file mode 100644 index 000000000..5984f3e7f --- /dev/null +++ b/modules/billing/middlewares/billing.attachUsageContext.js @@ -0,0 +1,68 @@ +/** + * Module dependencies + */ +import config from '../../../config/index.js'; +import BillingUsageService from '../services/billing.usage.service.js'; +import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js'; + +/** + * @desc Express middleware that decorates `req.meterContext` with the current + * meter usage, quota, extras remaining, and breakdown for downstream handlers. + * Also sets the `X-Meter-Remaining` response header to enable Vue mini-bar polling. + * + * Only active when `config.billing.meterMode === true`. + * In legacy mode (meterMode=false), this middleware is a no-op pass-through. + * + * Failures are non-blocking: any error is logged and the request continues. + * Mount after `resolveOrganization` so that `req.organization` is populated. + * + * NOTE: This middleware is documented here but intentionally NOT auto-wired in + * lib/app.js — downstream projects (e.g. Trawl) decide where to mount it. + * Example: `app.use(resolveOrganization, attachUsageContext);` + * + * @param {import('express').Request} req - Express request object. + * @param {import('express').Response} res - Express response object. + * @param {import('express').NextFunction} next - Express next callback. + * @returns {Promise} + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js middleware, not Qwik +const attachUsageContext = async (req, res, next) => { + // No-op when meterMode is disabled or organization is not resolved + if (!config.billing?.meterMode || !req.organization) { + return next(); + } + + try { + const orgId = req.organization._id.toString(); + + const [meter, extrasBalance] = await Promise.all([ + BillingUsageService.getMeter(orgId), + BillingExtraBalanceRepository.getBalance(orgId), + ]); + + const meterUsed = meter?.meterUsed ?? 0; + const meterQuota = meter?.meterQuota ?? 0; + const remaining = (meterQuota - meterUsed) + extrasBalance; + + req.meterContext = { + used: meterUsed, + quota: meterQuota, + extrasRemaining: extrasBalance, + breakdown: meter?.meterBreakdown ?? {}, + }; + + res.setHeader('X-Meter-Remaining', String(remaining)); + } catch (err) { + // Non-blocking — log and continue. Never let a context decorator 500 the request. + try { + const { default: logger } = await import('../../../lib/services/logger.js'); + logger.error('[billing.attachUsageContext] Failed to attach meter context:', err); + } catch { + // logger import failed — silently ignore + } + } + + return next(); +}; + +export default attachUsageContext; diff --git a/modules/billing/middlewares/billing.requireQuota.js b/modules/billing/middlewares/billing.requireQuota.js index 434786695..16c35ab51 100644 --- a/modules/billing/middlewares/billing.requireQuota.js +++ b/modules/billing/middlewares/billing.requireQuota.js @@ -3,6 +3,7 @@ */ import SubscriptionRepository from '../repositories/billing.subscription.repository.js'; import BillingUsageService from '../services/billing.usage.service.js'; +import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js'; import { activeStatuses } from '../lib/constants.js'; import config from '../../../config/index.js'; @@ -10,18 +11,21 @@ import responses from '../../../lib/helpers/responses.js'; /** * Returns Express middleware that gates access based on plan quotas. - * Reads limits from `config.billing.quotas[plan][resource][action]` and - * compares against the current month's usage via BillingUsageService. * - * When no quota is configured for the plan/resource/action combination - * (limit is `null` or `undefined`), the request is allowed through. - * When the limit is `Infinity`, the request is also allowed without - * checking usage (unlimited plan). + * Dual mode: + * - When `config.billing.meterMode === false` (default): legacy quota logic. + * Reads limits from `config.billing.quotas[plan][resource][action]` and + * compares against the current month's usage via BillingUsageService. + * When no quota is configured or limit is Infinity, the request is allowed. + * + * - When `config.billing.meterMode === true`: meter quota gate. + * Computes `(meterQuota - meterUsed) + extrasBalance`. Returns 402 when <= 0, + * including pack purchase info for the client. Falls through to next() otherwise. * * Expects `req.organization` to be set by resolveOrganization upstream. * - * @param {string} resource - The quota resource name (e.g. 'scraps'). - * @param {string} action - The quota action name (e.g. 'create', 'execute'). + * @param {string} resource - The quota resource name (e.g. 'scraps'). Used in legacy mode. + * @param {string} action - The quota action name (e.g. 'create', 'execute'). Used in legacy mode. * @returns {Function} Express middleware function. */ function requireQuota(resource, action) { @@ -38,6 +42,31 @@ function requireQuota(resource, action) { } try { + // ── Meter mode (meterMode: true) ────────────────────────────────────── + if (config.billing?.meterMode === true) { + const orgId = req.organization._id.toString(); + const usage = await BillingUsageService.getMeter(orgId); + const extrasBalance = await BillingExtraBalanceRepository.getBalance(orgId); + + const meterUsed = usage?.meterUsed ?? 0; + const meterQuota = usage?.meterQuota ?? 0; + const remaining = (meterQuota - meterUsed) + extrasBalance; + + if (remaining <= 0) { + return responses.error(res, 402, 'Payment Required', 'Meter exhausted')({ + type: 'METER_EXHAUSTED', + meterUsed, + meterQuota, + extrasRemaining: extrasBalance, + packsAvailable: config.billing?.packs ?? [], + upgradeUrl: config.billing?.upgradeUrl ?? '/billing/plans', + }); + } + + return next(); + } + + // ── Legacy mode (meterMode: false, default) ─────────────────────────── // Determine current plan — default to free when subscription is missing or inactive const subscription = await SubscriptionRepository.findByOrganization(req.organization._id); const plan = (!subscription || !activeStatuses.includes(subscription.status)) ? 'free' : (subscription.plan || 'free'); diff --git a/modules/billing/models/billing.subscription.schema.js b/modules/billing/models/billing.subscription.schema.js index a47ad1a65..127980199 100644 --- a/modules/billing/models/billing.subscription.schema.js +++ b/modules/billing/models/billing.subscription.schema.js @@ -65,9 +65,21 @@ const PortalRequest = z }) .strict(); +/** + * Extras checkout request body schema + */ +const ExtrasCheckoutRequest = z + .object({ + packId: z.string().trim().min(1, 'packId is required'), + successUrl: z.string().url('successUrl must be a valid URL'), + cancelUrl: z.string().url('cancelUrl must be a valid URL'), + }) + .strict(); + export default { Subscription, SubscriptionUpdate, CheckoutRequest, PortalRequest, + ExtrasCheckoutRequest, }; diff --git a/modules/billing/policies/billing.policy.js b/modules/billing/policies/billing.policy.js index f06680675..c5ac036ee 100644 --- a/modules/billing/policies/billing.policy.js +++ b/modules/billing/policies/billing.policy.js @@ -15,6 +15,10 @@ export function billingSubjectRegistration({ registerPathSubject }) { registerPathSubject('/api/billing/subscription', 'BillingSubscription'); registerPathSubject('/api/billing/usage', 'BillingUsage'); registerPathSubject('/api/billing/plans', 'BillingPlans'); + // Extras pack purchase + balance/ledger queries + registerPathSubject('/api/billing/extras/checkout', 'BillingExtrasCheckout'); + registerPathSubject('/api/billing/extras/balance', 'BillingExtrasBalance'); + registerPathSubject('/api/billing/extras/ledger', 'BillingExtrasLedger'); // Webhook is mounted in billing.preroute.js before body parsing and auth middleware, // so it bypasses policy.isAllowed entirely. Registered here for documentation completeness. registerPathSubject('/api/billing/webhook', 'BillingWebhook'); @@ -43,6 +47,10 @@ export function billingAbilities(user, membership, { can, cannot }) { can('create', 'BillingPortal'); can('read', 'BillingSubscription'); can('read', 'BillingUsage'); + // Extras: members can purchase packs and read their own balance/ledger + can('create', 'BillingExtrasCheckout'); + can('read', 'BillingExtrasBalance'); + can('read', 'BillingExtrasLedger'); } /** diff --git a/modules/billing/routes/billing.routes.js b/modules/billing/routes/billing.routes.js index d841f6c40..6869390b9 100644 --- a/modules/billing/routes/billing.routes.js +++ b/modules/billing/routes/billing.routes.js @@ -42,4 +42,22 @@ export default (app) => { .route('/api/billing/usage') .all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed) .get(billing.getUsage); + + // extras checkout (one-time pack purchase) + app + .route('/api/billing/extras/checkout') + .all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed) + .post(model.isValid(billingSchema.ExtrasCheckoutRequest), billing.extrasCheckout); + + // extras balance + app + .route('/api/billing/extras/balance') + .all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed) + .get(billing.extrasBalance); + + // extras ledger (paginated: ?page=1&limit=20) + app + .route('/api/billing/extras/ledger') + .all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed) + .get(billing.extrasLedger); }; diff --git a/modules/billing/services/billing.service.js b/modules/billing/services/billing.service.js index ae8518d0b..b18e11ee0 100644 --- a/modules/billing/services/billing.service.js +++ b/modules/billing/services/billing.service.js @@ -97,17 +97,21 @@ const createCheckout = async (organization, priceId, successUrl, cancelUrl) => { if (latest?.stripeCustomerId) subscription = latest; } - const session = await stripe.checkout.sessions.create({ - customer: subscription.stripeCustomerId, - mode: 'subscription', - line_items: [{ price: priceId, quantity: 1 }], - success_url: successUrl, - cancel_url: cancelUrl, - metadata: { - organizationId: String(organization._id), - plan: matchedPlan.planId, + const session = await stripe.checkout.sessions.create( + { + customer: subscription.stripeCustomerId, + mode: 'subscription', + line_items: [{ price: priceId, quantity: 1 }], + success_url: successUrl, + cancel_url: cancelUrl, + automatic_tax: { enabled: true }, + metadata: { + organizationId: String(organization._id), + plan: matchedPlan.planId, + }, }, - }); + { idempotencyKey: `sub_checkout_${String(organization._id)}_${priceId}` }, + ); return session.url; }; @@ -136,6 +140,108 @@ const createPortalSession = async (organization, returnUrl) => { return session.url; }; +/** + * @desc Create a Stripe Checkout Session for an extras pack purchase. + * Uses mode:'payment' (one-time) with automatic_tax and idempotency key. + * + * Note on stripeSessionId metadata: Stripe does not allow a session to + * self-reference its own id at creation time. The `stripeSessionId` field + * is therefore set to the literal string `'__pending__'` during creation. + * The webhook handler reads `event.data.object.id` (the actual session id) + * directly from the Stripe event — no post-creation patch is needed. + * + * @param {Object} organization - The organization document + * @param {String} packId - Pack identifier (must exist in config.billing.packs) + * @param {String} successUrl - URL to redirect on payment success + * @param {String} cancelUrl - URL to redirect on cancel + * @returns {Promise<{url: String}>} Object with the Checkout session URL + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik +const createExtrasCheckout = async (organization, packId, successUrl, cancelUrl) => { + const stripe = getStripe(); + if (!stripe) throw new Error('Stripe is not configured'); + + if (!isAllowedUrl(successUrl) || !isAllowedUrl(cancelUrl)) { + throw new Error('Invalid redirect URL: must be a valid URL (production requires HTTPS and a matching application domain)'); + } + + // Validate packId against known packs in config + const packs = config?.billing?.packs ?? []; + const pack = packs.find((p) => p.packId === packId); + if (!pack) throw new Error(`Invalid packId: pack not found: ${packId}`); + + // Resolve Stripe price ID for the pack + const priceId = config?.stripe?.prices?.packs?.[packId]; + if (!priceId) throw new Error(`Invalid packId: no Stripe price configured for pack: ${packId}`); + + // Find or create subscription record with Stripe customer + let subscription = await SubscriptionRepository.findByOrganization(organization._id); + + if (!subscription?.stripeCustomerId) { + const customer = await stripe.customers.create( + { + name: organization.name, + metadata: { organizationId: String(organization._id) }, + }, + { idempotencyKey: `cus_create_${String(organization._id)}` }, + ); + + if (subscription) { + subscription = await SubscriptionRepository.update({ + _id: subscription._id, + stripeCustomerId: customer.id, + }); + } else { + try { + subscription = await SubscriptionRepository.create({ + organization: organization._id, + stripeCustomerId: customer.id, + }); + } catch (err) { + if (err.code === 11000) { + subscription = await SubscriptionRepository.findByOrganization(organization._id); + } else { + throw err; + } + } + } + // Re-read to handle race: if another request already set stripeCustomerId, use that + const latest = await SubscriptionRepository.findByOrganization(organization._id); + if (latest?.stripeCustomerId) subscription = latest; + } + + // Use a timestamped idempotency key (debounce double-click within ~1s granularity) + const idempotencyKey = `extras_checkout_${String(organization._id)}_${packId}_${Date.now()}`; + + const session = await stripe.checkout.sessions.create( + { + customer: subscription.stripeCustomerId, + mode: 'payment', + line_items: [{ price: priceId, quantity: 1 }], + success_url: successUrl, + cancel_url: cancelUrl, + automatic_tax: { enabled: true }, + metadata: { + organizationId: String(organization._id), + packId, + kind: 'extras', + stripeSessionId: '__pending__', + }, + payment_intent_data: { + metadata: { + organizationId: String(organization._id), + packId, + kind: 'extras', + stripeSessionId: '__pending__', + }, + }, + }, + { idempotencyKey }, + ); + + return { url: session.url }; +}; + /** * @desc Get subscription for the given organization * @param {String} organizationId - The organization ID @@ -145,6 +251,7 @@ const getSubscription = async (organizationId) => SubscriptionRepository.findByO export default { createCheckout, + createExtrasCheckout, createPortalSession, getSubscription, }; diff --git a/modules/billing/tests/billing.attachUsageContext.unit.tests.js b/modules/billing/tests/billing.attachUsageContext.unit.tests.js new file mode 100644 index 000000000..8e71c84d6 --- /dev/null +++ b/modules/billing/tests/billing.attachUsageContext.unit.tests.js @@ -0,0 +1,165 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for billing.attachUsageContext middleware + */ +describe('billing.attachUsageContext middleware unit tests:', () => { + let attachUsageContext; + let mockBillingUsageService; + let mockBillingExtraBalanceRepository; + let mockConfig; + let req; + let res; + let next; + + const orgId = '507f1f77bcf86cd799439011'; + + beforeEach(async () => { + jest.resetModules(); + + mockBillingUsageService = { + getMeter: jest.fn(), + }; + + mockBillingExtraBalanceRepository = { + getBalance: jest.fn(), + }; + + mockConfig = { + billing: { + meterMode: true, + }, + }; + + jest.unstable_mockModule('../services/billing.usage.service.js', () => ({ + default: mockBillingUsageService, + })); + + jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({ + default: mockBillingExtraBalanceRepository, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: mockConfig, + })); + + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, + })); + + const mod = await import('../middlewares/billing.attachUsageContext.js'); + attachUsageContext = mod.default; + + req = { + organization: { _id: orgId }, + }; + + res = { + setHeader: jest.fn(), + }; + + next = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should decorate req.meterContext with meter data', async () => { + mockBillingUsageService.getMeter.mockResolvedValue({ + meterUsed: 1200, + meterQuota: 5000, + meterBreakdown: { scrape: 1000, llm: 200 }, + }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(2500); + + await attachUsageContext(req, res, next); + + expect(req.meterContext).toEqual({ + used: 1200, + quota: 5000, + extrasRemaining: 2500, + breakdown: { scrape: 1000, llm: 200 }, + }); + expect(next).toHaveBeenCalled(); + }); + + test('should set X-Meter-Remaining header', async () => { + mockBillingUsageService.getMeter.mockResolvedValue({ + meterUsed: 1000, + meterQuota: 5000, + meterBreakdown: {}, + }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(500); + + await attachUsageContext(req, res, next); + + // remaining = (5000 - 1000) + 500 = 4500 + expect(res.setHeader).toHaveBeenCalledWith('X-Meter-Remaining', '4500'); + expect(next).toHaveBeenCalled(); + }); + + test('should be a no-op when meterMode is false', async () => { + mockConfig.billing.meterMode = false; + + await attachUsageContext(req, res, next); + + expect(mockBillingUsageService.getMeter).not.toHaveBeenCalled(); + expect(mockBillingExtraBalanceRepository.getBalance).not.toHaveBeenCalled(); + expect(req.meterContext).toBeUndefined(); + expect(next).toHaveBeenCalled(); + }); + + test('should be a no-op when req.organization is missing', async () => { + req.organization = undefined; + + await attachUsageContext(req, res, next); + + expect(mockBillingUsageService.getMeter).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('should call next() even when getMeter throws (non-blocking failure)', async () => { + mockBillingUsageService.getMeter.mockRejectedValue(new Error('DB error')); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + await attachUsageContext(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.meterContext).toBeUndefined(); + }); + + test('should handle null meter doc gracefully (zeros)', async () => { + mockBillingUsageService.getMeter.mockResolvedValue(null); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(100); + + await attachUsageContext(req, res, next); + + expect(req.meterContext).toEqual({ + used: 0, + quota: 0, + extrasRemaining: 100, + breakdown: {}, + }); + // remaining = (0 - 0) + 100 = 100 + expect(res.setHeader).toHaveBeenCalledWith('X-Meter-Remaining', '100'); + expect(next).toHaveBeenCalled(); + }); + + test('should not throw 500 when getBalance also fails', async () => { + mockBillingUsageService.getMeter.mockRejectedValue(new Error('DB error on getMeter')); + mockBillingExtraBalanceRepository.getBalance.mockRejectedValue(new Error('DB error on getBalance')); + + await attachUsageContext(req, res, next); + + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/modules/billing/tests/billing.checkout.unit.tests.js b/modules/billing/tests/billing.checkout.unit.tests.js index bae9d814a..0c8352e02 100644 --- a/modules/billing/tests/billing.checkout.unit.tests.js +++ b/modules/billing/tests/billing.checkout.unit.tests.js @@ -227,17 +227,21 @@ describe('Billing service unit tests:', () => { expect(url).toBe('https://checkout.stripe.com/session_123'); expect(mockStripeInstance.customers.create).not.toHaveBeenCalled(); - expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalledWith({ - customer: 'cus_existing', - mode: 'subscription', - line_items: [{ price: 'price_starter_m', quantity: 1 }], - success_url: 'http://ok', - cancel_url: 'http://cancel', - metadata: { - organizationId: orgId, - plan: 'starter', + expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalledWith( + { + customer: 'cus_existing', + mode: 'subscription', + line_items: [{ price: 'price_starter_m', quantity: 1 }], + success_url: 'http://ok', + cancel_url: 'http://cancel', + automatic_tax: { enabled: true }, + metadata: { + organizationId: orgId, + plan: 'starter', + }, }, - }); + { idempotencyKey: `sub_checkout_${orgId}_price_starter_m` }, + ); }); }); diff --git a/modules/billing/tests/billing.extras.controller.unit.tests.js b/modules/billing/tests/billing.extras.controller.unit.tests.js new file mode 100644 index 000000000..0b7b86287 --- /dev/null +++ b/modules/billing/tests/billing.extras.controller.unit.tests.js @@ -0,0 +1,296 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for extras controller handlers: extrasCheckout, extrasBalance, extrasLedger, + * and the updated getUsage (meter mode). + */ +describe('Billing extras controller unit tests:', () => { + let BillingController; + let mockBillingService; + let mockBillingExtraService; + let mockBillingUsageService; + let mockBillingExtraBalanceRepository; + let mockConfig; + let req; + let res; + + const orgId = '507f1f77bcf86cd799439011'; + + /** + * @returns {Object} A mock res object with chainable methods. + */ + const makeRes = () => ({ + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + setHeader: jest.fn(), + }); + + beforeEach(async () => { + jest.resetModules(); + + mockBillingService = { + createExtrasCheckout: jest.fn(), + getSubscription: jest.fn(), + }; + + mockBillingExtraService = { + listLedger: jest.fn(), + }; + + mockBillingUsageService = { + getMeter: jest.fn(), + get: jest.fn(), + currentWeekKey: jest.fn().mockReturnValue('2026-W18'), + }; + + mockBillingExtraBalanceRepository = { + getBalance: jest.fn(), + }; + + mockConfig = { + billing: { + meterMode: false, + packs: [{ packId: 'pack_500k', meterUnits: 500000 }], + quotas: { + free: { scraps: { create: 3 } }, + }, + }, + }; + + jest.unstable_mockModule('../services/billing.service.js', () => ({ + default: mockBillingService, + })); + + jest.unstable_mockModule('../services/billing.extra.service.js', () => ({ + default: mockBillingExtraService, + })); + + jest.unstable_mockModule('../services/billing.usage.service.js', () => ({ + default: mockBillingUsageService, + })); + + jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({ + default: mockBillingExtraBalanceRepository, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: mockConfig, + })); + + jest.unstable_mockModule('../lib/constants.js', () => ({ + activeStatuses: ['active', 'trialing'], + })); + + const mod = await import('../controllers/billing.controller.js'); + BillingController = mod.default; + + req = { + organization: { _id: orgId }, + body: {}, + query: {}, + }; + + res = makeRes(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── extrasCheckout ───────────────────────────────────────────────────────── + + describe('extrasCheckout', () => { + test('should return 200 with url on success', async () => { + req.body = { packId: 'pack_500k', successUrl: 'http://ok', cancelUrl: 'http://cancel' }; + mockBillingService.createExtrasCheckout.mockResolvedValue({ url: 'https://checkout.stripe.com/abc' }); + + await BillingController.extrasCheckout(req, res); + + expect(mockBillingService.createExtrasCheckout).toHaveBeenCalledWith( + req.organization, 'pack_500k', 'http://ok', 'http://cancel', + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + data: { url: 'https://checkout.stripe.com/abc' }, + })); + }); + + test('should return 422 when packId is invalid', async () => { + req.body = { packId: 'pack_unknown', successUrl: 'http://ok', cancelUrl: 'http://cancel' }; + mockBillingService.createExtrasCheckout.mockRejectedValue(new Error('Invalid packId: pack not found: pack_unknown')); + + await BillingController.extrasCheckout(req, res); + + expect(res.status).toHaveBeenCalledWith(422); + }); + + test('should return 502 when Stripe call fails with generic error', async () => { + req.body = { packId: 'pack_500k', successUrl: 'http://ok', cancelUrl: 'http://cancel' }; + mockBillingService.createExtrasCheckout.mockRejectedValue(new Error('Network error')); + + await BillingController.extrasCheckout(req, res); + + expect(res.status).toHaveBeenCalledWith(502); + }); + + test('should return 422 when Stripe is not configured', async () => { + req.body = { packId: 'pack_500k', successUrl: 'http://ok', cancelUrl: 'http://cancel' }; + mockBillingService.createExtrasCheckout.mockRejectedValue(new Error('Invalid redirect URL')); + + await BillingController.extrasCheckout(req, res); + + expect(res.status).toHaveBeenCalledWith(422); + }); + }); + + // ── extrasBalance ────────────────────────────────────────────────────────── + + describe('extrasBalance', () => { + test('should return 200 with balance and packsAvailable', async () => { + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(150000); + + await BillingController.extrasBalance(req, res); + + expect(mockBillingExtraBalanceRepository.getBalance).toHaveBeenCalledWith(orgId); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + data: { + balance: 150000, + packsAvailable: mockConfig.billing.packs, + }, + })); + }); + + test('should return 500 when repository throws', async () => { + mockBillingExtraBalanceRepository.getBalance.mockRejectedValue(new Error('DB error')); + + await BillingController.extrasBalance(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + }); + }); + + // ── extrasLedger ─────────────────────────────────────────────────────────── + + describe('extrasLedger', () => { + test('should return 200 with paginated ledger', async () => { + req.query = { page: '1', limit: '10' }; + const ledgerResult = { entries: [{ kind: 'topup', amount: 500000 }], total: 1, balance: 500000 }; + mockBillingExtraService.listLedger.mockResolvedValue(ledgerResult); + + await BillingController.extrasLedger(req, res); + + expect(mockBillingExtraService.listLedger).toHaveBeenCalledWith(orgId, { page: 1, limit: 10 }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + data: ledgerResult, + })); + }); + + test('should use default page=1, limit=20 when query params are absent', async () => { + req.query = {}; + mockBillingExtraService.listLedger.mockResolvedValue({ entries: [], total: 0, balance: 0 }); + + await BillingController.extrasLedger(req, res); + + expect(mockBillingExtraService.listLedger).toHaveBeenCalledWith(orgId, { page: 1, limit: 20 }); + }); + + test('should cap limit to 100 max', async () => { + req.query = { page: '1', limit: '999' }; + mockBillingExtraService.listLedger.mockResolvedValue({ entries: [], total: 0, balance: 0 }); + + await BillingController.extrasLedger(req, res); + + expect(mockBillingExtraService.listLedger).toHaveBeenCalledWith(orgId, { page: 1, limit: 100 }); + }); + + test('should return 500 when service throws', async () => { + mockBillingExtraService.listLedger.mockRejectedValue(new Error('DB error')); + + await BillingController.extrasLedger(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + }); + }); + + // ── getUsage — meter mode ────────────────────────────────────────────────── + + describe('getUsage (meter mode)', () => { + test('should return meter fields when meterMode=true', async () => { + mockConfig.billing.meterMode = true; + mockBillingService.getSubscription.mockResolvedValue({ plan: 'pro', status: 'active' }); + mockBillingUsageService.getMeter.mockResolvedValue({ + weekKey: '2026-W18', + planVersion: 'v2', + resetAt: new Date('2026-05-04'), + meterUsed: 1200, + meterQuota: 5000, + meterBreakdown: { scrape: 1000, llm: 200 }, + }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(2500); + + await BillingController.getUsage(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + plan: 'pro', + planVersion: 'v2', + weekKey: '2026-W18', + meterUsed: 1200, + meterQuota: 5000, + meterBreakdown: { scrape: 1000, llm: 200 }, + extrasRemaining: 2500, + packsAvailable: mockConfig.billing.packs, + }), + })); + }); + + test('should return zeroed meter fields when meter doc is null', async () => { + mockConfig.billing.meterMode = true; + mockBillingService.getSubscription.mockResolvedValue({ plan: 'free', status: 'active' }); + mockBillingUsageService.getMeter.mockResolvedValue(null); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + await BillingController.getUsage(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + meterUsed: 0, + meterQuota: 0, + extrasRemaining: 0, + weekKey: '2026-W18', + }), + })); + }); + + test('should return legacy usage shape when meterMode=false', async () => { + mockConfig.billing.meterMode = false; + mockBillingService.getSubscription.mockResolvedValue({ plan: 'free', status: 'active' }); + mockBillingUsageService.get.mockResolvedValue({ month: '2026-04', counters: { scraps_create: 1 } }); + + await BillingController.getUsage(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + plan: 'free', + period: '2026-04', + usage: { scraps_create: 1 }, + }), + })); + // meter fields should NOT be present in legacy mode + const data = res.json.mock.calls[0][0].data; + expect(data.meterUsed).toBeUndefined(); + expect(data.meterQuota).toBeUndefined(); + }); + }); +}); diff --git a/modules/billing/tests/billing.quota.unit.tests.js b/modules/billing/tests/billing.quota.unit.tests.js index 9f8dde539..4425f512c 100644 --- a/modules/billing/tests/billing.quota.unit.tests.js +++ b/modules/billing/tests/billing.quota.unit.tests.js @@ -194,4 +194,122 @@ describe('requireQuota middleware:', () => { expect(next).toHaveBeenCalled(); }); + + // ── Meter mode (meterMode: true) ─────────────────────────────────────────── + + describe('meter mode (meterMode: true)', () => { + let mockBillingExtraBalanceRepository; + + beforeEach(async () => { + jest.resetModules(); + + mockConfig = { + billing: { + meterMode: true, + quotas: {}, + packs: [{ packId: 'pack_500k', meterUnits: 500000 }], + upgradeUrl: '/billing/plans', + }, + }; + + mockBillingUsageService = { + get: jest.fn(), + getMeter: jest.fn(), + }; + + mockBillingExtraBalanceRepository = { + getBalance: jest.fn(), + }; + + jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({ + default: mockSubscriptionRepository, + })); + + jest.unstable_mockModule('../services/billing.usage.service.js', () => ({ + default: mockBillingUsageService, + })); + + jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({ + default: mockBillingExtraBalanceRepository, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: mockConfig, + })); + + const mod = await import('../middlewares/billing.requireQuota.js'); + requireQuota = mod.default; + }); + + test('should call next() when remaining quota > 0 (meter not exhausted)', async () => { + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 1000, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + await requireQuota('scraps', 'create')(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('should call next() when plan quota exhausted but extras balance covers it', async () => { + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 5000, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(500000); + + await requireQuota('scraps', 'create')(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should return 402 when meterUsed >= meterQuota and extras balance is 0', async () => { + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 5000, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + await requireQuota('scraps', 'create')(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(402); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + code: 402, + })); + }); + + test('should include METER_EXHAUSTED payload with pack info in 402 response', async () => { + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 6000, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + await requireQuota('scraps', 'create')(req, res, next); + + expect(res.status).toHaveBeenCalledWith(402); + const payload = res.json.mock.calls[0][0]; + expect(payload.description).toBe('Meter exhausted'); + // The error object contains the metadata + const errData = JSON.parse(payload.error); + expect(errData.type).toBe('METER_EXHAUSTED'); + expect(errData.meterUsed).toBe(6000); + expect(errData.meterQuota).toBe(5000); + expect(errData.extrasRemaining).toBe(0); + expect(Array.isArray(errData.packsAvailable)).toBe(true); + }); + + test('should return 402 when meter doc is null (no usage yet) and no extras', async () => { + mockBillingUsageService.getMeter.mockResolvedValue(null); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + await requireQuota('scraps', 'create')(req, res, next); + + // meterUsed=0, meterQuota=0 → remaining = (0-0)+0 = 0 → 402 + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(402); + }); + + test('should not call SubscriptionRepository in meter mode', async () => { + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + await requireQuota('scraps', 'create')(req, res, next); + + expect(mockSubscriptionRepository.findByOrganization).not.toHaveBeenCalled(); + }); + }); }); diff --git a/modules/billing/tests/billing.routes.integration.tests.js b/modules/billing/tests/billing.routes.integration.tests.js new file mode 100644 index 000000000..49d48afc8 --- /dev/null +++ b/modules/billing/tests/billing.routes.integration.tests.js @@ -0,0 +1,193 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Integration-style smoke tests for extras billing routes. + * Tests controller + route layer wiring: auth guard, policy check, validator, handler. + * Uses mocked services — no real HTTP server or DB. + */ +describe('Billing extras routes integration tests:', () => { + let BillingController; + let mockBillingService; + let mockBillingExtraService; + let mockBillingUsageService; + let mockBillingExtraBalanceRepository; + let req; + let res; + + const orgId = '507f1f77bcf86cd799439011'; + + /** + * @returns {Object} A mock res object with chainable methods. + */ + const makeRes = () => ({ + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }); + + beforeEach(async () => { + jest.resetModules(); + + mockBillingService = { + createExtrasCheckout: jest.fn().mockResolvedValue({ url: 'https://checkout.stripe.com/abc' }), + getSubscription: jest.fn().mockResolvedValue({ plan: 'pro', status: 'active' }), + }; + + mockBillingExtraService = { + listLedger: jest.fn().mockResolvedValue({ entries: [], total: 0, balance: 0 }), + }; + + mockBillingUsageService = { + getMeter: jest.fn().mockResolvedValue(null), + get: jest.fn().mockResolvedValue({ month: '2026-04', counters: {} }), + currentWeekKey: jest.fn().mockReturnValue('2026-W18'), + }; + + mockBillingExtraBalanceRepository = { + getBalance: jest.fn().mockResolvedValue(0), + }; + + jest.unstable_mockModule('../services/billing.service.js', () => ({ + default: mockBillingService, + })); + + jest.unstable_mockModule('../services/billing.extra.service.js', () => ({ + default: mockBillingExtraService, + })); + + jest.unstable_mockModule('../services/billing.usage.service.js', () => ({ + default: mockBillingUsageService, + })); + + jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({ + default: mockBillingExtraBalanceRepository, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { + billing: { + meterMode: false, + packs: [{ packId: 'pack_500k', meterUnits: 500000 }], + quotas: { free: {} }, + }, + }, + })); + + jest.unstable_mockModule('../lib/constants.js', () => ({ + activeStatuses: ['active', 'trialing'], + })); + + const mod = await import('../controllers/billing.controller.js'); + BillingController = mod.default; + + req = { + organization: { _id: orgId }, + body: {}, + query: {}, + }; + + res = makeRes(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── POST /api/billing/extras/checkout ───────────────────────────────────── + + describe('POST /api/billing/extras/checkout', () => { + test('returns 200 and url for authenticated org member', async () => { + req.body = { packId: 'pack_500k', successUrl: 'http://ok', cancelUrl: 'http://cancel' }; + + await BillingController.extrasCheckout(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + data: { url: 'https://checkout.stripe.com/abc' }, + })); + }); + + test('returns 422 when service throws Invalid packId', async () => { + req.body = { packId: 'bad', successUrl: 'http://ok', cancelUrl: 'http://cancel' }; + mockBillingService.createExtrasCheckout.mockRejectedValue(new Error('Invalid packId: pack not found: bad')); + + await BillingController.extrasCheckout(req, res); + + expect(res.status).toHaveBeenCalledWith(422); + }); + + test('returns 502 when Stripe throws a generic error', async () => { + req.body = { packId: 'pack_500k', successUrl: 'http://ok', cancelUrl: 'http://cancel' }; + mockBillingService.createExtrasCheckout.mockRejectedValue(new Error('Stripe network error')); + + await BillingController.extrasCheckout(req, res); + + expect(res.status).toHaveBeenCalledWith(502); + }); + }); + + // ── GET /api/billing/extras/balance ─────────────────────────────────────── + + describe('GET /api/billing/extras/balance', () => { + test('returns 200 with balance=0 when no extras purchased', async () => { + await BillingController.extrasBalance(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + data: { balance: 0, packsAvailable: expect.any(Array) }, + })); + }); + + test('returns 200 with non-zero balance after pack credit', async () => { + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(500000); + + await BillingController.extrasBalance(req, res); + + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ balance: 500000 }), + })); + }); + + test('returns 500 when repository throws', async () => { + mockBillingExtraBalanceRepository.getBalance.mockRejectedValue(new Error('DB down')); + + await BillingController.extrasBalance(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + }); + }); + + // ── GET /api/billing/extras/ledger ──────────────────────────────────────── + + describe('GET /api/billing/extras/ledger', () => { + test('returns 200 with empty ledger by default', async () => { + await BillingController.extrasLedger(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + data: { entries: [], total: 0, balance: 0 }, + })); + }); + + test('passes page and limit query params to service', async () => { + req.query = { page: '2', limit: '5' }; + + await BillingController.extrasLedger(req, res); + + expect(mockBillingExtraService.listLedger).toHaveBeenCalledWith(orgId, { page: 2, limit: 5 }); + }); + + test('returns 500 when service throws', async () => { + mockBillingExtraService.listLedger.mockRejectedValue(new Error('DB down')); + + await BillingController.extrasLedger(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + }); + }); +}); diff --git a/modules/billing/tests/billing.service.extras.unit.tests.js b/modules/billing/tests/billing.service.extras.unit.tests.js new file mode 100644 index 000000000..bfa834cae --- /dev/null +++ b/modules/billing/tests/billing.service.extras.unit.tests.js @@ -0,0 +1,263 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for BillingService.createExtrasCheckout + */ +describe('BillingService.createExtrasCheckout unit tests:', () => { + let BillingService; + let mockStripeInstance; + let mockSubscriptionRepository; + + const orgId = '507f1f77bcf86cd799439011'; + const mockOrganization = { _id: orgId, name: 'Test Org' }; + + /** + * @param {Object} [stripe={}] - stripe config overrides. + * @returns {Object} A billing config stub with packs and stripe prices. + */ + const makeConfig = (stripe = {}) => ({ + billing: { + plans: ['free', 'starter', 'pro'], + packs: [ + { packId: 'pack_500k', meterUnits: 500000, priceUsd: 49 }, + { packId: 'pack_2m', meterUnits: 2000000, priceUsd: 149 }, + ], + }, + stripe: { + secretKey: 'sk_test_abc', + prices: { + packs: { + pack_500k: 'price_pack500k', + pack_2m: 'price_pack2m', + }, + ...stripe, + }, + }, + }); + + beforeEach(async () => { + jest.resetModules(); + + mockStripeInstance = { + customers: { + create: jest.fn().mockResolvedValue({ id: 'cus_new123' }), + }, + checkout: { + sessions: { + create: jest.fn().mockResolvedValue({ url: 'https://checkout.stripe.com/session_extras' }), + }, + }, + }; + + jest.unstable_mockModule('stripe', () => ({ + default: jest.fn(() => mockStripeInstance), + })); + + mockSubscriptionRepository = { + findByOrganization: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }; + + jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({ + default: mockSubscriptionRepository, + })); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should throw when Stripe is not configured', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { stripe: {} }, + })); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + await expect( + BillingService.createExtrasCheckout(mockOrganization, 'pack_500k', 'http://ok', 'http://cancel'), + ).rejects.toThrow('Stripe is not configured'); + }); + + test('should throw when packId not found in config', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: makeConfig(), + })); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ stripeCustomerId: 'cus_existing' }); + + await expect( + BillingService.createExtrasCheckout(mockOrganization, 'pack_unknown', 'http://ok', 'http://cancel'), + ).rejects.toThrow('Invalid packId: pack not found: pack_unknown'); + }); + + test('should throw when no Stripe price configured for packId', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { + billing: { + plans: ['free'], + packs: [{ packId: 'pack_500k', meterUnits: 500000 }], + }, + stripe: { secretKey: 'sk_test_abc', prices: { packs: {} } }, + }, + })); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ stripeCustomerId: 'cus_existing' }); + + await expect( + BillingService.createExtrasCheckout(mockOrganization, 'pack_500k', 'http://ok', 'http://cancel'), + ).rejects.toThrow('Invalid packId: no Stripe price configured for pack: pack_500k'); + }); + + test('should throw on invalid successUrl', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: makeConfig(), + })); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ stripeCustomerId: 'cus_existing' }); + + await expect( + BillingService.createExtrasCheckout(mockOrganization, 'pack_500k', 'not-a-url', 'http://cancel'), + ).rejects.toThrow('Invalid redirect URL'); + }); + + test('should call Stripe with mode=payment and correct args when subscription exists', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: makeConfig(), + })); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ stripeCustomerId: 'cus_existing' }); + + const result = await BillingService.createExtrasCheckout( + mockOrganization, 'pack_500k', 'http://success', 'http://cancel', + ); + + expect(result).toEqual({ url: 'https://checkout.stripe.com/session_extras' }); + expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalledWith( + expect.objectContaining({ + customer: 'cus_existing', + mode: 'payment', + line_items: [{ price: 'price_pack500k', quantity: 1 }], + automatic_tax: { enabled: true }, + metadata: expect.objectContaining({ + organizationId: orgId, + packId: 'pack_500k', + kind: 'extras', + }), + payment_intent_data: { + metadata: expect.objectContaining({ + organizationId: orgId, + packId: 'pack_500k', + kind: 'extras', + }), + }, + }), + expect.objectContaining({ idempotencyKey: expect.stringContaining('extras_checkout_') }), + ); + }); + + test('should include idempotencyKey in Stripe call', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: makeConfig(), + })); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ stripeCustomerId: 'cus_existing' }); + + await BillingService.createExtrasCheckout( + mockOrganization, 'pack_2m', 'http://success', 'http://cancel', + ); + + const [, options] = mockStripeInstance.checkout.sessions.create.mock.calls[0]; + expect(options.idempotencyKey).toMatch(/^extras_checkout_.*pack_2m_\d+$/); + }); + + test('should set stripeSessionId to __pending__ in metadata', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: makeConfig(), + })); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ stripeCustomerId: 'cus_existing' }); + + await BillingService.createExtrasCheckout( + mockOrganization, 'pack_500k', 'http://success', 'http://cancel', + ); + + const [params] = mockStripeInstance.checkout.sessions.create.mock.calls[0]; + expect(params.metadata.stripeSessionId).toBe('__pending__'); + expect(params.payment_intent_data.metadata.stripeSessionId).toBe('__pending__'); + }); + + test('should create Stripe customer when subscription has no stripeCustomerId', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: makeConfig(), + })); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + mockSubscriptionRepository.findByOrganization + .mockResolvedValueOnce(null) // initial lookup + .mockResolvedValueOnce({ stripeCustomerId: 'cus_new123' }); // re-read after create + + mockSubscriptionRepository.create.mockResolvedValue({ organization: orgId, stripeCustomerId: 'cus_new123' }); + + await BillingService.createExtrasCheckout( + mockOrganization, 'pack_500k', 'http://success', 'http://cancel', + ); + + expect(mockStripeInstance.customers.create).toHaveBeenCalledWith( + { name: 'Test Org', metadata: { organizationId: orgId } }, + { idempotencyKey: `cus_create_${orgId}` }, + ); + expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalled(); + }); + + test('should handle 11000 duplicate key on subscription create gracefully', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: makeConfig(), + })); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + mockSubscriptionRepository.findByOrganization + .mockResolvedValueOnce(null) // initial lookup — no subscription + .mockResolvedValueOnce({ stripeCustomerId: 'cus_race' }); // re-read after dup error + + const dupErr = new Error('duplicate key'); + dupErr.code = 11000; + mockSubscriptionRepository.create.mockRejectedValue(dupErr); + + await BillingService.createExtrasCheckout( + mockOrganization, 'pack_500k', 'http://success', 'http://cancel', + ); + + expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalledWith( + expect.objectContaining({ customer: 'cus_race' }), + expect.any(Object), + ); + }); +}); diff --git a/modules/billing/tests/billing.unit.tests.js b/modules/billing/tests/billing.unit.tests.js index 8c613e6b1..c468436e0 100644 --- a/modules/billing/tests/billing.unit.tests.js +++ b/modules/billing/tests/billing.unit.tests.js @@ -244,4 +244,80 @@ describe('Billing unit tests:', () => { expect(result.data.planVersion).toBe('v3'); }); }); + + describe('ExtrasCheckoutRequest schema', () => { + let request; + + beforeEach(() => { + request = { + packId: 'pack_500k', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + }; + }); + + test('should be valid with all required fields', () => { + const result = schema.ExtrasCheckoutRequest.safeParse(request); + expect(result.error).toBeFalsy(); + expect(result.data.packId).toBe('pack_500k'); + expect(result.data.successUrl).toBe('https://example.com/success'); + expect(result.data.cancelUrl).toBe('https://example.com/cancel'); + }); + + test('should reject when packId is empty', () => { + request.packId = ''; + const result = schema.ExtrasCheckoutRequest.safeParse(request); + expect(result.error).toBeDefined(); + }); + + test('should reject when packId is missing', () => { + delete request.packId; + const result = schema.ExtrasCheckoutRequest.safeParse(request); + expect(result.error).toBeDefined(); + }); + + test('should reject when successUrl is not a valid URL', () => { + request.successUrl = 'not-a-url'; + const result = schema.ExtrasCheckoutRequest.safeParse(request); + expect(result.error).toBeDefined(); + }); + + test('should reject when cancelUrl is not a valid URL', () => { + request.cancelUrl = 'not-a-url'; + const result = schema.ExtrasCheckoutRequest.safeParse(request); + expect(result.error).toBeDefined(); + }); + + test('should trim whitespace from packId', () => { + request.packId = ' pack_500k '; + const result = schema.ExtrasCheckoutRequest.safeParse(request); + expect(result.error).toBeFalsy(); + expect(result.data.packId).toBe('pack_500k'); + }); + + test('should reject unknown fields (strict)', () => { + request.extraField = 'should not be here'; + const result = schema.ExtrasCheckoutRequest.safeParse(request); + expect(result.error).toBeDefined(); + }); + + test('should accept http:// URLs (for development environments)', () => { + request.successUrl = 'http://localhost:3000/success'; + request.cancelUrl = 'http://localhost:3000/cancel'; + const result = schema.ExtrasCheckoutRequest.safeParse(request); + expect(result.error).toBeFalsy(); + }); + + test('should reject when successUrl is missing', () => { + delete request.successUrl; + const result = schema.ExtrasCheckoutRequest.safeParse(request); + expect(result.error).toBeDefined(); + }); + + test('should reject when cancelUrl is missing', () => { + delete request.cancelUrl; + const result = schema.ExtrasCheckoutRequest.safeParse(request); + expect(result.error).toBeDefined(); + }); + }); });