mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 05:11:26 +00:00
feat(trial-notifications): add email notification for expiring trials and integrate with webhook
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user