diff --git a/apps/dokploy/__test__/billing/billing-status.test.ts b/apps/dokploy/__test__/billing/billing-status.test.ts
new file mode 100644
index 000000000..8340187e6
--- /dev/null
+++ b/apps/dokploy/__test__/billing/billing-status.test.ts
@@ -0,0 +1,117 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ subscriptionsList: vi.fn(),
+ findUserById: vi.fn(),
+ isCloud: true,
+}));
+
+vi.mock("@dokploy/server", () => ({
+ get IS_CLOUD() {
+ return mocks.isCloud;
+ },
+ findUserById: mocks.findUserById,
+}));
+
+vi.mock("@dokploy/server/services/proprietary/sso", () => ({
+ getOrganizationOwnerId: vi.fn(),
+}));
+
+vi.mock("@/server/utils/stripe", () => ({
+ HOBBY_PRICE_MONTHLY_ID: "price_hobby_monthly",
+ HOBBY_PRICE_ANNUAL_ID: "price_hobby_annual",
+ STARTUP_BASE_PRICE_MONTHLY_ID: "price_startup_monthly",
+ STARTUP_BASE_PRICE_ANNUAL_ID: "price_startup_annual",
+ LEGACY_PRICE_IDS: ["price_legacy_monthly", "price_legacy_annual"],
+}));
+
+vi.mock("stripe", () => ({
+ default: class MockStripe {
+ subscriptions = { list: mocks.subscriptionsList };
+ },
+}));
+
+const { getBillingStatus, TRIAL_DURATION_DAYS } = await import(
+ "@/server/utils/billing"
+);
+
+const HOBBY_MONTHLY = "price_hobby_monthly";
+
+const nowSeconds = () => Math.floor(Date.now() / 1000);
+
+const makeSub = (opts: {
+ status: "active" | "trialing";
+ priceId: string;
+ trialEnd?: number;
+}) => ({
+ id: `sub_${opts.status}_${opts.priceId}`,
+ status: opts.status,
+ trial_end: opts.trialEnd ?? null,
+ items: { data: [{ price: { id: opts.priceId }, quantity: 1 }] },
+});
+
+const ownerWith = (stripeCustomerId: string | null) => ({
+ id: "owner-1",
+ email: "owner@example.com",
+ stripeCustomerId,
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.isCloud = true;
+ mocks.findUserById.mockResolvedValue(ownerWith("cus_1"));
+});
+
+describe("getBillingStatus — non-cloud", () => {
+ it("reports active access for self-hosted (IS_CLOUD=false) without calling Stripe", async () => {
+ mocks.isCloud = false;
+ const status = await getBillingStatus("owner-1");
+ expect(status.hasActiveAccess).toBe(true);
+ expect(status.plan).toBeNull();
+ expect(status.isOnTrial).toBe(false);
+ expect(mocks.findUserById).not.toHaveBeenCalled();
+ expect(mocks.subscriptionsList).not.toHaveBeenCalled();
+ });
+});
+
+describe("getBillingStatus — cloud, no subscription", () => {
+ it("reports no access when the customer has zero subscriptions", async () => {
+ mocks.subscriptionsList.mockResolvedValue({ data: [] });
+ const status = await getBillingStatus("owner-1");
+ expect(status.hasActiveAccess).toBe(false);
+ expect(status.plan).toBeNull();
+ expect(status.isOnTrial).toBe(false);
+ expect(status.hasUsedTrial).toBe(false);
+ expect(mocks.subscriptionsList).toHaveBeenCalledWith(
+ expect.objectContaining({ customer: "cus_1", status: "all" }),
+ );
+ });
+});
+
+describe("getBillingStatus — cloud, trialing subscription (the contract the checkout gate relies on)", () => {
+ it("reports isOnTrial=true, plan=hobby, and hasActiveAccess=true for a trialing hobby sub — even though getProducts (status:active) would hide it", async () => {
+ const trialEnd = nowSeconds() + 14 * 24 * 60 * 60;
+ mocks.subscriptionsList.mockResolvedValue({
+ data: [makeSub({ status: "trialing", priceId: HOBBY_MONTHLY, trialEnd })],
+ });
+ const status = await getBillingStatus("owner-1");
+ expect(status.isOnTrial).toBe(true);
+ expect(status.plan).toBe("hobby");
+ expect(status.hasActiveAccess).toBe(true);
+ expect(status.hasUsedTrial).toBe(true);
+ expect(status.trialEndsAt).toEqual(new Date(trialEnd * 1000));
+ expect(status.trialDaysRemaining).toBe(TRIAL_DURATION_DAYS);
+ });
+});
+
+describe("getBillingStatus — cloud, active subscription", () => {
+ it("reports plan=hobby and hasActiveAccess=true for an active hobby sub", async () => {
+ mocks.subscriptionsList.mockResolvedValue({
+ data: [makeSub({ status: "active", priceId: HOBBY_MONTHLY })],
+ });
+ const status = await getBillingStatus("owner-1");
+ expect(status.plan).toBe("hobby");
+ expect(status.isOnTrial).toBe(false);
+ expect(status.hasActiveAccess).toBe(true);
+ });
+});
diff --git a/apps/dokploy/__test__/billing/can-create-checkout.test.ts b/apps/dokploy/__test__/billing/can-create-checkout.test.ts
new file mode 100644
index 000000000..e8dc4c8a9
--- /dev/null
+++ b/apps/dokploy/__test__/billing/can-create-checkout.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { canCreateCheckout } from "@/components/dashboard/settings/billing/billing-gates";
+
+describe("canCreateCheckout", () => {
+ it("is true for a non-enterprise user with no plan, no trial, and no subscriptions (happy path)", () => {
+ expect(canCreateCheckout(false, { isOnTrial: false, plan: null }, 0)).toBe(
+ true,
+ );
+ });
+
+ it("is true while billingStatus is still loading (undefined) and there are no subscriptions", () => {
+ expect(canCreateCheckout(false, undefined, 0)).toBe(true);
+ });
+
+ it("is false for an enterprise cloud user even with no plan/trial/subscriptions", () => {
+ expect(canCreateCheckout(true, { isOnTrial: false, plan: null }, 0)).toBe(
+ false,
+ );
+ });
+
+ it("is false when the user is on a free trial (getProducts hides trialing subs, so billingStatus.isOnTrial is the gate that closes the gap)", () => {
+ expect(
+ canCreateCheckout(false, { isOnTrial: true, plan: "hobby" }, 0),
+ ).toBe(false);
+ });
+
+ it("is false when the user has an active (non-trial) plan", () => {
+ expect(
+ canCreateCheckout(false, { isOnTrial: false, plan: "startup" }, 0),
+ ).toBe(false);
+ });
+
+ it("is false when there is an active subscription reported by getProducts", () => {
+ expect(canCreateCheckout(false, { isOnTrial: false, plan: null }, 1)).toBe(
+ false,
+ );
+ });
+});
diff --git a/apps/dokploy/__test__/billing/create-checkout-session.test.ts b/apps/dokploy/__test__/billing/create-checkout-session.test.ts
new file mode 100644
index 000000000..365f38ae6
--- /dev/null
+++ b/apps/dokploy/__test__/billing/create-checkout-session.test.ts
@@ -0,0 +1,193 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ isCloud: true,
+ findUserById: vi.fn(),
+ updateUser: vi.fn(),
+ findServersByUserId: vi.fn(),
+ getBillingStatus: vi.fn(),
+ getCurrentPlan: vi.fn(),
+ getStripeClient: vi.fn(),
+ getStripeItems: vi.fn(),
+ checkoutCreate: vi.fn(),
+ customersRetrieve: vi.fn(),
+}));
+
+vi.mock("@dokploy/server", () => ({
+ get IS_CLOUD() {
+ return mocks.isCloud;
+ },
+ findUserById: mocks.findUserById,
+ updateUser: mocks.updateUser,
+ findServersByUserId: mocks.findServersByUserId,
+}));
+
+vi.mock("@/server/api/trpc", async () => {
+ const { initTRPC, TRPCError } = await import("@trpc/server");
+ const t = initTRPC.create();
+ return {
+ adminProcedure: t.procedure.use(({ ctx, next }) => {
+ // The mocked tRPC has no context type, so cast to the shape we pass in tests.
+ const c = ctx as {
+ session?: unknown;
+ user?: { role?: string };
+ };
+ if (
+ !c.session ||
+ !c.user ||
+ (c.user.role !== "owner" && c.user.role !== "admin")
+ ) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+ return next({ ctx: { session: c.session, user: c.user } });
+ }),
+ createTRPCRouter: t.router,
+ protectedProcedure: t.procedure,
+ withPermission: () => t.procedure.use(({ next }) => next()),
+ };
+});
+
+vi.mock("@/server/utils/billing", () => ({
+ getBillingStatus: mocks.getBillingStatus,
+ getCurrentPlan: mocks.getCurrentPlan,
+ getStripeClient: mocks.getStripeClient,
+ TRIAL_DURATION_DAYS: 14,
+ TRIAL_SERVER_LIMIT: 1,
+}));
+
+vi.mock("@/server/utils/stripe", () => ({
+ getStripeItems: mocks.getStripeItems,
+ HOBBY_PRICE_ANNUAL_ID: "price_hobby_annual",
+ HOBBY_PRICE_MONTHLY_ID: "price_hobby_monthly",
+ HOBBY_PRODUCT_ID: "prod_hobby",
+ LEGACY_PRICE_IDS: ["price_legacy_monthly", "price_legacy_annual"],
+ PRODUCT_ANNUAL_ID: "prod_annual",
+ PRODUCT_MONTHLY_ID: "prod_monthly",
+ STARTUP_BASE_PRICE_ANNUAL_ID: "price_startup_annual",
+ STARTUP_BASE_PRICE_MONTHLY_ID: "price_startup_monthly",
+ STARTUP_PRODUCT_ID: "prod_startup",
+ WEBSITE_URL: "https://app.example.com",
+}));
+
+vi.mock("stripe", () => ({
+ default: class MockStripe {
+ checkout = { sessions: { create: mocks.checkoutCreate } };
+ customers = { retrieve: mocks.customersRetrieve };
+ },
+}));
+
+const { stripeRouter } = await import("@/server/api/routers/stripe");
+
+const ownerCtx = {
+ session: { activeOrganizationId: "org-1" },
+ user: { id: "user-1", role: "owner", ownerId: "owner-1" },
+};
+
+const input = (tier: "legacy" | "hobby" | "startup") => ({
+ tier,
+ productId: "prod_x",
+ serverQuantity: tier === "startup" ? 3 : 1,
+ isAnnual: false,
+});
+
+// The real router infers a full Next context type (db/req/res/...); the mocked
+// procedures only read session+user. Cast so tests can pass a minimal context.
+const makeCaller = (ctx: unknown) => stripeRouter.createCaller(ctx as never);
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.isCloud = true;
+ mocks.findUserById.mockResolvedValue({
+ id: "owner-1",
+ email: "owner@example.com",
+ stripeCustomerId: "cus_1",
+ });
+ mocks.getStripeItems.mockReturnValue([{ price: "price_x", quantity: 1 }]);
+ mocks.customersRetrieve.mockResolvedValue({ deleted: false });
+ mocks.checkoutCreate.mockResolvedValue({ id: "cs_test_123" });
+});
+
+describe("createCheckoutSession — duplicate-subscription guard", () => {
+ it("rejects a trialing user with 'You already have an active plan or trial' and never calls Stripe", async () => {
+ mocks.getBillingStatus.mockResolvedValue({
+ hasActiveAccess: true,
+ isOnTrial: true,
+ plan: "hobby",
+ });
+ const caller = makeCaller(ownerCtx);
+ await expect(caller.createCheckoutSession(input("hobby"))).rejects.toThrow(
+ "You already have an active plan or trial",
+ );
+ expect(mocks.findUserById).toHaveBeenCalledWith("owner-1");
+ expect(mocks.getBillingStatus).toHaveBeenCalledWith("owner-1");
+ expect(mocks.checkoutCreate).not.toHaveBeenCalled();
+ });
+
+ it("rejects a user with an active (non-trial) plan and never calls Stripe", async () => {
+ mocks.getBillingStatus.mockResolvedValue({
+ hasActiveAccess: true,
+ isOnTrial: false,
+ plan: "startup",
+ });
+ const caller = makeCaller(ownerCtx);
+ await expect(
+ caller.createCheckoutSession(input("startup")),
+ ).rejects.toThrow("You already have an active plan or trial");
+ expect(mocks.checkoutCreate).not.toHaveBeenCalled();
+ });
+
+ it("rejects non-cloud callers before any user/billing/Stripe call", async () => {
+ mocks.isCloud = false;
+ const caller = makeCaller(ownerCtx);
+ await expect(caller.createCheckoutSession(input("hobby"))).rejects.toThrow(
+ "only available in Dokploy Cloud",
+ );
+ expect(mocks.findUserById).not.toHaveBeenCalled();
+ expect(mocks.getBillingStatus).not.toHaveBeenCalled();
+ expect(mocks.checkoutCreate).not.toHaveBeenCalled();
+ });
+});
+
+describe("createCheckoutSession — happy path (no existing plan/trial)", () => {
+ it("creates a Stripe checkout session for an eligible user and returns the session id", async () => {
+ mocks.getBillingStatus.mockResolvedValue({
+ hasActiveAccess: false,
+ isOnTrial: false,
+ plan: null,
+ });
+ const caller = makeCaller(ownerCtx);
+ const result = await caller.createCheckoutSession(input("hobby"));
+ expect(result).toEqual({ sessionId: "cs_test_123" });
+ expect(mocks.getStripeItems).toHaveBeenCalledWith("hobby", 1, false);
+ expect(mocks.customersRetrieve).toHaveBeenCalledWith("cus_1");
+ expect(mocks.checkoutCreate).toHaveBeenCalledTimes(1);
+ expect(mocks.checkoutCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ mode: "subscription",
+ line_items: [{ price: "price_x", quantity: 1 }],
+ customer: "cus_1",
+ metadata: { adminId: "owner-1" },
+ }),
+ );
+ });
+
+ it("creates a checkout session when the customer has no stripe id (email-based checkout)", async () => {
+ mocks.findUserById.mockResolvedValue({
+ id: "owner-1",
+ email: "owner@example.com",
+ stripeCustomerId: null,
+ });
+ mocks.getBillingStatus.mockResolvedValue({
+ hasActiveAccess: false,
+ isOnTrial: false,
+ plan: null,
+ });
+ const caller = makeCaller(ownerCtx);
+ const result = await caller.createCheckoutSession(input("hobby"));
+ expect(result).toEqual({ sessionId: "cs_test_123" });
+ expect(mocks.customersRetrieve).not.toHaveBeenCalled();
+ expect(mocks.checkoutCreate).toHaveBeenCalledWith(
+ expect.objectContaining({ customer_email: "owner@example.com" }),
+ );
+ });
+});
diff --git a/apps/dokploy/components/dashboard/settings/billing/billing-gates.ts b/apps/dokploy/components/dashboard/settings/billing/billing-gates.ts
new file mode 100644
index 000000000..3ffd23a90
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/billing/billing-gates.ts
@@ -0,0 +1,29 @@
+/**
+ * Pure predicates that gate the billing-pricing checkout CTAs.
+ *
+ * Kept in a standalone, import-light module (no React / Next / Stripe client
+ * deps) so they can be unit-tested directly — mirroring the convention used by
+ * `server/utils/billing.ts` and `server/utils/stripe.ts`.
+ */
+
+/**
+ * Decides whether a pricing-card checkout CTA (Hobby "Get Started", Startup
+ * "Get Started", Legacy "Subscribe") may be rendered.
+ *
+ * `getProducts` lists subscriptions with `status: "active"` only, so a
+ * `trialing` subscription is invisible to `data.subscriptions` — relying solely
+ * on `subscriptionsLength === 0` would leave the CTA enabled during a free
+ * trial, letting a trialing user start a second, paid Stripe subscription.
+ * The `billingStatus` fields (`isOnTrial`, `plan`) close that gap. Plan/active
+ * and trial state come from `getBillingStatus`, which lists `status: "all"`
+ * and considers both `active` and `trialing` subscriptions.
+ */
+export const canCreateCheckout = (
+ isEnterpriseCloud: boolean,
+ billingStatus: { isOnTrial: boolean | null; plan: string | null } | undefined,
+ subscriptionsLength: number,
+): boolean =>
+ !isEnterpriseCloud &&
+ !billingStatus?.isOnTrial &&
+ !billingStatus?.plan &&
+ subscriptionsLength === 0;
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
index 52e68a547..091fd71d5 100644
--- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
+++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
@@ -16,6 +16,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { toast } from "sonner";
+import { canCreateCheckout } from "@/components/dashboard/settings/billing/billing-gates";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -983,18 +984,21 @@ export const ShowBilling = () => {
Manage Subscription
)}
- {!isEnterpriseCloud &&
- (data?.subscriptions?.length ?? 0) === 0 && (
-
- )}
+ {canCreateCheckout(
+ isEnterpriseCloud,
+ billingStatus,
+ data?.subscriptions?.length ?? 0,
+ ) && (
+
+ )}
@@ -1128,24 +1132,26 @@ export const ShowBilling = () => {
Manage Subscription
)}
- {!isEnterpriseCloud &&
- (data?.subscriptions?.length ?? 0) === 0 && (
-
- )}
+ {canCreateCheckout(
+ isEnterpriseCloud,
+ billingStatus,
+ data?.subscriptions?.length ?? 0,
+ ) && (
+
+ )}
@@ -1350,18 +1356,21 @@ export const ShowBilling = () => {
Manage Subscription
)}
- {!isEnterpriseCloud &&
- (data?.subscriptions?.length ?? 0) === 0 && (
-
- )}
+ {canCreateCheckout(
+ isEnterpriseCloud,
+ billingStatus,
+ data?.subscriptions?.length ?? 0,
+ ) && (
+
+ )}
diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts
index ce5e15151..d8cc70812 100644
--- a/apps/dokploy/server/api/routers/stripe.ts
+++ b/apps/dokploy/server/api/routers/stripe.ts
@@ -226,6 +226,23 @@ export const stripeRouter = createTRPCRouter({
}),
)
.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);
+
+ const billingStatus = await getBillingStatus(owner.id);
+ if (billingStatus.hasActiveAccess) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You already have an active plan or trial",
+ });
+ }
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
@@ -235,8 +252,6 @@ export const stripeRouter = createTRPCRouter({
input.serverQuantity,
input.isAnnual,
);
- // Always operate on the organization owner's Stripe customer
- const owner = await findUserById(ctx.user.ownerId);
let stripeCustomerId = owner.stripeCustomerId;