diff --git a/app/api/credits/route.ts b/app/api/credits/route.ts new file mode 100644 index 000000000..e2decf44b --- /dev/null +++ b/app/api/credits/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 + * + * 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 4xx/5xx. + */ +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/route.ts b/app/api/subscription/route.ts new file mode 100644 index 000000000..96709191a --- /dev/null +++ b/app/api/subscription/route.ts @@ -0,0 +1,33 @@ +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 + * + * 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 4xx/5xx. + */ +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..943d09da2 --- /dev/null +++ b/lib/credits/__tests__/getCreditsHandler.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +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": "*" })), +})); + +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 = () => new NextRequest("http://localhost/api/credits"); + +describe("getCreditsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + afterEach(() => vi.mocked(console.error).mockRestore()); + + 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 for the authenticated account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "token", + }); + + 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()); + 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()); + 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()); + 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..750ff9572 --- /dev/null +++ b/lib/credits/getCreditsHandler.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAndResetCredits } from "@/lib/credits/checkAndResetCredits"; + +/** + * 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 authContext = await validateAuthContext(request, {}); + if (authContext instanceof NextResponse) { + return authContext; + } + + try { + const creditsUsage = await checkAndResetCredits(authContext.accountId); + return NextResponse.json({ data: creditsUsage }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + console.error("/api/credits error", error); + return NextResponse.json( + { message: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} 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..e7bbd2424 --- /dev/null +++ b/lib/subscription/__tests__/getSubscriptionStatusHandler.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +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": "*" })), +})); + +vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ + getActiveSubscriptionDetails: vi.fn(), +})); + +vi.mock("@/lib/stripe/getOrgSubscription", () => ({ + getOrgSubscription: vi.fn(), +})); + +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 = () => new NextRequest("http://localhost/api/subscription"); + +const mockAuthOk = () => + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "token", + }); + +describe("getSubscriptionStatusHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + afterEach(() => vi.mocked(console.error).mockRestore()); + + 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()); + 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()); + 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()); + 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()); + 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..947489c00 --- /dev/null +++ b/lib/subscription/getSubscriptionStatusHandler.ts @@ -0,0 +1,40 @@ +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"; + +export interface SubscriptionStatusResponse { + isPro: boolean; +} + +/** + * 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, {}); + if (authContext instanceof NextResponse) { + return authContext; + } + + try { + const [accountSubscription, orgSubscription] = await Promise.all([ + getActiveSubscriptionDetails(authContext.accountId), + getOrgSubscription(authContext.accountId), + ]); + + const isPro = + isActiveSubscription(accountSubscription) || isActiveSubscription(orgSubscription); + + return NextResponse.json({ isPro }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + console.error("/api/subscription error", error); + return NextResponse.json( + { message: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +}