diff --git a/app/api/subscribe/__tests__/route.test.ts b/app/api/subscribe/__tests__/route.test.ts index 415e7df68..6516ed198 100644 --- a/app/api/subscribe/__tests__/route.test.ts +++ b/app/api/subscribe/__tests__/route.test.ts @@ -190,6 +190,101 @@ 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", + 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 f7b0eb5d8..8f9eb84bf 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,33 @@ export const POST = async (req: NextRequest) => { await workos.userManagement.listOrganizationMemberships({ userId, statuses: ["active"], + ...(organizationId && { organizationId }), }); 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) { - // 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", + code: "organization_selection_required", + }, + { status: 409 }, + ); + } const membership = existingMemberships.data[0]; if (!canManageOrganizationBilling(membership)) { return json(