-
setIsAnnual(e === "annual")}
- >
-
- Monthly
- Annual
-
-
{admin?.user.stripeSubscriptionId && (
Servers Plan
@@ -160,6 +192,334 @@ export const ShowBilling = () => {
)}
)}
+ {/* Upgrade: solo para usuarios en plan legacy con nuevos planes disponibles */}
+ {useNewPricing &&
+ data?.currentPlan === "legacy" &&
+ data?.subscriptions?.length > 0 && (
+
+
Upgrade your plan
+
+ You’re on the legacy plan. Switch to Hobby or Startup
+ (same benefits). You can also choose annual billing (20%
+ off). Stripe will prorate the change.
+
+
+
+ Billing interval
+
+
+ setUpdateFormAnnual(false)}
+ >
+ Monthly
+
+ setUpdateFormAnnual(true)}
+ >
+ Annual (20% off)
+
+
+
+
New plan
+
+ setUpgradeTier("hobby")}
+ >
+ Hobby
+
+ setUpgradeTier("startup")}
+ >
+ Startup
+
+
+
+ {upgradeTier && (
+
+
+ Servers
+ {upgradeTier === "startup" &&
+ ` (min. ${STARTUP_SERVERS_INCLUDED})`}
+
+
+
+ setUpgradeServerQty((q) =>
+ Math.max(
+ upgradeTier === "startup"
+ ? STARTUP_SERVERS_INCLUDED
+ : 1,
+ q - 1,
+ ),
+ )
+ }
+ >
+
+
+
{
+ const v =
+ Number((e.target as HTMLInputElement).value) ||
+ 0;
+ setUpgradeServerQty(
+ Math.max(
+ upgradeTier === "startup"
+ ? STARTUP_SERVERS_INCLUDED
+ : 1,
+ v,
+ ),
+ );
+ }}
+ className="w-20 h-8"
+ />
+ setUpgradeServerQty((q) => q + 1)}
+ >
+
+
+
+
+ {upgradeTier === "hobby"
+ ? `$${calculatePriceHobby(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`
+ : `$${calculatePriceStartup(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`}
+
+
{
+ if (!upgradeTier) return;
+ await upgradeSubscription({
+ tier: upgradeTier,
+ serverQuantity: upgradeServerQty,
+ isAnnual: updateFormAnnual,
+ });
+ await utils.stripe.getProducts.invalidate();
+ await utils.user.get.invalidate();
+ setUpgradeTier(null);
+ }}
+ >
+ {isUpgrading ? (
+ <>
+
+ Upgrading…
+ >
+ ) : (
+ "Upgrade plan"
+ )}
+
+
+ )}
+
+ )}
+ {/* Cambiar plan o cantidad de servidores (usuarios en Hobby o Startup; el portal no permite esto) */}
+ {useNewPricing &&
+ (data?.currentPlan === "hobby" ||
+ data?.currentPlan === "startup") &&
+ data?.subscriptions?.length > 0 && (
+
+
+ Change plan or number of servers
+
+
+ Your current plan:{" "}
+
+ {data?.currentPlan === "startup" ? "Startup" : "Hobby"}
+
+ {" · "}
+
+ {admin?.user.serversQuantity ?? 0} server
+ {(admin?.user.serversQuantity ?? 0) !== 1 ? "s" : ""}
+
+ {data?.currentPriceAmount != null && (
+ <>
+ {" · "}
+
+ $
+ {data.currentPriceAmount.toFixed(2)}/
+ {data?.isAnnualCurrent ? "yr" : "mo"}
+
+ >
+ )}{" "}
+ ({data?.isAnnualCurrent ? "annual" : "monthly"} billing).
+
+
+ Add more servers, switch between Hobby and Startup, or
+ change to annual billing (20% off). Stripe will prorate
+ the change.
+
+
+
+ Billing interval
+
+
+ setUpdateFormAnnual(false)}
+ >
+ Monthly
+
+ setUpdateFormAnnual(true)}
+ >
+ Annual (20% off)
+
+
+
+
Plan
+
+ setUpgradeTier("hobby")}
+ >
+ Hobby
+
+ setUpgradeTier("startup")}
+ >
+ Startup
+
+
+
+ {upgradeTier && (
+
+
+ Servers
+ {upgradeTier === "startup" &&
+ ` (min. ${STARTUP_SERVERS_INCLUDED})`}
+
+
+
+ setUpgradeServerQty((q) =>
+ Math.max(
+ upgradeTier === "startup"
+ ? STARTUP_SERVERS_INCLUDED
+ : 1,
+ q - 1,
+ ),
+ )
+ }
+ >
+
+
+
{
+ const v =
+ Number((e.target as HTMLInputElement).value) ||
+ 0;
+ setUpgradeServerQty(
+ Math.max(
+ upgradeTier === "startup"
+ ? STARTUP_SERVERS_INCLUDED
+ : 1,
+ v,
+ ),
+ );
+ }}
+ className="w-20 h-8"
+ />
+ setUpgradeServerQty((q) => q + 1)}
+ >
+
+
+
+
+ {upgradeTier === "hobby"
+ ? `$${calculatePriceHobby(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`
+ : `$${calculatePriceStartup(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`}
+
+
{
+ if (!upgradeTier) return;
+ await upgradeSubscription({
+ tier: upgradeTier,
+ serverQuantity: upgradeServerQty,
+ isAnnual: updateFormAnnual,
+ });
+ await utils.stripe.getProducts.invalidate();
+ await utils.user.get.invalidate();
+ setUpgradeTier(null);
+ // window.location.reload();
+ }}
+ >
+ {isUpgrading ? (
+ <>
+
+ Updating…
+ >
+ ) : (
+ "Update subscription"
+ )}
+
+
+ )}
+
+ )}
Need Help? We are here to help you.
@@ -191,8 +551,328 @@ export const ShowBilling = () => {
Loading...
+ ) : useNewPricing ? (
+ <>
+
setIsAnnual(e === "annual")}
+ >
+
+ Monthly
+ Annual (20% off)
+
+
+
+ {/* Hobby */}
+
+ {isAnnual && (
+
+ 20% off
+
+ )}
+
+ Hobby
+
+
+ Everything an individual developer needs
+
+
+
+ $
+ {calculatePriceHobby(
+ serverQuantity,
+ isAnnual,
+ ).toFixed(2)}
+ /{isAnnual ? "yr" : "mo"}
+
+
+ {isAnnual ? "$45.90/yr" : "$4.50/mo"} per server (add
+ as many as you'd like for{" "}
+ {isAnnual ? "$45.90/yr" : "$4.50/mo"})
+
+
+
+ {[
+ "Unlimited Deployments",
+ "Unlimited Databases",
+ "Unlimited Applications",
+ "1 Server Included",
+ "1 Organization",
+ "1 User",
+ "2 Environments",
+ "1 Volume Backup per Application",
+ "1 Backup per Database",
+ "1 Scheduled Job per Application",
+ "Community Support (Discord)",
+ ].map((f) => (
+
+
+ {f}
+
+ ))}
+
+
+
+
+ Servers:
+
+
+ setServerQuantity((q) => Math.max(1, q - 1))
+ }
+ >
+
+
+
+ setServerQuantity(
+ Math.max(
+ 1,
+ Number(
+ (e.target as HTMLInputElement).value,
+ ) || 1,
+ ),
+ )
+ }
+ className="text-center"
+ />
+ setServerQuantity((q) => q + 1)}
+ >
+
+
+
+
+ {admin?.user.stripeCustomerId && (
+ {
+ const session =
+ await createCustomerPortalSession();
+ window.open(session.url);
+ }}
+ >
+ Manage Subscription
+
+ )}
+ {(data?.subscriptions?.length ?? 0) === 0 && (
+
+ handleCheckout("hobby", data!.hobbyProductId!)
+ }
+ disabled={serverQuantity < 1}
+ >
+ Get Started
+
+ )}
+
+
+
+
+ {/* Startup - Recommended */}
+
+
+
+ Recommended
+
+ {isAnnual && (
+
+ 20% off
+
+ )}
+
+
+ Startup
+
+
+ Perfect for small to mid-size teams
+
+
+
+ $
+ {calculatePriceStartup(
+ serverQuantity,
+ isAnnual,
+ ).toFixed(2)}
+ /{isAnnual ? "yr" : "mo"}
+
+
+ Add more servers as you'd like for{" "}
+ {isAnnual ? "$45.90/yr" : "$4.50/mo"}
+
+
+
+
+
+ All the features of Hobby, plus…
+
+ {[
+ "3 Servers Included",
+ "3 Organizations",
+ "Unlimited Users",
+ "Unlimited Environments",
+ "Unlimited Volume Backups",
+ "Unlimited Database Backups",
+ "Unlimited Scheduled Jobs",
+ "Basic RBAC (Admin, Developer)",
+ "2FA",
+ "Email and Chat Support",
+ ].map((f) => (
+
+
+ {f}
+
+ ))}
+
+
+
+
+ Servers (min. {STARTUP_SERVERS_INCLUDED} included)
+
+
+
+ setServerQuantity((q) =>
+ Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
+ )
+ }
+ >
+
+
+
+ setServerQuantity(
+ Math.max(
+ STARTUP_SERVERS_INCLUDED,
+ Number(
+ (e.target as HTMLInputElement).value,
+ ) || STARTUP_SERVERS_INCLUDED,
+ ),
+ )
+ }
+ className="h-8 text-center"
+ />
+ setServerQuantity((q) => q + 1)}
+ >
+
+
+
+
+
+ {admin?.user.stripeCustomerId && (
+ {
+ const session =
+ await createCustomerPortalSession();
+ window.open(session.url);
+ }}
+ >
+ Manage Subscription
+
+ )}
+ {(data?.subscriptions?.length ?? 0) === 0 && (
+
+ handleCheckout(
+ "startup",
+ data!.startupProductId!,
+ )
+ }
+ disabled={
+ serverQuantity < STARTUP_SERVERS_INCLUDED
+ }
+ >
+ Get Started
+
+ )}
+
+
+
+
+ {/* Enterprise */}
+
+
+ Enterprise
+
+
+ For large organizations who want more control
+
+
+
+
+
+ All the features of Startup, plus…
+
+ {[
+ "Up to Unlimited Servers",
+ "Up to Unlimited Organizations",
+ "Fine-grained RBAC",
+ "Complete Hosting Flexibility",
+ "SSO / SAML (Azure, OKTA, etc)",
+ "Audit Logs",
+ "MSA/SLA",
+ "White Labeling",
+ "Priority Support and Services",
+ ].map((f) => (
+
+
+ {f}
+
+ ))}
+
+
+
+ Contact Sales
+
+
+
+
+ >
) : (
<>
+
setIsAnnual(e === "annual")}
+ >
+
+ Monthly
+ Annual (20% off)
+
+
{products?.map((product) => {
const featured = true;
return (
@@ -311,15 +991,7 @@ export const ShowBilling = () => {
-
0
- ? "justify-between"
- : "justify-end",
- "flex flex-row items-center gap-2 mt-4",
- )}
- >
+
{admin?.user.stripeCustomerId && (
{
onClick={async () => {
const session =
await createCustomerPortalSession();
-
window.open(session.url);
}}
>
Manage Subscription
)}
-
- {data?.subscriptions?.length === 0 && (
-
- {
- handleCheckout(product.id);
- }}
- disabled={serverQuantity < 1}
- >
- Subscribe
-
-
+ {(data?.subscriptions?.length ?? 0) === 0 && (
+
{
+ handleCheckout("legacy", product.id);
+ }}
+ disabled={serverQuantity < 1}
+ >
+ Subscribe
+
)}
diff --git a/apps/dokploy/pages/api/stripe/webhook.ts b/apps/dokploy/pages/api/stripe/webhook.ts
index b44b63d45..f1a33507b 100644
--- a/apps/dokploy/pages/api/stripe/webhook.ts
+++ b/apps/dokploy/pages/api/stripe/webhook.ts
@@ -8,6 +8,26 @@ import { organization, server, user } from "@/server/db/schema";
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
+const STARTUP_BASE_PRICE_IDS = [
+ process.env.STARTUP_BASE_PRICE_MONTHLY_ID,
+ process.env.STARTUP_BASE_PRICE_ANNUAL_ID,
+].filter(Boolean) as string[];
+
+const STARTUP_SERVERS_INCLUDED = 3;
+
+/** Total de servidores: ítem "Startup base" (qty 1) = 3 servidores; el resto = quantity por ítem. */
+function getSubscriptionServersQuantity(
+ items: Stripe.SubscriptionItem[],
+): number {
+ return items.reduce((sum, item) => {
+ const priceId = (item.price as Stripe.Price).id;
+ if (STARTUP_BASE_PRICE_IDS.includes(priceId)) {
+ return sum + STARTUP_SERVERS_INCLUDED;
+ }
+ return sum + (item.quantity ?? 0);
+ }, 0);
+}
+
export const config = {
api: {
bodyParser: false,
@@ -63,12 +83,15 @@ export default async function handler(
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string,
);
+ const serversQuantity = getSubscriptionServersQuantity(
+ subscription?.items?.data ?? [],
+ );
await db
.update(user)
.set({
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
- serversQuantity: subscription?.items?.data?.[0]?.quantity ?? 0,
+ serversQuantity,
})
.where(eq(user.id, adminId))
.returning();
@@ -130,15 +153,15 @@ export default async function handler(
}
if (newSubscription.status === "active") {
+ const serversQuantity = getSubscriptionServersQuantity(
+ newSubscription?.items?.data ?? [],
+ );
await db
.update(user)
- .set({
- serversQuantity: newSubscription?.items?.data?.[0]?.quantity ?? 0,
- })
+ .set({ serversQuantity })
.where(eq(user.stripeCustomerId, newSubscription.customer as string));
- const newServersQuantity = admin.serversQuantity;
- await updateServersBasedOnQuantity(admin.id, newServersQuantity);
+ await updateServersBasedOnQuantity(admin.id, serversQuantity);
} else {
await disableServers(admin.id);
await db
@@ -163,11 +186,12 @@ export default async function handler(
break;
}
+ const serversQuantity = getSubscriptionServersQuantity(
+ suscription?.items?.data ?? [],
+ );
await db
.update(user)
- .set({
- serversQuantity: suscription?.items?.data?.[0]?.quantity ?? 0,
- })
+ .set({ serversQuantity })
.where(eq(user.stripeCustomerId, suscription.customer as string));
const admin = await findUserByStripeCustomerId(
diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts
index 963ec8c5b..a27adcd7d 100644
--- a/apps/dokploy/server/api/routers/stripe.ts
+++ b/apps/dokploy/server/api/routers/stripe.ts
@@ -8,9 +8,17 @@ import { TRPCError } from "@trpc/server";
import Stripe from "stripe";
import { z } from "zod";
import {
+ type BillingTier,
getStripeItems,
+ HOBBY_PRICE_ANNUAL_ID,
+ HOBBY_PRICE_MONTHLY_ID,
+ HOBBY_PRODUCT_ID,
+ LEGACY_PRICE_IDS,
PRODUCT_ANNUAL_ID,
PRODUCT_MONTHLY_ID,
+ STARTUP_BASE_PRICE_ANNUAL_ID,
+ STARTUP_BASE_PRICE_MONTHLY_ID,
+ STARTUP_PRODUCT_ID,
WEBSITE_URL,
} from "@/server/utils/stripe";
import { adminProcedure, createTRPCRouter } from "../trpc";
@@ -29,16 +37,25 @@ export const stripeRouter = createTRPCRouter({
active: true,
});
- const filteredProducts = products.data.filter((product) => {
- return (
- product.id === PRODUCT_MONTHLY_ID || product.id === PRODUCT_ANNUAL_ID
- );
- });
+ const productIds = [
+ PRODUCT_MONTHLY_ID,
+ PRODUCT_ANNUAL_ID,
+ ...(HOBBY_PRODUCT_ID ? [HOBBY_PRODUCT_ID] : []),
+ ...(STARTUP_PRODUCT_ID ? [STARTUP_PRODUCT_ID] : []),
+ ].filter(Boolean);
+ const filteredProducts = products.data.filter((product) =>
+ productIds.includes(product.id),
+ );
if (!stripeCustomerId) {
return {
products: filteredProducts,
subscriptions: [],
+ hobbyProductId: HOBBY_PRODUCT_ID || undefined,
+ startupProductId: STARTUP_PRODUCT_ID || undefined,
+ currentPlan: null as "legacy" | "hobby" | "startup" | null,
+ isAnnualCurrent: false,
+ currentPriceAmount: null,
};
}
@@ -48,25 +65,86 @@ export const stripeRouter = createTRPCRouter({
expand: ["data.items.data.price"],
});
+ // Detectar plan actual para mostrar upgrade a usuarios legacy
+ type CurrentPlan = "legacy" | "hobby" | "startup";
+ let currentPlan: CurrentPlan = "legacy";
+ let isAnnualCurrent = false;
+ let currentPriceAmount: number | null = null;
+ const activeSub = subscriptions.data[0];
+ if (activeSub) {
+ const priceIds = activeSub.items.data.map(
+ (item) => (item.price as Stripe.Price).id,
+ );
+ if (
+ priceIds.some(
+ (id) =>
+ id === STARTUP_BASE_PRICE_MONTHLY_ID ||
+ id === STARTUP_BASE_PRICE_ANNUAL_ID,
+ )
+ ) {
+ currentPlan = "startup";
+ } else if (
+ priceIds.some(
+ (id) =>
+ id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
+ )
+ ) {
+ currentPlan = "hobby";
+ } else if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
+ currentPlan = "legacy";
+ }
+ const firstPrice = activeSub.items.data[0]?.price as
+ | Stripe.Price
+ | undefined;
+ isAnnualCurrent = firstPrice?.recurring?.interval === "year";
+ // Precio actual total del intervalo (mes o año) desde Stripe
+ const totalCents = activeSub.items.data.reduce((sum, item) => {
+ const price = item.price as Stripe.Price;
+ const amount = price.unit_amount ?? 0;
+ const qty = item.quantity ?? 1;
+ return sum + amount * qty;
+ }, 0);
+ currentPriceAmount = totalCents / 100;
+ }
+
return {
products: filteredProducts,
subscriptions: subscriptions.data,
+ hobbyProductId: HOBBY_PRODUCT_ID || undefined,
+ startupProductId: STARTUP_PRODUCT_ID || undefined,
+ currentPlan: currentPlan as "legacy" | "hobby" | "startup" | null,
+ isAnnualCurrent,
+ currentPriceAmount,
};
}),
createCheckoutSession: adminProcedure
.input(
- z.object({
- productId: z.string(),
- serverQuantity: z.number().min(1),
- isAnnual: z.boolean(),
- }),
+ z
+ .object({
+ tier: z.enum(["legacy", "hobby", "startup"]),
+ productId: z.string(),
+ serverQuantity: z.number().min(1),
+ isAnnual: z.boolean(),
+ })
+ .refine(
+ (data) =>
+ data.tier !== "startup" || data.serverQuantity >= 3,
+ {
+ message: "Startup plan requires at least 3 servers",
+ path: ["serverQuantity"],
+ },
+ ),
)
.mutation(async ({ ctx, input }) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
- const items = getStripeItems(input.serverQuantity, input.isAnnual);
+ const items = getStripeItems(
+ input.tier as BillingTier,
+ input.serverQuantity,
+ input.isAnnual,
+ );
// Always operate on the organization owner's Stripe customer
const owner = await findUserById(ctx.user.ownerId);
@@ -129,6 +207,83 @@ export const stripeRouter = createTRPCRouter({
}
}),
+ /** Cambiar de plan o cantidad de servidores (legacy→Hobby/Startup, o Hobby/Startup→cambiar tier/cantidad). Portal deshabilitado para esto; se hace desde la app. */
+ upgradeSubscription: adminProcedure
+ .input(
+ z
+ .object({
+ tier: z.enum(["hobby", "startup"]),
+ serverQuantity: z.number().min(1),
+ isAnnual: z.boolean(),
+ })
+ .refine(
+ (data) =>
+ data.tier !== "startup" || data.serverQuantity >= 3,
+ {
+ message: "Startup plan requires at least 3 servers",
+ path: ["serverQuantity"],
+ },
+ ),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
+ apiVersion: "2024-09-30.acacia",
+ });
+ const owner = await findUserById(ctx.user.ownerId);
+
+ if (!owner.stripeSubscriptionId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "No active subscription found",
+ });
+ }
+
+ const subscription = await stripe.subscriptions.retrieve(
+ owner.stripeSubscriptionId,
+ { expand: ["items.data.price"] },
+ );
+
+ if (subscription.status !== "active") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Subscription is not active",
+ });
+ }
+
+ const newItems = getStripeItems(
+ input.tier as BillingTier,
+ input.serverQuantity,
+ input.isAnnual,
+ );
+ const currentItems = subscription.items.data;
+
+ const updateItems: Stripe.SubscriptionUpdateParams["items"] =
+ currentItems.map((item, i) => {
+ if (i < newItems.length) {
+ return {
+ id: item.id,
+ price: newItems[i]!.price,
+ quantity: newItems[i]!.quantity,
+ };
+ }
+ return { id: item.id, deleted: true };
+ });
+
+ for (let i = currentItems.length; i < newItems.length; i++) {
+ updateItems.push({
+ price: newItems[i]!.price,
+ quantity: newItems[i]!.quantity,
+ });
+ }
+
+ await stripe.subscriptions.update(owner.stripeSubscriptionId, {
+ items: updateItems,
+ proration_behavior: "create_prorations",
+ });
+
+ return { ok: true };
+ }),
+
canCreateMoreServers: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const servers = await findServersByUserId(user.id);
diff --git a/apps/dokploy/server/utils/stripe.ts b/apps/dokploy/server/utils/stripe.ts
index 8d1aebb29..3a05dd640 100644
--- a/apps/dokploy/server/utils/stripe.ts
+++ b/apps/dokploy/server/utils/stripe.ts
@@ -3,28 +3,72 @@ export const WEBSITE_URL =
? "http://localhost:3000"
: process.env.SITE_URL;
-export const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID!; // $4.00
-
-export const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID!; // $7.99
-
+// Legacy: precios/productos actuales. No borrar; usuarios existentes siguen con estos.
+export const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID!;
+export const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID!;
export const PRODUCT_MONTHLY_ID = process.env.PRODUCT_MONTHLY_ID!;
export const PRODUCT_ANNUAL_ID = process.env.PRODUCT_ANNUAL_ID!;
-export const getStripeItems = (serverQuantity: number, isAnnual: boolean) => {
- const items = [];
+/** Price IDs legacy: suscripciones con estos IDs no se migran y siguen con los mismos beneficios. */
+export const LEGACY_PRICE_IDS = [
+ process.env.BASE_PRICE_MONTHLY_ID,
+ process.env.BASE_ANNUAL_MONTHLY_ID,
+].filter(Boolean) as string[];
- if (isAnnual) {
+// Nuevos planes (opcionales hasta que crees los productos en Stripe)
+export const HOBBY_PRODUCT_ID = process.env.HOBBY_PRODUCT_ID ?? "";
+export const HOBBY_PRICE_MONTHLY_ID = process.env.HOBBY_PRICE_MONTHLY_ID ?? "";
+export const HOBBY_PRICE_ANNUAL_ID = process.env.HOBBY_PRICE_ANNUAL_ID ?? "";
+
+export const STARTUP_PRODUCT_ID = process.env.STARTUP_PRODUCT_ID ?? "";
+export const STARTUP_BASE_PRICE_MONTHLY_ID =
+ process.env.STARTUP_BASE_PRICE_MONTHLY_ID ?? "";
+export const STARTUP_BASE_PRICE_ANNUAL_ID =
+ process.env.STARTUP_BASE_PRICE_ANNUAL_ID ?? "";
+
+export type BillingTier = "legacy" | "hobby" | "startup";
+
+/** Line items para Stripe Checkout según tier y cantidad de servidores. */
+export const getStripeItems = (
+ tier: BillingTier,
+ serverQuantity: number,
+ isAnnual: boolean,
+) => {
+ const items: { price: string; quantity: number }[] = [];
+
+ if (tier === "legacy") {
items.push({
- price: BASE_ANNUAL_MONTHLY_ID,
+ price: isAnnual ? BASE_ANNUAL_MONTHLY_ID : BASE_PRICE_MONTHLY_ID,
quantity: serverQuantity,
});
-
return items;
}
+ if (tier === "hobby") {
+ const price = isAnnual
+ ? HOBBY_PRICE_ANNUAL_ID || BASE_ANNUAL_MONTHLY_ID
+ : HOBBY_PRICE_MONTHLY_ID || BASE_PRICE_MONTHLY_ID;
+ items.push({ price, quantity: serverQuantity });
+ return items;
+ }
+
+ // Startup: base incluye 3 servidores; del 4º en adelante = precio Hobby ($4.50 c/u)
+ if (tier === "startup") {
+ const basePrice = isAnnual
+ ? STARTUP_BASE_PRICE_ANNUAL_ID
+ : STARTUP_BASE_PRICE_MONTHLY_ID;
+ const extraServerPrice = isAnnual
+ ? HOBBY_PRICE_ANNUAL_ID || BASE_ANNUAL_MONTHLY_ID
+ : HOBBY_PRICE_MONTHLY_ID || BASE_PRICE_MONTHLY_ID;
+ if (basePrice) items.push({ price: basePrice, quantity: 1 });
+ const extraQty = Math.max(0, serverQuantity - 3);
+ if (extraQty > 0) items.push({ price: extraServerPrice, quantity: extraQty });
+ return items;
+ }
+
+ // Fallback legacy
items.push({
- price: BASE_PRICE_MONTHLY_ID,
+ price: isAnnual ? BASE_ANNUAL_MONTHLY_ID : BASE_PRICE_MONTHLY_ID,
quantity: serverQuantity,
});
-
return items;
};