Skip to content
Open
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
33 changes: 33 additions & 0 deletions app/api/credits/route.ts
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;
33 changes: 33 additions & 0 deletions app/api/subscription/route.ts
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;
88 changes: 88 additions & 0 deletions lib/credits/__tests__/getCreditsHandler.test.ts
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");
});
});
57 changes: 57 additions & 0 deletions lib/credits/checkAndResetCredits.ts
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(),
},
});
};
27 changes: 27 additions & 0 deletions lib/credits/getCreditsHandler.ts
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() },
);
}
}
18 changes: 18 additions & 0 deletions lib/stripe/getActiveSubscriptionDetails.ts
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 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This try/catch is redundant because getActiveSubscriptions already catches and suppresses errors, so the local catch branch is effectively unreachable.

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

<comment>This `try/catch` is redundant because `getActiveSubscriptions` already catches and suppresses errors, so the local `catch` branch is effectively unreachable.</comment>

<file context>
@@ -0,0 +1,18 @@
+export const getActiveSubscriptionDetails = async (
+  accountId: string,
+): Promise<Stripe.Subscription | null> => {
+  try {
+    const activeSubscriptions = await getActiveSubscriptions(accountId);
+    return activeSubscriptions.length > 0 ? activeSubscriptions[0] : null;
</file context>

const activeSubscriptions = await getActiveSubscriptions(accountId);
return activeSubscriptions.length > 0 ? activeSubscriptions[0] : null;
} catch (error) {
console.error("Error fetching subscription:", error);
return null;
}
};
27 changes: 27 additions & 0 deletions lib/stripe/getActiveSubscriptions.ts
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: This only reads the first Stripe page (limit: 100) before filtering by account, so accounts can be reported as unsubscribed when their subscription is on a later page.

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

<comment>This only reads the first Stripe page (`limit: 100`) before filtering by account, so accounts can be reported as unsubscribed when their subscription is on a later page.</comment>

<file context>
@@ -0,0 +1,27 @@
+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),
</file context>
Suggested change
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,
);
let subscriptions: Stripe.Subscription[] = [];
let startingAfter: string | undefined;
do {
const page = await stripeClient.subscriptions.list({
limit: 100,
current_period_end: {
gt: Math.floor(Date.now() / 1000),
},
...(startingAfter ? { starting_after: startingAfter } : {}),
});
subscriptions = subscriptions.concat(page.data);
startingAfter = page.has_more ? page.data[page.data.length - 1]?.id : undefined;
} while (startingAfter);
const activeSubscriptions = subscriptions.filter(
(subscription: Stripe.Subscription) => subscription.metadata?.accountId === accountId,
);

return activeSubscriptions || [];
} catch (error) {
console.error("Error fetching subscriptions:", error);
return [];
}
};
22 changes: 22 additions & 0 deletions lib/stripe/getOrgSubscription.ts
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;
}
14 changes: 14 additions & 0 deletions lib/stripe/isActiveSubscription.ts
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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: isActiveSubscription can return true for subscriptions with status === "canceled", which misclassifies canceled subscriptions as active.

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

<comment>`isActiveSubscription` can return `true` for subscriptions with `status === "canceled"`, which misclassifies canceled subscriptions as active.</comment>

<file context>
@@ -0,0 +1,14 @@
+  if (!subscription) return false;
+  const isTrial = subscription?.status === "trialing";
+  const isCanceledTrial = isTrial && subscription?.canceled_at;
+  const subscriptionActive = !isCanceledTrial;
+  return subscriptionActive;
+};
</file context>

return subscriptionActive;
};

export default isActiveSubscription;
Loading
Loading