feat: add proration invoice and reminder email templates (#27246)

* feat: add proration invoice and reminder email templates

Add email templates for monthly proration billing notifications:

- ProrationInvoiceEmail: Sent when invoice is created for additional seats
- ProrationReminderEmail: Sent 7 days later if invoice remains unpaid

Includes:
- React email templates using V2BaseEmailHtml
- BaseEmail classes for rendering
- Billing email service functions
- Translation keys for email content

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use allSettled

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
sean-brydon
2026-01-27 09:28:27 +00:00
committed by GitHub
co-authored by Claude Opus 4.5
parent bf39489899
commit cb76e8bde0
7 changed files with 522 additions and 38 deletions
@@ -3791,6 +3791,18 @@
"cal_ai_credit_limit_reached_message": "Your Cal.com team {{teamName}} has run out of credits. As a result, Cal.ai phone calls are now disabled. To resume using Cal.ai phone calls, please purchase additional credits.",
"cal_ai_credit_limit_reached_message_user": "Your Cal.com account has run out of credits. As a result, Cal.ai phone calls are now disabled. To resume using Cal.ai phone calls, please purchase additional credits.",
"current_credit_balance": "Current balance: {{balance}} credits",
"proration_invoice_subject": "Invoice for additional seats - {{teamName}} (${{amount}})",
"proration_invoice_auto_charge_message": "An invoice for ${{amount}} has been created for {{seats}} additional seat(s) added to your team {{teamName}} during {{month}}. This amount will be automatically charged to your default payment method.",
"proration_invoice_manual_payment_message": "An invoice for ${{amount}} has been created for {{seats}} additional seat(s) added to your team {{teamName}} during {{month}}. Please pay this invoice to complete the billing for your additional seats.",
"proration_description": "Description",
"proration_line_item": "{{seats}} additional seat(s) - {{month}}",
"amount": "Amount",
"pay_invoice": "Pay Invoice",
"pay_invoice_now": "Pay Invoice Now",
"view_billing_settings": "View Billing Settings",
"proration_reminder_subject": "Payment Reminder - {{teamName}}",
"proration_reminder_message": "This is a reminder that your invoice of ${{amount}} for team {{teamName}} is still unpaid.",
"proration_reminder_warning": "If this invoice remains unpaid, you will not be able to add new users to your team until payment is received.",
"current_balance": "Current balance:",
"notification_about_your_booking": "Notification about your booking",
"monthly_credits": "Monthly credits",
+106 -7
View File
@@ -1,14 +1,14 @@
import type { TFunction } from "i18next";
import type BaseEmail from "@calcom/emails/templates/_base-email";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { CreditUsageType } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import OrganizerPaymentRefundFailedEmail from "./templates/organizer-payment-refund-failed-email";
import NoShowFeeChargedEmail from "./templates/no-show-fee-charged-email";
import CreditBalanceLowWarningEmail from "./templates/credit-balance-low-warning-email";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { TFunction } from "i18next";
import CreditBalanceLimitReachedEmail from "./templates/credit-balance-limit-reached-email";
import CreditBalanceLowWarningEmail from "./templates/credit-balance-low-warning-email";
import NoShowFeeChargedEmail from "./templates/no-show-fee-charged-email";
import OrganizerPaymentRefundFailedEmail from "./templates/organizer-payment-refund-failed-email";
import ProrationInvoiceEmail from "./templates/proration-invoice-email";
import ProrationReminderEmail from "./templates/proration-reminder-email";
const sendEmail = (prepare: () => BaseEmail) => {
return new Promise((resolve, reject) => {
@@ -127,3 +127,102 @@ export const sendCreditBalanceLimitReachedEmails = async ({
await sendEmail(() => new CreditBalanceLimitReachedEmail({ user, creditFor }));
}
};
export const sendProrationInvoiceEmails = async ({
team,
proration,
invoiceUrl,
isAutoCharge,
adminAndOwners,
}: {
team: {
id: number;
name: string | null;
};
proration: {
monthKey: string;
netSeatIncrease: number;
proratedAmount: number;
};
invoiceUrl?: string | null;
isAutoCharge: boolean;
adminAndOwners: {
id: number;
name: string | null;
email: string;
t: TFunction;
}[];
}) => {
if (!adminAndOwners.length) return;
const emailsToSend: Promise<unknown>[] = [];
for (const admin of adminAndOwners) {
emailsToSend.push(
sendEmail(
() =>
new ProrationInvoiceEmail({
user: admin,
team,
proration,
invoiceUrl,
isAutoCharge,
})
)
);
}
const results = await Promise.allSettled(emailsToSend);
const failures = results.filter((r) => r.status === "rejected");
if (failures.length > 0) {
console.error(`${failures.length} email(s) failed to send`, failures);
}
};
export const sendProrationReminderEmails = async ({
team,
proration,
invoiceUrl,
adminAndOwners,
}: {
team: {
id: number;
name: string | null;
};
proration: {
monthKey: string;
netSeatIncrease: number;
proratedAmount: number;
};
invoiceUrl?: string | null;
adminAndOwners: {
id: number;
name: string | null;
email: string;
t: TFunction;
}[];
}) => {
if (!adminAndOwners.length) return;
const emailsToSend: Promise<unknown>[] = [];
for (const admin of adminAndOwners) {
emailsToSend.push(
sendEmail(
() =>
new ProrationReminderEmail({
user: admin,
team,
proration,
invoiceUrl,
})
)
);
}
const results = await Promise.allSettled(emailsToSend);
const failures = results.filter((r) => r.status === "rejected");
if (failures.length > 0) {
console.error(`${failures.length} email(s) failed to send`, failures);
}
};
@@ -0,0 +1,111 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { TFunction } from "i18next";
import { CallToAction, V2BaseEmailHtml } from "../components";
export interface ProrationInvoiceEmailProps {
user: {
name: string;
email: string;
t: TFunction;
};
team: {
id: number;
name: string;
};
proration: {
monthKey: string;
netSeatIncrease: number;
proratedAmount: number;
};
invoiceUrl?: string | null;
isAutoCharge: boolean;
}
export const ProrationInvoiceEmail = (props: ProrationInvoiceEmailProps) => {
const { user, team, proration, invoiceUrl, isAutoCharge } = props;
const { t } = user;
const formattedAmount = (proration.proratedAmount / 100).toFixed(2);
return (
<V2BaseEmailHtml
subject={t("proration_invoice_subject", {
teamName: team.name,
amount: formattedAmount,
interpolation: { escapeValue: false },
})}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
{t("hi_user_name", { name: user.name, interpolation: { escapeValue: false } })},
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "24px" }}>
{isAutoCharge
? t("proration_invoice_auto_charge_message", {
teamName: team.name,
seats: proration.netSeatIncrease,
amount: formattedAmount,
month: proration.monthKey,
interpolation: { escapeValue: false },
})
: t("proration_invoice_manual_payment_message", {
teamName: team.name,
seats: proration.netSeatIncrease,
amount: formattedAmount,
month: proration.monthKey,
interpolation: { escapeValue: false },
})}
</p>
{/* Invoice Details Table */}
<div style={{ marginBottom: "24px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: "1px solid #E5E7EB",
padding: "12px 0",
}}>
<span style={{ fontWeight: 500, color: "#374151" }}>{t("proration_description")}</span>
<span style={{ fontWeight: 500, color: "#374151" }}>{t("amount")}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: "1px solid #E5E7EB",
padding: "12px 0",
}}>
<span style={{ color: "#6B7280" }}>
{t("proration_line_item", {
seats: proration.netSeatIncrease,
month: proration.monthKey,
})}
</span>
<span style={{ color: "#6B7280" }}>${formattedAmount}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
padding: "12px 0",
}}>
<span style={{ fontWeight: 600, color: "#111827" }}>{t("total")}</span>
<span style={{ fontWeight: 600, color: "#111827" }}>${formattedAmount}</span>
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}>
<a
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
style={{ color: "#6B7280", fontSize: "14px", textDecoration: "none" }}>
{t("view_billing_settings")}
</a>
{!isAutoCharge && invoiceUrl && <CallToAction label={t("pay_invoice")} href={invoiceUrl} />}
</div>
</V2BaseEmailHtml>
);
};
@@ -0,0 +1,113 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { TFunction } from "i18next";
import { CallToAction, V2BaseEmailHtml } from "../components";
export interface ProrationReminderEmailProps {
user: {
name: string;
email: string;
t: TFunction;
};
team: {
id: number;
name: string;
};
proration: {
monthKey: string;
netSeatIncrease: number;
proratedAmount: number;
};
invoiceUrl?: string | null;
}
export const ProrationReminderEmail = (props: ProrationReminderEmailProps) => {
const { user, team, proration, invoiceUrl } = props;
const { t } = user;
const formattedAmount = (proration.proratedAmount / 100).toFixed(2);
return (
<V2BaseEmailHtml
subject={t("proration_reminder_subject", {
teamName: team.name,
interpolation: { escapeValue: false },
})}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
{t("hi_user_name", { name: user.name, interpolation: { escapeValue: false } })},
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "24px" }}>
{t("proration_reminder_message", {
teamName: team.name,
amount: formattedAmount,
interpolation: { escapeValue: false },
})}
</p>
{/* Warning Banner */}
<p
style={{
backgroundColor: "#FEF3C7",
borderLeft: "4px solid #F59E0B",
padding: "12px 16px",
marginBottom: "24px",
color: "#92400E",
fontWeight: 500,
lineHeight: "20px",
}}>
{t("proration_reminder_warning")}
</p>
{/* Invoice Details Table */}
<div style={{ marginBottom: "24px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: "1px solid #E5E7EB",
padding: "12px 0",
}}>
<span style={{ fontWeight: 500, color: "#374151" }}>{t("proration_description")}</span>
<span style={{ fontWeight: 500, color: "#374151" }}>{t("amount")}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: "1px solid #E5E7EB",
padding: "12px 0",
}}>
<span style={{ color: "#6B7280" }}>
{t("proration_line_item", {
seats: proration.netSeatIncrease,
month: proration.monthKey,
})}
</span>
<span style={{ color: "#6B7280" }}>${formattedAmount}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
padding: "12px 0",
}}>
<span style={{ fontWeight: 600, color: "#111827" }}>{t("total")}</span>
<span style={{ fontWeight: 600, color: "#111827" }}>${formattedAmount}</span>
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}>
<a
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
style={{ color: "#6B7280", fontSize: "14px", textDecoration: "none" }}>
{t("view_billing_settings")}
</a>
{invoiceUrl && <CallToAction label={t("pay_invoice_now")} href={invoiceUrl} />}
</div>
</V2BaseEmailHtml>
);
};
+33 -31
View File
@@ -1,48 +1,50 @@
export * from "@calcom/app-store/routing-forms/emails/components";
export { AdminOAuthClientNotificationEmail } from "./AdminOAuthClientNotificationEmail";
export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail";
export { AttendeeAddGuestsEmail } from "./AttendeeAddGuestsEmail";
export { AttendeeAwaitingPaymentEmail } from "./AttendeeAwaitingPaymentEmail";
export { AttendeeCancelledEmail } from "./AttendeeCancelledEmail";
export { AttendeeCancelledSeatEmail } from "./AttendeeCancelledSeatEmail";
export { AttendeeDeclinedEmail } from "./AttendeeDeclinedEmail";
export { AttendeeLocationChangeEmail } from "./AttendeeLocationChangeEmail";
export { AttendeeRequestEmail } from "./AttendeeRequestEmail";
export { AttendeeWasRequestedToRescheduleEmail } from "./AttendeeWasRequestedToRescheduleEmail";
export { AttendeeRescheduledEmail } from "./AttendeeRescheduledEmail";
export { AttendeeUpdatedEmail } from "./AttendeeUpdatedEmail";
export { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
export { DisabledAppEmail } from "./DisabledAppEmail";
export { SlugReplacementEmail } from "./SlugReplacementEmail";
export { FeedbackEmail } from "./FeedbackEmail";
export { ForgotPasswordEmail } from "./ForgotPasswordEmail";
export { OrganizerCancelledEmail } from "./OrganizerCancelledEmail";
export { OrganizerReassignedEmail } from "./OrganizerReassignedEmail";
export { OrganizerLocationChangeEmail } from "./OrganizerLocationChangeEmail";
export { OrganizerPaymentRefundFailedEmail } from "./OrganizerPaymentRefundFailedEmail";
export { OrganizerRequestEmail } from "./OrganizerRequestEmail";
export { OrganizerRequestEmailV2 } from "./OrganizerRequestEmailV2";
export { OrganizerRequestReminderEmail } from "./OrganizerRequestReminderEmail";
export { OrganizerRequestedToRescheduleEmail } from "./OrganizerRequestedToRescheduleEmail";
export { OrganizerRescheduledEmail } from "./OrganizerRescheduledEmail";
export { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export { TeamInviteEmail } from "./TeamInviteEmail";
export { AttendeeUpdatedEmail } from "./AttendeeUpdatedEmail";
export { AttendeeWasRequestedToRescheduleEmail } from "./AttendeeWasRequestedToRescheduleEmail";
export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail";
export { CreditBalanceLowWarningEmail } from "./CreditBalanceLowWarningEmail";
export { CreditBalanceLimitReachedEmail } from "./CreditBalanceLimitReachedEmail";
export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail";
export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail";
export { VerifyAccountEmail } from "./VerifyAccountEmail";
export { VerifyEmailByCode } from "./VerifyEmailByCode";
export * from "@calcom/app-store/routing-forms/emails/components";
export { CreditBalanceLowWarningEmail } from "./CreditBalanceLowWarningEmail";
export { DailyVideoDownloadRecordingEmail } from "./DailyVideoDownloadRecordingEmail";
export { DailyVideoDownloadTranscriptEmail } from "./DailyVideoDownloadTranscriptEmail";
export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail";
export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail";
export { DisabledAppEmail } from "./DisabledAppEmail";
export { FeedbackEmail } from "./FeedbackEmail";
export { ForgotPasswordEmail } from "./ForgotPasswordEmail";
export { MonthlyDigestEmail } from "./MonthlyDigestEmail";
export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail";
export { AdminOAuthClientNotificationEmail } from "./AdminOAuthClientNotificationEmail";
export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail";
export { OAuthClientApprovedNotificationEmail } from "./OAuthClientApprovedNotificationEmail";
export { OAuthClientRejectedNotificationEmail } from "./OAuthClientRejectedNotificationEmail";
export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail";
export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail";
export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail";
export { OrganizationAdminNoSlotsEmail } from "./OrganizationAdminNoSlots";
export { OrganizationCreationEmail } from "./OrganizationCreationEmail";
export { OrganizerAddGuestsEmail } from "./OrganizerAddGuestsEmail";
export { AttendeeAddGuestsEmail } from "./AttendeeAddGuestsEmail";
export { OrganizationAdminNoSlotsEmail } from "./OrganizationAdminNoSlots";
export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail";
export { OrganizerCancelledEmail } from "./OrganizerCancelledEmail";
export { OrganizerLocationChangeEmail } from "./OrganizerLocationChangeEmail";
export { OrganizerPaymentRefundFailedEmail } from "./OrganizerPaymentRefundFailedEmail";
export { OrganizerReassignedEmail } from "./OrganizerReassignedEmail";
export { OrganizerRequestEmail } from "./OrganizerRequestEmail";
export { OrganizerRequestEmailV2 } from "./OrganizerRequestEmailV2";
export { OrganizerRequestedToRescheduleEmail } from "./OrganizerRequestedToRescheduleEmail";
export { OrganizerRequestReminderEmail } from "./OrganizerRequestReminderEmail";
export { OrganizerRescheduledEmail } from "./OrganizerRescheduledEmail";
export { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export { ProrationInvoiceEmail } from "./ProrationInvoiceEmail";
export { ProrationReminderEmail } from "./ProrationReminderEmail";
export { SlugReplacementEmail } from "./SlugReplacementEmail";
export { TeamInviteEmail } from "./TeamInviteEmail";
export { VerifyAccountEmail } from "./VerifyAccountEmail";
export { VerifyEmailByCode } from "./VerifyEmailByCode";
export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail";
@@ -0,0 +1,77 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import type { TFunction } from "i18next";
import renderEmail from "../src/renderEmail";
import BaseEmail from "./_base-email";
export interface ProrationInvoiceEmailParams {
user: {
name: string | null;
email: string;
t: TFunction;
};
team: {
id: number;
name: string | null;
};
proration: {
monthKey: string;
netSeatIncrease: number;
proratedAmount: number;
};
invoiceUrl?: string | null;
isAutoCharge: boolean;
}
export default class ProrationInvoiceEmail extends BaseEmail {
user: {
name: string;
email: string;
t: TFunction;
};
team: {
id: number;
name: string;
};
proration: {
monthKey: string;
netSeatIncrease: number;
proratedAmount: number;
};
invoiceUrl?: string | null;
isAutoCharge: boolean;
constructor(params: ProrationInvoiceEmailParams) {
super();
this.user = { ...params.user, name: params.user.name || "" };
this.team = { ...params.team, name: params.team.name || "" };
this.proration = params.proration;
this.invoiceUrl = params.invoiceUrl;
this.isAutoCharge = params.isAutoCharge;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const formattedAmount = (this.proration.proratedAmount / 100).toFixed(2);
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: this.user.email,
subject: this.user.t("proration_invoice_subject", {
teamName: this.team.name,
amount: formattedAmount,
}),
html: await renderEmail("ProrationInvoiceEmail", {
user: this.user,
team: this.team,
proration: this.proration,
invoiceUrl: this.invoiceUrl,
isAutoCharge: this.isAutoCharge,
}),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
const formattedAmount = (this.proration.proratedAmount / 100).toFixed(2);
return `An invoice for $${formattedAmount} has been created for ${this.proration.netSeatIncrease} additional seat(s) for your team ${this.team.name}.`;
}
}
@@ -0,0 +1,70 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import type { TFunction } from "i18next";
import renderEmail from "../src/renderEmail";
import BaseEmail from "./_base-email";
export interface ProrationReminderEmailParams {
user: {
name: string | null;
email: string;
t: TFunction;
};
team: {
id: number;
name: string | null;
};
proration: {
monthKey: string;
netSeatIncrease: number;
proratedAmount: number;
};
invoiceUrl?: string | null;
}
export default class ProrationReminderEmail extends BaseEmail {
user: {
name: string;
email: string;
t: TFunction;
};
team: {
id: number;
name: string;
};
proration: {
monthKey: string;
netSeatIncrease: number;
proratedAmount: number;
};
invoiceUrl?: string | null;
constructor(params: ProrationReminderEmailParams) {
super();
this.user = { ...params.user, name: params.user.name || "" };
this.team = { ...params.team, name: params.team.name || "" };
this.proration = params.proration;
this.invoiceUrl = params.invoiceUrl;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: this.user.email,
subject: this.user.t("proration_reminder_subject", {
teamName: this.team.name,
}),
html: await renderEmail("ProrationReminderEmail", {
user: this.user,
team: this.team,
proration: this.proration,
invoiceUrl: this.invoiceUrl,
}),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
const formattedAmount = (this.proration.proratedAmount / 100).toFixed(2);
return `Reminder: Your invoice of $${formattedAmount} for team ${this.team.name} is still unpaid. Please pay to avoid restrictions on adding new users.`;
}
}