mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-04 21:45:26 +02:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user