From 95b2712c26ac5dd3fc0d94e16a8edbbe1a6a8f7e Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:35:27 -0600 Subject: [PATCH 1/7] fix(billing): pick a plan for the trial and warn before it expires Trials were always Hobby, so a trial user who wanted Startup had to go through Checkout, which created a second subscription on the same customer. Seven days later Stripe cancelled the trial sub and the webhook, matching only on customer id, zeroed serversQuantity and deactivated the servers of someone who was already paying. The trial now starts on the plan the user chooses and switching plans mutates that same subscription, so a customer never holds two. - startFreeTrial takes a tier; TRIAL_SERVER_LIMIT becomes TRIAL_SERVER_LIMITS (Hobby 1 server, Startup 3) - onboarding offers both plans with a trial and a subscribe button - billing exposes Switch to Startup/Hobby during the trial and hides the checkout CTAs and server steppers that would create a second subscription - upgradeSubscription caps the server count to the plan's included servers while trialing and skips proration, which emitted an orphan invoice - webhook treats trialing as active instead of disabling servers, and guards invoices that carry no subscription - getBillingStatus reports hasPaymentMethod, so the banners stop asking for a card once one is on file - new cloud-only daily job emails trial users without a payment method at 3 and 1 days left, deduped through subscription metadata claimed before the send so concurrent replicas cannot double-send --- .../dashboard/billing/trial-banner.tsx | 23 +- .../onboarding/onboarding-wizard.tsx | 5 +- .../dashboard/onboarding/steps/plan-step.tsx | 237 +++++----- .../onboarding/steps/project-step.tsx | 2 +- .../onboarding/steps/server-step.tsx | 24 +- .../onboarding/steps/welcome-step.tsx | 4 +- .../settings/billing/show-billing.tsx | 435 ++++++++++-------- apps/dokploy/pages/api/stripe/webhook.ts | 47 +- apps/dokploy/server/api/routers/stripe.ts | 201 +++++--- apps/dokploy/server/server.ts | 5 + apps/dokploy/server/utils/billing.ts | 20 +- .../server/utils/trial-notifications.ts | 157 +++++++ .../src/emails/emails/trial-expiring.tsx | 141 ++++++ 13 files changed, 874 insertions(+), 427 deletions(-) create mode 100644 apps/dokploy/server/utils/trial-notifications.ts create mode 100644 packages/server/src/emails/emails/trial-expiring.tsx diff --git a/apps/dokploy/components/dashboard/billing/trial-banner.tsx b/apps/dokploy/components/dashboard/billing/trial-banner.tsx index 0c5bfdffb..caf3634f1 100644 --- a/apps/dokploy/components/dashboard/billing/trial-banner.tsx +++ b/apps/dokploy/components/dashboard/billing/trial-banner.tsx @@ -6,28 +6,41 @@ import { api } from "@/utils/api"; export const TrialBanner = () => { const router = useRouter(); const { data: billingStatus } = api.stripe.getBillingStatus.useQuery(); + const { mutateAsync: createCustomerPortalSession, isPending } = + api.stripe.createCustomerPortalSession.useMutation(); - if (!billingStatus?.isOnTrial) { + const handleAddPaymentMethod = async () => { + const session = await createCustomerPortalSession().catch(() => null); + if (session?.url) { + window.open(session.url); + return; + } + router.push("/dashboard/settings/billing"); + }; + + if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) { return null; } const daysRemaining = billingStatus.trialDaysRemaining ?? 0; + const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`; return (
{daysRemaining > 0 - ? `You have ${daysRemaining} day${daysRemaining === 1 ? "" : "s"} left in your free trial.` - : "Your free trial ends today."} + ? `You have ${daysLabel} left in your free trial. Add a payment method to keep your servers.` + : "Your free trial ends today. Add a payment method to keep your servers."}
); diff --git a/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx index da29cae7c..95780bf63 100644 --- a/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx +++ b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx @@ -278,8 +278,9 @@ export const OnboardingWizard = ({ onClose }: Props) => { Skip onboarding? If this is your first time using Dokploy, we recommend going - through these steps — it only takes a couple of minutes and gives - you a feel for how projects, servers and deployments fit together. + through these steps – it only takes a couple of minutes and gives + you a feel for how projects, servers, and deployments fit + together. diff --git a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx index 9fe8a4947..9863aaf38 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx @@ -15,13 +15,50 @@ const stripePromise = loadStripe( process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, ); +type Tier = "hobby" | "startup"; + +const PLANS: { + tier: Tier; + name: string; + description: string; + price: number; + features: string[]; + recommended?: boolean; +}[] = [ + { + tier: "hobby", + name: "Hobby", + description: "For individual developers", + price: calculatePriceHobby(1, false), + features: [ + "Setup 1 server", + "Unlimited apps & databases", + "2 environments", + "Community support", + ], + }, + { + tier: "startup", + name: "Startup", + description: "For small to mid-size teams", + price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false), + recommended: true, + features: [ + `Setup up to ${STARTUP_SERVERS_INCLUDED} servers`, + "Unlimited users & environments", + "Basic RBAC + 2FA", + "Email & chat support", + ], + }, +]; + interface Props { onNext: () => void; } export const PlanStep = ({ onNext }: Props) => { - const [loadingTier, setLoadingTier] = useState< - "hobby" | "startup" | "trial" | null + const [loading, setLoading] = useState< + `${"trial" | "checkout"}:${Tier}` | null >(null); const { data } = api.stripe.getProducts.useQuery(); const { mutateAsync: createCheckoutSession } = @@ -30,12 +67,12 @@ export const PlanStep = ({ onNext }: Props) => { api.stripe.startFreeTrial.useMutation(); const utils = api.useUtils(); - const handleCheckout = async (tier: "hobby" | "startup") => { + const handleCheckout = async (tier: Tier) => { if (!data) return; const productId = tier === "hobby" ? data.hobbyProductId : data.startupProductId; if (!productId) return; - setLoadingTier(tier); + setLoading(`checkout:${tier}`); try { const stripe = await stripePromise; const session = await createCheckoutSession({ @@ -47,14 +84,14 @@ export const PlanStep = ({ onNext }: Props) => { await stripe?.redirectToCheckout({ sessionId: session.sessionId }); } catch { toast.error("Error starting checkout"); - setLoadingTier(null); + setLoading(null); } }; - const handleTrial = async () => { - setLoadingTier("trial"); + const handleTrial = async (tier: Tier) => { + setLoading(`trial:${tier}`); try { - await startFreeTrial(); + await startFreeTrial({ tier }); await utils.project.onboardingStatus.invalidate(); toast.success("Your 7-day trial has started"); onNext(); @@ -63,7 +100,7 @@ export const PlanStep = ({ onNext }: Props) => { error instanceof Error ? error.message : "Error starting trial", ); } finally { - setLoadingTier(null); + setLoading(null); } }; @@ -79,134 +116,72 @@ export const PlanStep = ({ onNext }: Props) => { Start free, upgrade when ready.

- No credit card for the trial — add one only if you decide to stay. + Try any plan free for 7 days. No credit card required — add one only + if you decide to stay.

-
-
-
- - Recommended - -

- 7-day free trial -

-

- No card required — cancel anytime. -

-
    - {[ - "Setup 1 server", - "Unlimited apps & databases", - "Community support", - ].map((f) => ( -
  • - - {f} -
  • - ))} -
-
- -
-
-
-
-
-

Hobby

-

- For individual developers -

-

- ${calculatePriceHobby(1, false).toFixed(2)} - - {" "} - /mo - -

-
    - {[ - "Setup 1 server", - "Unlimited apps & databases", - "2 environments", - "Community support", - ].map((f) => ( -
  • { + const productId = + plan.tier === "hobby" + ? data?.hobbyProductId + : data?.startupProductId; + return ( +
    +
    + {plan.recommended && ( + + Recommended + + )} +

    {plan.name}

    +

    + {plan.description} +

    +

    + ${plan.price.toFixed(2)} + + {" "} + /mo + +

    +
      + {plan.features.map((f) => ( +
    • + + {f} +
    • + ))} +
    +
    +
    +
  • - ))} -
-
- -
- -
-
-

Startup

-

- For small to mid-size teams -

-

- $ - {calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed( - 2, - )} - - {" "} - /mo - -

-
    - {[ - `Setup up to ${STARTUP_SERVERS_INCLUDED} servers`, - "Unlimited users & environments", - "Basic RBAC + 2FA", - "Email & chat support", - ].map((f) => ( -
  • + +
  • - ))} -
-
- -
+ Subscribe now + +
+ + ); + })} ); diff --git a/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx index 24c37d9d9..ccc43b030 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx @@ -65,7 +65,7 @@ export const ProjectStep = ({ onNext, plainTitle }: Props) => {

Create your first project.

- Projects group your apps, databases and environments together. + Projects group your apps, databases, and environments together.

diff --git a/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx index 08efb12c3..7af9b6ee3 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx @@ -1,6 +1,12 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import copy from "copy-to-clipboard"; -import { CheckCircle2, CopyIcon, Loader2, XCircle } from "lucide-react"; +import { + CheckCircle2, + CopyIcon, + InfoIcon, + Loader2, + XCircle, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -200,9 +206,19 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {

Connect a server.

- Dokploy deploys to servers you own — buy one from any VPS provider + Dokploy deploys to servers you own. Buy one from any VPS provider (Hetzner, DigitalOcean, Hostinger...) and paste its IP below.

+
+ +
+ Requirements + + To ensure a smooth experience with Dokploy, your server should + have at least 1GB of RAM and 30GB of disk space. + +
+
{!createdServerId ? ( @@ -287,7 +303,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => { {isSettingUp ? (
- Setting up your server — installing Docker and dependencies... + Setting up your server – installing Docker and dependencies…
) : isValidating && !validation ? (
@@ -311,7 +327,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
{!isReady && !isSettingUp && ( - Setup usually takes a minute after the server boots — this checks + Setup runs after the server boots and this page checks for it automatically. You can retry setup, or use "Skip for now" above to continue and come back to this later. diff --git a/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx index 967075452..6f5ef9a1e 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx @@ -39,8 +39,8 @@ export const WelcomeStep = ({ onNext }: Props) => { Let's get your first app live.

- A few steps — pick a plan, connect a server, ship something. You'll - have a working URL by the end. + It just takes a few steps: pick a plan, connect a server, ship + something. You'll have a working URL by the end.

diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index a85b3242e..b66a23ca4 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -105,13 +105,47 @@ export const ShowBilling = () => { api.stripe.upgradeSubscription.useMutation(); const { mutateAsync: updateInvoiceNotifications } = api.stripe.updateInvoiceNotifications.useMutation(); - const { mutateAsync: startFreeTrial, isPending: isStartingTrial } = + const { mutateAsync: startFreeTrial } = api.stripe.startFreeTrial.useMutation(); const utils = api.useUtils(); + const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null); + const [switchingTrial, setSwitchingTrial] = useState(false); - const handleStartTrial = async () => { + const handleSwitchTrialPlan = async (tier: "hobby" | "startup") => { + const planName = tier === "startup" ? "Startup" : "Hobby"; + + const switchPlan = async () => { + await upgradeSubscription({ + tier, + serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1, + isAnnual: false, + }); + await new Promise((resolve) => setTimeout(resolve, 3000)); + await Promise.all([ + utils.stripe.getBillingStatus.invalidate(), + utils.stripe.getProducts.invalidate(), + utils.user.get.invalidate(), + utils.server.count.invalidate(), + ]); + }; + + setSwitchingTrial(true); + await toast + .promise(switchPlan(), { + loading: `Switching to ${planName}...`, + success: `Your trial is now on the ${planName} plan`, + error: (error) => + error instanceof Error ? error.message : "Error switching plan", + }) + .unwrap() + .catch(() => undefined); + setSwitchingTrial(false); + }; + + const handleStartTrial = async (tier: "hobby" | "startup") => { + setTrialTier(tier); try { - await startFreeTrial(); + await startFreeTrial({ tier }); await Promise.all([ utils.stripe.getBillingStatus.invalidate(), utils.stripe.getProducts.invalidate(), @@ -122,6 +156,8 @@ export const ShowBilling = () => { toast.error( error instanceof Error ? error.message : "Error starting trial", ); + } finally { + setTrialTier(null); } }; @@ -170,12 +206,23 @@ export const ShowBilling = () => { const useNewPricing = data?.hobbyProductId && data?.startupProductId; const products = data?.products.filter((product) => { - // @ts-ignore + // @ts-expect-error const interval = product?.default_price?.recurring?.interval; return isAnnual ? interval === "year" : interval === "month"; }); const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false; + const canStartTrial = + !isEnterpriseCloud && + !!billingStatus && + !billingStatus.plan && + !billingStatus.isOnTrial && + !billingStatus.hasUsedTrial; + /** Checkout would create a second subscription alongside a running trial. */ + const canSubscribe = + !isEnterpriseCloud && + (data?.subscriptions?.length ?? 0) === 0 && + !billingStatus?.isOnTrial; const maxServers = admin?.user.serversQuantity ?? 1; const percentage = ((servers ?? 0) / maxServers) * 100; const safePercentage = Math.min(percentage, 100); @@ -296,67 +343,72 @@ export const ShowBilling = () => { billingStatus.trialEndsAt ? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}` : "" + }${ + billingStatus.hasPaymentMethod + ? " · billing starts automatically" + : " · add a card to keep your servers" }` : "You're subscribed and billed automatically."} - {billingStatus.isOnTrial && - admin?.user.stripeCustomerId && ( + {admin?.user.stripeCustomerId && ( +
+ {billingStatus.isOnTrial && + useNewPricing && + billingStatus.plan && ( + + )} - )} -
- )} - {!isEnterpriseCloud && - billingStatus && - !billingStatus.plan && - !billingStatus.isOnTrial && - !billingStatus.hasUsedTrial && ( -
-
- -
- - 7-day free trial - - - No credit card required — cancel anytime. - -
    - {[ - "Setup 1 server", - "Unlimited apps & databases", - "Community support", - ].map((feature) => ( -
  • - - {feature} -
  • - ))} -
-
- + )}
)} + {canStartTrial && ( +
+ +
+ + 7-day free trial on any plan + + + No credit card required — pick Hobby or Startup below. + +
+
+ )} {(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (

Servers Plan

@@ -933,68 +985,71 @@ export const ShowBilling = () => { ))}
-
- - Servers: - - - - setHobbyServerQuantity( - Math.max( - 1, - Number( - (e.target as HTMLInputElement).value, - ) || 1, - ), - ) - } - className="text-center" - /> - -
-
- {admin?.user.stripeCustomerId && ( + {canSubscribe && ( +
+ + Servers: + + + setHobbyServerQuantity( + Math.max( + 1, + Number( + (e.target as HTMLInputElement).value, + ) || 1, + ), + ) + } + className="text-center" + /> + +
+ )} +
+ {canStartTrial && ( + + )} + {canSubscribe && ( + )} - {!isEnterpriseCloud && - (data?.subscriptions?.length ?? 0) === 0 && ( - - )}
@@ -1067,85 +1122,83 @@ export const ShowBilling = () => { ))}
-
- - Servers (min. {STARTUP_SERVERS_INCLUDED} included) - -
- - - setStartupServerQuantity( - Math.max( - STARTUP_SERVERS_INCLUDED, - Number( - (e.target as HTMLInputElement).value, - ) || STARTUP_SERVERS_INCLUDED, - ), - ) - } - className="h-8 text-center" - /> - -
-
-
- {admin?.user.stripeCustomerId && ( - - )} - {!isEnterpriseCloud && - (data?.subscriptions?.length ?? 0) === 0 && ( + {canSubscribe && ( +
+ + Servers (min. {STARTUP_SERVERS_INCLUDED} included) + +
- )} + + setStartupServerQuantity( + Math.max( + STARTUP_SERVERS_INCLUDED, + Number( + (e.target as HTMLInputElement).value, + ) || STARTUP_SERVERS_INCLUDED, + ), + ) + } + className="h-8 text-center" + /> + +
+
+ )} +
+ {canStartTrial && ( + + )} + {canSubscribe && ( + + )}
@@ -1337,31 +1390,17 @@ export const ShowBilling = () => {
- {admin?.user.stripeCustomerId && ( + {canSubscribe && ( )} - {!isEnterpriseCloud && - (data?.subscriptions?.length ?? 0) === 0 && ( - - )}
diff --git a/apps/dokploy/pages/api/stripe/webhook.ts b/apps/dokploy/pages/api/stripe/webhook.ts index ef8f90632..703bd9fc7 100644 --- a/apps/dokploy/pages/api/stripe/webhook.ts +++ b/apps/dokploy/pages/api/stripe/webhook.ts @@ -176,7 +176,10 @@ export default async function handler( break; } - if (newSubscription.status === "active") { + if ( + newSubscription.status === "active" || + newSubscription.status === "trialing" + ) { const serversQuantity = getSubscriptionServersQuantity( newSubscription?.items?.data ?? [], ); @@ -209,9 +212,12 @@ export default async function handler( case "invoice.payment_succeeded": { const newInvoice = event.data.object as Stripe.Invoice; - const subscription = await stripe.subscriptions.retrieve( - newInvoice.subscription as string, - ); + const subscriptionId = getInvoiceSubscriptionId(newInvoice); + if (!subscriptionId) { + break; + } + + const subscription = await stripe.subscriptions.retrieve(subscriptionId); if (subscription.status !== "active") { console.log( @@ -220,6 +226,14 @@ export default async function handler( break; } + const admin = await findUserByStripeCustomerId( + subscription.customer as string, + ); + + if (!admin) { + return res.status(400).send("Webhook Error: Admin not found"); + } + const serversQuantity = getSubscriptionServersQuantity( subscription?.items?.data ?? [], ); @@ -233,18 +247,10 @@ export default async function handler( ), ); - const admin = await findUserByStripeCustomerId( - subscription.customer as string, - ); - - if (!admin) { - return res.status(400).send("Webhook Error: Admin not found"); - } if (admin.isEnterpriseCloud) { break; } - const newServersQuantity = admin.serversQuantity; - await updateServersBasedOnQuantity(admin.id, newServersQuantity); + await updateServersBasedOnQuantity(admin.id, serversQuantity); if (admin.sendInvoiceNotifications) { await sendInvoiceEmail(newInvoice, admin); @@ -255,9 +261,12 @@ export default async function handler( case "invoice.payment_failed": { const newInvoice = event.data.object as Stripe.Invoice; - const subscription = await stripe.subscriptions.retrieve( - newInvoice.subscription as string, - ); + const subscriptionId = getInvoiceSubscriptionId(newInvoice); + if (!subscriptionId) { + break; + } + + const subscription = await stripe.subscriptions.retrieve(subscriptionId); if (subscription.status !== "active") { const admin = await findUserByStripeCustomerId( newInvoice.customer as string, @@ -351,6 +360,12 @@ const findUserByStripeCustomerId = async (stripeCustomerId: string) => { return userResult; }; +const getInvoiceSubscriptionId = (invoice: Stripe.Invoice): string | null => { + const subscription = invoice.subscription; + if (typeof subscription === "string") return subscription; + return subscription?.id ?? null; +}; + const activateServer = async (serverId: string) => { await db .update(server) diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts index ce5e15151..fc2abf842 100644 --- a/apps/dokploy/server/api/routers/stripe.ts +++ b/apps/dokploy/server/api/routers/stripe.ts @@ -12,7 +12,7 @@ import { getCurrentPlan as getCurrentPlanForOrganization, getStripeClient, TRIAL_DURATION_DAYS, - TRIAL_SERVER_LIMIT, + TRIAL_SERVER_LIMITS, } from "@/server/utils/billing"; import { type BillingTier, @@ -28,6 +28,10 @@ import { STARTUP_PRODUCT_ID, WEBSITE_URL, } from "@/server/utils/stripe"; +import { + processTrialExpirations, + sendTrialExpiringEmail, +} from "@/server/utils/trial-notifications"; import { adminProcedure, createTRPCRouter, @@ -35,6 +39,27 @@ import { withPermission, } from "../trpc"; +// These reach every trialing customer on the platform, so an org admin is not +// enough; only the Dokploy Cloud root account may run them. +const assertRootAccess = (ctx: { + user: { id: string }; + session?: { impersonatedBy?: string | null } | null; +}) => { + if (!IS_CLOUD) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This feature is only available in Dokploy Cloud", + }); + } + const rootId = process.env.USER_ADMIN_ID; + if ( + !rootId || + (ctx.user.id !== rootId && ctx.session?.impersonatedBy !== rootId) + ) { + throw new TRPCError({ code: "FORBIDDEN", message: "Root access required" }); + } +}; + export const stripeRouter = createTRPCRouter({ /** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */ getCurrentPlan: protectedProcedure.query(async ({ ctx }) => { @@ -45,72 +70,82 @@ export const stripeRouter = createTRPCRouter({ return getBillingStatus(ctx.user.ownerId); }), - startFreeTrial: adminProcedure.mutation(async ({ ctx }) => { - if (!IS_CLOUD) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This feature is only available in Dokploy Cloud", - }); - } - - if (!HOBBY_PRICE_MONTHLY_ID) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Trials are not configured", - }); - } - - const owner = await findUserById(ctx.user.ownerId); - const billingStatus = await getBillingStatus(owner.id); - - if (billingStatus.hasActiveAccess) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You already have an active plan or trial", - }); - } - - if (billingStatus.hasUsedTrial) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You have already used your free trial", - }); - } - - const stripe = getStripeClient(); - - let stripeCustomerId = owner.stripeCustomerId; - if (stripeCustomerId) { - const customer = await stripe.customers.retrieve(stripeCustomerId); - if (customer.deleted) { - stripeCustomerId = null; + startFreeTrial: adminProcedure + .input(z.object({ tier: z.enum(["hobby", "startup"]) })) + .mutation(async ({ ctx, input }) => { + if (!IS_CLOUD) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This feature is only available in Dokploy Cloud", + }); } - } - if (!stripeCustomerId) { - const customer = await stripe.customers.create({ email: owner.email }); - stripeCustomerId = customer.id; - } - const subscription = await stripe.subscriptions.create({ - customer: stripeCustomerId, - items: [{ price: HOBBY_PRICE_MONTHLY_ID, quantity: 1 }], - trial_period_days: TRIAL_DURATION_DAYS, - trial_settings: { end_behavior: { missing_payment_method: "cancel" } }, - metadata: { source: "onboarding_trial", adminId: owner.id }, - }); + const trialPriceId = + input.tier === "startup" + ? STARTUP_BASE_PRICE_MONTHLY_ID + : HOBBY_PRICE_MONTHLY_ID; + if (!trialPriceId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Trials are not configured", + }); + } - await updateUser(owner.id, { - stripeCustomerId, - stripeSubscriptionId: subscription.id, - serversQuantity: TRIAL_SERVER_LIMIT, - }); + const owner = await findUserById(ctx.user.ownerId); + const billingStatus = await getBillingStatus(owner.id); - return { - trialEndsAt: subscription.trial_end - ? new Date(subscription.trial_end * 1000) - : null, - }; - }), + if (billingStatus.hasActiveAccess) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You already have an active plan or trial", + }); + } + + if (billingStatus.hasUsedTrial) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You have already used your free trial", + }); + } + + const stripe = getStripeClient(); + + let stripeCustomerId = owner.stripeCustomerId; + if (stripeCustomerId) { + const customer = await stripe.customers.retrieve(stripeCustomerId); + if (customer.deleted) { + stripeCustomerId = null; + } + } + if (!stripeCustomerId) { + const customer = await stripe.customers.create({ email: owner.email }); + stripeCustomerId = customer.id; + } + + const subscription = await stripe.subscriptions.create({ + customer: stripeCustomerId, + items: [{ price: trialPriceId, quantity: 1 }], + trial_period_days: TRIAL_DURATION_DAYS, + trial_settings: { end_behavior: { missing_payment_method: "cancel" } }, + metadata: { + source: "onboarding_trial", + adminId: owner.id, + tier: input.tier, + }, + }); + + await updateUser(owner.id, { + stripeCustomerId, + stripeSubscriptionId: subscription.id, + serversQuantity: TRIAL_SERVER_LIMITS[input.tier], + }); + + return { + trialEndsAt: subscription.trial_end + ? new Date(subscription.trial_end * 1000) + : null, + }; + }), getProducts: adminProcedure.query(async ({ ctx }) => { const user = await findUserById(ctx.user.ownerId); @@ -343,9 +378,15 @@ export const stripeRouter = createTRPCRouter({ }); } + const isTrialing = subscription.status === "trialing"; + // Trials are capped at the plan's included servers; paid plans can scale. + const serverQuantity = isTrialing + ? TRIAL_SERVER_LIMITS[input.tier] + : input.serverQuantity; + const newItems = getStripeItems( input.tier as BillingTier, - input.serverQuantity, + serverQuantity, input.isAnnual, ); const currentItems = subscription.items.data; @@ -371,7 +412,7 @@ export const stripeRouter = createTRPCRouter({ await stripe.subscriptions.update(owner.stripeSubscriptionId, { items: updateItems, - proration_behavior: "create_prorations", + proration_behavior: isTrialing ? "none" : "create_prorations", }); return { ok: true }; @@ -406,6 +447,36 @@ export const stripeRouter = createTRPCRouter({ return { ok: true }; }), + sendTestTrialEmail: adminProcedure + .input(z.object({ daysRemaining: z.union([z.literal(1), z.literal(3)]) })) + .mutation(async ({ ctx, input }) => { + assertRootAccess(ctx); + + const owner = await findUserById(ctx.user.ownerId); + const billingStatus = await getBillingStatus(owner.id); + + await sendTrialExpiringEmail({ + email: ctx.user.email, + firstName: owner.firstName, + planName: billingStatus.plan + ? billingStatus.plan.charAt(0).toUpperCase() + + billingStatus.plan.slice(1) + : "Hobby", + daysRemaining: input.daysRemaining, + trialEndsAt: + billingStatus.trialEndsAt ?? + new Date(Date.now() + input.daysRemaining * 24 * 60 * 60 * 1000), + }); + + return { sentTo: ctx.user.email }; + }), + + runTrialExpirationJob: adminProcedure.mutation(async ({ ctx }) => { + assertRootAccess(ctx); + + return processTrialExpirations(); + }), + getInvoices: adminProcedure.query(async ({ ctx }) => { const user = await findUserById(ctx.user.ownerId); const stripeCustomerId = user.stripeCustomerId; diff --git a/apps/dokploy/server/server.ts b/apps/dokploy/server/server.ts index 5fe048c92..f94839f8f 100644 --- a/apps/dokploy/server/server.ts +++ b/apps/dokploy/server/server.ts @@ -16,6 +16,7 @@ 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"; @@ -69,6 +70,10 @@ 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/billing.ts b/apps/dokploy/server/utils/billing.ts index f2a98fa02..527ba4194 100644 --- a/apps/dokploy/server/utils/billing.ts +++ b/apps/dokploy/server/utils/billing.ts @@ -16,7 +16,7 @@ export const getStripeClient = () => apiVersion: "2024-09-30.acacia", }); -const planFromPriceIds = (priceIds: string[]): BillingPlan | null => { +export const planFromPriceIds = (priceIds: string[]): BillingPlan | null => { if ( priceIds.some( (id) => @@ -78,7 +78,11 @@ export const getCurrentPlan = async ( }; export const TRIAL_DURATION_DAYS = 7; -export const TRIAL_SERVER_LIMIT = 1; +export type TrialTier = "hobby" | "startup"; +export const TRIAL_SERVER_LIMITS: Record = { + hobby: 1, + startup: 3, +}; export interface BillingStatus { plan: BillingPlan | null; @@ -87,6 +91,7 @@ export interface BillingStatus { trialDaysRemaining: number | null; hasUsedTrial: boolean; hasActiveAccess: boolean; + hasPaymentMethod: boolean; } export const getBillingStatus = async ( @@ -100,6 +105,7 @@ export const getBillingStatus = async ( trialDaysRemaining: null, hasUsedTrial: false, hasActiveAccess: true, + hasPaymentMethod: true, }; } @@ -112,6 +118,7 @@ export const getBillingStatus = async ( trialDaysRemaining: null, hasUsedTrial: false, hasActiveAccess: false, + hasPaymentMethod: false, }; } @@ -119,7 +126,7 @@ export const getBillingStatus = async ( const subscriptions = await stripe.subscriptions.list({ customer: owner.stripeCustomerId, status: "all", - expand: ["data.items.data.price"], + expand: ["data.items.data.price", "data.customer"], }); const relevantSubs = subscriptions.data.filter( @@ -133,6 +140,12 @@ export const getBillingStatus = async ( const trialingSub = subscriptions.data.find( (sub) => sub.status === "trialing", ); + + const currentSub = trialingSub ?? relevantSubs[0]; + const customer = currentSub?.customer as Stripe.Customer | undefined; + const hasPaymentMethod = + !!currentSub?.default_payment_method || + !!customer?.invoice_settings?.default_payment_method; const trialEndsAt = trialingSub?.trial_end ? new Date(trialingSub.trial_end * 1000) : null; @@ -148,6 +161,7 @@ export const getBillingStatus = async ( isOnTrial: !!trialingSub, trialEndsAt, trialDaysRemaining, + hasPaymentMethod, hasUsedTrial: subscriptions.data.length > 0, hasActiveAccess: plan !== null || !!trialingSub, }; diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts new file mode 100644 index 000000000..e12c055c6 --- /dev/null +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -0,0 +1,157 @@ +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 { 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)); + +export 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, + daysRemaining, + endsOn: format(trialEndsAt, "MMM dd, yyyy"), + billingUrl: `${WEBSITE_URL}/dashboard/settings/billing`, + }), + ); + + await sendEmail({ + 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; +}; + +export 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, + ); + } + }); +}; diff --git a/packages/server/src/emails/emails/trial-expiring.tsx b/packages/server/src/emails/emails/trial-expiring.tsx new file mode 100644 index 000000000..68dd48c95 --- /dev/null +++ b/packages/server/src/emails/emails/trial-expiring.tsx @@ -0,0 +1,141 @@ +import { + Body, + Button, + Column, + Container, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Row, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { emailTailwindConfig } from "../tailwind-config"; + +export type TemplateProps = { + userName: string; + planName: string; + daysRemaining: number; + endsOn: string; + billingUrl: string; +}; + +export const TrialExpiringEmail = ({ + userName = "User", + planName = "Hobby", + daysRemaining = 3, + endsOn = "2024-01-01", + billingUrl = "https://app.dokploy.com/dashboard/settings/billing", +}: TemplateProps) => { + const dayLabel = daysRemaining === 1 ? "day" : "days"; + const previewText = + daysRemaining === 1 + ? "Your Dokploy trial ends tomorrow" + : `Your Dokploy trial ends in ${daysRemaining} ${dayLabel}`; + + return ( + + + {previewText} + + + +
+ Dokploy +
+ +
+ + {daysRemaining === 1 + ? "Your trial ends tomorrow" + : `Your trial ends in ${daysRemaining} ${dayLabel}`} + + + Hello {userName}, add a payment method to keep your servers and + applications running once the trial ends. + + +
+ + + + Plan + + + {planName} + + + + + Trial ends + + + {endsOn} + + + +
+ +
+ + Without a payment method your subscription is cancelled when + the trial ends and your servers are deactivated. + +
+ +
+ +
+ +
+ + + You are billed only when the trial ends. You can cancel at any + time before then. + +
+ +
+ + This is an automated email from{" "} + + Dokploy Cloud + + . If you have any questions about your billing, please contact + our{" "} + + support team + + . + +
+
+ +
+ + ); +}; + +export default TrialExpiringEmail; From d00ae319208e5d72bac494e56e2934ae4aeed5b3 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:38:02 -0600 Subject: [PATCH 2/7] refactor(billing): drop the sendTestTrialEmail endpoint The scan endpoint already covers manual runs, so the one-off sender was redundant. sendTrialExpiringEmail goes back to being module private. --- apps/dokploy/server/api/routers/stripe.ts | 29 +------------------ .../server/utils/trial-notifications.ts | 2 +- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts index fc2abf842..76d07eaf4 100644 --- a/apps/dokploy/server/api/routers/stripe.ts +++ b/apps/dokploy/server/api/routers/stripe.ts @@ -28,10 +28,7 @@ import { STARTUP_PRODUCT_ID, WEBSITE_URL, } from "@/server/utils/stripe"; -import { - processTrialExpirations, - sendTrialExpiringEmail, -} from "@/server/utils/trial-notifications"; +import { processTrialExpirations } from "@/server/utils/trial-notifications"; import { adminProcedure, createTRPCRouter, @@ -447,30 +444,6 @@ export const stripeRouter = createTRPCRouter({ return { ok: true }; }), - sendTestTrialEmail: adminProcedure - .input(z.object({ daysRemaining: z.union([z.literal(1), z.literal(3)]) })) - .mutation(async ({ ctx, input }) => { - assertRootAccess(ctx); - - const owner = await findUserById(ctx.user.ownerId); - const billingStatus = await getBillingStatus(owner.id); - - await sendTrialExpiringEmail({ - email: ctx.user.email, - firstName: owner.firstName, - planName: billingStatus.plan - ? billingStatus.plan.charAt(0).toUpperCase() + - billingStatus.plan.slice(1) - : "Hobby", - daysRemaining: input.daysRemaining, - trialEndsAt: - billingStatus.trialEndsAt ?? - new Date(Date.now() + input.daysRemaining * 24 * 60 * 60 * 1000), - }); - - return { sentTo: ctx.user.email }; - }), - runTrialExpirationJob: adminProcedure.mutation(async ({ ctx }) => { assertRootAccess(ctx); diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts index e12c055c6..e66307d6c 100644 --- a/apps/dokploy/server/utils/trial-notifications.ts +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -25,7 +25,7 @@ const PLAN_LABELS: Record = { const daysUntil = (timestamp: number) => Math.ceil((timestamp * 1000 - Date.now()) / (1000 * 60 * 60 * 24)); -export const sendTrialExpiringEmail = async ({ +const sendTrialExpiringEmail = async ({ email, firstName, planName, From f34151bc1267b17e663098522ddcd1c2891e08b4 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:39:33 -0600 Subject: [PATCH 3/7] refactor(billing): drop the runTrialExpirationJob endpoint Removes the manual trigger and the root guard it needed. The cron job is the only caller now, so processTrialExpirations goes back to module private. --- apps/dokploy/server/api/routers/stripe.ts | 28 ------------------- .../server/utils/trial-notifications.ts | 2 +- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts index 76d07eaf4..944c86c8f 100644 --- a/apps/dokploy/server/api/routers/stripe.ts +++ b/apps/dokploy/server/api/routers/stripe.ts @@ -28,7 +28,6 @@ import { STARTUP_PRODUCT_ID, WEBSITE_URL, } from "@/server/utils/stripe"; -import { processTrialExpirations } from "@/server/utils/trial-notifications"; import { adminProcedure, createTRPCRouter, @@ -36,27 +35,6 @@ import { withPermission, } from "../trpc"; -// These reach every trialing customer on the platform, so an org admin is not -// enough; only the Dokploy Cloud root account may run them. -const assertRootAccess = (ctx: { - user: { id: string }; - session?: { impersonatedBy?: string | null } | null; -}) => { - if (!IS_CLOUD) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This feature is only available in Dokploy Cloud", - }); - } - const rootId = process.env.USER_ADMIN_ID; - if ( - !rootId || - (ctx.user.id !== rootId && ctx.session?.impersonatedBy !== rootId) - ) { - throw new TRPCError({ code: "FORBIDDEN", message: "Root access required" }); - } -}; - export const stripeRouter = createTRPCRouter({ /** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */ getCurrentPlan: protectedProcedure.query(async ({ ctx }) => { @@ -444,12 +422,6 @@ export const stripeRouter = createTRPCRouter({ return { ok: true }; }), - runTrialExpirationJob: adminProcedure.mutation(async ({ ctx }) => { - assertRootAccess(ctx); - - return processTrialExpirations(); - }), - getInvoices: adminProcedure.query(async ({ ctx }) => { const user = await findUserById(ctx.user.ownerId); const stripeCustomerId = user.stripeCustomerId; diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts index e66307d6c..2a7b9c90e 100644 --- a/apps/dokploy/server/utils/trial-notifications.ts +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -117,7 +117,7 @@ const processSubscription = async ( return true; }; -export const processTrialExpirations = async () => { +const processTrialExpirations = async () => { const stripe = getStripeClient(); let sent = 0; let scanned = 0; From 14e059784cdde4212d94f92154f7b304b78c7e39 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:41:28 -0600 Subject: [PATCH 4/7] fix(billing): stop the reminder claim from rewriting stale metadata Stripe merges metadata on update, so spreading the snapshot read during the list call re-asserted stale values over anything written since. Send only the key being claimed. The claim is not exclusive and the comment no longer says it is: two replicas can still read the same unset marker. Marking before the send only keeps a crash mid-send from resending on the next run. --- apps/dokploy/server/utils/trial-notifications.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts index 2a7b9c90e..7e2fc20e7 100644 --- a/apps/dokploy/server/utils/trial-notifications.ts +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -94,9 +94,11 @@ const processSubscription = async ( ); const plan = planFromPriceIds(priceIds); - // Claim the reminder before sending so concurrent replicas don't double-send. + // Marked before sending so a crash mid-send cannot resend on the next run. + // Stripe merges metadata, so only the claimed key travels; spreading the + // snapshot read at list time would rewrite concurrent changes. await stripe.subscriptions.update(subscription.id, { - metadata: { ...subscription.metadata, [threshold.metadataKey]: "sent" }, + metadata: { [threshold.metadataKey]: "sent" }, }); try { @@ -109,7 +111,7 @@ const processSubscription = async ( }); } catch (error) { await stripe.subscriptions.update(subscription.id, { - metadata: { ...subscription.metadata, [threshold.metadataKey]: "" }, + metadata: { [threshold.metadataKey]: "" }, }); throw error; } From 037230496c1880ed4ff1463ffc812dd7a01165b2 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:45:46 -0600 Subject: [PATCH 5/7] fix(billing): one reminder at 1 day, guarded by an advisory lock Cloud runs two frontend replicas and node-schedule fires on each, so both would read the same unset marker and every trial user would get the email twice. A Postgres advisory lock lets one replica run and the other skip; it lives in the connection rather than the schema, so no migration. Drops the 3-day reminder, leaving the single notice before billing starts. --- .../server/utils/trial-notifications.ts | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts index 7e2fc20e7..090be2748 100644 --- a/apps/dokploy/server/utils/trial-notifications.ts +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -1,21 +1,42 @@ import { IS_CLOUD } from "@dokploy/server"; -import { db } from "@dokploy/server/db"; +import { db, dbUrl } 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 postgres from "postgres"; import type Stripe from "stripe"; import { user } from "@/server/db/schema"; import { getStripeClient, 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; +// Cloud runs several replicas and node-schedule fires on each one, so without +// this every trial user would get one email per replica. Advisory locks live +// in the connection rather than the schema, so no migration is needed. +const ADVISORY_LOCK_KEY = 4820260918; + +const withAdvisoryLock = async (run: () => Promise) => { + const sql = postgres(dbUrl, { max: 1 }); + try { + const [row] = + await sql`select pg_try_advisory_lock(${ADVISORY_LOCK_KEY}) as locked`; + if (!row?.locked) return null; + try { + return await run(); + } finally { + await sql`select pg_advisory_unlock(${ADVISORY_LOCK_KEY})`; + } + } finally { + await sql.end({ timeout: 5 }); + } +}; + const PLAN_LABELS: Record = { hobby: "Hobby", startup: "Startup", @@ -148,7 +169,14 @@ export const initTrialNotificationsCronJob = () => { scheduleJob("trial-expiration-reminders", "0 14 * * *", async () => { try { - await processTrialExpirations(); + const result = await withAdvisoryLock(processTrialExpirations); + if (!result) { + console.log("Trial reminders: another replica holds the lock"); + return; + } + console.log( + `Trial reminders: scanned ${result.scanned}, sent ${result.sent}`, + ); } catch (error) { console.error( "Trial reminder job failed:", From 8236c31fcdda0eb50338167c2f90ce14ad62ab6a Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 02:49:18 -0600 Subject: [PATCH 6/7] Revert "fix(billing): one reminder at 1 day, guarded by an advisory lock" Revert "fix(billing): stop the reminder claim from rewriting stale metadata" This reverts commits 037230496 and 14e059784. --- .../server/utils/trial-notifications.ts | 42 +++---------------- 1 file changed, 6 insertions(+), 36 deletions(-) diff --git a/apps/dokploy/server/utils/trial-notifications.ts b/apps/dokploy/server/utils/trial-notifications.ts index 090be2748..2a7b9c90e 100644 --- a/apps/dokploy/server/utils/trial-notifications.ts +++ b/apps/dokploy/server/utils/trial-notifications.ts @@ -1,42 +1,21 @@ import { IS_CLOUD } from "@dokploy/server"; -import { db, dbUrl } from "@dokploy/server/db"; +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 postgres from "postgres"; import type Stripe from "stripe"; import { user } from "@/server/db/schema"; import { getStripeClient, 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; -// Cloud runs several replicas and node-schedule fires on each one, so without -// this every trial user would get one email per replica. Advisory locks live -// in the connection rather than the schema, so no migration is needed. -const ADVISORY_LOCK_KEY = 4820260918; - -const withAdvisoryLock = async (run: () => Promise) => { - const sql = postgres(dbUrl, { max: 1 }); - try { - const [row] = - await sql`select pg_try_advisory_lock(${ADVISORY_LOCK_KEY}) as locked`; - if (!row?.locked) return null; - try { - return await run(); - } finally { - await sql`select pg_advisory_unlock(${ADVISORY_LOCK_KEY})`; - } - } finally { - await sql.end({ timeout: 5 }); - } -}; - const PLAN_LABELS: Record = { hobby: "Hobby", startup: "Startup", @@ -115,11 +94,9 @@ const processSubscription = async ( ); const plan = planFromPriceIds(priceIds); - // Marked before sending so a crash mid-send cannot resend on the next run. - // Stripe merges metadata, so only the claimed key travels; spreading the - // snapshot read at list time would rewrite concurrent changes. + // Claim the reminder before sending so concurrent replicas don't double-send. await stripe.subscriptions.update(subscription.id, { - metadata: { [threshold.metadataKey]: "sent" }, + metadata: { ...subscription.metadata, [threshold.metadataKey]: "sent" }, }); try { @@ -132,7 +109,7 @@ const processSubscription = async ( }); } catch (error) { await stripe.subscriptions.update(subscription.id, { - metadata: { [threshold.metadataKey]: "" }, + metadata: { ...subscription.metadata, [threshold.metadataKey]: "" }, }); throw error; } @@ -169,14 +146,7 @@ export const initTrialNotificationsCronJob = () => { scheduleJob("trial-expiration-reminders", "0 14 * * *", async () => { try { - const result = await withAdvisoryLock(processTrialExpirations); - if (!result) { - console.log("Trial reminders: another replica holds the lock"); - return; - } - console.log( - `Trial reminders: scanned ${result.scanned}, sent ${result.sent}`, - ); + await processTrialExpirations(); } catch (error) { console.error( "Trial reminder job failed:", From a0e1001808817dfc6950b42aaf8f883dc21d81e1 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 18 Sep 2026 03:03:51 -0600 Subject: [PATCH 7/7] feat(trial-notifications): add email notification for expiring trials and integrate with webhook --- apps/dokploy/pages/api/stripe/webhook.ts | 8 + apps/dokploy/server/server.ts | 5 - .../server/utils/trial-notifications.ts | 165 +++++------------- 3 files changed, 51 insertions(+), 127 deletions(-) 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, - ); - } - }); -};