Skip to content

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

Open
sweetmantech wants to merge 1 commit into
mainfrom
feat/1861-web-search-1-credit-auto-recharge-optout
Open

feat(credits): /research/web → 1 credit + auto top-up opt-out via Stripe customer metadata#769
sweetmantech wants to merge 1 commit 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, giving users control over off-session charges. This makes agentic web search cheaper and respects consent before charging.

  • New Features
    • POST /api/research/web now costs 1 credit with a dedicated gate (ensureWebResearchCredits); deduction updated to 1. Other research endpoints remain at 5.
    • Auto top-up opt-out stored on the Stripe Customer (auto_recharge_opt_out metadata). autoRechargeOrFail reads a fresh customers.retrieve and never charges off-session when opted out; it returns 402 with a checkoutUrl for manual top-ups.
    • New GET/PATCH /api/accounts/{id}/auto-recharge to read/update the setting ({ account_id, enabled }). Default is enabled; GET is read-only; PATCH resolves/provisions the Customer and writes presence-based metadata (delete on opt-in, never write "false").

Written for commit 3cffc47. 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 8, 2026 5:43pm

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: 40 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: 6ef68cf8-ed21-4e68-a6f0-6c54164b11d7

📥 Commits

Reviewing files that changed from the base of the PR and between 1b8012b and 3cffc47.

⛔ Files ignored due to path filters (8)
  • 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/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>

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