mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-05 14:05:30 +02:00
- Simplified code formatting for better readability in various components. - Updated job scheduling functions to handle errors gracefully, ensuring that failures in scheduling do not disrupt the overall process. - Enhanced logging for better traceability of job scheduling issues. These changes improve code maintainability and user experience by providing clearer error messages and more organized code structure.
391 lines
10 KiB
TypeScript
391 lines
10 KiB
TypeScript
import {
|
|
findServersByUserId,
|
|
findUserById,
|
|
IS_CLOUD,
|
|
updateUser,
|
|
} from "@dokploy/server";
|
|
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,
|
|
protectedProcedure,
|
|
withPermission,
|
|
} from "../trpc";
|
|
|
|
export const stripeRouter = createTRPCRouter({
|
|
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
|
|
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
|
|
if (!IS_CLOUD) return null;
|
|
const owner = await findUserById(ctx.user.ownerId);
|
|
if (!owner?.stripeCustomerId) return null;
|
|
|
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
apiVersion: "2024-09-30.acacia",
|
|
});
|
|
const subscriptions = await stripe.subscriptions.list({
|
|
customer: owner.stripeCustomerId,
|
|
status: "active",
|
|
expand: ["data.items.data.price"],
|
|
});
|
|
const activeSub = subscriptions.data[0];
|
|
if (!activeSub) return null;
|
|
|
|
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,
|
|
)
|
|
) {
|
|
return "startup" as const;
|
|
}
|
|
if (
|
|
priceIds.some(
|
|
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
|
|
)
|
|
) {
|
|
return "hobby" as const;
|
|
}
|
|
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
|
|
return "legacy" as const;
|
|
}
|
|
return null;
|
|
}),
|
|
|
|
getProducts: adminProcedure.query(async ({ ctx }) => {
|
|
const user = await findUserById(ctx.user.ownerId);
|
|
const stripeCustomerId = user.stripeCustomerId;
|
|
|
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
apiVersion: "2024-09-30.acacia",
|
|
});
|
|
|
|
const products = await stripe.products.list({
|
|
expand: ["data.default_price"],
|
|
active: true,
|
|
});
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
const subscriptions = await stripe.subscriptions.list({
|
|
customer: stripeCustomerId,
|
|
status: "active",
|
|
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({
|
|
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.tier as BillingTier,
|
|
input.serverQuantity,
|
|
input.isAnnual,
|
|
);
|
|
// Always operate on the organization owner's Stripe customer
|
|
const owner = await findUserById(ctx.user.ownerId);
|
|
|
|
let stripeCustomerId = owner.stripeCustomerId;
|
|
|
|
if (stripeCustomerId) {
|
|
const customer = await stripe.customers.retrieve(stripeCustomerId);
|
|
|
|
if (customer.deleted) {
|
|
await updateUser(owner.id, {
|
|
stripeCustomerId: null,
|
|
});
|
|
stripeCustomerId = null;
|
|
}
|
|
}
|
|
|
|
const session = await stripe.checkout.sessions.create({
|
|
mode: "subscription",
|
|
line_items: items,
|
|
...(stripeCustomerId
|
|
? {
|
|
customer: stripeCustomerId,
|
|
customer_update: { name: "auto", address: "auto" },
|
|
}
|
|
: { customer_email: owner.email }),
|
|
metadata: {
|
|
adminId: owner.id,
|
|
},
|
|
billing_address_collection: "required",
|
|
tax_id_collection: { enabled: true },
|
|
allow_promotion_codes: true,
|
|
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
|
|
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,
|
|
});
|
|
|
|
return { sessionId: session.id };
|
|
}),
|
|
createCustomerPortalSession: adminProcedure.mutation(async ({ ctx }) => {
|
|
// Use the organization's owner account for billing portal
|
|
const owner = await findUserById(ctx.user.ownerId);
|
|
|
|
if (!owner.stripeCustomerId) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Stripe Customer ID not found",
|
|
});
|
|
}
|
|
const stripeCustomerId = owner.stripeCustomerId;
|
|
|
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
apiVersion: "2024-09-30.acacia",
|
|
});
|
|
|
|
try {
|
|
const session = await stripe.billingPortal.sessions.create({
|
|
customer: stripeCustomerId,
|
|
return_url: `${WEBSITE_URL}/dashboard/settings/billing`,
|
|
});
|
|
|
|
return { url: session.url };
|
|
} catch (_) {
|
|
return {
|
|
url: "",
|
|
};
|
|
}
|
|
}),
|
|
|
|
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: withPermission("server", "create").query(
|
|
async ({ ctx }) => {
|
|
const user = await findUserById(ctx.user.ownerId);
|
|
const servers = await findServersByUserId(user.id);
|
|
|
|
if (!IS_CLOUD) {
|
|
return true;
|
|
}
|
|
|
|
return servers.length < user.serversQuantity;
|
|
},
|
|
),
|
|
|
|
updateInvoiceNotifications: adminProcedure
|
|
.input(z.object({ enabled: z.boolean() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
if (!IS_CLOUD) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "This feature is only available in Dokploy Cloud",
|
|
});
|
|
}
|
|
const owner = await findUserById(ctx.user.ownerId);
|
|
await updateUser(owner.id, {
|
|
sendInvoiceNotifications: input.enabled,
|
|
});
|
|
return { ok: true };
|
|
}),
|
|
|
|
getInvoices: adminProcedure.query(async ({ ctx }) => {
|
|
const user = await findUserById(ctx.user.ownerId);
|
|
const stripeCustomerId = user.stripeCustomerId;
|
|
|
|
if (!stripeCustomerId) {
|
|
return [];
|
|
}
|
|
|
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
apiVersion: "2024-09-30.acacia",
|
|
});
|
|
|
|
try {
|
|
const invoices = await stripe.invoices.list({
|
|
customer: stripeCustomerId,
|
|
limit: 100,
|
|
});
|
|
|
|
return invoices.data.map((invoice) => ({
|
|
id: invoice.id,
|
|
number: invoice.number,
|
|
status: invoice.status,
|
|
amountDue: invoice.amount_due,
|
|
amountPaid: invoice.amount_paid,
|
|
currency: invoice.currency,
|
|
created: invoice.created,
|
|
dueDate: invoice.due_date,
|
|
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
|
invoicePdf: invoice.invoice_pdf,
|
|
}));
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
}),
|
|
});
|