diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
index 1460244c1..740c5179c 100644
--- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
+++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
@@ -11,7 +11,9 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
-import { useState } from "react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -31,6 +33,7 @@ const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
+/** Precio legacy / Hobby: $4.50/mo primer servidor, $3.50 siguientes; anual $45.90 primero, $35.70 siguientes. */
export const calculatePrice = (count: number, isAnnual = false) => {
if (isAnnual) {
if (count <= 1) return 45.9;
@@ -40,6 +43,27 @@ export const calculatePrice = (count: number, isAnnual = false) => {
return count * 3.5;
};
+/** Hobby: $4.50/mo per server; annual 20% off = $43.20/yr per server (4.5 * 12 * 0.8). */
+export const calculatePriceHobby = (count: number, isAnnual = false) => {
+ const perServerMonthly = 4.5;
+ const perServerAnnual = 43.2; // 4.5 * 12 * 0.8
+ return isAnnual ? count * perServerAnnual : count * perServerMonthly;
+};
+
+/** Startup: 3 servers included ($15/mo); extra servers $4.50/mo each. Annual 20% off. */
+export const STARTUP_SERVERS_INCLUDED = 3;
+export const calculatePriceStartup = (count: number, isAnnual = false) => {
+ const baseMonthly = 15;
+ const extraMonthly = 4.5;
+ const baseAnnual = 144; // 15 * 12 * 0.8
+ const extraAnnual = 43.2; // 4.5 * 12 * 0.8, consistent with Hobby annual
+ if (count <= STARTUP_SERVERS_INCLUDED)
+ return isAnnual ? baseAnnual : baseMonthly;
+ return isAnnual
+ ? baseAnnual + (count - STARTUP_SERVERS_INCLUDED) * extraAnnual
+ : baseMonthly + (count - STARTUP_SERVERS_INCLUDED) * extraMonthly;
+};
+
const navigationItems = [
{
name: "Subscription",
@@ -63,16 +87,35 @@ export const ShowBilling = () => {
const { mutateAsync: createCustomerPortalSession } =
api.stripe.createCustomerPortalSession.useMutation();
+ const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
+ api.stripe.upgradeSubscription.useMutation();
+ const utils = api.useUtils();
const [serverQuantity, setServerQuantity] = useState(3);
const [isAnnual, setIsAnnual] = useState(false);
+ const [upgradeTier, setUpgradeTier] = useState<"hobby" | "startup" | null>(
+ null,
+ );
+ const [upgradeServerQty, setUpgradeServerQty] = useState(3);
+ /** Billing interval in the upgrade/update form; synced to current when data loads. */
+ const [updateFormAnnual, setUpdateFormAnnual] = useState(false);
- const handleCheckout = async (productId: string) => {
+ useEffect(() => {
+ if (data?.isAnnualCurrent !== undefined) {
+ setUpdateFormAnnual(data.isAnnualCurrent);
+ }
+ }, [data?.isAnnualCurrent]);
+
+ const handleCheckout = async (
+ tier: "legacy" | "hobby" | "startup",
+ productId: string,
+ ) => {
const stripe = await stripePromise;
if (data && data.subscriptions.length === 0) {
createCheckoutSession({
+ tier,
productId,
- serverQuantity: serverQuantity,
+ serverQuantity,
isAnnual,
}).then(async (session) => {
await stripe?.redirectToCheckout({
@@ -81,6 +124,8 @@ export const ShowBilling = () => {
});
}
};
+
+ const useNewPricing = data?.hobbyProductId && data?.startupProductId;
const products = data?.products.filter((product) => {
// @ts-ignore
const interval = product?.default_price?.recurring?.interval;
@@ -93,7 +138,7 @@ export const ShowBilling = () => {
return (
-
+
@@ -128,17 +173,6 @@ export const ShowBilling = () => {
-
setIsAnnual(e === "annual")}
- >
-
- Monthly
- Annual
-
-
{admin?.user.stripeSubscriptionId && (
Servers Plan
@@ -160,6 +194,429 @@ export const ShowBilling = () => {
)}
)}
+ {/* Upgrade: solo para usuarios en plan legacy con nuevos planes disponibles */}
+ {useNewPricing &&
+ data?.currentPlan === "legacy" &&
+ data?.subscriptions?.length > 0 && (
+
+
Upgrade your plan
+
+ You’re on the legacy plan. Switch to Hobby or Startup
+ (same benefits). You can also choose annual billing (20%
+ off). Stripe will prorate the change.
+
+
+
+ Billing interval
+
+
+ setUpdateFormAnnual(false)}
+ >
+ Monthly
+
+ setUpdateFormAnnual(true)}
+ >
+ Annual (20% off)
+
+
+
+
New plan
+
+ setUpgradeTier("hobby")}
+ >
+ Hobby
+
+ setUpgradeTier("startup")}
+ >
+ Startup
+
+
+
+ {upgradeTier && (
+
+
+ Servers
+ {upgradeTier === "startup" &&
+ ` (min. ${STARTUP_SERVERS_INCLUDED})`}
+
+
+
+ setUpgradeServerQty((q) =>
+ Math.max(
+ upgradeTier === "startup"
+ ? STARTUP_SERVERS_INCLUDED
+ : 1,
+ q - 1,
+ ),
+ )
+ }
+ >
+
+
+
{
+ const v =
+ Number((e.target as HTMLInputElement).value) ||
+ 0;
+ setUpgradeServerQty(
+ Math.max(
+ upgradeTier === "startup"
+ ? STARTUP_SERVERS_INCLUDED
+ : 1,
+ v,
+ ),
+ );
+ }}
+ className="w-20 h-8"
+ />
+ setUpgradeServerQty((q) => q + 1)}
+ >
+
+
+
+
+ {upgradeTier === "hobby"
+ ? `$${calculatePriceHobby(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`
+ : `$${calculatePriceStartup(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`}
+
+
+
+ Current plan: Legacy
+
+
+ New plan:{" "}
+ {upgradeTier === "startup"
+ ? "Startup"
+ : "Hobby"}{" "}
+ · {upgradeServerQty} server
+ {upgradeServerQty !== 1 ? "s" : ""} · $
+ {upgradeTier === "hobby"
+ ? calculatePriceHobby(
+ upgradeServerQty,
+ updateFormAnnual,
+ ).toFixed(2)
+ : calculatePriceStartup(
+ upgradeServerQty,
+ updateFormAnnual,
+ ).toFixed(2)}
+ /{updateFormAnnual ? "yr" : "mo"} (
+ {updateFormAnnual ? "annual" : "monthly"})
+
+
+ Stripe will prorate the change.
+
+
+ }
+ type="default"
+ onClick={async () => {
+ if (!upgradeTier) return;
+ try {
+ await upgradeSubscription({
+ tier: upgradeTier,
+ serverQuantity: upgradeServerQty,
+ isAnnual: updateFormAnnual,
+ });
+ await utils.stripe.getProducts.invalidate();
+ await utils.user.get.invalidate();
+ setUpgradeTier(null);
+ toast.success("Plan upgraded successfully");
+ } catch {
+ toast.error("Error upgrading plan");
+ }
+ }}
+ >
+
+ {isUpgrading ? (
+ <>
+
+ Upgrading…
+ >
+ ) : (
+ "Upgrade plan"
+ )}
+
+
+
+ )}
+
+ )}
+ {/* Cambiar plan o cantidad de servidores (usuarios en Hobby o Startup; el portal no permite esto) */}
+ {useNewPricing &&
+ (data?.currentPlan === "hobby" ||
+ data?.currentPlan === "startup") &&
+ data?.subscriptions?.length > 0 && (
+
+
+ Change plan or number of servers
+
+
+ Your current plan:{" "}
+
+ {data?.currentPlan === "startup" ? "Startup" : "Hobby"}
+
+ {" · "}
+
+ {admin?.user.serversQuantity ?? 0} server
+ {(admin?.user.serversQuantity ?? 0) !== 1 ? "s" : ""}
+
+ {data?.currentPriceAmount != null && (
+ <>
+ {" · "}
+
+ ${data.currentPriceAmount.toFixed(2)}/
+ {data?.isAnnualCurrent ? "yr" : "mo"}
+
+ >
+ )}{" "}
+ ({data?.isAnnualCurrent ? "annual" : "monthly"} billing).
+
+
+ Add more servers, switch between Hobby and Startup, or
+ change to annual billing (20% off). Stripe will prorate
+ the change.
+
+
+
+ Billing interval
+
+
+ setUpdateFormAnnual(false)}
+ >
+ Monthly
+
+ setUpdateFormAnnual(true)}
+ >
+ Annual (20% off)
+
+
+
+
Plan
+
+ setUpgradeTier("hobby")}
+ >
+ Hobby
+
+ setUpgradeTier("startup")}
+ >
+ Startup
+
+
+
+ {upgradeTier && (
+
+
+ Servers
+ {upgradeTier === "startup" &&
+ ` (min. ${STARTUP_SERVERS_INCLUDED})`}
+
+
+
+ setUpgradeServerQty((q) =>
+ Math.max(
+ upgradeTier === "startup"
+ ? STARTUP_SERVERS_INCLUDED
+ : 1,
+ q - 1,
+ ),
+ )
+ }
+ >
+
+
+
{
+ const v =
+ Number((e.target as HTMLInputElement).value) ||
+ 0;
+ setUpgradeServerQty(
+ Math.max(
+ upgradeTier === "startup"
+ ? STARTUP_SERVERS_INCLUDED
+ : 1,
+ v,
+ ),
+ );
+ }}
+ className="w-20 h-8"
+ />
+ setUpgradeServerQty((q) => q + 1)}
+ >
+
+
+
+
+ {upgradeTier === "hobby"
+ ? `$${calculatePriceHobby(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`
+ : `$${calculatePriceStartup(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`}
+
+
+
+ Current plan:{" "}
+ {data?.currentPlan === "startup"
+ ? "Startup"
+ : "Hobby"}{" "}
+ · {admin?.user.serversQuantity ?? 0} server
+ {(admin?.user.serversQuantity ?? 0) !== 1
+ ? "s"
+ : ""}{" "}
+ ·{" "}
+ {data?.currentPriceAmount != null
+ ? `$${data.currentPriceAmount.toFixed(2)}/${data?.isAnnualCurrent ? "yr" : "mo"}`
+ : ""}{" "}
+ ({data?.isAnnualCurrent ? "annual" : "monthly"})
+
+
+ New plan:{" "}
+ {upgradeTier === "startup"
+ ? "Startup"
+ : "Hobby"}{" "}
+ · {upgradeServerQty} server
+ {upgradeServerQty !== 1 ? "s" : ""} · $
+ {upgradeTier === "hobby"
+ ? calculatePriceHobby(
+ upgradeServerQty,
+ updateFormAnnual,
+ ).toFixed(2)
+ : calculatePriceStartup(
+ upgradeServerQty,
+ updateFormAnnual,
+ ).toFixed(2)}
+ /{updateFormAnnual ? "yr" : "mo"} (
+ {updateFormAnnual ? "annual" : "monthly"})
+
+
+ Stripe will prorate the change.
+
+
+ }
+ type="default"
+ onClick={async () => {
+ if (!upgradeTier) return;
+ try {
+ await upgradeSubscription({
+ tier: upgradeTier,
+ serverQuantity: upgradeServerQty,
+ isAnnual: updateFormAnnual,
+ });
+ await utils.stripe.getProducts.invalidate();
+
+ // add delay of 3 seconds
+ await new Promise((resolve) =>
+ setTimeout(resolve, 3000),
+ );
+ await utils.user.get.invalidate();
+ setUpgradeTier(null);
+ toast.success(
+ "Subscription updated successfully",
+ );
+ } catch {
+ toast.error("Error updating subscription");
+ }
+ }}
+ >
+
+ {isUpgrading ? (
+ <>
+
+ Updating…
+ >
+ ) : (
+ "Update subscription"
+ )}
+
+
+
+ )}
+
+ )}
Need Help? We are here to help you.
@@ -191,8 +648,345 @@ export const ShowBilling = () => {
Loading...
+ ) : useNewPricing ? (
+ <>
+
setIsAnnual(e === "annual")}
+ >
+
+ Monthly
+ Annual (20% off)
+
+
+
+ {/* Hobby */}
+
+ {isAnnual && (
+
+ 20% off
+
+ )}
+
+ Hobby
+
+
+ Everything an individual developer needs
+
+
+
+ $
+ {calculatePriceHobby(
+ serverQuantity,
+ isAnnual,
+ ).toFixed(2)}
+ /{isAnnual ? "yr" : "mo"}
+
+
+ Add more servers as you'd like for{" "}
+ {isAnnual ? "$43.20/yr" : "$4.50/mo"}
+
+ {isAnnual && (
+
+ $
+ {(
+ calculatePriceHobby(serverQuantity, true) / 12
+ ).toFixed(2)}
+ /mo
+
+ )}
+
+
+ {[
+ "Unlimited Deployments",
+ "Unlimited Databases",
+ "Unlimited Applications",
+ "1 Server Included",
+ "1 Organization",
+ "1 User",
+ "2 Environments",
+ "1 Volume Backup per Application",
+ "1 Backup per Database",
+ "1 Scheduled Job per Application",
+ "Community Support (Discord)",
+ ].map((f) => (
+
+
+ {f}
+
+ ))}
+
+
+
+
+ Servers:
+
+
+ setServerQuantity((q) => Math.max(1, q - 1))
+ }
+ >
+
+
+
+ setServerQuantity(
+ Math.max(
+ 1,
+ Number(
+ (e.target as HTMLInputElement).value,
+ ) || 1,
+ ),
+ )
+ }
+ className="text-center"
+ />
+ setServerQuantity((q) => q + 1)}
+ >
+
+
+
+
+ {admin?.user.stripeCustomerId && (
+ {
+ const session =
+ await createCustomerPortalSession();
+ window.open(session.url);
+ }}
+ >
+ Manage Subscription
+
+ )}
+ {(data?.subscriptions?.length ?? 0) === 0 && (
+
+ handleCheckout("hobby", data!.hobbyProductId!)
+ }
+ disabled={serverQuantity < 1}
+ >
+ Get Started
+
+ )}
+
+
+
+
+ {/* Startup - Recommended */}
+
+
+
+ Recommended
+
+ {isAnnual && (
+
+ 20% off
+
+ )}
+
+
+ Startup
+
+
+ Perfect for small to mid-size teams
+
+
+
+ $
+ {calculatePriceStartup(
+ serverQuantity,
+ isAnnual,
+ ).toFixed(2)}
+ /{isAnnual ? "yr" : "mo"}
+
+
+ Add more servers as you'd like for{" "}
+ {isAnnual ? "$43.20/yr" : "$4.50/mo"}
+
+ {isAnnual && (
+
+ $
+ {(
+ calculatePriceStartup(serverQuantity, true) / 12
+ ).toFixed(2)}
+ /mo
+
+ )}
+
+
+
+
+ All the features of Hobby, plus…
+
+ {[
+ "3 Servers Included",
+ "3 Organizations",
+ "Unlimited Users",
+ "Unlimited Environments",
+ "Unlimited Volume Backups",
+ "Unlimited Database Backups",
+ "Unlimited Scheduled Jobs",
+ "Basic RBAC (Admin, Developer)",
+ "2FA",
+ "Email and Chat Support",
+ ].map((f) => (
+
+
+ {f}
+
+ ))}
+
+
+
+
+ Servers (min. {STARTUP_SERVERS_INCLUDED} included)
+
+
+
+ setServerQuantity((q) =>
+ Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
+ )
+ }
+ >
+
+
+
+ setServerQuantity(
+ Math.max(
+ STARTUP_SERVERS_INCLUDED,
+ Number(
+ (e.target as HTMLInputElement).value,
+ ) || STARTUP_SERVERS_INCLUDED,
+ ),
+ )
+ }
+ className="h-8 text-center"
+ />
+ setServerQuantity((q) => q + 1)}
+ >
+
+
+
+
+
+ {admin?.user.stripeCustomerId && (
+ {
+ const session =
+ await createCustomerPortalSession();
+ window.open(session.url);
+ }}
+ >
+ Manage Subscription
+
+ )}
+ {(data?.subscriptions?.length ?? 0) === 0 && (
+
+ handleCheckout(
+ "startup",
+ data!.startupProductId!,
+ )
+ }
+ disabled={
+ serverQuantity < STARTUP_SERVERS_INCLUDED
+ }
+ >
+ Get Started
+
+ )}
+
+
+
+
+ {/* Enterprise */}
+
+
+ Enterprise
+
+
+ For large organizations who want more control
+
+
+
+
+
+ All the features of Startup, plus…
+
+ {[
+ "Up to Unlimited Servers",
+ "Up to Unlimited Organizations",
+ "Fine-grained RBAC",
+ "Complete Hosting Flexibility",
+ "SSO / SAML (Azure, OKTA, etc)",
+ "Audit Logs",
+ "MSA/SLA",
+ "White Labeling",
+ "Priority Support and Services",
+ ].map((f) => (
+
+
+ {f}
+
+ ))}
+
+
+
+ Contact Sales
+
+
+
+
+ >
) : (
<>
+
setIsAnnual(e === "annual")}
+ >
+
+ Monthly
+ Annual (20% off)
+
+
{products?.map((product) => {
const featured = true;
return (
@@ -311,15 +1105,7 @@ export const ShowBilling = () => {
- 0
- ? "justify-between"
- : "justify-end",
- "flex flex-row items-center gap-2 mt-4",
- )}
- >
+
{admin?.user.stripeCustomerId && (
{
onClick={async () => {
const session =
await createCustomerPortalSession();
-
window.open(session.url);
}}
>
Manage Subscription
)}
-
- {data?.subscriptions?.length === 0 && (
-
- {
- handleCheckout(product.id);
- }}
- disabled={serverQuantity < 1}
- >
- Subscribe
-
-
+ {(data?.subscriptions?.length ?? 0) === 0 && (
+
{
+ handleCheckout("legacy", product.id);
+ }}
+ disabled={serverQuantity < 1}
+ >
+ Subscribe
+
)}
diff --git a/apps/dokploy/pages/api/stripe/webhook.ts b/apps/dokploy/pages/api/stripe/webhook.ts
index b44b63d45..f950eb344 100644
--- a/apps/dokploy/pages/api/stripe/webhook.ts
+++ b/apps/dokploy/pages/api/stripe/webhook.ts
@@ -8,6 +8,25 @@ import { organization, server, user } from "@/server/db/schema";
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
+const STARTUP_BASE_PRICE_IDS = [
+ process.env.STARTUP_BASE_PRICE_MONTHLY_ID,
+ process.env.STARTUP_BASE_PRICE_ANNUAL_ID,
+].filter(Boolean) as string[];
+
+const STARTUP_SERVERS_INCLUDED = 3;
+
+function getSubscriptionServersQuantity(
+ items: Stripe.SubscriptionItem[],
+): number {
+ return items.reduce((sum, item) => {
+ const priceId = (item.price as Stripe.Price).id;
+ if (STARTUP_BASE_PRICE_IDS.includes(priceId)) {
+ return sum + STARTUP_SERVERS_INCLUDED;
+ }
+ return sum + (item.quantity ?? 0);
+ }, 0);
+}
+
export const config = {
api: {
bodyParser: false,
@@ -63,12 +82,15 @@ export default async function handler(
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string,
);
+ const serversQuantity = getSubscriptionServersQuantity(
+ subscription?.items?.data ?? [],
+ );
await db
.update(user)
.set({
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
- serversQuantity: subscription?.items?.data?.[0]?.quantity ?? 0,
+ serversQuantity,
})
.where(eq(user.id, adminId))
.returning();
@@ -130,15 +152,15 @@ export default async function handler(
}
if (newSubscription.status === "active") {
+ const serversQuantity = getSubscriptionServersQuantity(
+ newSubscription?.items?.data ?? [],
+ );
await db
.update(user)
- .set({
- serversQuantity: newSubscription?.items?.data?.[0]?.quantity ?? 0,
- })
+ .set({ serversQuantity })
.where(eq(user.stripeCustomerId, newSubscription.customer as string));
- const newServersQuantity = admin.serversQuantity;
- await updateServersBasedOnQuantity(admin.id, newServersQuantity);
+ await updateServersBasedOnQuantity(admin.id, serversQuantity);
} else {
await disableServers(admin.id);
await db
@@ -163,11 +185,12 @@ export default async function handler(
break;
}
+ const serversQuantity = getSubscriptionServersQuantity(
+ suscription?.items?.data ?? [],
+ );
await db
.update(user)
- .set({
- serversQuantity: suscription?.items?.data?.[0]?.quantity ?? 0,
- })
+ .set({ serversQuantity })
.where(eq(user.stripeCustomerId, suscription.customer as string));
const admin = await findUserByStripeCustomerId(
diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts
index 963ec8c5b..c910b1d58 100644
--- a/apps/dokploy/server/api/routers/stripe.ts
+++ b/apps/dokploy/server/api/routers/stripe.ts
@@ -8,9 +8,17 @@ import { TRPCError } from "@trpc/server";
import Stripe from "stripe";
import { z } from "zod";
import {
+ type BillingTier,
getStripeItems,
+ HOBBY_PRICE_ANNUAL_ID,
+ HOBBY_PRICE_MONTHLY_ID,
+ HOBBY_PRODUCT_ID,
+ LEGACY_PRICE_IDS,
PRODUCT_ANNUAL_ID,
PRODUCT_MONTHLY_ID,
+ STARTUP_BASE_PRICE_ANNUAL_ID,
+ STARTUP_BASE_PRICE_MONTHLY_ID,
+ STARTUP_PRODUCT_ID,
WEBSITE_URL,
} from "@/server/utils/stripe";
import { adminProcedure, createTRPCRouter } from "../trpc";
@@ -29,16 +37,25 @@ export const stripeRouter = createTRPCRouter({
active: true,
});
- const filteredProducts = products.data.filter((product) => {
- return (
- product.id === PRODUCT_MONTHLY_ID || product.id === PRODUCT_ANNUAL_ID
- );
- });
+ const productIds = [
+ PRODUCT_MONTHLY_ID,
+ PRODUCT_ANNUAL_ID,
+ HOBBY_PRODUCT_ID,
+ STARTUP_PRODUCT_ID,
+ ].filter(Boolean);
+ const filteredProducts = products.data.filter((product) =>
+ productIds.includes(product.id),
+ );
if (!stripeCustomerId) {
return {
products: filteredProducts,
subscriptions: [],
+ hobbyProductId: HOBBY_PRODUCT_ID || undefined,
+ startupProductId: STARTUP_PRODUCT_ID || undefined,
+ currentPlan: null as "legacy" | "hobby" | "startup" | null,
+ isAnnualCurrent: false,
+ currentPriceAmount: null,
};
}
@@ -48,25 +65,79 @@ export const stripeRouter = createTRPCRouter({
expand: ["data.items.data.price"],
});
+ type CurrentPlan = "legacy" | "hobby" | "startup";
+ let currentPlan: CurrentPlan = "legacy";
+ let isAnnualCurrent = false;
+ let currentPriceAmount: number | null = null;
+ const activeSub = subscriptions.data[0];
+ if (activeSub) {
+ const priceIds = activeSub.items.data.map(
+ (item) => (item.price as Stripe.Price).id,
+ );
+ if (
+ priceIds.some(
+ (id) =>
+ id === STARTUP_BASE_PRICE_MONTHLY_ID ||
+ id === STARTUP_BASE_PRICE_ANNUAL_ID,
+ )
+ ) {
+ currentPlan = "startup";
+ } else if (
+ priceIds.some(
+ (id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
+ )
+ ) {
+ currentPlan = "hobby";
+ } else if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
+ currentPlan = "legacy";
+ }
+ const firstPrice = activeSub.items.data[0]?.price as
+ | Stripe.Price
+ | undefined;
+ isAnnualCurrent = firstPrice?.recurring?.interval === "year";
+ const totalCents = activeSub.items.data.reduce((sum, item) => {
+ const price = item.price as Stripe.Price;
+ const amount = price.unit_amount ?? 0;
+ const qty = item.quantity ?? 1;
+ return sum + amount * qty;
+ }, 0);
+ currentPriceAmount = totalCents / 100;
+ }
+
return {
products: filteredProducts,
subscriptions: subscriptions.data,
+ hobbyProductId: HOBBY_PRODUCT_ID || undefined,
+ startupProductId: STARTUP_PRODUCT_ID || undefined,
+ currentPlan: currentPlan as "legacy" | "hobby" | "startup" | null,
+ isAnnualCurrent,
+ currentPriceAmount,
};
}),
createCheckoutSession: adminProcedure
.input(
- z.object({
- productId: z.string(),
- serverQuantity: z.number().min(1),
- isAnnual: z.boolean(),
- }),
+ z
+ .object({
+ tier: z.enum(["legacy", "hobby", "startup"]),
+ productId: z.string(),
+ serverQuantity: z.number().min(1),
+ isAnnual: z.boolean(),
+ })
+ .refine((data) => data.tier !== "startup" || data.serverQuantity >= 3, {
+ message: "Startup plan requires at least 3 servers",
+ path: ["serverQuantity"],
+ }),
)
.mutation(async ({ ctx, input }) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
- const items = getStripeItems(input.serverQuantity, input.isAnnual);
+ const items = getStripeItems(
+ input.tier as BillingTier,
+ input.serverQuantity,
+ input.isAnnual,
+ );
// Always operate on the organization owner's Stripe customer
const owner = await findUserById(ctx.user.ownerId);
@@ -129,6 +200,78 @@ export const stripeRouter = createTRPCRouter({
}
}),
+ upgradeSubscription: adminProcedure
+ .input(
+ z
+ .object({
+ tier: z.enum(["hobby", "startup"]),
+ serverQuantity: z.number().min(1),
+ isAnnual: z.boolean(),
+ })
+ .refine((data) => data.tier !== "startup" || data.serverQuantity >= 3, {
+ message: "Startup plan requires at least 3 servers",
+ path: ["serverQuantity"],
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
+ apiVersion: "2024-09-30.acacia",
+ });
+ const owner = await findUserById(ctx.user.ownerId);
+
+ if (!owner.stripeSubscriptionId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "No active subscription found",
+ });
+ }
+
+ const subscription = await stripe.subscriptions.retrieve(
+ owner.stripeSubscriptionId,
+ { expand: ["items.data.price"] },
+ );
+
+ if (subscription.status !== "active") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Subscription is not active",
+ });
+ }
+
+ const newItems = getStripeItems(
+ input.tier as BillingTier,
+ input.serverQuantity,
+ input.isAnnual,
+ );
+ const currentItems = subscription.items.data;
+
+ const updateItems: Stripe.SubscriptionUpdateParams["items"] =
+ currentItems.map((item, i) => {
+ if (i < newItems.length) {
+ return {
+ id: item.id,
+ price: newItems[i]!.price,
+ quantity: newItems[i]!.quantity,
+ };
+ }
+ return { id: item.id, deleted: true };
+ });
+
+ for (let i = currentItems.length; i < newItems.length; i++) {
+ updateItems.push({
+ price: newItems[i]!.price,
+ quantity: newItems[i]!.quantity,
+ });
+ }
+
+ await stripe.subscriptions.update(owner.stripeSubscriptionId, {
+ items: updateItems,
+ proration_behavior: "create_prorations",
+ });
+
+ return { ok: true };
+ }),
+
canCreateMoreServers: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const servers = await findServersByUserId(user.id);
diff --git a/apps/dokploy/server/utils/stripe.ts b/apps/dokploy/server/utils/stripe.ts
index 8d1aebb29..078885a98 100644
--- a/apps/dokploy/server/utils/stripe.ts
+++ b/apps/dokploy/server/utils/stripe.ts
@@ -3,28 +3,69 @@ export const WEBSITE_URL =
? "http://localhost:3000"
: process.env.SITE_URL;
-export const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID!; // $4.00
-
-export const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID!; // $7.99
-
+export const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID!;
+export const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID!;
export const PRODUCT_MONTHLY_ID = process.env.PRODUCT_MONTHLY_ID!;
export const PRODUCT_ANNUAL_ID = process.env.PRODUCT_ANNUAL_ID!;
-export const getStripeItems = (serverQuantity: number, isAnnual: boolean) => {
- const items = [];
+export const LEGACY_PRICE_IDS = [
+ process.env.BASE_PRICE_MONTHLY_ID,
+ process.env.BASE_ANNUAL_MONTHLY_ID,
+].filter(Boolean) as string[];
- if (isAnnual) {
+export const HOBBY_PRODUCT_ID = process.env.HOBBY_PRODUCT_ID ?? "";
+export const HOBBY_PRICE_MONTHLY_ID = process.env.HOBBY_PRICE_MONTHLY_ID ?? "";
+export const HOBBY_PRICE_ANNUAL_ID = process.env.HOBBY_PRICE_ANNUAL_ID ?? "";
+
+export const STARTUP_PRODUCT_ID = process.env.STARTUP_PRODUCT_ID ?? "";
+export const STARTUP_BASE_PRICE_MONTHLY_ID =
+ process.env.STARTUP_BASE_PRICE_MONTHLY_ID ?? "";
+export const STARTUP_BASE_PRICE_ANNUAL_ID =
+ process.env.STARTUP_BASE_PRICE_ANNUAL_ID ?? "";
+
+export type BillingTier = "legacy" | "hobby" | "startup";
+
+export const getStripeItems = (
+ tier: BillingTier,
+ serverQuantity: number,
+ isAnnual: boolean,
+) => {
+ const items: { price: string; quantity: number }[] = [];
+
+ if (tier === "legacy") {
items.push({
- price: BASE_ANNUAL_MONTHLY_ID,
+ price: isAnnual ? BASE_ANNUAL_MONTHLY_ID : BASE_PRICE_MONTHLY_ID,
quantity: serverQuantity,
});
-
return items;
}
+ if (tier === "hobby") {
+ const price = isAnnual
+ ? HOBBY_PRICE_ANNUAL_ID || BASE_ANNUAL_MONTHLY_ID
+ : HOBBY_PRICE_MONTHLY_ID || BASE_PRICE_MONTHLY_ID;
+ items.push({ price, quantity: serverQuantity });
+ return items;
+ }
+
+ // Startup: base incluye 3 servidores; del 4º en adelante = precio Hobby ($4.50 c/u)
+ if (tier === "startup") {
+ const basePrice = isAnnual
+ ? STARTUP_BASE_PRICE_ANNUAL_ID
+ : STARTUP_BASE_PRICE_MONTHLY_ID;
+ const extraServerPrice = isAnnual
+ ? HOBBY_PRICE_ANNUAL_ID || BASE_ANNUAL_MONTHLY_ID
+ : HOBBY_PRICE_MONTHLY_ID || BASE_PRICE_MONTHLY_ID;
+ if (basePrice) items.push({ price: basePrice, quantity: 1 });
+ const extraQty = Math.max(0, serverQuantity - 3);
+ if (extraQty > 0)
+ items.push({ price: extraServerPrice, quantity: extraQty });
+ return items;
+ }
+
+ // Fallback legacy
items.push({
- price: BASE_PRICE_MONTHLY_ID,
+ price: isAnnual ? BASE_ANNUAL_MONTHLY_ID : BASE_PRICE_MONTHLY_ID,
quantity: serverQuantity,
});
-
return items;
};