From 3cffc47bafccb742bd964f9657d0192588e6b326 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 8 Jul 2026 12:30:51 -0500 Subject: [PATCH 1/2] feat(credits): reprice /research/web to 1 credit + Stripe-metadata auto top-up opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web search now deducts 1 credit (own gate, ensureWebResearchCredits) instead of riding the research family's 5 — upstream Perplexity Search API is a flat $0.005/request, so 5 credits punished high-frequency agentic search (chat#1861). Songstats-backed research endpoints keep RESEARCH_CREDIT_COST=5. Auto top-up consent now lives on the Stripe Customer (auto_recharge_opt_out metadata key, presence semantics, deleted on opt-in). autoRechargeOrFail does a fresh customers.retrieve before any off-session charge — never the eventually-consistent search copy — and opted-out accounts fall to the existing 402 + checkoutUrl Checkout path. New GET/PATCH /api/accounts/{id}/auto-recharge mirrors the payment-method route. TDD: all units RED before GREEN; 87 files / 385 tests pass across stripe/billing/credits/research; tsc clean on touched files. Co-Authored-By: Claude Fable 5 --- app/api/accounts/[id]/auto-recharge/route.ts | 56 +++++++++++ .../__tests__/getAutoRechargeHandler.test.ts | 78 +++++++++++++++ .../updateAutoRechargeHandler.test.ts | 95 +++++++++++++++++++ lib/billing/getAutoRechargeHandler.ts | 42 ++++++++ lib/billing/updateAutoRechargeHandler.ts | 52 ++++++++++ lib/billing/validateAutoRechargeParams.ts | 32 +++++++ lib/billing/validateUpdateAutoRechargeBody.ts | 30 ++++++ .../__tests__/autoRechargeOrFail.test.ts | 24 +++++ lib/credits/autoRechargeOrFail.ts | 26 +++++ .../ensureWebResearchCredits.test.ts | 34 +++++++ .../__tests__/postResearchWebHandler.test.ts | 4 + .../validatePostResearchWebRequest.test.ts | 8 ++ lib/research/ensureWebResearchCredits.ts | 22 +++++ lib/research/postResearchWebHandler.ts | 2 +- .../validatePostResearchWebRequest.ts | 4 +- .../__tests__/getAutoRechargeOptOut.test.ts | 52 ++++++++++ .../__tests__/setAutoRechargeOptOut.test.ts | 38 ++++++++ lib/stripe/getAutoRechargeOptOut.ts | 22 +++++ lib/stripe/setAutoRechargeOptOut.ts | 15 +++ 19 files changed, 633 insertions(+), 3 deletions(-) create mode 100644 app/api/accounts/[id]/auto-recharge/route.ts create mode 100644 lib/billing/__tests__/getAutoRechargeHandler.test.ts create mode 100644 lib/billing/__tests__/updateAutoRechargeHandler.test.ts create mode 100644 lib/billing/getAutoRechargeHandler.ts create mode 100644 lib/billing/updateAutoRechargeHandler.ts create mode 100644 lib/billing/validateAutoRechargeParams.ts create mode 100644 lib/billing/validateUpdateAutoRechargeBody.ts create mode 100644 lib/research/__tests__/ensureWebResearchCredits.test.ts create mode 100644 lib/research/ensureWebResearchCredits.ts create mode 100644 lib/stripe/__tests__/getAutoRechargeOptOut.test.ts create mode 100644 lib/stripe/__tests__/setAutoRechargeOptOut.test.ts create mode 100644 lib/stripe/getAutoRechargeOptOut.ts create mode 100644 lib/stripe/setAutoRechargeOptOut.ts diff --git a/app/api/accounts/[id]/auto-recharge/route.ts b/app/api/accounts/[id]/auto-recharge/route.ts new file mode 100644 index 000000000..9ca34ff55 --- /dev/null +++ b/app/api/accounts/[id]/auto-recharge/route.ts @@ -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"; diff --git a/lib/billing/__tests__/getAutoRechargeHandler.test.ts b/lib/billing/__tests__/getAutoRechargeHandler.test.ts new file mode 100644 index 000000000..e2a760974 --- /dev/null +++ b/lib/billing/__tests__/getAutoRechargeHandler.test.ts @@ -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 () => { + findCustomerMock.mockRejectedValue(new Error("stripe down")); + + const res = await getAutoRechargeHandler(makeRequest(), Promise.resolve({ id: ACCOUNT })); + expect(res.status).toBe(500); + }); +}); diff --git a/lib/billing/__tests__/updateAutoRechargeHandler.test.ts b/lib/billing/__tests__/updateAutoRechargeHandler.test.ts new file mode 100644 index 000000000..a84259903 --- /dev/null +++ b/lib/billing/__tests__/updateAutoRechargeHandler.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +const { validateParamsMock, resolveCustomerMock, setOptOutMock } = vi.hoisted(() => ({ + validateParamsMock: vi.fn(), + resolveCustomerMock: vi.fn(), + setOptOutMock: vi.fn(), +})); + +vi.mock("@/lib/billing/validateAutoRechargeParams", () => ({ + validateAutoRechargeParams: validateParamsMock, +})); +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(body: unknown): NextRequest { + return new NextRequest(`http://localhost/api/accounts/${ACCOUNT}/auto-recharge`, { + method: "PATCH", + body: JSON.stringify(body), + }); +} + +describe("updateAutoRechargeHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + validateParamsMock.mockResolvedValue(ACCOUNT); + resolveCustomerMock.mockResolvedValue("cus_x"); + setOptOutMock.mockResolvedValue(undefined); + }); + + it("forwards validation error responses", async () => { + const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + validateParamsMock.mockResolvedValue(err); + + const res = await updateAutoRechargeHandler( + makeRequest({ enabled: false }), + Promise.resolve({ id: ACCOUNT }), + ); + expect(res.status).toBe(401); + }); + + it("returns 400 when enabled is missing or not a boolean", async () => { + const res = await updateAutoRechargeHandler( + makeRequest({ enabled: "nope" }), + Promise.resolve({ id: ACCOUNT }), + ); + expect(res.status).toBe(400); + expect(setOptOutMock).not.toHaveBeenCalled(); + }); + + it("opts out: resolves the customer by accountId and stamps the flag", async () => { + const res = await updateAutoRechargeHandler( + makeRequest({ enabled: false }), + Promise.resolve({ id: ACCOUNT }), + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ account_id: ACCOUNT, enabled: false }); + expect(resolveCustomerMock).toHaveBeenCalledWith(ACCOUNT); + expect(setOptOutMock).toHaveBeenCalledWith("cus_x", true); + }); + + it("opts back in: deletes the flag", async () => { + const res = await updateAutoRechargeHandler( + makeRequest({ enabled: true }), + 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 () => { + setOptOutMock.mockRejectedValue(new Error("stripe down")); + + const res = await updateAutoRechargeHandler( + makeRequest({ enabled: false }), + Promise.resolve({ id: ACCOUNT }), + ); + expect(res.status).toBe(500); + }); +}); diff --git a/lib/billing/getAutoRechargeHandler.ts b/lib/billing/getAutoRechargeHandler.ts new file mode 100644 index 000000000..7223fa5bf --- /dev/null +++ b/lib/billing/getAutoRechargeHandler.ts @@ -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 { + try { + const { id } = await params; + const validated = await validateAutoRechargeParams(request, id); + if (validated instanceof NextResponse) { + return validated; + } + + 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() }, + ); + } +} diff --git a/lib/billing/updateAutoRechargeHandler.ts b/lib/billing/updateAutoRechargeHandler.ts new file mode 100644 index 000000000..d5383f147 --- /dev/null +++ b/lib/billing/updateAutoRechargeHandler.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAutoRechargeParams } from "@/lib/billing/validateAutoRechargeParams"; +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 { + try { + const { id } = await params; + const validated = await validateAutoRechargeParams(request, id); + if (validated instanceof NextResponse) { + return validated; + } + + const body = await request.json().catch(() => null); + const validatedBody = validateUpdateAutoRechargeBody(body); + if (validatedBody instanceof NextResponse) { + return validatedBody; + } + + const customer = await resolveStripeCustomerForAccount(validated); + await setAutoRechargeOptOut(customer, !validatedBody.enabled); + + return NextResponse.json( + { account_id: validated, enabled: validatedBody.enabled }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[updateAutoRechargeHandler]", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/billing/validateAutoRechargeParams.ts b/lib/billing/validateAutoRechargeParams.ts new file mode 100644 index 000000000..ebcbba526 --- /dev/null +++ b/lib/billing/validateAutoRechargeParams.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +const idSchema = z.string().uuid("id must be a valid UUID"); + +/** + * Validates the `[id]` path param for /api/accounts/{id}/auto-recharge and + * confirms the caller may access that account (own account or accessible via + * organization membership). Returns the validated UUID on success, or a + * NextResponse with the upstream auth/validation error to forward. + */ +export async function validateAutoRechargeParams( + request: NextRequest, + id: string, +): Promise { + const parsed = idSchema.safeParse(id); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const auth = await validateAuthContext(request, { accountId: parsed.data }); + if (auth instanceof NextResponse) { + return auth; + } + + return parsed.data; +} diff --git a/lib/billing/validateUpdateAutoRechargeBody.ts b/lib/billing/validateUpdateAutoRechargeBody.ts new file mode 100644 index 000000000..8c3cc526c --- /dev/null +++ b/lib/billing/validateUpdateAutoRechargeBody.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +export const updateAutoRechargeBodySchema = z.object({ + enabled: z.boolean({ message: "enabled must be a boolean" }), +}); + +export type UpdateAutoRechargeBody = z.infer; + +/** + * Validates the request body for PATCH /api/accounts/{id}/auto-recharge. + * + * @param body - The parsed request body + * @returns A 400 NextResponse when validation fails, or the validated body. + */ +export function validateUpdateAutoRechargeBody( + body: unknown, +): NextResponse | UpdateAutoRechargeBody { + const result = updateAutoRechargeBodySchema.safeParse(body); + + if (!result.success) { + return NextResponse.json( + { error: result.error.issues[0]?.message ?? "Invalid request body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return result.data; +} diff --git a/lib/credits/__tests__/autoRechargeOrFail.test.ts b/lib/credits/__tests__/autoRechargeOrFail.test.ts index b3ddd3573..dc6e81c6f 100644 --- a/lib/credits/__tests__/autoRechargeOrFail.test.ts +++ b/lib/credits/__tests__/autoRechargeOrFail.test.ts @@ -7,6 +7,7 @@ const { chargeOffSessionMock, createCreditsSessionMock, computeChargeMock, + getAutoRechargeOptOutMock, } = vi.hoisted(() => ({ selectCreditsUsageMock: vi.fn(), incrementMock: vi.fn(), @@ -14,6 +15,7 @@ const { chargeOffSessionMock: vi.fn(), createCreditsSessionMock: vi.fn(), computeChargeMock: vi.fn(), + getAutoRechargeOptOutMock: vi.fn(), })); vi.mock("@/lib/supabase/credits_usage/selectCreditsUsage", () => ({ @@ -34,6 +36,9 @@ vi.mock("@/lib/stripe/createCreditsStripeSession", () => ({ vi.mock("@/lib/stripe/computeCreditsTopupCharge", () => ({ computeCreditsTopupCharge: computeChargeMock, })); +vi.mock("@/lib/stripe/getAutoRechargeOptOut", () => ({ + getAutoRechargeOptOut: getAutoRechargeOptOutMock, +})); const { autoRechargeOrFail } = await import("@/lib/credits/autoRechargeOrFail"); @@ -48,6 +53,7 @@ beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => undefined); computeChargeMock.mockReturnValue({ totalCents: 534 }); resolveStripeCustomerMock.mockResolvedValue("cus_x"); + getAutoRechargeOptOutMock.mockResolvedValue(false); }); describe("autoRechargeOrFail", () => { @@ -62,6 +68,24 @@ describe("autoRechargeOrFail", () => { expect(createCreditsSessionMock).not.toHaveBeenCalled(); }); + it("never charges off-session when the customer has opted out — falls to checkout", async () => { + selectCreditsUsageMock.mockResolvedValue([{ remaining_credits: 2 }]); + getAutoRechargeOptOutMock.mockResolvedValue(true); + createCreditsSessionMock.mockResolvedValue({ url: "https://checkout.stripe.com/x" }); + + const result = await autoRechargeOrFail(params); + + expect(result).toEqual({ + kind: "insufficient_credits", + remainingCredits: 2, + requiredCredits: 5, + checkoutUrl: "https://checkout.stripe.com/x", + }); + expect(getAutoRechargeOptOutMock).toHaveBeenCalledWith("cus_x"); + expect(chargeOffSessionMock).not.toHaveBeenCalled(); + expect(incrementMock).not.toHaveBeenCalled(); + }); + it("auto-charges and increments when balance is short and card succeeds", async () => { selectCreditsUsageMock.mockResolvedValue([{ remaining_credits: 2 }]); chargeOffSessionMock.mockResolvedValue({ kind: "charged", paymentIntentId: "pi_ok" }); diff --git a/lib/credits/autoRechargeOrFail.ts b/lib/credits/autoRechargeOrFail.ts index 713f85e5d..ae2dcd913 100644 --- a/lib/credits/autoRechargeOrFail.ts +++ b/lib/credits/autoRechargeOrFail.ts @@ -1,6 +1,7 @@ import { selectCreditsUsage } from "@/lib/supabase/credits_usage/selectCreditsUsage"; import { incrementRemainingCredits } from "@/lib/supabase/credits_usage/incrementRemainingCredits"; import { resolveStripeCustomerForAccount } from "@/lib/stripe/resolveStripeCustomerForAccount"; +import { getAutoRechargeOptOut } from "@/lib/stripe/getAutoRechargeOptOut"; import { chargeCustomerOffSession, type DeclineReason, @@ -47,6 +48,31 @@ export async function autoRechargeOrFail( if (remaining >= creditsToDeduct) return { kind: "available" }; const customer = await resolveStripeCustomerForAccount(accountId); + + // Consent gate: an opted-out customer is never charged off-session. Fresh + // read by id (not the eventually-consistent search index) so a just-flipped + // opt-out is honored by this very request. Falls through to the Checkout + // session below — the explicit-consent path. + if (await getAutoRechargeOptOut(customer)) { + const session = await createCreditsStripeSession({ + accountId, + credits: CREDIT_AUTO_RECHARGE_CREDITS, + successUrl, + customer, + }); + if (!session.url) { + throw new Error( + `[autoRechargeOrFail] createCreditsStripeSession returned no url for account ${accountId}`, + ); + } + return { + kind: "insufficient_credits", + remainingCredits: remaining, + requiredCredits: creditsToDeduct, + checkoutUrl: session.url, + }; + } + const { totalCents } = computeCreditsTopupCharge(CREDIT_AUTO_RECHARGE_CREDITS); const charge = await chargeCustomerOffSession({ diff --git a/lib/research/__tests__/ensureWebResearchCredits.test.ts b/lib/research/__tests__/ensureWebResearchCredits.test.ts new file mode 100644 index 000000000..76105e093 --- /dev/null +++ b/lib/research/__tests__/ensureWebResearchCredits.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; + +const { ensureCreditsMock } = vi.hoisted(() => ({ + ensureCreditsMock: vi.fn(), +})); + +vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ + ensureCreditsOrShortCircuit: ensureCreditsMock, +})); + +const { ensureWebResearchCredits } = await import("@/lib/research/ensureWebResearchCredits"); + +describe("ensureWebResearchCredits", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("gates with exactly 1 credit (web search is repriced below the research family)", async () => { + ensureCreditsMock.mockResolvedValue(null); + + await expect(ensureWebResearchCredits("acct")).resolves.toBeNull(); + expect(ensureCreditsMock).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "acct", creditsToDeduct: 1 }), + ); + }); + + it("forwards the 402 short-circuit response", async () => { + const short = NextResponse.json({ error: "Payment Required" }, { status: 402 }); + ensureCreditsMock.mockResolvedValue(short); + + await expect(ensureWebResearchCredits("acct")).resolves.toBe(short); + }); +}); diff --git a/lib/research/__tests__/postResearchWebHandler.test.ts b/lib/research/__tests__/postResearchWebHandler.test.ts index f93d99015..112419a91 100644 --- a/lib/research/__tests__/postResearchWebHandler.test.ts +++ b/lib/research/__tests__/postResearchWebHandler.test.ts @@ -5,6 +5,7 @@ import { postResearchWebHandler } from "../postResearchWebHandler"; import { validatePostResearchWebRequest } from "../validatePostResearchWebRequest"; import { searchPerplexity } from "@/lib/perplexity/searchPerplexity"; import { formatSearchResultsAsMarkdown } from "@/lib/perplexity/formatSearchResultsAsMarkdown"; +import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), @@ -79,5 +80,8 @@ describe("postResearchWebHandler", () => { expect(body.status).toBe("success"); expect(body.results).toEqual(mockResults); expect(body.formatted).toBe("# Results\n..."); + expect(recordCreditDeduction).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "test-id", creditsToDeduct: 1 }), + ); }); }); diff --git a/lib/research/__tests__/validatePostResearchWebRequest.test.ts b/lib/research/__tests__/validatePostResearchWebRequest.test.ts index af806c894..297b28bd9 100644 --- a/lib/research/__tests__/validatePostResearchWebRequest.test.ts +++ b/lib/research/__tests__/validatePostResearchWebRequest.test.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { validatePostResearchWebRequest } from "../validatePostResearchWebRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { ensureCreditsOrShortCircuit } from "@/lib/credits/ensureCreditsOrShortCircuit"; vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), @@ -52,4 +53,11 @@ describe("validatePostResearchWebRequest", () => { const res = await validatePostResearchWebRequest(req({ query: "x", country: "US" })); expect(res).toEqual({ accountId: "acct", query: "x", country: "US" }); }); + + it("gates with exactly 1 credit (web search costs 1, not the research family's 5)", async () => { + await validatePostResearchWebRequest(req({ query: "x" })); + expect(ensureCreditsOrShortCircuit).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "acct", creditsToDeduct: 1 }), + ); + }); }); diff --git a/lib/research/ensureWebResearchCredits.ts b/lib/research/ensureWebResearchCredits.ts new file mode 100644 index 000000000..a762187f4 --- /dev/null +++ b/lib/research/ensureWebResearchCredits.ts @@ -0,0 +1,22 @@ +import { ensureCreditsOrShortCircuit } from "@/lib/credits/ensureCreditsOrShortCircuit"; +import { CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL } from "@/lib/credits/const"; + +/** + * Credits charged per web-search call. Priced separately from the research + * family's 5: the upstream Perplexity Search API costs a flat $0.005/request, + * so 1 credit keeps margin without punishing high-frequency agentic search + * (chat#1861). Songstats-backed research endpoints stay on + * `ensureResearchCredits` at 5. + */ +const WEB_RESEARCH_CREDIT_COST = 1; + +/** + * Per-route credit gate for `POST /api/research/web`. Returns a 402 + * NextResponse the route can `return` directly, or `null` to proceed. + */ +export const ensureWebResearchCredits = (accountId: string) => + ensureCreditsOrShortCircuit({ + accountId, + creditsToDeduct: WEB_RESEARCH_CREDIT_COST, + successUrl: CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL, + }); diff --git a/lib/research/postResearchWebHandler.ts b/lib/research/postResearchWebHandler.ts index b73270ccc..c77620a99 100644 --- a/lib/research/postResearchWebHandler.ts +++ b/lib/research/postResearchWebHandler.ts @@ -28,7 +28,7 @@ export async function postResearchWebHandler(request: NextRequest): Promise ({ + customersRetrieve: vi.fn(), +})); + +vi.mock("@/lib/stripe/client", () => ({ + default: { + customers: { + retrieve: customersRetrieve, + }, + }, +})); + +const { getAutoRechargeOptOut } = await import("@/lib/stripe/getAutoRechargeOptOut"); + +describe("getAutoRechargeOptOut", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true when the opt-out metadata key is present", async () => { + customersRetrieve.mockResolvedValue({ + id: "cus_x", + metadata: { auto_recharge_opt_out: "true" }, + }); + + await expect(getAutoRechargeOptOut("cus_x")).resolves.toBe(true); + expect(customersRetrieve).toHaveBeenCalledWith("cus_x"); + }); + + it("returns true for any non-empty value (presence semantics, never parse)", async () => { + customersRetrieve.mockResolvedValue({ + id: "cus_x", + metadata: { auto_recharge_opt_out: "false" }, + }); + + await expect(getAutoRechargeOptOut("cus_x")).resolves.toBe(true); + }); + + it("returns false when the key is absent", async () => { + customersRetrieve.mockResolvedValue({ id: "cus_x", metadata: {} }); + + await expect(getAutoRechargeOptOut("cus_x")).resolves.toBe(false); + }); + + it("returns false for a deleted customer", async () => { + customersRetrieve.mockResolvedValue({ id: "cus_x", deleted: true }); + + await expect(getAutoRechargeOptOut("cus_x")).resolves.toBe(false); + }); +}); diff --git a/lib/stripe/__tests__/setAutoRechargeOptOut.test.ts b/lib/stripe/__tests__/setAutoRechargeOptOut.test.ts new file mode 100644 index 000000000..773b28c59 --- /dev/null +++ b/lib/stripe/__tests__/setAutoRechargeOptOut.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { customersUpdate } = vi.hoisted(() => ({ + customersUpdate: vi.fn(), +})); + +vi.mock("@/lib/stripe/client", () => ({ + default: { + customers: { + update: customersUpdate, + }, + }, +})); + +const { setAutoRechargeOptOut } = await import("@/lib/stripe/setAutoRechargeOptOut"); + +describe("setAutoRechargeOptOut", () => { + beforeEach(() => { + vi.clearAllMocks(); + customersUpdate.mockResolvedValue({ id: "cus_x" }); + }); + + it("stamps a truthy marker when opting out", async () => { + await setAutoRechargeOptOut("cus_x", true); + + expect(customersUpdate).toHaveBeenCalledWith("cus_x", { + metadata: { auto_recharge_opt_out: "true" }, + }); + }); + + it("deletes the key (empty string) when opting back in — never writes 'false'", async () => { + await setAutoRechargeOptOut("cus_x", false); + + expect(customersUpdate).toHaveBeenCalledWith("cus_x", { + metadata: { auto_recharge_opt_out: "" }, + }); + }); +}); diff --git a/lib/stripe/getAutoRechargeOptOut.ts b/lib/stripe/getAutoRechargeOptOut.ts new file mode 100644 index 000000000..7f52e90c1 --- /dev/null +++ b/lib/stripe/getAutoRechargeOptOut.ts @@ -0,0 +1,22 @@ +import type Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; + +/** + * Reads whether the Stripe Customer has opted out of automatic top-up. + * + * The flag is the presence of the `auto_recharge_opt_out` metadata key — + * any non-empty value means opted out; the value is never parsed. Opting + * back in deletes the key (see `setAutoRechargeOptOut`). + * + * Always a fresh `customers.retrieve`: Stripe's customer-search index is + * eventually consistent (~60s), so a charge path reading a search-result + * copy could bill a customer who just opted out. Retrieval by id is + * strongly consistent. + */ +export async function getAutoRechargeOptOut(customerId: string): Promise { + const customer = (await stripeClient.customers.retrieve(customerId)) as + | Stripe.Customer + | Stripe.DeletedCustomer; + if (customer.deleted) return false; + return Boolean((customer as Stripe.Customer).metadata?.auto_recharge_opt_out); +} diff --git a/lib/stripe/setAutoRechargeOptOut.ts b/lib/stripe/setAutoRechargeOptOut.ts new file mode 100644 index 000000000..7432e1092 --- /dev/null +++ b/lib/stripe/setAutoRechargeOptOut.ts @@ -0,0 +1,15 @@ +import stripeClient from "@/lib/stripe/client"; + +/** + * Sets or clears the automatic top-up opt-out flag on a Stripe Customer. + * + * Presence semantics: opting out stamps `auto_recharge_opt_out: "true"`; + * opting back in sets the key to `""`, which Stripe treats as a delete — + * the key disappears from metadata entirely. Never write `"false"`: readers + * check presence, not value, so a `"false"` string would still opt out. + */ +export async function setAutoRechargeOptOut(customerId: string, optOut: boolean): Promise { + await stripeClient.customers.update(customerId, { + metadata: { auto_recharge_opt_out: optOut ? "true" : "" }, + }); +} From 2355fe19e36e7a17399644a2ae9bc05aea734fe5 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 9 Jul 2026 12:45:56 -0500 Subject: [PATCH 2/2] refactor(billing): fold params + safeParseJson into validateUpdateAutoRechargeBody (SRP) Review feedback: the PATCH handler called two validators and parsed JSON inline. Now the handler calls only validateUpdateAutoRechargeBody, which runs validateAutoRechargeParams and parses the body via the shared safeParseJson like sibling validate functions. Response contract unchanged. Co-Authored-By: Claude Fable 5 --- .../updateAutoRechargeHandler.test.ts | 50 +++++-------- .../validateUpdateAutoRechargeBody.test.ts | 71 +++++++++++++++++++ lib/billing/updateAutoRechargeHandler.ts | 15 ++-- lib/billing/validateUpdateAutoRechargeBody.ts | 37 +++++++--- 4 files changed, 119 insertions(+), 54 deletions(-) create mode 100644 lib/billing/__tests__/validateUpdateAutoRechargeBody.test.ts diff --git a/lib/billing/__tests__/updateAutoRechargeHandler.test.ts b/lib/billing/__tests__/updateAutoRechargeHandler.test.ts index a84259903..c211c8bd4 100644 --- a/lib/billing/__tests__/updateAutoRechargeHandler.test.ts +++ b/lib/billing/__tests__/updateAutoRechargeHandler.test.ts @@ -1,14 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -const { validateParamsMock, resolveCustomerMock, setOptOutMock } = vi.hoisted(() => ({ - validateParamsMock: vi.fn(), +const { validateBodyMock, resolveCustomerMock, setOptOutMock } = vi.hoisted(() => ({ + validateBodyMock: vi.fn(), resolveCustomerMock: vi.fn(), setOptOutMock: vi.fn(), })); -vi.mock("@/lib/billing/validateAutoRechargeParams", () => ({ - validateAutoRechargeParams: validateParamsMock, +vi.mock("@/lib/billing/validateUpdateAutoRechargeBody", () => ({ + validateUpdateAutoRechargeBody: validateBodyMock, })); vi.mock("@/lib/stripe/resolveStripeCustomerForAccount", () => ({ resolveStripeCustomerForAccount: resolveCustomerMock, @@ -24,10 +24,9 @@ const { updateAutoRechargeHandler } = await import("@/lib/billing/updateAutoRech const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; -function makeRequest(body: unknown): NextRequest { +function makeRequest(): NextRequest { return new NextRequest(`http://localhost/api/accounts/${ACCOUNT}/auto-recharge`, { method: "PATCH", - body: JSON.stringify(body), }); } @@ -35,48 +34,35 @@ describe("updateAutoRechargeHandler", () => { beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, "error").mockImplementation(() => undefined); - validateParamsMock.mockResolvedValue(ACCOUNT); resolveCustomerMock.mockResolvedValue("cus_x"); setOptOutMock.mockResolvedValue(undefined); }); - it("forwards validation error responses", async () => { + it("forwards validation error responses (auth, params, and body errors alike)", async () => { const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - validateParamsMock.mockResolvedValue(err); + validateBodyMock.mockResolvedValue(err); - const res = await updateAutoRechargeHandler( - makeRequest({ enabled: false }), - Promise.resolve({ id: ACCOUNT }), - ); + const res = await updateAutoRechargeHandler(makeRequest(), Promise.resolve({ id: ACCOUNT })); expect(res.status).toBe(401); - }); - - it("returns 400 when enabled is missing or not a boolean", async () => { - const res = await updateAutoRechargeHandler( - makeRequest({ enabled: "nope" }), - Promise.resolve({ id: ACCOUNT }), - ); - expect(res.status).toBe(400); expect(setOptOutMock).not.toHaveBeenCalled(); }); it("opts out: resolves the customer by accountId and stamps the flag", async () => { - const res = await updateAutoRechargeHandler( - makeRequest({ enabled: false }), - Promise.resolve({ id: ACCOUNT }), - ); + 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 () => { - const res = await updateAutoRechargeHandler( - makeRequest({ enabled: true }), - Promise.resolve({ id: ACCOUNT }), - ); + 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 }); @@ -84,12 +70,10 @@ describe("updateAutoRechargeHandler", () => { }); it("returns 500 on unexpected errors", async () => { + validateBodyMock.mockResolvedValue({ accountId: ACCOUNT, enabled: false }); setOptOutMock.mockRejectedValue(new Error("stripe down")); - const res = await updateAutoRechargeHandler( - makeRequest({ enabled: false }), - Promise.resolve({ id: ACCOUNT }), - ); + const res = await updateAutoRechargeHandler(makeRequest(), Promise.resolve({ id: ACCOUNT })); expect(res.status).toBe(500); }); }); diff --git a/lib/billing/__tests__/validateUpdateAutoRechargeBody.test.ts b/lib/billing/__tests__/validateUpdateAutoRechargeBody.test.ts new file mode 100644 index 000000000..95dda4e93 --- /dev/null +++ b/lib/billing/__tests__/validateUpdateAutoRechargeBody.test.ts @@ -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 }); + }); +}); diff --git a/lib/billing/updateAutoRechargeHandler.ts b/lib/billing/updateAutoRechargeHandler.ts index d5383f147..f3d92cabc 100644 --- a/lib/billing/updateAutoRechargeHandler.ts +++ b/lib/billing/updateAutoRechargeHandler.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAutoRechargeParams } from "@/lib/billing/validateAutoRechargeParams"; import { validateUpdateAutoRechargeBody } from "@/lib/billing/validateUpdateAutoRechargeBody"; import { resolveStripeCustomerForAccount } from "@/lib/stripe/resolveStripeCustomerForAccount"; import { setAutoRechargeOptOut } from "@/lib/stripe/setAutoRechargeOptOut"; @@ -24,22 +23,16 @@ export async function updateAutoRechargeHandler( ): Promise { try { const { id } = await params; - const validated = await validateAutoRechargeParams(request, id); + const validated = await validateUpdateAutoRechargeBody(request, id); if (validated instanceof NextResponse) { return validated; } - const body = await request.json().catch(() => null); - const validatedBody = validateUpdateAutoRechargeBody(body); - if (validatedBody instanceof NextResponse) { - return validatedBody; - } - - const customer = await resolveStripeCustomerForAccount(validated); - await setAutoRechargeOptOut(customer, !validatedBody.enabled); + const customer = await resolveStripeCustomerForAccount(validated.accountId); + await setAutoRechargeOptOut(customer, !validated.enabled); return NextResponse.json( - { account_id: validated, enabled: validatedBody.enabled }, + { account_id: validated.accountId, enabled: validated.enabled }, { status: 200, headers: getCorsHeaders() }, ); } catch (error) { diff --git a/lib/billing/validateUpdateAutoRechargeBody.ts b/lib/billing/validateUpdateAutoRechargeBody.ts index 8c3cc526c..bf10bb7e5 100644 --- a/lib/billing/validateUpdateAutoRechargeBody.ts +++ b/lib/billing/validateUpdateAutoRechargeBody.ts @@ -1,24 +1,41 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateAutoRechargeParams } from "@/lib/billing/validateAutoRechargeParams"; export const updateAutoRechargeBodySchema = z.object({ enabled: z.boolean({ message: "enabled must be a boolean" }), }); -export type UpdateAutoRechargeBody = z.infer; +export type ValidatedUpdateAutoRechargeRequest = { + /** The validated account UUID the caller may act on. */ + accountId: string; + /** The requested auto top-up state. */ + enabled: boolean; +}; /** - * Validates the request body for PATCH /api/accounts/{id}/auto-recharge. + * Validates the full PATCH /api/accounts/{id}/auto-recharge request: the + * `[id]` path param and caller access (via {@link validateAutoRechargeParams}), + * then the JSON body (`{ enabled: boolean }`, parsed with the shared + * `safeParseJson`). The handler calls only this function. * - * @param body - The parsed request body - * @returns A 400 NextResponse when validation fails, or the validated body. + * @param request - The incoming HTTP request. + * @param id - The raw `[id]` path param. + * @returns A 4xx NextResponse to forward, or the validated accountId + enabled. */ -export function validateUpdateAutoRechargeBody( - body: unknown, -): NextResponse | UpdateAutoRechargeBody { - const result = updateAutoRechargeBodySchema.safeParse(body); +export async function validateUpdateAutoRechargeBody( + request: NextRequest, + id: string, +): Promise { + const validated = await validateAutoRechargeParams(request, id); + if (validated instanceof NextResponse) { + return validated; + } + const body = await safeParseJson(request); + const result = updateAutoRechargeBodySchema.safeParse(body); if (!result.success) { return NextResponse.json( { error: result.error.issues[0]?.message ?? "Invalid request body" }, @@ -26,5 +43,5 @@ export function validateUpdateAutoRechargeBody( ); } - return result.data; + return { accountId: validated, enabled: result.data.enabled }; }