From b18ae74bec82340ecdf5c654ea07f3dfc6ba164b Mon Sep 17 00:00:00 2001 From: Basri Akkaya <48864655+basriakkaya@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:41:01 +0300 Subject: [PATCH 1/2] Scope subscription checkout to active organization --- app/api/subscribe/__tests__/route.test.ts | 64 +++++++++++++++++++++++ app/api/subscribe/route.ts | 12 ++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/app/api/subscribe/__tests__/route.test.ts b/app/api/subscribe/__tests__/route.test.ts index 415e7df68..9a87d444d 100644 --- a/app/api/subscribe/__tests__/route.test.ts +++ b/app/api/subscribe/__tests__/route.test.ts @@ -190,6 +190,70 @@ describe("POST /api/subscribe", () => { expect(mockCreateCheckoutSession).not.toHaveBeenCalled(); }); + it("scopes checkout creation to the active organization", async () => { + mockGetUserIDAndPro.mockResolvedValueOnce({ + userId: "user_123", + subscription: "free", + organizationId: "org_active", + freeQuotaSubject: "free_quota_subject", + } as never); + mockListOrganizationMemberships.mockResolvedValueOnce({ + data: [ + { + organizationId: "org_active", + role: { slug: "admin" }, + }, + ], + } as never); + mockGetOrganization.mockResolvedValueOnce({ + id: "org_active", + stripeCustomerId: "cus_active", + } as never); + mockRetrieveCustomer.mockResolvedValueOnce({ + id: "cus_active", + metadata: { workOSOrganizationId: "org_active" }, + } as never); + + const { POST } = await import("../route"); + const response = await POST(makeRequest({ plan: "pro-monthly-plan" })); + + expect(response.status).toBe(200); + expect(mockListOrganizationMemberships).toHaveBeenCalledWith({ + userId: "user_123", + statuses: ["active"], + organizationId: "org_active", + }); + expect(mockGetOrganization).toHaveBeenCalledWith("org_active"); + expect(mockCreateCheckoutSession).toHaveBeenCalledWith( + expect.objectContaining({ + customer: "cus_active", + metadata: expect.objectContaining({ + workOSOrganizationId: "org_active", + }), + }), + ); + }); + + it("rejects ambiguous multi-organization checkout without an active organization", async () => { + mockListOrganizationMemberships.mockResolvedValueOnce({ + data: [ + { organizationId: "org_a", role: { slug: "admin" } }, + { organizationId: "org_b", role: { slug: "admin" } }, + ], + } as never); + + const { POST } = await import("../route"); + const response = await POST(makeRequest({ plan: "pro-monthly-plan" })); + const body = await response.json(); + + expect(response.status).toBe(409); + expect(body).toEqual({ + error: "Select an active organization before subscribing", + }); + expect(mockGetOrganization).not.toHaveBeenCalled(); + expect(mockCreateCheckoutSession).not.toHaveBeenCalled(); + }); + it("returns unauthenticated requests as 401 responses", async () => { mockGetUserIDAndPro.mockRejectedValueOnce( new ChatSDKError("unauthorized:auth") as never, diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts index f7b0eb5d8..7520e597b 100644 --- a/app/api/subscribe/route.ts +++ b/app/api/subscribe/route.ts @@ -238,7 +238,7 @@ export const POST = async (req: NextRequest) => { const fromTier = paidFunnelTierFromUnknown(body?.fromTier); const posthogSessionId = req.headers.get("x-posthog-session-id"); // Get user ID and subscription state from authenticated session - const { userId, subscription, freeQuotaSubject } = + const { userId, subscription, organizationId, freeQuotaSubject } = await getUserIDAndPro(req); // Get user details from WorkOS to create a personal organization. @@ -330,12 +330,20 @@ export const POST = async (req: NextRequest) => { await workos.userManagement.listOrganizationMemberships({ userId, statuses: ["active"], + ...(organizationId && { organizationId }), }); let organization; if (existingMemberships.data && existingMemberships.data.length > 0) { - // User already has an organization, use the first one + // The authenticated active organization scopes multi-organization users. + // Without one, only a single unambiguous membership is safe to select. + if (!organizationId && existingMemberships.data.length > 1) { + return json( + { error: "Select an active organization before subscribing" }, + { status: 409 }, + ); + } const membership = existingMemberships.data[0]; if (!canManageOrganizationBilling(membership)) { return json( From a82de01665dc6a91450c26efffd2bf86d74684d8 Mon Sep 17 00:00:00 2001 From: Ross <144740362+ross0x01@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:34:45 +0000 Subject: [PATCH 2/2] Reject stale active organization checkout --- app/api/subscribe/__tests__/route.test.ts | 31 +++++++++++++++++++++++ app/api/subscribe/route.ts | 15 ++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/app/api/subscribe/__tests__/route.test.ts b/app/api/subscribe/__tests__/route.test.ts index 9a87d444d..6516ed198 100644 --- a/app/api/subscribe/__tests__/route.test.ts +++ b/app/api/subscribe/__tests__/route.test.ts @@ -249,11 +249,42 @@ describe("POST /api/subscribe", () => { expect(response.status).toBe(409); expect(body).toEqual({ error: "Select an active organization before subscribing", + code: "organization_selection_required", }); expect(mockGetOrganization).not.toHaveBeenCalled(); expect(mockCreateCheckoutSession).not.toHaveBeenCalled(); }); + it("rejects checkout when the active organization membership is stale", async () => { + mockGetUserIDAndPro.mockResolvedValueOnce({ + userId: "user_123", + subscription: "free", + organizationId: "org_stale", + freeQuotaSubject: "free_quota_subject", + } as never); + mockListOrganizationMemberships.mockResolvedValueOnce({ + data: [], + } as never); + + const { POST } = await import("../route"); + const response = await POST(makeRequest({ plan: "pro-monthly-plan" })); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: "Select an active organization before subscribing", + code: "organization_selection_required", + }); + expect(mockListOrganizationMemberships).toHaveBeenCalledWith({ + userId: "user_123", + statuses: ["active"], + organizationId: "org_stale", + }); + expect(mockCreateOrganization).not.toHaveBeenCalled(); + expect(mockCreateOrganizationMembership).not.toHaveBeenCalled(); + expect(mockCreateCustomer).not.toHaveBeenCalled(); + expect(mockCreateCheckoutSession).not.toHaveBeenCalled(); + }); + it("returns unauthenticated requests as 401 responses", async () => { mockGetUserIDAndPro.mockRejectedValueOnce( new ChatSDKError("unauthorized:auth") as never, diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts index 7520e597b..8f9eb84bf 100644 --- a/app/api/subscribe/route.ts +++ b/app/api/subscribe/route.ts @@ -335,12 +335,25 @@ export const POST = async (req: NextRequest) => { let organization; + if (organizationId && existingMemberships.data.length === 0) { + return json( + { + error: "Select an active organization before subscribing", + code: "organization_selection_required", + }, + { status: 409 }, + ); + } + if (existingMemberships.data && existingMemberships.data.length > 0) { // The authenticated active organization scopes multi-organization users. // Without one, only a single unambiguous membership is safe to select. if (!organizationId && existingMemberships.data.length > 1) { return json( - { error: "Select an active organization before subscribing" }, + { + error: "Select an active organization before subscribing", + code: "organization_selection_required", + }, { status: 409 }, ); }