fix(billing): hide checkout CTAs and guard createCheckoutSession during trial/active plan

This commit is contained in:
detail-app[bot] 2026-09-04 02:49:44 +00:00 committed by GitHub
parent 1572008cdf
commit 6526f4a3b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 445 additions and 44 deletions

View File

@ -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);
});
});

View File

@ -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,
);
});
});

View File

@ -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" }),
);
});
});

View File

@ -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;

View File

@ -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
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={hobbyServerQuantity < 1}
>
Get Started
</Button>
)}
{canCreateCheckout(
isEnterpriseCloud,
billingStatus,
data?.subscriptions?.length ?? 0,
) && (
<Button
className="w-full"
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={hobbyServerQuantity < 1}
>
Get Started
</Button>
)}
</div>
</div>
</section>
@ -1128,24 +1132,26 @@ export const ShowBilling = () => {
Manage Subscription
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout(
"startup",
data!.startupProductId!,
)
}
disabled={
startupServerQuantity <
STARTUP_SERVERS_INCLUDED
}
>
Get Started
</Button>
)}
{canCreateCheckout(
isEnterpriseCloud,
billingStatus,
data?.subscriptions?.length ?? 0,
) && (
<Button
className="w-full"
onClick={() =>
handleCheckout(
"startup",
data!.startupProductId!,
)
}
disabled={
startupServerQuantity < STARTUP_SERVERS_INCLUDED
}
>
Get Started
</Button>
)}
</div>
</div>
</section>
@ -1350,18 +1356,21 @@ export const ShowBilling = () => {
Manage Subscription
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={async () => {
handleCheckout("legacy", product.id);
}}
disabled={hobbyServerQuantity < 1}
>
Subscribe
</Button>
)}
{canCreateCheckout(
isEnterpriseCloud,
billingStatus,
data?.subscriptions?.length ?? 0,
) && (
<Button
className="w-full"
onClick={async () => {
handleCheckout("legacy", product.id);
}}
disabled={hobbyServerQuantity < 1}
>
Subscribe
</Button>
)}
</div>
</div>
</section>

View File

@ -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;