diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 7cac4dc55f3f9a..a04a58bac4f892 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3824,6 +3824,8 @@ "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.", + "proration_invoice_text": "An invoice for ${{amount}} has been created for {{seats}} additional seat(s) for your team {{teamName}}.", + "proration_reminder_text": "Reminder: Your invoice of ${{amount}} for team {{teamName}} is still unpaid. Please pay to avoid restrictions on adding new users.", "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 0e9a24759d84ea..f6f76f9c34da43 100644 --- a/packages/emails/billing-email-service.ts +++ b/packages/emails/billing-email-service.ts @@ -25,13 +25,21 @@ const eventTypeDisableAttendeeEmail = (metadata?: EventTypeMetadata) => { return !!metadata?.disableStandardEmails?.all?.attendee; }; -export const sendOrganizerPaymentRefundFailedEmail = async (calEvent: CalendarEvent) => { +export const sendOrganizerPaymentRefundFailedEmail = async ( + calEvent: CalendarEvent +) => { const emailsToSend: Promise[] = []; - emailsToSend.push(sendEmail(() => new OrganizerPaymentRefundFailedEmail({ calEvent }))); + emailsToSend.push( + sendEmail(() => new OrganizerPaymentRefundFailedEmail({ calEvent })) + ); if (calEvent.team?.members) { for (const teamMember of calEvent.team.members) { - emailsToSend.push(sendEmail(() => new OrganizerPaymentRefundFailedEmail({ calEvent, teamMember }))); + emailsToSend.push( + sendEmail( + () => new OrganizerPaymentRefundFailedEmail({ calEvent, teamMember }) + ) + ); } } @@ -75,7 +83,15 @@ export const sendCreditBalanceLowWarningEmails = async (input: { for (const admin of team.adminAndOwners) { emailsToSend.push( - sendEmail(() => new CreditBalanceLowWarningEmail({ user: admin, balance, team, creditFor })) + sendEmail( + () => + new CreditBalanceLowWarningEmail({ + user: admin, + balance, + team, + creditFor, + }) + ) ); } @@ -83,7 +99,9 @@ export const sendCreditBalanceLowWarningEmails = async (input: { } if (user) { - await sendEmail(() => new CreditBalanceLowWarningEmail({ user, balance, creditFor })); + await sendEmail( + () => new CreditBalanceLowWarningEmail({ user, balance, creditFor }) + ); } }; @@ -117,14 +135,19 @@ export const sendCreditBalanceLimitReachedEmails = async ({ for (const admin of team.adminAndOwners) { emailsToSend.push( - sendEmail(() => new CreditBalanceLimitReachedEmail({ user: admin, team, creditFor })) + sendEmail( + () => + new CreditBalanceLimitReachedEmail({ user: admin, team, creditFor }) + ) ); } await Promise.all(emailsToSend); } if (user) { - await sendEmail(() => new CreditBalanceLimitReachedEmail({ user, creditFor })); + await sendEmail( + () => new CreditBalanceLimitReachedEmail({ user, creditFor }) + ); } }; diff --git a/packages/emails/templates/proration-invoice-email.ts b/packages/emails/templates/proration-invoice-email.ts index 2a3089ce65f42f..1d0ffb781997b8 100644 --- a/packages/emails/templates/proration-invoice-email.ts +++ b/packages/emails/templates/proration-invoice-email.ts @@ -72,6 +72,10 @@ export default class ProrationInvoiceEmail extends BaseEmail { 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}.`; + return this.user.t("proration_invoice_text", { + amount: formattedAmount, + seats: this.proration.netSeatIncrease, + teamName: this.team.name, + }); } } diff --git a/packages/emails/templates/proration-reminder-email.ts b/packages/emails/templates/proration-reminder-email.ts index db8c4526d716e6..25b3ab60fcdc61 100644 --- a/packages/emails/templates/proration-reminder-email.ts +++ b/packages/emails/templates/proration-reminder-email.ts @@ -65,6 +65,9 @@ export default class ProrationReminderEmail extends BaseEmail { 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.`; + return this.user.t("proration_reminder_text", { + amount: formattedAmount, + teamName: this.team.name, + }); } } diff --git a/packages/features/ee/billing/di/tasker/ProrationEmailSyncTasker.module.ts b/packages/features/ee/billing/di/tasker/ProrationEmailSyncTasker.module.ts new file mode 100644 index 00000000000000..36cfaf66c15762 --- /dev/null +++ b/packages/features/ee/billing/di/tasker/ProrationEmailSyncTasker.module.ts @@ -0,0 +1,21 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as loggerServiceModule } from "@calcom/features/di/shared/services/logger.service"; +import { ProrationEmailSyncTasker } from "@calcom/features/ee/billing/service/proration/tasker/ProrationEmailSyncTasker"; + +import { PRORATION_EMAIL_TASKER_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = PRORATION_EMAIL_TASKER_DI_TOKENS.PRORATION_EMAIL_SYNC_TASKER; +const moduleToken = PRORATION_EMAIL_TASKER_DI_TOKENS.PRORATION_EMAIL_SYNC_TASKER_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: ProrationEmailSyncTasker, + dep: loggerServiceModule, +}); + +export const moduleLoader = { + token, + loadModule, +} satisfies ModuleLoader; diff --git a/packages/features/ee/billing/di/tasker/ProrationEmailTasker.container.ts b/packages/features/ee/billing/di/tasker/ProrationEmailTasker.container.ts new file mode 100644 index 00000000000000..5080c888179556 --- /dev/null +++ b/packages/features/ee/billing/di/tasker/ProrationEmailTasker.container.ts @@ -0,0 +1,12 @@ +import { createContainer } from "@calcom/features/di/di"; +import type { ProrationEmailTasker } from "@calcom/features/ee/billing/service/proration/tasker/ProrationEmailTasker"; + +import { moduleLoader as prorationEmailTaskerModule } from "./ProrationEmailTasker.module"; +import { PRORATION_EMAIL_TASKER_DI_TOKENS } from "./tokens"; + +const container = createContainer(); + +export function getProrationEmailTasker(): ProrationEmailTasker { + prorationEmailTaskerModule.loadModule(container); + return container.get(PRORATION_EMAIL_TASKER_DI_TOKENS.PRORATION_EMAIL_TASKER); +} diff --git a/packages/features/ee/billing/di/tasker/ProrationEmailTasker.module.ts b/packages/features/ee/billing/di/tasker/ProrationEmailTasker.module.ts new file mode 100644 index 00000000000000..63c801ccd9d820 --- /dev/null +++ b/packages/features/ee/billing/di/tasker/ProrationEmailTasker.module.ts @@ -0,0 +1,27 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as loggerServiceModule } from "@calcom/features/di/shared/services/logger.service"; +import { ProrationEmailTasker } from "@calcom/features/ee/billing/service/proration/tasker/ProrationEmailTasker"; + +import { moduleLoader as prorationEmailSyncTaskerModule } from "./ProrationEmailSyncTasker.module"; +import { moduleLoader as prorationEmailTriggerTaskerModule } from "./ProrationEmailTriggerDevTasker.module"; +import { PRORATION_EMAIL_TASKER_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = PRORATION_EMAIL_TASKER_DI_TOKENS.PRORATION_EMAIL_TASKER; +const moduleToken = PRORATION_EMAIL_TASKER_DI_TOKENS.PRORATION_EMAIL_TASKER_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: ProrationEmailTasker, + depsMap: { + logger: loggerServiceModule, + asyncTasker: prorationEmailTriggerTaskerModule, + syncTasker: prorationEmailSyncTaskerModule, + }, +}); + +export const moduleLoader = { + token, + loadModule, +} satisfies ModuleLoader; diff --git a/packages/features/ee/billing/di/tasker/ProrationEmailTriggerDevTasker.module.ts b/packages/features/ee/billing/di/tasker/ProrationEmailTriggerDevTasker.module.ts new file mode 100644 index 00000000000000..0bc006403f37d0 --- /dev/null +++ b/packages/features/ee/billing/di/tasker/ProrationEmailTriggerDevTasker.module.ts @@ -0,0 +1,23 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as loggerServiceModule } from "@calcom/features/di/shared/services/logger.service"; +import { ProrationEmailTriggerDevTasker } from "@calcom/features/ee/billing/service/proration/tasker/ProrationEmailTriggerDevTasker"; + +import { PRORATION_EMAIL_TASKER_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = PRORATION_EMAIL_TASKER_DI_TOKENS.PRORATION_EMAIL_TRIGGER_TASKER; +const moduleToken = PRORATION_EMAIL_TASKER_DI_TOKENS.PRORATION_EMAIL_TRIGGER_TASKER_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: ProrationEmailTriggerDevTasker, + depsMap: { + logger: loggerServiceModule, + }, +}); + +export const moduleLoader = { + token, + loadModule, +} satisfies ModuleLoader; diff --git a/packages/features/ee/billing/di/tasker/tokens.ts b/packages/features/ee/billing/di/tasker/tokens.ts index d7edb00978ca8c..b9128e1e2ba7a9 100644 --- a/packages/features/ee/billing/di/tasker/tokens.ts +++ b/packages/features/ee/billing/di/tasker/tokens.ts @@ -6,3 +6,12 @@ export const MONTHLY_PRORATION_TASKER_DI_TOKENS = { MONTHLY_PRORATION_TRIGGER_TASKER: Symbol("MonthlyProrationTriggerTasker"), MONTHLY_PRORATION_TRIGGER_TASKER_MODULE: Symbol("MonthlyProrationTriggerTaskerModule"), }; + +export const PRORATION_EMAIL_TASKER_DI_TOKENS = { + PRORATION_EMAIL_TASKER: Symbol("ProrationEmailTasker"), + PRORATION_EMAIL_TASKER_MODULE: Symbol("ProrationEmailTaskerModule"), + PRORATION_EMAIL_SYNC_TASKER: Symbol("ProrationEmailSyncTasker"), + PRORATION_EMAIL_SYNC_TASKER_MODULE: Symbol("ProrationEmailSyncTaskerModule"), + PRORATION_EMAIL_TRIGGER_TASKER: Symbol("ProrationEmailTriggerTasker"), + PRORATION_EMAIL_TRIGGER_TASKER_MODULE: Symbol("ProrationEmailTriggerTaskerModule"), +}; diff --git a/packages/features/ee/billing/repository/proration/MonthlyProrationRepository.ts b/packages/features/ee/billing/repository/proration/MonthlyProrationRepository.ts index 2697d7eee55499..dd26a994990b60 100644 --- a/packages/features/ee/billing/repository/proration/MonthlyProrationRepository.ts +++ b/packages/features/ee/billing/repository/proration/MonthlyProrationRepository.ts @@ -90,4 +90,17 @@ export class MonthlyProrationRepository { }, }); } + + async findForEmail(prorationId: string) { + return await this.prisma.monthlyProration.findUnique({ + where: { id: prorationId }, + select: { + invoiceId: true, + monthKey: true, + netSeatIncrease: true, + proratedAmount: true, + status: true, + }, + }); + } } diff --git a/packages/features/ee/billing/service/proration/ProrationEmailService.ts b/packages/features/ee/billing/service/proration/ProrationEmailService.ts new file mode 100644 index 00000000000000..1f60a235cdbd27 --- /dev/null +++ b/packages/features/ee/billing/service/proration/ProrationEmailService.ts @@ -0,0 +1,181 @@ +import type { TFunction } from "i18next"; + +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { prisma } from "@calcom/prisma"; + +import { getUserAndTeamWithBillingPermission } from "../../helpers/getUserAndTeamWithBillingPermission"; +import { MonthlyProrationRepository } from "../../repository/proration/MonthlyProrationRepository"; + +const log = logger.getSubLogger({ prefix: ["ProrationEmailService"] }); + +interface UserWithBillingAccess { + id: number; + name: string | null; + email: string; + t: TFunction; +} + +export interface SendInvoiceEmailParams { + prorationId: string; + teamId: number; + isAutoCharge: boolean; +} + +export interface SendReminderEmailParams { + prorationId: string; + teamId: number; +} + +export class ProrationEmailService { + private prorationRepository: MonthlyProrationRepository; + + constructor() { + this.prorationRepository = new MonthlyProrationRepository(); + } + + async sendInvoiceEmail(params: SendInvoiceEmailParams): Promise { + const { prorationId, teamId, isAutoCharge } = params; + + log.debug( + `Processing invoice email for prorationId ${prorationId}, teamId ${teamId}` + ); + + const proration = await this.prorationRepository.findForEmail(prorationId); + if (!proration) { + log.warn(`Proration ${prorationId} not found, skipping invoice email`); + return; + } + + const { team } = await getUserAndTeamWithBillingPermission({ + teamId, + prismaClient: prisma, + }); + + if (!team) { + log.warn(`Team ${teamId} not found, skipping invoice email`); + return; + } + + if (team.adminAndOwners.length === 0) { + log.warn( + `No users with billing permission found for team ${teamId}, skipping invoice email` + ); + return; + } + + const invoiceUrl = await this.getInvoiceUrl(proration.invoiceId); + + const { sendProrationInvoiceEmails } = await import( + "@calcom/emails/billing-email-service" + ); + await sendProrationInvoiceEmails({ + team: { id: team.id, name: team.name }, + proration: { + monthKey: proration.monthKey, + netSeatIncrease: proration.netSeatIncrease, + proratedAmount: proration.proratedAmount, + }, + invoiceUrl, + isAutoCharge, + adminAndOwners: team.adminAndOwners, + }); + + log.debug( + `Successfully sent proration invoice emails for prorationId ${prorationId}` + ); + } + + async sendReminderEmail(params: SendReminderEmailParams): Promise { + const { prorationId, teamId } = params; + + log.debug( + `Processing reminder email for prorationId ${prorationId}, teamId ${teamId}` + ); + + const proration = await this.prorationRepository.findForEmail(prorationId); + if (!proration) { + log.warn(`Proration ${prorationId} not found, skipping reminder email`); + return; + } + + if (proration.status === "CHARGED") { + log.debug( + `Proration ${prorationId} already charged, skipping reminder email` + ); + return; + } + + const { team } = await getUserAndTeamWithBillingPermission({ + teamId, + prismaClient: prisma, + }); + + if (!team) { + log.warn(`Team ${teamId} not found, skipping reminder email`); + return; + } + + if (team.adminAndOwners.length === 0) { + log.warn( + `No users with billing permission found for team ${teamId}, skipping reminder email` + ); + return; + } + + const invoiceUrl = await this.getInvoiceUrl(proration.invoiceId); + + const { default: ProrationReminderEmail } = await import( + "@calcom/emails/templates/proration-reminder-email" + ); + + const emailPromises = team.adminAndOwners.map( + async (user: UserWithBillingAccess) => { + const email = new ProrationReminderEmail({ + user: { + name: user.name, + email: user.email, + t: user.t, + }, + team: { + id: team.id, + name: team.name, + }, + proration: { + monthKey: proration.monthKey, + netSeatIncrease: proration.netSeatIncrease, + proratedAmount: proration.proratedAmount, + }, + invoiceUrl, + }); + return email.sendEmail(); + } + ); + + await Promise.all(emailPromises); + + log.debug( + `Successfully sent proration reminder emails for prorationId ${prorationId}` + ); + } + + private async getInvoiceUrl( + invoiceId: string | null + ): Promise { + if (!invoiceId) return null; + + try { + const { default: stripe } = await import( + "@calcom/features/ee/payments/server/stripe" + ); + const invoice = await stripe.invoices.retrieve(invoiceId); + return invoice.hosted_invoice_url ?? null; + } catch (error) { + log.warn( + `Failed to retrieve invoice URL for ${invoiceId}`, + safeStringify(error) + ); + return null; + } + } +} diff --git a/packages/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker.ts b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker.ts index e9ed45d0a11718..519dc2b811815b 100644 --- a/packages/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker.ts +++ b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker.ts @@ -2,6 +2,7 @@ import { nanoid } from "nanoid"; import type { Logger } from "tslog"; import { MonthlyProrationService } from "../MonthlyProrationService"; +import { ProrationEmailService } from "../ProrationEmailService"; import type { IMonthlyProrationTasker } from "./types"; export class MonthlyProrationSyncTasker implements IMonthlyProrationTasker { @@ -10,7 +11,25 @@ export class MonthlyProrationSyncTasker implements IMonthlyProrationTasker { async processBatch(payload: Parameters[0]) { const runId = `sync_${nanoid(10)}`; const prorationService = new MonthlyProrationService(this.logger); - await prorationService.processMonthlyProrations(payload); + const prorationResults = await prorationService.processMonthlyProrations(payload); + + // Send invoice emails for eligible prorations + const emailService = new ProrationEmailService(); + for (const proration of prorationResults) { + const isAutoCharge = proration.status === "INVOICE_CREATED"; + const isPending = proration.status === "PENDING"; + + if (isAutoCharge || isPending) { + await emailService.sendInvoiceEmail({ + prorationId: proration.id, + teamId: proration.teamId, + isAutoCharge, + }); + // Note: In sync mode, reminder emails are not scheduled + // as they require Trigger.dev for delayed execution + } + } + return { runId }; } } diff --git a/packages/features/ee/billing/service/proration/tasker/ProrationEmailSyncTasker.ts b/packages/features/ee/billing/service/proration/tasker/ProrationEmailSyncTasker.ts new file mode 100644 index 00000000000000..3971c8fdce9ffe --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/ProrationEmailSyncTasker.ts @@ -0,0 +1,37 @@ +import { nanoid } from "nanoid"; +import type { Logger } from "tslog"; + +import type { IProrationEmailTasker } from "./types"; + +export class ProrationEmailSyncTasker implements IProrationEmailTasker { + constructor(private readonly logger: Logger) {} + + async sendInvoiceEmail(payload: Parameters[0]) { + const runId = `sync_${nanoid(10)}`; + this.logger.info(`[ProrationEmailSyncTasker] sendInvoiceEmail runId=${runId}`); + const { ProrationEmailService } = await import("../ProrationEmailService"); + const emailService = new ProrationEmailService(); + await emailService.sendInvoiceEmail(payload); + return { runId }; + } + + async sendReminderEmail(payload: Parameters[0]) { + const runId = `sync_${nanoid(10)}`; + this.logger.info(`[ProrationEmailSyncTasker] sendReminderEmail runId=${runId}`); + const { ProrationEmailService } = await import("../ProrationEmailService"); + const emailService = new ProrationEmailService(); + await emailService.sendReminderEmail(payload); + return { runId }; + } + + async cancelReminder(payload: Parameters[0]) { + const runId = `sync_${nanoid(10)}`; + // In sync mode, reminders are not scheduled (they require Trigger.dev) + // so cancellation is a no-op + this.logger.info( + `[ProrationEmailSyncTasker] cancelReminder runId=${runId} - no-op in sync mode`, + { prorationId: payload.prorationId } + ); + return { runId }; + } +} diff --git a/packages/features/ee/billing/service/proration/tasker/ProrationEmailTasker.ts b/packages/features/ee/billing/service/proration/tasker/ProrationEmailTasker.ts new file mode 100644 index 00000000000000..75e8531ae2db08 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/ProrationEmailTasker.ts @@ -0,0 +1,35 @@ +import { Tasker } from "@calcom/lib/tasker/Tasker"; +import type { Logger } from "tslog"; + +import type { ProrationEmailSyncTasker } from "./ProrationEmailSyncTasker"; +import type { ProrationEmailTriggerDevTasker } from "./ProrationEmailTriggerDevTasker"; +import type { + CancelReminderPayload, + IProrationEmailTasker, + SendInvoiceEmailPayload, + SendReminderEmailPayload, +} from "./types"; + +export interface ProrationEmailTaskerDependencies { + asyncTasker: ProrationEmailTriggerDevTasker; + syncTasker: ProrationEmailSyncTasker; + logger: Logger; +} + +export class ProrationEmailTasker extends Tasker { + constructor(dependencies: ProrationEmailTaskerDependencies) { + super(dependencies); + } + + async sendInvoiceEmail(payload: SendInvoiceEmailPayload): Promise<{ runId: string }> { + return await this.dispatch("sendInvoiceEmail", payload); + } + + async sendReminderEmail(payload: SendReminderEmailPayload): Promise<{ runId: string }> { + return await this.dispatch("sendReminderEmail", payload); + } + + async cancelReminder(payload: CancelReminderPayload): Promise<{ runId: string }> { + return await this.dispatch("cancelReminder", payload); + } +} diff --git a/packages/features/ee/billing/service/proration/tasker/ProrationEmailTriggerDevTasker.ts b/packages/features/ee/billing/service/proration/tasker/ProrationEmailTriggerDevTasker.ts new file mode 100644 index 00000000000000..c6b0f119e593e3 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/ProrationEmailTriggerDevTasker.ts @@ -0,0 +1,25 @@ +import type { ITaskerDependencies } from "@calcom/lib/tasker/types"; + +import type { IProrationEmailTasker } from "./types"; + +export class ProrationEmailTriggerDevTasker implements IProrationEmailTasker { + constructor(public readonly dependencies: ITaskerDependencies) {} + + async sendInvoiceEmail(payload: Parameters[0]) { + const { sendProrationInvoiceEmail } = await import("./trigger/sendProrationInvoiceEmail"); + const handle = await sendProrationInvoiceEmail.trigger(payload); + return { runId: handle.id }; + } + + async sendReminderEmail(payload: Parameters[0]) { + const { sendProrationReminderEmail } = await import("./trigger/sendProrationReminderEmail"); + const handle = await sendProrationReminderEmail.trigger(payload); + return { runId: handle.id }; + } + + async cancelReminder(payload: Parameters[0]) { + const { cancelProrationReminder } = await import("./trigger/cancelProrationReminder"); + const handle = await cancelProrationReminder.trigger(payload); + return { runId: handle.id }; + } +} diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/cancelProrationReminder.ts b/packages/features/ee/billing/service/proration/tasker/trigger/cancelProrationReminder.ts new file mode 100644 index 00000000000000..8cdfd1a218814b --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/cancelProrationReminder.ts @@ -0,0 +1,27 @@ +import { runs, schemaTask } from "@trigger.dev/sdk"; + +import { prorationEmailTaskConfig } from "./emailConfig"; +import { cancelReminderSchema } from "./emailSchemas"; +import { sendProrationReminderEmail } from "./sendProrationReminderEmail"; + +export const cancelProrationReminder = schemaTask({ + id: "billing.proration.cancel-reminder", + ...prorationEmailTaskConfig, + schema: cancelReminderSchema, + run: async (payload) => { + const idempotencyKey = `proration-reminder-${payload.prorationId}`; + + // Find and cancel any scheduled reminder runs with this idempotency key + // Use for-await to auto-paginate through all results + for await (const run of runs.list({ + taskIdentifier: [sendProrationReminderEmail.id], + status: ["DELAYED", "WAITING_FOR_DEPLOY", "QUEUED", "PENDING"], + })) { + if (run.idempotencyKey === idempotencyKey) { + await runs.cancel(run.id); + } + } + + return { success: true }; + }, +}); diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/emailConfig.ts b/packages/features/ee/billing/service/proration/tasker/trigger/emailConfig.ts new file mode 100644 index 00000000000000..73256526d79313 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/emailConfig.ts @@ -0,0 +1,20 @@ +import { queue, type schemaTask } from "@trigger.dev/sdk"; + +type ProrationEmailTaskConfig = Pick[0], "machine" | "retry" | "queue">; + +export const prorationEmailQueue = queue({ + name: "proration-email", + concurrencyLimit: 10, +}); + +export const prorationEmailTaskConfig: ProrationEmailTaskConfig = { + queue: prorationEmailQueue, + machine: "small-1x", + retry: { + maxAttempts: 3, + factor: 2, + minTimeoutInMs: 1_000, + maxTimeoutInMs: 30_000, + randomize: true, + }, +}; diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/emailSchemas.ts b/packages/features/ee/billing/service/proration/tasker/trigger/emailSchemas.ts new file mode 100644 index 00000000000000..c4d5c342ff8b55 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/emailSchemas.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +export const sendInvoiceEmailSchema = z.object({ + prorationId: z.string().uuid(), + teamId: z.number().int().positive(), + isAutoCharge: z.boolean(), +}); + +export const sendReminderEmailSchema = z.object({ + prorationId: z.string().uuid(), + teamId: z.number().int().positive(), +}); + +export const cancelReminderSchema = z.object({ + prorationId: z.string().uuid(), +}); + +export type SendInvoiceEmailPayload = z.infer; +export type SendReminderEmailPayload = z.infer; +export type CancelReminderPayload = z.infer; diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts b/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts index ab5ab8ae44fb6e..aaaecfc8fcee8f 100644 --- a/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts +++ b/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts @@ -2,6 +2,10 @@ import { schemaTask } from "@trigger.dev/sdk"; import { monthlyProrationTaskConfig } from "./config"; import { monthlyProrationBatchSchema } from "./schema"; +import { sendProrationInvoiceEmail } from "./sendProrationInvoiceEmail"; +import { sendProrationReminderEmail } from "./sendProrationReminderEmail"; + +const REMINDER_DELAY_DAYS = 7; export const processMonthlyProrationBatch = schemaTask({ id: "billing.monthly-proration.batch", @@ -14,9 +18,38 @@ export const processMonthlyProrationBatch = schemaTask({ const prorationService = getMonthlyProrationService(); - return await prorationService.processMonthlyProrations({ + const prorationResults = await prorationService.processMonthlyProrations({ monthKey: payload.monthKey, teamIds: payload.teamIds, }); + + for (const proration of prorationResults) { + const isAutoCharge = proration.status === "INVOICE_CREATED"; + const isPending = proration.status === "PENDING"; + + if (isAutoCharge || isPending) { + await sendProrationInvoiceEmail.trigger({ + prorationId: proration.id, + teamId: proration.teamId, + isAutoCharge, + }); + + // Schedule reminder email for non-auto-charge invoices (7 days later) + if (!isAutoCharge) { + await sendProrationReminderEmail.trigger( + { + prorationId: proration.id, + teamId: proration.teamId, + }, + { + delay: `${REMINDER_DELAY_DAYS}d`, + idempotencyKey: `proration-reminder-${proration.id}`, + } + ); + } + } + } + + return prorationResults; }, }); diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/sendProrationInvoiceEmail.ts b/packages/features/ee/billing/service/proration/tasker/trigger/sendProrationInvoiceEmail.ts new file mode 100644 index 00000000000000..4cc6b8572a7de3 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/sendProrationInvoiceEmail.ts @@ -0,0 +1,16 @@ +import { schemaTask } from "@trigger.dev/sdk"; + +import { prorationEmailTaskConfig } from "./emailConfig"; +import { sendInvoiceEmailSchema } from "./emailSchemas"; + +export const sendProrationInvoiceEmail = schemaTask({ + id: "billing.proration.send-invoice-email", + ...prorationEmailTaskConfig, + schema: sendInvoiceEmailSchema, + run: async (payload) => { + const { ProrationEmailService } = await import("../../ProrationEmailService"); + const emailService = new ProrationEmailService(); + await emailService.sendInvoiceEmail(payload); + return { success: true }; + }, +}); diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/sendProrationReminderEmail.ts b/packages/features/ee/billing/service/proration/tasker/trigger/sendProrationReminderEmail.ts new file mode 100644 index 00000000000000..b14eff7582f482 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/sendProrationReminderEmail.ts @@ -0,0 +1,16 @@ +import { schemaTask } from "@trigger.dev/sdk"; + +import { prorationEmailTaskConfig } from "./emailConfig"; +import { sendReminderEmailSchema } from "./emailSchemas"; + +export const sendProrationReminderEmail = schemaTask({ + id: "billing.proration.send-reminder-email", + ...prorationEmailTaskConfig, + schema: sendReminderEmailSchema, + run: async (payload) => { + const { ProrationEmailService } = await import("../../ProrationEmailService"); + const emailService = new ProrationEmailService(); + await emailService.sendReminderEmail(payload); + return { success: true }; + }, +}); diff --git a/packages/features/ee/billing/service/proration/tasker/types.ts b/packages/features/ee/billing/service/proration/tasker/types.ts index eaa479e8c740f8..21d463f296a244 100644 --- a/packages/features/ee/billing/service/proration/tasker/types.ts +++ b/packages/features/ee/billing/service/proration/tasker/types.ts @@ -6,3 +6,24 @@ export type MonthlyProrationBatchPayload = { export interface IMonthlyProrationTasker { processBatch(payload: MonthlyProrationBatchPayload): Promise<{ runId: string }>; } + +export interface SendInvoiceEmailPayload { + prorationId: string; + teamId: number; + isAutoCharge: boolean; +} + +export interface SendReminderEmailPayload { + prorationId: string; + teamId: number; +} + +export interface CancelReminderPayload { + prorationId: string; +} + +export interface IProrationEmailTasker { + sendInvoiceEmail(payload: SendInvoiceEmailPayload): Promise<{ runId: string }>; + sendReminderEmail(payload: SendReminderEmailPayload): Promise<{ runId: string }>; + cancelReminder(payload: CancelReminderPayload): Promise<{ runId: string }>; +} diff --git a/packages/features/ee/teams/repositories/TeamRepository.ts b/packages/features/ee/teams/repositories/TeamRepository.ts index a4f8e62fc7499c..830bdf18398c36 100644 --- a/packages/features/ee/teams/repositories/TeamRepository.ts +++ b/packages/features/ee/teams/repositories/TeamRepository.ts @@ -10,7 +10,10 @@ import { MembershipRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; type TeamGetPayloadWithParsedMetadata = - | (Omit, "metadata" | "isOrganization"> & { + | (Omit< + Prisma.TeamGetPayload<{ select: TeamSelect }>, + "metadata" | "isOrganization" + > & { metadata: z.infer; isOrganization: boolean; }) @@ -48,14 +51,17 @@ async function getTeamOrOrg({ forOrgWithSlug: forOrgWithSlug, isOrg, teamSelect, -}: GetTeamOrOrgArg): Promise> { +}: GetTeamOrOrgArg): Promise< + TeamGetPayloadWithParsedMetadata +> { const where: Prisma.TeamFindFirstArgs["where"] = {}; teamSelect = { ...teamSelect, metadata: true, isOrganization: true, } satisfies TeamSelect; - if (lookupBy.havingMemberWithId) where.members = { some: { userId: lookupBy.havingMemberWithId } }; + if (lookupBy.havingMemberWithId) + where.members = { some: { userId: lookupBy.havingMemberWithId } }; if ("id" in lookupBy) { where.id = lookupBy.id; @@ -108,17 +114,20 @@ async function getTeamOrOrg({ }); if (teamsWithParsedMetadata.length > 1) { - log.error("Found more than one team/Org. We should be doing something wrong.", { - isOrgView: isOrg, - where, - teams: teamsWithParsedMetadata.map((team) => { - const t = team as unknown as { id: number; slug: string }; - return { - id: t.id, - slug: t.slug, - }; - }), - }); + log.error( + "Found more than one team/Org. We should be doing something wrong.", + { + isOrgView: isOrg, + where, + teams: teamsWithParsedMetadata.map((team) => { + const t = team as unknown as { id: number; slug: string }; + return { + id: t.id, + slug: t.slug, + }; + }), + } + ); } const team = teamsWithParsedMetadata[0]; @@ -132,7 +141,9 @@ export async function getTeam({ lookupBy, forOrgWithSlug: forOrgWithSlug, teamSelect, -}: Omit, "isOrg">): Promise> { +}: Omit, "isOrg">): Promise< + TeamGetPayloadWithParsedMetadata +> { return getTeamOrOrg({ lookupBy, forOrgWithSlug: forOrgWithSlug, @@ -145,7 +156,9 @@ export async function getOrg({ lookupBy, forOrgWithSlug: forOrgWithSlug, teamSelect, -}: Omit, "isOrg">): Promise> { +}: Omit, "isOrg">): Promise< + TeamGetPayloadWithParsedMetadata +> { return getTeamOrOrg({ lookupBy, forOrgWithSlug: forOrgWithSlug, @@ -241,7 +254,9 @@ export class TeamRepository { return await this.prismaClient.team.findFirst({ where: { slug, - parent: parentSlug ? whereClauseForOrgWithSlugOrRequestedSlug(parentSlug) : null, + parent: parentSlug + ? whereClauseForOrgWithSlugOrRequestedSlug(parentSlug) + : null, }, select, }); @@ -292,7 +307,13 @@ export class TeamRepository { }); } - async findTeamsByUserId({ userId, includeOrgs }: { userId: number; includeOrgs?: boolean }) { + async findTeamsByUserId({ + userId, + includeOrgs, + }: { + userId: number; + includeOrgs?: boolean; + }) { const memberships = await this.prismaClient.membership.findMany({ where: { // Show all the teams this user belongs to regardless of the team being part of the user's org or not @@ -335,7 +356,10 @@ export class TeamRepository { // Only return inviteToken if user is OWNER or ADMIN const inviteToken = membership.role === "OWNER" || membership.role === "ADMIN" - ? inviteTokens.find((token) => token.identifier === `invite-link-for-teamId-${team.id}`) + ? inviteTokens.find( + (token) => + token.identifier === `invite-link-for-teamId-${team.id}` + ) : null; return { @@ -372,7 +396,9 @@ export class TeamRepository { }, }); - return memberships.filter((mmship) => !mmship.team.isOrganization).map((mmship) => mmship.team); + return memberships + .filter((mmship) => !mmship.team.isOrganization) + .map((mmship) => mmship.team); } async findTeamWithOrganizationSettings(teamId: number) { @@ -447,7 +473,13 @@ export class TeamRepository { }); } - async findOrganization({ teamId, userId }: { teamId?: number; userId: number }) { + async findOrganization({ + teamId, + userId, + }: { + teamId?: number; + userId: number; + }) { return await this.prismaClient.team.findFirst({ where: { isOrganization: true, @@ -469,7 +501,11 @@ export class TeamRepository { }); } - async findOrganizationIdBySlug({ slug }: { slug: string }): Promise { + async findOrganizationIdBySlug({ + slug, + }: { + slug: string; + }): Promise { const org = await this.prismaClient.team.findFirst({ where: { slug, @@ -509,7 +545,13 @@ export class TeamRepository { return !conflictingTeam; } - async getTeamByIdIfUserIsAdmin({ userId, teamId }: { userId: number; teamId: number }) { + async getTeamByIdIfUserIsAdmin({ + userId, + teamId, + }: { + userId: number; + teamId: number; + }) { return await this.prismaClient.team.findUnique({ where: { id: teamId, @@ -529,7 +571,13 @@ export class TeamRepository { }); } - async findOrgTeamsExcludingTeam({ parentId, excludeTeamId }: { parentId: number; excludeTeamId: number }) { + async findOrgTeamsExcludingTeam({ + parentId, + excludeTeamId, + }: { + parentId: number; + excludeTeamId: number; + }) { return await this.prismaClient.team.findMany({ where: { parentId, @@ -544,7 +592,12 @@ export class TeamRepository { async findTeamsForCreditCheck({ teamIds }: { teamIds: number[] }) { return await this.prismaClient.team.findMany({ where: { id: { in: teamIds } }, - select: { id: true, isOrganization: true, parentId: true, parent: { select: { id: true } } }, + select: { + id: true, + isOrganization: true, + parentId: true, + parent: { select: { id: true } }, + }, }); } @@ -569,7 +622,7 @@ export class TeamRepository { const users = await this.prismaClient.$queryRaw` SELECT DISTINCT u.id, u.name, u.email, u.locale FROM "Membership" m - INNER JOIN "User" u ON m."userId" = u.id + INNER JOIN "users" u ON m."userId" = u.id LEFT JOIN "Role" r ON m."customRoleId" = r.id LEFT JOIN "TeamFeatures" f ON m."teamId" = f."teamId" AND f."featureId" = 'pbac' AND f.enabled = true WHERE m."teamId" = ${teamId} @@ -597,14 +650,23 @@ export class TeamRepository { return users; } - private parsePermission(permission: string): { resource: string; action: string } { + private parsePermission(permission: string): { + resource: string; + action: string; + } { const lastDotIndex = permission.lastIndexOf("."); const resource = permission.substring(0, lastDotIndex); const action = permission.substring(lastDotIndex + 1); return { resource, action }; } - async findTeamsNotBelongingToOrgByIds({ teamIds, orgId }: { teamIds: number[]; orgId: number }) { + async findTeamsNotBelongingToOrgByIds({ + teamIds, + orgId, + }: { + teamIds: number[]; + orgId: number; + }) { return await this.prismaClient.team.findMany({ where: { id: { in: teamIds }, @@ -615,7 +677,13 @@ export class TeamRepository { }); } - async findByIdsAndOrgId({ teamIds, orgId }: { teamIds: number[]; orgId: number }) { + async findByIdsAndOrgId({ + teamIds, + orgId, + }: { + teamIds: number[]; + orgId: number; + }) { return await this.prismaClient.team.findMany({ where: { id: { in: teamIds }, diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index b647ae05333019..20c4edce41561c 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -42,6 +42,15 @@ type TaskPayloads = { sendAwaitingPaymentEmail: z.infer< typeof import("./tasks/sendAwaitingPaymentEmail").sendAwaitingPaymentEmailPayloadSchema >; + sendProrationInvoiceEmail: z.infer< + typeof import("./tasks/sendProrationInvoiceEmail").sendProrationInvoiceEmailPayloadSchema + >; + sendProrationReminderEmail: z.infer< + typeof import("./tasks/sendProrationReminderEmail").sendProrationReminderEmailPayloadSchema + >; + cancelProrationReminder: z.infer< + typeof import("./tasks/cancelProrationReminder").cancelProrationReminderPayloadSchema + >; webhookDelivery: z.infer< typeof import("@calcom/features/webhooks/lib/types/webhookTask").webhookTaskPayloadSchema >; diff --git a/packages/features/tasker/tasks/cancelProrationReminder.ts b/packages/features/tasker/tasks/cancelProrationReminder.ts new file mode 100644 index 00000000000000..ce3a59a7848392 --- /dev/null +++ b/packages/features/tasker/tasks/cancelProrationReminder.ts @@ -0,0 +1,29 @@ +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { z } from "zod"; + +const log = logger.getSubLogger({ prefix: ["cancelProrationReminder"] }); + +export const cancelProrationReminderPayloadSchema = z.object({ + prorationId: z.string(), +}); + +export async function cancelProrationReminder(payload: string): Promise { + try { + const { prorationId } = cancelProrationReminderPayloadSchema.parse(JSON.parse(payload)); + + log.debug(`Processing cancelProrationReminder task for prorationId ${prorationId}`); + + const { default: tasker } = await import("@calcom/features/tasker"); + + await tasker.cancelWithReference(`proration-reminder-${prorationId}`, "sendProrationReminderEmail"); + + log.debug(`Successfully cancelled proration reminder for prorationId ${prorationId}`); + } catch (error) { + log.warn( + `Failed to cancel proration reminder`, + safeStringify({ payload, error: error instanceof Error ? error.message : String(error) }) + ); + // Don't throw - cancellation failure is non-critical + } +} diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index a0ad2dabc62b3b..930151b98df547 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -1,5 +1,4 @@ import { IS_PRODUCTION } from "@calcom/lib/constants"; - import type { TaskHandler, TaskTypes } from "../tasker"; /** @@ -33,6 +32,12 @@ const tasks: Record Promise> = { executeAIPhoneCall: () => import("./executeAIPhoneCall").then((module) => module.executeAIPhoneCall), sendAwaitingPaymentEmail: () => import("./sendAwaitingPaymentEmail").then((module) => module.sendAwaitingPaymentEmail), + sendProrationInvoiceEmail: () => + import("./sendProrationInvoiceEmail").then((module) => module.sendProrationInvoiceEmail), + sendProrationReminderEmail: () => + import("./sendProrationReminderEmail").then((module) => module.sendProrationReminderEmail), + cancelProrationReminder: () => + import("./cancelProrationReminder").then((module) => module.cancelProrationReminder), bookingAudit: () => import("./bookingAudit").then((module) => module.bookingAudit), webhookDelivery: () => import("./webhookDelivery").then((module) => module.webhookDelivery), }; diff --git a/packages/features/tasker/tasks/sendProrationInvoiceEmail.ts b/packages/features/tasker/tasks/sendProrationInvoiceEmail.ts new file mode 100644 index 00000000000000..27104ce552a462 --- /dev/null +++ b/packages/features/tasker/tasks/sendProrationInvoiceEmail.ts @@ -0,0 +1,34 @@ +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { z } from "zod"; + +const log = logger.getSubLogger({ prefix: ["sendProrationInvoiceEmail"] }); + +export const sendProrationInvoiceEmailPayloadSchema = z.object({ + prorationId: z.string(), + teamId: z.number(), + isAutoCharge: z.boolean(), +}); + +export async function sendProrationInvoiceEmail(payload: string): Promise { + try { + const { prorationId, teamId, isAutoCharge } = sendProrationInvoiceEmailPayloadSchema.parse( + JSON.parse(payload) + ); + + log.debug(`Processing sendProrationInvoiceEmail task for prorationId ${prorationId}, teamId ${teamId}`); + + const { ProrationEmailService } = await import( + "@calcom/features/ee/billing/service/proration/ProrationEmailService" + ); + + const emailService = new ProrationEmailService(); + await emailService.sendInvoiceEmail({ prorationId, teamId, isAutoCharge }); + } catch (error) { + log.error( + `Failed to send proration invoice email`, + safeStringify({ payload, error: error instanceof Error ? error.message : String(error) }) + ); + throw error; + } +} diff --git a/packages/features/tasker/tasks/sendProrationReminderEmail.ts b/packages/features/tasker/tasks/sendProrationReminderEmail.ts new file mode 100644 index 00000000000000..4ce661417125d0 --- /dev/null +++ b/packages/features/tasker/tasks/sendProrationReminderEmail.ts @@ -0,0 +1,31 @@ +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { z } from "zod"; + +const log = logger.getSubLogger({ prefix: ["sendProrationReminderEmail"] }); + +export const sendProrationReminderEmailPayloadSchema = z.object({ + prorationId: z.string(), + teamId: z.number(), +}); + +export async function sendProrationReminderEmail(payload: string): Promise { + try { + const { prorationId, teamId } = sendProrationReminderEmailPayloadSchema.parse(JSON.parse(payload)); + + log.debug(`Processing sendProrationReminderEmail task for prorationId ${prorationId}, teamId ${teamId}`); + + const { ProrationEmailService } = await import( + "@calcom/features/ee/billing/service/proration/ProrationEmailService" + ); + + const emailService = new ProrationEmailService(); + await emailService.sendReminderEmail({ prorationId, teamId }); + } catch (error) { + log.error( + `Failed to send proration reminder email`, + safeStringify({ payload, error: error instanceof Error ? error.message : String(error) }) + ); + throw error; + } +}