From 10213f646f0d3acc2d1e5956ece06205f31eb9d2 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 1 May 2026 19:32:01 +0530 Subject: [PATCH 1/3] feat(api): migrate GET /api/credits/get + GET /api/subscription/status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 (Billing read) of the chat→api migration plan. Ports chat's account-scoped read endpoints into recoup-api with 1:1 response shapes so chat-side cutover is a base-URL swap. - New routes: - GET /api/credits/get?accountId=… → { data: CreditsUsage | null } - GET /api/subscription/status?accountId=… → { isPro: boolean } - Handlers + Zod query validators in lib/credits and lib/subscription. - Ports stripe helpers (getActiveSubscriptions, getActiveSubscriptionDetails, isActiveSubscription, getOrgSubscription) into lib/stripe and the monthly-refill checkAndResetCredits into lib/credits, reusing existing lib/supabase/credits_usage and lib/supabase/account_organization_ids. - Endpoints unauthenticated to mirror chat parity. STRIPE_SK already wired. - 9 new vitest cases. Full suite 2379/2379 green; lint + format clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/credits/get/route.ts | 33 ++++++++ app/api/subscription/status/route.ts | 32 +++++++ .../__tests__/getCreditsHandler.test.ts | 65 +++++++++++++++ lib/credits/checkAndResetCredits.ts | 57 +++++++++++++ lib/credits/getCreditsHandler.ts | 26 ++++++ lib/credits/validateCreditsGetQuery.ts | 24 ++++++ lib/stripe/getActiveSubscriptionDetails.ts | 18 ++++ lib/stripe/getActiveSubscriptions.ts | 27 ++++++ lib/stripe/getOrgSubscription.ts | 22 +++++ lib/stripe/isActiveSubscription.ts | 14 ++++ .../getSubscriptionStatusHandler.test.ts | 83 +++++++++++++++++++ .../getSubscriptionStatusHandler.ts | 39 +++++++++ .../validateSubscriptionStatusQuery.ts | 24 ++++++ 13 files changed, 464 insertions(+) create mode 100644 app/api/credits/get/route.ts create mode 100644 app/api/subscription/status/route.ts create mode 100644 lib/credits/__tests__/getCreditsHandler.test.ts create mode 100644 lib/credits/checkAndResetCredits.ts create mode 100644 lib/credits/getCreditsHandler.ts create mode 100644 lib/credits/validateCreditsGetQuery.ts create mode 100644 lib/stripe/getActiveSubscriptionDetails.ts create mode 100644 lib/stripe/getActiveSubscriptions.ts create mode 100644 lib/stripe/getOrgSubscription.ts create mode 100644 lib/stripe/isActiveSubscription.ts create mode 100644 lib/subscription/__tests__/getSubscriptionStatusHandler.test.ts create mode 100644 lib/subscription/getSubscriptionStatusHandler.ts create mode 100644 lib/subscription/validateSubscriptionStatusQuery.ts diff --git a/app/api/credits/get/route.ts b/app/api/credits/get/route.ts new file mode 100644 index 000000000..0d1060d1b --- /dev/null +++ b/app/api/credits/get/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getCreditsHandler } from "@/lib/credits/getCreditsHandler"; + +/** + * 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/credits/get?accountId=xxx + * + * Returns the credits row for the given account (auto-refilling on + * monthly cadence or just-activated subscription). No authentication + * required. + * + * @param request - The incoming HTTP request. + * @returns A NextResponse with `{ data }` on 200 or `{ message }` on 400/500. + */ +export async function GET(request: NextRequest) { + return getCreditsHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/app/api/subscription/status/route.ts b/app/api/subscription/status/route.ts new file mode 100644 index 000000000..796d62219 --- /dev/null +++ b/app/api/subscription/status/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSubscriptionStatusHandler } from "@/lib/subscription/getSubscriptionStatusHandler"; + +/** + * 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/subscription/status?accountId=xxx + * + * Returns whether the account is on a pro Stripe subscription (account + * or any of its organizations). No authentication required. + * + * @param request - The incoming HTTP request. + * @returns A NextResponse with `{ isPro }` on 200 or `{ message }` on 400/500. + */ +export async function GET(request: NextRequest) { + return getSubscriptionStatusHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/credits/__tests__/getCreditsHandler.test.ts b/lib/credits/__tests__/getCreditsHandler.test.ts new file mode 100644 index 000000000..9399e4fcc --- /dev/null +++ b/lib/credits/__tests__/getCreditsHandler.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { getCreditsHandler } from "@/lib/credits/getCreditsHandler"; +import { checkAndResetCredits } from "@/lib/credits/checkAndResetCredits"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/credits/checkAndResetCredits", () => ({ + checkAndResetCredits: vi.fn(), +})); + +const ACCOUNT = "11111111-2222-3333-4444-555555555555"; + +const buildRequest = (qs: string) => new NextRequest(`http://localhost/api/credits/get${qs}`); + +describe("getCreditsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + afterEach(() => vi.mocked(console.error).mockRestore()); + + it("returns 400 when accountId is missing", async () => { + const res = await getCreditsHandler(buildRequest("")); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ message: "accountId is required" }); + expect(checkAndResetCredits).not.toHaveBeenCalled(); + }); + + it("returns 200 with the credits row", async () => { + const row = { + account_id: ACCOUNT, + remaining_credits: 250, + timestamp: "2026-01-01T00:00:00.000Z", + }; + vi.mocked(checkAndResetCredits).mockResolvedValue( + row as Awaited>, + ); + + const res = await getCreditsHandler(buildRequest(`?accountId=${ACCOUNT}`)); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ data: row }); + expect(checkAndResetCredits).toHaveBeenCalledWith(ACCOUNT); + }); + + it("returns 200 with data:null when no credits row exists", async () => { + vi.mocked(checkAndResetCredits).mockResolvedValue(null); + + const res = await getCreditsHandler(buildRequest(`?accountId=${ACCOUNT}`)); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ data: null }); + }); + + it("returns 500 with generic message when checkAndResetCredits throws", async () => { + vi.mocked(checkAndResetCredits).mockRejectedValue(new Error("DB down")); + + const res = await getCreditsHandler(buildRequest(`?accountId=${ACCOUNT}`)); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body).toEqual({ message: "Internal server error" }); + expect(body.message).not.toContain("DB down"); + }); +}); diff --git a/lib/credits/checkAndResetCredits.ts b/lib/credits/checkAndResetCredits.ts new file mode 100644 index 000000000..feeef9261 --- /dev/null +++ b/lib/credits/checkAndResetCredits.ts @@ -0,0 +1,57 @@ +import { selectCreditsUsage } from "@/lib/supabase/credits_usage/selectCreditsUsage"; +import { updateCreditsUsage } from "@/lib/supabase/credits_usage/updateCreditsUsage"; +import type { CreditsUsage } from "@/lib/supabase/credits_usage/selectCreditsUsage"; +import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; + +export const CHECK_AND_RESET_DEFAULT_CREDITS = 333; +export const CHECK_AND_RESET_PRO_CREDITS = 1000; + +/** + * Returns the credits row for the given account, refilling it when the + * account is on a refill cycle (monthly cadence or just-activated + * subscription). Honors both account-level and organization-level Stripe + * subscriptions when deciding whether the refill should use the pro tier. + */ +export const checkAndResetCredits = async (accountId: string): Promise => { + const found = await selectCreditsUsage({ account_id: accountId }); + if (!found || found.length === 0) return null; + + const creditsUsage = found[0]; + if (!creditsUsage.timestamp) return creditsUsage; + + const lastUpdatedCredits = new Date(creditsUsage.timestamp); + const oneMonthAgo = new Date(); + oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); + + const accountSubscription = await getActiveSubscriptionDetails(accountId); + const orgSubscription = await getOrgSubscription(accountId); + + const hasAccountSubscription = isActiveSubscription(accountSubscription); + const hasOrgSubscription = isActiveSubscription(orgSubscription); + const isPro = hasAccountSubscription || hasOrgSubscription; + + const activeSubscription = hasAccountSubscription + ? accountSubscription + : hasOrgSubscription + ? orgSubscription + : null; + const subscriptionStartUnix = + activeSubscription?.current_period_start ?? activeSubscription?.start_date; + const isMonthlyRefill = lastUpdatedCredits < oneMonthAgo; + const hasActiveSubscription = isPro && subscriptionStartUnix; + const subscriptionStart = hasActiveSubscription ? new Date(subscriptionStartUnix * 1000) : null; + const isSubscriptionStartedAfterLastUpdate = + subscriptionStart && lastUpdatedCredits < subscriptionStart; + const isRefill = isMonthlyRefill || isSubscriptionStartedAfterLastUpdate; + if (!isRefill) return creditsUsage; + + return updateCreditsUsage({ + account_id: accountId, + updates: { + remaining_credits: isPro ? CHECK_AND_RESET_PRO_CREDITS : CHECK_AND_RESET_DEFAULT_CREDITS, + timestamp: new Date().toISOString(), + }, + }); +}; diff --git a/lib/credits/getCreditsHandler.ts b/lib/credits/getCreditsHandler.ts new file mode 100644 index 000000000..d71b59ad9 --- /dev/null +++ b/lib/credits/getCreditsHandler.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { checkAndResetCredits } from "@/lib/credits/checkAndResetCredits"; +import { validateCreditsGetQuery } from "@/lib/credits/validateCreditsGetQuery"; + +/** + * Handles GET /api/credits/get — returns the credits row for the given + * account, refilling it when the account is on a refill cycle. + */ +export async function getCreditsHandler(request: NextRequest): Promise { + const validated = validateCreditsGetQuery(request.nextUrl.searchParams); + if (validated instanceof NextResponse) { + return validated; + } + + try { + const creditsUsage = await checkAndResetCredits(validated.accountId); + return NextResponse.json({ data: creditsUsage }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + console.error("/api/credits/get error", error); + return NextResponse.json( + { message: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/credits/validateCreditsGetQuery.ts b/lib/credits/validateCreditsGetQuery.ts new file mode 100644 index 000000000..3883f0450 --- /dev/null +++ b/lib/credits/validateCreditsGetQuery.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +const creditsGetQuerySchema = z.object({ + accountId: z.string().min(1, "accountId is required"), +}); + +export type CreditsGetQuery = z.infer; + +export function validateCreditsGetQuery( + searchParams: URLSearchParams, +): NextResponse | CreditsGetQuery { + const accountId = searchParams.get("accountId") ?? ""; + const result = creditsGetQuerySchema.safeParse({ accountId }); + if (!result.success) { + const first = result.error.issues[0]; + return NextResponse.json( + { message: first.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + return result.data; +} diff --git a/lib/stripe/getActiveSubscriptionDetails.ts b/lib/stripe/getActiveSubscriptionDetails.ts new file mode 100644 index 000000000..8e92b3dc3 --- /dev/null +++ b/lib/stripe/getActiveSubscriptionDetails.ts @@ -0,0 +1,18 @@ +import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; +import type Stripe from "stripe"; + +/** + * Returns the first active Stripe subscription for the given account, + * or null if none exists. + */ +export const getActiveSubscriptionDetails = async ( + accountId: string, +): Promise => { + try { + const activeSubscriptions = await getActiveSubscriptions(accountId); + return activeSubscriptions.length > 0 ? activeSubscriptions[0] : null; + } catch (error) { + console.error("Error fetching subscription:", error); + return null; + } +}; diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts new file mode 100644 index 000000000..2367bc850 --- /dev/null +++ b/lib/stripe/getActiveSubscriptions.ts @@ -0,0 +1,27 @@ +import stripeClient from "@/lib/stripe/client"; +import type Stripe from "stripe"; + +/** + * Returns Stripe subscriptions whose metadata `accountId` matches the + * given account and whose `current_period_end` is in the future. Returns + * an empty array on any error (logged). + */ +export const getActiveSubscriptions = async (accountId: string): Promise => { + try { + const subscriptions = await stripeClient.subscriptions.list({ + limit: 100, + current_period_end: { + gt: parseInt(Number(Date.now() / 1000).toFixed(0), 10), + }, + }); + + const activeSubscriptions = subscriptions?.data?.filter( + (subscription: Stripe.Subscription) => subscription.metadata?.accountId === accountId, + ); + + return activeSubscriptions || []; + } catch (error) { + console.error("Error fetching subscriptions:", error); + return []; + } +}; diff --git a/lib/stripe/getOrgSubscription.ts b/lib/stripe/getOrgSubscription.ts new file mode 100644 index 000000000..60528d07a --- /dev/null +++ b/lib/stripe/getOrgSubscription.ts @@ -0,0 +1,22 @@ +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; +import type Stripe from "stripe"; + +/** + * Returns the first active Stripe subscription found across any of the + * given account's organizations, or null when none. + */ +export async function getOrgSubscription(accountId: string): Promise { + if (!accountId) return null; + + const accountOrgs = await getAccountOrganizations({ accountId }); + if (accountOrgs.length === 0) return null; + + const orgIds = accountOrgs + .map(org => org.organization_id) + .filter((id): id is string => id !== null); + + const subscriptions = await Promise.all(orgIds.map(orgId => getActiveSubscriptionDetails(orgId))); + + return subscriptions.find(sub => sub !== null) ?? null; +} diff --git a/lib/stripe/isActiveSubscription.ts b/lib/stripe/isActiveSubscription.ts new file mode 100644 index 000000000..36e0eacd0 --- /dev/null +++ b/lib/stripe/isActiveSubscription.ts @@ -0,0 +1,14 @@ +import type Stripe from "stripe"; + +/** + * Returns true when the subscription is non-null and not a canceled trial. + */ +const isActiveSubscription = (subscription?: Stripe.Subscription | null): boolean => { + if (!subscription) return false; + const isTrial = subscription?.status === "trialing"; + const isCanceledTrial = isTrial && subscription?.canceled_at; + const subscriptionActive = !isCanceledTrial; + return subscriptionActive; +}; + +export default isActiveSubscription; diff --git a/lib/subscription/__tests__/getSubscriptionStatusHandler.test.ts b/lib/subscription/__tests__/getSubscriptionStatusHandler.test.ts new file mode 100644 index 000000000..f03007ebb --- /dev/null +++ b/lib/subscription/__tests__/getSubscriptionStatusHandler.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { getSubscriptionStatusHandler } from "@/lib/subscription/getSubscriptionStatusHandler"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; +import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ + getActiveSubscriptionDetails: vi.fn(), +})); + +vi.mock("@/lib/stripe/getOrgSubscription", () => ({ + getOrgSubscription: vi.fn(), +})); + +vi.mock("@/lib/stripe/isActiveSubscription", () => ({ + default: vi.fn(), +})); + +const ACCOUNT = "11111111-2222-3333-4444-555555555555"; + +const buildRequest = (qs: string) => + new NextRequest(`http://localhost/api/subscription/status${qs}`); + +describe("getSubscriptionStatusHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + afterEach(() => vi.mocked(console.error).mockRestore()); + + it("returns 400 when accountId is missing", async () => { + const res = await getSubscriptionStatusHandler(buildRequest("")); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ message: "accountId is required" }); + expect(getActiveSubscriptionDetails).not.toHaveBeenCalled(); + }); + + it("returns isPro:true when the account has an active subscription", async () => { + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({} as never); + vi.mocked(getOrgSubscription).mockResolvedValue(null); + vi.mocked(isActiveSubscription).mockImplementation(sub => sub !== null && sub !== undefined); + + const res = await getSubscriptionStatusHandler(buildRequest(`?accountId=${ACCOUNT}`)); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ isPro: true }); + }); + + it("returns isPro:true when only the org has a subscription", async () => { + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + vi.mocked(getOrgSubscription).mockResolvedValue({} as never); + vi.mocked(isActiveSubscription).mockImplementation(sub => sub !== null && sub !== undefined); + + const res = await getSubscriptionStatusHandler(buildRequest(`?accountId=${ACCOUNT}`)); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ isPro: true }); + }); + + it("returns isPro:false when neither the account nor its orgs have one", async () => { + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + vi.mocked(getOrgSubscription).mockResolvedValue(null); + vi.mocked(isActiveSubscription).mockReturnValue(false); + + const res = await getSubscriptionStatusHandler(buildRequest(`?accountId=${ACCOUNT}`)); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ isPro: false }); + }); + + it("returns 500 with generic message when an upstream call throws", async () => { + vi.mocked(getActiveSubscriptionDetails).mockRejectedValue(new Error("Stripe down")); + vi.mocked(getOrgSubscription).mockResolvedValue(null); + + const res = await getSubscriptionStatusHandler(buildRequest(`?accountId=${ACCOUNT}`)); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body).toEqual({ message: "Internal server error" }); + expect(body.message).not.toContain("Stripe down"); + }); +}); diff --git a/lib/subscription/getSubscriptionStatusHandler.ts b/lib/subscription/getSubscriptionStatusHandler.ts new file mode 100644 index 000000000..eb68eda6b --- /dev/null +++ b/lib/subscription/getSubscriptionStatusHandler.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; +import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; +import { validateSubscriptionStatusQuery } from "@/lib/subscription/validateSubscriptionStatusQuery"; + +export interface SubscriptionStatusResponse { + isPro: boolean; +} + +/** + * Handles GET /api/subscription/status — returns whether the account or + * any of its organizations has an active Stripe subscription. + */ +export async function getSubscriptionStatusHandler(request: NextRequest): Promise { + const validated = validateSubscriptionStatusQuery(request.nextUrl.searchParams); + if (validated instanceof NextResponse) { + return validated; + } + + try { + const [accountSubscription, orgSubscription] = await Promise.all([ + getActiveSubscriptionDetails(validated.accountId), + getOrgSubscription(validated.accountId), + ]); + + const isPro = + isActiveSubscription(accountSubscription) || isActiveSubscription(orgSubscription); + + return NextResponse.json({ isPro }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + console.error("/api/subscription/status error", error); + return NextResponse.json( + { message: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/subscription/validateSubscriptionStatusQuery.ts b/lib/subscription/validateSubscriptionStatusQuery.ts new file mode 100644 index 000000000..a4bb81291 --- /dev/null +++ b/lib/subscription/validateSubscriptionStatusQuery.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +const subscriptionStatusQuerySchema = z.object({ + accountId: z.string().min(1, "accountId is required"), +}); + +export type SubscriptionStatusQuery = z.infer; + +export function validateSubscriptionStatusQuery( + searchParams: URLSearchParams, +): NextResponse | SubscriptionStatusQuery { + const accountId = searchParams.get("accountId") ?? ""; + const result = subscriptionStatusQuerySchema.safeParse({ accountId }); + if (!result.success) { + const first = result.error.issues[0]; + return NextResponse.json( + { message: first.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + return result.data; +} From 1cb613925e1fed8436384d7c8387a40baa9ea925 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 1 May 2026 19:42:19 +0530 Subject: [PATCH 2/3] refactor(api): adopt REST conventions for billing reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the two new endpoints to follow api repo conventions (plural collections, no verb suffixes) and derives accountId from validateAuthContext instead of an accountId query param — matching the group 4 stripe outbound migration. - /api/credits/get → /api/credits - /api/subscription/status → /api/subscriptions/status - Drops validate*Query.ts; handlers call validateAuthContext directly. - Tests updated; full suite 2379/2379 green. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/credits/{get => }/route.ts | 10 ++--- .../status/route.ts | 9 ++-- .../__tests__/getCreditsHandler.test.ts | 43 ++++++++++++++----- lib/credits/getCreditsHandler.ts | 17 ++++---- lib/credits/validateCreditsGetQuery.ts | 24 ----------- .../getSubscriptionStatusHandler.test.ts | 39 ++++++++++++----- .../getSubscriptionStatusHandler.ts | 19 ++++---- .../validateSubscriptionStatusQuery.ts | 24 ----------- 8 files changed, 90 insertions(+), 95 deletions(-) rename app/api/credits/{get => }/route.ts (78%) rename app/api/{subscription => subscriptions}/status/route.ts (79%) delete mode 100644 lib/credits/validateCreditsGetQuery.ts delete mode 100644 lib/subscription/validateSubscriptionStatusQuery.ts diff --git a/app/api/credits/get/route.ts b/app/api/credits/route.ts similarity index 78% rename from app/api/credits/get/route.ts rename to app/api/credits/route.ts index 0d1060d1b..e2decf44b 100644 --- a/app/api/credits/get/route.ts +++ b/app/api/credits/route.ts @@ -15,14 +15,14 @@ export async function OPTIONS() { } /** - * GET /api/credits/get?accountId=xxx + * GET /api/credits * - * Returns the credits row for the given account (auto-refilling on - * monthly cadence or just-activated subscription). No authentication - * required. + * Returns the credits row for the authenticated account (auto-refilling + * on monthly cadence or just-activated subscription). Auth: API key or + * Privy Bearer token. * * @param request - The incoming HTTP request. - * @returns A NextResponse with `{ data }` on 200 or `{ message }` on 400/500. + * @returns A NextResponse with `{ data }` on 200 or `{ message }` on 4xx/5xx. */ export async function GET(request: NextRequest) { return getCreditsHandler(request); diff --git a/app/api/subscription/status/route.ts b/app/api/subscriptions/status/route.ts similarity index 79% rename from app/api/subscription/status/route.ts rename to app/api/subscriptions/status/route.ts index 796d62219..3faf71008 100644 --- a/app/api/subscription/status/route.ts +++ b/app/api/subscriptions/status/route.ts @@ -15,13 +15,14 @@ export async function OPTIONS() { } /** - * GET /api/subscription/status?accountId=xxx + * GET /api/subscriptions/status * - * Returns whether the account is on a pro Stripe subscription (account - * or any of its organizations). No authentication required. + * Returns whether the authenticated account is on a pro Stripe + * subscription (account or any of its organizations). Auth: API key or + * Privy Bearer token. * * @param request - The incoming HTTP request. - * @returns A NextResponse with `{ isPro }` on 200 or `{ message }` on 400/500. + * @returns A NextResponse with `{ isPro }` on 200 or `{ message }` on 4xx/5xx. */ export async function GET(request: NextRequest) { return getSubscriptionStatusHandler(request); diff --git a/lib/credits/__tests__/getCreditsHandler.test.ts b/lib/credits/__tests__/getCreditsHandler.test.ts index 9399e4fcc..943d09da2 100644 --- a/lib/credits/__tests__/getCreditsHandler.test.ts +++ b/lib/credits/__tests__/getCreditsHandler.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { getCreditsHandler } from "@/lib/credits/getCreditsHandler"; import { checkAndResetCredits } from "@/lib/credits/checkAndResetCredits"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -11,9 +12,13 @@ vi.mock("@/lib/credits/checkAndResetCredits", () => ({ checkAndResetCredits: vi.fn(), })); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + const ACCOUNT = "11111111-2222-3333-4444-555555555555"; -const buildRequest = (qs: string) => new NextRequest(`http://localhost/api/credits/get${qs}`); +const buildRequest = () => new NextRequest("http://localhost/api/credits"); describe("getCreditsHandler", () => { beforeEach(() => { @@ -22,14 +27,22 @@ describe("getCreditsHandler", () => { }); afterEach(() => vi.mocked(console.error).mockRestore()); - it("returns 400 when accountId is missing", async () => { - const res = await getCreditsHandler(buildRequest("")); - expect(res.status).toBe(400); - await expect(res.json()).resolves.toEqual({ message: "accountId is required" }); + it("returns the auth-error response unchanged when auth fails", async () => { + const err = NextResponse.json({ message: "unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(err); + + const res = await getCreditsHandler(buildRequest()); + expect(res).toBe(err); expect(checkAndResetCredits).not.toHaveBeenCalled(); }); - it("returns 200 with the credits row", async () => { + it("returns 200 with the credits row for the authenticated account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "token", + }); + const row = { account_id: ACCOUNT, remaining_credits: 250, @@ -39,24 +52,34 @@ describe("getCreditsHandler", () => { row as Awaited>, ); - const res = await getCreditsHandler(buildRequest(`?accountId=${ACCOUNT}`)); + const res = await getCreditsHandler(buildRequest()); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ data: row }); expect(checkAndResetCredits).toHaveBeenCalledWith(ACCOUNT); }); it("returns 200 with data:null when no credits row exists", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "token", + }); vi.mocked(checkAndResetCredits).mockResolvedValue(null); - const res = await getCreditsHandler(buildRequest(`?accountId=${ACCOUNT}`)); + const res = await getCreditsHandler(buildRequest()); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ data: null }); }); it("returns 500 with generic message when checkAndResetCredits throws", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "token", + }); vi.mocked(checkAndResetCredits).mockRejectedValue(new Error("DB down")); - const res = await getCreditsHandler(buildRequest(`?accountId=${ACCOUNT}`)); + const res = await getCreditsHandler(buildRequest()); expect(res.status).toBe(500); const body = await res.json(); expect(body).toEqual({ message: "Internal server error" }); diff --git a/lib/credits/getCreditsHandler.ts b/lib/credits/getCreditsHandler.ts index d71b59ad9..750ff9572 100644 --- a/lib/credits/getCreditsHandler.ts +++ b/lib/credits/getCreditsHandler.ts @@ -1,23 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { checkAndResetCredits } from "@/lib/credits/checkAndResetCredits"; -import { validateCreditsGetQuery } from "@/lib/credits/validateCreditsGetQuery"; /** - * Handles GET /api/credits/get — returns the credits row for the given - * account, refilling it when the account is on a refill cycle. + * Handles GET /api/credits — returns the credits row for the + * authenticated account, refilling it when the account is on a refill + * cycle. */ export async function getCreditsHandler(request: NextRequest): Promise { - const validated = validateCreditsGetQuery(request.nextUrl.searchParams); - if (validated instanceof NextResponse) { - return validated; + const authContext = await validateAuthContext(request, {}); + if (authContext instanceof NextResponse) { + return authContext; } try { - const creditsUsage = await checkAndResetCredits(validated.accountId); + const creditsUsage = await checkAndResetCredits(authContext.accountId); return NextResponse.json({ data: creditsUsage }, { status: 200, headers: getCorsHeaders() }); } catch (error) { - console.error("/api/credits/get error", error); + console.error("/api/credits error", error); return NextResponse.json( { message: "Internal server error" }, { status: 500, headers: getCorsHeaders() }, diff --git a/lib/credits/validateCreditsGetQuery.ts b/lib/credits/validateCreditsGetQuery.ts deleted file mode 100644 index 3883f0450..000000000 --- a/lib/credits/validateCreditsGetQuery.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextResponse } from "next/server"; -import { z } from "zod"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; - -const creditsGetQuerySchema = z.object({ - accountId: z.string().min(1, "accountId is required"), -}); - -export type CreditsGetQuery = z.infer; - -export function validateCreditsGetQuery( - searchParams: URLSearchParams, -): NextResponse | CreditsGetQuery { - const accountId = searchParams.get("accountId") ?? ""; - const result = creditsGetQuerySchema.safeParse({ accountId }); - if (!result.success) { - const first = result.error.issues[0]; - return NextResponse.json( - { message: first.message }, - { status: 400, headers: getCorsHeaders() }, - ); - } - return result.data; -} diff --git a/lib/subscription/__tests__/getSubscriptionStatusHandler.test.ts b/lib/subscription/__tests__/getSubscriptionStatusHandler.test.ts index f03007ebb..a5304bb42 100644 --- a/lib/subscription/__tests__/getSubscriptionStatusHandler.test.ts +++ b/lib/subscription/__tests__/getSubscriptionStatusHandler.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { getSubscriptionStatusHandler } from "@/lib/subscription/getSubscriptionStatusHandler"; import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -21,10 +22,20 @@ vi.mock("@/lib/stripe/isActiveSubscription", () => ({ default: vi.fn(), })); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + const ACCOUNT = "11111111-2222-3333-4444-555555555555"; -const buildRequest = (qs: string) => - new NextRequest(`http://localhost/api/subscription/status${qs}`); +const buildRequest = () => new NextRequest("http://localhost/api/subscriptions/status"); + +const mockAuthOk = () => + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "token", + }); describe("getSubscriptionStatusHandler", () => { beforeEach(() => { @@ -33,48 +44,54 @@ describe("getSubscriptionStatusHandler", () => { }); afterEach(() => vi.mocked(console.error).mockRestore()); - it("returns 400 when accountId is missing", async () => { - const res = await getSubscriptionStatusHandler(buildRequest("")); - expect(res.status).toBe(400); - await expect(res.json()).resolves.toEqual({ message: "accountId is required" }); + it("returns the auth-error response unchanged when auth fails", async () => { + const err = NextResponse.json({ message: "unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(err); + + const res = await getSubscriptionStatusHandler(buildRequest()); + expect(res).toBe(err); expect(getActiveSubscriptionDetails).not.toHaveBeenCalled(); }); it("returns isPro:true when the account has an active subscription", async () => { + mockAuthOk(); vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({} as never); vi.mocked(getOrgSubscription).mockResolvedValue(null); vi.mocked(isActiveSubscription).mockImplementation(sub => sub !== null && sub !== undefined); - const res = await getSubscriptionStatusHandler(buildRequest(`?accountId=${ACCOUNT}`)); + const res = await getSubscriptionStatusHandler(buildRequest()); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ isPro: true }); }); it("returns isPro:true when only the org has a subscription", async () => { + mockAuthOk(); vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); vi.mocked(getOrgSubscription).mockResolvedValue({} as never); vi.mocked(isActiveSubscription).mockImplementation(sub => sub !== null && sub !== undefined); - const res = await getSubscriptionStatusHandler(buildRequest(`?accountId=${ACCOUNT}`)); + const res = await getSubscriptionStatusHandler(buildRequest()); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ isPro: true }); }); it("returns isPro:false when neither the account nor its orgs have one", async () => { + mockAuthOk(); vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); vi.mocked(getOrgSubscription).mockResolvedValue(null); vi.mocked(isActiveSubscription).mockReturnValue(false); - const res = await getSubscriptionStatusHandler(buildRequest(`?accountId=${ACCOUNT}`)); + const res = await getSubscriptionStatusHandler(buildRequest()); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ isPro: false }); }); it("returns 500 with generic message when an upstream call throws", async () => { + mockAuthOk(); vi.mocked(getActiveSubscriptionDetails).mockRejectedValue(new Error("Stripe down")); vi.mocked(getOrgSubscription).mockResolvedValue(null); - const res = await getSubscriptionStatusHandler(buildRequest(`?accountId=${ACCOUNT}`)); + const res = await getSubscriptionStatusHandler(buildRequest()); expect(res.status).toBe(500); const body = await res.json(); expect(body).toEqual({ message: "Internal server error" }); diff --git a/lib/subscription/getSubscriptionStatusHandler.ts b/lib/subscription/getSubscriptionStatusHandler.ts index eb68eda6b..d01507bdf 100644 --- a/lib/subscription/getSubscriptionStatusHandler.ts +++ b/lib/subscription/getSubscriptionStatusHandler.ts @@ -1,28 +1,29 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; -import { validateSubscriptionStatusQuery } from "@/lib/subscription/validateSubscriptionStatusQuery"; export interface SubscriptionStatusResponse { isPro: boolean; } /** - * Handles GET /api/subscription/status — returns whether the account or - * any of its organizations has an active Stripe subscription. + * Handles GET /api/subscriptions/status — returns whether the + * authenticated account or any of its organizations has an active + * Stripe subscription. */ export async function getSubscriptionStatusHandler(request: NextRequest): Promise { - const validated = validateSubscriptionStatusQuery(request.nextUrl.searchParams); - if (validated instanceof NextResponse) { - return validated; + const authContext = await validateAuthContext(request, {}); + if (authContext instanceof NextResponse) { + return authContext; } try { const [accountSubscription, orgSubscription] = await Promise.all([ - getActiveSubscriptionDetails(validated.accountId), - getOrgSubscription(validated.accountId), + getActiveSubscriptionDetails(authContext.accountId), + getOrgSubscription(authContext.accountId), ]); const isPro = @@ -30,7 +31,7 @@ export async function getSubscriptionStatusHandler(request: NextRequest): Promis return NextResponse.json({ isPro }, { status: 200, headers: getCorsHeaders() }); } catch (error) { - console.error("/api/subscription/status error", error); + console.error("/api/subscriptions/status error", error); return NextResponse.json( { message: "Internal server error" }, { status: 500, headers: getCorsHeaders() }, diff --git a/lib/subscription/validateSubscriptionStatusQuery.ts b/lib/subscription/validateSubscriptionStatusQuery.ts deleted file mode 100644 index a4bb81291..000000000 --- a/lib/subscription/validateSubscriptionStatusQuery.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextResponse } from "next/server"; -import { z } from "zod"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; - -const subscriptionStatusQuerySchema = z.object({ - accountId: z.string().min(1, "accountId is required"), -}); - -export type SubscriptionStatusQuery = z.infer; - -export function validateSubscriptionStatusQuery( - searchParams: URLSearchParams, -): NextResponse | SubscriptionStatusQuery { - const accountId = searchParams.get("accountId") ?? ""; - const result = subscriptionStatusQuerySchema.safeParse({ accountId }); - if (!result.success) { - const first = result.error.issues[0]; - return NextResponse.json( - { message: first.message }, - { status: 400, headers: getCorsHeaders() }, - ); - } - return result.data; -} From 2b033c6f4bdfa6a2c3ccc0db0222145639262f32 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 1 May 2026 19:52:09 +0530 Subject: [PATCH 3/3] refactor(api): rename to GET /api/subscription (singular, drop /status) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint is the singleton "current subscription status for the authenticated account" — no resource collection makes sense here, so a singular path without a sub-resource verb reads better than /api/subscriptions/status. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/{subscriptions/status => subscription}/route.ts | 2 +- .../__tests__/getSubscriptionStatusHandler.test.ts | 2 +- lib/subscription/getSubscriptionStatusHandler.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) rename app/api/{subscriptions/status => subscription}/route.ts (96%) diff --git a/app/api/subscriptions/status/route.ts b/app/api/subscription/route.ts similarity index 96% rename from app/api/subscriptions/status/route.ts rename to app/api/subscription/route.ts index 3faf71008..96709191a 100644 --- a/app/api/subscriptions/status/route.ts +++ b/app/api/subscription/route.ts @@ -15,7 +15,7 @@ export async function OPTIONS() { } /** - * GET /api/subscriptions/status + * GET /api/subscription * * Returns whether the authenticated account is on a pro Stripe * subscription (account or any of its organizations). Auth: API key or diff --git a/lib/subscription/__tests__/getSubscriptionStatusHandler.test.ts b/lib/subscription/__tests__/getSubscriptionStatusHandler.test.ts index a5304bb42..e7bbd2424 100644 --- a/lib/subscription/__tests__/getSubscriptionStatusHandler.test.ts +++ b/lib/subscription/__tests__/getSubscriptionStatusHandler.test.ts @@ -28,7 +28,7 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ const ACCOUNT = "11111111-2222-3333-4444-555555555555"; -const buildRequest = () => new NextRequest("http://localhost/api/subscriptions/status"); +const buildRequest = () => new NextRequest("http://localhost/api/subscription"); const mockAuthOk = () => vi.mocked(validateAuthContext).mockResolvedValue({ diff --git a/lib/subscription/getSubscriptionStatusHandler.ts b/lib/subscription/getSubscriptionStatusHandler.ts index d01507bdf..947489c00 100644 --- a/lib/subscription/getSubscriptionStatusHandler.ts +++ b/lib/subscription/getSubscriptionStatusHandler.ts @@ -10,9 +10,9 @@ export interface SubscriptionStatusResponse { } /** - * Handles GET /api/subscriptions/status — returns whether the - * authenticated account or any of its organizations has an active - * Stripe subscription. + * Handles GET /api/subscription — returns whether the authenticated + * account or any of its organizations has an active Stripe + * subscription. */ export async function getSubscriptionStatusHandler(request: NextRequest): Promise { const authContext = await validateAuthContext(request, {}); @@ -31,7 +31,7 @@ export async function getSubscriptionStatusHandler(request: NextRequest): Promis return NextResponse.json({ isPro }, { status: 200, headers: getCorsHeaders() }); } catch (error) { - console.error("/api/subscriptions/status error", error); + console.error("/api/subscription error", error); return NextResponse.json( { message: "Internal server error" }, { status: 500, headers: getCorsHeaders() },