Merge pull request #3758 from Dokploy/feat/add-new-pricing

Feat/add new pricing
This commit is contained in:
Mauricio Siu
2026-02-19 15:01:18 -06:00
committed by GitHub
4 changed files with 1058 additions and 69 deletions

View File

@@ -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 (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -128,17 +173,6 @@ export const ShowBilling = () => {
</nav>
<div className="flex flex-col gap-4 w-full mt-6">
<Tabs
defaultValue="monthly"
value={isAnnual ? "annual" : "monthly"}
className="w-full"
onValueChange={(e) => setIsAnnual(e === "annual")}
>
<TabsList>
<TabsTrigger value="monthly">Monthly</TabsTrigger>
<TabsTrigger value="annual">Annual</TabsTrigger>
</TabsList>
</Tabs>
{admin?.user.stripeSubscriptionId && (
<div className="space-y-2 flex flex-col">
<h3 className="text-lg font-medium">Servers Plan</h3>
@@ -160,6 +194,429 @@ export const ShowBilling = () => {
)}
</div>
)}
{/* Upgrade: solo para usuarios en plan legacy con nuevos planes disponibles */}
{useNewPricing &&
data?.currentPlan === "legacy" &&
data?.subscriptions?.length > 0 && (
<div className="rounded-xl border border-border bg-primary/5 p-4 space-y-4 max-w-2xl">
<h3 className="text-lg font-medium">Upgrade your plan</h3>
<p className="text-sm text-muted-foreground">
Youre on the legacy plan. Switch to Hobby or Startup
(same benefits). You can also choose annual billing (20%
off). Stripe will prorate the change.
</p>
<span className="text-sm font-medium block">
Billing interval
</span>
<div className="flex gap-2 flex-wrap">
<Button
variant={!updateFormAnnual ? "default" : "outline"}
size="sm"
className="min-w-[6rem]"
onClick={() => setUpdateFormAnnual(false)}
>
Monthly
</Button>
<Button
variant={updateFormAnnual ? "default" : "outline"}
size="sm"
className="min-w-[6rem]"
onClick={() => setUpdateFormAnnual(true)}
>
Annual (20% off)
</Button>
</div>
<span className="text-sm font-medium block">New plan</span>
<div className="flex gap-2 flex-wrap">
<Button
variant={
upgradeTier === "hobby" ? "default" : "outline"
}
size="sm"
className="min-w-[6rem]"
onClick={() => setUpgradeTier("hobby")}
>
Hobby
</Button>
<Button
variant={
upgradeTier === "startup" ? "default" : "outline"
}
size="sm"
className="min-w-[6rem]"
onClick={() => setUpgradeTier("startup")}
>
Startup
</Button>
</div>
{upgradeTier && (
<div className="flex flex-col gap-3 pt-1">
<span className="text-sm font-medium">
Servers
{upgradeTier === "startup" &&
` (min. ${STARTUP_SERVERS_INCLUDED})`}
</span>
<div className="flex items-center gap-2 w-fit">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={
upgradeTier === "startup"
? upgradeServerQty <= STARTUP_SERVERS_INCLUDED
: upgradeServerQty <= 1
}
onClick={() =>
setUpgradeServerQty((q) =>
Math.max(
upgradeTier === "startup"
? STARTUP_SERVERS_INCLUDED
: 1,
q - 1,
),
)
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={upgradeServerQty}
onChange={(e) => {
const v =
Number((e.target as HTMLInputElement).value) ||
0;
setUpgradeServerQty(
Math.max(
upgradeTier === "startup"
? STARTUP_SERVERS_INCLUDED
: 1,
v,
),
);
}}
className="w-20 h-8"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setUpgradeServerQty((q) => q + 1)}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{upgradeTier === "hobby"
? `$${calculatePriceHobby(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`
: `$${calculatePriceStartup(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`}
</p>
<DialogAction
title="Confirm upgrade"
description={
<div className="space-y-2">
<p className="font-medium text-foreground">
Current plan: Legacy
</p>
<p className="font-medium text-foreground">
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"})
</p>
<p className="text-sm text-muted-foreground">
Stripe will prorate the change.
</p>
</div>
}
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");
}
}}
>
<Button
className="w-full sm:w-auto"
disabled={
isUpgrading ||
(upgradeTier === "startup" &&
upgradeServerQty < STARTUP_SERVERS_INCLUDED)
}
>
{isUpgrading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Upgrading
</>
) : (
"Upgrade plan"
)}
</Button>
</DialogAction>
</div>
)}
</div>
)}
{/* 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 && (
<div className="rounded-xl border border-border bg-primary/5 p-4 space-y-4 max-w-2xl">
<h3 className="text-lg font-medium">
Change plan or number of servers
</h3>
<p className="text-sm text-muted-foreground">
Your current plan:{" "}
<span className="font-medium text-foreground">
{data?.currentPlan === "startup" ? "Startup" : "Hobby"}
</span>
{" · "}
<span className="font-medium text-foreground">
{admin?.user.serversQuantity ?? 0} server
{(admin?.user.serversQuantity ?? 0) !== 1 ? "s" : ""}
</span>
{data?.currentPriceAmount != null && (
<>
{" · "}
<span className="font-medium text-foreground">
${data.currentPriceAmount.toFixed(2)}/
{data?.isAnnualCurrent ? "yr" : "mo"}
</span>
</>
)}{" "}
({data?.isAnnualCurrent ? "annual" : "monthly"} billing).
</p>
<p className="text-sm text-muted-foreground">
Add more servers, switch between Hobby and Startup, or
change to annual billing (20% off). Stripe will prorate
the change.
</p>
<span className="text-sm font-medium block">
Billing interval
</span>
<div className="flex gap-2 flex-wrap">
<Button
variant={!updateFormAnnual ? "default" : "outline"}
size="sm"
className="min-w-[6rem]"
onClick={() => setUpdateFormAnnual(false)}
>
Monthly
</Button>
<Button
variant={updateFormAnnual ? "default" : "outline"}
size="sm"
className="min-w-[6rem]"
onClick={() => setUpdateFormAnnual(true)}
>
Annual (20% off)
</Button>
</div>
<span className="text-sm font-medium block">Plan</span>
<div className="flex gap-2 flex-wrap">
<Button
variant={
upgradeTier === "hobby" ? "default" : "outline"
}
size="sm"
className="min-w-[6rem]"
onClick={() => setUpgradeTier("hobby")}
>
Hobby
</Button>
<Button
variant={
upgradeTier === "startup" ? "default" : "outline"
}
size="sm"
className="min-w-[6rem]"
onClick={() => setUpgradeTier("startup")}
>
Startup
</Button>
</div>
{upgradeTier && (
<div className="flex flex-col gap-3 pt-1">
<span className="text-sm font-medium">
Servers
{upgradeTier === "startup" &&
` (min. ${STARTUP_SERVERS_INCLUDED})`}
</span>
<div className="flex items-center gap-2 w-fit">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={
upgradeTier === "startup"
? upgradeServerQty <= STARTUP_SERVERS_INCLUDED
: upgradeServerQty <= 1
}
onClick={() =>
setUpgradeServerQty((q) =>
Math.max(
upgradeTier === "startup"
? STARTUP_SERVERS_INCLUDED
: 1,
q - 1,
),
)
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={upgradeServerQty}
onChange={(e) => {
const v =
Number((e.target as HTMLInputElement).value) ||
0;
setUpgradeServerQty(
Math.max(
upgradeTier === "startup"
? STARTUP_SERVERS_INCLUDED
: 1,
v,
),
);
}}
className="w-20 h-8"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setUpgradeServerQty((q) => q + 1)}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{upgradeTier === "hobby"
? `$${calculatePriceHobby(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`
: `$${calculatePriceStartup(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`}
</p>
<DialogAction
title="Confirm plan change"
description={
<div className="space-y-2">
<p className="font-medium text-foreground">
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"})
</p>
<p className="font-medium text-foreground">
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"})
</p>
<p className="text-sm text-muted-foreground">
Stripe will prorate the change.
</p>
</div>
}
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");
}
}}
>
<Button
className="w-auto"
disabled={
isUpgrading ||
(upgradeTier === "startup" &&
upgradeServerQty < STARTUP_SERVERS_INCLUDED)
}
>
{isUpgrading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating
</>
) : (
"Update subscription"
)}
</Button>
</DialogAction>
</div>
)}
</div>
)}
<div className="flex flex-col gap-1.5 mt-4">
<span className="text-base text-primary">
Need Help? We are here to help you.
@@ -191,8 +648,345 @@ export const ShowBilling = () => {
Loading...
<Loader2 className="animate-spin" />
</span>
) : useNewPricing ? (
<>
<Tabs
defaultValue="monthly"
value={isAnnual ? "annual" : "monthly"}
className="w-full"
onValueChange={(e) => setIsAnnual(e === "annual")}
>
<TabsList className="">
<TabsTrigger value="monthly">Monthly</TabsTrigger>
<TabsTrigger value="annual">Annual (20% off)</TabsTrigger>
</TabsList>
</Tabs>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{/* Hobby */}
<section className="flex flex-col rounded-2xl border border-border px-5 py-6 shadow-sm">
{isAnnual && (
<Badge className="mb-3 w-fit" variant="secondary">
20% off
</Badge>
)}
<h3 className="text-xl font-bold tracking-tight text-foreground">
Hobby
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Everything an individual developer needs
</p>
<div className="mt-4">
<p className="text-2xl font-semibold text-foreground">
$
{calculatePriceHobby(
serverQuantity,
isAnnual,
).toFixed(2)}
/{isAnnual ? "yr" : "mo"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Add more servers as you&apos;d like for{" "}
{isAnnual ? "$43.20/yr" : "$4.50/mo"}
</p>
{isAnnual && (
<p className="text-xs text-muted-foreground mt-2">
$
{(
calculatePriceHobby(serverQuantity, true) / 12
).toFixed(2)}
/mo
</p>
)}
</div>
<ul className="mt-5 flex flex-col gap-2 text-sm text-muted-foreground">
{[
"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) => (
<li key={f} className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 shrink-0 text-green-600 dark:text-green-500 mt-0.5" />
<span>{f}</span>
</li>
))}
</ul>
<div className="mt-6 flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Servers:
</span>
<Button
disabled={serverQuantity <= 1}
variant="outline"
size="icon"
onClick={() =>
setServerQuantity((q) => Math.max(1, q - 1))
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
onChange={(e) =>
setServerQuantity(
Math.max(
1,
Number(
(e.target as HTMLInputElement).value,
) || 1,
),
)
}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={() => setServerQuantity((q) => q + 1)}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-col gap-2 w-full">
{admin?.user.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"
onClick={async () => {
const session =
await createCustomerPortalSession();
window.open(session.url);
}}
>
Manage Subscription
</Button>
)}
{(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={serverQuantity < 1}
>
Get Started
</Button>
)}
</div>
</div>
</section>
{/* Startup - Recommended */}
<section className="flex flex-col rounded-2xl border-2 border-primary px-5 py-6 shadow-sm">
<div className="mb-3 flex flex-wrap gap-2">
<Badge className="w-fit" variant="default">
Recommended
</Badge>
{isAnnual && (
<Badge className="w-fit" variant="secondary">
20% off
</Badge>
)}
</div>
<h3 className="text-xl font-bold tracking-tight text-foreground">
Startup
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Perfect for small to mid-size teams
</p>
<div className="mt-4">
<p className="text-2xl font-semibold text-foreground">
$
{calculatePriceStartup(
serverQuantity,
isAnnual,
).toFixed(2)}
/{isAnnual ? "yr" : "mo"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Add more servers as you&apos;d like for{" "}
{isAnnual ? "$43.20/yr" : "$4.50/mo"}
</p>
{isAnnual && (
<p className="text-xs text-muted-foreground mt-2">
$
{(
calculatePriceStartup(serverQuantity, true) / 12
).toFixed(2)}
/mo
</p>
)}
</div>
<ul className="mt-5 flex flex-col gap-2 text-sm text-muted-foreground">
<li className="flex items-start gap-2 font-medium text-foreground">
<CheckIcon className="h-4 w-4 shrink-0 text-green-600 dark:text-green-500 mt-0.5" />
All the features of Hobby, plus
</li>
{[
"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) => (
<li key={f} className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 shrink-0 text-green-600 dark:text-green-500 mt-0.5" />
<span>{f}</span>
</li>
))}
</ul>
<div className="mt-6 flex flex-col gap-3">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-foreground">
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
</span>
<div className="flex items-center gap-2">
<Button
disabled={
serverQuantity <= STARTUP_SERVERS_INCLUDED
}
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
setServerQuantity((q) =>
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
)
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
onChange={(e) =>
setServerQuantity(
Math.max(
STARTUP_SERVERS_INCLUDED,
Number(
(e.target as HTMLInputElement).value,
) || STARTUP_SERVERS_INCLUDED,
),
)
}
className="h-8 text-center"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setServerQuantity((q) => q + 1)}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-col gap-2 w-full">
{admin?.user.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"
onClick={async () => {
const session =
await createCustomerPortalSession();
window.open(session.url);
}}
>
Manage Subscription
</Button>
)}
{(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout(
"startup",
data!.startupProductId!,
)
}
disabled={
serverQuantity < STARTUP_SERVERS_INCLUDED
}
>
Get Started
</Button>
)}
</div>
</div>
</section>
{/* Enterprise */}
<section className="flex flex-col rounded-2xl border border-border px-5 py-6 shadow-sm">
<h3 className="text-xl font-bold tracking-tight text-foreground">
Enterprise
</h3>
<p className="mt-1 text-sm text-muted-foreground">
For large organizations who want more control
</p>
<div className="mt-4">
<p className="text-2xl font-semibold text-foreground">
Contact Sales
</p>
</div>
<ul className="mt-5 flex flex-col gap-2 text-sm text-muted-foreground">
<li className="flex items-start gap-2 font-medium text-foreground">
<CheckIcon className="h-4 w-4 shrink-0 text-green-600 dark:text-green-500 mt-0.5" />
All the features of Startup, plus
</li>
{[
"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) => (
<li key={f} className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 shrink-0 text-green-600 dark:text-green-500 mt-0.5" />
<span>{f}</span>
</li>
))}
</ul>
<Button variant="outline" className="mt-6 w-full" asChild>
<Link
href="https://dokploy.com/contact"
target="_blank"
rel="noopener noreferrer"
>
Contact Sales
</Link>
</Button>
</section>
</div>
</>
) : (
<>
<Tabs
defaultValue="monthly"
value={isAnnual ? "annual" : "monthly"}
className="w-full"
onValueChange={(e) => setIsAnnual(e === "annual")}
>
<TabsList className="grid w-full max-w-[14rem] grid-cols-2">
<TabsTrigger value="monthly">Monthly</TabsTrigger>
<TabsTrigger value="annual">Annual (20% off)</TabsTrigger>
</TabsList>
</Tabs>
{products?.map((product) => {
const featured = true;
return (
@@ -311,15 +1105,7 @@ export const ShowBilling = () => {
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<div
className={cn(
data?.subscriptions &&
data?.subscriptions?.length > 0
? "justify-between"
: "justify-end",
"flex flex-row items-center gap-2 mt-4",
)}
>
<div className="flex flex-col gap-2 mt-4 w-full">
{admin?.user.stripeCustomerId && (
<Button
variant="secondary"
@@ -327,26 +1113,22 @@ export const ShowBilling = () => {
onClick={async () => {
const session =
await createCustomerPortalSession();
window.open(session.url);
}}
>
Manage Subscription
</Button>
)}
{data?.subscriptions?.length === 0 && (
<div className="justify-end w-full">
<Button
className="w-full"
onClick={async () => {
handleCheckout(product.id);
}}
disabled={serverQuantity < 1}
>
Subscribe
</Button>
</div>
{(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={async () => {
handleCheckout("legacy", product.id);
}}
disabled={serverQuantity < 1}
>
Subscribe
</Button>
)}
</div>
</div>

View File

@@ -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(

View File

@@ -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);

View File

@@ -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;
};