Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions config/templates/billing-payment-failed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en"><head><title></title></head>
<body>
<p>Hello,</p>
<p>We were unable to process the latest payment for your <b>{{appName}}</b> subscription.</p>
<p>Your account remains active for now. Please update your payment method before the grace period ends to avoid any service interruption.</p>
<p>Update your card on your <a href="{{billingPortalUrl}}">billing portal</a>.</p>
<br />
<p>The <b>{{appName}}</b> Team.</p>
<br/>
<i style="color:#9b9b9b">Please do not reply to this email, you can contact us <a href="mailto:{{appContact}}">here</a>.</i>
</body></html>
12 changes: 12 additions & 0 deletions config/templates/billing-quota-reached-100.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en"><head><title></title></head>
<body>
<p>Hello,</p>
<p>Your <b>{{appName}}</b> weekly quota is now fully used ({{meterUsed}} / {{meterQuota}} units).</p>
<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>
<p>Check your consumption or add an extras pack on your <a href="{{billingUrl}}">billing dashboard</a>.</p>
<br />
<p>The <b>{{appName}}</b> Team.</p>
<br/>
<i style="color:#9b9b9b">Please do not reply to this email, you can contact us <a href="mailto:{{appContact}}">here</a>.</i>
</body></html>
12 changes: 12 additions & 0 deletions config/templates/billing-quota-warning-80.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en"><head><title></title></head>
<body>
<p>Hello,</p>
<p>Your <b>{{appName}}</b> plan has reached <b>{{threshold}}%</b> of its weekly quota ({{meterUsed}} / {{meterQuota}} units used).</p>
<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>
<p>Review your usage, add an extras pack, or upgrade your plan on your <a href="{{billingUrl}}">billing dashboard</a>.</p>
<br />
<p>The <b>{{appName}}</b> Team.</p>
<br/>
<i style="color:#9b9b9b">Please do not reply to this email, you can contact us <a href="mailto:{{appContact}}">here</a>.</i>
</body></html>
121 changes: 121 additions & 0 deletions modules/billing/billing.email.js
Original file line number Diff line number Diff line change
@@ -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<string[]>}
*/
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',
);
}
});
});
};
4 changes: 4 additions & 0 deletions modules/billing/billing.init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading