Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 34 additions & 0 deletions app/api/accounts/[id]/subscription/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getSubscriptionsHandler } from "@/lib/subscriptions/getSubscriptionsHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A NextResponse with CORS headers.
*/
export async function OPTIONS() {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Module should export a single primary function whose name matches the filename

Module exports multiple top-level functions and none matches the route.ts basename, violating the single-primary-export naming rule.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/accounts/[id]/subscription/route.ts, line 10:

<comment>Module exports multiple top-level functions and none matches the `route.ts` basename, violating the single-primary-export naming rule.</comment>

<file context>
@@ -0,0 +1,34 @@
+ *
+ * @returns A NextResponse with CORS headers.
+ */
+export async function OPTIONS() {
+  return new NextResponse(null, {
+    status: 200,
</file context>
Fix with Cubic

return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* GET /api/accounts/{id}/subscription
*
* Returns the account's active Stripe subscription, or a short-circuit
* `{ isEnterprise: true }` for enterprise-domain accounts.
*
* @param request - The request object
* @param options - Route options containing params
* @param options.params - Route params containing the account ID
* @returns A NextResponse with `{ status, subscription }` or `{ status, isEnterprise }`
*/
export async function GET(request: NextRequest, options: { params: Promise<{ id: string }> }) {
return getSubscriptionsHandler(request, options.params);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
18 changes: 18 additions & 0 deletions lib/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,21 @@ export * from "./const";

// Additional constants needed for evals that are specific to Recoup-Chat
export const NEW_API_BASE_URL = "https://recoup-api.vercel.app";

/**
* Email domains that grant enterprise-tier access.
*
* Used by `isEnterprise()` to short-circuit the Stripe lookup in
* `GET /api/accounts/{id}/subscription`: accounts whose emails match any
* of these domains are treated as paid without needing an active Stripe
* subscription. Ported verbatim from the legacy Express service to
* preserve behaviour.
*/
export const ENTERPRISE_DOMAINS: ReadonlySet<string> = new Set([
"recoupable.com",
"rostrum.com",
"spaceheatermusic.io",
"fatbeats.com",
"cantorarecords.net",
"rostrumrecords.com",
]);
15 changes: 15 additions & 0 deletions lib/enterprise/isEnterprise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ENTERPRISE_DOMAINS } from "@/lib/consts";
import { extractDomain } from "@/lib/email/extractDomain";

/**
* Returns true when the email's domain is on the enterprise allow-list.
*
* Enterprise-tagged accounts bypass the Stripe active-subscription check
* in `GET /api/accounts/{id}/subscription`.
*/
export function isEnterprise(email: string): boolean {
if (!email) return false;
const domain = extractDomain(email);
if (!domain) return false;
return ENTERPRISE_DOMAINS.has(domain);
}
25 changes: 25 additions & 0 deletions lib/stripe/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Stripe from "stripe";

/**
* Pinned Stripe API version. Matches the default version shipped with
* `stripe@17.x` (the version used by `mono/chat`), so this service and
* the chat app talk to the same Stripe schema.
*/
const STRIPE_API_VERSION = "2024-10-28.acacia" as const;

const stripeSecretKey = process.env.STRIPE_SECRET_KEY ?? process.env.STRIPE_SK;

// Fail-closed in production: a missing secret means every subscription
// lookup would silently 500 at first Stripe call. Outside production we
// let module-load succeed so tests and local builds don't break.
if (!stripeSecretKey && process.env.NODE_ENV === "production") {
throw new Error(
"Stripe secret key is not configured. Set STRIPE_SECRET_KEY (preferred) or STRIPE_SK.",
);
}

const stripeClient = new Stripe(stripeSecretKey ?? "sk_test_unset", {
apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion,
});

export default stripeClient;
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Module should export a single primary function whose name matches the filename

client.ts exports a default object instance instead of a primary function named to match the filename (client).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/stripe/client.ts, line 25:

<comment>`client.ts` exports a default object instance instead of a primary function named to match the filename (`client`).</comment>

<file context>
@@ -0,0 +1,25 @@
+  apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion,
+});
+
+export default stripeClient;
</file context>
Fix with Cubic

86 changes: 86 additions & 0 deletions lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom agent: Enforce Clear Code Style and Maintainability Practices

New file exceeds the 100-line file-size limit required by Rule 3.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts, line 1:

<comment>New file exceeds the 100-line file-size limit required by Rule 3.</comment>

<file context>
@@ -0,0 +1,120 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest, NextResponse } from "next/server";
+
</file context>
Fix with Cubic

import { NextRequest, NextResponse } from "next/server";

import { getSubscriptionsHandler } from "../getSubscriptionsHandler";
import { validateGetSubscriptionRequest } from "../validateGetSubscriptionRequest";
import { getAccountSubscription } from "../getAccountSubscription";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

vi.mock("../validateGetSubscriptionRequest", () => ({
validateGetSubscriptionRequest: vi.fn(),
}));

vi.mock("../getAccountSubscription", () => ({
getAccountSubscription: vi.fn(),
}));

const accountId = "550e8400-e29b-41d4-a716-446655440000";
const validated = { account_id: accountId };
const makeRequest = () =>
new NextRequest(`http://localhost/api/accounts/${accountId}/subscription`, { method: "GET" });

describe("getSubscriptionsHandler", () => {
beforeEach(() => vi.clearAllMocks());

it("returns the validator error when validation fails", async () => {
const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 });
vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(err);

const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId }));

expect(res).toBe(err);
expect(getAccountSubscription).not.toHaveBeenCalled();
});

it("returns 200 isEnterprise for enterprise accounts", async () => {
vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated);
vi.mocked(getAccountSubscription).mockResolvedValue({ isEnterprise: true });

const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId }));

expect(res.status).toBe(200);
expect(await res.json()).toEqual({ status: "success", isEnterprise: true });
});

it("returns 200 with the subscription for a paid account", async () => {
const subscription = { id: "sub_123", metadata: { accountId } } as never;
vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated);
vi.mocked(getAccountSubscription).mockResolvedValue({ subscription });

const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId }));

expect(res.status).toBe(200);
expect(await res.json()).toEqual({
status: "success",
subscription: { id: "sub_123", metadata: { accountId } },
});
expect(getAccountSubscription).toHaveBeenCalledWith(accountId);
});

it("returns 404 when no active subscription exists", async () => {
vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated);
vi.mocked(getAccountSubscription).mockResolvedValue(null);

const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId }));

expect(res.status).toBe(404);
expect(await res.json()).toEqual({ status: "error", error: "No active subscription found" });
});

it("returns 500 generic error and never leaks the raw exception message", async () => {
vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated);
vi.mocked(getAccountSubscription).mockImplementation(() => {
throw new Error("db down: connection refused at 10.0.0.1:5432");
});

const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId }));
const body = await res.json();

expect(res.status).toBe(500);
expect(body).toEqual({ status: "error", error: "Internal server error" });
expect(body.error).not.toContain("db down");
});
});
111 changes: 111 additions & 0 deletions lib/subscriptions/__tests__/validateGetSubscriptionRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom agent: Enforce Clear Code Style and Maintainability Practices

File exceeds the repository’s 100-line maintainability cap.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/subscriptions/__tests__/validateGetSubscriptionRequest.test.ts, line 1:

<comment>File exceeds the repository’s 100-line maintainability cap.</comment>

<file context>
@@ -0,0 +1,125 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest, NextResponse } from "next/server";
+
</file context>
Fix with Cubic

import { NextRequest, NextResponse } from "next/server";

import { validateGetSubscriptionRequest } from "../validateGetSubscriptionRequest";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts";
import { canAccessAccount } from "@/lib/organizations/canAccessAccount";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));

vi.mock("@/lib/supabase/accounts/selectAccounts", () => ({
selectAccounts: vi.fn(),
}));

vi.mock("@/lib/organizations/canAccessAccount", () => ({
canAccessAccount: vi.fn(),
}));

const accountId = "550e8400-e29b-41d4-a716-446655440000";
const requesterId = "660e8400-e29b-41d4-a716-446655440000";
const authOk = { accountId: requesterId, authToken: "t", orgId: null };
const makeRequest = (id = accountId) =>
new NextRequest(`http://localhost/api/accounts/${id}/subscription`, {
method: "GET",
headers: { Authorization: "Bearer test-token" },
});

describe("validateGetSubscriptionRequest", () => {
beforeEach(() => vi.clearAllMocks());

it.each([
["not-a-uuid", "non-UUID id"],
["", "empty id"],
])("returns 400 for %s (%s)", async id => {
vi.mocked(validateAuthContext).mockResolvedValue(authOk);

const result = await validateGetSubscriptionRequest(makeRequest(), id);

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
expect(selectAccounts).not.toHaveBeenCalled();
});

it("returns the auth error when authentication fails", async () => {
const authError = NextResponse.json({ error: "Unauthorized" }, { status: 401 });
vi.mocked(validateAuthContext).mockResolvedValue(authError);

const result = await validateGetSubscriptionRequest(makeRequest(), accountId);

expect(result).toBe(authError);
expect(selectAccounts).not.toHaveBeenCalled();
});

it("returns 404 when the account does not exist", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(authOk);
vi.mocked(selectAccounts).mockResolvedValue([]);

const result = await validateGetSubscriptionRequest(makeRequest(), accountId);

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(404);
expect(canAccessAccount).not.toHaveBeenCalled();
});

it("returns 403 when the requester cannot access the account", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(authOk);
vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never);
vi.mocked(canAccessAccount).mockResolvedValue(false);

const result = await validateGetSubscriptionRequest(makeRequest(), accountId);

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(403);
// Regression: access check must compare the CALLER's account id against
// the target path id — never the path id against itself.
expect(canAccessAccount).toHaveBeenCalledWith({
currentAccountId: requesterId,
targetAccountId: accountId,
});
});

it("does not pass the target accountId as an override to validateAuthContext", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(authOk);
vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never);
vi.mocked(canAccessAccount).mockResolvedValue(true);

await validateGetSubscriptionRequest(makeRequest(), accountId);

// Regression: passing `{ accountId: id }` here would rewrite
// authResult.accountId to the target, collapsing the access check into
// a self-check that always returns true.
const call = vi.mocked(validateAuthContext).mock.calls[0];
expect(call[1]).toBeUndefined();
});

it("returns the validated account_id on success", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(authOk);
vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never);
vi.mocked(canAccessAccount).mockResolvedValue(true);

const result = await validateGetSubscriptionRequest(makeRequest(), accountId);

expect(result).toEqual({ account_id: accountId });
});
});
37 changes: 37 additions & 0 deletions lib/subscriptions/getAccountSubscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Stripe from "stripe";
import stripeClient from "@/lib/stripe/client";
import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails";
import { isEnterprise } from "@/lib/enterprise/isEnterprise";

export type AccountSubscription =
| { isEnterprise: true }
| { subscription: Stripe.Subscription }
| null;

/**
* Resolves an account's subscription state: enterprise (via email domain),
* an active Stripe subscription (`metadata.accountId` match), or null.
*
* Uses `stripe.subscriptions.search` so the metadata + status filter runs on
* Stripe's side — scales independently of total subscription count (a prior
* `list({ limit: 100 })` + client-side filter was lossy past 100). Search is
* eventually consistent (~1 min lag after writes).
*/
export async function getAccountSubscription(accountId: string): Promise<AccountSubscription> {
try {
const emails = await selectAccountEmails({ accountIds: accountId });
if (emails.some(record => isEnterprise(record.email || ""))) {
return { isEnterprise: true };
}

const result = await stripeClient.subscriptions.search({
query: `status:"active" AND metadata["accountId"]:"${accountId}"`,
limit: 1,
});
const subscription = result.data[0];
return subscription ? { subscription } : null;
} catch (error) {
console.error("[ERROR] getAccountSubscription:", error);
return null;
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Do not return null from the catch block here; it causes backend failures to be reported as 404 "No active subscription found" instead of a 500 error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/subscriptions/getAccountSubscription.ts, line 35:

<comment>Do not return `null` from the catch block here; it causes backend failures to be reported as 404 "No active subscription found" instead of a 500 error.</comment>

<file context>
@@ -0,0 +1,37 @@
+    return subscription ? { subscription } : null;
+  } catch (error) {
+    console.error("[ERROR] getAccountSubscription:", error);
+    return null;
+  }
+}
</file context>
Fix with Cubic

}
}
34 changes: 34 additions & 0 deletions lib/subscriptions/getSubscriptionsHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { errorResponse } from "@/lib/networking/errorResponse";
import { validateGetSubscriptionRequest } from "@/lib/subscriptions/validateGetSubscriptionRequest";
import { getAccountSubscription } from "@/lib/subscriptions/getAccountSubscription";

/**
* Handler for GET /api/accounts/{id}/subscription. When the account is a paid
* Stripe customer, `subscription` is a raw `Stripe.Subscription` — its keys
* stay camelCase by design (third-party typed payload), despite the rest of
* the API being snake_case.
*/
export async function getSubscriptionsHandler(
request: NextRequest,
params: Promise<{ id: string }>,
): Promise<NextResponse> {
try {
const { id } = await params;

const validated = await validateGetSubscriptionRequest(request, id);
if (validated instanceof NextResponse) return validated;

const result = await getAccountSubscription(validated.account_id);
if (!result) return errorResponse("No active subscription found", 404);

return NextResponse.json(
{ status: "success", ...result },
{ status: 200, headers: getCorsHeaders() },
);
} catch (error) {
console.error("[ERROR] getSubscriptionsHandler:", error);
return errorResponse("Internal server error", 500);
}
}
Loading
Loading