Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -3764,6 +3764,18 @@
"cal_ai_credit_limit_reached_message": "Your Cal.com team {{teamName}} has run out of credits. As a result, Cal.ai phone calls are now disabled. To resume using Cal.ai phone calls, please purchase additional credits.",
"cal_ai_credit_limit_reached_message_user": "Your Cal.com account has run out of credits. As a result, Cal.ai phone calls are now disabled. To resume using Cal.ai phone calls, please purchase additional credits.",
"current_credit_balance": "Current balance: {{balance}} credits",
"proration_invoice_subject": "Invoice for additional seats - {{teamName}} (${{amount}})",
"proration_invoice_auto_charge_message": "An invoice for ${{amount}} has been created for {{seats}} additional seat(s) added to your team {{teamName}} during {{month}}. This amount will be automatically charged to your default payment method.",
"proration_invoice_manual_payment_message": "An invoice for ${{amount}} has been created for {{seats}} additional seat(s) added to your team {{teamName}} during {{month}}. Please pay this invoice to complete the billing for your additional seats.",
"proration_description": "Description",
"proration_line_item": "{{seats}} additional seat(s) - {{month}}",
"amount": "Amount",
"pay_invoice": "Pay Invoice",
"pay_invoice_now": "Pay Invoice Now",
"view_billing_settings": "View Billing Settings",
"proration_reminder_subject": "Payment Reminder - {{teamName}}",
"proration_reminder_message": "This is a reminder that your invoice of ${{amount}} for team {{teamName}} is still unpaid.",
"proration_reminder_warning": "If this invoice remains unpaid, you will not be able to add new users to your team until payment is received.",
"current_balance": "Current balance:",
"notification_about_your_booking": "Notification about your booking",
"monthly_credits": "Monthly credits",
Expand Down
113 changes: 106 additions & 7 deletions packages/emails/billing-email-service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { TFunction } from "i18next";

import type BaseEmail from "@calcom/emails/templates/_base-email";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { CreditUsageType } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";

import OrganizerPaymentRefundFailedEmail from "./templates/organizer-payment-refund-failed-email";
import NoShowFeeChargedEmail from "./templates/no-show-fee-charged-email";
import CreditBalanceLowWarningEmail from "./templates/credit-balance-low-warning-email";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { TFunction } from "i18next";
import CreditBalanceLimitReachedEmail from "./templates/credit-balance-limit-reached-email";
import CreditBalanceLowWarningEmail from "./templates/credit-balance-low-warning-email";
import NoShowFeeChargedEmail from "./templates/no-show-fee-charged-email";
import OrganizerPaymentRefundFailedEmail from "./templates/organizer-payment-refund-failed-email";
import ProrationInvoiceEmail from "./templates/proration-invoice-email";
import ProrationReminderEmail from "./templates/proration-reminder-email";

const sendEmail = (prepare: () => BaseEmail) => {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -127,3 +127,102 @@ export const sendCreditBalanceLimitReachedEmails = async ({
await sendEmail(() => new CreditBalanceLimitReachedEmail({ user, creditFor }));
}
};

export const sendProrationInvoiceEmails = async ({
team,
proration,
invoiceUrl,
isAutoCharge,
adminAndOwners,
}: {
team: {
id: number;
name: string | null;
};
proration: {
monthKey: string;
netSeatIncrease: number;
proratedAmount: number;
};
invoiceUrl?: string | null;
isAutoCharge: boolean;
adminAndOwners: {
id: number;
name: string | null;
email: string;
t: TFunction;
}[];
}) => {
if (!adminAndOwners.length) return;

const emailsToSend: Promise<unknown>[] = [];

for (const admin of adminAndOwners) {
emailsToSend.push(
sendEmail(
() =>
new ProrationInvoiceEmail({
user: admin,
team,
proration,
invoiceUrl,
isAutoCharge,
})
)
);
}

const results = await Promise.allSettled(emailsToSend);
const failures = results.filter((r) => r.status === "rejected");
if (failures.length > 0) {
console.error(`${failures.length} email(s) failed to send`, failures);
}
};

export const sendProrationReminderEmails = async ({
team,
proration,
invoiceUrl,
adminAndOwners,
}: {
team: {
id: number;
name: string | null;
};
proration: {
monthKey: string;
netSeatIncrease: number;
proratedAmount: number;
};
invoiceUrl?: string | null;
adminAndOwners: {
id: number;
name: string | null;
email: string;
t: TFunction;
}[];
}) => {
if (!adminAndOwners.length) return;

const emailsToSend: Promise<unknown>[] = [];

for (const admin of adminAndOwners) {
emailsToSend.push(
sendEmail(
() =>
new ProrationReminderEmail({
user: admin,
team,
proration,
invoiceUrl,
})
)
);
}

const results = await Promise.allSettled(emailsToSend);
const failures = results.filter((r) => r.status === "rejected");
if (failures.length > 0) {
console.error(`${failures.length} email(s) failed to send`, failures);
}
};
111 changes: 111 additions & 0 deletions packages/emails/src/templates/ProrationInvoiceEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { TFunction } from "i18next";

import { CallToAction, V2BaseEmailHtml } from "../components";

export interface ProrationInvoiceEmailProps {
user: {
name: string;
email: string;
t: TFunction;
};
team: {
id: number;
name: string;
};
proration: {
monthKey: string;
netSeatIncrease: number;
proratedAmount: number;
};
invoiceUrl?: string | null;
isAutoCharge: boolean;
}

export const ProrationInvoiceEmail = (props: ProrationInvoiceEmailProps) => {
const { user, team, proration, invoiceUrl, isAutoCharge } = props;
const { t } = user;
const formattedAmount = (proration.proratedAmount / 100).toFixed(2);

return (
<V2BaseEmailHtml
subject={t("proration_invoice_subject", {
teamName: team.name,
amount: formattedAmount,
interpolation: { escapeValue: false },
})}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
{t("hi_user_name", { name: user.name, interpolation: { escapeValue: false } })},
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "24px" }}>
{isAutoCharge
? t("proration_invoice_auto_charge_message", {
teamName: team.name,
seats: proration.netSeatIncrease,
amount: formattedAmount,
month: proration.monthKey,
interpolation: { escapeValue: false },
})
: t("proration_invoice_manual_payment_message", {
teamName: team.name,
seats: proration.netSeatIncrease,
amount: formattedAmount,
month: proration.monthKey,
interpolation: { escapeValue: false },
})}
</p>

{/* Invoice Details Table */}
<div style={{ marginBottom: "24px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: "1px solid #E5E7EB",
padding: "12px 0",
}}>
<span style={{ fontWeight: 500, color: "#374151" }}>{t("proration_description")}</span>
<span style={{ fontWeight: 500, color: "#374151" }}>{t("amount")}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: "1px solid #E5E7EB",
padding: "12px 0",
}}>
<span style={{ color: "#6B7280" }}>
{t("proration_line_item", {
seats: proration.netSeatIncrease,
month: proration.monthKey,
})}
</span>
<span style={{ color: "#6B7280" }}>${formattedAmount}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
padding: "12px 0",
}}>
<span style={{ fontWeight: 600, color: "#111827" }}>{t("total")}</span>
<span style={{ fontWeight: 600, color: "#111827" }}>${formattedAmount}</span>
</div>
</div>

<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}>
<a
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
style={{ color: "#6B7280", fontSize: "14px", textDecoration: "none" }}>
{t("view_billing_settings")}
</a>
{!isAutoCharge && invoiceUrl && <CallToAction label={t("pay_invoice")} href={invoiceUrl} />}
</div>
</V2BaseEmailHtml>
);
};
113 changes: 113 additions & 0 deletions packages/emails/src/templates/ProrationReminderEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { TFunction } from "i18next";

import { CallToAction, V2BaseEmailHtml } from "../components";

export interface ProrationReminderEmailProps {
user: {
name: string;
email: string;
t: TFunction;
};
team: {
id: number;
name: string;
};
proration: {
monthKey: string;
netSeatIncrease: number;
proratedAmount: number;
};
invoiceUrl?: string | null;
}

export const ProrationReminderEmail = (props: ProrationReminderEmailProps) => {
const { user, team, proration, invoiceUrl } = props;
const { t } = user;
const formattedAmount = (proration.proratedAmount / 100).toFixed(2);

return (
<V2BaseEmailHtml
subject={t("proration_reminder_subject", {
teamName: team.name,
interpolation: { escapeValue: false },
})}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
{t("hi_user_name", { name: user.name, interpolation: { escapeValue: false } })},
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "24px" }}>
{t("proration_reminder_message", {
teamName: team.name,
amount: formattedAmount,
interpolation: { escapeValue: false },
})}
</p>

{/* Warning Banner */}
<p
style={{
backgroundColor: "#FEF3C7",
borderLeft: "4px solid #F59E0B",
padding: "12px 16px",
marginBottom: "24px",
color: "#92400E",
fontWeight: 500,
lineHeight: "20px",
}}>
{t("proration_reminder_warning")}
</p>

{/* Invoice Details Table */}
<div style={{ marginBottom: "24px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: "1px solid #E5E7EB",
padding: "12px 0",
}}>
<span style={{ fontWeight: 500, color: "#374151" }}>{t("proration_description")}</span>
<span style={{ fontWeight: 500, color: "#374151" }}>{t("amount")}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: "1px solid #E5E7EB",
padding: "12px 0",
}}>
<span style={{ color: "#6B7280" }}>
{t("proration_line_item", {
seats: proration.netSeatIncrease,
month: proration.monthKey,
})}
</span>
<span style={{ color: "#6B7280" }}>${formattedAmount}</span>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
padding: "12px 0",
}}>
<span style={{ fontWeight: 600, color: "#111827" }}>{t("total")}</span>
<span style={{ fontWeight: 600, color: "#111827" }}>${formattedAmount}</span>
</div>
</div>

<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}>
<a
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
style={{ color: "#6B7280", fontSize: "14px", textDecoration: "none" }}>
{t("view_billing_settings")}
</a>
{invoiceUrl && <CallToAction label={t("pay_invoice_now")} href={invoiceUrl} />}
</div>
</V2BaseEmailHtml>
);
};
Loading
Loading