From 32a14be5648d67f2e067f33140f1a97690ade81c Mon Sep 17 00:00:00 2001
From: Mauricio Siu
Date: Thu, 19 Feb 2026 02:04:59 -0600
Subject: [PATCH 1/7] feat: enhance billing functionality with new pricing
tiers and upgrade options
- Added new pricing calculations for Hobby and Startup tiers, including server quantity handling.
- Implemented upgrade functionality for users on legacy plans to switch to Hobby or Startup plans.
- Updated Stripe webhook to correctly calculate server quantities based on subscription items.
- Refactored getStripeItems utility to accommodate new billing tiers and server quantity logic.
---
.../settings/billing/show-billing.tsx | 744 +++++++++++++++++-
apps/dokploy/pages/api/stripe/webhook.ts | 42 +-
apps/dokploy/server/api/routers/stripe.ts | 177 ++++-
apps/dokploy/server/utils/stripe.ts | 66 +-
4 files changed, 960 insertions(+), 69 deletions(-)
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
index 1460244c1..ddb7e525e 100644
--- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
+++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -31,6 +31,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 +41,27 @@ export const calculatePrice = (count: number, isAnnual = false) => {
return count * 3.5;
};
+/** Hobby: $4.50/mo por servidor; anual $45.90 por servidor (o 20% off = 43.92). */
+export const calculatePriceHobby = (count: number, isAnnual = false) => {
+ const perServerMonthly = 4.5;
+ const perServerAnnual = 45.9;
+ return isAnnual ? count * perServerAnnual : count * perServerMonthly;
+};
+
+/** Startup: incluye 3 servidores ($15/mo); del 4º en adelante $4.50 c/u. Anual 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 con 20% off
+ const extraAnnual = 45.9;
+ 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 +85,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 +122,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 +136,7 @@ export const ShowBilling = () => {
return (
-
+
@@ -128,17 +171,6 @@ export const ShowBilling = () => {
-
setIsAnnual(e === "annual")}
- >
-
- Monthly
- Annual
-
-
{admin?.user.stripeSubscriptionId && (
Servers Plan
@@ -160,6 +192,334 @@ 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"}`}
+
+
{
+ if (!upgradeTier) return;
+ await upgradeSubscription({
+ tier: upgradeTier,
+ serverQuantity: upgradeServerQty,
+ isAnnual: updateFormAnnual,
+ });
+ await utils.stripe.getProducts.invalidate();
+ await utils.user.get.invalidate();
+ setUpgradeTier(null);
+ }}
+ >
+ {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"}`}
+
+
{
+ if (!upgradeTier) return;
+ await upgradeSubscription({
+ tier: upgradeTier,
+ serverQuantity: upgradeServerQty,
+ isAnnual: updateFormAnnual,
+ });
+ await utils.stripe.getProducts.invalidate();
+ await utils.user.get.invalidate();
+ setUpgradeTier(null);
+ // window.location.reload();
+ }}
+ >
+ {isUpgrading ? (
+ <>
+
+ Updating…
+ >
+ ) : (
+ "Update subscription"
+ )}
+
+
+ )}
+
+ )}
Need Help? We are here to help you.
@@ -191,8 +551,328 @@ 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"}
+
+
+ {isAnnual ? "$45.90/yr" : "$4.50/mo"} per server (add
+ as many as you'd like for{" "}
+ {isAnnual ? "$45.90/yr" : "$4.50/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 ? "$45.90/yr" : "$4.50/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 +991,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..f1a33507b 100644
--- a/apps/dokploy/pages/api/stripe/webhook.ts
+++ b/apps/dokploy/pages/api/stripe/webhook.ts
@@ -8,6 +8,26 @@ 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;
+
+/** Total de servidores: ítem "Startup base" (qty 1) = 3 servidores; el resto = quantity por ítem. */
+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 +83,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 +153,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 +186,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..a27adcd7d 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 ? [HOBBY_PRODUCT_ID] : []),
+ ...(STARTUP_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,86 @@ export const stripeRouter = createTRPCRouter({
expand: ["data.items.data.price"],
});
+ // Detectar plan actual para mostrar upgrade a usuarios legacy
+ 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";
+ // Precio actual total del intervalo (mes o año) desde Stripe
+ 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 +207,83 @@ export const stripeRouter = createTRPCRouter({
}
}),
+ /** Cambiar de plan o cantidad de servidores (legacy→Hobby/Startup, o Hobby/Startup→cambiar tier/cantidad). Portal deshabilitado para esto; se hace desde la app. */
+ 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..3a05dd640 100644
--- a/apps/dokploy/server/utils/stripe.ts
+++ b/apps/dokploy/server/utils/stripe.ts
@@ -3,28 +3,72 @@ 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
-
+// Legacy: precios/productos actuales. No borrar; usuarios existentes siguen con estos.
+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 = [];
+/** Price IDs legacy: suscripciones con estos IDs no se migran y siguen con los mismos beneficios. */
+export const LEGACY_PRICE_IDS = [
+ process.env.BASE_PRICE_MONTHLY_ID,
+ process.env.BASE_ANNUAL_MONTHLY_ID,
+].filter(Boolean) as string[];
- if (isAnnual) {
+// Nuevos planes (opcionales hasta que crees los productos en Stripe)
+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";
+
+/** Line items para Stripe Checkout según tier y cantidad de servidores. */
+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;
};
From 28fc58d89896d873509facabebe2194e14b304c1 Mon Sep 17 00:00:00 2001
From: Mauricio Siu
Date: Thu, 19 Feb 2026 12:11:27 -0600
Subject: [PATCH 2/7] feat: enhance billing upgrade process with confirmation
dialog and toast notifications
- Integrated a confirmation dialog for upgrading plans, providing users with clear details about their current and new plans.
- Added toast notifications to inform users of successful upgrades or errors during the process.
- Updated UI elements for better styling and user experience in the billing section.
---
.../settings/billing/show-billing.tsx | 199 +++++++++++++-----
1 file changed, 148 insertions(+), 51 deletions(-)
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
index ddb7e525e..0fe3c2a07 100644
--- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
+++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
@@ -12,6 +12,8 @@ import {
import Link from "next/link";
import { useRouter } from "next/router";
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 {
@@ -196,7 +198,7 @@ export const ShowBilling = () => {
{useNewPricing &&
data?.currentPlan === "legacy" &&
data?.subscriptions?.length > 0 && (
-
+
Upgrade your plan
You’re on the legacy plan. Switch to Hobby or Startup
@@ -311,34 +313,73 @@ export const ShowBilling = () => {
? `$${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;
- await upgradeSubscription({
- tier: upgradeTier,
- serverQuantity: upgradeServerQty,
- isAnnual: updateFormAnnual,
- });
- await utils.stripe.getProducts.invalidate();
- await utils.user.get.invalidate();
- setUpgradeTier(null);
+ 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"
- )}
-
+
+ {isUpgrading ? (
+ <>
+
+ Upgrading…
+ >
+ ) : (
+ "Upgrade plan"
+ )}
+
+
)}
@@ -348,7 +389,7 @@ export const ShowBilling = () => {
(data?.currentPlan === "hobby" ||
data?.currentPlan === "startup") &&
data?.subscriptions?.length > 0 && (
-
+
Change plan or number of servers
@@ -366,8 +407,7 @@ export const ShowBilling = () => {
<>
{" · "}
- $
- {data.currentPriceAmount.toFixed(2)}/
+ ${data.currentPriceAmount.toFixed(2)}/
{data?.isAnnualCurrent ? "yr" : "mo"}
>
@@ -487,35 +527,92 @@ export const ShowBilling = () => {
? `$${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;
- await upgradeSubscription({
- tier: upgradeTier,
- serverQuantity: upgradeServerQty,
- isAnnual: updateFormAnnual,
- });
- await utils.stripe.getProducts.invalidate();
- await utils.user.get.invalidate();
- setUpgradeTier(null);
- // window.location.reload();
+ 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"
- )}
-
+
+ {isUpgrading ? (
+ <>
+
+ Updating…
+ >
+ ) : (
+ "Update subscription"
+ )}
+
+
)}
From 97374f736e72e9932d4ae3ff39dfea7b59cbded1 Mon Sep 17 00:00:00 2001
From: Mauricio Siu
Date: Thu, 19 Feb 2026 12:45:18 -0600
Subject: [PATCH 3/7] feat: update billing display to show monthly pricing for
additional servers
- Modified the billing section to clarify that users can add more servers at the specified monthly rate.
- Added monthly pricing display for both Hobby and Startup tiers when the annual plan is selected.
- Enhanced user experience by providing clearer pricing information based on server quantity.
---
.../settings/billing/show-billing.tsx | 23 ++++++++++++++++---
1 file changed, 20 insertions(+), 3 deletions(-)
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
index 0fe3c2a07..cdb127880 100644
--- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
+++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
@@ -685,10 +685,18 @@ export const ShowBilling = () => {
/{isAnnual ? "yr" : "mo"}
- {isAnnual ? "$45.90/yr" : "$4.50/mo"} per server (add
- as many as you'd like for{" "}
- {isAnnual ? "$45.90/yr" : "$4.50/mo"})
+ Add more servers as you'd like for{" "}
+ {isAnnual ? "$45.90/yr" : "$4.50/mo"}
+ {isAnnual && (
+
+ $
+ {(
+ calculatePriceHobby(serverQuantity, true) / 12
+ ).toFixed(2)}
+ /mo
+
+ )}
{[
@@ -807,6 +815,15 @@ export const ShowBilling = () => {
Add more servers as you'd like for{" "}
{isAnnual ? "$45.90/yr" : "$4.50/mo"}
+ {isAnnual && (
+
+ $
+ {(
+ calculatePriceStartup(serverQuantity, true) / 12
+ ).toFixed(2)}
+ /mo
+
+ )}
From 2b42ef7829211144b43e3b48a04d822d72cea95d Mon Sep 17 00:00:00 2001
From: Mauricio Siu
Date: Thu, 19 Feb 2026 14:50:28 -0600
Subject: [PATCH 4/7] refactor: simplify product ID handling and refine
validation logic in Stripe router
- Updated the product ID array construction to directly include Hobby and Startup product IDs.
- Streamlined the refinement logic for server quantity validation, improving readability without altering functionality.
- Ensured consistent validation messages for the Startup plan requirements.
---
apps/dokploy/server/api/routers/stripe.ts | 31 ++++++++---------------
1 file changed, 11 insertions(+), 20 deletions(-)
diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts
index a27adcd7d..b69716951 100644
--- a/apps/dokploy/server/api/routers/stripe.ts
+++ b/apps/dokploy/server/api/routers/stripe.ts
@@ -40,8 +40,8 @@ export const stripeRouter = createTRPCRouter({
const productIds = [
PRODUCT_MONTHLY_ID,
PRODUCT_ANNUAL_ID,
- ...(HOBBY_PRODUCT_ID ? [HOBBY_PRODUCT_ID] : []),
- ...(STARTUP_PRODUCT_ID ? [STARTUP_PRODUCT_ID] : []),
+ HOBBY_PRODUCT_ID,
+ STARTUP_PRODUCT_ID,
].filter(Boolean);
const filteredProducts = products.data.filter((product) =>
productIds.includes(product.id),
@@ -85,8 +85,7 @@ export const stripeRouter = createTRPCRouter({
currentPlan = "startup";
} else if (
priceIds.some(
- (id) =>
- id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
+ (id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
)
) {
currentPlan = "hobby";
@@ -126,14 +125,10 @@ export const stripeRouter = createTRPCRouter({
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"],
- },
- ),
+ .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!, {
@@ -216,14 +211,10 @@ export const stripeRouter = createTRPCRouter({
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"],
- },
- ),
+ .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!, {
From 66190434a7887f7cd6e3153582480a26cfbef38f Mon Sep 17 00:00:00 2001
From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com>
Date: Thu, 19 Feb 2026 20:51:00 +0000
Subject: [PATCH 5/7] [autofix.ci] apply automated fixes
---
apps/dokploy/server/utils/stripe.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/dokploy/server/utils/stripe.ts b/apps/dokploy/server/utils/stripe.ts
index 3a05dd640..23e9a6b8f 100644
--- a/apps/dokploy/server/utils/stripe.ts
+++ b/apps/dokploy/server/utils/stripe.ts
@@ -61,7 +61,8 @@ export const getStripeItems = (
: 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 });
+ if (extraQty > 0)
+ items.push({ price: extraServerPrice, quantity: extraQty });
return items;
}
From 781bf5e1160e2d09a914d2da7db580c486faea56 Mon Sep 17 00:00:00 2001
From: Mauricio Siu
Date: Thu, 19 Feb 2026 14:53:28 -0600
Subject: [PATCH 6/7] refactor: remove commented-out code and clean up Stripe
router
- Eliminated outdated comments related to legacy plan detection and current pricing calculations.
- Improved code readability by removing unnecessary comments that no longer serve a purpose.
---
apps/dokploy/pages/api/stripe/webhook.ts | 1 -
apps/dokploy/server/api/routers/stripe.ts | 3 ---
apps/dokploy/server/utils/stripe.ts | 4 ----
3 files changed, 8 deletions(-)
diff --git a/apps/dokploy/pages/api/stripe/webhook.ts b/apps/dokploy/pages/api/stripe/webhook.ts
index f1a33507b..f950eb344 100644
--- a/apps/dokploy/pages/api/stripe/webhook.ts
+++ b/apps/dokploy/pages/api/stripe/webhook.ts
@@ -15,7 +15,6 @@ const STARTUP_BASE_PRICE_IDS = [
const STARTUP_SERVERS_INCLUDED = 3;
-/** Total de servidores: ítem "Startup base" (qty 1) = 3 servidores; el resto = quantity por ítem. */
function getSubscriptionServersQuantity(
items: Stripe.SubscriptionItem[],
): number {
diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts
index b69716951..c910b1d58 100644
--- a/apps/dokploy/server/api/routers/stripe.ts
+++ b/apps/dokploy/server/api/routers/stripe.ts
@@ -65,7 +65,6 @@ export const stripeRouter = createTRPCRouter({
expand: ["data.items.data.price"],
});
- // Detectar plan actual para mostrar upgrade a usuarios legacy
type CurrentPlan = "legacy" | "hobby" | "startup";
let currentPlan: CurrentPlan = "legacy";
let isAnnualCurrent = false;
@@ -96,7 +95,6 @@ export const stripeRouter = createTRPCRouter({
| Stripe.Price
| undefined;
isAnnualCurrent = firstPrice?.recurring?.interval === "year";
- // Precio actual total del intervalo (mes o año) desde Stripe
const totalCents = activeSub.items.data.reduce((sum, item) => {
const price = item.price as Stripe.Price;
const amount = price.unit_amount ?? 0;
@@ -202,7 +200,6 @@ export const stripeRouter = createTRPCRouter({
}
}),
- /** Cambiar de plan o cantidad de servidores (legacy→Hobby/Startup, o Hobby/Startup→cambiar tier/cantidad). Portal deshabilitado para esto; se hace desde la app. */
upgradeSubscription: adminProcedure
.input(
z
diff --git a/apps/dokploy/server/utils/stripe.ts b/apps/dokploy/server/utils/stripe.ts
index 23e9a6b8f..078885a98 100644
--- a/apps/dokploy/server/utils/stripe.ts
+++ b/apps/dokploy/server/utils/stripe.ts
@@ -3,19 +3,16 @@ export const WEBSITE_URL =
? "http://localhost:3000"
: process.env.SITE_URL;
-// Legacy: precios/productos actuales. No borrar; usuarios existentes siguen con estos.
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!;
-/** Price IDs legacy: suscripciones con estos IDs no se migran y siguen con los mismos beneficios. */
export const LEGACY_PRICE_IDS = [
process.env.BASE_PRICE_MONTHLY_ID,
process.env.BASE_ANNUAL_MONTHLY_ID,
].filter(Boolean) as string[];
-// Nuevos planes (opcionales hasta que crees los productos en Stripe)
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 ?? "";
@@ -28,7 +25,6 @@ export const STARTUP_BASE_PRICE_ANNUAL_ID =
export type BillingTier = "legacy" | "hobby" | "startup";
-/** Line items para Stripe Checkout según tier y cantidad de servidores. */
export const getStripeItems = (
tier: BillingTier,
serverQuantity: number,
From daf700429d21b6a4ade95514095526b4d73f677d Mon Sep 17 00:00:00 2001
From: Mauricio Siu
Date: Thu, 19 Feb 2026 14:57:45 -0600
Subject: [PATCH 7/7] refactor: update billing pricing calculations and display
- Revised pricing calculations for Hobby and Startup tiers to reflect accurate annual rates with 20% discount.
- Updated comments for clarity and consistency in pricing logic.
- Adjusted billing display to show the correct annual pricing for additional servers.
---
.../dashboard/settings/billing/show-billing.tsx | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
index cdb127880..740c5179c 100644
--- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
+++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
@@ -43,20 +43,20 @@ export const calculatePrice = (count: number, isAnnual = false) => {
return count * 3.5;
};
-/** Hobby: $4.50/mo por servidor; anual $45.90 por servidor (o 20% off = 43.92). */
+/** 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 = 45.9;
+ const perServerAnnual = 43.2; // 4.5 * 12 * 0.8
return isAnnual ? count * perServerAnnual : count * perServerMonthly;
};
-/** Startup: incluye 3 servidores ($15/mo); del 4º en adelante $4.50 c/u. Anual 20% off. */
+/** 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 con 20% off
- const extraAnnual = 45.9;
+ 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
@@ -686,7 +686,7 @@ export const ShowBilling = () => {
Add more servers as you'd like for{" "}
- {isAnnual ? "$45.90/yr" : "$4.50/mo"}
+ {isAnnual ? "$43.20/yr" : "$4.50/mo"}
{isAnnual && (
@@ -813,7 +813,7 @@ export const ShowBilling = () => {
Add more servers as you'd like for{" "}
- {isAnnual ? "$45.90/yr" : "$4.50/mo"}
+ {isAnnual ? "$43.20/yr" : "$4.50/mo"}
{isAnnual && (