From 00ea2d449de462d085d1e62a01db1c8bca146c2a Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Wed, 29 Apr 2026 22:43:53 +0200 Subject: [PATCH 1/3] feat(billing): crons + dunning grace 7d (PR-N5) Add 3 standalone k8s CronJob scripts (weeklyReset, extrasExpiration, dunningSweep), 7-day grace period in requireQuota middleware for past_due subs, pastDueSince stamping on invoice.payment_failed (idempotent), payment.failed event, and new repo methods (findStaleDunning, markUnpaid, findOrgsWithExpiringTopups). All paths gate on meterMode. --- modules/billing/lib/events.js | 4 +- .../middlewares/billing.requireQuota.js | 24 ++- .../billing.extraBalance.repository.js | 41 +++++ .../billing.subscription.repository.js | 38 ++++ .../services/billing.webhook.service.js | 22 ++- ...e.findOrgsWithExpiringTopups.unit.tests.js | 134 ++++++++++++++ .../billing/tests/billing.quota.unit.tests.js | 94 +++++++++- .../tests/billing.service.unit.tests.js | 11 +- ...ling.subscription.repository.unit.tests.js | 170 ++++++++++++++++++ ...billing.webhook.subscription.unit.tests.js | 59 +++++- scripts/crons/README.md | 55 ++++++ scripts/crons/billing.dunningSweep.js | 71 ++++++++ scripts/crons/billing.extrasExpiration.js | 60 +++++++ scripts/crons/billing.weeklyReset.js | 39 ++++ .../billing.cron.dunningSweep.unit.tests.js | 168 +++++++++++++++++ ...illing.cron.extrasExpiration.unit.tests.js | 131 ++++++++++++++ .../billing.cron.weeklyReset.unit.tests.js | 123 +++++++++++++ 17 files changed, 1227 insertions(+), 17 deletions(-) create mode 100644 modules/billing/tests/billing.extraBalance.findOrgsWithExpiringTopups.unit.tests.js create mode 100644 modules/billing/tests/billing.subscription.repository.unit.tests.js create mode 100644 scripts/crons/README.md create mode 100644 scripts/crons/billing.dunningSweep.js create mode 100644 scripts/crons/billing.extrasExpiration.js create mode 100644 scripts/crons/billing.weeklyReset.js create mode 100644 scripts/tests/billing.cron.dunningSweep.unit.tests.js create mode 100644 scripts/tests/billing.cron.extrasExpiration.unit.tests.js create mode 100644 scripts/tests/billing.cron.weeklyReset.unit.tests.js diff --git a/modules/billing/lib/events.js b/modules/billing/lib/events.js index 2088a6ad9..c88a218df 100644 --- a/modules/billing/lib/events.js +++ b/modules/billing/lib/events.js @@ -7,8 +7,10 @@ import { EventEmitter } from 'events'; * Singleton event emitter for billing events. * * Events: - * - `plan.changed` — emitted when a subscription's plan changes + * - `plan.changed` — emitted when a subscription's plan changes * Payload: { organizationId, previousPlan, newPlan, subscription, isDowngrade } + * - `payment.failed` — emitted when an invoice payment fails (pastDueSince set on first failure) + * Payload: { organizationId } */ const billingEvents = new EventEmitter(); diff --git a/modules/billing/middlewares/billing.requireQuota.js b/modules/billing/middlewares/billing.requireQuota.js index 16c35ab51..06fbd9c36 100644 --- a/modules/billing/middlewares/billing.requireQuota.js +++ b/modules/billing/middlewares/billing.requireQuota.js @@ -19,8 +19,11 @@ import responses from '../../../lib/helpers/responses.js'; * 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. + * First checks for past_due degraded mode: + * - past_due + pastDueSince set + within 7-day grace: sets res.locals.billingDegraded = true + * and falls through to the meter check (may still block on exhaustion). + * - past_due + pastDueSince set + grace elapsed (>=7d): returns 402 PAYMENT_PAST_DUE. + * Then computes `(meterQuota - meterUsed) + extrasBalance`. Returns 402 METER_EXHAUSTED when <= 0. * * Expects `req.organization` to be set by resolveOrganization upstream. * @@ -45,6 +48,23 @@ function requireQuota(resource, action) { // ── Meter mode (meterMode: true) ────────────────────────────────────── if (config.billing?.meterMode === true) { const orgId = req.organization._id.toString(); + + // ── Degraded-mode gate (past_due grace period) ───────────────────── + const subscription = await SubscriptionRepository.findByOrganization(req.organization._id); + if (subscription?.status === 'past_due' && subscription.pastDueSince != null) { + const gracePeriodMs = 7 * 24 * 60 * 60 * 1000; + const elapsed = Date.now() - new Date(subscription.pastDueSince).getTime(); + if (elapsed >= gracePeriodMs) { + return responses.error(res, 402, 'Payment Required', 'Subscription past due, please update payment')({ + type: 'PAYMENT_PAST_DUE', + message: 'Subscription past due, please update payment', + subscriptionStatus: 'past_due', + }); + } + // Within grace period — mark degraded for downstream awareness but allow through + res.locals.billingDegraded = true; + } + const usage = await BillingUsageService.getMeter(orgId); const extrasBalance = await BillingExtraBalanceRepository.getBalance(orgId); diff --git a/modules/billing/repositories/billing.extraBalance.repository.js b/modules/billing/repositories/billing.extraBalance.repository.js index 111f90100..7caedff3d 100644 --- a/modules/billing/repositories/billing.extraBalance.repository.js +++ b/modules/billing/repositories/billing.extraBalance.repository.js @@ -243,6 +243,46 @@ const getBalance = async (orgId) => { return doc ? doc.cachedBalance : 0; }; +/** + * @function findOrgsWithExpiringTopups + * @description Return the distinct organizationIds that have at least one topup ledger entry + * with `expiresAt < now` for which no matching expiration entry (`kind: 'expiration'` + * with `refId: 'expire-'`) has been recorded yet. + * Used by the billing.extrasExpiration cron to build the sweep target list. + * @param {Date} now - Cutoff timestamp. Topups with expiresAt strictly before this are candidates. + * @returns {Promise} Array of distinct organizationId strings. + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik +const findOrgsWithExpiringTopups = async (now) => { + // Pull only the ledger field (projection) to keep the payload small. + const docs = await BillingExtraBalance() + .find( + { + 'ledger.kind': 'topup', + 'ledger.expiresAt': { $lt: now }, + }, + { organization: 1, ledger: 1 }, + ) + .lean(); + + const orgIds = []; + for (const doc of docs) { + const existingExpireRefs = new Set( + (doc.ledger ?? []).filter((e) => e.kind === 'expiration').map((e) => e.refId), + ); + const hasUnhandled = (doc.ledger ?? []).some( + (e) => + e.kind === 'topup' && + e.expiresAt && + new Date(e.expiresAt) < now && + !existingExpireRefs.has(`expire-${e._id}`), + ); + if (hasUnhandled) orgIds.push(String(doc.organization)); + } + + return orgIds; +}; + export default { getOrCreate, creditPack, @@ -250,4 +290,5 @@ export default { addExpirationEntries, refundPartial, getBalance, + findOrgsWithExpiringTopups, }; diff --git a/modules/billing/repositories/billing.subscription.repository.js b/modules/billing/repositories/billing.subscription.repository.js index 1d176318e..e726dd212 100644 --- a/modules/billing/repositories/billing.subscription.repository.js +++ b/modules/billing/repositories/billing.subscription.repository.js @@ -128,6 +128,42 @@ const findAllDueForReset = (from, to) => { organization: 1, currentPeriodStart: 1 }, ).lean(); +/** + * @function findStaleDunning + * @description Fetch subscriptions with status 'past_due' whose pastDueSince is set + * and falls on or before the given threshold date. + * Used by the dunning sweep cron to transition stale past_due subs to 'unpaid'. + * Returns lean plain objects for performance. + * @param {Date} threshold - Subscriptions with pastDueSince <= threshold are returned. + * @returns {Promise>} + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik +const findStaleDunning = (threshold) => + Subscription.find( + { + status: 'past_due', + pastDueSince: { $ne: null, $lte: threshold }, + }, + { _id: 1, organization: 1 }, + ).lean(); + +/** + * @function markUnpaid + * @description Atomically transition a subscription to 'unpaid' and downgrade plan to 'free'. + * Idempotent: if the subscription is already unpaid the operation is effectively a no-op. + * @param {string} id - The subscription ObjectId (string). + * @returns {Promise} The updated subscription document or null if id is invalid. + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik +const markUnpaid = (id) => { + if (!id || !mongoose.Types.ObjectId.isValid(id)) return null; + return Subscription.findByIdAndUpdate( + id, + { $set: { status: 'unpaid', plan: 'free' } }, + { returnDocument: 'after', runValidators: true }, + ).exec(); +}; + export default { list, create, @@ -138,4 +174,6 @@ export default { findByStripeCustomerId, findByStripeSubscriptionId, findAllDueForReset, + findStaleDunning, + markUnpaid, }; diff --git a/modules/billing/services/billing.webhook.service.js b/modules/billing/services/billing.webhook.service.js index 82280d611..91b666968 100644 --- a/modules/billing/services/billing.webhook.service.js +++ b/modules/billing/services/billing.webhook.service.js @@ -251,7 +251,10 @@ const handleSubscriptionDeleted = async (subscription) => { }; /** - * @description Handle invoice.payment_failed event — mark subscription as past_due + * @description Handle invoice.payment_failed event — mark subscription as past_due. + * Sets pastDueSince = now only when not already set (idempotent: multiple + * failed invoices do not reset the grace-period clock). + * Emits 'payment.failed' so downstream listeners can react (e.g. notifications). * @param {Object} invoice - Stripe invoice object * @returns {Promise} */ @@ -263,10 +266,19 @@ const handleInvoicePaymentFailed = async (invoice) => { const existing = await SubscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId); if (!existing) return; - await SubscriptionRepository.update({ - _id: existing._id, - status: 'past_due', - }); + const updatePayload = { _id: existing._id, status: 'past_due' }; + + // Only set pastDueSince on first failure — do not reset the grace-period clock on retries. + if (existing.pastDueSince == null) { + updatePayload.pastDueSince = new Date(); + } + + await SubscriptionRepository.update(updatePayload); + + const organizationId = String(existing.organization?._id || existing.organization); + try { + billingEvents.emit('payment.failed', { organizationId }); + } catch { /* listener errors must not disrupt webhook processing */ } }; /** diff --git a/modules/billing/tests/billing.extraBalance.findOrgsWithExpiringTopups.unit.tests.js b/modules/billing/tests/billing.extraBalance.findOrgsWithExpiringTopups.unit.tests.js new file mode 100644 index 000000000..49723f1d6 --- /dev/null +++ b/modules/billing/tests/billing.extraBalance.findOrgsWithExpiringTopups.unit.tests.js @@ -0,0 +1,134 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for BillingExtraBalanceRepository.findOrgsWithExpiringTopups (PR-N5) + */ +describe('BillingExtraBalanceRepository.findOrgsWithExpiringTopups:', () => { + let BillingExtraBalanceRepository; + let mockModel; + + const orgId1 = '507f1f77bcf86cd799439011'; + const orgId2 = '507f1f77bcf86cd799439022'; + + /** + * @param {string} topupId - Fake ObjectId for the topup entry. + * @param {Date} expiresAt - Expiry date for the topup. + * @param {boolean} [withExpiration=false] - Whether to include a matching expiration entry. + * @returns {Array} Ledger array. + */ + const makeLedger = (topupId, expiresAt, withExpiration = false) => { + const ledger = [ + { _id: topupId, kind: 'topup', amount: 1000, expiresAt }, + ]; + if (withExpiration) { + ledger.push({ kind: 'expiration', amount: -1000, refId: `expire-${topupId}` }); + } + return ledger; + }; + + beforeEach(async () => { + jest.resetModules(); + + mockModel = { + find: jest.fn(), + }; + + jest.unstable_mockModule('mongoose', () => ({ + default: { + Types: { ObjectId: { isValid: (id) => /^[a-f\d]{24}$/i.test(id) } }, + model: jest.fn(() => mockModel), + }, + })); + + const mod = await import('../repositories/billing.extraBalance.repository.js'); + BillingExtraBalanceRepository = mod.default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('returns empty array when no docs match', async () => { + mockModel.find.mockReturnValue({ lean: jest.fn().mockResolvedValue([]) }); + + const result = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(new Date()); + expect(result).toEqual([]); + }); + + test('returns orgId when unhandled expired topup exists', async () => { + const now = new Date(); + const pastDate = new Date(now.getTime() - 1000); + const topupId = 'aaaaaaaaaaaaaaaaaaaaaaaa'; + const docs = [ + { organization: orgId1, ledger: makeLedger(topupId, pastDate, false) }, + ]; + mockModel.find.mockReturnValue({ lean: jest.fn().mockResolvedValue(docs) }); + + const result = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now); + expect(result).toContain(orgId1); + }); + + test('excludes org when all expired topups already have expiration entries', async () => { + const now = new Date(); + const pastDate = new Date(now.getTime() - 1000); + const topupId = 'aaaaaaaaaaaaaaaaaaaaaaaa'; + const docs = [ + { organization: orgId1, ledger: makeLedger(topupId, pastDate, true) }, + ]; + mockModel.find.mockReturnValue({ lean: jest.fn().mockResolvedValue(docs) }); + + const result = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now); + expect(result).not.toContain(orgId1); + }); + + test('excludes org when topup is not yet expired (expiresAt in the future)', async () => { + const now = new Date(); + const futureDate = new Date(now.getTime() + 10000); + const topupId = 'aaaaaaaaaaaaaaaaaaaaaaaa'; + const docs = [ + { organization: orgId1, ledger: makeLedger(topupId, futureDate, false) }, + ]; + mockModel.find.mockReturnValue({ lean: jest.fn().mockResolvedValue(docs) }); + + const result = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now); + expect(result).not.toContain(orgId1); + }); + + test('returns multiple orgIds when multiple orgs have unhandled expirations', async () => { + const now = new Date(); + const pastDate = new Date(now.getTime() - 1000); + const topupId1 = 'aaaaaaaaaaaaaaaaaaaaaaaa'; + const topupId2 = 'bbbbbbbbbbbbbbbbbbbbbbbb'; + const docs = [ + { organization: orgId1, ledger: makeLedger(topupId1, pastDate, false) }, + { organization: orgId2, ledger: makeLedger(topupId2, pastDate, false) }, + ]; + mockModel.find.mockReturnValue({ lean: jest.fn().mockResolvedValue(docs) }); + + const result = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now); + expect(result).toHaveLength(2); + expect(result).toContain(orgId1); + expect(result).toContain(orgId2); + }); + + test('handles org with mixed expired (handled) and unhandled topups — returns org', async () => { + const now = new Date(); + const pastDate = new Date(now.getTime() - 1000); + const topupId1 = 'aaaaaaaaaaaaaaaaaaaaaaaa'; + const topupId2 = 'bbbbbbbbbbbbbbbbbbbbbbbb'; + // topupId1 already has expiration, topupId2 does not + const ledger = [ + { _id: topupId1, kind: 'topup', amount: 1000, expiresAt: pastDate }, + { kind: 'expiration', amount: -1000, refId: `expire-${topupId1}` }, + { _id: topupId2, kind: 'topup', amount: 500, expiresAt: pastDate }, + ]; + const docs = [{ organization: orgId1, ledger }]; + mockModel.find.mockReturnValue({ lean: jest.fn().mockResolvedValue(docs) }); + + const result = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now); + expect(result).toContain(orgId1); + }); +}); diff --git a/modules/billing/tests/billing.quota.unit.tests.js b/modules/billing/tests/billing.quota.unit.tests.js index 4425f512c..06768583a 100644 --- a/modules/billing/tests/billing.quota.unit.tests.js +++ b/modules/billing/tests/billing.quota.unit.tests.js @@ -303,13 +303,103 @@ describe('requireQuota middleware:', () => { expect(res.status).toHaveBeenCalledWith(402); }); - test('should not call SubscriptionRepository in meter mode', async () => { + test('should call SubscriptionRepository in meter mode (degraded-mode gate)', async () => { + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ status: 'active', pastDueSince: null }); mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); await requireQuota('scraps', 'create')(req, res, next); - expect(mockSubscriptionRepository.findByOrganization).not.toHaveBeenCalled(); + expect(mockSubscriptionRepository.findByOrganization).toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('should allow through with billingDegraded flag when past_due within 7-day grace period (J+5)', async () => { + const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ + status: 'past_due', + pastDueSince: fiveDaysAgo, + }); + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + res.locals = {}; + await requireQuota('scraps', 'create')(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.locals.billingDegraded).toBe(true); + expect(res.status).not.toHaveBeenCalledWith(402); + }); + + test('should return 402 PAYMENT_PAST_DUE when past_due and grace period elapsed (J+10)', async () => { + const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ + status: 'past_due', + pastDueSince: tenDaysAgo, + }); + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + res.locals = {}; + await requireQuota('scraps', 'create')(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(402); + const payload = res.json.mock.calls[0][0]; + const errData = JSON.parse(payload.error); + expect(errData.type).toBe('PAYMENT_PAST_DUE'); + expect(errData.subscriptionStatus).toBe('past_due'); + }); + + test('should NOT block past_due with no pastDueSince set (legacy/missing field)', async () => { + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ + status: 'past_due', + pastDueSince: null, + }); + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + res.locals = {}; + await requireQuota('scraps', 'create')(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.locals.billingDegraded).toBeUndefined(); + }); + + test('should return 402 PAYMENT_PAST_DUE at exactly the 7-day boundary', async () => { + // Exactly 7 days ago → elapsed >= gracePeriodMs → block + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000 - 1); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ + status: 'past_due', + pastDueSince: sevenDaysAgo, + }); + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + res.locals = {}; + await requireQuota('scraps', 'create')(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(402); + }); + + test('degraded J+5: still blocks if meter is exhausted', async () => { + const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ + status: 'past_due', + pastDueSince: fiveDaysAgo, + }); + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 5000, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + res.locals = {}; + await requireQuota('scraps', 'create')(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(402); + const payload = res.json.mock.calls[0][0]; + const errData = JSON.parse(payload.error); + expect(errData.type).toBe('METER_EXHAUSTED'); }); }); }); diff --git a/modules/billing/tests/billing.service.unit.tests.js b/modules/billing/tests/billing.service.unit.tests.js index 3816ba815..d20fb0b6b 100644 --- a/modules/billing/tests/billing.service.unit.tests.js +++ b/modules/billing/tests/billing.service.unit.tests.js @@ -259,8 +259,8 @@ describe('Billing webhook service unit tests:', () => { }); describe('handleInvoicePaymentFailed', () => { - test('should mark subscription as past_due', async () => { - const existing = { _id: subId }; + test('should mark subscription as past_due and set pastDueSince on first failure', async () => { + const existing = { _id: subId, organization: orgId, pastDueSince: null }; mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing); mockSubscriptionRepository.update.mockResolvedValue({}); @@ -269,10 +269,9 @@ describe('Billing webhook service unit tests:', () => { await BillingWebhookService.handleInvoicePaymentFailed({ subscription: 'sub_456' }); - expect(mockSubscriptionRepository.update).toHaveBeenCalledWith({ - _id: subId, - status: 'past_due', - }); + expect(mockSubscriptionRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ _id: subId, status: 'past_due', pastDueSince: expect.any(Date) }), + ); }); test('should return early when no subscription ID in invoice', async () => { diff --git a/modules/billing/tests/billing.subscription.repository.unit.tests.js b/modules/billing/tests/billing.subscription.repository.unit.tests.js new file mode 100644 index 000000000..aaf572ce9 --- /dev/null +++ b/modules/billing/tests/billing.subscription.repository.unit.tests.js @@ -0,0 +1,170 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for billing.subscription.repository.js + * Covers: findStaleDunning + markUnpaid (PR-N5 additions) + */ +describe('BillingSubscriptionRepository unit tests:', () => { + let BillingSubscriptionRepository; + let mockModel; + + const orgId = '507f1f77bcf86cd799439011'; + const subId = '607f1f77bcf86cd799439022'; + + beforeEach(async () => { + jest.resetModules(); + + mockModel = { + find: jest.fn(), + findOne: jest.fn(), + findByIdAndUpdate: jest.fn(), + deleteOne: jest.fn(), + }; + + jest.unstable_mockModule('mongoose', () => ({ + default: { + Types: { ObjectId: { isValid: (id) => /^[a-f\d]{24}$/i.test(id) } }, + model: jest.fn(() => mockModel), + }, + })); + + const mod = await import('../repositories/billing.subscription.repository.js'); + BillingSubscriptionRepository = mod.default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── findStaleDunning ────────────────────────────────────────────────────── + + describe('findStaleDunning', () => { + test('queries for past_due status and pastDueSince <= threshold', async () => { + const threshold = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000); + const staleSubs = [{ _id: subId, organization: orgId }]; + const leanMock = jest.fn().mockResolvedValue(staleSubs); + mockModel.find.mockReturnValue({ lean: leanMock }); + + const result = await BillingSubscriptionRepository.findStaleDunning(threshold); + + expect(mockModel.find).toHaveBeenCalledWith( + { + status: 'past_due', + pastDueSince: { $ne: null, $lte: threshold }, + }, + { _id: 1, organization: 1 }, + ); + expect(leanMock).toHaveBeenCalled(); + expect(result).toEqual(staleSubs); + }); + + test('returns empty array when no stale subscriptions exist', async () => { + const leanMock = jest.fn().mockResolvedValue([]); + mockModel.find.mockReturnValue({ lean: leanMock }); + + const result = await BillingSubscriptionRepository.findStaleDunning(new Date()); + expect(result).toEqual([]); + }); + + test('returns multiple stale subscriptions', async () => { + const staleSubs = [ + { _id: subId, organization: orgId }, + { _id: '707f1f77bcf86cd799439033', organization: '507f1f77bcf86cd799439044' }, + ]; + const leanMock = jest.fn().mockResolvedValue(staleSubs); + mockModel.find.mockReturnValue({ lean: leanMock }); + + const result = await BillingSubscriptionRepository.findStaleDunning(new Date()); + expect(result).toHaveLength(2); + }); + + test('uses lean() for performance (no population)', async () => { + const leanMock = jest.fn().mockResolvedValue([]); + mockModel.find.mockReturnValue({ lean: leanMock }); + + await BillingSubscriptionRepository.findStaleDunning(new Date()); + + expect(leanMock).toHaveBeenCalled(); + }); + }); + + // ── markUnpaid ──────────────────────────────────────────────────────────── + + describe('markUnpaid', () => { + test('sets status to unpaid and plan to free atomically', async () => { + const updated = { _id: subId, status: 'unpaid', plan: 'free' }; + mockModel.findByIdAndUpdate.mockReturnValue({ exec: jest.fn().mockResolvedValue(updated) }); + + const result = await BillingSubscriptionRepository.markUnpaid(subId); + + expect(mockModel.findByIdAndUpdate).toHaveBeenCalledWith( + subId, + { $set: { status: 'unpaid', plan: 'free' } }, + { returnDocument: 'after', runValidators: true }, + ); + expect(result).toEqual(updated); + }); + + test('returns null for invalid ObjectId string', async () => { + const result = await BillingSubscriptionRepository.markUnpaid('not-a-valid-id'); + expect(result).toBeNull(); + expect(mockModel.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + test('returns null for undefined id', async () => { + const result = await BillingSubscriptionRepository.markUnpaid(undefined); + expect(result).toBeNull(); + expect(mockModel.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + test('returns null for null id', async () => { + const result = await BillingSubscriptionRepository.markUnpaid(null); + expect(result).toBeNull(); + expect(mockModel.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + test('is idempotent — already-unpaid subscription can be called again without error', async () => { + const already = { _id: subId, status: 'unpaid', plan: 'free' }; + mockModel.findByIdAndUpdate.mockReturnValue({ exec: jest.fn().mockResolvedValue(already) }); + + const result = await BillingSubscriptionRepository.markUnpaid(subId); + + expect(result.status).toBe('unpaid'); + expect(result.plan).toBe('free'); + }); + + test('uses returnDocument: after to return the updated document', async () => { + const updated = { _id: subId, status: 'unpaid', plan: 'free' }; + mockModel.findByIdAndUpdate.mockReturnValue({ exec: jest.fn().mockResolvedValue(updated) }); + + await BillingSubscriptionRepository.markUnpaid(subId); + + const callArgs = mockModel.findByIdAndUpdate.mock.calls[0]; + expect(callArgs[2]).toMatchObject({ returnDocument: 'after' }); + }); + }); + + // ── findAllDueForReset (existing — smoke test) ──────────────────────────── + + describe('findAllDueForReset', () => { + test('queries for active/trialing status within window', async () => { + const from = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const to = new Date(); + const leanMock = jest.fn().mockResolvedValue([]); + mockModel.find.mockReturnValue({ lean: leanMock }); + + await BillingSubscriptionRepository.findAllDueForReset(from, to); + + expect(mockModel.find).toHaveBeenCalledWith( + { + status: { $in: ['active', 'trialing'] }, + currentPeriodStart: { $gte: from, $lte: to }, + }, + { organization: 1, currentPeriodStart: 1 }, + ); + }); + }); +}); diff --git a/modules/billing/tests/billing.webhook.subscription.unit.tests.js b/modules/billing/tests/billing.webhook.subscription.unit.tests.js index b2c12def0..6cb745e25 100644 --- a/modules/billing/tests/billing.webhook.subscription.unit.tests.js +++ b/modules/billing/tests/billing.webhook.subscription.unit.tests.js @@ -261,7 +261,7 @@ describe('Billing webhook subscription unit tests:', () => { describe('handleInvoicePaymentFailed', () => { test('should set status to past_due', async () => { - const existing = { _id: subId, organization: orgId }; + const existing = { _id: subId, organization: orgId, pastDueSince: null }; mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing); mockSubscriptionRepository.update.mockResolvedValue({}); @@ -272,10 +272,67 @@ describe('Billing webhook subscription unit tests:', () => { ); }); + test('should set pastDueSince on first failure (when currently null)', async () => { + const existing = { _id: subId, organization: orgId, pastDueSince: null }; + mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing); + mockSubscriptionRepository.update.mockResolvedValue({}); + + const before = new Date(); + await BillingWebhookService.handleInvoicePaymentFailed({ subscription: 'sub_456' }); + const after = new Date(); + + const callArg = mockSubscriptionRepository.update.mock.calls[0][0]; + expect(callArg.pastDueSince).toBeInstanceOf(Date); + expect(callArg.pastDueSince.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(callArg.pastDueSince.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + test('should NOT overwrite pastDueSince on subsequent failures (idempotent grace clock)', async () => { + const originalDate = new Date('2026-04-01T00:00:00Z'); + const existing = { _id: subId, organization: orgId, pastDueSince: originalDate }; + mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing); + mockSubscriptionRepository.update.mockResolvedValue({}); + + await BillingWebhookService.handleInvoicePaymentFailed({ subscription: 'sub_456' }); + + const callArg = mockSubscriptionRepository.update.mock.calls[0][0]; + // pastDueSince must NOT be present in the update payload (preserves original date) + expect(callArg.pastDueSince).toBeUndefined(); + }); + + test('should emit payment.failed event with organizationId', async () => { + const existing = { _id: subId, organization: orgId, pastDueSince: null }; + mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing); + mockSubscriptionRepository.update.mockResolvedValue({}); + + await BillingWebhookService.handleInvoicePaymentFailed({ subscription: 'sub_456' }); + + expect(mockEvents.emit).toHaveBeenCalledWith('payment.failed', { organizationId: orgId }); + }); + + test('should not throw when event listener errors', async () => { + const existing = { _id: subId, organization: orgId, pastDueSince: null }; + mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing); + mockSubscriptionRepository.update.mockResolvedValue({}); + mockEvents.emit.mockImplementation(() => { throw new Error('listener error'); }); + + await expect( + BillingWebhookService.handleInvoicePaymentFailed({ subscription: 'sub_456' }), + ).resolves.not.toThrow(); + }); + test('should return early when no subscription ID in invoice', async () => { await BillingWebhookService.handleInvoicePaymentFailed({ subscription: null }); expect(mockSubscriptionRepository.findByStripeSubscriptionId).not.toHaveBeenCalled(); }); + + test('should return early when subscription not found', async () => { + mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(null); + + await BillingWebhookService.handleInvoicePaymentFailed({ subscription: 'sub_unknown' }); + + expect(mockSubscriptionRepository.update).not.toHaveBeenCalled(); + }); }); }); diff --git a/scripts/crons/README.md b/scripts/crons/README.md new file mode 100644 index 000000000..853474bc2 --- /dev/null +++ b/scripts/crons/README.md @@ -0,0 +1,55 @@ +# Billing Cron Scripts + +Standalone CLI scripts intended to be executed as Kubernetes CronJobs. + +All scripts gate on `config.billing.meterMode === true` and exit 0 immediately when the flag is `false` (default). +No `node-cron` dependency — orchestration is handled by Kubernetes CronJob manifests. + +## Scripts + +| Script | Purpose | Recommended schedule | +|--------|---------|----------------------| +| `billing.weeklyReset.js` | Reset meter counters for orgs whose billing period rolled over | Daily `0 1 * * *` | +| `billing.extrasExpiration.js` | Expire topup ledger entries past their `expiresAt` date | Daily `0 2 * * *` | +| `billing.dunningSweep.js` | Downgrade stale `past_due` subs (>14d) to `unpaid` + `free` | Daily `0 3 * * *` | + +## Usage + +```sh +NODE_ENV=production node scripts/crons/billing.weeklyReset.js +NODE_ENV=production node scripts/crons/billing.extrasExpiration.js +NODE_ENV=production node scripts/crons/billing.dunningSweep.js +``` + +Exit code 0 = success (or meterMode disabled). Exit code 1 = at least one error or fatal failure. + +## Kubernetes CronJob example + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: billing-weekly-reset + namespace: pierreb-projects +spec: + schedule: "0 1 * * *" + concurrencyPolicy: Forbid + jobTemplate: + spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: billing-weekly-reset + image: ghcr.io/your-org/your-app:main + command: ["node", "scripts/crons/billing.weeklyReset.js"] + env: + - name: NODE_ENV + value: production +``` + +Repeat the manifest for `billing.extrasExpiration.js` and `billing.dunningSweep.js`, adjusting `name` and `schedule`. + +## Dependency: meterMode flag + +All scripts check `config.billing.meterMode` at startup. Downstream projects must set this flag to `true` in their project config to activate billing crons. The devkit default is `false` — all crons are no-ops until explicitly enabled. diff --git a/scripts/crons/billing.dunningSweep.js b/scripts/crons/billing.dunningSweep.js new file mode 100644 index 000000000..109383fb5 --- /dev/null +++ b/scripts/crons/billing.dunningSweep.js @@ -0,0 +1,71 @@ +/** + * Cron script — dunning sweep. + * + * Finds subscriptions in 'past_due' status whose pastDueSince is older than 14 days + * (i.e. the 7-day grace period has elapsed with no payment), transitions them to + * 'unpaid' + plan 'free', and syncs the Organization.plan field accordingly. + * + * No-op when config.billing.meterMode === false (default). + * Intended to run as a Kubernetes CronJob — see scripts/crons/README.md. + * + * Usage: + * NODE_ENV=production node scripts/crons/billing.dunningSweep.js + */ + +process.env.NODE_ENV = process.env.NODE_ENV || 'development'; + +const [{ default: config }, { default: mongooseService }] = await Promise.all([ + import('../../config/index.js'), + import('../../lib/services/mongoose.js'), +]); + +if (!config?.billing?.meterMode) { + console.log('[billing.dunningSweep] meterMode disabled — skipping.'); + process.exit(0); +} + +await mongooseService.connect(); + +try { + const [{ default: mongoose }, { default: BillingSubscriptionRepository }] = await Promise.all([ + import('mongoose'), + import('../../modules/billing/repositories/billing.subscription.repository.js'), + ]); + + const Organization = mongoose.model('Organization'); + + const now = new Date(); + const threshold = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + + const staleSubs = await BillingSubscriptionRepository.findStaleDunning(threshold); + console.log(`[billing.dunningSweep] ${staleSubs.length} stale past_due subscription(s) found`); + + let processed = 0; + let errors = 0; + + for (const sub of staleSubs) { + try { + await BillingSubscriptionRepository.markUnpaid(String(sub._id)); + + // Sync Organization.plan to 'free' + const orgId = String(sub.organization); + if (mongoose.Types.ObjectId.isValid(orgId)) { + await Organization.findByIdAndUpdate(orgId, { plan: 'free' }, { runValidators: true }).exec(); + } + + console.log(`[billing.dunningSweep] sub ${sub._id} → unpaid, org ${sub.organization} → free`); + processed += 1; + } catch (err) { + errors += 1; + console.error(`[billing.dunningSweep] failed for sub ${sub._id}:`, err); + } + } + + console.log(`[billing.dunningSweep] done — processed: ${processed}, errors: ${errors}`); + process.exit(errors > 0 ? 1 : 0); +} catch (err) { + console.error('[billing.dunningSweep] fatal:', err); + process.exit(1); +} finally { + await mongooseService.disconnect?.(); +} diff --git a/scripts/crons/billing.extrasExpiration.js b/scripts/crons/billing.extrasExpiration.js new file mode 100644 index 000000000..764109dc1 --- /dev/null +++ b/scripts/crons/billing.extrasExpiration.js @@ -0,0 +1,60 @@ +/** + * Cron script — extra balance expiration sweep. + * + * Finds organizations with expired topup ledger entries that have not yet been + * offset by a matching expiration entry, then calls BillingExtraService.expireOldEntries + * for each. + * + * No-op when config.billing.meterMode === false (default). + * Intended to run as a Kubernetes CronJob — see scripts/crons/README.md. + * + * Usage: + * NODE_ENV=production node scripts/crons/billing.extrasExpiration.js + */ + +process.env.NODE_ENV = process.env.NODE_ENV || 'development'; + +const [{ default: config }, { default: mongooseService }] = await Promise.all([ + import('../../config/index.js'), + import('../../lib/services/mongoose.js'), +]); + +if (!config?.billing?.meterMode) { + console.log('[billing.extrasExpiration] meterMode disabled — skipping.'); + process.exit(0); +} + +await mongooseService.connect(); + +try { + const [{ default: BillingExtraService }, { default: BillingExtraBalanceRepository }] = + await Promise.all([ + import('../../modules/billing/services/billing.extra.service.js'), + import('../../modules/billing/repositories/billing.extraBalance.repository.js'), + ]); + + const now = new Date(); + const orgIds = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now); + + let processed = 0; + let errors = 0; + + for (const orgId of orgIds) { + try { + const added = await BillingExtraService.expireOldEntries(orgId); + console.log(`[billing.extrasExpiration] org ${orgId}: ${added} expiration entries added`); + processed += 1; + } catch (err) { + errors += 1; + console.error(`[billing.extrasExpiration] expireOldEntries failed for org ${orgId}:`, err); + } + } + + console.log(`[billing.extrasExpiration] done — processed: ${processed}, errors: ${errors}`); + process.exit(errors > 0 ? 1 : 0); +} catch (err) { + console.error('[billing.extrasExpiration] fatal:', err); + process.exit(1); +} finally { + await mongooseService.disconnect?.(); +} diff --git a/scripts/crons/billing.weeklyReset.js b/scripts/crons/billing.weeklyReset.js new file mode 100644 index 000000000..a8a21e55b --- /dev/null +++ b/scripts/crons/billing.weeklyReset.js @@ -0,0 +1,39 @@ +/** + * Cron script — weekly meter reset sweep. + * + * Iterates active subscriptions and resets the meter for each org whose + * billing period rolled over within the last 7 days. + * + * No-op when config.billing.meterMode === false (default). + * Intended to run as a Kubernetes CronJob — see scripts/crons/README.md. + * + * Usage: + * NODE_ENV=production node scripts/crons/billing.weeklyReset.js + */ + +process.env.NODE_ENV = process.env.NODE_ENV || 'development'; + +const [{ default: config }, { default: mongooseService }] = await Promise.all([ + import('../../config/index.js'), + import('../../lib/services/mongoose.js'), +]); + +if (!config?.billing?.meterMode) { + console.log('[billing.weeklyReset] meterMode disabled — skipping.'); + process.exit(0); +} + +await mongooseService.connect(); + +try { + const { default: BillingResetService } = await import('../../modules/billing/services/billing.reset.service.js'); + + const result = await BillingResetService.resetAllDue(); + console.log(`[billing.weeklyReset] done — processed: ${result.processed}, errors: ${result.errors}`); + process.exit(result.errors > 0 ? 1 : 0); +} catch (err) { + console.error('[billing.weeklyReset] fatal:', err); + process.exit(1); +} finally { + await mongooseService.disconnect?.(); +} diff --git a/scripts/tests/billing.cron.dunningSweep.unit.tests.js b/scripts/tests/billing.cron.dunningSweep.unit.tests.js new file mode 100644 index 000000000..bdcec763c --- /dev/null +++ b/scripts/tests/billing.cron.dunningSweep.unit.tests.js @@ -0,0 +1,168 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for billing.dunningSweep cron logic. + * + * Tests cover: + * - meterMode gate (early exit when false) + * - findStaleDunning threshold calculation (14 days) + * - markUnpaid called per stale subscription + * - Organization.plan synced to 'free' + * - error counting + continuation + * - idempotency: already-unpaid subscriptions are no-ops + */ +describe('billing.dunningSweep cron — BillingSubscriptionRepository:', () => { + let BillingSubscriptionRepository; + let mockConfig; + let mockModel; + let mockOrganizationModel; + + const orgId = '507f1f77bcf86cd799439011'; + const subId = '607f1f77bcf86cd799439022'; + + beforeEach(async () => { + jest.resetModules(); + + mockConfig = { + billing: { meterMode: true }, + }; + + mockModel = { + find: jest.fn(), + findByIdAndUpdate: jest.fn(), + }; + + mockOrganizationModel = { + findByIdAndUpdate: jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue({}) }), + }; + + jest.unstable_mockModule('../../config/index.js', () => ({ default: mockConfig })); + + jest.unstable_mockModule('mongoose', () => ({ + default: { + Types: { ObjectId: { isValid: (id) => /^[a-f\d]{24}$/i.test(id) } }, + model: (name) => { + if (name === 'Organization') return mockOrganizationModel; + return mockModel; + }, + }, + })); + + const mod = await import('../../modules/billing/repositories/billing.subscription.repository.js'); + BillingSubscriptionRepository = mod.default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('findStaleDunning', () => { + test('returns subscriptions with status past_due and pastDueSince <= threshold', async () => { + const threshold = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000); + const staleSubs = [{ _id: subId, organization: orgId }]; + const leanMock = jest.fn().mockResolvedValue(staleSubs); + mockModel.find.mockReturnValue({ lean: leanMock }); + + const result = await BillingSubscriptionRepository.findStaleDunning(threshold); + + expect(mockModel.find).toHaveBeenCalledWith( + expect.objectContaining({ status: 'past_due', pastDueSince: expect.objectContaining({ $lte: threshold }) }), + expect.any(Object), + ); + expect(result).toEqual(staleSubs); + }); + + test('returns empty array when no stale subscriptions', async () => { + const leanMock = jest.fn().mockResolvedValue([]); + mockModel.find.mockReturnValue({ lean: leanMock }); + + const result = await BillingSubscriptionRepository.findStaleDunning(new Date()); + expect(result).toEqual([]); + }); + }); + + describe('markUnpaid', () => { + test('sets status to unpaid and plan to free', async () => { + const updated = { _id: subId, status: 'unpaid', plan: 'free' }; + mockModel.findByIdAndUpdate.mockReturnValue({ exec: jest.fn().mockResolvedValue(updated) }); + + const result = await BillingSubscriptionRepository.markUnpaid(subId); + + expect(mockModel.findByIdAndUpdate).toHaveBeenCalledWith( + subId, + { $set: { status: 'unpaid', plan: 'free' } }, + expect.objectContaining({ returnDocument: 'after' }), + ); + expect(result).toEqual(updated); + }); + + test('returns null for invalid id', async () => { + const result = await BillingSubscriptionRepository.markUnpaid('not-valid'); + expect(result).toBeNull(); + expect(mockModel.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + test('returns null for missing id', async () => { + const result = await BillingSubscriptionRepository.markUnpaid(undefined); + expect(result).toBeNull(); + }); + }); + + describe('dunning sweep logic (integration of findStaleDunning + markUnpaid)', () => { + test('processes multiple stale subscriptions', async () => { + const staleSubs = [ + { _id: subId, organization: orgId }, + { _id: '707f1f77bcf86cd799439033', organization: '507f1f77bcf86cd799439044' }, + ]; + const leanMock = jest.fn().mockResolvedValue(staleSubs); + mockModel.find.mockReturnValue({ lean: leanMock }); + mockModel.findByIdAndUpdate.mockReturnValue({ exec: jest.fn().mockResolvedValue({}) }); + + const returned = await BillingSubscriptionRepository.findStaleDunning(new Date()); + let processed = 0; + let errors = 0; + for (const sub of returned) { + try { + await BillingSubscriptionRepository.markUnpaid(String(sub._id)); + processed += 1; + } catch { + errors += 1; + } + } + + expect(processed).toBe(2); + expect(errors).toBe(0); + expect(mockModel.findByIdAndUpdate).toHaveBeenCalledTimes(2); + }); + + test('counts errors and continues when markUnpaid throws', async () => { + const staleSubs = [ + { _id: subId, organization: orgId }, + { _id: '707f1f77bcf86cd799439033', organization: '507f1f77bcf86cd799439044' }, + ]; + const leanMock = jest.fn().mockResolvedValue(staleSubs); + mockModel.find.mockReturnValue({ lean: leanMock }); + mockModel.findByIdAndUpdate + .mockReturnValueOnce({ exec: jest.fn().mockRejectedValue(new Error('DB error')) }) + .mockReturnValue({ exec: jest.fn().mockResolvedValue({}) }); + + const returned = await BillingSubscriptionRepository.findStaleDunning(new Date()); + let processed = 0; + let errors = 0; + for (const sub of returned) { + try { + await BillingSubscriptionRepository.markUnpaid(String(sub._id)); + processed += 1; + } catch { + errors += 1; + } + } + + expect(processed).toBe(1); + expect(errors).toBe(1); + }); + }); +}); diff --git a/scripts/tests/billing.cron.extrasExpiration.unit.tests.js b/scripts/tests/billing.cron.extrasExpiration.unit.tests.js new file mode 100644 index 000000000..a46080f90 --- /dev/null +++ b/scripts/tests/billing.cron.extrasExpiration.unit.tests.js @@ -0,0 +1,131 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for billing.extrasExpiration cron logic. + * + * Tests cover: + * - meterMode gate (early exit when false) + * - findOrgsWithExpiringTopups returns org list + * - BillingExtraService.expireOldEntries called per org + * - error counting + continuation + * - empty result (no-op) + */ +describe('billing.extrasExpiration cron — logic:', () => { + let BillingExtraService; + let BillingExtraBalanceRepository; + let mockConfig; + + beforeEach(async () => { + jest.resetModules(); + + mockConfig = { + billing: { meterMode: true }, + }; + + jest.unstable_mockModule('../../config/index.js', () => ({ default: mockConfig })); + + jest.unstable_mockModule('../../modules/billing/repositories/billing.extraBalance.repository.js', () => ({ + default: { + findOrgsWithExpiringTopups: jest.fn(), + addExpirationEntries: jest.fn(), + getOrCreate: jest.fn(), + creditPack: jest.fn(), + debit: jest.fn(), + refundPartial: jest.fn(), + getBalance: jest.fn(), + }, + })); + + jest.unstable_mockModule('../../modules/billing/services/billing.extra.service.js', () => ({ + default: { + expireOldEntries: jest.fn(), + creditPack: jest.fn(), + debit: jest.fn(), + refundPartial: jest.fn(), + listLedger: jest.fn(), + }, + })); + + const [extraServiceMod, repMod] = await Promise.all([ + import('../../modules/billing/services/billing.extra.service.js'), + import('../../modules/billing/repositories/billing.extraBalance.repository.js'), + ]); + BillingExtraService = extraServiceMod.default; + BillingExtraBalanceRepository = repMod.default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('skips sweep when meterMode is false', async () => { + mockConfig.billing.meterMode = false; + + // Simulate the script gate logic inline + const shouldSkip = !mockConfig?.billing?.meterMode; + expect(shouldSkip).toBe(true); + expect(BillingExtraBalanceRepository.findOrgsWithExpiringTopups).not.toHaveBeenCalled(); + }); + + test('returns no-op when findOrgsWithExpiringTopups returns empty list', async () => { + BillingExtraBalanceRepository.findOrgsWithExpiringTopups.mockResolvedValue([]); + + const now = new Date(); + const orgIds = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now); + expect(orgIds).toHaveLength(0); + expect(BillingExtraService.expireOldEntries).not.toHaveBeenCalled(); + }); + + test('calls expireOldEntries for each org returned by findOrgsWithExpiringTopups', async () => { + const orgIds = ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439022']; + BillingExtraBalanceRepository.findOrgsWithExpiringTopups.mockResolvedValue(orgIds); + BillingExtraService.expireOldEntries.mockResolvedValue(1); + + const now = new Date(); + const returned = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now); + + let processed = 0; + let errors = 0; + for (const orgId of returned) { + try { + await BillingExtraService.expireOldEntries(orgId); + processed += 1; + } catch { + errors += 1; + } + } + + expect(processed).toBe(2); + expect(errors).toBe(0); + expect(BillingExtraService.expireOldEntries).toHaveBeenCalledTimes(2); + expect(BillingExtraService.expireOldEntries).toHaveBeenCalledWith('507f1f77bcf86cd799439011'); + expect(BillingExtraService.expireOldEntries).toHaveBeenCalledWith('507f1f77bcf86cd799439022'); + }); + + test('counts errors and continues when expireOldEntries throws', async () => { + const orgIds = ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439022']; + BillingExtraBalanceRepository.findOrgsWithExpiringTopups.mockResolvedValue(orgIds); + BillingExtraService.expireOldEntries + .mockRejectedValueOnce(new Error('DB error')) + .mockResolvedValueOnce(1); + + const returned = await BillingExtraBalanceRepository.findOrgsWithExpiringTopups(new Date()); + let processed = 0; + let errors = 0; + for (const orgId of returned) { + try { + await BillingExtraService.expireOldEntries(orgId); + processed += 1; + } catch { + errors += 1; + } + } + + expect(processed).toBe(1); + expect(errors).toBe(1); + }); +}); + diff --git a/scripts/tests/billing.cron.weeklyReset.unit.tests.js b/scripts/tests/billing.cron.weeklyReset.unit.tests.js new file mode 100644 index 000000000..5753e14a5 --- /dev/null +++ b/scripts/tests/billing.cron.weeklyReset.unit.tests.js @@ -0,0 +1,123 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for billing.weeklyReset cron script logic. + * + * The script itself is a top-level-await CLI entry point that connects to MongoDB and exits. + * We test the underlying BillingResetService.resetAllDue integration path rather than the + * script file directly (which would require a live DB connection). + */ +describe('billing.weeklyReset cron — BillingResetService.resetAllDue:', () => { + let BillingResetService; + let mockConfig; + let mockUsageRepository; + let mockSubscriptionRepository; + let mockPlanService; + + beforeEach(async () => { + jest.resetModules(); + + mockConfig = { + billing: { + meterMode: true, + plans: ['pro'], + }, + }; + + mockUsageRepository = { + findByWeek: jest.fn().mockResolvedValue(null), + archiveOtherWeeks: jest.fn().mockResolvedValue({ modifiedCount: 0 }), + upsertWeekSnapshot: jest.fn(), + }; + + mockPlanService = { + getActivePlan: jest.fn().mockResolvedValue({ planId: 'pro', version: 'v1', meterQuota: 500000 }), + }; + + mockSubscriptionRepository = { + findAllDueForReset: jest.fn(), + }; + + jest.unstable_mockModule('../../config/index.js', () => ({ default: mockConfig })); + jest.unstable_mockModule('../../modules/billing/repositories/billing.usage.repository.js', () => ({ + default: mockUsageRepository, + })); + jest.unstable_mockModule('../../modules/billing/repositories/billing.subscription.repository.js', () => ({ + default: mockSubscriptionRepository, + })); + jest.unstable_mockModule('../../modules/billing/services/billing.plan.service.js', () => ({ + default: mockPlanService, + })); + + const mod = await import('../../modules/billing/services/billing.reset.service.js'); + BillingResetService = mod.default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('resetAllDue returns { processed: 0, errors: 0 } when meterMode is false', async () => { + mockConfig.billing.meterMode = false; + + const result = await BillingResetService.resetAllDue(); + + expect(result).toEqual({ processed: 0, errors: 0 }); + expect(mockSubscriptionRepository.findAllDueForReset).not.toHaveBeenCalled(); + }); + + test('resetAllDue returns { processed: 0, errors: 0 } when no subscriptions are due', async () => { + mockSubscriptionRepository.findAllDueForReset.mockResolvedValue([]); + + const result = await BillingResetService.resetAllDue(); + + expect(result).toEqual({ processed: 0, errors: 0 }); + }); + + test('resetAllDue processes each due subscription and returns correct count', async () => { + const subs = [ + { organization: '507f1f77bcf86cd799439011', currentPeriodStart: new Date() }, + { organization: '507f1f77bcf86cd799439022', currentPeriodStart: new Date() }, + ]; + mockSubscriptionRepository.findAllDueForReset.mockResolvedValue(subs); + mockUsageRepository.upsertWeekSnapshot.mockResolvedValue({ weekKey: '2026-W18' }); + + const result = await BillingResetService.resetAllDue(); + + expect(result.processed).toBe(2); + expect(result.errors).toBe(0); + }); + + test('resetAllDue counts errors and continues on individual failure', async () => { + const subs = [ + { organization: '507f1f77bcf86cd799439011', currentPeriodStart: new Date() }, + { organization: '507f1f77bcf86cd799439022', currentPeriodStart: new Date() }, + ]; + mockSubscriptionRepository.findAllDueForReset.mockResolvedValue(subs); + // First call throws, second succeeds + mockUsageRepository.upsertWeekSnapshot + .mockRejectedValueOnce(new Error('DB error')) + .mockResolvedValueOnce({ weekKey: '2026-W18' }); + + const result = await BillingResetService.resetAllDue(); + + expect(result.processed).toBe(1); + expect(result.errors).toBe(1); + }); + + test('resetAllDue is idempotent — no double-upsert when week doc already exists', async () => { + const subs = [{ organization: '507f1f77bcf86cd799439011', currentPeriodStart: new Date() }]; + mockSubscriptionRepository.findAllDueForReset.mockResolvedValue(subs); + // findByWeek returns existing doc → resetWeek returns early without upsert + mockUsageRepository.findByWeek.mockResolvedValue({ weekKey: '2026-W18', meterUsed: 100 }); + + const result = await BillingResetService.resetAllDue(); + + expect(result.processed).toBe(1); + expect(result.errors).toBe(0); + expect(mockUsageRepository.upsertWeekSnapshot).not.toHaveBeenCalled(); + }); +}); From 17c2662733c807913e94c514902183f7ba8d708a Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Wed, 29 Apr 2026 22:50:25 +0200 Subject: [PATCH 2/3] fix(billing): dunningSweep atomic Subscription+Org via repository + input guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace direct mongoose.model('Organization') in dunningSweep cron with OrganizationRepository.setPlan — eliminates direct DB access from script layer - Add OrganizationRepository.setPlan(orgId, plan): atomic findByIdAndUpdate with ObjectId guard and runValidators - Separate compensation path: if setPlan throws after markUnpaid succeeds, log desyncErrors counter for manual reconciliation without reverting Subscription - Add TypeError input guard on BillingSubscriptionRepository.findStaleDunning(threshold) - Add TypeError input guard on BillingExtraBalanceRepository.findOrgsWithExpiringTopups(now) with JSDoc comment explaining pre-filter behavior - Update README.md cron k8s image placeholder with clarifying comment - 8 new unit tests (917 total, +8 from 909 baseline) --- .../billing.extraBalance.repository.js | 4 ++ .../billing.subscription.repository.js | 6 +- ...e.findOrgsWithExpiringTopups.unit.tests.js | 7 ++ .../repositories/organizations.repository.js | 15 ++++ ...nizations.repository.setPlan.unit.tests.js | 71 +++++++++++++++++++ scripts/crons/README.md | 2 +- scripts/crons/billing.dunningSweep.js | 23 +++--- .../billing.cron.dunningSweep.unit.tests.js | 50 ++++++++++++- 8 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 modules/organizations/tests/organizations.repository.setPlan.unit.tests.js diff --git a/modules/billing/repositories/billing.extraBalance.repository.js b/modules/billing/repositories/billing.extraBalance.repository.js index 7caedff3d..f23b1ba96 100644 --- a/modules/billing/repositories/billing.extraBalance.repository.js +++ b/modules/billing/repositories/billing.extraBalance.repository.js @@ -254,7 +254,11 @@ const getBalance = async (orgId) => { */ // biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik const findOrgsWithExpiringTopups = async (now) => { + if (!(now instanceof Date)) throw new TypeError('now must be a Date instance'); // Pull only the ledger field (projection) to keep the payload small. + // Note: the MongoDB pre-filter `ledger.expiresAt: { $lt: now }` is a coarse pre-filter — + // some returned docs may have no unhandled expirations (already recorded expiration entries); + // the in-memory loop below performs the precise check. This is intentional for simplicity. const docs = await BillingExtraBalance() .find( { diff --git a/modules/billing/repositories/billing.subscription.repository.js b/modules/billing/repositories/billing.subscription.repository.js index e726dd212..2b3e9e5e0 100644 --- a/modules/billing/repositories/billing.subscription.repository.js +++ b/modules/billing/repositories/billing.subscription.repository.js @@ -138,14 +138,16 @@ const findAllDueForReset = (from, to) => * @returns {Promise>} */ // biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik -const findStaleDunning = (threshold) => - Subscription.find( +const findStaleDunning = (threshold) => { + if (!(threshold instanceof Date)) throw new TypeError('threshold must be a Date instance'); + return Subscription.find( { status: 'past_due', pastDueSince: { $ne: null, $lte: threshold }, }, { _id: 1, organization: 1 }, ).lean(); +}; /** * @function markUnpaid diff --git a/modules/billing/tests/billing.extraBalance.findOrgsWithExpiringTopups.unit.tests.js b/modules/billing/tests/billing.extraBalance.findOrgsWithExpiringTopups.unit.tests.js index 49723f1d6..2276a9224 100644 --- a/modules/billing/tests/billing.extraBalance.findOrgsWithExpiringTopups.unit.tests.js +++ b/modules/billing/tests/billing.extraBalance.findOrgsWithExpiringTopups.unit.tests.js @@ -58,6 +58,13 @@ describe('BillingExtraBalanceRepository.findOrgsWithExpiringTopups:', () => { expect(result).toEqual([]); }); + test('throws TypeError when now is not a Date', async () => { + await expect(BillingExtraBalanceRepository.findOrgsWithExpiringTopups('2026-01-01')).rejects.toThrow(TypeError); + await expect(BillingExtraBalanceRepository.findOrgsWithExpiringTopups(null)).rejects.toThrow(TypeError); + await expect(BillingExtraBalanceRepository.findOrgsWithExpiringTopups(undefined)).rejects.toThrow(TypeError); + await expect(BillingExtraBalanceRepository.findOrgsWithExpiringTopups(Date.now())).rejects.toThrow(TypeError); + }); + test('returns orgId when unhandled expired topup exists', async () => { const now = new Date(); const pastDate = new Date(now.getTime() - 1000); diff --git a/modules/organizations/repositories/organizations.repository.js b/modules/organizations/repositories/organizations.repository.js index e4dffa586..218c4844a 100644 --- a/modules/organizations/repositories/organizations.repository.js +++ b/modules/organizations/repositories/organizations.repository.js @@ -85,6 +85,20 @@ const findOne = (filter) => Organization.findOne(filter).exec(); */ const exists = (filter) => Organization.exists(filter); +/** + * @function setPlan + * @description Atomically update the plan field of a single organization. + * Used by billing crons to keep Organization.plan in sync after + * a subscription status change. + * @param {string} orgId - The organization ObjectId (string). + * @param {string} plan - The target plan value (e.g. 'free'). + * @returns {Promise} The updated organization or null if orgId is invalid. + */ +const setPlan = (orgId, plan) => { + if (!mongoose.Types.ObjectId.isValid(orgId)) return Promise.resolve(null); + return Organization.findByIdAndUpdate(orgId, { plan }, { returnDocument: 'after', runValidators: true }).exec(); +}; + export default { list, create, @@ -94,4 +108,5 @@ export default { deleteMany, findOne, exists, + setPlan, }; diff --git a/modules/organizations/tests/organizations.repository.setPlan.unit.tests.js b/modules/organizations/tests/organizations.repository.setPlan.unit.tests.js new file mode 100644 index 000000000..54e2af427 --- /dev/null +++ b/modules/organizations/tests/organizations.repository.setPlan.unit.tests.js @@ -0,0 +1,71 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for OrganizationRepository.setPlan (PR-N5) + */ +describe('OrganizationRepository.setPlan:', () => { + let OrganizationRepository; + let mockModel; + + const orgId = '507f1f77bcf86cd799439011'; + + beforeEach(async () => { + jest.resetModules(); + + mockModel = { + findByIdAndUpdate: jest.fn(), + }; + + jest.unstable_mockModule('mongoose', () => ({ + default: { + Types: { ObjectId: { isValid: (id) => /^[a-f\d]{24}$/i.test(id) } }, + model: jest.fn(() => mockModel), + }, + })); + + const mod = await import('../repositories/organizations.repository.js'); + OrganizationRepository = mod.default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('calls findByIdAndUpdate with correct plan payload', async () => { + const updatedOrg = { _id: orgId, plan: 'free' }; + mockModel.findByIdAndUpdate.mockReturnValue({ exec: jest.fn().mockResolvedValue(updatedOrg) }); + + const result = await OrganizationRepository.setPlan(orgId, 'free'); + + expect(mockModel.findByIdAndUpdate).toHaveBeenCalledWith( + orgId, + { plan: 'free' }, + expect.objectContaining({ returnDocument: 'after', runValidators: true }), + ); + expect(result).toEqual(updatedOrg); + }); + + test('returns null for invalid orgId without calling findByIdAndUpdate', async () => { + const result = await OrganizationRepository.setPlan('not-valid', 'free'); + + expect(result).toBeNull(); + expect(mockModel.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + test('returns null for empty string orgId', async () => { + const result = await OrganizationRepository.setPlan('', 'free'); + + expect(result).toBeNull(); + expect(mockModel.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + test('propagates DB errors to the caller', async () => { + const dbErr = new Error('connection lost'); + mockModel.findByIdAndUpdate.mockReturnValue({ exec: jest.fn().mockRejectedValue(dbErr) }); + + await expect(OrganizationRepository.setPlan(orgId, 'free')).rejects.toThrow('connection lost'); + }); +}); diff --git a/scripts/crons/README.md b/scripts/crons/README.md index 853474bc2..a8ea0f87f 100644 --- a/scripts/crons/README.md +++ b/scripts/crons/README.md @@ -41,7 +41,7 @@ spec: restartPolicy: OnFailure containers: - name: billing-weekly-reset - image: ghcr.io/your-org/your-app:main + image: ghcr.io/your-org/your-app:main # replace with your project image command: ["node", "scripts/crons/billing.weeklyReset.js"] env: - name: NODE_ENV diff --git a/scripts/crons/billing.dunningSweep.js b/scripts/crons/billing.dunningSweep.js index 109383fb5..1e39d5370 100644 --- a/scripts/crons/billing.dunningSweep.js +++ b/scripts/crons/billing.dunningSweep.js @@ -27,13 +27,11 @@ if (!config?.billing?.meterMode) { await mongooseService.connect(); try { - const [{ default: mongoose }, { default: BillingSubscriptionRepository }] = await Promise.all([ - import('mongoose'), + const [{ default: BillingSubscriptionRepository }, { default: OrganizationRepository }] = await Promise.all([ import('../../modules/billing/repositories/billing.subscription.repository.js'), + import('../../modules/organizations/repositories/organizations.repository.js'), ]); - const Organization = mongoose.model('Organization'); - const now = new Date(); const threshold = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); @@ -42,15 +40,20 @@ try { let processed = 0; let errors = 0; + let desyncErrors = 0; for (const sub of staleSubs) { try { - await BillingSubscriptionRepository.markUnpaid(String(sub._id)); + const subscription = await BillingSubscriptionRepository.markUnpaid(String(sub._id)); + if (!subscription) continue; // markUnpaid returns null on invalid id - // Sync Organization.plan to 'free' - const orgId = String(sub.organization); - if (mongoose.Types.ObjectId.isValid(orgId)) { - await Organization.findByIdAndUpdate(orgId, { plan: 'free' }, { runValidators: true }).exec(); + try { + await OrganizationRepository.setPlan(String(sub.organization), 'free'); + } catch (orgErr) { + // Compensation: Subscription is now unpaid but Org.plan update failed. + // Log for manual reconciliation — do not revert Subscription status. + console.error('[billing.dunningSweep] Org plan sync failed (manual reconciliation required):', orgErr); + desyncErrors += 1; } console.log(`[billing.dunningSweep] sub ${sub._id} → unpaid, org ${sub.organization} → free`); @@ -61,7 +64,7 @@ try { } } - console.log(`[billing.dunningSweep] done — processed: ${processed}, errors: ${errors}`); + console.log(`[billing.dunningSweep] done — processed: ${processed}, errors: ${errors}, desyncErrors: ${desyncErrors}`); process.exit(errors > 0 ? 1 : 0); } catch (err) { console.error('[billing.dunningSweep] fatal:', err); diff --git a/scripts/tests/billing.cron.dunningSweep.unit.tests.js b/scripts/tests/billing.cron.dunningSweep.unit.tests.js index bdcec763c..0277bf071 100644 --- a/scripts/tests/billing.cron.dunningSweep.unit.tests.js +++ b/scripts/tests/billing.cron.dunningSweep.unit.tests.js @@ -9,8 +9,10 @@ import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globa * Tests cover: * - meterMode gate (early exit when false) * - findStaleDunning threshold calculation (14 days) + * - findStaleDunning input guard (TypeError on non-Date) * - markUnpaid called per stale subscription - * - Organization.plan synced to 'free' + * - Organization.plan synced via OrganizationRepository.setPlan + * - desyncErrors incremented when setPlan throws (compensation log) * - error counting + continuation * - idempotency: already-unpaid subscriptions are no-ops */ @@ -82,6 +84,13 @@ describe('billing.dunningSweep cron — BillingSubscriptionRepository:', () => { const result = await BillingSubscriptionRepository.findStaleDunning(new Date()); expect(result).toEqual([]); }); + + test('throws TypeError when threshold is not a Date', () => { + expect(() => BillingSubscriptionRepository.findStaleDunning('2026-01-01')).toThrow(TypeError); + expect(() => BillingSubscriptionRepository.findStaleDunning(null)).toThrow(TypeError); + expect(() => BillingSubscriptionRepository.findStaleDunning(undefined)).toThrow(TypeError); + expect(() => BillingSubscriptionRepository.findStaleDunning(1234567890)).toThrow(TypeError); + }); }); describe('markUnpaid', () => { @@ -111,7 +120,7 @@ describe('billing.dunningSweep cron — BillingSubscriptionRepository:', () => { }); }); - describe('dunning sweep logic (integration of findStaleDunning + markUnpaid)', () => { + describe('dunning sweep logic (integration of findStaleDunning + markUnpaid + OrganizationRepository)', () => { test('processes multiple stale subscriptions', async () => { const staleSubs = [ { _id: subId, organization: orgId }, @@ -164,5 +173,42 @@ describe('billing.dunningSweep cron — BillingSubscriptionRepository:', () => { expect(processed).toBe(1); expect(errors).toBe(1); }); + + test('desync: markUnpaid succeeds but setPlan throws — increments desyncErrors', async () => { + // Simulate the cron compensation path: markUnpaid OK, OrganizationRepository.setPlan fails. + // Cron should not rethrow — it logs and increments desyncErrors, continues processing. + const updatedSub = { _id: subId, organization: orgId, status: 'unpaid', plan: 'free' }; + mockModel.findByIdAndUpdate.mockReturnValue({ exec: jest.fn().mockResolvedValue(updatedSub) }); + + const setPlanMock = jest.fn().mockRejectedValue(new Error('org DB error')); + + // Replicate cron loop logic inline (cron imports are not re-executed in unit context) + let processed = 0; + let desyncErrors = 0; + const staleSubs = [{ _id: subId, organization: orgId }]; + for (const sub of staleSubs) { + const subscription = await BillingSubscriptionRepository.markUnpaid(String(sub._id)); + if (!subscription) continue; + try { + await setPlanMock(String(sub.organization), 'free'); + } catch { + desyncErrors += 1; + } + processed += 1; + } + + expect(processed).toBe(1); + expect(desyncErrors).toBe(1); + expect(setPlanMock).toHaveBeenCalledWith(orgId, 'free'); + }); + + test('markUnpaid returns null for invalid sub id — cron skips (continue)', async () => { + // markUnpaid returns null for invalid ids; cron should continue without incrementing errors. + const badSubId = 'not-a-valid-objectid'; + const result = await BillingSubscriptionRepository.markUnpaid(badSubId); + + expect(result).toBeNull(); + expect(mockModel.findByIdAndUpdate).not.toHaveBeenCalled(); + }); }); }); From 97e9662b94e0e1bd2396a7b37f9c32822c7bec3d Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Wed, 29 Apr 2026 22:56:36 +0200 Subject: [PATCH 3/3] fix(billing): webhook service + cron cleanup for Codacy MEDIUM pass 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - billing.webhook.service: replace direct mongoose.model('Organization') with OrganizationRepository.setPlan — keeps DB access in repo layer - billing.webhook.service: add error logging to silent event emitter catch blocks (plan.changed, payment.failed) for traceability - billing.crons (dunningSweep/weeklyReset/extrasExpiration): move mongooseService.connect inside try block so connection errors reach the fatal handler; replace process.exit() in try with process.exitCode + trailing process.exit() so finally disconnect always runs - billing.dunningSweep: clarify doc comment: 14d = 7d grace + 7d blocked - tests: update 4 webhook test files to mock OrganizationRepository.setPlan instead of mongoose.model('Organization').findByIdAndUpdate --- .../services/billing.webhook.service.js | 18 +++++--- .../tests/billing.service.unit.tests.js | 43 ++++++++++++++---- .../billing.webhook.checkout.unit.tests.js | 15 ++++--- .../billing.webhook.integration.tests.js | 44 +++++++------------ ...billing.webhook.subscription.unit.tests.js | 15 ++++--- scripts/crons/billing.dunningSweep.js | 14 +++--- scripts/crons/billing.extrasExpiration.js | 9 ++-- scripts/crons/billing.weeklyReset.js | 9 ++-- 8 files changed, 98 insertions(+), 69 deletions(-) diff --git a/modules/billing/services/billing.webhook.service.js b/modules/billing/services/billing.webhook.service.js index 91b666968..4c69a8b6f 100644 --- a/modules/billing/services/billing.webhook.service.js +++ b/modules/billing/services/billing.webhook.service.js @@ -6,12 +6,11 @@ import mongoose from 'mongoose'; import config from '../../../config/index.js'; import SubscriptionRepository from '../repositories/billing.subscription.repository.js'; import ProcessedStripeEventRepository from '../repositories/billing.processedStripeEvent.repository.js'; +import OrganizationRepository from '../../organizations/repositories/organizations.repository.js'; import BillingExtraService from './billing.extra.service.js'; import BillingResetService from './billing.reset.service.js'; import billingEvents from '../lib/events.js'; -const Organization = mongoose.model('Organization'); - /** * Valid plan names from config (immutable set for O(1) lookups). */ @@ -44,14 +43,15 @@ const resolvePlan = (subscription) => { }; /** - * @description Sync the organization plan field to match the subscription plan + * @description Sync the organization plan field to match the subscription plan. + * Delegates to OrganizationRepository.setPlan to keep DB access in the repo layer. * @param {String} organizationId - Organization document ID * @param {String} plan - Plan name to set * @returns {Promise} */ const syncOrganizationPlan = async (organizationId, plan) => { if (!organizationId || !mongoose.Types.ObjectId.isValid(organizationId)) return; - await Organization.findByIdAndUpdate(organizationId, { plan }, { runValidators: true }).exec(); + await OrganizationRepository.setPlan(organizationId, plan); }; /** @@ -211,7 +211,10 @@ const handleSubscriptionUpdated = async (subscription, event) => { subscription, isDowngrade, }); - } catch { /* listener errors must not disrupt webhook processing */ } + } catch (evtErr) { + // Listener errors must not disrupt webhook processing — log for traceability + console.error('[billing.webhook] plan.changed listener error (non-fatal):', evtErr?.message ?? evtErr); + } } } @@ -278,7 +281,10 @@ const handleInvoicePaymentFailed = async (invoice) => { const organizationId = String(existing.organization?._id || existing.organization); try { billingEvents.emit('payment.failed', { organizationId }); - } catch { /* listener errors must not disrupt webhook processing */ } + } catch (evtErr) { + // Listener errors must not disrupt webhook processing — log for traceability + console.error('[billing.webhook] payment.failed listener error (non-fatal):', evtErr?.message ?? evtErr); + } }; /** diff --git a/modules/billing/tests/billing.service.unit.tests.js b/modules/billing/tests/billing.service.unit.tests.js index d20fb0b6b..f91fc23d7 100644 --- a/modules/billing/tests/billing.service.unit.tests.js +++ b/modules/billing/tests/billing.service.unit.tests.js @@ -9,7 +9,7 @@ import { jest, beforeEach, afterEach } from '@jest/globals'; describe('Billing webhook service unit tests:', () => { let BillingWebhookService; let mockSubscriptionRepository; - let mockOrganizationModel; + let mockOrganizationRepository; const orgId = '507f1f77bcf86cd799439011'; const subId = '507f1f77bcf86cd799439022'; @@ -25,17 +25,44 @@ describe('Billing webhook service unit tests:', () => { update: jest.fn(), }; + mockOrganizationRepository = { + setPlan: jest.fn().mockResolvedValue({}), + }; + jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({ default: mockSubscriptionRepository, })); - mockOrganizationModel = { - findByIdAndUpdate: jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue({}) }), - }; + jest.unstable_mockModule('../repositories/billing.processedStripeEvent.repository.js', () => ({ + default: { + wasProcessed: jest.fn().mockResolvedValue(false), + tryRecord: jest.fn().mockResolvedValue({ recorded: true }), + }, + })); + + jest.unstable_mockModule('../../organizations/repositories/organizations.repository.js', () => ({ + default: mockOrganizationRepository, + })); + + jest.unstable_mockModule('../services/billing.extra.service.js', () => ({ + default: { creditPack: jest.fn(), refundPartial: jest.fn() }, + })); + + jest.unstable_mockModule('../services/billing.reset.service.js', () => ({ + default: { resetWeek: jest.fn(), resetAllDue: jest.fn() }, + })); + + jest.unstable_mockModule('../lib/events.js', () => ({ + default: { emit: jest.fn() }, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { billing: { plans: ['free', 'starter', 'pro', 'enterprise'] } }, + })); jest.unstable_mockModule('mongoose', () => ({ default: { - model: jest.fn().mockReturnValue(mockOrganizationModel), + model: jest.fn().mockReturnValue({}), Types: { ObjectId: { isValid: jest.fn().mockReturnValue(true) } }, }, })); @@ -66,7 +93,7 @@ describe('Billing webhook service unit tests:', () => { plan: 'pro', status: 'active', }); - expect(mockOrganizationModel.findByIdAndUpdate).toHaveBeenCalledWith(orgId, { plan: 'pro' }, { runValidators: true }); + expect(mockOrganizationRepository.setPlan).toHaveBeenCalledWith(orgId, 'pro'); }); test('should update existing subscription on checkout', async () => { @@ -170,7 +197,7 @@ describe('Billing webhook service unit tests:', () => { currentPeriodEnd: new Date(1700000000 * 1000), cancelAtPeriodEnd: false, }); - expect(mockOrganizationModel.findByIdAndUpdate).toHaveBeenCalledWith(orgId, { plan: 'pro' }, { runValidators: true }); + expect(mockOrganizationRepository.setPlan).toHaveBeenCalledWith(orgId, 'pro'); }); test('should return early when subscription not found', async () => { @@ -243,7 +270,7 @@ describe('Billing webhook service unit tests:', () => { plan: 'free', status: 'canceled', }); - expect(mockOrganizationModel.findByIdAndUpdate).toHaveBeenCalledWith(orgId, { plan: 'free' }, { runValidators: true }); + expect(mockOrganizationRepository.setPlan).toHaveBeenCalledWith(orgId, 'free'); }); test('should return early when subscription not found', async () => { diff --git a/modules/billing/tests/billing.webhook.checkout.unit.tests.js b/modules/billing/tests/billing.webhook.checkout.unit.tests.js index 3ba735bbf..4e325b143 100644 --- a/modules/billing/tests/billing.webhook.checkout.unit.tests.js +++ b/modules/billing/tests/billing.webhook.checkout.unit.tests.js @@ -12,7 +12,7 @@ import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globa describe('Billing webhook checkout unit tests:', () => { let BillingWebhookService; let mockSubscriptionRepository; - let mockOrganizationModel; + let mockOrganizationRepository; let mockExtraService; const orgId = '507f1f77bcf86cd799439011'; @@ -30,8 +30,8 @@ describe('Billing webhook checkout unit tests:', () => { update: jest.fn(), }; - mockOrganizationModel = { - findByIdAndUpdate: jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue({}) }), + mockOrganizationRepository = { + setPlan: jest.fn().mockResolvedValue({}), }; mockExtraService = { @@ -50,6 +50,10 @@ describe('Billing webhook checkout unit tests:', () => { }, })); + jest.unstable_mockModule('../../organizations/repositories/organizations.repository.js', () => ({ + default: mockOrganizationRepository, + })); + jest.unstable_mockModule('../services/billing.extra.service.js', () => ({ default: mockExtraService, })); @@ -71,10 +75,7 @@ describe('Billing webhook checkout unit tests:', () => { jest.unstable_mockModule('mongoose', () => ({ default: { Types: { ObjectId: { isValid: (id) => /^[a-f\d]{24}$/i.test(id) } }, - model: (name) => { - if (name === 'Organization') return mockOrganizationModel; - return {}; - }, + model: () => ({}), }, })); diff --git a/modules/billing/tests/billing.webhook.integration.tests.js b/modules/billing/tests/billing.webhook.integration.tests.js index 37b09a384..93d74e46d 100644 --- a/modules/billing/tests/billing.webhook.integration.tests.js +++ b/modules/billing/tests/billing.webhook.integration.tests.js @@ -9,7 +9,7 @@ import { jest, beforeEach, afterEach } from '@jest/globals'; describe('Billing webhook integration tests:', () => { let WebhookService; let mockSubscriptionRepository; - let mockOrganizationModel; + let mockOrganizationRepository; const orgId = '507f1f77bcf86cd799439011'; const subId = '607f1f77bcf86cd799439022'; @@ -25,30 +25,24 @@ describe('Billing webhook integration tests:', () => { update: jest.fn(), }; - mockOrganizationModel = { - findByIdAndUpdate: jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue({}) }), + mockOrganizationRepository = { + setPlan: jest.fn().mockResolvedValue({}), }; jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({ default: mockSubscriptionRepository, })); - jest.unstable_mockModule('mongoose', () => { - const actualTypes = { - ObjectId: { - isValid: (id) => /^[a-f\d]{24}$/i.test(id), - }, - }; - return { - default: { - Types: actualTypes, - model: (name) => { - if (name === 'Organization') return mockOrganizationModel; - return {}; - }, - }, - }; - }); + jest.unstable_mockModule('../../organizations/repositories/organizations.repository.js', () => ({ + default: mockOrganizationRepository, + })); + + jest.unstable_mockModule('mongoose', () => ({ + default: { + Types: { ObjectId: { isValid: (id) => /^[a-f\d]{24}$/i.test(id) } }, + model: () => ({}), + }, + })); jest.unstable_mockModule('../../../config/index.js', () => ({ default: { @@ -85,9 +79,7 @@ describe('Billing webhook integration tests:', () => { expect(mockSubscriptionRepository.update).toHaveBeenCalledWith( expect.objectContaining({ _id: subId, plan: 'pro', status: 'active' }), ); - expect(mockOrganizationModel.findByIdAndUpdate).toHaveBeenCalledWith( - orgId, { plan: 'pro' }, { runValidators: true }, - ); + expect(mockOrganizationRepository.setPlan).toHaveBeenCalledWith(orgId, 'pro'); }); test('should create subscription when none exists', async () => { @@ -203,9 +195,7 @@ describe('Billing webhook integration tests:', () => { cancelAtPeriodEnd: true, }), ); - expect(mockOrganizationModel.findByIdAndUpdate).toHaveBeenCalledWith( - orgId, { plan: 'pro' }, { runValidators: true }, - ); + expect(mockOrganizationRepository.setPlan).toHaveBeenCalledWith(orgId, 'pro'); }); test('should return early when subscription not found', async () => { @@ -252,9 +242,7 @@ describe('Billing webhook integration tests:', () => { expect(mockSubscriptionRepository.update).toHaveBeenCalledWith( expect.objectContaining({ _id: subId, plan: 'free', status: 'canceled' }), ); - expect(mockOrganizationModel.findByIdAndUpdate).toHaveBeenCalledWith( - orgId, { plan: 'free' }, { runValidators: true }, - ); + expect(mockOrganizationRepository.setPlan).toHaveBeenCalledWith(orgId, 'free'); }); test('should return early when subscription not found', async () => { diff --git a/modules/billing/tests/billing.webhook.subscription.unit.tests.js b/modules/billing/tests/billing.webhook.subscription.unit.tests.js index 6cb745e25..5688a5eb1 100644 --- a/modules/billing/tests/billing.webhook.subscription.unit.tests.js +++ b/modules/billing/tests/billing.webhook.subscription.unit.tests.js @@ -12,7 +12,7 @@ import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globa describe('Billing webhook subscription unit tests:', () => { let BillingWebhookService; let mockSubscriptionRepository; - let mockOrganizationModel; + let mockOrganizationRepository; let mockResetService; let mockEvents; @@ -30,8 +30,8 @@ describe('Billing webhook subscription unit tests:', () => { update: jest.fn(), }; - mockOrganizationModel = { - findByIdAndUpdate: jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue({}) }), + mockOrganizationRepository = { + setPlan: jest.fn().mockResolvedValue({}), }; mockResetService = { @@ -51,6 +51,10 @@ describe('Billing webhook subscription unit tests:', () => { }, })); + jest.unstable_mockModule('../../organizations/repositories/organizations.repository.js', () => ({ + default: mockOrganizationRepository, + })); + jest.unstable_mockModule('../services/billing.extra.service.js', () => ({ default: { creditPack: jest.fn(), refundPartial: jest.fn() }, })); @@ -75,10 +79,7 @@ describe('Billing webhook subscription unit tests:', () => { jest.unstable_mockModule('mongoose', () => ({ default: { Types: { ObjectId: { isValid: (id) => /^[a-f\d]{24}$/i.test(id) } }, - model: (name) => { - if (name === 'Organization') return mockOrganizationModel; - return {}; - }, + model: () => ({}), }, })); diff --git a/scripts/crons/billing.dunningSweep.js b/scripts/crons/billing.dunningSweep.js index 1e39d5370..cf706a824 100644 --- a/scripts/crons/billing.dunningSweep.js +++ b/scripts/crons/billing.dunningSweep.js @@ -2,9 +2,12 @@ * Cron script — dunning sweep. * * Finds subscriptions in 'past_due' status whose pastDueSince is older than 14 days - * (i.e. the 7-day grace period has elapsed with no payment), transitions them to + * (7-day grace period + 7-day blocked period elapsed with no payment), transitions them to * 'unpaid' + plan 'free', and syncs the Organization.plan field accordingly. * + * Timeline: payment fails → pastDueSince set → 7d grace (degraded mode) → 7d blocked (402) → + * this cron fires on day 14+ and downgrades to free. + * * No-op when config.billing.meterMode === false (default). * Intended to run as a Kubernetes CronJob — see scripts/crons/README.md. * @@ -24,9 +27,9 @@ if (!config?.billing?.meterMode) { process.exit(0); } -await mongooseService.connect(); - try { + await mongooseService.connect(); + const [{ default: BillingSubscriptionRepository }, { default: OrganizationRepository }] = await Promise.all([ import('../../modules/billing/repositories/billing.subscription.repository.js'), import('../../modules/organizations/repositories/organizations.repository.js'), @@ -65,10 +68,11 @@ try { } console.log(`[billing.dunningSweep] done — processed: ${processed}, errors: ${errors}, desyncErrors: ${desyncErrors}`); - process.exit(errors > 0 ? 1 : 0); + process.exitCode = errors > 0 ? 1 : 0; } catch (err) { console.error('[billing.dunningSweep] fatal:', err); - process.exit(1); + process.exitCode = 1; } finally { await mongooseService.disconnect?.(); } +process.exit(process.exitCode ?? 0); diff --git a/scripts/crons/billing.extrasExpiration.js b/scripts/crons/billing.extrasExpiration.js index 764109dc1..712f5a9a5 100644 --- a/scripts/crons/billing.extrasExpiration.js +++ b/scripts/crons/billing.extrasExpiration.js @@ -24,9 +24,9 @@ if (!config?.billing?.meterMode) { process.exit(0); } -await mongooseService.connect(); - try { + await mongooseService.connect(); + const [{ default: BillingExtraService }, { default: BillingExtraBalanceRepository }] = await Promise.all([ import('../../modules/billing/services/billing.extra.service.js'), @@ -51,10 +51,11 @@ try { } console.log(`[billing.extrasExpiration] done — processed: ${processed}, errors: ${errors}`); - process.exit(errors > 0 ? 1 : 0); + process.exitCode = errors > 0 ? 1 : 0; } catch (err) { console.error('[billing.extrasExpiration] fatal:', err); - process.exit(1); + process.exitCode = 1; } finally { await mongooseService.disconnect?.(); } +process.exit(process.exitCode ?? 0); diff --git a/scripts/crons/billing.weeklyReset.js b/scripts/crons/billing.weeklyReset.js index a8a21e55b..164499d23 100644 --- a/scripts/crons/billing.weeklyReset.js +++ b/scripts/crons/billing.weeklyReset.js @@ -23,17 +23,18 @@ if (!config?.billing?.meterMode) { process.exit(0); } -await mongooseService.connect(); - try { + await mongooseService.connect(); + const { default: BillingResetService } = await import('../../modules/billing/services/billing.reset.service.js'); const result = await BillingResetService.resetAllDue(); console.log(`[billing.weeklyReset] done — processed: ${result.processed}, errors: ${result.errors}`); - process.exit(result.errors > 0 ? 1 : 0); + process.exitCode = result.errors > 0 ? 1 : 0; } catch (err) { console.error('[billing.weeklyReset] fatal:', err); - process.exit(1); + process.exitCode = 1; } finally { await mongooseService.disconnect?.(); } +process.exit(process.exitCode ?? 0);