-
Notifications
You must be signed in to change notification settings - Fork 9
feat(api): migrate GET /api/subscriptions to /api/accounts/{id}/subscription #476
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
aeaf9b9
a2bf081
7b44cd1
3f922d4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||
| import { getSubscriptionsHandler } from "@/lib/subscriptions/getSubscriptionsHandler"; | ||
|
|
||
| /** | ||
| * OPTIONS handler for CORS preflight requests. | ||
| * | ||
| * @returns A NextResponse with CORS headers. | ||
| */ | ||
| export async function OPTIONS() { | ||
| return new NextResponse(null, { | ||
| status: 200, | ||
| headers: getCorsHeaders(), | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * GET /api/accounts/{id}/subscription | ||
| * | ||
| * Returns the account's active Stripe subscription, or a short-circuit | ||
| * `{ isEnterprise: true }` for enterprise-domain accounts. | ||
| * | ||
| * @param request - The request object | ||
| * @param options - Route options containing params | ||
| * @param options.params - Route params containing the account ID | ||
| * @returns A NextResponse with `{ status, subscription }` or `{ status, isEnterprise }` | ||
| */ | ||
| export async function GET(request: NextRequest, options: { params: Promise<{ id: string }> }) { | ||
| return getSubscriptionsHandler(request, options.params); | ||
| } | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
| export const fetchCache = "force-no-store"; | ||
| export const revalidate = 0; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { ENTERPRISE_DOMAINS } from "@/lib/consts"; | ||
| import { extractDomain } from "@/lib/email/extractDomain"; | ||
|
|
||
| /** | ||
| * Returns true when the email's domain is on the enterprise allow-list. | ||
| * | ||
| * Enterprise-tagged accounts bypass the Stripe active-subscription check | ||
| * in `GET /api/accounts/{id}/subscription`. | ||
| */ | ||
| export function isEnterprise(email: string): boolean { | ||
| if (!email) return false; | ||
| const domain = extractDomain(email); | ||
| if (!domain) return false; | ||
| return ENTERPRISE_DOMAINS.has(domain); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import Stripe from "stripe"; | ||
|
|
||
| /** | ||
| * Pinned Stripe API version. Matches the default version shipped with | ||
| * `stripe@17.x` (the version used by `mono/chat`), so this service and | ||
| * the chat app talk to the same Stripe schema. | ||
| */ | ||
| const STRIPE_API_VERSION = "2024-10-28.acacia" as const; | ||
|
|
||
| const stripeSecretKey = process.env.STRIPE_SECRET_KEY ?? process.env.STRIPE_SK; | ||
|
|
||
| // Fail-closed in production: a missing secret means every subscription | ||
| // lookup would silently 500 at first Stripe call. Outside production we | ||
| // let module-load succeed so tests and local builds don't break. | ||
| if (!stripeSecretKey && process.env.NODE_ENV === "production") { | ||
| throw new Error( | ||
| "Stripe secret key is not configured. Set STRIPE_SECRET_KEY (preferred) or STRIPE_SK.", | ||
| ); | ||
| } | ||
|
|
||
| const stripeClient = new Stripe(stripeSecretKey ?? "sk_test_unset", { | ||
| apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion, | ||
| }); | ||
|
|
||
| export default stripeClient; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Custom agent: Module should export a single primary function whose name matches the filename
Prompt for AI agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Custom agent: Enforce Clear Code Style and Maintainability Practices New file exceeds the 100-line file-size limit required by Rule 3. Prompt for AI agents |
||
| import { NextRequest, NextResponse } from "next/server"; | ||
|
|
||
| import { getSubscriptionsHandler } from "../getSubscriptionsHandler"; | ||
| import { validateGetSubscriptionRequest } from "../validateGetSubscriptionRequest"; | ||
| import { getAccountSubscription } from "../getAccountSubscription"; | ||
|
|
||
| vi.mock("@/lib/networking/getCorsHeaders", () => ({ | ||
| getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), | ||
| })); | ||
|
|
||
| vi.mock("../validateGetSubscriptionRequest", () => ({ | ||
| validateGetSubscriptionRequest: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock("../getAccountSubscription", () => ({ | ||
| getAccountSubscription: vi.fn(), | ||
| })); | ||
|
|
||
| const accountId = "550e8400-e29b-41d4-a716-446655440000"; | ||
| const validated = { account_id: accountId }; | ||
| const makeRequest = () => | ||
| new NextRequest(`http://localhost/api/accounts/${accountId}/subscription`, { method: "GET" }); | ||
|
|
||
| describe("getSubscriptionsHandler", () => { | ||
| beforeEach(() => vi.clearAllMocks()); | ||
|
|
||
| it("returns the validator error when validation fails", async () => { | ||
| const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
| vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(err); | ||
|
|
||
| const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); | ||
|
|
||
| expect(res).toBe(err); | ||
| expect(getAccountSubscription).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("returns 200 isEnterprise for enterprise accounts", async () => { | ||
| vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); | ||
| vi.mocked(getAccountSubscription).mockResolvedValue({ isEnterprise: true }); | ||
|
|
||
| const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); | ||
|
|
||
| expect(res.status).toBe(200); | ||
| expect(await res.json()).toEqual({ status: "success", isEnterprise: true }); | ||
| }); | ||
|
|
||
| it("returns 200 with the subscription for a paid account", async () => { | ||
| const subscription = { id: "sub_123", metadata: { accountId } } as never; | ||
| vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); | ||
| vi.mocked(getAccountSubscription).mockResolvedValue({ subscription }); | ||
|
|
||
| const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); | ||
|
|
||
| expect(res.status).toBe(200); | ||
| expect(await res.json()).toEqual({ | ||
| status: "success", | ||
| subscription: { id: "sub_123", metadata: { accountId } }, | ||
| }); | ||
| expect(getAccountSubscription).toHaveBeenCalledWith(accountId); | ||
| }); | ||
|
|
||
| it("returns 404 when no active subscription exists", async () => { | ||
| vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); | ||
| vi.mocked(getAccountSubscription).mockResolvedValue(null); | ||
|
|
||
| const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); | ||
|
|
||
| expect(res.status).toBe(404); | ||
| expect(await res.json()).toEqual({ status: "error", error: "No active subscription found" }); | ||
| }); | ||
|
|
||
| it("returns 500 generic error and never leaks the raw exception message", async () => { | ||
| vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); | ||
| vi.mocked(getAccountSubscription).mockImplementation(() => { | ||
| throw new Error("db down: connection refused at 10.0.0.1:5432"); | ||
| }); | ||
|
|
||
| const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); | ||
| const body = await res.json(); | ||
|
|
||
| expect(res.status).toBe(500); | ||
| expect(body).toEqual({ status: "error", error: "Internal server error" }); | ||
| expect(body.error).not.toContain("db down"); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Custom agent: Enforce Clear Code Style and Maintainability Practices File exceeds the repository’s 100-line maintainability cap. Prompt for AI agents |
||
| import { NextRequest, NextResponse } from "next/server"; | ||
|
|
||
| import { validateGetSubscriptionRequest } from "../validateGetSubscriptionRequest"; | ||
| import { validateAuthContext } from "@/lib/auth/validateAuthContext"; | ||
| import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; | ||
| import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; | ||
|
|
||
| vi.mock("@/lib/networking/getCorsHeaders", () => ({ | ||
| getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), | ||
| })); | ||
|
|
||
| vi.mock("@/lib/auth/validateAuthContext", () => ({ | ||
| validateAuthContext: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock("@/lib/supabase/accounts/selectAccounts", () => ({ | ||
| selectAccounts: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock("@/lib/organizations/canAccessAccount", () => ({ | ||
| canAccessAccount: vi.fn(), | ||
| })); | ||
|
|
||
| const accountId = "550e8400-e29b-41d4-a716-446655440000"; | ||
| const requesterId = "660e8400-e29b-41d4-a716-446655440000"; | ||
| const authOk = { accountId: requesterId, authToken: "t", orgId: null }; | ||
| const makeRequest = (id = accountId) => | ||
| new NextRequest(`http://localhost/api/accounts/${id}/subscription`, { | ||
| method: "GET", | ||
| headers: { Authorization: "Bearer test-token" }, | ||
| }); | ||
|
|
||
| describe("validateGetSubscriptionRequest", () => { | ||
| beforeEach(() => vi.clearAllMocks()); | ||
|
|
||
| it.each([ | ||
| ["not-a-uuid", "non-UUID id"], | ||
| ["", "empty id"], | ||
| ])("returns 400 for %s (%s)", async id => { | ||
| vi.mocked(validateAuthContext).mockResolvedValue(authOk); | ||
|
|
||
| const result = await validateGetSubscriptionRequest(makeRequest(), id); | ||
|
|
||
| expect(result).toBeInstanceOf(NextResponse); | ||
| expect((result as NextResponse).status).toBe(400); | ||
| expect(selectAccounts).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("returns the auth error when authentication fails", async () => { | ||
| const authError = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
| vi.mocked(validateAuthContext).mockResolvedValue(authError); | ||
|
|
||
| const result = await validateGetSubscriptionRequest(makeRequest(), accountId); | ||
|
|
||
| expect(result).toBe(authError); | ||
| expect(selectAccounts).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("returns 404 when the account does not exist", async () => { | ||
| vi.mocked(validateAuthContext).mockResolvedValue(authOk); | ||
| vi.mocked(selectAccounts).mockResolvedValue([]); | ||
|
|
||
| const result = await validateGetSubscriptionRequest(makeRequest(), accountId); | ||
|
|
||
| expect(result).toBeInstanceOf(NextResponse); | ||
| expect((result as NextResponse).status).toBe(404); | ||
| expect(canAccessAccount).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("returns 403 when the requester cannot access the account", async () => { | ||
| vi.mocked(validateAuthContext).mockResolvedValue(authOk); | ||
| vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never); | ||
| vi.mocked(canAccessAccount).mockResolvedValue(false); | ||
|
|
||
| const result = await validateGetSubscriptionRequest(makeRequest(), accountId); | ||
|
|
||
| expect(result).toBeInstanceOf(NextResponse); | ||
| expect((result as NextResponse).status).toBe(403); | ||
| // Regression: access check must compare the CALLER's account id against | ||
| // the target path id — never the path id against itself. | ||
| expect(canAccessAccount).toHaveBeenCalledWith({ | ||
| currentAccountId: requesterId, | ||
| targetAccountId: accountId, | ||
| }); | ||
| }); | ||
|
|
||
| it("does not pass the target accountId as an override to validateAuthContext", async () => { | ||
| vi.mocked(validateAuthContext).mockResolvedValue(authOk); | ||
| vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never); | ||
| vi.mocked(canAccessAccount).mockResolvedValue(true); | ||
|
|
||
| await validateGetSubscriptionRequest(makeRequest(), accountId); | ||
|
|
||
| // Regression: passing `{ accountId: id }` here would rewrite | ||
| // authResult.accountId to the target, collapsing the access check into | ||
| // a self-check that always returns true. | ||
| const call = vi.mocked(validateAuthContext).mock.calls[0]; | ||
| expect(call[1]).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("returns the validated account_id on success", async () => { | ||
| vi.mocked(validateAuthContext).mockResolvedValue(authOk); | ||
| vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never); | ||
| vi.mocked(canAccessAccount).mockResolvedValue(true); | ||
|
|
||
| const result = await validateGetSubscriptionRequest(makeRequest(), accountId); | ||
|
|
||
| expect(result).toEqual({ account_id: accountId }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import Stripe from "stripe"; | ||
| import stripeClient from "@/lib/stripe/client"; | ||
| import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; | ||
| import { isEnterprise } from "@/lib/enterprise/isEnterprise"; | ||
|
|
||
| export type AccountSubscription = | ||
| | { isEnterprise: true } | ||
| | { subscription: Stripe.Subscription } | ||
| | null; | ||
|
|
||
| /** | ||
| * Resolves an account's subscription state: enterprise (via email domain), | ||
| * an active Stripe subscription (`metadata.accountId` match), or null. | ||
| * | ||
| * Uses `stripe.subscriptions.search` so the metadata + status filter runs on | ||
| * Stripe's side — scales independently of total subscription count (a prior | ||
| * `list({ limit: 100 })` + client-side filter was lossy past 100). Search is | ||
| * eventually consistent (~1 min lag after writes). | ||
| */ | ||
| export async function getAccountSubscription(accountId: string): Promise<AccountSubscription> { | ||
| try { | ||
| const emails = await selectAccountEmails({ accountIds: accountId }); | ||
| if (emails.some(record => isEnterprise(record.email || ""))) { | ||
| return { isEnterprise: true }; | ||
| } | ||
|
|
||
| const result = await stripeClient.subscriptions.search({ | ||
| query: `status:"active" AND metadata["accountId"]:"${accountId}"`, | ||
| limit: 1, | ||
| }); | ||
| const subscription = result.data[0]; | ||
| return subscription ? { subscription } : null; | ||
| } catch (error) { | ||
| console.error("[ERROR] getAccountSubscription:", error); | ||
| return null; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Do not return Prompt for AI agents |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||
| import { errorResponse } from "@/lib/networking/errorResponse"; | ||
| import { validateGetSubscriptionRequest } from "@/lib/subscriptions/validateGetSubscriptionRequest"; | ||
| import { getAccountSubscription } from "@/lib/subscriptions/getAccountSubscription"; | ||
|
|
||
| /** | ||
| * Handler for GET /api/accounts/{id}/subscription. When the account is a paid | ||
| * Stripe customer, `subscription` is a raw `Stripe.Subscription` — its keys | ||
| * stay camelCase by design (third-party typed payload), despite the rest of | ||
| * the API being snake_case. | ||
| */ | ||
| export async function getSubscriptionsHandler( | ||
| request: NextRequest, | ||
| params: Promise<{ id: string }>, | ||
| ): Promise<NextResponse> { | ||
| try { | ||
| const { id } = await params; | ||
|
|
||
| const validated = await validateGetSubscriptionRequest(request, id); | ||
| if (validated instanceof NextResponse) return validated; | ||
|
|
||
| const result = await getAccountSubscription(validated.account_id); | ||
| if (!result) return errorResponse("No active subscription found", 404); | ||
|
|
||
| return NextResponse.json( | ||
| { status: "success", ...result }, | ||
| { status: 200, headers: getCorsHeaders() }, | ||
| ); | ||
| } catch (error) { | ||
| console.error("[ERROR] getSubscriptionsHandler:", error); | ||
| return errorResponse("Internal server error", 500); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: Custom agent: Module should export a single primary function whose name matches the filename
Module exports multiple top-level functions and none matches the
route.tsbasename, violating the single-primary-export naming rule.Prompt for AI agents