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.
This commit is contained in:
Mauricio Siu
2026-02-19 02:04:59 -06:00
parent 660bc3cd00
commit 32a14be564
4 changed files with 960 additions and 69 deletions

View File

@@ -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 (
<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 +171,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 +192,334 @@ 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-primary/30 bg-primary/5 p-4 space-y-4">
<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>
<Button
className="w-full sm:w-auto"
disabled={
isUpgrading ||
(upgradeTier === "startup" &&
upgradeServerQty < STARTUP_SERVERS_INCLUDED)
}
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);
}}
>
{isUpgrading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Upgrading
</>
) : (
"Upgrade plan"
)}
</Button>
</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-primary/30 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>
<Button
className="w-auto"
disabled={
isUpgrading ||
(upgradeTier === "startup" &&
upgradeServerQty < STARTUP_SERVERS_INCLUDED)
}
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();
}}
>
{isUpgrading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating
</>
) : (
"Update subscription"
)}
</Button>
</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 +551,328 @@ 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">
{isAnnual ? "$45.90/yr" : "$4.50/mo"} per server (add
as many as you&apos;d like for{" "}
{isAnnual ? "$45.90/yr" : "$4.50/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 ? "$45.90/yr" : "$4.50/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 +991,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 +999,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,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(

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 ? [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);

View File

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