Skip to content

feat(credits): /research/web → 1 credit + auto top-up opt-out via Stripe customer metadata#769

Merged
sweetmantech merged 3 commits into
mainfrom
feat/1861-web-search-1-credit-auto-recharge-optout
Jul 9, 2026
Merged

feat(credits): /research/web → 1 credit + auto top-up opt-out via Stripe customer metadata#769
sweetmantech merged 3 commits into
mainfrom
feat/1861-web-search-1-credit-auto-recharge-optout

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the api slice of recoupable/chat#1861, fulfilling the contract in docs#270 (merge order: docs#270 → this → chat).

1. Web search repriced 5 → 1 credit

  • lib/research/ensureWebResearchCredits.ts — dedicated 1-credit gate for POST /api/research/web (the shared RESEARCH_CREDIT_COST = 5 is untouched: Songstats-backed endpoints cost us ~$0.09/call and stay at 5; Perplexity web search costs a flat $0.005/request).
  • postResearchWebHandler deducts 1 on success.

2. Auto top-up opt-out (consent on the Stripe Customer)

  • lib/stripe/getAutoRechargeOptOut.tsfresh customers.retrieve (never the ~60s-stale search-index copy), presence semantics: any non-empty auto_recharge_opt_out metadata value = opted out.
  • lib/stripe/setAutoRechargeOptOut.ts — opt-out stamps "true"; opt-in writes "" (Stripe deletes the key). Never writes "false".
  • autoRechargeOrFail checks consent before chargeCustomerOffSession; opted-out accounts get the existing 402 + checkoutUrl Checkout fallback — manual top-ups (and card saving via Checkout) keep working, but the saved card is never charged silently.
  • New GET/PATCH /api/accounts/{id}/auto-recharge (mirrors the payment-method route: same param validation, auth via validateAuthContext, flat { account_id, enabled } response). GET is read-only (no Customer provisioning); PATCH resolves via resolveStripeCustomerForAccount — the same metadata.accountId lookup the charge path uses — so the flag always lands on the Customer the gate reads.

TDD evidence

Every unit was RED before GREEN (new modules failed collection; modified suites failed on the new assertions — e.g. creditsToDeduct: 5 vs expected 1). Final: 87 test files / 385 tests pass across lib/stripe, lib/billing, lib/credits, lib/research; tsc --noEmit reports zero errors in touched files (remaining tsc noise is pre-existing on origin/main, verified by stash-compare); pnpm lint clean.

Preview verification results will be posted as a comment once the deployment is Ready.

🤖 Generated with Claude Code


Summary by cubic

Repriced web search to 1 credit and added a Stripe-based auto top-up opt-out so off-session charges only happen with consent. This makes web research cheaper and gives users a clear way to avoid silent charges.

  • New Features

    • POST /api/research/web now costs 1 credit via ensureWebResearchCredits; other research endpoints stay at 5.
    • Auto top-up opt-out stored on the Stripe Customer (auto_recharge_opt_out metadata). autoRechargeOrFail does a fresh customers.retrieve, never charges off-session when opted out, and falls back to 402 with a checkoutUrl.
    • GET/PATCH /api/accounts/{id}/auto-recharge to read/update the setting. Default is enabled; GET is read-only; PATCH provisions the Customer and uses presence-based metadata (delete on opt-in; never write "false").
  • Refactors

    • Consolidated PATCH validation: validateUpdateAutoRechargeBody now handles path/auth checks and safe JSON parsing; response contract unchanged.

Written for commit 8016fac. Summary will update on new commits.

Review in cubic

…to top-up opt-out

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 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 9, 2026 7:55pm

Request Review

@cursor

cursor Bot commented Jul 8, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 58 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 82fd16e3-86a9-4b7c-b46c-56e7b625eaea

📥 Commits

Reviewing files that changed from the base of the PR and between 6bdcff7 and 8016fac.

⛔ Files ignored due to path filters (9)
  • lib/billing/__tests__/getAutoRechargeHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/billing/__tests__/updateAutoRechargeHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/billing/__tests__/validateUpdateAutoRechargeBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/credits/__tests__/autoRechargeOrFail.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/research/__tests__/ensureWebResearchCredits.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/research/__tests__/postResearchWebHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/research/__tests__/validatePostResearchWebRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/getAutoRechargeOptOut.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/setAutoRechargeOptOut.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (11)
  • app/api/accounts/[id]/auto-recharge/route.ts
  • lib/billing/getAutoRechargeHandler.ts
  • lib/billing/updateAutoRechargeHandler.ts
  • lib/billing/validateAutoRechargeParams.ts
  • lib/billing/validateUpdateAutoRechargeBody.ts
  • lib/credits/autoRechargeOrFail.ts
  • lib/research/ensureWebResearchCredits.ts
  • lib/research/postResearchWebHandler.ts
  • lib/research/validatePostResearchWebRequest.ts
  • lib/stripe/getAutoRechargeOptOut.ts
  • lib/stripe/setAutoRechargeOptOut.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1861-web-search-1-credit-auto-recharge-optout

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

10 issues found across 19 files

Confidence score: 3/5

  • In lib/billing/getAutoRechargeHandler.ts, forwarding validateAutoRechargeParams responses directly can break the endpoint’s documented { error } contract on auth/validation failures, which risks client-side parsing regressions after merge — normalize upstream validator responses to the handler’s error shape before merging.
  • The 500-path tests in lib/billing/__tests__/getAutoRechargeHandler.test.ts and lib/billing/__tests__/updateAutoRechargeHandler.test.ts only assert status, so a future refactor could leak internal exception details without test failures — add explicit response-body assertions that verify only the generic internal-error payload is returned.
  • lib/credits/autoRechargeOrFail.ts adds an opt-out path that can throw when createCreditsStripeSession returns a null URL, but lib/credits/__tests__/autoRechargeOrFail.test.ts does not cover that branch; this leaves a user-facing failure mode under-tested — add a null-URL test for the opt-out path (and ideally consolidate duplicated session-creation logic to keep branches aligned).
  • The remaining findings (lib/research/postResearchWebHandler.ts hardcoded credit cost, duplicated auth validation in lib/billing/validateAutoRechargeParams.ts, and style/comment drift in lib/credits/autoRechargeOrFail.ts) are mostly maintainability/drift risks rather than immediate breakage, so they can be follow-up items if you lock down the contract and error-handling tests first.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/billing/validateAutoRechargeParams.ts">

<violation number="1" location="lib/billing/validateAutoRechargeParams.ts:14">
P3: Account path-param authorization logic is now duplicated in two billing validators, which raises drift risk when auth/error behavior changes. Consider factoring this into one shared validator (or delegating to the existing helper) so both routes stay consistent.</violation>
</file>

<file name="lib/billing/__tests__/getAutoRechargeHandler.test.ts">

<violation number="1" location="lib/billing/__tests__/getAutoRechargeHandler.test.ts:72">
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" })`.</violation>
</file>

<file name="lib/credits/autoRechargeOrFail.ts">

<violation number="1" location="lib/credits/autoRechargeOrFail.ts:52">
P2: Custom agent: **Enforce Clear Code Style and Maintainability Practices**

This file now exceeds the 100-line limit stated in the codebase maintainability guidelines (Rule 3). At 120 lines it is the longest module in `lib/credits`, `lib/stripe`, and `lib/billing` by a significant margin (the next longest is 87 lines). The duplicated checkout-session creation + `insufficient_credits` return logic (appearing both inside the new consent gate and after the off-session charge attempt) is a natural extraction point. Consider splitting the consent gate fallback and/or the session builder into a dedicated helper in a separate file to bring this module back under the limit and avoid further drift.</violation>

<violation number="2" location="lib/credits/autoRechargeOrFail.ts:55">
P3: This comment describes falling through to the later Checkout block, but this branch now creates a session and returns early. Updating the wording would keep the control-flow documentation accurate.</violation>

<violation number="3" location="lib/credits/autoRechargeOrFail.ts:56">
P3: The `createCreditsStripeSession` call followed by the URL-null check + error throw is repeated verbatim in the opt-out path and the fallback charge-failure path. This makes the two branches harder to evolve independently — a fix to one is easy to miss in the other. Consider extracting a small helper like `createSessionOrThrow(...)` that wraps the session creation and URL check so the error message and guard live in one place.</violation>
</file>

<file name="lib/billing/getAutoRechargeHandler.ts">

<violation number="1" location="lib/billing/getAutoRechargeHandler.ts:25">
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.</violation>
</file>

<file name="lib/billing/__tests__/updateAutoRechargeHandler.test.ts">

<violation number="1" location="lib/billing/__tests__/updateAutoRechargeHandler.test.ts:54">
P3: The test description says `"returns 400 when enabled is missing or not a boolean"` but the test payload `{ enabled: "nope" }` only covers the "not a boolean" case. The "missing" case (e.g., `{}` without the `enabled` key) is not tested. Consider either narrowing the description to match what is actually tested, or adding a separate test for the missing-key scenario (`{}` or `{ something: 42 }`) to ensure coverage matches the description.</violation>

<violation number="2" location="lib/billing/__tests__/updateAutoRechargeHandler.test.ts:87">
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.</violation>
</file>

<file name="lib/credits/__tests__/autoRechargeOrFail.test.ts">

<violation number="1" location="lib/credits/__tests__/autoRechargeOrFail.test.ts:71">
P2: The new opt-out path calls `createCreditsStripeSession` and can throw when the session URL is null. The existing test for the null-URL error only covers the fallback (non-opt-out) path. Adding a test case where `getAutoRechargeOptOut` returns `true` and the session has no URL would ensure this error path is covered before it reaches production.</violation>
</file>

<file name="lib/research/postResearchWebHandler.ts">

<violation number="1" location="lib/research/postResearchWebHandler.ts:31">
P3: The `postResearchWebHandler` hardcodes `creditsToDeduct: 1`, but the corresponding gate in `ensureWebResearchCredits` defines this same value as `WEB_RESEARCH_CREDIT_COST` locally without exporting it. If the cost ever changes (e.g., Perplexity reprices), both locations need manual updates — and there's nothing to remind a future developer to update the handler. Consider exporting `WEB_RESEARCH_CREDIT_COST` from `ensureWebResearchCredits` and importing it here so the gate and deduction always stay in sync.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as Client / UI
    participant API as API Route (Next.js)
    participant Valid as Validation Layer
    participant Auth as Auth Context
    participant Credits as Credits Logic
    participant DB as Supabase (credits_usage)
    participant Stripe as Stripe API
    participant PP as Perplexity API

    Note over Client,PP: NEW: Web Search Repricing (1 credit)

    Client->>API: POST /api/research/web { query, country }
    API->>Valid: validatePostResearchWebRequest()
    Valid->>Auth: validateAuthContext()
    Auth-->>Valid: accountId
    Valid->>Credits: ensureWebResearchCredits(accountId) [NEW: 1 credit]
    Credits->>Credits: ensureCreditsOrShortCircuit(creditsToDeduct=1)
    Credits->>DB: selectCreditsUsage()
    DB-->>Credits: { remaining_credits }
    alt sufficient credits (remaining >= 1)
        Credits-->>Valid: null (proceed)
    else insufficient credits
        Credits-->>Valid: 402 NextResponse
        Valid-->>API: short-circuit response
        API-->>Client: 402 + checkoutUrl
    end
    Valid-->>API: { accountId, query, country }
    API->>PP: searchPerplexity(query)
    PP-->>API: results
    API->>Credits: recordCreditDeduction(accountId, creditsToDeduct=1) [CHANGED: 5→1]
    Credits->>DB: incrementRemainingCredits(-1)
    API-->>Client: 200 { results, formatted }

    Note over Client,Stripe: NEW: Auto Top-Up Opt-Out (GET)

    Client->>API: GET /api/accounts/{id}/auto-recharge
    API->>Valid: validateAutoRechargeParams()
    Valid->>Auth: validateAuthContext(accountId)
    Auth-->>Valid: ok
    Valid-->>API: accountId (UUID)
    API->>Credits: findStripeCustomerForAccount(accountId)
    Credits->>Stripe: search customers by metadata.accountId
    alt customer found
        Stripe-->>Credits: customerId = "cus_x"
    else no customer yet
        Stripe-->>Credits: null
    end
    alt customer is null
        API-->>Client: 200 { account_id, enabled: true }
    else customer exists
        API->>Credits: getAutoRechargeOptOut(customerId) [NEW]
        Credits->>Stripe: customers.retrieve(cus_x) [fresh, not indexed]
        Stripe-->>Credits: { metadata: { auto_recharge_opt_out? } }
        alt metadata key present
            Credits-->>API: optedOut = true
            API-->>Client: 200 { account_id, enabled: false }
        else key absent or deleted
            Credits-->>API: optedOut = false
            API-->>Client: 200 { account_id, enabled: true }
        end
    end

    Note over Client,Stripe: NEW: Auto Top-Up Opt-Out (PATCH)

    Client->>API: PATCH /api/accounts/{id}/auto-recharge { enabled: false }
    API->>Valid: validateAutoRechargeParams()
    Valid->>Auth: validateAuthContext()
    Auth-->>Valid: ok
    Valid-->>API: accountId
    API->>Valid: validateUpdateAutoRechargeBody({ enabled: false })
    Valid-->>API: { enabled: false }
    API->>Credits: resolveStripeCustomerForAccount(accountId) [provision if needed]
    Credits->>Stripe: find or create customer
    Stripe-->>Credits: customerId = "cus_x"
    API->>Credits: setAutoRechargeOptOut(cus_x, optOut=true) [NEW]
    Credits->>Stripe: customers.update(cus_x, { metadata: { auto_recharge_opt_out: "true" } })
    Stripe-->>Credits: updated
    API-->>Client: 200 { account_id, enabled: false }

    Note over Client,Stripe: CHANGED: autoRechargeOrFail consent gate

    Client->>API: POST /api/research/web (another call)
    API->>Credits: autoRechargeOrFail(accountId, creditsToDeduct=1)
    Credits->>DB: selectCreditsUsage()
    DB-->>Credits: remaining = 2 (insufficient)
    Credits->>Credits: resolveStripeCustomerForAccount()
    Credits->>Credits: getAutoRechargeOptOut(customer) [NEW: fresh read]
    Credits->>Stripe: customers.retrieve(cus_x)
    alt optedOut == true
        Credits->>Stripe: createCreditsStripeSession() [checkout, no off-session charge]
        Stripe-->>Credits: { url: "https://checkout.stripe.com/y" }
        Credits-->>API: { kind: "insufficient_credits", checkoutUrl }
        API-->>Client: 402 + checkoutUrl
    else optedOut == false
        Credits->>Stripe: chargeCustomerOffSession()
        alt charge success
            Stripe-->>Credits: { kind: "charged" }
            Credits->>DB: incrementRemainingCredits(CREDIT_AUTO_RECHARGE_CREDITS)
            Credits-->>API: { kind: "available" }
            API->>PP: searchPerplexity()
            API-->>Client: 200
        else charge declined
            Stripe-->>Credits: { kind: "declined" }
            Credits->>Stripe: createCreditsStripeSession()
            Credits-->>API: { kind: "insufficient_credits", checkoutUrl }
            API-->>Client: 402 + checkoutUrl
        end
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

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>

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>

});

it("returns 500 on unexpected errors", async () => {
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>

expect(createCreditsSessionMock).not.toHaveBeenCalled();
});

it("never charges off-session when the customer has opted out — falls to checkout", 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 new opt-out path calls createCreditsStripeSession and can throw when the session URL is null. The existing test for the null-URL error only covers the fallback (non-opt-out) path. Adding a test case where getAutoRechargeOptOut returns true and the session has no URL would ensure this error path is covered before it reaches production.

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

<comment>The new opt-out path calls `createCreditsStripeSession` and can throw when the session URL is null. The existing test for the null-URL error only covers the fallback (non-opt-out) path. Adding a test case where `getAutoRechargeOptOut` returns `true` and the session has no URL would ensure this error path is covered before it reaches production.</comment>

<file context>
@@ -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);
</file context>

@@ -1,6 +1,7 @@
import { selectCreditsUsage } from "@/lib/supabase/credits_usage/selectCreditsUsage";

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: Custom agent: Enforce Clear Code Style and Maintainability Practices

This file now exceeds the 100-line limit stated in the codebase maintainability guidelines (Rule 3). At 120 lines it is the longest module in lib/credits, lib/stripe, and lib/billing by a significant margin (the next longest is 87 lines). The duplicated checkout-session creation + insufficient_credits return logic (appearing both inside the new consent gate and after the off-session charge attempt) is a natural extraction point. Consider splitting the consent gate fallback and/or the session builder into a dedicated helper in a separate file to bring this module back under the limit and avoid further drift.

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

<comment>This file now exceeds the 100-line limit stated in the codebase maintainability guidelines (Rule 3). At 120 lines it is the longest module in `lib/credits`, `lib/stripe`, and `lib/billing` by a significant margin (the next longest is 87 lines). The duplicated checkout-session creation + `insufficient_credits` return logic (appearing both inside the new consent gate and after the off-session charge attempt) is a natural extraction point. Consider splitting the consent gate fallback and/or the session builder into a dedicated helper in a separate file to bring this module back under the limit and avoid further drift.</comment>

<file context>
@@ -47,6 +48,31 @@ export async function autoRechargeOrFail(
 
   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
</file context>

* organization membership). Returns the validated UUID on success, or a
* NextResponse with the upstream auth/validation error to forward.
*/
export async function validateAutoRechargeParams(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Account path-param authorization logic is now duplicated in two billing validators, which raises drift risk when auth/error behavior changes. Consider factoring this into one shared validator (or delegating to the existing helper) so both routes stay consistent.

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

<comment>Account path-param authorization logic is now duplicated in two billing validators, which raises drift risk when auth/error behavior changes. Consider factoring this into one shared validator (or delegating to the existing helper) so both routes stay consistent.</comment>

<file context>
@@ -0,0 +1,32 @@
+ * 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,
</file context>

// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This comment describes falling through to the later Checkout block, but this branch now creates a session and returns early. Updating the wording would keep the control-flow documentation accurate.

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

<comment>This comment describes falling through to the later Checkout block, but this branch now creates a session and returns early. Updating the wording would keep the control-flow documentation accurate.</comment>

<file context>
@@ -47,6 +48,31 @@ export async function autoRechargeOrFail(
+  // 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({
</file context>

expect(res.status).toBe(401);
});

it("returns 400 when enabled is missing or not a boolean", 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.

P3: The test description says "returns 400 when enabled is missing or not a boolean" but the test payload { enabled: "nope" } only covers the "not a boolean" case. The "missing" case (e.g., {} without the enabled key) is not tested. Consider either narrowing the description to match what is actually tested, or adding a separate test for the missing-key scenario ({} or { something: 42 }) to ensure coverage matches the description.

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 54:

<comment>The test description says `"returns 400 when enabled is missing or not a boolean"` but the test payload `{ enabled: "nope" }` only covers the "not a boolean" case. The "missing" case (e.g., `{}` without the `enabled` key) is not tested. Consider either narrowing the description to match what is actually tested, or adding a separate test for the missing-key scenario (`{}` or `{ something: 42 }`) to ensure coverage matches the description.</comment>

<file context>
@@ -0,0 +1,95 @@
+    expect(res.status).toBe(401);
+  });
+
+  it("returns 400 when enabled is missing or not a boolean", async () => {
+    const res = await updateAutoRechargeHandler(
+      makeRequest({ enabled: "nope" }),
</file context>

// 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The createCreditsStripeSession call followed by the URL-null check + error throw is repeated verbatim in the opt-out path and the fallback charge-failure path. This makes the two branches harder to evolve independently — a fix to one is easy to miss in the other. Consider extracting a small helper like createSessionOrThrow(...) that wraps the session creation and URL check so the error message and guard live in one place.

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

<comment>The `createCreditsStripeSession` call followed by the URL-null check + error throw is repeated verbatim in the opt-out path and the fallback charge-failure path. This makes the two branches harder to evolve independently — a fix to one is easy to miss in the other. Consider extracting a small helper like `createSessionOrThrow(...)` that wraps the session creation and URL check so the error message and guard live in one place.</comment>

<file context>
@@ -47,6 +48,31 @@ export async function autoRechargeOrFail(
+  // 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,
</file context>

await recordCreditDeduction({
accountId: validated.accountId,
creditsToDeduct: 5,
creditsToDeduct: 1,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The postResearchWebHandler hardcodes creditsToDeduct: 1, but the corresponding gate in ensureWebResearchCredits defines this same value as WEB_RESEARCH_CREDIT_COST locally without exporting it. If the cost ever changes (e.g., Perplexity reprices), both locations need manual updates — and there's nothing to remind a future developer to update the handler. Consider exporting WEB_RESEARCH_CREDIT_COST from ensureWebResearchCredits and importing it here so the gate and deduction always stay in sync.

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

<comment>The `postResearchWebHandler` hardcodes `creditsToDeduct: 1`, but the corresponding gate in `ensureWebResearchCredits` defines this same value as `WEB_RESEARCH_CREDIT_COST` locally without exporting it. If the cost ever changes (e.g., Perplexity reprices), both locations need manual updates — and there's nothing to remind a future developer to update the handler. Consider exporting `WEB_RESEARCH_CREDIT_COST` from `ensureWebResearchCredits` and importing it here so the gate and deduction always stay in sync.</comment>

<file context>
@@ -28,7 +28,7 @@ export async function postResearchWebHandler(request: NextRequest): Promise<Next
       await recordCreditDeduction({
         accountId: validated.accountId,
-        creditsToDeduct: 5,
+        creditsToDeduct: 1,
         source: "api",
       });
</file context>

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — 2026-07-09

Tested against the preview deployment for this PR's head commit (3cffc47): https://api-oiz4e8lf0-recoup.vercel.app (deployment confirmed built from 3cffc47 via the GitHub deployments API, status success). Auth: preview-app Privy Bearer token, account 2d3f7de4-6189-4603-ace4-624a0e7864dd (test account, started at 333 credits, enabled: true default).

# Check Documented / expected Actual (preview) Result
1 GET /api/accounts/{id}/auto-recharge default 200 {account_id, enabled: true} for an account that never touched the setting 200 {"account_id":"2d3f…","enabled":true}
2 PATCH {"enabled": false} (opt out) 200 {account_id, enabled: false} 200 {"enabled":false}
3 GET reflects the opt-out enabled: false false — but see the read-after-first-write lag note below ✅ w/ note
4 PATCH {"enabled": "nope"} 400, zod message 400 {"error":"enabled must be a boolean"}
5 GET with non-UUID id 400 400 {"error":"id must be a valid UUID"}
6 GET with no auth 401 401 {"status":"error","error":"Exactly one of x-api-key or Authorization must be provided"}
7 GET another account's id (random UUID, my token) 403 403 {"status":"error","error":"Access denied to specified account_id"}
8 POST /api/research/web deducts 1 credit 200 + balance −1 200 with Perplexity results; remaining_credits 333 → 332 (exactly −1)
9 Sibling research endpoint still deducts 5 GET /api/research?q=… → 200 + balance −5 200; remaining_credits 332 → 327 (exactly −5)
10 PATCH {"enabled": true} (opt back in) 200 {enabled: true}; no card re-entry needed 200; immediate GET confirms true

Account left in its original state: enabled: true, 6 credits burned by design (1 web + 5 search).

Observation: read-after-first-write lag on GET (not a merge blocker)

Immediately after the first-ever PATCH enabled: false (which returned 200 enabled: false), GET still returned enabled: true; it flipped to false within ~2.5 min (PATCH ~16:04 UTC, first correct read 16:06:42 UTC). Cause: this account had no Stripe customer, so the PATCH provisioned one via resolveStripeCustomerForAccount and stamped the flag — but getAutoRechargeHandler finds the customer via the eventually-consistent Stripe Search index (findStripeCustomerForAccount, ~60s lag per the resolver docstring), so until the new customer is indexed, GET falls back to the enabled: true default. Once the customer is indexed, flips read back instantly (check 10 confirmed immediately).

Scope: only affects accounts with no pre-existing Stripe customer (who by definition have no saved card, so no off-session charge is possible during the window) — the safety property is unaffected, and the charge gate reads the flag via fresh customers.retrieve, not the search index. Worth knowing for recoupable/chat#1862 though: the toggle renders live GET state, so a first-time opt-out on a card-less account can visually snap back to "on" for ~1–2 min. Optimistic UI or a first-write carve-out may be warranted there.

Not exercised live

  • Opted-out + low-balance 402 (the incident-prevention path): requires an account with remaining_credits below the request cost; my test account holds 327 and I had no service credentials in this session to stage a low-balance fixture (Vercel CLI token expired). Covered by the new unit test lib/credits/__tests__/autoRechargeOrFail.test.ts ("never charges off-session when the customer has opted out — falls to checkout": asserts chargeCustomerOffSession is never called and the return is the existing insufficient_credits + checkoutUrl shape).
  • usage_events.credits_deducted_cents = 1 row / gate-passes-at-exactly-1-credit boundary: same fixture constraint; the −1 balance delta in check 8 is the API-visible equivalent. Unit-covered by ensureWebResearchCredits.test.ts and postResearchWebHandler.test.ts.

Contract cross-check: response shapes, status codes, default semantics, and the "disabling never removes the saved card / re-enabling needs no card re-entry" behavior all match the merged docs contract (recoupable/docs#270, merged 2026-07-09).

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Full auto-recharge sequence — live preview verification, 2026-07-09

Follow-up to the first verification pass: the two paths previously listed under Not exercised live have now been exercised for real. Same preview (https://api-oiz4e8lf0-recoup.vercel.app, head 3cffc47), same test account (2d3f7de4-…). Balances were staged by writing credits_usage.remaining_credits directly (previews share the prod database); Stripe is live mode, so the off-session charge below is a real card charge.

Setup: the account started with no Stripe card. A card was attached via the $0 mode: "setup" card-on-file Checkout (minted from the Songstats payment-method gate's 402) — notable because the off-session charge path then found it through the paymentMethods.list fallback in findDefaultPaymentMethodForCustomer (no invoice_settings.default_payment_method was ever set).

# Scenario (all with balance staged to 0 unless noted) Documented / expected Actual (preview, live Stripe) Result
A Enabled, no card, 0 credits → POST /research/web 402 insufficient_credits + checkoutUrl, no declineReason 402, required_credits: 1, checkoutUrl (live Checkout session), no declineReason
B Balance staged to exactly 1POST /research/web 200; gate passes at exactly 1; ledger row of 1 200; balance 1 → 0; usage_events.credits_deducted_cents = 1 (16:26:06 UTC)
C Enabled, card on file, 0 credits → POST /research/web Silent off-session top-up: request succeeds, balance +500 −1 200 in 3.1s; balance 0 → 499; real ~$5.34 off-session charge on the test card; ledger shows exactly one 1-cent deduction (16:37:09 UTC)
D PATCH {"enabled": false} (GET confirmed false), card still on file, 0 credits → POST /research/web 402 + checkoutUrl, no declineReason, no off-session charge despite a chargeable card 402 insufficient_credits + checkoutUrl, no declineReason; balance stayed 0 (no +500 increment ⇒ no successful PI); no ledger row
E Re-enable: PATCH {"enabled": true} 200; no card re-entry needed 200; immediate GET confirms true; the card saved via the setup session was never re-entered at any point in the sequence

The Phase D result is the incident-prevention property this PR exists for: opt-out + low balance + chargeable card ⇒ fail closed to explicit Checkout, card untouched. The balance-increment check is the API-visible proxy for "no PaymentIntent" (incrementRemainingCredits only runs after chargeCustomerOffSession returns charged); the cardholder's statement shows exactly one charge (Phase C's), none from Phase D.

State after testing: enabled: true restored, balance restored to 825 (= 333 pre-test − 8 legitimately deducted across all checks + 500 purchased in Phase C).

With the earlier pass, every Done-when criterion on recoupable/chat#1861 that belongs to this PR is now verified against the live preview.

Comment on lines +27 to +36
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;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SRP

  • actual: multiple validates and json parsing in the handler
  • required: only validateUpdateAutoRechargeBody is called in the handler. validateAutoRechargeParams is called in validateUpdateAutoRechargeBody and body is parsed using the shared safeParseJson lib like other validate functions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 2355fe1. updateAutoRechargeHandler now calls only validateUpdateAutoRechargeBody, which runs validateAutoRechargeParams (UUID + auth) first and parses the body with the shared safeParseJson — mirroring validateUpdatePulsesRequest. Response contract unchanged (400 {"error":"enabled must be a boolean"} etc.). TDD: rewrote the handler test to mock only the composed validator and added validateUpdateAutoRechargeBody.test.ts (5 cases incl. auth-error forwarding and invalid-JSON 400) — confirmed RED before the refactor, then GREEN; full suite 738 files / 4040 tests pass, tsc adds no new errors in lib/billing, prettier clean.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Re-verified live on the 2355fe1 preview (api-nkzuglix4) with a fresh token — refactored route is contract-identical: bad body → 400 enabled must be a boolean; invalid JSON → 400 (safeParseJson fallback); PATCH false/true round-trip → 200 with immediate GET read-back both ways; other account → 403; no auth → 401; non-UUID → 400. Account left enabled: true.

…oRechargeBody (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 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 4 files (changes from recent commits).

Requires human review: Auto-approval blocked by 10 unresolved issues from previous reviews.

Re-trigger cubic

@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@sweetmantech sweetmantech merged commit c922691 into main Jul 9, 2026
4 of 6 checks passed
sweetmantech added a commit to recoupable/chat that referenced this pull request Jul 9, 2026
* feat(billing): Automatic top-up toggle in the profile settings menu

Adds an 'Automatic top-up' checkbox item to the user-profile dropdown's
settings group, backed by GET/PATCH /api/accounts/{id}/auto-recharge
(recoupable/api#769). Renders the live Stripe-read state — never a cached
local flag — so the toggle always shows the consent the charge path will
honor. Disabling keeps the saved card and manual checkout top-ups working.

TDD: fetch-helper tests RED before implementation; 35 files / 130 tests
pass; eslint + tsc clean on touched files; production build succeeds.

Part of #1861 (merge order: docs#270 → api#769 → this).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(billing): move auto top-up toggle into a Billing section of the account modal

Review feedback: the profile dropdown is primarily navigational; a billing-
consent control deserves explanation a menu item can't carry. The toggle is
now a labeled Switch in a divided Billing section of the Account modal with
helper text stating exactly what gets charged ($5 for 500 credits) and that
disabling never removes the card. The section is visually separated because
it applies instantly via PATCH, unlike the save-to-apply form above it.
Also surfaces mutation failures via toast (was silent).

useAutoRecharge and the lib/recoup fetchers are unchanged aside from onError;
all 130 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant