feat(credits): /research/web → 1 credit + auto top-up opt-out via Stripe customer metadata#769
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
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. |
|
Warning Review limit reached
Next review available in: 40 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (8)
📒 Files selected for processing (11)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
10 issues found across 19 files
Confidence score: 3/5
- In
lib/billing/getAutoRechargeHandler.ts, forwardingvalidateAutoRechargeParamsresponses 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.tsandlib/billing/__tests__/updateAutoRechargeHandler.test.tsonly 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.tsadds an opt-out path that can throw whencreateCreditsStripeSessionreturns a null URL, butlib/credits/__tests__/autoRechargeOrFail.test.tsdoes 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.tshardcoded credit cost, duplicated auth validation inlib/billing/validateAutoRechargeParams.ts, and style/comment drift inlib/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
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 () => { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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")); |
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
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"; | |||
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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>
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 forPOST /api/research/web(the sharedRESEARCH_CREDIT_COST = 5is untouched: Songstats-backed endpoints cost us ~$0.09/call and stay at 5; Perplexity web search costs a flat $0.005/request).postResearchWebHandlerdeducts 1 on success.2. Auto top-up opt-out (consent on the Stripe Customer)
lib/stripe/getAutoRechargeOptOut.ts— freshcustomers.retrieve(never the ~60s-stale search-index copy), presence semantics: any non-emptyauto_recharge_opt_outmetadata value = opted out.lib/stripe/setAutoRechargeOptOut.ts— opt-out stamps"true"; opt-in writes""(Stripe deletes the key). Never writes"false".autoRechargeOrFailchecks consent beforechargeCustomerOffSession; opted-out accounts get the existing402 + checkoutUrlCheckout fallback — manual top-ups (and card saving via Checkout) keep working, but the saved card is never charged silently.GET/PATCH /api/accounts/{id}/auto-recharge(mirrors thepayment-methodroute: same param validation, auth viavalidateAuthContext, flat{ account_id, enabled }response). GET is read-only (no Customer provisioning); PATCH resolves viaresolveStripeCustomerForAccount— the samemetadata.accountIdlookup 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: 5vs expected1). Final: 87 test files / 385 tests pass acrosslib/stripe,lib/billing,lib/credits,lib/research;tsc --noEmitreports zero errors in touched files (remaining tsc noise is pre-existing onorigin/main, verified by stash-compare);pnpm lintclean.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.
POST /api/research/webnow costs 1 credit with a dedicated gate (ensureWebResearchCredits); deduction updated to 1. Other research endpoints remain at 5.auto_recharge_opt_outmetadata).autoRechargeOrFailreads a freshcustomers.retrieveand never charges off-session when opted out; it returns402with acheckoutUrlfor manual top-ups.GET/PATCH /api/accounts/{id}/auto-rechargeto 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.