From cb76e8bde0521b1b627ff0dbf74343de60c36fe2 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:28:27 +0000 Subject: [PATCH] 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 * fix: use allSettled --------- Co-authored-by: Claude Opus 4.5 --- apps/web/public/static/locales/en/common.json | 12 ++ packages/emails/billing-email-service.ts | 113 ++++++++++++++++-- .../src/templates/ProrationInvoiceEmail.tsx | 111 +++++++++++++++++ .../src/templates/ProrationReminderEmail.tsx | 113 ++++++++++++++++++ packages/emails/src/templates/index.ts | 64 +++++----- .../templates/proration-invoice-email.ts | 77 ++++++++++++ .../templates/proration-reminder-email.ts | 70 +++++++++++ 7 files changed, 522 insertions(+), 38 deletions(-) create mode 100644 packages/emails/src/templates/ProrationInvoiceEmail.tsx create mode 100644 packages/emails/src/templates/ProrationReminderEmail.tsx create mode 100644 packages/emails/templates/proration-invoice-email.ts create mode 100644 packages/emails/templates/proration-reminder-email.ts diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 8a4bc44105..8fa7b7c798 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/emails/billing-email-service.ts b/packages/emails/billing-email-service.ts index 4660482489..0e9a24759d 100644 --- a/packages/emails/billing-email-service.ts +++ b/packages/emails/billing-email-service.ts @@ -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[] = []; + + 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[] = []; + + 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); + } +}; diff --git a/packages/emails/src/templates/ProrationInvoiceEmail.tsx b/packages/emails/src/templates/ProrationInvoiceEmail.tsx new file mode 100644 index 0000000000..ab62b5f2f4 --- /dev/null +++ b/packages/emails/src/templates/ProrationInvoiceEmail.tsx @@ -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 ( + +

+ {t("hi_user_name", { name: user.name, interpolation: { escapeValue: false } })}, +

+

+ {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 }, + })} +

+ + {/* Invoice Details Table */} +
+
+ {t("proration_description")} + {t("amount")} +
+
+ + {t("proration_line_item", { + seats: proration.netSeatIncrease, + month: proration.monthKey, + })} + + ${formattedAmount} +
+
+ {t("total")} + ${formattedAmount} +
+
+ +
+ + {t("view_billing_settings")} + + {!isAutoCharge && invoiceUrl && } +
+
+ ); +}; diff --git a/packages/emails/src/templates/ProrationReminderEmail.tsx b/packages/emails/src/templates/ProrationReminderEmail.tsx new file mode 100644 index 0000000000..990538a702 --- /dev/null +++ b/packages/emails/src/templates/ProrationReminderEmail.tsx @@ -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 ( + +

+ {t("hi_user_name", { name: user.name, interpolation: { escapeValue: false } })}, +

+

+ {t("proration_reminder_message", { + teamName: team.name, + amount: formattedAmount, + interpolation: { escapeValue: false }, + })} +

+ + {/* Warning Banner */} +

+ {t("proration_reminder_warning")} +

+ + {/* Invoice Details Table */} +
+
+ {t("proration_description")} + {t("amount")} +
+
+ + {t("proration_line_item", { + seats: proration.netSeatIncrease, + month: proration.monthKey, + })} + + ${formattedAmount} +
+
+ {t("total")} + ${formattedAmount} +
+
+ +
+ + {t("view_billing_settings")} + + {invoiceUrl && } +
+
+ ); +}; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index 7989abe6bd..60a49b09e2 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -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"; diff --git a/packages/emails/templates/proration-invoice-email.ts b/packages/emails/templates/proration-invoice-email.ts new file mode 100644 index 0000000000..2a3089ce65 --- /dev/null +++ b/packages/emails/templates/proration-invoice-email.ts @@ -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> { + 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}.`; + } +} diff --git a/packages/emails/templates/proration-reminder-email.ts b/packages/emails/templates/proration-reminder-email.ts new file mode 100644 index 0000000000..db8c4526d7 --- /dev/null +++ b/packages/emails/templates/proration-reminder-email.ts @@ -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> { + 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.`; + } +}