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..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} + + + +
+ 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;