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..c211c8bd4 --- /dev/null +++ b/lib/billing/__tests__/updateAutoRechargeHandler.test.ts @@ -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")); + + 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/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..f3d92cabc --- /dev/null +++ b/lib/billing/updateAutoRechargeHandler.ts @@ -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 { + 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() }, + ); + } +} 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..bf10bb7e5 --- /dev/null +++ b/lib/billing/validateUpdateAutoRechargeBody.ts @@ -0,0 +1,47 @@ +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 ValidatedUpdateAutoRechargeRequest = { + /** The validated account UUID the caller may act on. */ + accountId: string; + /** The requested auto top-up state. */ + enabled: boolean; +}; + +/** + * 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 request - The incoming HTTP request. + * @param id - The raw `[id]` path param. + * @returns A 4xx NextResponse to forward, or the validated accountId + enabled. + */ +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" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return { accountId: validated, enabled: result.data.enabled }; +} 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" : "" }, + }); +}