-
Notifications
You must be signed in to change notification settings - Fork 9
feat(api): add GET /api/subscriptions/status #506
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
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
9b0964c
chore(sandbox): merge updates from main and align with @vercel/sandbo…
ahmednahima0-beep 05774b9
feat(stripe): enhance isActiveSubscription logic and add tests
ahmednahima0-beep 536fa9d
feat(stripe): improve getActiveSubscriptions to handle pagination and…
ahmednahima0-beep 97abf90
refactor(stripe): optimize getOrgSubscription to return first active …
ahmednahima0-beep 202fa58
test(subscriptions): enhance route tests for GET and OPTIONS handlers
ahmednahima0-beep f43aab7
feat(stripe): export querySchema and simplify request validation
ahmednahima0-beep 2be3abd
refactor(stripe): rename validation function and remove deprecated re…
ahmednahima0-beep 7890132
feat(stripe): enhance getActiveSubscriptions to limit pagination and …
ahmednahima0-beep 5bdd3a8
refactor(stripe): remove pagination limit in getActiveSubscriptions f…
ahmednahima0-beep ccf722c
refactor(tests): streamline getActiveSubscriptions tests and improve …
ahmednahima0-beep a9075a5
refactor(tests): restructure getActiveSubscriptions test helpers for …
ahmednahima0-beep 37032cf
refactor(api): move subscription status to GET /api/accounts/{id}/sub…
sweetmantech 8786078
Merge remote-tracking branch 'origin/test' into feature/api-subscript…
sweetmantech c1ebf26
refactor(stripe): drop unused stripeCustomerId; extract toStatus to i…
sweetmantech File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
46 changes: 46 additions & 0 deletions
46
app/api/accounts/[id]/subscription/__tests__/route.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import "./routeTestMocks"; | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||
| import { getAccountSubscriptionHandler } from "@/lib/stripe/getAccountSubscriptionHandler"; | ||
|
|
||
| const { GET, OPTIONS } = await import("../route"); | ||
|
|
||
| const ACCOUNT_ID = "123e4567-e89b-12d3-a456-426614174000"; | ||
|
|
||
| describe("app/api/accounts/[id]/subscription/route", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it("OPTIONS returns 200 with CORS headers", async () => { | ||
| const res = await OPTIONS(); | ||
| expect(res.status).toBe(200); | ||
| expect(getCorsHeaders).toHaveBeenCalled(); | ||
| expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); | ||
| }); | ||
|
|
||
| it("GET delegates to getAccountSubscriptionHandler with the path params", async () => { | ||
| const handlerRes = NextResponse.json( | ||
| { isPro: true, status: "active", plan: "pro", source: "account" }, | ||
| { status: 200 }, | ||
| ); | ||
| vi.mocked(getAccountSubscriptionHandler).mockResolvedValue(handlerRes); | ||
|
|
||
| const req = new NextRequest(`http://localhost/api/accounts/${ACCOUNT_ID}/subscription`, { | ||
| headers: { "x-api-key": "test-key" }, | ||
| }); | ||
| const params = Promise.resolve({ id: ACCOUNT_ID }); | ||
| const res = await GET(req, { params }); | ||
|
|
||
| expect(getAccountSubscriptionHandler).toHaveBeenCalledTimes(1); | ||
| expect(getAccountSubscriptionHandler).toHaveBeenCalledWith(req, params); | ||
| expect(res).toBe(handlerRes); | ||
| await expect(res.json()).resolves.toEqual({ | ||
| isPro: true, | ||
| status: "active", | ||
| plan: "pro", | ||
| source: "account", | ||
| }); | ||
| }); | ||
| }); |
9 changes: 9 additions & 0 deletions
9
app/api/accounts/[id]/subscription/__tests__/routeTestMocks.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { vi } from "vitest"; | ||
|
|
||
| vi.mock("@/lib/networking/getCorsHeaders", () => ({ | ||
| getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), | ||
| })); | ||
|
|
||
| vi.mock("@/lib/stripe/getAccountSubscriptionHandler", () => ({ | ||
| getAccountSubscriptionHandler: vi.fn(), | ||
| })); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||
| import { getAccountSubscriptionHandler } from "@/lib/stripe/getAccountSubscriptionHandler"; | ||
|
|
||
| /** | ||
| * OPTIONS handler for CORS preflight requests. | ||
| * | ||
| * @returns A 200 NextResponse carrying the CORS headers. | ||
| */ | ||
| export async function OPTIONS() { | ||
| return new NextResponse(null, { | ||
| status: 200, | ||
| headers: getCorsHeaders(), | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * GET /api/accounts/[id]/subscription | ||
| * | ||
| * Returns the subscription resource for an account, including coverage via organization | ||
| * membership. Requires authentication via `x-api-key` or `Authorization: Bearer`; the caller | ||
| * must be the account itself or have access via organization membership. | ||
| * | ||
| * @param request - Incoming request; auth is read from headers. | ||
| * @param context - Route context from Next.js. | ||
| * @param context.params - Promise resolving to `{ id }`, the account UUID from the URL path. | ||
| * @returns A 200 NextResponse with `{ isPro, status, plan, source }`, or 4xx with `{ error }`. | ||
| */ | ||
| export async function GET(request: NextRequest, context: { params: Promise<{ id: string }> }) { | ||
| return getAccountSubscriptionHandler(request, context.params); | ||
| } | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
| export const fetchCache = "force-no-store"; | ||
| export const revalidate = 0; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| import type Stripe from "stripe"; | ||
| import { buildSubscriptionResponse } from "@/lib/stripe/buildSubscriptionResponse"; | ||
|
|
||
| const activeSub = (status: Stripe.Subscription.Status = "active") => | ||
| ({ status, canceled_at: null }) as unknown as Stripe.Subscription; | ||
|
|
||
| describe("buildSubscriptionResponse", () => { | ||
| it("returns isPro:false / none / null / null when neither subscription is active", () => { | ||
| expect(buildSubscriptionResponse({ account: null, organization: null })).toEqual({ | ||
| isPro: false, | ||
| status: "none", | ||
| plan: null, | ||
| source: null, | ||
| }); | ||
| }); | ||
|
|
||
| it("prefers the account subscription when active", () => { | ||
| expect( | ||
| buildSubscriptionResponse({ | ||
| account: activeSub("active"), | ||
| organization: activeSub("trialing"), | ||
| }), | ||
| ).toEqual({ | ||
| isPro: true, | ||
| status: "active", | ||
| plan: "pro", | ||
| source: "account", | ||
| }); | ||
| }); | ||
|
|
||
| it("falls back to the organization subscription when only org is active", () => { | ||
| expect( | ||
| buildSubscriptionResponse({ | ||
| account: null, | ||
| organization: activeSub("trialing"), | ||
| }), | ||
| ).toEqual({ | ||
| isPro: true, | ||
| status: "trialing", | ||
| plan: "pro", | ||
| source: "organization", | ||
| }); | ||
| }); | ||
|
|
||
| it("treats trialing-with-canceled_at as inactive", () => { | ||
| const canceledTrial = { | ||
| status: "trialing", | ||
| canceled_at: 1700000000, | ||
| } as unknown as Stripe.Subscription; | ||
|
|
||
| expect(buildSubscriptionResponse({ account: canceledTrial, organization: null })).toEqual({ | ||
| isPro: false, | ||
| status: "none", | ||
| plan: null, | ||
| source: null, | ||
| }); | ||
| }); | ||
|
|
||
| it("normalizes unsupported Stripe statuses to 'none' when somehow active", () => { | ||
| const weird = { status: "incomplete", canceled_at: null } as unknown as Stripe.Subscription; | ||
| expect(buildSubscriptionResponse({ account: weird, organization: null })).toEqual({ | ||
| isPro: false, | ||
| status: "none", | ||
| plan: null, | ||
| source: null, | ||
| }); | ||
| }); | ||
| }); |
98 changes: 98 additions & 0 deletions
98
lib/stripe/__tests__/getAccountSubscriptionHandler.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { getAccountSubscriptionHandler } from "@/lib/stripe/getAccountSubscriptionHandler"; | ||
|
|
||
| import { validateAccountSubscriptionParams } from "@/lib/stripe/validateAccountSubscriptionParams"; | ||
| import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; | ||
| import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; | ||
|
|
||
| vi.mock("@/lib/networking/getCorsHeaders", () => ({ | ||
| getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), | ||
| })); | ||
|
|
||
| vi.mock("@/lib/stripe/validateAccountSubscriptionParams", () => ({ | ||
| validateAccountSubscriptionParams: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ | ||
| getActiveSubscriptionDetails: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock("@/lib/stripe/getOrgSubscription", () => ({ | ||
| getOrgSubscription: vi.fn(), | ||
| })); | ||
|
|
||
| const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; | ||
|
|
||
| const buildRequest = () => new NextRequest(`http://localhost/api/accounts/${ACCOUNT}/subscription`); | ||
|
|
||
| const buildParams = () => Promise.resolve({ id: ACCOUNT }); | ||
|
|
||
| describe("getAccountSubscriptionHandler", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it("forwards validation/auth errors as { error } with original status", async () => { | ||
| const denial = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); | ||
| vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(denial); | ||
|
|
||
| const res = await getAccountSubscriptionHandler(buildRequest(), buildParams()); | ||
| expect(res.status).toBe(401); | ||
| await expect(res.json()).resolves.toEqual({ error: "Unauthorized" }); | ||
| expect(getActiveSubscriptionDetails).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("returns the resource shape for an active account subscription", async () => { | ||
| vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(ACCOUNT); | ||
| vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({ | ||
| id: "sub_1", | ||
| status: "active", | ||
| canceled_at: null, | ||
| } as never); | ||
| vi.mocked(getOrgSubscription).mockResolvedValue(null); | ||
|
|
||
| const res = await getAccountSubscriptionHandler(buildRequest(), buildParams()); | ||
| expect(res.status).toBe(200); | ||
| await expect(res.json()).resolves.toEqual({ | ||
| isPro: true, | ||
| status: "active", | ||
| plan: "pro", | ||
| source: "account", | ||
| }); | ||
| }); | ||
|
|
||
| it("returns source: organization when only the org subscription is active", async () => { | ||
| vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(ACCOUNT); | ||
| vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); | ||
| vi.mocked(getOrgSubscription).mockResolvedValue({ | ||
| id: "sub_org", | ||
| status: "trialing", | ||
| canceled_at: null, | ||
| } as never); | ||
|
|
||
| const res = await getAccountSubscriptionHandler(buildRequest(), buildParams()); | ||
| expect(res.status).toBe(200); | ||
| await expect(res.json()).resolves.toEqual({ | ||
| isPro: true, | ||
| status: "trialing", | ||
| plan: "pro", | ||
| source: "organization", | ||
| }); | ||
| }); | ||
|
|
||
| it("returns isPro:false / none / null when neither subscription is active", async () => { | ||
| vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(ACCOUNT); | ||
| vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); | ||
| vi.mocked(getOrgSubscription).mockResolvedValue(null); | ||
|
|
||
| const res = await getAccountSubscriptionHandler(buildRequest(), buildParams()); | ||
| expect(res.status).toBe(200); | ||
| await expect(res.json()).resolves.toEqual({ | ||
| isPro: false, | ||
| status: "none", | ||
| plan: null, | ||
| source: null, | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; | ||
| import stripeClient from "@/lib/stripe/client"; | ||
| import { getActiveSubscriptionsTestHelpers } from "./getActiveSubscriptionsTestHelpers"; | ||
|
|
||
| vi.mock("@/lib/stripe/client", () => ({ | ||
| default: { subscriptions: { list: vi.fn() } }, | ||
| })); | ||
|
|
||
| const { | ||
| testAccountId: ACC, | ||
| subscription: sub, | ||
| subscriptionListPage: apiList, | ||
| } = getActiveSubscriptionsTestHelpers(); | ||
| const list = () => vi.mocked(stripeClient.subscriptions.list); | ||
|
|
||
| describe("getActiveSubscriptions", () => { | ||
| beforeEach(() => vi.clearAllMocks()); | ||
|
|
||
| it("walks pages until a batch matches accountId", async () => { | ||
| list() | ||
| .mockResolvedValueOnce(apiList([sub("sub_x", "other")], true)) | ||
| .mockResolvedValueOnce(apiList([sub("sub_1", ACC)], true)); | ||
| const result = await getActiveSubscriptions(ACC); | ||
| expect(list()).toHaveBeenCalledTimes(2); | ||
| expect(result.map(s => s.id)).toEqual(["sub_1"]); | ||
| expect(list().mock.calls[1][0]).toMatchObject({ starting_after: "sub_x", limit: 100 }); | ||
| }); | ||
|
|
||
| it("finds a match on a later page (no artificial page limit)", async () => { | ||
| for (let i = 0; i < 52; i++) | ||
| list().mockResolvedValueOnce(apiList([sub(`sub_${i}`, "other")], true)); | ||
| list().mockResolvedValueOnce(apiList([sub("sub_late", ACC)], false)); | ||
| const result = await getActiveSubscriptions(ACC); | ||
| expect(result.map(s => s.id)).toEqual(["sub_late"]); | ||
| expect(list()).toHaveBeenCalledTimes(53); | ||
| }); | ||
|
|
||
| it("stops after the first page that includes a match", async () => { | ||
| list().mockResolvedValueOnce( | ||
| apiList([sub("sub_x", "other"), sub("sub_1", ACC), sub("sub_2", ACC)], true), | ||
| ); | ||
| const result = await getActiveSubscriptions(ACC); | ||
| expect(list()).toHaveBeenCalledTimes(1); | ||
| expect(result.map(s => s.id)).toEqual(["sub_1", "sub_2"]); | ||
| }); | ||
|
|
||
| it("returns [] when nothing matches after Stripe exhausts pages", async () => { | ||
| for (let i = 0; i < 3; i++) { | ||
| list().mockResolvedValueOnce(apiList([sub(`sub_${i}`, "other")], i < 2)); | ||
| } | ||
| const result = await getActiveSubscriptions(ACC); | ||
| expect(result).toEqual([]); | ||
| expect(list()).toHaveBeenCalledTimes(3); | ||
| }); | ||
|
|
||
| it("breaks if pagination cursor does not advance", async () => { | ||
| const s = sub("sub_stuck", "other"); | ||
| list() | ||
| .mockResolvedValueOnce(apiList([s], true)) | ||
| .mockResolvedValueOnce(apiList([s], true)); | ||
| const result = await getActiveSubscriptions(ACC); | ||
| expect(result).toEqual([]); | ||
| expect(list()).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it("returns [] when Stripe throws", async () => { | ||
| list().mockRejectedValue(new Error("stripe error")); | ||
| await expect(getActiveSubscriptions(ACC)).resolves.toEqual([]); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import type Stripe from "stripe"; | ||
|
|
||
| export function getActiveSubscriptionsTestHelpers() { | ||
| const testAccountId = "acc-a"; | ||
|
|
||
| function subscription(id: string, accountId: string): Stripe.Subscription { | ||
| return { id, metadata: { accountId } } as Stripe.Subscription; | ||
| } | ||
|
|
||
| function subscriptionListPage( | ||
| data: Stripe.Subscription[], | ||
| hasMore: boolean, | ||
| ): Stripe.Response<Stripe.ApiList<Stripe.Subscription>> { | ||
| return { data, has_more: hasMore } as Stripe.Response<Stripe.ApiList<Stripe.Subscription>>; | ||
| } | ||
|
|
||
| return { testAccountId, subscription, subscriptionListPage }; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.