mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
Merge pull request #3758 from Dokploy/feat/add-new-pricing
Feat/add new pricing
This commit is contained in:
@@ -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">
|
||||
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.
|
||||
</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'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'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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user