From 2c3f1eda199add02fee881e5b41435d048c3ca1c Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 31 May 2026 20:49:03 +0200 Subject: [PATCH] feat(billing): extract email listeners + ship generic templates (#3746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `modules/billing/billing.email.js` exports `setupBillingEmails()` which wires `meter.threshold_crossed` (80%/100%) and `payment.failed` event listeners, plus `resolveOrgAdminEmails` and `sendBillingEmail` helpers - `billing.init.js` imports and calls `setupBillingEmails()` from the new file - Ship 3 generic HTML templates in `config/templates/billing-*.html` using `{{appName}}` placeholder (resolved from `config.app.title` at send-time); zero hardcoded branding — every downstream gets working billing emails out of the box - Downstream override: place same-named files in downstream's `config/templates/`; they shadow devkit defaults via template-resolution glob-merge - Port + de-trawlify 18 unit tests covering both listener paths (threshold + payment.failed) - Subjects are now config-driven (`${config.app.title} weekly quota reached`) so each downstream gets its own app name in email subjects Closes #3746 --- config/templates/billing-payment-failed.html | 12 + .../templates/billing-quota-reached-100.html | 12 + .../templates/billing-quota-warning-80.html | 12 + modules/billing/billing.email.js | 121 +++++ modules/billing/billing.init.js | 4 + .../billing.init.email-alerts.unit.tests.js | 412 ++++++++++++++++++ .../billing.init.ops-listeners.unit.tests.js | 5 + .../billing/tests/billing.init.unit.tests.js | 5 + 8 files changed, 583 insertions(+) create mode 100644 config/templates/billing-payment-failed.html create mode 100644 config/templates/billing-quota-reached-100.html create mode 100644 config/templates/billing-quota-warning-80.html create mode 100644 modules/billing/billing.email.js create mode 100644 modules/billing/tests/billing.init.email-alerts.unit.tests.js diff --git a/config/templates/billing-payment-failed.html b/config/templates/billing-payment-failed.html new file mode 100644 index 000000000..ea72734b1 --- /dev/null +++ b/config/templates/billing-payment-failed.html @@ -0,0 +1,12 @@ + + + +

Hello,

+

We were unable to process the latest payment for your {{appName}} subscription.

+

Your account remains active for now. Please update your payment method before the grace period ends to avoid any service interruption.

+

Update your card on your billing portal.

+
+

The {{appName}} Team.

+
+ Please do not reply to this email, you can contact us here. + diff --git a/config/templates/billing-quota-reached-100.html b/config/templates/billing-quota-reached-100.html new file mode 100644 index 000000000..39470a925 --- /dev/null +++ b/config/templates/billing-quota-reached-100.html @@ -0,0 +1,12 @@ + + + +

Hello,

+

Your {{appName}} weekly quota is now fully used ({{meterUsed}} / {{meterQuota}} units).

+

If you have an extras pack, your scheduled jobs keep running from its remaining balance until it's used up. Otherwise they pause until you add an extras pack or upgrade your plan — nothing is charged automatically.

+

Check your consumption or add an extras pack on your billing dashboard.

+
+

The {{appName}} Team.

+
+ Please do not reply to this email, you can contact us here. + diff --git a/config/templates/billing-quota-warning-80.html b/config/templates/billing-quota-warning-80.html new file mode 100644 index 000000000..842c53e15 --- /dev/null +++ b/config/templates/billing-quota-warning-80.html @@ -0,0 +1,12 @@ + + + +

Hello,

+

Your {{appName}} plan has reached {{threshold}}% of its weekly quota ({{meterUsed}} / {{meterQuota}} units used).

+

You still have room to keep going. Once you reach 100%, runs continue only while you have an extras-pack balance; without one they pause until you add an extras pack or upgrade your plan — nothing is charged automatically.

+

Review your usage, add an extras pack, or upgrade your plan on your billing dashboard.

+
+

The {{appName}} Team.

+
+ Please do not reply to this email, you can contact us here. + diff --git a/modules/billing/billing.email.js b/modules/billing/billing.email.js new file mode 100644 index 000000000..17b0ccec5 --- /dev/null +++ b/modules/billing/billing.email.js @@ -0,0 +1,121 @@ +/** + * Module dependencies + */ +import config from '../../config/index.js'; +import logger from '../../lib/services/logger.js'; +import billingEvents from './lib/events.js'; +import mailer from '../../lib/helpers/mailer/index.js'; +import MembershipRepository from '../organizations/repositories/organizations.membership.repository.js'; +import { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES } from '../organizations/lib/constants.js'; + +/** + * Resolve owner/admin emails for an organization. + * Returns an array of email strings (may be empty if none found or lookup fails). + * @param {string} organizationId + * @returns {Promise} + */ +export const resolveOrgAdminEmails = async (organizationId) => { + try { + const memberships = await MembershipRepository.list({ + organizationId, + role: { $in: [MEMBERSHIP_ROLES.OWNER, MEMBERSHIP_ROLES.ADMIN] }, + status: MEMBERSHIP_STATUSES.ACTIVE, + }); + return memberships.map((m) => m.userId?.email).filter(Boolean); + } catch (err) { + logger.warn('[billing.email] resolveOrgAdminEmails failed (non-fatal)', { + organizationId, + error: err?.message ?? err, + }); + return []; + } +}; + +/** + * Fire-and-forget email send. Logs mailer errors without re-throwing. + * @param {Object} mailOpts - Options passed directly to mailer.sendMail + * @param {string} context - Log prefix for error messages + */ +export const sendBillingEmail = (mailOpts, context) => { + if (!mailer.isConfigured()) return; + mailer.sendMail(mailOpts).catch((err) => { + logger.error(`[billing.email] ${context} email failed`, { + error: err?.message ?? err, + stack: err?.stack, + }); + }); +}; + +/** + * Wire billing email listeners onto billingEvents. + * Call once from billing.init.js after config is ready. + * + * Listeners registered: + * - meter.threshold_crossed — sends 80% warning or 100% quota-reached email to org admins/owners + * - payment.failed — sends payment-failed email prompting card update + * + * Template resolution: devkit ships generic templates in config/templates/billing-*.html. + * Downstream projects override by placing same-named files in their own config/templates/ + * directory — those shadow devkit defaults via the template-resolution glob-merge in config. + */ +export const setupBillingEmails = () => { + // ── meter.threshold_crossed — 80% / 100% quota emails ────────────────────── + + billingEvents.on('meter.threshold_crossed', ({ organizationId, threshold, meterUsed, meterQuota }) => { + if (threshold !== 80 && threshold !== 100) return; + + const appName = config.app?.title ?? ''; + const billingUrl = config.app?.url ? `${config.app.url}/billing` : ''; + + resolveOrgAdminEmails(organizationId).then((emails) => { + if (!emails.length) return; + const isAt80 = threshold === 80; + for (const email of emails) { + sendBillingEmail( + { + to: email, + subject: isAt80 + ? `Approaching your ${appName} quota — ${threshold}% used` + : `${appName} weekly quota reached`, + template: isAt80 ? 'billing-quota-warning-80' : 'billing-quota-reached-100', + params: { + threshold, + meterUsed: meterUsed ?? '?', + meterQuota: meterQuota ?? '?', + billingUrl, + appName, + appContact: config.app?.contact ?? '', + }, + }, + isAt80 ? 'meter.threshold_crossed@80' : 'meter.threshold_crossed@100', + ); + } + }); + }); + + // ── payment.failed — update card prompt ───────────────────────────────────── + + billingEvents.on('payment.failed', ({ organizationId }) => { + const appName = config.app?.title ?? ''; + const billingPortalUrl = config.app?.url ? `${config.app.url}/billing` : ''; + + resolveOrgAdminEmails(organizationId).then((emails) => { + if (!emails.length) return; + for (const email of emails) { + sendBillingEmail( + { + to: email, + subject: `${appName} billing — please update your payment method`, + template: 'billing-payment-failed', + params: { + billingPortalUrl, + appName, + appContact: config.app?.contact ?? '', + }, + }, + 'payment.failed', + ); + } + }); + }); +}; diff --git a/modules/billing/billing.init.js b/modules/billing/billing.init.js index 96744476b..7efab419b 100644 --- a/modules/billing/billing.init.js +++ b/modules/billing/billing.init.js @@ -8,6 +8,7 @@ import logger from '../../lib/services/logger.js'; import billingEvents from './lib/events.js'; import BillingUsageRepository from './repositories/billing.usage.repository.js'; import { getAlertThresholdPercents } from './lib/billing.constants.js'; +import { setupBillingEmails } from './billing.email.js'; /** * Billing module initialisation. @@ -40,6 +41,9 @@ export default async (app) => { } } + // Wire billing email listeners (quota warnings + payment-failed notifications). + setupBillingEmails(); + // Update analytics group properties when a subscription plan changes billingEvents.on('plan.changed', ({ organizationId, newPlan }) => { try { diff --git a/modules/billing/tests/billing.init.email-alerts.unit.tests.js b/modules/billing/tests/billing.init.email-alerts.unit.tests.js new file mode 100644 index 000000000..0bdb3a7e9 --- /dev/null +++ b/modules/billing/tests/billing.init.email-alerts.unit.tests.js @@ -0,0 +1,412 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for billing.email — setupBillingEmails() listener wiring. + * Covers: meter.threshold_crossed (80 & 100) and payment.failed. + */ +describe('billing.email setupBillingEmails listeners:', () => { + let setupBillingEmails; + let mockMailer; + let mockMembershipRepository; + let mockBillingEvents; + let mockConfig; + let mockLogger; + + const orgId = '507f1f77bcf86cd799439011'; + + // Captured listener callbacks per event name + const listeners = {}; + + beforeEach(async () => { + jest.resetModules(); + + mockConfig = { + app: { + title: 'MyApp', + url: 'https://myapp.example.com', + contact: 'support@myapp.example.com', + }, + billing: { + meterMode: false, + packs: [], + }, + }; + + mockMailer = { + isConfigured: jest.fn().mockReturnValue(true), + sendMail: jest.fn().mockResolvedValue({ accepted: ['owner@test.com'], rejected: [] }), + }; + + mockMembershipRepository = { + list: jest.fn().mockResolvedValue([ + { userId: { email: 'owner@test.com' } }, + ]), + }; + + mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + // Capture registered listeners so we can invoke them directly + mockBillingEvents = { + on: jest.fn((event, handler) => { + listeners[event] = handler; + }), + }; + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: mockConfig, + })); + + jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: mockMailer, + })); + + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: mockLogger, + })); + + jest.unstable_mockModule('../lib/events.js', () => ({ + default: mockBillingEvents, + })); + + // Relative path matches the import in billing.email.js + jest.unstable_mockModule('../../organizations/repositories/organizations.membership.repository.js', () => ({ + default: mockMembershipRepository, + })); + + jest.unstable_mockModule('../../organizations/lib/constants.js', () => ({ + MEMBERSHIP_ROLES: { OWNER: 'owner', ADMIN: 'admin', MEMBER: 'member' }, + MEMBERSHIP_STATUSES: { ACTIVE: 'active', PENDING: 'pending' }, + })); + + const mod = await import('../billing.email.js'); + setupBillingEmails = mod.setupBillingEmails; + + // Register listeners + setupBillingEmails(); + }); + + afterEach(() => { + // Clear captured listeners + for (const key of Object.keys(listeners)) delete listeners[key]; + jest.restoreAllMocks(); + }); + + // ── meter.threshold_crossed ─────────────────────────────────────────────── + + describe('meter.threshold_crossed listener', () => { + test('sends 80% warning email when threshold=80 and mailer configured', async () => { + expect(typeof listeners['meter.threshold_crossed']).toBe('function'); + listeners['meter.threshold_crossed']({ + organizationId: orgId, + threshold: 80, + meterUsed: 800, + meterQuota: 1000, + weekKey: '2026-W18', + }); + + // Promise resolution: allow microtasks to settle + await new Promise((r) => setImmediate(r)); + + expect(mockMembershipRepository.list).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: orgId }), + ); + expect(mockMailer.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'owner@test.com', + subject: expect.stringContaining('80%'), + template: 'billing-quota-warning-80', + }), + ); + }); + + test('sends 100% quota reached email when threshold=100', async () => { + listeners['meter.threshold_crossed']({ + organizationId: orgId, + threshold: 100, + meterUsed: 1000, + meterQuota: 1000, + weekKey: '2026-W18', + }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'owner@test.com', + subject: expect.stringContaining('weekly quota reached'), + template: 'billing-quota-reached-100', + }), + ); + }); + + test('subject includes appName from config.app.title', async () => { + listeners['meter.threshold_crossed']({ + organizationId: orgId, + threshold: 100, + meterUsed: 1000, + meterQuota: 1000, + weekKey: '2026-W18', + }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: expect.stringContaining('MyApp'), + }), + ); + }); + + test('skips email when threshold is neither 80 nor 100', async () => { + listeners['meter.threshold_crossed']({ + organizationId: orgId, + threshold: 60, + meterUsed: 600, + meterQuota: 1000, + weekKey: '2026-W18', + }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + }); + + test('skips email when mailer is not configured', async () => { + mockMailer.isConfigured.mockReturnValue(false); + + listeners['meter.threshold_crossed']({ + organizationId: orgId, + threshold: 80, + meterUsed: 800, + meterQuota: 1000, + weekKey: '2026-W18', + }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + }); + + test('skips email when no owner/admin emails found', async () => { + mockMembershipRepository.list.mockResolvedValue([]); + + listeners['meter.threshold_crossed']({ + organizationId: orgId, + threshold: 80, + meterUsed: 800, + meterQuota: 1000, + weekKey: '2026-W18', + }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + }); + + test('logs warn and skips email when membership lookup fails', async () => { + mockMembershipRepository.list.mockRejectedValue(new Error('DB error')); + + // Should not throw + expect(() => + listeners['meter.threshold_crossed']({ + organizationId: orgId, + threshold: 80, + meterUsed: 800, + meterQuota: 1000, + weekKey: '2026-W18', + }), + ).not.toThrow(); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + // resolveOrgAdminEmails catches internally and logs warn + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('resolveOrgAdminEmails failed'), + expect.objectContaining({ organizationId: orgId }), + ); + }); + + test('logs error but does not throw when sendMail rejects', async () => { + mockMailer.sendMail.mockRejectedValue(new Error('SMTP error')); + + listeners['meter.threshold_crossed']({ + organizationId: orgId, + threshold: 80, + meterUsed: 800, + meterQuota: 1000, + weekKey: '2026-W18', + }); + + await new Promise((r) => setImmediate(r)); + + // Give the Promise microtask queue one more tick for the sendMail rejection + await new Promise((r) => setImmediate(r)); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('email failed'), + expect.objectContaining({ error: 'SMTP error' }), + ); + }); + + test('sends email to multiple owner/admin addresses', async () => { + mockMembershipRepository.list.mockResolvedValue([ + { userId: { email: 'owner@test.com' } }, + { userId: { email: 'admin@test.com' } }, + ]); + + listeners['meter.threshold_crossed']({ + organizationId: orgId, + threshold: 80, + meterUsed: 800, + meterQuota: 1000, + weekKey: '2026-W18', + }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).toHaveBeenCalledTimes(2); + }); + + test('skips memberships with missing email', async () => { + mockMembershipRepository.list.mockResolvedValue([ + { userId: null }, + { userId: { email: null } }, + { userId: { email: 'valid@test.com' } }, + ]); + + listeners['meter.threshold_crossed']({ + organizationId: orgId, + threshold: 80, + meterUsed: 800, + meterQuota: 1000, + weekKey: '2026-W18', + }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).toHaveBeenCalledTimes(1); + expect(mockMailer.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ to: 'valid@test.com' }), + ); + }); + }); + + // ── payment.failed ──────────────────────────────────────────────────────── + + describe('payment.failed listener', () => { + test('sends payment failed email when mailer configured', async () => { + expect(typeof listeners['payment.failed']).toBe('function'); + listeners['payment.failed']({ organizationId: orgId }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMembershipRepository.list).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: orgId }), + ); + expect(mockMailer.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'owner@test.com', + subject: expect.stringContaining('billing'), + template: 'billing-payment-failed', + }), + ); + }); + + test('subject includes appName from config.app.title', async () => { + listeners['payment.failed']({ organizationId: orgId }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: expect.stringContaining('MyApp'), + }), + ); + }); + + test('skips email when mailer is not configured', async () => { + mockMailer.isConfigured.mockReturnValue(false); + + listeners['payment.failed']({ organizationId: orgId }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + }); + + test('skips email when no owner/admin emails found', async () => { + mockMembershipRepository.list.mockResolvedValue([]); + + listeners['payment.failed']({ organizationId: orgId }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + }); + + test('logs warn and skips email when membership lookup fails', async () => { + mockMembershipRepository.list.mockRejectedValue(new Error('DB error')); + + expect(() => listeners['payment.failed']({ organizationId: orgId })).not.toThrow(); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).not.toHaveBeenCalled(); + // resolveOrgAdminEmails catches internally and logs warn + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('resolveOrgAdminEmails failed'), + expect.objectContaining({ organizationId: orgId }), + ); + }); + + test('logs error but does not throw when sendMail rejects', async () => { + mockMailer.sendMail.mockRejectedValue(new Error('SMTP error')); + + listeners['payment.failed']({ organizationId: orgId }); + + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('email failed'), + expect.objectContaining({ error: 'SMTP error' }), + ); + }); + + test('includes billingPortalUrl built from config.app.url', async () => { + listeners['payment.failed']({ organizationId: orgId }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + billingPortalUrl: 'https://myapp.example.com/billing', + }), + }), + ); + }); + + test('sets billingPortalUrl to empty string when config.app.url is missing', async () => { + mockConfig.app = {}; + + listeners['payment.failed']({ organizationId: orgId }); + + await new Promise((r) => setImmediate(r)); + + expect(mockMailer.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ billingPortalUrl: '' }), + }), + ); + }); + }); +}); diff --git a/modules/billing/tests/billing.init.ops-listeners.unit.tests.js b/modules/billing/tests/billing.init.ops-listeners.unit.tests.js index ee2dfa4bf..25b33aeb6 100644 --- a/modules/billing/tests/billing.init.ops-listeners.unit.tests.js +++ b/modules/billing/tests/billing.init.ops-listeners.unit.tests.js @@ -67,6 +67,11 @@ describe('billing.init ops-listeners unit tests:', () => { }, })); + // Stub billing.email so ops-listener tests don't wire email listeners + jest.unstable_mockModule('../billing.email.js', () => ({ + setupBillingEmails: jest.fn(), + })); + // Import billing.init first — it registers listeners on the real billingEvents singleton. const mod = await import('../billing.init.js'); billingInit = mod.default; diff --git a/modules/billing/tests/billing.init.unit.tests.js b/modules/billing/tests/billing.init.unit.tests.js index 019bdf1d0..82cb79faf 100644 --- a/modules/billing/tests/billing.init.unit.tests.js +++ b/modules/billing/tests/billing.init.unit.tests.js @@ -58,6 +58,11 @@ describe('billing.init unit tests:', () => { default: { on: jest.fn(), emit: jest.fn() }, })); + // Stub billing.email so boot validator tests don't wire real email listeners + jest.unstable_mockModule('../billing.email.js', () => ({ + setupBillingEmails: jest.fn(), + })); + jest.unstable_mockModule('mongoose', () => ({ default: mockMongoose, }));