mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
fix(billing): hide checkout CTAs and guard createCheckoutSession during trial/active plan
This commit is contained in:
parent
1572008cdf
commit
6526f4a3b9
117
apps/dokploy/__test__/billing/billing-status.test.ts
Normal file
117
apps/dokploy/__test__/billing/billing-status.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
38
apps/dokploy/__test__/billing/can-create-checkout.test.ts
Normal file
38
apps/dokploy/__test__/billing/can-create-checkout.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
193
apps/dokploy/__test__/billing/create-checkout-session.test.ts
Normal file
193
apps/dokploy/__test__/billing/create-checkout-session.test.ts
Normal 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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user