Skip to content

Commit 8393489

Browse files
feat(billing): extract email listeners + ship generic templates (#3746) (#3747)
- 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
1 parent 2ff4161 commit 8393489

8 files changed

Lines changed: 583 additions & 0 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html lang="en"><head><title></title></head>
3+
<body>
4+
<p>Hello,</p>
5+
<p>We were unable to process the latest payment for your <b>{{appName}}</b> subscription.</p>
6+
<p>Your account remains active for now. Please update your payment method before the grace period ends to avoid any service interruption.</p>
7+
<p>Update your card on your <a href="{{billingPortalUrl}}">billing portal</a>.</p>
8+
<br />
9+
<p>The <b>{{appName}}</b> Team.</p>
10+
<br/>
11+
<i style="color:#9b9b9b">Please do not reply to this email, you can contact us <a href="mailto:{{appContact}}">here</a>.</i>
12+
</body></html>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html lang="en"><head><title></title></head>
3+
<body>
4+
<p>Hello,</p>
5+
<p>Your <b>{{appName}}</b> weekly quota is now fully used ({{meterUsed}} / {{meterQuota}} units).</p>
6+
<p>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.</p>
7+
<p>Check your consumption or add an extras pack on your <a href="{{billingUrl}}">billing dashboard</a>.</p>
8+
<br />
9+
<p>The <b>{{appName}}</b> Team.</p>
10+
<br/>
11+
<i style="color:#9b9b9b">Please do not reply to this email, you can contact us <a href="mailto:{{appContact}}">here</a>.</i>
12+
</body></html>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html lang="en"><head><title></title></head>
3+
<body>
4+
<p>Hello,</p>
5+
<p>Your <b>{{appName}}</b> plan has reached <b>{{threshold}}%</b> of its weekly quota ({{meterUsed}} / {{meterQuota}} units used).</p>
6+
<p>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.</p>
7+
<p>Review your usage, add an extras pack, or upgrade your plan on your <a href="{{billingUrl}}">billing dashboard</a>.</p>
8+
<br />
9+
<p>The <b>{{appName}}</b> Team.</p>
10+
<br/>
11+
<i style="color:#9b9b9b">Please do not reply to this email, you can contact us <a href="mailto:{{appContact}}">here</a>.</i>
12+
</body></html>

modules/billing/billing.email.js

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import config from '../../config/index.js';
5+
import logger from '../../lib/services/logger.js';
6+
import billingEvents from './lib/events.js';
7+
import mailer from '../../lib/helpers/mailer/index.js';
8+
import MembershipRepository from '../organizations/repositories/organizations.membership.repository.js';
9+
import { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES } from '../organizations/lib/constants.js';
10+
11+
/**
12+
* Resolve owner/admin emails for an organization.
13+
* Returns an array of email strings (may be empty if none found or lookup fails).
14+
* @param {string} organizationId
15+
* @returns {Promise<string[]>}
16+
*/
17+
export const resolveOrgAdminEmails = async (organizationId) => {
18+
try {
19+
const memberships = await MembershipRepository.list({
20+
organizationId,
21+
role: { $in: [MEMBERSHIP_ROLES.OWNER, MEMBERSHIP_ROLES.ADMIN] },
22+
status: MEMBERSHIP_STATUSES.ACTIVE,
23+
});
24+
return memberships.map((m) => m.userId?.email).filter(Boolean);
25+
} catch (err) {
26+
logger.warn('[billing.email] resolveOrgAdminEmails failed (non-fatal)', {
27+
organizationId,
28+
error: err?.message ?? err,
29+
});
30+
return [];
31+
}
32+
};
33+
34+
/**
35+
* Fire-and-forget email send. Logs mailer errors without re-throwing.
36+
* @param {Object} mailOpts - Options passed directly to mailer.sendMail
37+
* @param {string} context - Log prefix for error messages
38+
*/
39+
export const sendBillingEmail = (mailOpts, context) => {
40+
if (!mailer.isConfigured()) return;
41+
mailer.sendMail(mailOpts).catch((err) => {
42+
logger.error(`[billing.email] ${context} email failed`, {
43+
error: err?.message ?? err,
44+
stack: err?.stack,
45+
});
46+
});
47+
};
48+
49+
/**
50+
* Wire billing email listeners onto billingEvents.
51+
* Call once from billing.init.js after config is ready.
52+
*
53+
* Listeners registered:
54+
* - meter.threshold_crossed — sends 80% warning or 100% quota-reached email to org admins/owners
55+
* - payment.failed — sends payment-failed email prompting card update
56+
*
57+
* Template resolution: devkit ships generic templates in config/templates/billing-*.html.
58+
* Downstream projects override by placing same-named files in their own config/templates/
59+
* directory — those shadow devkit defaults via the template-resolution glob-merge in config.
60+
*/
61+
export const setupBillingEmails = () => {
62+
// ── meter.threshold_crossed — 80% / 100% quota emails ──────────────────────
63+
64+
billingEvents.on('meter.threshold_crossed', ({ organizationId, threshold, meterUsed, meterQuota }) => {
65+
if (threshold !== 80 && threshold !== 100) return;
66+
67+
const appName = config.app?.title ?? '';
68+
const billingUrl = config.app?.url ? `${config.app.url}/billing` : '';
69+
70+
resolveOrgAdminEmails(organizationId).then((emails) => {
71+
if (!emails.length) return;
72+
const isAt80 = threshold === 80;
73+
for (const email of emails) {
74+
sendBillingEmail(
75+
{
76+
to: email,
77+
subject: isAt80
78+
? `Approaching your ${appName} quota — ${threshold}% used`
79+
: `${appName} weekly quota reached`,
80+
template: isAt80 ? 'billing-quota-warning-80' : 'billing-quota-reached-100',
81+
params: {
82+
threshold,
83+
meterUsed: meterUsed ?? '?',
84+
meterQuota: meterQuota ?? '?',
85+
billingUrl,
86+
appName,
87+
appContact: config.app?.contact ?? '',
88+
},
89+
},
90+
isAt80 ? 'meter.threshold_crossed@80' : 'meter.threshold_crossed@100',
91+
);
92+
}
93+
});
94+
});
95+
96+
// ── payment.failed — update card prompt ─────────────────────────────────────
97+
98+
billingEvents.on('payment.failed', ({ organizationId }) => {
99+
const appName = config.app?.title ?? '';
100+
const billingPortalUrl = config.app?.url ? `${config.app.url}/billing` : '';
101+
102+
resolveOrgAdminEmails(organizationId).then((emails) => {
103+
if (!emails.length) return;
104+
for (const email of emails) {
105+
sendBillingEmail(
106+
{
107+
to: email,
108+
subject: `${appName} billing — please update your payment method`,
109+
template: 'billing-payment-failed',
110+
params: {
111+
billingPortalUrl,
112+
appName,
113+
appContact: config.app?.contact ?? '',
114+
},
115+
},
116+
'payment.failed',
117+
);
118+
}
119+
});
120+
});
121+
};

modules/billing/billing.init.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import logger from '../../lib/services/logger.js';
88
import billingEvents from './lib/events.js';
99
import BillingUsageRepository from './repositories/billing.usage.repository.js';
1010
import { getAlertThresholdPercents } from './lib/billing.constants.js';
11+
import { setupBillingEmails } from './billing.email.js';
1112

1213
/**
1314
* Billing module initialisation.
@@ -40,6 +41,9 @@ export default async (app) => {
4041
}
4142
}
4243

44+
// Wire billing email listeners (quota warnings + payment-failed notifications).
45+
setupBillingEmails();
46+
4347
// Update analytics group properties when a subscription plan changes
4448
billingEvents.on('plan.changed', ({ organizationId, newPlan }) => {
4549
try {

0 commit comments

Comments
 (0)