feat: POST /api/credits/sessions + Stripe webhook for credit top-ups#553
Conversation
Documentation-driven follow-up to docs#205. Adds a one-time-payment
Stripe Checkout flow for arbitrary credit top-ups, plus the first
Stripe webhook in the codebase to credit accounts on payment success.
Route layer (mirrors POST /api/subscriptions/sessions exactly):
- POST /api/credits/sessions: { successUrl, credits, accountId? } -> { id, url }
- 1 credit = 1 US cent, priced via Stripe price_data (unit_amount: 1),
mode: "payment". Session metadata carries accountId + credits +
purpose: "credits_topup" so the webhook can credit the right account.
Webhook layer (new):
- POST /api/webhooks/stripe: signature-verified via
stripe.webhooks.constructEvent + STRIPE_WEBHOOK_SECRET. Handles
checkout.session.completed events and increments the account's
credits_usage.remaining_credits by the metadata-declared amount.
Returns 500 on handler failure so Stripe retries.
Known gap: idempotency-via-event-id is deferred (Stripe retries on 5xx
have a small double-credit window). Planned follow-up: add a
stripe_webhook_events dedupe table (requires a database migration).
TDD: 41 new tests across 10 files (schema, validate, stripe session,
handler, webhook verify, process, route exports, increment helper).
2737/2737 tests green; pnpm lint:check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR implements a complete Stripe credits top-up feature: clients request checkout sessions with fee gross-up calculation, successful payments trigger webhook events, and verified payments increment account credit balances in the database with full request validation and error handling throughout. ChangesStripe Credits Top-Up
Sequence DiagramsequenceDiagram
participant Client
participant CreateSessionAPI as /api/credits/sessions
participant ValidateReq as validateCreateCreditsSessionRequest
participant CreateSession as createCreditsStripeSession
participant Stripe
participant WebhookAPI as /api/webhooks/stripe
participant VerifyWebhook as verifyStripeWebhookEvent
participant ProcessSession as processCreditsTopupSession
participant UpdateDB as incrementRemainingCredits
participant Supabase
rect rgb(200, 150, 255, 0.5)
Note over Client,CreateSession: Outbound: Create Checkout Session
Client->>CreateSessionAPI: POST { successUrl, credits, accountId }
CreateSessionAPI->>ValidateReq: validate & auth check
ValidateReq-->>CreateSessionAPI: validated data
CreateSessionAPI->>CreateSession: build session
CreateSession->>CreateSession: compute fee gross-up
CreateSession->>Stripe: sessions.create()
Stripe-->>CreateSession: session with url
CreateSession-->>CreateSessionAPI: { id, url }
CreateSessionAPI-->>Client: 200 with session
end
rect rgb(150, 200, 255, 0.5)
Note over Stripe,UpdateDB: Inbound: Process Webhook
Client->>Stripe: complete payment
Stripe->>WebhookAPI: POST webhook event
WebhookAPI->>VerifyWebhook: verify signature
VerifyWebhook-->>WebhookAPI: Stripe.Event
WebhookAPI->>ProcessSession: handle completion
ProcessSession->>ProcessSession: validate metadata, payment_status
ProcessSession->>UpdateDB: increment credits
UpdateDB->>Supabase: fetch & update remaining_credits
Supabase-->>UpdateDB: updated row
UpdateDB-->>ProcessSession: CreditsUsage
ProcessSession-->>WebhookAPI: void
WebhookAPI-->>Stripe: { received: true } 200
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning Review ran into problems🔥 ProblemsTimed out fetching pipeline failures after 30000ms 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.
7 issues found across 22 files
Confidence score: 2/5
- There is a high-confidence concurrency bug in
lib/supabase/credits_usage/incrementRemainingCredits.ts: the read-modify-write increment is non-atomic, so concurrent updates can overwrite each other and lose user credits. lib/stripe/mapToCreditsSessionError.tscan throw on invalid upstream JSON instead of returning a normalized error, which adds regression risk in error paths and can surface inconsistent failures.- Most other findings are lower-severity consistency/test-quality concerns, but combined with the credits race condition they raise meaningful merge risk until the data-integrity path is fixed.
- Pay close attention to
lib/supabase/credits_usage/incrementRemainingCredits.tsandlib/stripe/mapToCreditsSessionError.ts- prevent credit-loss races and harden error parsing to avoid unexpected throws.
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/supabase/credits_usage/incrementRemainingCredits.ts">
<violation number="1" location="lib/supabase/credits_usage/incrementRemainingCredits.ts:24">
P1: This increment is non-atomic and can lose credits under concurrent updates (read-modify-write race). Use a single DB-side atomic increment (or transactional lock) instead of computing `newBalance` in application code.</violation>
</file>
<file name="lib/stripe/mapToCreditsSessionError.ts">
<violation number="1" location="lib/stripe/mapToCreditsSessionError.ts:16">
P2: Handle JSON parse failures when reading the upstream error body; otherwise this mapper can throw instead of returning a normalized error response.</violation>
</file>
<file name="lib/stripe/stripeWebhookHandler.ts">
<violation number="1" location="lib/stripe/stripeWebhookHandler.ts:24">
P2: Use the standard 500 error message `"Internal server error"` to keep server error responses consistent and avoid exposing endpoint-specific internals.
(Based on your team's feedback about standardizing 500 responses to a generic internal error message.) [FEEDBACK_USED]</violation>
</file>
<file name="lib/stripe/__tests__/processCreditsTopupSession.test.ts">
<violation number="1" location="lib/stripe/__tests__/processCreditsTopupSession.test.ts:1">
P3: Custom agent: **Enforce Clear Code Style and Maintainability Practices**
This test file exceeds the repository’s 100-line limit.</violation>
</file>
<file name="app/api/credits/sessions/__tests__/route.test.ts">
<violation number="1" location="app/api/credits/sessions/__tests__/route.test.ts:22">
P3: Custom agent: **Flag AI Slop and Fabricated Changes**
Test is only a smoke check for exported handlers; it does not assert the endpoint behavior added by the PR.</violation>
</file>
<file name="lib/stripe/__tests__/validateCreateCreditsSessionRequest.test.ts">
<violation number="1" location="lib/stripe/__tests__/validateCreateCreditsSessionRequest.test.ts:1">
P2: Custom agent: **Enforce Clear Code Style and Maintainability Practices**
New test file exceeds the 100-line file-size limit required by the repository style rule.</violation>
</file>
<file name="app/api/webhooks/stripe/__tests__/route.test.ts">
<violation number="1" location="app/api/webhooks/stripe/__tests__/route.test.ts:15">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**
Test only checks that the route exports exist instead of asserting the webhook route behavior.</violation>
</file>
Architecture diagram
sequenceDiagram
participant Client as External Client
participant Route as POST /api/credits/sessions (route.ts)
participant Val as validateCreateCreditsSessionRequest
participant Auth as validateAuthContext
participant Schemas as createCreditsSessionBodySchema
participant Handler as createCreditsSessionHandler
participant StripeAPI as Stripe API
participant WebhookRoute as POST /api/webhooks/stripe (route.ts)
participant Verify as verifyStripeWebhookEvent
participant Process as processCreditsTopupSession
participant IncCredits as incrementRemainingCredits
participant Supabase as Supabase DB
Note over Client,Supabase: NEW: Credit Top-up Flow
Client->>Route: POST /api/credits/sessions
Route->>Handler: createCreditsSessionHandler(request)
Handler->>Val: validateCreateCreditsSessionRequest(request)
Val->>Val: Parse JSON body
alt Invalid JSON
Val-->>Handler: 400 { error: "Invalid JSON body" }
else Valid JSON
Val->>Schemas: safeParse(body)
alt Schema violation
Schemas-->>Val: Error (e.g. missing successUrl)
Val-->>Handler: 400 { error: first issue message }
else Schema valid
Val->>Auth: validateAuthContext(request, { accountId?: uuid })
alt Auth failed (unauthorized)
Auth-->>Val: NextResponse (401/403)
Val-->>Handler: Mapped error response
else Auth succeeded
Auth-->>Val: { accountId, orgId, authToken }
Val-->>Handler: { accountId, successUrl, credits }
end
end
end
alt Validation failed (is NextResponse)
Handler-->>Route: Return error response as-is
else Validation succeeded
Handler->>StripeAPI: createCreditsStripeSession({ accountId, credits, successUrl })
Note over Handler,StripeAPI: unit_amount=1, mode="payment",<br/>metadata={ accountId, credits, purpose:"credits_topup" }
alt Stripe error (throw)
StripeAPI-->>Handler: Error
Handler-->>Route: 500 { error: "Internal server error" }
else Success
StripeAPI-->>Handler: { id, url }
alt url is null
Handler-->>Route: 400 { error: "Checkout session URL missing" }
else url present
Handler-->>Route: 200 { id, url }
end
end
end
Route-->>Client: Response (JSON)
Note over Client,Supabase: NEW: Stripe Webhook Flow (async, after payment)
Client->>StripeAPI: User completes checkout
StripeAPI->>WebhookRoute: POST /api/webhooks/stripe (event: checkout.session.completed)
WebhookRoute->>Verify: verifyStripeWebhookEvent(request)
alt Missing stripe-signature header
Verify-->>WebhookRoute: { error: "Missing stripe-signature header" }
WebhookRoute-->>StripeAPI: 400 { error }
else Signature invalid
Verify-->>WebhookRoute: { error: "Invalid Stripe signature" }
WebhookRoute-->>StripeAPI: 400 { error }
else Signature valid
Verify-->>WebhookRoute: { event: Stripe.Event }
end
WebhookRoute->>Process: processCreditsTopupSession(session)
alt mode !== "payment" or purpose !== "credits_topup"
Process-->>WebhookRoute: return (skip silently)
else payment_status !== "paid"
Process-->>WebhookRoute: console.warn + return (skip)
else All checks pass
Process->>Process: Validate accountId and credits metadata
alt Missing/invalid metadata
Process-->>WebhookRoute: throw error (Stripe will retry)
WebhookRoute-->>StripeAPI: 500 { error: "Webhook handler failed" }
else Metadata valid
Process->>IncCredits: incrementRemainingCredits({ accountId, delta: credits })
IncCredits->>Supabase: selectCreditsUsage({ account_id })
Supabase-->>IncCredits: current balance row
IncCredits->>Supabase: updateCreditsUsage({ remaining_credits: newBalance })
Supabase-->>IncCredits: updated row
IncCredits-->>Process: updated CreditsUsage
Process-->>WebhookRoute: success
WebhookRoute-->>StripeAPI: 200 { received: true }
end
end
Note over WebhookRoute,StripeAPI: Unhandled event types (e.g. customer.created)<br/>return 200 { received: true } immediately
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| } | ||
|
|
||
| const current = rows[0]; | ||
| const newBalance = current.remaining_credits + delta; |
There was a problem hiding this comment.
P1: This increment is non-atomic and can lose credits under concurrent updates (read-modify-write race). Use a single DB-side atomic increment (or transactional lock) instead of computing newBalance in application code.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/credits_usage/incrementRemainingCredits.ts, line 24:
<comment>This increment is non-atomic and can lose credits under concurrent updates (read-modify-write race). Use a single DB-side atomic increment (or transactional lock) instead of computing `newBalance` in application code.</comment>
<file context>
@@ -0,0 +1,30 @@
+ }
+
+ const current = rows[0];
+ const newBalance = current.remaining_credits + delta;
+
+ return updateCreditsUsage({
</file context>
|
|
||
| describe("app/api/webhooks/stripe/route", () => { | ||
| it("exports POST and OPTIONS handlers", () => { | ||
| expect(typeof POST).toBe("function"); |
There was a problem hiding this comment.
P2: Custom agent: Flag AI Slop and Fabricated Changes
Test only checks that the route exports exist instead of asserting the webhook route behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/webhooks/stripe/__tests__/route.test.ts, line 15:
<comment>Test only checks that the route exports exist instead of asserting the webhook route behavior.</comment>
<file context>
@@ -0,0 +1,18 @@
+
+describe("app/api/webhooks/stripe/route", () => {
+ it("exports POST and OPTIONS handlers", () => {
+ expect(typeof POST).toBe("function");
+ expect(typeof OPTIONS).toBe("function");
+ });
</file context>
Preview verification trailPreview: https://api-git-feat-credits-topup-checkout-and-webhook-recoup.vercel.app Confirmed Test fixtures usedA fresh isolated test account was provisioned through the existing public curl -s -X POST -H "content-type: application/json" \
-d '{"email":"agent+credits-test-1778595900@recoupable.com"}' \
https://api-git-feat-credits-topup-checkout-and-webhook-recoup.vercel.app/api/agents/signup→ Returned What's been verified end-to-end against the preview
Live checkout session for the webhook → balance-increment pathThe session below was created against the preview using the fixtures above:
How a reviewer can re-verify (no payment needed)PREVIEW=https://api-git-feat-credits-topup-checkout-and-webhook-recoup.vercel.app
# 1. Mint a fresh test account + key
RESP=$(curl -s -X POST -H "content-type: application/json" \
-d "{\"email\":\"agent+test-$(date +%s)@recoupable.com\"}" \
"$PREVIEW/api/agents/signup")
API_KEY=$(echo "$RESP" | jq -r .api_key)
ACCOUNT=$(echo "$RESP" | jq -r .account_id)
# 2. Read fresh balance (expect 333/333)
curl -s -H "x-api-key: $API_KEY" "$PREVIEW/api/accounts/$ACCOUNT/credits" | jq
# 3. Create a checkout session (expect 200 with cs_live_ id + pay.recoupable.com URL)
curl -s -X POST -H "content-type: application/json" -H "x-api-key: $API_KEY" \
-d '{"successUrl":"https://chat.recoupable.com?credits=success","credits":250}' \
"$PREVIEW/api/credits/sessions" | jq
# 4. Confirm webhook signature path (expect 400 "Invalid Stripe signature")
curl -s -X POST -H "stripe-signature: t=1,v1=deadbeef" -H "content-type: application/json" \
-d '{}' "$PREVIEW/api/webhooks/stripe"Final step pendingAfter the author completes the live $2.50 payment, run: curl -s -H "x-api-key: <author-only>" \
"$PREVIEW/api/accounts/5e1ed046-c228-493b-94b5-67725393f629/credits"Expected: |
sturdier error mappers Fee model (option B per the PR discussion): - New lib/stripe/computeCreditsTopupCharge.ts grosses up the charge so the customer covers the 2.9% + 30¢ US card fee. For 250 credits → $2.89 total (250¢ credits + 39¢ "Processing fee" line item). Business nets the full credits-cents value after Stripe takes its cut. - createCreditsStripeSession now emits two line_items so the customer sees an explicit fee breakdown at checkout. Webhook still only credits metadata.credits — the fee line is purely a Stripe-facing artifact. - New constants STRIPE_CARD_FEE_PERCENTAGE / STRIPE_CARD_FEE_FIXED_CENTS in lib/stripe/config.ts. Review-comment fixes: - Move CREDIT_TOPUP_PURPOSE out of config.ts into its own file so lib/stripe/config.ts stays for environment-derived configuration (sweetman comment on config.ts). - Drop the redundant re-destructure in createCreditsSessionHandler — validated already matches createCreditsStripeSession's param shape (sweetman comment on the handler). - mapToCreditsSessionError.catch JSON parse failures so the mapper always returns a NextResponse instead of throwing (cubic P2). - Standardize webhook 500 message to "Internal server error" per team convention (cubic P2). Test reorganization to honor the 100-line file rule + the route-test pattern established by /api/subscriptions/sessions: - Split validateCreateCreditsSessionRequest.test.ts into .body.test.ts + .auth.test.ts (cubic P2). - Trim processCreditsTopupSession.test.ts from 110 → 69 lines via it.each parameterization (cubic P3). - Extract route mocks to routeTestMocks.ts and add route.post.outcomes.test.ts for both /credits/sessions and /webhooks/stripe routes, covering validation, success, and error paths through the POST entry point (cubic P2/P3 — answers "smoke test only" critique). Deferred to follow-up (cubic P1): atomic increment in incrementRemainingCredits. Current read-modify-write matches the existing pattern in deductCredits.ts and is one half of the same follow-up that adds idempotency-via-event-id (both need a Postgres RPC + a database submodule migration). Tests: 2756 / 2756 green (was 2737 pre-fee). Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review responses + customer-paid Stripe feesPushed Direct comments
Cubic bot comments
Bonus — fee model now lives in this PRPer @sweetmantech's "customers pay the fee" direction, the checkout session now bills the customer for both credits and the Stripe processing fee via a second
A small docs follow-up PR will land separately to update the OpenAPI description so the public reference matches what the customer actually pays. Tests2756 / 2756 green (was 2737 pre-fee, +19 from new fee helper tests, parameterized re-tests, and the new route-outcomes tests). |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (6)
app/api/webhooks/stripe/route.ts (1)
10-15: ⚡ Quick winRemove unnecessary async keyword from OPTIONS handler.
The OPTIONS handler doesn't perform any asynchronous operations, so the
asynckeyword is unnecessary overhead.♻️ Proposed simplification
-export async function OPTIONS() { +export function OPTIONS() { return new NextResponse(null, {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/api/webhooks/stripe/route.ts` around lines 10 - 15, The OPTIONS handler is declared async but performs no asynchronous work; remove the async keyword from the export function declaration (export function OPTIONS() {...}) so the handler is a plain synchronous function, ensuring there are no await uses inside OPTIONS and leaving the returned NextResponse and getCorsHeaders() call unchanged.lib/stripe/createCreditsSessionHandler.ts (1)
6-32: ⚖️ Poor tradeoffFunction exceeds 20-line guideline.
The handler function is 25 lines, which exceeds the coding guideline of keeping functions under 20 lines. While the logic is clear and the function is reasonably cohesive for an API handler, consider extracting helpers for response formatting or error handling if this grows further.
For now this is acceptable given the clear flow, but keep this in mind for future changes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/stripe/createCreditsSessionHandler.ts` around lines 6 - 32, The createCreditsSessionHandler function exceeds the 20-line guideline; extract small helpers to reduce its length: move the JSON response creation into a helper like formatJsonResponse(body, status) (used instead of direct NextResponse.json calls) and move the catch-block response into a createErrorResponse(error, status) or handleInternalError(error) helper that logs and returns the 500 response; update createCreditsSessionHandler to call validateCreateCreditsSessionRequest and createCreditsStripeSession as before but delegate all NextResponse.json and error logging to those helpers so the handler body shrinks under 20 lines while keeping the same behavior (references: createCreditsSessionHandler, validateCreateCreditsSessionRequest, createCreditsStripeSession, getCorsHeaders).app/api/credits/sessions/route.ts (1)
10-15: ⚡ Quick winRemove unnecessary async keyword from OPTIONS handler.
The OPTIONS handler performs no asynchronous operations, making the
asynckeyword unnecessary.♻️ Proposed simplification
-export async function OPTIONS() { +export function OPTIONS() { return new NextResponse(null, {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/api/credits/sessions/route.ts` around lines 10 - 15, The OPTIONS handler is declared async though it performs no async work; remove the unnecessary async keyword from the exported OPTIONS function (change export async function OPTIONS() to export function OPTIONS()) so it’s a plain synchronous handler and rerun type/lint checks to ensure no usages relied on a Promise return type.lib/stripe/stripeWebhookHandler.ts (2)
15-20: ⚡ Quick winLog unhandled event types for observability.
The handler silently returns success for non-
checkout.session.completedevents. Adding logging would help identify which events are being received but not processed, which is valuable for monitoring and future feature work.📊 Proposed logging enhancement
try { if (event.type === "checkout.session.completed") { await processCreditsTopupSession(event.data.object as Stripe.Checkout.Session); + } else { + console.log("[stripeWebhookHandler] Unhandled event type:", event.type); } return NextResponse.json({ received: true }, { status: 200, headers: getCorsHeaders() });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/stripe/stripeWebhookHandler.ts` around lines 15 - 20, The webhook handler currently ignores non-checkout events; add a log for unhandled event types so we can observe incoming but unprocessed events. Inside the try block where you check event.type === "checkout.session.completed" (in the stripeWebhookHandler / handler that calls processCreditsTopupSession), add a single informational log that records event.type and event.id (or other non-sensitive identifiers) when the type is not "checkout.session.completed"; use the project's logger (e.g., processLogger) if available, or console.log otherwise, and avoid dumping sensitive payloads—then return the same NextResponse.json(...) as before.
17-17: ⚡ Quick winType assertion bypasses runtime safety.
The type assertion
as Stripe.Checkout.Sessionassumes the event payload matches without validation. While Stripe's webhook signature ensures authenticity, object shape mismatches could still cause runtime errors inprocessCreditsTopupSession.Consider either:
- Adding a runtime check for expected properties before the cast, or
- Document the assumption that Stripe guarantees the shape for this event type
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/stripe/stripeWebhookHandler.ts` at line 17, The code unsafely casts event.data.object to Stripe.Checkout.Session before verifying shape, which can cause runtime errors in processCreditsTopupSession; update the stripeWebhookHandler to validate the event type and required session properties (e.g., event.type === 'checkout.session.completed' and presence of id, amount_total, currency, metadata or whatever processCreditsTopupSession expects) before calling processCreditsTopupSession, returning/throwing a clear error if validation fails, or alternatively add an explicit comment documenting the strict assumption that Stripe guarantees the payload shape for this event type.lib/stripe/verifyStripeWebhookEvent.ts (1)
20-21: ⚡ Quick winLog the caught error for debugging webhook signature failures.
The catch block silently swallows the underlying error, making it difficult to diagnose why signature verification failed (wrong secret, malformed signature, expired timestamp, etc.). Consider logging the error while still returning the generic message to the caller.
🔍 Proposed fix to add error logging
- } catch { + } catch (error) { + console.error("[verifyStripeWebhookEvent] Signature verification failed:", error); return { error: "Invalid Stripe signature" }; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/stripe/verifyStripeWebhookEvent.ts` around lines 20 - 21, In verifyStripeWebhookEvent's catch block, stop silently swallowing the exception: catch the error as a variable (e.g., catch (err)) and log the error details using the project's logger or console (e.g., processLogger.error(err) or console.error("Stripe signature verification failed:", err)) before returning the existing generic { error: "Invalid Stripe signature" } response so debugging info is preserved while the outward message remains unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/stripe/config.ts`:
- Line 3: The exported STRIPE_WEBHOOK_SECRET currently defaults to an empty
string which hides misconfiguration; change the module to fail fast by reading
process.env.STRIPE_WEBHOOK_SECRET and throwing an explicit Error (or using a
small helper like getRequiredEnv/ensureEnv) when the value is missing or empty,
so STRIPE_WEBHOOK_SECRET is only exported if present and application startup
fails with a clear message instead of deferring errors to webhook runtime.
In `@lib/stripe/processCreditsTopupSession.ts`:
- Line 31: In processCreditsTopupSession, make applying a Stripe top-up
idempotent by recording and checking the Stripe event.id before calling
incrementRemainingCredits; implement a persistent table/collection (e.g.,
credit_topup_events with unique event_id) and in processCreditsTopupSession
check if event.id already exists and return no-op if so, otherwise within a DB
transaction insert the event record (event_id, accountId, credits, metadata) and
then call incrementRemainingCredits; ensure the insert uses a unique constraint
on event_id and handle duplicate-key errors as a safe no-op to protect against
concurrent webhook retries.
In `@lib/supabase/credits_usage/incrementRemainingCredits.ts`:
- Around line 18-29: The current read-modify-write in incrementRemainingCredits
(uses selectCreditsUsage and updateCreditsUsage) is non-atomic and can lose
updates; replace it with a single DB-side atomic increment (e.g., call an RPC or
run an UPDATE ... SET remaining_credits = remaining_credits + :delta RETURNING *
or use the Supabase client's increment/pg function) so the database performs the
addition atomically and returns the new row; update the function to accept
accountId and delta, perform that single SQL update (or RPC) and return the
updated row, and remove the separate selectCreditsUsage call to avoid race
conditions.
---
Nitpick comments:
In `@app/api/credits/sessions/route.ts`:
- Around line 10-15: The OPTIONS handler is declared async though it performs no
async work; remove the unnecessary async keyword from the exported OPTIONS
function (change export async function OPTIONS() to export function OPTIONS())
so it’s a plain synchronous handler and rerun type/lint checks to ensure no
usages relied on a Promise return type.
In `@app/api/webhooks/stripe/route.ts`:
- Around line 10-15: The OPTIONS handler is declared async but performs no
asynchronous work; remove the async keyword from the export function declaration
(export function OPTIONS() {...}) so the handler is a plain synchronous
function, ensuring there are no await uses inside OPTIONS and leaving the
returned NextResponse and getCorsHeaders() call unchanged.
In `@lib/stripe/createCreditsSessionHandler.ts`:
- Around line 6-32: The createCreditsSessionHandler function exceeds the 20-line
guideline; extract small helpers to reduce its length: move the JSON response
creation into a helper like formatJsonResponse(body, status) (used instead of
direct NextResponse.json calls) and move the catch-block response into a
createErrorResponse(error, status) or handleInternalError(error) helper that
logs and returns the 500 response; update createCreditsSessionHandler to call
validateCreateCreditsSessionRequest and createCreditsStripeSession as before but
delegate all NextResponse.json and error logging to those helpers so the handler
body shrinks under 20 lines while keeping the same behavior (references:
createCreditsSessionHandler, validateCreateCreditsSessionRequest,
createCreditsStripeSession, getCorsHeaders).
In `@lib/stripe/stripeWebhookHandler.ts`:
- Around line 15-20: The webhook handler currently ignores non-checkout events;
add a log for unhandled event types so we can observe incoming but unprocessed
events. Inside the try block where you check event.type ===
"checkout.session.completed" (in the stripeWebhookHandler / handler that calls
processCreditsTopupSession), add a single informational log that records
event.type and event.id (or other non-sensitive identifiers) when the type is
not "checkout.session.completed"; use the project's logger (e.g., processLogger)
if available, or console.log otherwise, and avoid dumping sensitive
payloads—then return the same NextResponse.json(...) as before.
- Line 17: The code unsafely casts event.data.object to Stripe.Checkout.Session
before verifying shape, which can cause runtime errors in
processCreditsTopupSession; update the stripeWebhookHandler to validate the
event type and required session properties (e.g., event.type ===
'checkout.session.completed' and presence of id, amount_total, currency,
metadata or whatever processCreditsTopupSession expects) before calling
processCreditsTopupSession, returning/throwing a clear error if validation
fails, or alternatively add an explicit comment documenting the strict
assumption that Stripe guarantees the payload shape for this event type.
In `@lib/stripe/verifyStripeWebhookEvent.ts`:
- Around line 20-21: In verifyStripeWebhookEvent's catch block, stop silently
swallowing the exception: catch the error as a variable (e.g., catch (err)) and
log the error details using the project's logger or console (e.g.,
processLogger.error(err) or console.error("Stripe signature verification
failed:", err)) before returning the existing generic { error: "Invalid Stripe
signature" } response so debugging info is preserved while the outward message
remains unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 7d570858-786f-4fd1-a9f3-0c9c5aad3dde
⛔ Files ignored due to path filters (16)
app/api/credits/sessions/__tests__/route.post.outcomes.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included byapp/**app/api/credits/sessions/__tests__/route.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included byapp/**app/api/credits/sessions/__tests__/routeTestMocks.tsis excluded by!**/__tests__/**and included byapp/**app/api/webhooks/stripe/__tests__/route.post.outcomes.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included byapp/**app/api/webhooks/stripe/__tests__/route.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included byapp/**app/api/webhooks/stripe/__tests__/routeTestMocks.tsis excluded by!**/__tests__/**and included byapp/**lib/stripe/__tests__/computeCreditsTopupCharge.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/createCreditsSessionHandler.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/createCreditsSessionSchemas.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/createCreditsStripeSession.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/processCreditsTopupSession.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/stripeWebhookHandler.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/validateCreateCreditsSessionRequest.auth.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/validateCreateCreditsSessionRequest.body.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/verifyStripeWebhookEvent.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/supabase/credits_usage/__tests__/incrementRemainingCredits.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**
📒 Files selected for processing (14)
app/api/credits/sessions/route.tsapp/api/webhooks/stripe/route.tslib/stripe/computeCreditsTopupCharge.tslib/stripe/config.tslib/stripe/createCreditsSessionHandler.tslib/stripe/createCreditsSessionSchemas.tslib/stripe/createCreditsStripeSession.tslib/stripe/creditsTopupPurpose.tslib/stripe/mapToCreditsSessionError.tslib/stripe/processCreditsTopupSession.tslib/stripe/stripeWebhookHandler.tslib/stripe/validateCreateCreditsSessionRequest.tslib/stripe/verifyStripeWebhookEvent.tslib/supabase/credits_usage/incrementRemainingCredits.ts
| @@ -1,2 +1,8 @@ | |||
| export const STRIPE_SUBSCRIPTION_PRICE_ID = "price_1RyDFD00JObOnOb53PcVOeBz"; | |||
| export const STRIPE_SUBSCRIPTION_TRIAL_PERIOD_DAYS = 30; | |||
| export const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET ?? ""; | |||
There was a problem hiding this comment.
Fail fast when STRIPE_WEBHOOK_SECRET is missing.
Defaulting to "" hides bad configuration and pushes failure to runtime webhook traffic. Prefer a required-env guard at startup.
Suggested change
-export const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET ?? "";
+const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
+if (!stripeWebhookSecret) {
+ throw new Error("Missing required env var: STRIPE_WEBHOOK_SECRET");
+}
+export const STRIPE_WEBHOOK_SECRET = stripeWebhookSecret;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/stripe/config.ts` at line 3, The exported STRIPE_WEBHOOK_SECRET currently
defaults to an empty string which hides misconfiguration; change the module to
fail fast by reading process.env.STRIPE_WEBHOOK_SECRET and throwing an explicit
Error (or using a small helper like getRequiredEnv/ensureEnv) when the value is
missing or empty, so STRIPE_WEBHOOK_SECRET is only exported if present and
application startup fails with a clear message instead of deferring errors to
webhook runtime.
| ); | ||
| } | ||
|
|
||
| await incrementRemainingCredits({ accountId, delta: credits }); |
There was a problem hiding this comment.
Make credit application idempotent before incrementing balance.
Webhook retries/replays can hit this code more than once, and each pass increments credits again. Persist/check Stripe event.id (unique) before incrementRemainingCredits so duplicates no-op.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/stripe/processCreditsTopupSession.ts` at line 31, In
processCreditsTopupSession, make applying a Stripe top-up idempotent by
recording and checking the Stripe event.id before calling
incrementRemainingCredits; implement a persistent table/collection (e.g.,
credit_topup_events with unique event_id) and in processCreditsTopupSession
check if event.id already exists and return no-op if so, otherwise within a DB
transaction insert the event record (event_id, accountId, credits, metadata) and
then call incrementRemainingCredits; ensure the insert uses a unique constraint
on event_id and handle duplicate-key errors as a safe no-op to protect against
concurrent webhook retries.
| const rows = await selectCreditsUsage({ account_id: accountId }); | ||
| if (!rows || rows.length === 0) { | ||
| throw new Error(`No credits usage found for account_id: ${accountId}`); | ||
| } | ||
|
|
||
| const current = rows[0]; | ||
| const newBalance = current.remaining_credits + delta; | ||
|
|
||
| return updateCreditsUsage({ | ||
| account_id: accountId, | ||
| updates: { remaining_credits: newBalance }, | ||
| }); |
There was a problem hiding this comment.
Use an atomic DB-side increment to avoid lost credits.
This read-modify-write sequence is non-atomic. Two webhook deliveries can read the same remaining_credits and one write will overwrite the other. Please switch to a single atomic increment statement (or RPC) in the database.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/supabase/credits_usage/incrementRemainingCredits.ts` around lines 18 -
29, The current read-modify-write in incrementRemainingCredits (uses
selectCreditsUsage and updateCreditsUsage) is non-atomic and can lose updates;
replace it with a single DB-side atomic increment (e.g., call an RPC or run an
UPDATE ... SET remaining_credits = remaining_credits + :delta RETURNING * or use
the Supabase client's increment/pg function) so the database performs the
addition atomically and returns the new row; update the function to accept
accountId and delta, perform that single SQL update (or RPC) and return the
updated row, and remove the separate selectCreditsUsage call to avoid race
conditions.
✅ End-to-end verified on previewReal Stripe live payment completed against the preview deployment. Live results
{
"account_id": "8602d907-811d-4e75-baa2-5f53c1c09c44",
"remaining_credits": 351,
"total_credits": 333,
"used_credits": 0,
"is_pro": false,
"timestamp": "2026-05-12T14:50:56.950655"
}What this proves
Minor inconsistencies surfaced (not blocking — fold into the atomic-increment follow-up)
Ready for review
|
…553) (#554) * feat: POST /api/credits/sessions + Stripe webhook for credit top-ups Documentation-driven follow-up to docs#205. Adds a one-time-payment Stripe Checkout flow for arbitrary credit top-ups, plus the first Stripe webhook in the codebase to credit accounts on payment success. Route layer (mirrors POST /api/subscriptions/sessions exactly): - POST /api/credits/sessions: { successUrl, credits, accountId? } -> { id, url } - 1 credit = 1 US cent, priced via Stripe price_data (unit_amount: 1), mode: "payment". Session metadata carries accountId + credits + purpose: "credits_topup" so the webhook can credit the right account. Webhook layer (new): - POST /api/webhooks/stripe: signature-verified via stripe.webhooks.constructEvent + STRIPE_WEBHOOK_SECRET. Handles checkout.session.completed events and increments the account's credits_usage.remaining_credits by the metadata-declared amount. Returns 500 on handler failure so Stripe retries. Known gap: idempotency-via-event-id is deferred (Stripe retries on 5xx have a small double-credit window). Planned follow-up: add a stripe_webhook_events dedupe table (requires a database migration). TDD: 41 new tests across 10 files (schema, validate, stripe session, handler, webhook verify, process, route exports, increment helper). 2737/2737 tests green; pnpm lint:check clean. * fix: review feedback — customer-paid Stripe fees, smaller test files, sturdier error mappers Fee model (option B per the PR discussion): - New lib/stripe/computeCreditsTopupCharge.ts grosses up the charge so the customer covers the 2.9% + 30¢ US card fee. For 250 credits → $2.89 total (250¢ credits + 39¢ "Processing fee" line item). Business nets the full credits-cents value after Stripe takes its cut. - createCreditsStripeSession now emits two line_items so the customer sees an explicit fee breakdown at checkout. Webhook still only credits metadata.credits — the fee line is purely a Stripe-facing artifact. - New constants STRIPE_CARD_FEE_PERCENTAGE / STRIPE_CARD_FEE_FIXED_CENTS in lib/stripe/config.ts. Review-comment fixes: - Move CREDIT_TOPUP_PURPOSE out of config.ts into its own file so lib/stripe/config.ts stays for environment-derived configuration (sweetman comment on config.ts). - Drop the redundant re-destructure in createCreditsSessionHandler — validated already matches createCreditsStripeSession's param shape (sweetman comment on the handler). - mapToCreditsSessionError.catch JSON parse failures so the mapper always returns a NextResponse instead of throwing (cubic P2). - Standardize webhook 500 message to "Internal server error" per team convention (cubic P2). Test reorganization to honor the 100-line file rule + the route-test pattern established by /api/subscriptions/sessions: - Split validateCreateCreditsSessionRequest.test.ts into .body.test.ts + .auth.test.ts (cubic P2). - Trim processCreditsTopupSession.test.ts from 110 → 69 lines via it.each parameterization (cubic P3). - Extract route mocks to routeTestMocks.ts and add route.post.outcomes.test.ts for both /credits/sessions and /webhooks/stripe routes, covering validation, success, and error paths through the POST entry point (cubic P2/P3 — answers "smoke test only" critique). Deferred to follow-up (cubic P1): atomic increment in incrementRemainingCredits. Current read-modify-write matches the existing pattern in deductCredits.ts and is one half of the same follow-up that adds idempotency-via-event-id (both need a Postgres RPC + a database submodule migration). Tests: 2756 / 2756 green (was 2737 pre-fee). Lint clean. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Implementation of the credits top-up endpoint documented in docs#205. PR 2 of 3 (docs ✅ → api (this PR) → open-agents).
Adds a one-time-payment Stripe Checkout flow that lets clients purchase an arbitrary number of credits at 1 credit = 1 US cent, plus the first Stripe webhook in the codebase so that the purchased credits actually land on the account when payment completes.
Route layer —
POST /api/credits/sessionsMirrors
POST /api/subscriptions/sessions1:1 in structure (same handler split: schema → validate → create → handler → thin route).{ successUrl: string, credits: number (int ≥ 1), accountId?: uuid }{ id, url }— hosted Stripe Checkout URLprice_datawithunit_amount: 1,currency: "usd",mode: "payment". Total charge =creditscents.validateAuthContextwith optional adminaccountIdoverride (per the OpenAPI schema; subscription endpoint has the same body shape but doesn't wire the override — minor pre-existing drift).{ accountId, credits, purpose: "credits_topup" }— also mirrored ontopayment_intent_data.metadataso it survives into the PaymentIntent.Webhook layer —
POST /api/webhooks/stripeFirst Stripe webhook in the codebase. Verifies the signature with
stripe.webhooks.constructEventagainstSTRIPE_WEBHOOK_SECRET, then dispatches byevent.type:checkout.session.completed→processCreditsTopupSession:mode !== "payment"ormetadata.purpose !== "credits_topup"(so other event shapes pass through harmlessly).console.warnifpayment_status !== "paid"(handles async-payment cases).metadata.accountIdormetadata.creditsis missing/invalid (Stripe will retry).incrementRemainingCredits({ accountId, delta: credits }).200 { received: true }(so Stripe stops retrying).500 { error: "Webhook handler failed" }so Stripe retries.New env var
```
STRIPE_WEBHOOK_SECRET=whsec_...
```
Needed in preview + prod before merge. Stripe Dashboard → Developers → Webhooks → add endpoint
https://<host>/api/webhooks/stripelistening tocheckout.session.completed, copy the signing secret.TDD trail
Strict red → green → refactor. 41 new tests across 10 files:
Local:
pnpm test→ 2737/2737 ✅ ;pnpm lint:check→ clean. Pre-existing TS errors in unrelated files (trigger, getCreditUsage tests, next.config.ts workflow plugin) are not from this PR.Known gap (deferred follow-up)
No idempotency-via-event-id yet. If
processCreditsTopupSessionincrements credits but the webhook response fails to land (timeout, etc.), Stripe will retry → double-credit. The window is small (Stripe retries only on 5xx, and we return 200 right after the DB write succeeds), and the per-event exposure is bounded by the purchase size, but we should close it before scaling. Plan: astripe_webhook_events(event_id pk, processed_at)dedupe table — requires a database migration, so left as a separate PR.Test plan
STRIPE_WEBHOOK_SECRETfrom astripe listen --forward-to ...sessioncurl -X POST .../api/credits/sessions -H 'x-api-key: …' -d '{"successUrl":"https://chat.recoupable.com?credits=success","credits":250}'→ returns{ id, url }url, complete checkout with Stripe test cardstripe listenshowscheckout.session.completeddelivered to/api/webhooks/stripeGET /api/accounts/{id}/creditsshows balance increased by 250customer.created) return 200 silentlystripe-signatureheader returns 400credits: 0, missingsuccessUrl, malformedaccountIdall return 400Follow-ups
<CreditsMeter />in the profile sidebar — POSTs to/api/credits/sessions, redirects tosession.url.stripe_webhook_eventsdedupe table; switch webhook to INSERT-on-conflict for idempotency.accountId?but the impl doesn't honor it — bring the two into sync in either direction.🤖 Generated with Claude Code
Summary by cubic
Adds a one-time Stripe Checkout flow for credit top-ups and a Stripe webhook to credit accounts on payment success. Checkout now grosses up charges so customers cover Stripe fees while accounts receive the full purchased credits.
New Features
POST /api/credits/sessions→ creates a Stripe Checkout session to buy credits.{ successUrl: string, credits: int >= 1, accountId?: uuid }{ id, url }creditsamount.{ accountId, credits, purpose: "credits_topup" }on session and PaymentIntent.validateAuthContext; adminaccountIdoverride supported.POST /api/webhooks/stripe→ verifies signature and handlescheckout.session.completed.mode: "payment"withpurpose: "credits_topup"andpayment_status: "paid".credits.Internal server erroron handler errors so Stripe retries (idempotency via event id is a planned follow-up).Migration
STRIPE_WEBHOOK_SECRETin env.https://<host>/api/webhooks/stripeand subscribe tocheckout.session.completed.Written for commit 890a4bf. Summary will update on new commits.
Summary by CodeRabbit