-
Notifications
You must be signed in to change notification settings - Fork 12k
feat: Add email template for failed team subscription payments #24518
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
612e16d
3fd49d4
85a9346
9a606f0
a9c8cc6
bcc4b47
ad50ee9
cb63e6a
b869354
d4ee916
26a4d3b
5d3c541
d2950b3
67d269b
6ec4945
db39851
b4259a0
f22a4f8
7a0cba1
b1d3f00
be21877
82dc722
9378e39
2a9b721
c3a6283
46e1ca2
803a015
fc81ce1
de6e2fc
29b4239
059d819
015e8e0
ad59eb7
65b42e0
b6b9722
76cb6f9
5412960
1cbfe9f
fe50537
6c7b0f2
239dbeb
c5417e1
0c0e573
cfea43f
9d3d457
c434f9a
d844c3f
34f3d8a
e11f458
b91a461
76ba33b
635a88f
3a49799
37e24c5
4251ebf
81f1e01
a8927f8
fff0958
6ecf42f
4751813
ae363cd
1bb98a5
b22fc84
9b3ffaf
de674d9
154eeeb
e762e85
14f97c1
811bd8b
60796ea
b344299
6ecc387
5488c05
d5b46f0
53f0489
70d980e
8e436cc
4e707e6
618b7bb
25cfbb1
817be7c
a346d66
14f6670
f599ecb
7ebe67b
4e214bf
48214a7
a60194d
cb8f610
fbf586f
45c5527
b4910e7
40b74fb
8dc9464
3dac4dc
80f7839
ed0f815
e9cc790
7ea9bbe
5d2f514
158a226
6f50d92
f6e847a
5cac485
93726c4
dcc151b
159ea5f
bcbce07
9586afb
55ea14e
26ecf86
f9badeb
aef65be
927c35e
0c75b5b
801ba07
f892874
5083faa
0299078
fd6ac3f
c3250de
ea6a3ab
6569183
1c94218
6a701c5
5f98bea
e67507e
3768a4a
d12293f
17d7c5b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { BaseEmailHtml, CallToAction } from "../components"; | ||
|
|
||
| type SubscriptionPaymentFailedEmailProps = { | ||
| entityName: string; | ||
| billingPortalUrl: string; | ||
| supportEmail: string; | ||
| language: { | ||
| translate: (key: string, variables?: Record<string, string | number>) => string; | ||
| }; | ||
| }; | ||
|
|
||
| export const SubscriptionPaymentFailedEmail = (props: SubscriptionPaymentFailedEmailProps) => { | ||
| const t = props.language.translate; | ||
|
|
||
| return ( | ||
| <BaseEmailHtml | ||
| headerType="xCircle" | ||
| subject="subscription_payment_failed_subject" | ||
| title={t("subscription_payment_failed_title", { entityName: props.entityName })} | ||
| callToAction={ | ||
| props.billingPortalUrl ? ( | ||
| <CallToAction label={t("update_payment_method")} href={props.billingPortalUrl} /> | ||
| ) : null | ||
| }> | ||
| <PaymentFailedInformation {...props} /> | ||
| </BaseEmailHtml> | ||
| ); | ||
| }; | ||
|
|
||
| function PaymentFailedInformation(props: SubscriptionPaymentFailedEmailProps) { | ||
| const t = props.language.translate; | ||
|
|
||
| return ( | ||
| <> | ||
| <tr> | ||
| <td align="center" style={{ fontSize: "0px", padding: "10px 25px", wordBreak: "break-word" }}> | ||
| <div | ||
| style={{ | ||
| fontFamily: "Roboto, Helvetica, sans-serif", | ||
| fontSize: "16px", | ||
| fontWeight: 400, | ||
| lineHeight: "24px", | ||
| textAlign: "center", | ||
| color: "#494949", | ||
| }}> | ||
| {t("subscription_payment_failed_next_steps")} | ||
| </div> | ||
| </td> | ||
| </tr> | ||
| <tr> | ||
| <td align="center" style={{ fontSize: "0px", padding: "10px 25px", wordBreak: "break-word" }}> | ||
| <div | ||
| style={{ | ||
| fontFamily: "Roboto, Helvetica, sans-serif", | ||
| fontSize: "14px", | ||
| fontWeight: 400, | ||
| lineHeight: "20px", | ||
| textAlign: "center", | ||
| color: "#666666", | ||
| }}> | ||
| {t("subscription_payment_failed_contact_support", { | ||
| supportEmail: props.supportEmail, | ||
| })} | ||
| </div> | ||
| </td> | ||
| </tr> | ||
| </> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { EMAIL_FROM_NAME } from "@calcom/lib/constants"; | ||
|
|
||
| import renderEmail from "../src/renderEmail"; | ||
| import BaseEmail from "./_base-email"; | ||
|
|
||
| export interface SubscriptionPaymentFailedEmailData { | ||
| entityName: string; | ||
| billingPortalUrl: string; | ||
| to: string; | ||
| language: { | ||
| translate: (key: string, variables?: Record<string, string | number>) => string; | ||
| }; | ||
| } | ||
|
|
||
| export default class SubscriptionPaymentFailedEmail extends BaseEmail { | ||
| emailData: SubscriptionPaymentFailedEmailData; | ||
|
|
||
| constructor(emailData: SubscriptionPaymentFailedEmailData) { | ||
| super(); | ||
| this.name = "SEND_SUBSCRIPTION_PAYMENT_FAILED_EMAIL"; | ||
| this.emailData = emailData; | ||
| } | ||
|
|
||
| protected override getLocale(): string { | ||
| return ""; | ||
| } | ||
|
|
||
| protected async getNodeMailerPayload(): Promise<Record<string, unknown>> { | ||
| const t = this.emailData.language.translate; | ||
|
|
||
| return { | ||
| from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`, | ||
| to: this.emailData.to, | ||
| subject: t("subscription_payment_failed_subject", { | ||
| entityName: this.emailData.entityName, | ||
| }), | ||
| html: await renderEmail("SubscriptionPaymentFailedEmail", { | ||
| entityName: this.emailData.entityName, | ||
| billingPortalUrl: this.emailData.billingPortalUrl, | ||
| supportEmail: process.env.NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS || "support@cal.com", | ||
| language: this.emailData.language, | ||
| }), | ||
| text: this.getTextBody(), | ||
| }; | ||
| } | ||
|
|
||
| protected getTextBody(): string { | ||
| const t = this.emailData.language.translate; | ||
| const supportEmail = process.env.NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS || "support@cal.com"; | ||
|
|
||
| return ` | ||
| ${t("subscription_payment_failed_title")} | ||
|
|
||
| ${t("subscription_payment_failed_description", { | ||
| entityName: this.emailData.entityName, | ||
| })} | ||
|
|
||
| ${t("subscription_payment_failed_next_steps")} | ||
|
|
||
| ${t("update_payment_method")}: ${this.emailData.billingPortalUrl} | ||
|
|
||
| ${t("subscription_payment_failed_contact_support", { | ||
| supportEmail, | ||
| })} | ||
| `.trim(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,20 @@ | ||
| import type { z } from "zod"; | ||
|
|
||
| import { BillingPortalServiceFactory } from "@calcom/app-store/stripepayment/lib/services/factory/BillingPortalServiceFactory"; | ||
| import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; | ||
| import { sendSubscriptionPaymentFailedEmail } from "@calcom/emails/email-manager"; | ||
| import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; | ||
| import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; | ||
| import { WEBAPP_URL } from "@calcom/lib/constants"; | ||
| import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers"; | ||
| import logger from "@calcom/lib/logger"; | ||
| import { Redirect } from "@calcom/lib/redirect"; | ||
| import { safeStringify } from "@calcom/lib/safeStringify"; | ||
| import { getTranslation } from "@calcom/lib/server/i18n"; | ||
| import { prisma } from "@calcom/prisma"; | ||
| import type { Prisma } from "@calcom/prisma/client"; | ||
| import { MembershipRole } from "@calcom/prisma/enums"; | ||
| import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; | ||
| import type { z } from "zod"; | ||
| import { updateSubscriptionQuantity } from "../../lib/subscription-updates"; | ||
| // import billing from "../.."; | ||
| import type { | ||
|
|
@@ -232,4 +238,53 @@ export class TeamBillingService implements ITeamBillingService { | |
| async saveTeamBilling(args: IBillingRepositoryCreateArgs) { | ||
| await this.billingRepository.create(args); | ||
| } | ||
|
|
||
| /** | ||
| * Sends a payment failed email to team/organization admins with billing portal link | ||
| */ | ||
| async sendPaymentFailedEmails(): Promise<void> { | ||
| try { | ||
| // Get members of the team that have access to billing | ||
| const billingPortalService = await BillingPortalServiceFactory.createService(this.team.id); | ||
| const billingPortalUrl = await billingPortalService.processBillingPortalWithoutPermissionChecks({ | ||
| teamId: this.team.id, | ||
| }); | ||
|
|
||
| const permissionService = new PermissionCheckService(); | ||
| // Use the correct permission based on whether this is an organization or team | ||
| const billingPermission = this.team.isOrganization | ||
| ? "organization.manageBilling" | ||
| : "team.manageBilling"; | ||
| const membersToSendBillingEmail = await permissionService.getUsersWithPermissionForTeam({ | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. New |
||
| teamId: this.team.id, | ||
| permission: billingPermission, | ||
| fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], | ||
| }); | ||
|
|
||
| const emailsSent = await Promise.allSettled( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Send payment failed email to all members that have billing permissions |
||
| membersToSendBillingEmail.map(async (member) => { | ||
| const translate = await getTranslation(member.locale || "en", "common"); | ||
|
|
||
| await sendSubscriptionPaymentFailedEmail({ | ||
| entityName: `${this.team.name} ${this.team.isOrganization ? "organization" : "team"}`, | ||
| billingPortalUrl: billingPortalUrl, | ||
| to: member.email, | ||
| language: { translate }, | ||
| }); | ||
| }) | ||
| ); | ||
|
|
||
| const failedEmails = emailsSent.filter((result) => result.status === "rejected"); | ||
| const failedEmailCount = failedEmails.length; | ||
|
|
||
| log.info( | ||
| `Sent payment failed email for team ${this.team.id} to ${ | ||
| membersToSendBillingEmail.length - failedEmailCount | ||
| } members. Failed to send email to ${failedEmailCount} members.` | ||
| ); | ||
| } catch (error) { | ||
| this.logErrorFromUnknown(error); | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Need this to add a link directly to the billing portal in the email