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;