Skip to content
Merged
Show file tree
Hide file tree
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 May 3, 2026
05774b9
feat(stripe): enhance isActiveSubscription logic and add tests
ahmednahima0-beep May 3, 2026
536fa9d
feat(stripe): improve getActiveSubscriptions to handle pagination and…
ahmednahima0-beep May 3, 2026
97abf90
refactor(stripe): optimize getOrgSubscription to return first active …
ahmednahima0-beep May 3, 2026
202fa58
test(subscriptions): enhance route tests for GET and OPTIONS handlers
ahmednahima0-beep May 3, 2026
f43aab7
feat(stripe): export querySchema and simplify request validation
ahmednahima0-beep May 3, 2026
2be3abd
refactor(stripe): rename validation function and remove deprecated re…
ahmednahima0-beep May 3, 2026
7890132
feat(stripe): enhance getActiveSubscriptions to limit pagination and …
ahmednahima0-beep May 3, 2026
5bdd3a8
refactor(stripe): remove pagination limit in getActiveSubscriptions f…
ahmednahima0-beep May 3, 2026
ccf722c
refactor(tests): streamline getActiveSubscriptions tests and improve …
ahmednahima0-beep May 3, 2026
a9075a5
refactor(tests): restructure getActiveSubscriptions test helpers for …
ahmednahima0-beep May 3, 2026
37032cf
refactor(api): move subscription status to GET /api/accounts/{id}/sub…
sweetmantech May 6, 2026
8786078
Merge remote-tracking branch 'origin/test' into feature/api-subscript…
sweetmantech May 6, 2026
c1ebf26
refactor(stripe): drop unused stripeCustomerId; extract toStatus to i…
sweetmantech May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions app/api/accounts/[id]/subscription/__tests__/route.test.ts
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",
});
});
});
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(),
}));
35 changes: 35 additions & 0 deletions app/api/accounts/[id]/subscription/route.ts
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;
69 changes: 69 additions & 0 deletions lib/stripe/__tests__/buildSubscriptionResponse.test.ts
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 lib/stripe/__tests__/getAccountSubscriptionHandler.test.ts
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,
});
});
});
71 changes: 71 additions & 0 deletions lib/stripe/__tests__/getActiveSubscriptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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([]);
});
});
18 changes: 18 additions & 0 deletions lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts
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 };
}
Loading
Loading