diff --git a/apps/dokploy/pages/api/stripe/webhook.ts b/apps/dokploy/pages/api/stripe/webhook.ts index 703bd9fc7..e495a40d4 100644 --- a/apps/dokploy/pages/api/stripe/webhook.ts +++ b/apps/dokploy/pages/api/stripe/webhook.ts @@ -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; diff --git a/apps/dokploy/server/server.ts b/apps/dokploy/server/server.ts index f94839f8f..5fe048c92 100644 --- a/apps/dokploy/server/server.ts +++ b/apps/dokploy/server/server.ts @@ -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"); diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts index 2a7b9c90e..1c7e0e515 100644 --- a/apps/dokploy/server/utils/trial-notifications.ts +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -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 = { 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, - ); - } - }); -};