Skip to content

feat: POST /api/credits/sessions + Stripe webhook for credit top-ups#553

Merged
sweetmantech merged 2 commits into
testfrom
feat/credits-topup-checkout-and-webhook
May 12, 2026
Merged

feat: POST /api/credits/sessions + Stripe webhook for credit top-ups#553
sweetmantech merged 2 commits into
testfrom
feat/credits-topup-checkout-and-webhook

Conversation

@sweetmantech
Copy link
Copy Markdown
Contributor

@sweetmantech sweetmantech commented May 12, 2026

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

Mirrors POST /api/subscriptions/sessions 1:1 in structure (same handler split: schema → validate → create → handler → thin route).

  • Request body: { successUrl: string, credits: number (int ≥ 1), accountId?: uuid }
  • Response: { id, url } — hosted Stripe Checkout URL
  • Pricing: Stripe price_data with unit_amount: 1, currency: "usd", mode: "payment". Total charge = credits cents.
  • Auth: validateAuthContext with optional admin accountId override (per the OpenAPI schema; subscription endpoint has the same body shape but doesn't wire the override — minor pre-existing drift).
  • Metadata stamped on the session: { accountId, credits, purpose: "credits_topup" } — also mirrored onto payment_intent_data.metadata so it survives into the PaymentIntent.

Webhook layer — POST /api/webhooks/stripe

First Stripe webhook in the codebase. Verifies the signature with stripe.webhooks.constructEvent against STRIPE_WEBHOOK_SECRET, then dispatches by event.type:

  • checkout.session.completedprocessCreditsTopupSession:
    • Returns early if mode !== "payment" or metadata.purpose !== "credits_topup" (so other event shapes pass through harmlessly).
    • Skips with a console.warn if payment_status !== "paid" (handles async-payment cases).
    • Throws if metadata.accountId or metadata.credits is missing/invalid (Stripe will retry).
    • Calls incrementRemainingCredits({ accountId, delta: credits }).
  • Unhandled event types → 200 { received: true } (so Stripe stops retrying).
  • Any thrown error → 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/stripe listening to checkout.session.completed, copy the signing secret.

TDD trail

Strict red → green → refactor. 41 new tests across 10 files:

Test file Tests
`lib/stripe/tests/createCreditsSessionSchemas.test.ts` 10
`lib/stripe/tests/validateCreateCreditsSessionRequest.test.ts` 7
`lib/stripe/tests/createCreditsStripeSession.test.ts` 2
`lib/stripe/tests/createCreditsSessionHandler.test.ts` 4
`lib/stripe/tests/verifyStripeWebhookEvent.test.ts` 3
`lib/stripe/tests/processCreditsTopupSession.test.ts` 6
`lib/stripe/tests/stripeWebhookHandler.test.ts` 4
`lib/supabase/credits_usage/tests/incrementRemainingCredits.test.ts` 3
`app/api/credits/sessions/tests/route.test.ts` 1
`app/api/webhooks/stripe/tests/route.test.ts` 1

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 processCreditsTopupSession increments 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: a stripe_webhook_events(event_id pk, processed_at) dedupe table — requires a database migration, so left as a separate PR.

Test plan

  • Preview env: set STRIPE_WEBHOOK_SECRET from a stripe listen --forward-to ... session
  • curl -X POST .../api/credits/sessions -H 'x-api-key: …' -d '{"successUrl":"https://chat.recoupable.com?credits=success","credits":250}' → returns { id, url }
  • Visit the returned url, complete checkout with Stripe test card
  • stripe listen shows checkout.session.completed delivered to /api/webhooks/stripe
  • GET /api/accounts/{id}/credits shows balance increased by 250
  • Unhandled event types (e.g. customer.created) return 200 silently
  • Missing/invalid stripe-signature header returns 400
  • credits: 0, missing successUrl, malformed accountId all return 400

Follow-ups

  • PR 3 (open-agents): "Top up credits" CTA inside <CreditsMeter /> in the profile sidebar — POSTs to /api/credits/sessions, redirects to session.url.
  • Database PR: add stripe_webhook_events dedupe table; switch webhook to INSERT-on-conflict for idempotency.
  • Docs cleanup: subscription endpoint OpenAPI lists 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.
      • Body: { successUrl: string, credits: int >= 1, accountId?: uuid }
      • Response: { id, url }
      • Pricing: 1 credit = 1¢ net. Session has two line items: credits at 1¢ each + a computed "Processing fee" to cover 2.9% + 30¢ (US card). Webhook credits only the credits amount.
      • Stamps metadata { accountId, credits, purpose: "credits_topup" } on session and PaymentIntent.
      • Auth via validateAuthContext; admin accountId override supported.
    • POST /api/webhooks/stripe → verifies signature and handles checkout.session.completed.
      • Only processes mode: "payment" with purpose: "credits_topup" and payment_status: "paid".
      • Increments account credits by metadata credits.
      • Returns 500 with Internal server error on handler errors so Stripe retries (idempotency via event id is a planned follow-up).
  • Migration

    • Set STRIPE_WEBHOOK_SECRET in env.
    • In Stripe Dashboard, add webhook endpoint https://<host>/api/webhooks/stripe and subscribe to checkout.session.completed.

Written for commit 890a4bf. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Users can now purchase credits via Stripe integration for account usage.
    • Automatic processing and application of credits to accounts upon successful payment completion.

Review Change Stack

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>
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 12, 2026

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

Project Deployment Actions Updated (UTC)
api Ready Ready Preview May 12, 2026 2:46pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

📝 Walkthrough

Walkthrough

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

Changes

Stripe Credits Top-Up

Layer / File(s) Summary
Stripe Fee Configuration and Charge Calculation
lib/stripe/config.ts, lib/stripe/computeCreditsTopupCharge.ts, lib/stripe/creditsTopupPurpose.ts
Stripe configuration constants for webhook validation and card fees; computeCreditsTopupCharge applies inverse fee formula to gross-up charge amounts, covering Stripe's percentage and fixed-cent processing fees.
Request Validation Framework
lib/stripe/createCreditsSessionSchemas.ts, lib/stripe/validateCreateCreditsSessionRequest.ts, lib/stripe/mapToCreditsSessionError.ts
Zod schema validates incoming requests (successUrl, credits, optional accountId); request validator parses JSON, validates schema, checks authentication, and normalizes errors to CORS-enabled JSON responses.
Credits Session Creation
lib/stripe/createCreditsStripeSession.ts, lib/stripe/createCreditsSessionHandler.ts, app/api/credits/sessions/route.ts
Session handler composes Stripe Checkout with dual line items (credits and processing fee), metadata, and payment intent data; HTTP endpoint delegates to handler and returns session id and url with CORS headers.
Webhook Signature Verification
lib/stripe/verifyStripeWebhookEvent.ts
Extracts stripe-signature header, reads raw body, constructs Stripe event via client and webhook secret, returns structured result with event or error message.
Webhook Processing and Credits Update
lib/stripe/processCreditsTopupSession.ts, lib/supabase/credits_usage/incrementRemainingCredits.ts, lib/stripe/stripeWebhookHandler.ts, app/api/webhooks/stripe/route.ts
Webhook handler verifies signature, processes checkout.session.completed events, validates metadata and payment status, increments user credits balance in database, returns CORS-enabled success/error responses.

Sequence Diagram

sequenceDiagram
  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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • recoupable/api#493: Implements subscription checkout sessions using the same routing pattern, handler architecture, Stripe integration approach, and CORS/validation utilities.
  • recoupable/api#549: Updates credits usage data layer (initialization and insertion); this PR's webhook processing uses incrementRemainingCredits on the same credits_usage table and related helpers.

Poem

✨ Credits flow in, fees gross up high,
Sessions spawn, requests don't lie,
Webhooks verify, balances grow,
A payment dance, smooth as snow! 🎫

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/credits-topup-checkout-and-webhook

Warning

Review ran into problems

🔥 Problems

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

❤️ Share

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

Copy link
Copy Markdown

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

Choose a reason for hiding this comment

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

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.ts can 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.ts and lib/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
Loading

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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>

Comment thread lib/stripe/mapToCreditsSessionError.ts
Comment thread lib/stripe/stripeWebhookHandler.ts Outdated
Comment thread lib/stripe/__tests__/validateCreateCreditsSessionRequest.test.ts Outdated

describe("app/api/webhooks/stripe/route", () => {
it("exports POST and OPTIONS handlers", () => {
expect(typeof POST).toBe("function");
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: 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>

Comment thread lib/stripe/__tests__/processCreditsTopupSession.test.ts
Comment thread app/api/credits/sessions/__tests__/route.test.ts
Comment thread lib/stripe/config.ts Outdated
Comment thread lib/stripe/createCreditsSessionHandler.ts Outdated
@sweetmantech
Copy link
Copy Markdown
Contributor Author

Preview verification trail

Preview: https://api-git-feat-credits-topup-checkout-and-webhook-recoup.vercel.app
Deployment: https://vercel.com/recoup/api/6La7dyHrkRRuhub7U5Kyt7jAPtjg (Ready ✅)

Confirmed STRIPE_WEBHOOK_SECRET is populated in the preview env (signature verification path is exercised — see test #15 below).

Test fixtures used

A fresh isolated test account was provisioned through the existing public agent+ signup flow so the verification could run without touching any real customer data:

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 account_id=5e1ed046-c228-493b-94b5-67725393f629 and a fresh API key. (Key not pasted here — anyone reviewing can re-run the same signup against a new agent+...-test@recoupable.com email to get one.)

What's been verified end-to-end against the preview

# Path Expected Result
1 OPTIONS /api/credits/sessions 200 + CORS *
2 POST with no auth headers 401 Exactly one of x-api-key or Authorization must be provided
3 POST with BOTH x-api-key and Authorization 401 (same message)
4 POST with malformed JSON body 400 Invalid JSON body
5 POST missing successUrl 400 (Zod)
6 POST missing credits 400 credits is required
7 POST credits: 0 400 credits must be a positive integer
8 POST credits: 12.5 400 credits must be an integer
9 POST with unknown body key (strict schema) 400 Unrecognized key: "extra"
10 POST successUrl: "not-a-url" 400 successUrl must be a valid URL
11 POST with fake API key 401 Unauthorized
12 POST /api/agents/signup with agent+ email 200 with account_id + api_key
13 GET /api/accounts/{id}/credits on fresh account {remaining: 333, total: 333, used: 0, is_pro: false} (plan-aware seeding from #549 working)
14 POST /api/credits/sessions with valid auth + {successUrl, credits: 250} 200 {id, url} with a real Stripe Checkout URL
15 POST /api/webhooks/stripe with bogus stripe-signature header 400 Invalid Stripe signature (proves STRIPE_WEBHOOK_SECRET is set + constructEvent is wired)
16 POST /api/webhooks/stripe with no stripe-signature header 400 Missing stripe-signature header

Live checkout session for the webhook → balance-increment path

The session below was created against the preview using the fixtures above:

⚠️ Live Stripe key on preview — opening that URL and completing checkout will charge a real card $2.50. Author is completing the payment now; I'll post a follow-up comment with the balance-increment confirmation once it lands.

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 pending

After 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: remaining_credits jumps from 333583, timestamp updates.

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>
@sweetmantech
Copy link
Copy Markdown
Contributor Author

Review responses + customer-paid Stripe fees

Pushed 890a4bf addressing all in-line review feedback plus the fee model.

Direct comments

Comment File Resolution
YAGNI — what's the purpose of this const? lib/stripe/config.ts:4 Moved CREDIT_TOPUP_PURPOSE out of config.ts into its own file lib/stripe/creditsTopupPurpose.ts. config.ts is now exclusively for environment-derived configuration; the contract sentinel that has to stay in sync between writer (createCreditsStripeSession) and reader (processCreditsTopupSession) lives in its own one-line module with a JSDoc explaining why a constant is justified there.
KISS — pass validated directly lib/stripe/createCreditsSessionHandler.ts:17 Done. validated's shape already matches createCreditsStripeSession's param interface — the re-destructure was just noise.

Cubic bot comments

Severity Comment Resolution
P1 Race condition in incrementRemainingCredits Deferred to follow-up. Same read-modify-write pattern as the existing deductCredits.ts. The proper fix needs a Postgres INCREMENT RPC, which needs a database submodule migration. I'm pairing this fix with the idempotency-via-event-id follow-up (both need that migration) — see the existing "Known gap" section of the PR description. Comment thread to be marked resolved with this rationale.
P2 mapToCreditsSessionError JSON parse failure Fixed — added a .catch that falls back to { error: "Unauthorized" } so the mapper can never throw.
P2 Standardize 500 message Fixed — webhook handler now returns { error: "Internal server error" } (was "Webhook handler failed"). Webhook test updated.
P2 validateCreateCreditsSessionRequest.test.ts > 100 lines Fixed — split into .body.test.ts (51 lines, body-validation only, parameterized via it.each) and .auth.test.ts (83 lines, auth + happy path).
P3 processCreditsTopupSession.test.ts > 100 lines Fixed — trimmed from 110 → 69 lines by parameterizing the skip and throw paths via it.each.
P2/P3 Route tests only check exports Fixed — extracted routeTestMocks.ts and added route.post.outcomes.test.ts for both /api/credits/sessions and /api/webhooks/stripe covering validation, success, and error paths through the POST entry point. Matches the existing /api/subscriptions/sessions test layout exactly.

Bonus — fee model now lives in this PR

Per @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 line_items entry:

  • New lib/stripe/computeCreditsTopupCharge.ts computes totalCents = ⌈(credits + 30) / 0.971⌉ so the business nets exactly credits cents after Stripe takes 2.9% + 30¢. For 250 credits: customer pays $2.89, sees Recoup credits $2.50 + Processing fee $0.39 at checkout, business nets ≥ $2.50.
  • New constants STRIPE_CARD_FEE_PERCENTAGE / STRIPE_CARD_FEE_FIXED_CENTS in lib/stripe/config.ts.
  • Webhook side is unchanged — metadata.credits still drives the balance increment, the fee line is purely a Stripe-facing artifact.
  • 4 new tests for the gross-up helper, existing createCreditsStripeSession.test.ts updated to assert the two-line-item shape.

A small docs follow-up PR will land separately to update the OpenAPI description so the public reference matches what the customer actually pays.

Tests

2756 / 2756 green (was 2737 pre-fee, +19 from new fee helper tests, parameterized re-tests, and the new route-outcomes tests). pnpm lint:check clean. Preview redeploy should be picking up 890a4bf now.

Copy link
Copy Markdown

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

Choose a reason for hiding this comment

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

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

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (6)
app/api/webhooks/stripe/route.ts (1)

10-15: ⚡ Quick win

Remove unnecessary async keyword from OPTIONS handler.

The OPTIONS handler doesn't perform any asynchronous operations, so the async keyword 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 tradeoff

Function 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 win

Remove unnecessary async keyword from OPTIONS handler.

The OPTIONS handler performs no asynchronous operations, making the async keyword 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 win

Log unhandled event types for observability.

The handler silently returns success for non-checkout.session.completed events. 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 win

Type assertion bypasses runtime safety.

The type assertion as Stripe.Checkout.Session assumes the event payload matches without validation. While Stripe's webhook signature ensures authenticity, object shape mismatches could still cause runtime errors in processCreditsTopupSession.

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 win

Log 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

📥 Commits

Reviewing files that changed from the base of the PR and between d14d59f and 890a4bf.

⛔ Files ignored due to path filters (16)
  • app/api/credits/sessions/__tests__/route.post.outcomes.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • app/api/credits/sessions/__tests__/route.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • app/api/credits/sessions/__tests__/routeTestMocks.ts is excluded by !**/__tests__/** and included by app/**
  • app/api/webhooks/stripe/__tests__/route.post.outcomes.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • app/api/webhooks/stripe/__tests__/route.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • app/api/webhooks/stripe/__tests__/routeTestMocks.ts is excluded by !**/__tests__/** and included by app/**
  • lib/stripe/__tests__/computeCreditsTopupCharge.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/createCreditsSessionHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/createCreditsSessionSchemas.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/createCreditsStripeSession.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/processCreditsTopupSession.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/stripeWebhookHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/validateCreateCreditsSessionRequest.auth.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/validateCreateCreditsSessionRequest.body.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/verifyStripeWebhookEvent.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/credits_usage/__tests__/incrementRemainingCredits.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (14)
  • app/api/credits/sessions/route.ts
  • app/api/webhooks/stripe/route.ts
  • lib/stripe/computeCreditsTopupCharge.ts
  • lib/stripe/config.ts
  • lib/stripe/createCreditsSessionHandler.ts
  • lib/stripe/createCreditsSessionSchemas.ts
  • lib/stripe/createCreditsStripeSession.ts
  • lib/stripe/creditsTopupPurpose.ts
  • lib/stripe/mapToCreditsSessionError.ts
  • lib/stripe/processCreditsTopupSession.ts
  • lib/stripe/stripeWebhookHandler.ts
  • lib/stripe/validateCreateCreditsSessionRequest.ts
  • lib/stripe/verifyStripeWebhookEvent.ts
  • lib/supabase/credits_usage/incrementRemainingCredits.ts

Comment thread lib/stripe/config.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 ?? "";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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 });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

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.

Comment on lines +18 to +29
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 },
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

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.

@sweetmantech
Copy link
Copy Markdown
Contributor Author

✅ End-to-end verified on preview

Real Stripe live payment completed against the preview deployment.

Live results

Account 8602d907-811d-4e75-baa2-5f53c1c09c44
Checkout session cs_live_b1g3mvS1KTXWRoAVJHIgMvGEiOteOfZIdn9GUAbv4jeze50XJjZNxN876R
Purchased 18 credits (the gross-up math floor for Stripe's 50¢ USD minimum)
Customer charged $0.50 ($0.18 credits + $0.32 processing fee — confirmed two-line-item breakdown shown at checkout)
Balance before 333 / 333
Balance after 351 / 333
{
  "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

  • POST /api/credits/sessions creates a real cs_live_… Checkout Session
  • ✅ Two-line-item fee model bills the customer exactly credits + Stripe fees
  • ✅ Stripe Dashboard webhook endpoint delivers checkout.session.completed to /api/webhooks/stripe
  • verifyStripeWebhookEvent accepts the real signature against STRIPE_WEBHOOK_SECRET
  • processCreditsTopupSession reads metadata.{accountId, credits, purpose} correctly
  • incrementRemainingCredits writes the new balance to the right account
  • GET /api/accounts/{id}/credits reflects the increment immediately

Minor inconsistencies surfaced (not blocking — fold into the atomic-increment follow-up)

  1. timestamp is stale. incrementRemainingCredits only writes remaining_credits, so the response still shows the original seeding timestamp. The OpenAPI spec says timestamp reflects "the last balance update or monthly refill" — should be updated by the increment too.
  2. used_credits: 0 with total_credits: 333 < remaining_credits: 351. The builder clamps used = max(total - remaining, 0), which is correct, but total_credits no longer represents reality once a top-up pushes the balance above the plan allotment. Worth deciding: should total_credits track max(plan, remaining), or do we introduce a separate bonus_credits field for one-off top-ups? Either way it's a downstream / response-builder concern, not a webhook bug.

Ready for review

  • Tests: 2756 / 2756 green
  • Lint: clean
  • Real money flowed end-to-end on preview
  • Known gap (idempotency-via-event-id + atomic increment) documented and paired with the future database migration

Cc @sweetmantech

@sweetmantech sweetmantech merged commit 6c16583 into test May 12, 2026
6 checks passed
@sweetmantech sweetmantech deleted the feat/credits-topup-checkout-and-webhook branch May 12, 2026 14:54
sweetmantech added a commit that referenced this pull request May 12, 2026
…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>
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