Skip to content
Merged
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
56 changes: 56 additions & 0 deletions app/api/accounts/[id]/auto-recharge/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getAutoRechargeHandler } from "@/lib/billing/getAutoRechargeHandler";
import { updateAutoRechargeHandler } from "@/lib/billing/updateAutoRechargeHandler";

/**
* 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]/auto-recharge
*
* Returns whether automatic top-up is enabled for the account. `enabled: true`
* is the default for every account; `enabled: false` means the account has
* opted out and billed requests that exceed the remaining balance return 402
* with a `checkoutUrl` instead of silently charging the saved card. The
* setting lives on the account's Stripe Customer record and is read live.
*
* Requires `x-api-key` or `Authorization: Bearer`; the caller must be the
* account itself or accessible 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.
* @returns A 200 NextResponse with `{ account_id, enabled }`, or 4xx with `{ error }`.
*/
export async function GET(request: NextRequest, context: { params: Promise<{ id: string }> }) {
return getAutoRechargeHandler(request, context.params);
}

/**
* PATCH /api/accounts/[id]/auto-recharge
*
* Enables or disables automatic top-up. Body: `{ enabled: boolean }`.
* Disabling never removes the saved card — manual checkout top-ups keep
* working and re-enabling requires no card re-entry.
*
* @param request - Incoming request with the JSON body.
* @param context - Route context from Next.js.
* @param context.params - Promise resolving to `{ id }`, the account UUID.
* @returns A 200 NextResponse with `{ account_id, enabled }`, or 4xx with `{ error }`.
*/
export async function PATCH(request: NextRequest, context: { params: Promise<{ id: string }> }) {
return updateAutoRechargeHandler(request, context.params);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
78 changes: 78 additions & 0 deletions lib/billing/__tests__/getAutoRechargeHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";

const { validateParamsMock, findCustomerMock, getOptOutMock } = vi.hoisted(() => ({
validateParamsMock: vi.fn(),
findCustomerMock: vi.fn(),
getOptOutMock: vi.fn(),
}));

vi.mock("@/lib/billing/validateAutoRechargeParams", () => ({
validateAutoRechargeParams: validateParamsMock,
}));
vi.mock("@/lib/stripe/findStripeCustomerForAccount", () => ({
findStripeCustomerForAccount: findCustomerMock,
}));
vi.mock("@/lib/stripe/getAutoRechargeOptOut", () => ({
getAutoRechargeOptOut: getOptOutMock,
}));
vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

const { getAutoRechargeHandler } = await import("@/lib/billing/getAutoRechargeHandler");

const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000";

function makeRequest(): NextRequest {
return new NextRequest(`http://localhost/api/accounts/${ACCOUNT}/auto-recharge`);
}

describe("getAutoRechargeHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => undefined);
validateParamsMock.mockResolvedValue(ACCOUNT);
});

it("forwards validation error responses", async () => {
const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 });
validateParamsMock.mockResolvedValue(err);

const res = await getAutoRechargeHandler(makeRequest(), Promise.resolve({ id: ACCOUNT }));
expect(res.status).toBe(401);
});

it("returns enabled: true when the account has no Stripe customer (default, no side effects)", async () => {
findCustomerMock.mockResolvedValue(null);

const res = await getAutoRechargeHandler(makeRequest(), Promise.resolve({ id: ACCOUNT }));
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ account_id: ACCOUNT, enabled: true });
expect(getOptOutMock).not.toHaveBeenCalled();
});

it("returns enabled: false when the customer has opted out", async () => {
findCustomerMock.mockResolvedValue("cus_x");
getOptOutMock.mockResolvedValue(true);

const res = await getAutoRechargeHandler(makeRequest(), Promise.resolve({ id: ACCOUNT }));
await expect(res.json()).resolves.toEqual({ account_id: ACCOUNT, enabled: false });
expect(getOptOutMock).toHaveBeenCalledWith("cus_x");
});

it("returns enabled: true when the customer exists and has not opted out", async () => {
findCustomerMock.mockResolvedValue("cus_x");
getOptOutMock.mockResolvedValue(false);

const res = await getAutoRechargeHandler(makeRequest(), Promise.resolve({ id: ACCOUNT }));
await expect(res.json()).resolves.toEqual({ account_id: ACCOUNT, enabled: true });
});

it("returns 500 on unexpected errors", async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The 500-error test should also assert the response body to prevent leaking internal exception details. The handler correctly returns a hardcoded string, but without a body assertion, a future refactor that accidentally includes the error message in the response would go undetected. Add an assertion like await expect(res.json()).resolves.toEqual({ error: "Internal server error" }).

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

<comment>The 500-error test should also assert the response body to prevent leaking internal exception details. The handler correctly returns a hardcoded string, but without a body assertion, a future refactor that accidentally includes the error message in the response would go undetected. Add an assertion like `await expect(res.json()).resolves.toEqual({ error: "Internal server error" })`.</comment>

<file context>
@@ -0,0 +1,78 @@
+    await expect(res.json()).resolves.toEqual({ account_id: ACCOUNT, enabled: true });
+  });
+
+  it("returns 500 on unexpected errors", async () => {
+    findCustomerMock.mockRejectedValue(new Error("stripe down"));
+
</file context>

findCustomerMock.mockRejectedValue(new Error("stripe down"));

const res = await getAutoRechargeHandler(makeRequest(), Promise.resolve({ id: ACCOUNT }));
expect(res.status).toBe(500);
});
});
79 changes: 79 additions & 0 deletions lib/billing/__tests__/updateAutoRechargeHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";

const { validateBodyMock, resolveCustomerMock, setOptOutMock } = vi.hoisted(() => ({
validateBodyMock: vi.fn(),
resolveCustomerMock: vi.fn(),
setOptOutMock: vi.fn(),
}));

vi.mock("@/lib/billing/validateUpdateAutoRechargeBody", () => ({
validateUpdateAutoRechargeBody: validateBodyMock,
}));
vi.mock("@/lib/stripe/resolveStripeCustomerForAccount", () => ({
resolveStripeCustomerForAccount: resolveCustomerMock,
}));
vi.mock("@/lib/stripe/setAutoRechargeOptOut", () => ({
setAutoRechargeOptOut: setOptOutMock,
}));
vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

const { updateAutoRechargeHandler } = await import("@/lib/billing/updateAutoRechargeHandler");

const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000";

function makeRequest(): NextRequest {
return new NextRequest(`http://localhost/api/accounts/${ACCOUNT}/auto-recharge`, {
method: "PATCH",
});
}

describe("updateAutoRechargeHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => undefined);
resolveCustomerMock.mockResolvedValue("cus_x");
setOptOutMock.mockResolvedValue(undefined);
});

it("forwards validation error responses (auth, params, and body errors alike)", async () => {
const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 });
validateBodyMock.mockResolvedValue(err);

const res = await updateAutoRechargeHandler(makeRequest(), Promise.resolve({ id: ACCOUNT }));
expect(res.status).toBe(401);
expect(setOptOutMock).not.toHaveBeenCalled();
});

it("opts out: resolves the customer by accountId and stamps the flag", async () => {
validateBodyMock.mockResolvedValue({ accountId: ACCOUNT, enabled: false });

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

expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ account_id: ACCOUNT, enabled: false });
expect(validateBodyMock).toHaveBeenCalledWith(expect.any(NextRequest), ACCOUNT);
expect(resolveCustomerMock).toHaveBeenCalledWith(ACCOUNT);
expect(setOptOutMock).toHaveBeenCalledWith("cus_x", true);
});

it("opts back in: deletes the flag", async () => {
validateBodyMock.mockResolvedValue({ accountId: ACCOUNT, enabled: true });

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

expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ account_id: ACCOUNT, enabled: true });
expect(setOptOutMock).toHaveBeenCalledWith("cus_x", false);
});

it("returns 500 on unexpected errors", async () => {
validateBodyMock.mockResolvedValue({ accountId: ACCOUNT, enabled: false });
setOptOutMock.mockRejectedValue(new Error("stripe down"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: 500 test doesn't assert that internal error details are excluded from the response body. The handler logs the full error server-side and returns { error: "Internal server error" }, but the test only checks the status code. Following the team's established practice, the test should verify the response shape so a future refactor can't accidentally leak exception text.

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

<comment>500 test doesn't assert that internal error details are excluded from the response body. The handler logs the full error server-side and returns `{ error: "Internal server error" }`, but the test only checks the status code. Following the team's established practice, the test should verify the response shape so a future refactor can't accidentally leak exception text.</comment>

<file context>
@@ -0,0 +1,95 @@
+  });
+
+  it("returns 500 on unexpected errors", async () => {
+    setOptOutMock.mockRejectedValue(new Error("stripe down"));
+
+    const res = await updateAutoRechargeHandler(
</file context>


const res = await updateAutoRechargeHandler(makeRequest(), Promise.resolve({ id: ACCOUNT }));
expect(res.status).toBe(500);
});
});
71 changes: 71 additions & 0 deletions lib/billing/__tests__/validateUpdateAutoRechargeBody.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";

const { validateParamsMock } = vi.hoisted(() => ({
validateParamsMock: vi.fn(),
}));

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

const { validateUpdateAutoRechargeBody } = await import(
"@/lib/billing/validateUpdateAutoRechargeBody"
);

const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000";

function makeRequest(body: unknown): NextRequest {
return new NextRequest(`http://localhost/api/accounts/${ACCOUNT}/auto-recharge`, {
method: "PATCH",
body: typeof body === "string" ? body : JSON.stringify(body),
});
}

describe("validateUpdateAutoRechargeBody", () => {
beforeEach(() => {
vi.clearAllMocks();
validateParamsMock.mockResolvedValue(ACCOUNT);
});

it("forwards params/auth error responses without reading the body", async () => {
const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 });
validateParamsMock.mockResolvedValue(err);

const result = await validateUpdateAutoRechargeBody(makeRequest({ enabled: false }), ACCOUNT);
expect(result).toBe(err);
});

it("returns 400 when enabled is not a boolean", async () => {
const result = await validateUpdateAutoRechargeBody(makeRequest({ enabled: "nope" }), ACCOUNT);

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
await expect((result as NextResponse).json()).resolves.toEqual({
error: "enabled must be a boolean",
});
});

it("returns 400 when the body is not valid JSON (safeParseJson empty-object fallback)", async () => {
const result = await validateUpdateAutoRechargeBody(makeRequest("not json"), ACCOUNT);

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

it("returns the validated accountId and enabled on success", async () => {
const result = await validateUpdateAutoRechargeBody(makeRequest({ enabled: false }), ACCOUNT);

expect(result).toEqual({ accountId: ACCOUNT, enabled: false });
expect(validateParamsMock).toHaveBeenCalledWith(expect.any(NextRequest), ACCOUNT);
});

it("passes enabled: true through", async () => {
const result = await validateUpdateAutoRechargeBody(makeRequest({ enabled: true }), ACCOUNT);

expect(result).toEqual({ accountId: ACCOUNT, enabled: true });
});
});
42 changes: 42 additions & 0 deletions lib/billing/getAutoRechargeHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateAutoRechargeParams } from "@/lib/billing/validateAutoRechargeParams";
import { findStripeCustomerForAccount } from "@/lib/stripe/findStripeCustomerForAccount";
import { getAutoRechargeOptOut } from "@/lib/stripe/getAutoRechargeOptOut";

/**
* GET /api/accounts/[id]/auto-recharge
*
* Returns whether automatic top-up is enabled for the account. `enabled: true`
* is the default; `enabled: false` means the account's Stripe Customer carries
* the opt-out metadata flag and no off-session charge will be attempted.
*
* Read-only: an account with no Stripe Customer yet is enabled by default and
* the lookup never provisions one.
*/
export async function getAutoRechargeHandler(
request: NextRequest,
params: Promise<{ id: string }>,
): Promise<NextResponse> {
try {
const { id } = await params;
const validated = await validateAutoRechargeParams(request, id);
if (validated instanceof NextResponse) {
return validated;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Auth/validation failures can return a different error shape than this endpoint’s documented { error } contract because the handler forwards validateAutoRechargeParams responses directly. Normalizing upstream NextResponse payloads (including masking 5xx) before returning would keep client parsing and error handling consistent with sibling billing GET routes.

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

<comment>Auth/validation failures can return a different error shape than this endpoint’s documented `{ error }` contract because the handler forwards `validateAutoRechargeParams` responses directly. Normalizing upstream `NextResponse` payloads (including masking 5xx) before returning would keep client parsing and error handling consistent with sibling billing GET routes.</comment>

<file context>
@@ -0,0 +1,42 @@
+    const { id } = await params;
+    const validated = await validateAutoRechargeParams(request, id);
+    if (validated instanceof NextResponse) {
+      return validated;
+    }
+
</file context>

}

const customer = await findStripeCustomerForAccount(validated);
const optedOut = customer ? await getAutoRechargeOptOut(customer) : false;

return NextResponse.json(
{ account_id: validated, enabled: !optedOut },
{ status: 200, headers: getCorsHeaders() },
);
} catch (error) {
console.error("[getAutoRechargeHandler]", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500, headers: getCorsHeaders() },
);
}
}
45 changes: 45 additions & 0 deletions lib/billing/updateAutoRechargeHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateUpdateAutoRechargeBody } from "@/lib/billing/validateUpdateAutoRechargeBody";
import { resolveStripeCustomerForAccount } from "@/lib/stripe/resolveStripeCustomerForAccount";
import { setAutoRechargeOptOut } from "@/lib/stripe/setAutoRechargeOptOut";

/**
* PATCH /api/accounts/[id]/auto-recharge
*
* Enables or disables automatic top-up for the account. The setting lives on
* the account's Stripe Customer (`auto_recharge_opt_out` metadata key), so it
* survives card changes: disabling never detaches the saved card, and
* re-enabling requires no card re-entry.
*
* Resolves the Customer via `resolveStripeCustomerForAccount` (the same
* `metadata.accountId` lookup the charge path uses) so the flag always lands
* on the Customer the gate reads — provisioning one if the account has never
* had a Stripe Customer.
*/
export async function updateAutoRechargeHandler(
request: NextRequest,
params: Promise<{ id: string }>,
): Promise<NextResponse> {
try {
const { id } = await params;
const validated = await validateUpdateAutoRechargeBody(request, id);
if (validated instanceof NextResponse) {
return validated;
}

const customer = await resolveStripeCustomerForAccount(validated.accountId);
await setAutoRechargeOptOut(customer, !validated.enabled);

return NextResponse.json(
{ account_id: validated.accountId, enabled: validated.enabled },
{ status: 200, headers: getCorsHeaders() },
);
} catch (error) {
console.error("[updateAutoRechargeHandler]", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500, headers: getCorsHeaders() },
);
}
}
Loading
Loading