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:
co-authored by
unknown
Claude Opus 4.5
Devin AI
parent
3c1d906808
commit
52f3ddaf74
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+25
@@ -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 };
|
||||
}
|
||||
}
|
||||
+27
@@ -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>;
|
||||
+34
-1
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
+16
@@ -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 };
|
||||
},
|
||||
});
|
||||
+16
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user