feat: add tasker integration for proration email notifications (#27247)

* 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>

* feat: add tasker integration for proration email notifications

Add task handlers for proration billing email flow:

- sendProrationInvoiceEmail: Sends invoice email and schedules reminder
- sendProrationReminderEmail: Sends reminder if invoice still unpaid
- cancelProrationReminder: Cancels scheduled reminder on payment success

The calling job should trigger these tasks after:
1. MonthlyProrationService.createProrationForTeam() succeeds with invoice
2. handleProrationPaymentSuccess() is called (to cancel reminder)

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

* feat: i18n + feedback from cubic + morgan

* fix: use default tasker import instead of non-existent getTasker

Co-Authored-By: unknown <>

* fix: add trigger tasks plus DI

* use for await

* fix query

* fix: remove duplicate JOIN alias in findTeamMembersWithPermission query

The raw SQL query had two INNER JOINs using the same alias 'u':
- INNER JOIN "users" u ON m."userId" = u.id
- INNER JOIN "User" u ON m."userId" = u.id

This would cause a SQL error at runtime. Removed the duplicate JOIN
with incorrect table name ("User" instead of "users").

Fixes issue identified by Cubic AI review.

Co-Authored-By: unknown <>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon
2026-02-03 08:29:36 +00:00
committed by GitHub
co-authored by unknown Claude Opus 4.5 Devin AI
parent 3c1d906808
commit 52f3ddaf74
28 changed files with 804 additions and 41 deletions
@@ -3826,6 +3826,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",
+30 -7
View File
@@ -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<unknown>[] = [];
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 })
);
}
};
@@ -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,
});
}
}
@@ -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,
});
}
}
@@ -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;
@@ -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<ProrationEmailTasker>(PRORATION_EMAIL_TASKER_DI_TOKENS.PRORATION_EMAIL_TASKER);
}
@@ -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;
@@ -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;
@@ -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"),
};
@@ -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,
},
});
}
}
@@ -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<void> {
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<void> {
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<string | null> {
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;
}
}
}
@@ -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<IMonthlyProrationTasker["processBatch"]>[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 };
}
}
@@ -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<unknown>) {}
async sendInvoiceEmail(payload: Parameters<IProrationEmailTasker["sendInvoiceEmail"]>[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<IProrationEmailTasker["sendReminderEmail"]>[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<IProrationEmailTasker["cancelReminder"]>[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 };
}
}
@@ -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<unknown>;
}
export class ProrationEmailTasker extends Tasker<IProrationEmailTasker> {
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);
}
}
@@ -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<IProrationEmailTasker["sendInvoiceEmail"]>[0]) {
const { sendProrationInvoiceEmail } = await import("./trigger/sendProrationInvoiceEmail");
const handle = await sendProrationInvoiceEmail.trigger(payload);
return { runId: handle.id };
}
async sendReminderEmail(payload: Parameters<IProrationEmailTasker["sendReminderEmail"]>[0]) {
const { sendProrationReminderEmail } = await import("./trigger/sendProrationReminderEmail");
const handle = await sendProrationReminderEmail.trigger(payload);
return { runId: handle.id };
}
async cancelReminder(payload: Parameters<IProrationEmailTasker["cancelReminder"]>[0]) {
const { cancelProrationReminder } = await import("./trigger/cancelProrationReminder");
const handle = await cancelProrationReminder.trigger(payload);
return { runId: handle.id };
}
}
@@ -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 };
},
});
@@ -0,0 +1,20 @@
import { queue, type schemaTask } from "@trigger.dev/sdk";
type ProrationEmailTaskConfig = Pick<Parameters<typeof schemaTask>[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,
},
};
@@ -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<typeof sendInvoiceEmailSchema>;
export type SendReminderEmailPayload = z.infer<typeof sendReminderEmailSchema>;
export type CancelReminderPayload = z.infer<typeof cancelReminderSchema>;
@@ -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;
},
});
@@ -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 };
},
});
@@ -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 };
},
});
@@ -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 }>;
}
@@ -10,7 +10,10 @@ import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
type TeamGetPayloadWithParsedMetadata<TeamSelect extends Prisma.TeamSelect> =
| (Omit<Prisma.TeamGetPayload<{ select: TeamSelect }>, "metadata" | "isOrganization"> & {
| (Omit<
Prisma.TeamGetPayload<{ select: TeamSelect }>,
"metadata" | "isOrganization"
> & {
metadata: z.infer<typeof teamMetadataSchema>;
isOrganization: boolean;
})
@@ -48,14 +51,17 @@ async function getTeamOrOrg<TeamSelect extends Prisma.TeamSelect>({
forOrgWithSlug: forOrgWithSlug,
isOrg,
teamSelect,
}: GetTeamOrOrgArg<TeamSelect>): Promise<TeamGetPayloadWithParsedMetadata<TeamSelect>> {
}: GetTeamOrOrgArg<TeamSelect>): Promise<
TeamGetPayloadWithParsedMetadata<TeamSelect>
> {
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<TeamSelect extends Prisma.TeamSelect>({
});
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<TeamSelect extends Prisma.TeamSelect>({
lookupBy,
forOrgWithSlug: forOrgWithSlug,
teamSelect,
}: Omit<GetTeamOrOrgArg<TeamSelect>, "isOrg">): Promise<TeamGetPayloadWithParsedMetadata<TeamSelect>> {
}: Omit<GetTeamOrOrgArg<TeamSelect>, "isOrg">): Promise<
TeamGetPayloadWithParsedMetadata<TeamSelect>
> {
return getTeamOrOrg({
lookupBy,
forOrgWithSlug: forOrgWithSlug,
@@ -145,7 +156,9 @@ export async function getOrg<TeamSelect extends Prisma.TeamSelect>({
lookupBy,
forOrgWithSlug: forOrgWithSlug,
teamSelect,
}: Omit<GetTeamOrOrgArg<TeamSelect>, "isOrg">): Promise<TeamGetPayloadWithParsedMetadata<TeamSelect>> {
}: Omit<GetTeamOrOrgArg<TeamSelect>, "isOrg">): Promise<
TeamGetPayloadWithParsedMetadata<TeamSelect>
> {
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<number | null> {
async findOrganizationIdBySlug({
slug,
}: {
slug: string;
}): Promise<number | null> {
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<UserResult[]>`
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 },
+9
View File
@@ -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
>;
@@ -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<void> {
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
}
}
+6 -1
View File
@@ -1,5 +1,4 @@
import { IS_PRODUCTION } from "@calcom/lib/constants";
import type { TaskHandler, TaskTypes } from "../tasker";
/**
@@ -33,6 +32,12 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
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),
};
@@ -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<void> {
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;
}
}
@@ -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<void> {
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;
}
}