feat(trial-notifications): add email notification for expiring trials and integrate with webhook

This commit is contained in:
Mauricio Siu
2026-09-18 03:03:51 -06:00
parent 8236c31fcd
commit a0e1001808
3 changed files with 51 additions and 127 deletions
+8
View File
@@ -9,6 +9,7 @@ import {
sendInvoiceEmail,
sendPaymentFailedEmail,
} from "@/server/utils/stripe-notifications";
import { sendTrialExpiringEmail } from "@/server/utils/trial-notifications";
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
@@ -72,6 +73,7 @@ export default async function handler(
"invoice.payment_failed",
"customer.deleted",
"checkout.session.completed",
"customer.subscription.trial_will_end",
];
if (!webhooksAllowed.includes(event.type)) {
@@ -110,6 +112,12 @@ export default async function handler(
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
break;
}
case "customer.subscription.trial_will_end": {
const trialingSubscription = event.data.object as Stripe.Subscription;
await sendTrialExpiringEmail(stripe, trialingSubscription, event.created);
break;
}
case "customer.subscription.created": {
const newSubscription = event.data.object as Stripe.Subscription;
-5
View File
@@ -16,7 +16,6 @@ import {
import { config } from "dotenv";
import next from "next";
import packageInfo from "../package.json";
import { initTrialNotificationsCronJob } from "./utils/trial-notifications";
import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs";
import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal";
import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats";
@@ -70,10 +69,6 @@ void app.prepare().then(async () => {
}
await initEnterpriseBackupCronJobs();
if (IS_CLOUD) {
initTrialNotificationsCronJob();
}
if (!IS_CLOUD) {
console.log("Starting Deployment Worker");
const { startDeploymentWorker } = await import("./queues/queueSetup");
+43 -122
View File
@@ -1,47 +1,64 @@
import { IS_CLOUD } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import TrialExpiringEmail from "@dokploy/server/emails/emails/trial-expiring";
import { sendEmail } from "@dokploy/server/verification/send-verification-email";
import { render } from "@react-email/components";
import { format } from "date-fns";
import { eq } from "drizzle-orm";
import { scheduleJob } from "node-schedule";
import type Stripe from "stripe";
import { user } from "@/server/db/schema";
import { getStripeClient, planFromPriceIds } from "@/server/utils/billing";
import { planFromPriceIds } from "@/server/utils/billing";
import { WEBSITE_URL } from "@/server/utils/stripe";
const REMINDER_THRESHOLDS = [
{ days: 3, metadataKey: "trialReminder3Sent" },
{ days: 1, metadataKey: "trialReminder1Sent" },
] as const;
const PLAN_LABELS: Record<string, string> = {
hobby: "Hobby",
startup: "Startup",
legacy: "Legacy",
};
const daysUntil = (timestamp: number) =>
Math.ceil((timestamp * 1000 - Date.now()) / (1000 * 60 * 60 * 24));
const customerIdOf = (subscription: Stripe.Subscription) =>
typeof subscription.customer === "string"
? subscription.customer
: subscription.customer.id;
const hasPaymentMethod = async (
stripe: Stripe,
subscription: Stripe.Subscription,
) => {
if (subscription.default_payment_method) return true;
const customer = await stripe.customers.retrieve(customerIdOf(subscription));
if (customer.deleted) return true;
return !!customer.invoice_settings?.default_payment_method;
};
export const sendTrialExpiringEmail = async (
stripe: Stripe,
subscription: Stripe.Subscription,
// Stripe retries a failing webhook for days, so the countdown is measured
// from when Stripe emitted the event rather than from the current time.
notifiedAtSeconds: number,
) => {
if (!subscription.trial_end) return false;
if (await hasPaymentMethod(stripe, subscription)) return false;
const owner = await db.query.user.findFirst({
where: eq(user.stripeCustomerId, customerIdOf(subscription)),
});
if (!owner || owner.isEnterpriseCloud) return false;
const trialEndsAt = new Date(subscription.trial_end * 1000);
const daysRemaining = Math.max(
1,
Math.ceil((subscription.trial_end - notifiedAtSeconds) / (60 * 60 * 24)),
);
const plan = planFromPriceIds(
subscription.items.data.map((item) => (item.price as Stripe.Price).id),
);
const sendTrialExpiringEmail = async ({
email,
firstName,
planName,
daysRemaining,
trialEndsAt,
}: {
email: string;
firstName: string;
planName: string;
daysRemaining: number;
trialEndsAt: Date;
}) => {
const htmlContent = await render(
TrialExpiringEmail({
userName: firstName || "User",
planName,
userName: owner.firstName || "User",
planName: plan ? (PLAN_LABELS[plan] ?? plan) : "Trial",
daysRemaining,
endsOn: format(trialEndsAt, "MMM dd, yyyy"),
billingUrl: `${WEBSITE_URL}/dashboard/settings/billing`,
@@ -49,109 +66,13 @@ const sendTrialExpiringEmail = async ({
);
await sendEmail({
email,
email: owner.email,
subject:
daysRemaining === 1
? "Your Dokploy trial ends tomorrow"
: `Your Dokploy trial ends in ${daysRemaining} days`,
text: htmlContent,
});
};
const hasPaymentMethod = (subscription: Stripe.Subscription) => {
if (subscription.default_payment_method) return true;
const customer = subscription.customer as Stripe.Customer | string;
if (typeof customer === "string") return false;
return !!customer.invoice_settings?.default_payment_method;
};
const processSubscription = async (
stripe: Stripe,
subscription: Stripe.Subscription,
) => {
if (!subscription.trial_end || hasPaymentMethod(subscription)) return false;
const daysRemaining = daysUntil(subscription.trial_end);
const threshold = REMINDER_THRESHOLDS.find(
(candidate) =>
daysRemaining <= candidate.days &&
!subscription.metadata?.[candidate.metadataKey],
);
if (!threshold) return false;
const customerId =
typeof subscription.customer === "string"
? subscription.customer
: subscription.customer.id;
const owner = await db.query.user.findFirst({
where: eq(user.stripeCustomerId, customerId),
});
if (!owner || owner.isEnterpriseCloud) return false;
const priceIds = subscription.items.data.map(
(item) => (item.price as Stripe.Price).id,
);
const plan = planFromPriceIds(priceIds);
// Claim the reminder before sending so concurrent replicas don't double-send.
await stripe.subscriptions.update(subscription.id, {
metadata: { ...subscription.metadata, [threshold.metadataKey]: "sent" },
});
try {
await sendTrialExpiringEmail({
email: owner.email,
firstName: owner.firstName,
planName: plan ? (PLAN_LABELS[plan] ?? plan) : "Trial",
daysRemaining: Math.max(daysRemaining, 1),
trialEndsAt: new Date(subscription.trial_end * 1000),
});
} catch (error) {
await stripe.subscriptions.update(subscription.id, {
metadata: { ...subscription.metadata, [threshold.metadataKey]: "" },
});
throw error;
}
return true;
};
const processTrialExpirations = async () => {
const stripe = getStripeClient();
let sent = 0;
let scanned = 0;
for await (const subscription of stripe.subscriptions.list({
status: "trialing",
limit: 100,
expand: ["data.customer", "data.items.data.price"],
})) {
scanned += 1;
try {
if (await processSubscription(stripe, subscription)) sent += 1;
} catch (error) {
console.error(
`Trial reminder failed for subscription ${subscription.id}:`,
error instanceof Error ? error.message : error,
);
}
}
return { scanned, sent };
};
export const initTrialNotificationsCronJob = () => {
if (!IS_CLOUD) return;
scheduleJob("trial-expiration-reminders", "0 14 * * *", async () => {
try {
await processTrialExpirations();
} catch (error) {
console.error(
"Trial reminder job failed:",
error instanceof Error ? error.message : error,
);
}
});
};