-
Notifications
You must be signed in to change notification settings - Fork 9
feat(api): migrate GET /api/credits/get + GET /api/subscription/status #500
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
base: test
Are you sure you want to change the base?
Changes from all commits
10213f6
1cb6139
2b033c6
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,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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ReturnType<typeof checkAndResetCredits>>, | ||
| ); | ||
|
|
||
| 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"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CreditsUsage | null> => { | ||
| 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(), | ||
| }, | ||
| }); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<NextResponse> { | ||
| 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() }, | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Stripe.Subscription | null> => { | ||
| try { | ||
| const activeSubscriptions = await getActiveSubscriptions(accountId); | ||
| return activeSubscriptions.length > 0 ? activeSubscriptions[0] : null; | ||
| } catch (error) { | ||
| console.error("Error fetching subscription:", error); | ||
| return null; | ||
| } | ||
| }; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<Stripe.Subscription[]> => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+12
to
+21
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: This only reads the first Stripe page ( Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return activeSubscriptions || []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error("Error fetching subscriptions:", error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Stripe.Subscription | null> { | ||
| 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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
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: Prompt for AI agents |
||
| return subscriptionActive; | ||
| }; | ||
|
|
||
| export default isActiveSubscription; | ||
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.
P3: This
try/catchis redundant becausegetActiveSubscriptionsalready catches and suppresses errors, so the localcatchbranch is effectively unreachable.Prompt for AI agents