diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index 1460244c1..740c5179c 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -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 (
- +
@@ -128,17 +173,6 @@ export const ShowBilling = () => {
- setIsAnnual(e === "annual")} - > - - Monthly - Annual - - {admin?.user.stripeSubscriptionId && (

Servers Plan

@@ -160,6 +194,429 @@ 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 + +
+ + +
+ + New plan +
+ + +
+ + {upgradeTier && ( +
+ + Servers + {upgradeTier === "startup" && + ` (min. ${STARTUP_SERVERS_INCLUDED})`} + +
+ + { + const v = + Number((e.target as HTMLInputElement).value) || + 0; + setUpgradeServerQty( + Math.max( + upgradeTier === "startup" + ? STARTUP_SERVERS_INCLUDED + : 1, + v, + ), + ); + }} + className="w-20 h-8" + /> + +
+

+ {upgradeTier === "hobby" + ? `$${calculatePriceHobby(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}` + : `$${calculatePriceStartup(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`} +

+ +

+ Current plan: Legacy +

+

+ New plan:{" "} + {upgradeTier === "startup" + ? "Startup" + : "Hobby"}{" "} + · {upgradeServerQty} server + {upgradeServerQty !== 1 ? "s" : ""} · $ + {upgradeTier === "hobby" + ? calculatePriceHobby( + upgradeServerQty, + updateFormAnnual, + ).toFixed(2) + : calculatePriceStartup( + upgradeServerQty, + updateFormAnnual, + ).toFixed(2)} + /{updateFormAnnual ? "yr" : "mo"} ( + {updateFormAnnual ? "annual" : "monthly"}) +

+

+ Stripe will prorate the change. +

+
+ } + type="default" + onClick={async () => { + if (!upgradeTier) return; + 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"); + } + }} + > + + +
+ )} +
+ )} + {/* 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 + +
+ + +
+ + Plan +
+ + +
+ + {upgradeTier && ( +
+ + Servers + {upgradeTier === "startup" && + ` (min. ${STARTUP_SERVERS_INCLUDED})`} + +
+ + { + const v = + Number((e.target as HTMLInputElement).value) || + 0; + setUpgradeServerQty( + Math.max( + upgradeTier === "startup" + ? STARTUP_SERVERS_INCLUDED + : 1, + v, + ), + ); + }} + className="w-20 h-8" + /> + +
+

+ {upgradeTier === "hobby" + ? `$${calculatePriceHobby(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}` + : `$${calculatePriceStartup(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`} +

+ +

+ Current plan:{" "} + {data?.currentPlan === "startup" + ? "Startup" + : "Hobby"}{" "} + · {admin?.user.serversQuantity ?? 0} server + {(admin?.user.serversQuantity ?? 0) !== 1 + ? "s" + : ""}{" "} + ·{" "} + {data?.currentPriceAmount != null + ? `$${data.currentPriceAmount.toFixed(2)}/${data?.isAnnualCurrent ? "yr" : "mo"}` + : ""}{" "} + ({data?.isAnnualCurrent ? "annual" : "monthly"}) +

+

+ New plan:{" "} + {upgradeTier === "startup" + ? "Startup" + : "Hobby"}{" "} + · {upgradeServerQty} server + {upgradeServerQty !== 1 ? "s" : ""} · $ + {upgradeTier === "hobby" + ? calculatePriceHobby( + upgradeServerQty, + updateFormAnnual, + ).toFixed(2) + : calculatePriceStartup( + upgradeServerQty, + updateFormAnnual, + ).toFixed(2)} + /{updateFormAnnual ? "yr" : "mo"} ( + {updateFormAnnual ? "annual" : "monthly"}) +

+

+ Stripe will prorate the change. +

+
+ } + type="default" + onClick={async () => { + if (!upgradeTier) return; + 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"); + } + }} + > + + +
+ )} +
+ )}
Need Help? We are here to help you. @@ -191,8 +648,345 @@ 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"} +

+

+ Add more servers as you'd like for{" "} + {isAnnual ? "$43.20/yr" : "$4.50/mo"} +

+ {isAnnual && ( +

+ $ + {( + calculatePriceHobby(serverQuantity, true) / 12 + ).toFixed(2)} + /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( + Math.max( + 1, + Number( + (e.target as HTMLInputElement).value, + ) || 1, + ), + ) + } + className="text-center" + /> + +
+
+ {admin?.user.stripeCustomerId && ( + + )} + {(data?.subscriptions?.length ?? 0) === 0 && ( + + )} +
+
+
+ + {/* 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 ? "$43.20/yr" : "$4.50/mo"} +

+ {isAnnual && ( +

+ $ + {( + calculatePriceStartup(serverQuantity, true) / 12 + ).toFixed(2)} + /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( + Math.max( + STARTUP_SERVERS_INCLUDED, + Number( + (e.target as HTMLInputElement).value, + ) || STARTUP_SERVERS_INCLUDED, + ), + ) + } + className="h-8 text-center" + /> + +
+
+
+ {admin?.user.stripeCustomerId && ( + + )} + {(data?.subscriptions?.length ?? 0) === 0 && ( + + )} +
+
+
+ + {/* Enterprise */} +
+

+ Enterprise +

+

+ For large organizations who want more control +

+
+

+ Contact Sales +

+
+
    +
  • + + 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} +
  • + ))} +
+ +
+
+ ) : ( <> + setIsAnnual(e === "annual")} + > + + Monthly + Annual (20% off) + + {products?.map((product) => { const featured = true; return ( @@ -311,15 +1105,7 @@ export const ShowBilling = () => {
-
0 - ? "justify-between" - : "justify-end", - "flex flex-row items-center gap-2 mt-4", - )} - > +
{admin?.user.stripeCustomerId && ( )} - - {data?.subscriptions?.length === 0 && ( -
- -
+ {(data?.subscriptions?.length ?? 0) === 0 && ( + )}
diff --git a/apps/dokploy/pages/api/stripe/webhook.ts b/apps/dokploy/pages/api/stripe/webhook.ts index b44b63d45..f950eb344 100644 --- a/apps/dokploy/pages/api/stripe/webhook.ts +++ b/apps/dokploy/pages/api/stripe/webhook.ts @@ -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( diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts index 963ec8c5b..c910b1d58 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, + 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); diff --git a/apps/dokploy/server/utils/stripe.ts b/apps/dokploy/server/utils/stripe.ts index 8d1aebb29..078885a98 100644 --- a/apps/dokploy/server/utils/stripe.ts +++ b/apps/dokploy/server/utils/stripe.ts @@ -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; };