Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions app/api/subscribe/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 23 additions & 2 deletions app/api/subscribe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down