- 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.
-
- 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) => {
- 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.
+
@@ -311,7 +327,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
{!isReady && !isSettingUp && (
- 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 && (
+
+ handleSwitchTrialPlan(
+ billingStatus.plan === "startup"
+ ? "hobby"
+ : "startup",
+ )
+ }
+ >
+ {billingStatus.plan === "startup"
+ ? "Switch to Hobby"
+ : "Switch to Startup"}
+
+ )}
{
const session = await createCustomerPortalSession();
window.open(session.url);
}}
>
- Add payment method
+ {billingStatus.isOnTrial &&
+ !billingStatus.hasPaymentMethod
+ ? "Add payment method"
+ : "Manage Subscription"}
- )}
-
- )}
- {!isEnterpriseCloud &&
- billingStatus &&
- !billingStatus.plan &&
- !billingStatus.isOnTrial &&
- !billingStatus.hasUsedTrial && (
-
Servers Plan
@@ -933,68 +985,71 @@ export const ShowBilling = () => {
))}
-
-
- Servers:
-
-
- setHobbyServerQuantity((q) => Math.max(1, q - 1))
- }
- >
-
-
-
- setHobbyServerQuantity(
- Math.max(
- 1,
- Number(
- (e.target as HTMLInputElement).value,
- ) || 1,
- ),
- )
- }
- className="text-center"
- />
- setHobbyServerQuantity((q) => q + 1)}
- >
-
-
-
-
- {admin?.user.stripeCustomerId && (
+ {canSubscribe && (
+
+
+ Servers:
+
{
- const session =
- await createCustomerPortalSession();
- window.open(session.url);
- }}
+ disabled={hobbyServerQuantity <= 1}
+ variant="outline"
+ size="icon"
+ onClick={() =>
+ setHobbyServerQuantity((q) =>
+ Math.max(1, q - 1),
+ )
+ }
>
- Manage Subscription
+
+
+
+ setHobbyServerQuantity(
+ Math.max(
+ 1,
+ Number(
+ (e.target as HTMLInputElement).value,
+ ) || 1,
+ ),
+ )
+ }
+ className="text-center"
+ />
+
+ setHobbyServerQuantity((q) => q + 1)
+ }
+ >
+
+
+
+ )}
+
+ {canStartTrial && (
+ handleStartTrial("hobby")}
+ >
+ Start 7-day free trial
+
+ )}
+ {canSubscribe && (
+
+ handleCheckout("hobby", data!.hobbyProductId!)
+ }
+ disabled={hobbyServerQuantity < 1}
+ >
+ Get Started
)}
- {!isEnterpriseCloud &&
- (data?.subscriptions?.length ?? 0) === 0 && (
-
- handleCheckout("hobby", data!.hobbyProductId!)
- }
- disabled={hobbyServerQuantity < 1}
- >
- Get Started
-
- )}
@@ -1067,85 +1122,83 @@ export const ShowBilling = () => {
))}
-
-
- Servers (min. {STARTUP_SERVERS_INCLUDED} included)
-
-
-
- setStartupServerQuantity((q) =>
- Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
- )
- }
- >
-
-
-
- setStartupServerQuantity(
- Math.max(
- STARTUP_SERVERS_INCLUDED,
- Number(
- (e.target as HTMLInputElement).value,
- ) || STARTUP_SERVERS_INCLUDED,
- ),
- )
- }
- className="h-8 text-center"
- />
-
- setStartupServerQuantity((q) => q + 1)
- }
- >
-
-
-
-
-
- {admin?.user.stripeCustomerId && (
-
{
- const session =
- await createCustomerPortalSession();
- window.open(session.url);
- }}
- >
- Manage Subscription
-
- )}
- {!isEnterpriseCloud &&
- (data?.subscriptions?.length ?? 0) === 0 && (
+ {canSubscribe && (
+
+
+ Servers (min. {STARTUP_SERVERS_INCLUDED} included)
+
+
- handleCheckout(
- "startup",
- data!.startupProductId!,
- )
- }
disabled={
- startupServerQuantity <
+ startupServerQuantity <=
STARTUP_SERVERS_INCLUDED
}
+ variant="outline"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() =>
+ setStartupServerQuantity((q) =>
+ Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
+ )
+ }
>
- Get Started
+
- )}
+
+ setStartupServerQuantity(
+ Math.max(
+ STARTUP_SERVERS_INCLUDED,
+ Number(
+ (e.target as HTMLInputElement).value,
+ ) || STARTUP_SERVERS_INCLUDED,
+ ),
+ )
+ }
+ className="h-8 text-center"
+ />
+
+ setStartupServerQuantity((q) => q + 1)
+ }
+ >
+
+
+
+
+ )}
+
+ {canStartTrial && (
+ handleStartTrial("startup")}
+ >
+ Start 7-day free trial
+
+ )}
+ {canSubscribe && (
+
+ handleCheckout(
+ "startup",
+ data!.startupProductId!,
+ )
+ }
+ disabled={
+ startupServerQuantity < STARTUP_SERVERS_INCLUDED
+ }
+ >
+ Get Started
+
+ )}
@@ -1337,31 +1390,17 @@ export const ShowBilling = () => {
- {admin?.user.stripeCustomerId && (
+ {canSubscribe && (
{
- const session =
- await createCustomerPortalSession();
- window.open(session.url);
+ handleCheckout("legacy", product.id);
}}
+ disabled={hobbyServerQuantity < 1}
>
- Manage Subscription
+ Subscribe
)}
- {!isEnterpriseCloud &&
- (data?.subscriptions?.length ?? 0) === 0 && (
- {
- handleCheckout("legacy", product.id);
- }}
- disabled={hobbyServerQuantity < 1}
- >
- Subscribe
-
- )}
diff --git a/apps/dokploy/pages/api/stripe/webhook.ts b/apps/dokploy/pages/api/stripe/webhook.ts
index ef8f90632..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;
@@ -176,7 +184,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 +220,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 +234,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 +255,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 +269,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 +368,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..944c86c8f 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,
@@ -45,72 +45,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 +353,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 +387,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 };
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..1c7e0e515
--- /dev/null
+++ b/apps/dokploy/server/utils/trial-notifications.ts
@@ -0,0 +1,78 @@
+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 type Stripe from "stripe";
+import { user } from "@/server/db/schema";
+import { planFromPriceIds } from "@/server/utils/billing";
+import { WEBSITE_URL } from "@/server/utils/stripe";
+
+const PLAN_LABELS: Record = {
+ hobby: "Hobby",
+ startup: "Startup",
+ legacy: "Legacy",
+};
+
+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 htmlContent = await render(
+ TrialExpiringEmail({
+ userName: owner.firstName || "User",
+ planName: plan ? (PLAN_LABELS[plan] ?? plan) : "Trial",
+ daysRemaining,
+ endsOn: format(trialEndsAt, "MMM dd, yyyy"),
+ billingUrl: `${WEBSITE_URL}/dashboard/settings/billing`,
+ }),
+ );
+
+ await sendEmail({
+ email: owner.email,
+ subject:
+ daysRemaining === 1
+ ? "Your Dokploy trial ends tomorrow"
+ : `Your Dokploy trial ends in ${daysRemaining} days`,
+ text: htmlContent,
+ });
+
+ return true;
+};
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}
+
+
+
+
+
+
+
+
+
+ {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.
+
+
+
+
+
+ Add Payment Method
+
+
+
+
+
+
+ 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;