fix(stripe): off-session fallback for any card-level error#561
Conversation
Surfaced during open-agents preview UI testing of #559: an off-session charge against an account that has a card on file but where Stripe returned a non-authentication_required error (declined / fraud check / expired / invalid request) bubbled out as a 500 instead of falling through to Checkout. Before: only StripeCardError with code === "authentication_required" returned kind: "requires_action". Everything else rethrew → handler returned 500 → UI showed "Couldn't create a checkout session". After: any StripeCardError or StripeInvalidRequestError returns kind: "requires_action" so the caller falls back to a Stripe Checkout Session — the customer can update the card, complete 3DS, or pick a different payment method interactively. Only genuinely unexpected errors (network, our own bugs) still bubble as 500. Tests: existing 9 cases plus a parameterized check across 4 common card error codes plus an InvalidRequestError case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThe PR extends the off-session charge failure path to capture and propagate structured Stripe decline information. It introduces a ChangesOff-Session Charge Decline Information Flow
Sequence DiagramsequenceDiagram
participant Client
participant Handler as createCreditsSessionHandler
participant Charge as chargeCustomerOffSession
participant Stripe
Client->>Handler: Request credits session
Handler->>Charge: Attempt off-session charge
Charge->>Stripe: Charge card on file
Stripe-->>Charge: Error (code, decline_code?, message)
Charge->>Charge: Parse decline fields into DeclineReason
Charge-->>Handler: { kind: "requires_action", declineReason }
Handler->>Stripe: Create Checkout session fallback
Stripe-->>Handler: { id, url }
Handler-->>Client: { id, url, declineReason? }
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
No issues found across 2 files
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Architecture diagram
sequenceDiagram
participant UI as Frontend UI
participant API as /api/credits/sessions
participant Handler as Handler (outer try/catch)
participant Charge as chargeCustomerOffSession
participant Stripe as Stripe API
participant Checkout as Stripe Checkout Session
Note over UI,Checkout: Credit Auto-Charge Flow (off-session)
UI->>API: POST /api/credits/sessions
API->>Charge: chargeCustomerOffSession(params)
alt Off-session succeeds
Charge->>Stripe: paymentIntents.create (off_session=true)
Stripe-->>Charge: PaymentIntent (succeeded or requires_action)
alt PaymentIntent requires_action
Charge-->>API: { kind: "requires_action" }
API->>Checkout: Create Checkout Session (fallback)
Checkout-->>API: Checkout URL
API-->>UI: Checkout URL (customer updates card/authenticates)
else PaymentIntent succeeded
Charge-->>API: { kind: "success" }
API-->>UI: Success response
end
else Off-session fails with card-level error (NEW: any StripeCardError or StripeInvalidRequestError)
Note over Charge: Previously only caught StripeCardError with code="authentication_required", others bubbled as 500
Stripe-->>Charge: Error (StripeCardError or StripeInvalidRequestError)
Charge->>Charge: console.warn (log error type/code)
Charge-->>API: { kind: "requires_action" } (fallback to Checkout)
API->>Checkout: Create Checkout Session
Checkout-->>API: Checkout URL
API-->>UI: Checkout URL (customer can update card/authenticate)
else Unexpected error (network failure, bug)
Stripe-->>Charge: Error (not StripeCardError/StripeInvalidRequestError)
Charge->>Charge: console.error
Charge-->>API: throws
API-->>UI: HTTP 500 Internal Server Error
end
Auto-approved: This change tightens error handling in a single function, replacing a 500 crash on any card-level or invalid-request Stripe error with a controlled fallback to Checkout, which is safer and matches the intended user experience; the low-risk, well-tested fix solves a production bug without...
Per real-world finding: when an off-session charge fails with
insufficient_funds / card_declined / expired_card / etc., the API was
silently falling through to Checkout with no signal of why. UI and
LLM-driven callers couldn't tell their human "your card has
insufficient funds" — they just saw a new Checkout URL.
Adds optional declineReason to the Checkout-fallback response shape:
{ id: "cs_…", url: "…",
declineReason: {
code: "card_declined", // Stripe error code
declineCode: "insufficient_funds", // Stripe decline_code on card errors
message: "Your card has insufficient funds."
} }
declineReason is omitted when fallback is from no_payment_method (no
prior off-session attempt was made), so fresh users still see the
clean { id, url } shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
lib/stripe/chargeCustomerOffSession.ts (1)
60-85: ⚡ Quick winPrefer Stripe's typed error classes over an ad-hoc structural cast.
The
error as { type?: string; code?: string; ... }cast works, but it sidesteps the types Stripe's SDK already ships for exactly this scenario. UsingStripe.errors.StripeCardError/Stripe.errors.StripeInvalidRequestError(or theinstanceofchecks against them) gives you:
- Compile-time guarantees that
code,decline_code, andmessageare real fields on those error subclasses (no silent typos likedeclineCodevsdecline_code).- Clearer intent for the next reader — the branch self-documents which Stripe error families it handles.
- Resilience against future Stripe SDK changes; if a field is renamed, TypeScript yells instead of the cast quietly returning
undefined.♻️ Suggested refactor using Stripe's typed error classes
} catch (error) { - const e = error as { - type?: string; - code?: string; - decline_code?: string; - message?: string; - }; // Any card-level failure (declined, expired, fraud, 3DS required, etc.) // or Stripe-rejected request shape should fall back to Checkout so the // customer can update their card / authenticate interactively. Capture // the Stripe decline reason so the API response can surface it to the // caller — programmatic integrations (and our UI) can then explain // "insufficient funds" / "expired card" instead of a silent fallback. - if (e?.type === "StripeCardError" || e?.type === "StripeInvalidRequestError") { + if ( + error instanceof Stripe.errors.StripeCardError || + error instanceof Stripe.errors.StripeInvalidRequestError + ) { console.warn( - `[chargeCustomerOffSession] off-session charge failed (${e.type}/${e.code}/${e.decline_code ?? "-"}), falling back to Checkout: ${e.message ?? ""}`, + `[chargeCustomerOffSession] off-session charge failed (${error.type}/${error.code}/${error.decline_code ?? "-"}), falling back to Checkout: ${error.message ?? ""}`, ); - const declineReason: DeclineReason | undefined = e.code + const declineReason: DeclineReason | undefined = error.code ? { - code: e.code, - ...(e.decline_code ? { declineCode: e.decline_code } : {}), - message: e.message ?? "Payment was declined", + code: error.code, + ...(error.decline_code ? { declineCode: error.decline_code } : {}), + message: error.message ?? "Payment was declined", } : undefined; return { kind: "requires_action", declineReason }; }Stripe Node SDK v17 Stripe.errors.StripeCardError instanceof decline_code property🤖 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/chargeCustomerOffSession.ts` around lines 60 - 85, The catch block in chargeCustomerOffSession.ts currently uses an ad-hoc structural cast for `error`; change it to catch `error: unknown` (or keep as is) and narrow by using Stripe's typed error classes (Stripe.errors.StripeCardError and Stripe.errors.StripeInvalidRequestError) with `instanceof` checks instead of `error as { ... }`; inside the `instanceof` branches you can safely read `code`, `decline_code`, and `message` from the typed error, and return the same `{ kind: "requires_action", declineReason }` payload; ensure you import/use the Stripe SDK error classes (Stripe.errors...) so TypeScript enforces the correct properties.
🤖 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.
Nitpick comments:
In `@lib/stripe/chargeCustomerOffSession.ts`:
- Around line 60-85: The catch block in chargeCustomerOffSession.ts currently
uses an ad-hoc structural cast for `error`; change it to catch `error: unknown`
(or keep as is) and narrow by using Stripe's typed error classes
(Stripe.errors.StripeCardError and Stripe.errors.StripeInvalidRequestError) with
`instanceof` checks instead of `error as { ... }`; inside the `instanceof`
branches you can safely read `code`, `decline_code`, and `message` from the
typed error, and return the same `{ kind: "requires_action", declineReason }`
payload; ensure you import/use the Stripe SDK error classes (Stripe.errors...)
so TypeScript enforces the correct properties.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 9404641a-dd3f-4e87-a817-8ee7d265db59
⛔ Files ignored due to path filters (2)
lib/stripe/__tests__/chargeCustomerOffSession.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**lib/stripe/__tests__/createCreditsSessionHandler.fallback.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**
📒 Files selected for processing (2)
lib/stripe/chargeCustomerOffSession.tslib/stripe/createCreditsSessionHandler.ts
There was a problem hiding this comment.
0 issues found across 4 files (changes from recent commits).
Auto-approved: This PR improves Stripe error handling by broadening the off-session charge catch block to include all card-level errors, ensuring users fall back to Checkout with specific decline reasons instead of encountering 500 errors.
Re-tested on the rebased preview deploymentAfter Body for both requests: { "credits": 100, "successUrl": "https://chat.recoupable.com/credits/success" }Test 1 — Account WITH a saved card that Stripe declines (insufficient_funds)Account: Verbatim response (HTTP 200): {
"id": "cs_live_b149p7rbnW93CGkiqQNbNXHDJmKfX5yuxSkmF2SuiMWYUBy3DxZs2vRpZY",
"url": "https://pay.recoupable.com/c/pay/cs_live_b149p7rbnW93CGkiqQNbNXHDJmKfX5yuxSkmF2SuiMWYUBy3DxZs2vRpZY#fidnandhYHdWcXxpYCc%2FJ2FgY2RwaXEnKSdicGRmZGhqaWBTZHdsZGtxJz8nZmprcXdqaScpJ2R1bE5gfCc%2FJ3VuWmlsc2BaMDRUVFNRZDU1T0pnSmtKZzAzNWE0Y2FGXTBgbF9HM1ZrXVFQUUk8UWJ9PXNGRjVsbDJzSEhoT01AXU5jQmhoSjFsdGlVampUbUF2UW5HVVZyRHRAR0Z0dHM1NWFCUjJhdVFhJyknY3dqaFZgd3Ngdyc%2FcXdwYCknZ2RmbmJ3anBrYUZqaWp3Jz8nJmNjY2NjYycpJ2lkfGpwcVF8dWAnPydocGlxbFpscWBoJyknYGtkZ2lgVWlkZmBtamlhYHd2Jz9xd3BgeCUl",
"declineReason": {
"code": "card_declined",
"declineCode": "insufficient_funds",
"message": "Your card has insufficient funds."
}
}✅ Off-session charge correctly fell through to Checkout (no more 500). Stripe Dashboard PaymentIntent for the failed off-session attempt: visible under Customer Test 2 — Account WITHOUT a card on file (clean Checkout fallback)Account: Verbatim response (HTTP 200): {
"id": "cs_live_b1Zvk9adJh5ffkAiznIkbUnf1RmAOYKLVCwc2mEgIjOnIhHUhwaZlqPCkq",
"url": "https://pay.recoupable.com/c/pay/cs_live_b1Zvk9adJh5ffkAiznIkbUnf1RmAOYKLVCwc2mEgIjOnIhHUhwaZlqPCkq#fidnandhYHdWcXxpYCc%2FJ2FgY2RwaXEnKSdicGRmZGhqaWBTZHdsZGtxJz8nZmprcXdqaScpJ2R1bE5gfCc%2FJ3VuWmlsc2BaMDRUVFNRZDU1T0pnSmtKZzAzNWE0Y2FGXTBgbF9HM1ZrXVFQUUk8UWJ9PXNGRjVsbDJzSEhoT01AXU5jQmhoSjFsdGlVampUbUF2UW5HVVZyRHRAR0Z0dHM1NWFCUjJhdVFhJyknY3dqaFZgd3Ngdyc%2FcXdwYCknZ2RmbmJ3anBrYUZqaWp3Jz8nJmNjY2NjYycpJ2lkfGpwcVF8dWAnPydocGlxbFpscWBoJyknYGtkZ2lgVWlkZmBtamlhYHd2Jz9xd3BgeCUl"
}✅ Response is the original clean Summary
Both code paths exercised end-to-end against live Stripe on the rebased preview build. Ready for merge. |
DRY: reuse Stripe SDK's typed error classes (StripeCardError / StripeInvalidRequestError) instead of defining our own structural type to read `type`, `code`, `decline_code`, `message`. TypeScript narrows each branch automatically; `decline_code` is only accessed on the StripeCardError branch where the SDK guarantees it. Tests updated to construct real Stripe error instances with the StripeCardError / StripeInvalidRequestError constructors so the `instanceof` path is what's exercised — the previous plain-object fixtures relied on duck-typing that no longer matches the impl. Addresses CodeRabbit nitpick on PR #561. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
lib/stripe/chargeCustomerOffSession.ts (1)
31-87: ⚡ Quick winSplit
chargeCustomerOffSessioninto smaller helpers to keep responsibilities focused.Line 31 onward now combines lookup, intent creation, status mapping, error parsing, and logging in a single long function. Extracting the Stripe-error mapping (and optionally params construction) will improve readability and testability while aligning with repo constraints.
♻️ Proposed refactor
+function mapStripeFallback( + error: Stripe.errors.StripeError, +): { kind: "requires_action"; declineReason?: DeclineReason } { + const declineCode = + error instanceof Stripe.errors.StripeCardError ? error.decline_code : undefined; + + console.warn( + `[chargeCustomerOffSession] off-session charge failed (${error.type}/${error.code}/${declineCode ?? "-"}), falling back to Checkout: ${error.message}`, + ); + + const declineReason: DeclineReason | undefined = error.code + ? { + code: error.code, + ...(declineCode ? { declineCode } : {}), + message: error.message, + } + : undefined; + + return { kind: "requires_action", declineReason }; +} + export async function chargeCustomerOffSession({ customer, totalCents, metadata, }: ChargeParams): Promise<OffSessionChargeResult> { @@ } catch (error) { @@ if ( error instanceof Stripe.errors.StripeCardError || error instanceof Stripe.errors.StripeInvalidRequestError ) { - const declineCode = - error instanceof Stripe.errors.StripeCardError ? error.decline_code : undefined; - console.warn( - `[chargeCustomerOffSession] off-session charge failed (${error.type}/${error.code}/${declineCode ?? "-"}), falling back to Checkout: ${error.message}`, - ); - const declineReason: DeclineReason | undefined = error.code - ? { - code: error.code, - ...(declineCode ? { declineCode } : {}), - message: error.message, - } - : undefined; - return { kind: "requires_action", declineReason }; + return mapStripeFallback(error); }As per coding guidelines, "Flag functions longer than 20 lines" and "Keep functions small and focused."
🤖 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/chargeCustomerOffSession.ts` around lines 31 - 87, The chargeCustomerOffSession function is doing too many things; split it into focused helpers: extract the PaymentIntent params construction into a helper (e.g., buildOffSessionParams(customer, totalCents, metadata)) and move Stripe error parsing/logging into a separate function (e.g., parseStripeErrorToDeclineReason(error): { declineReason?: DeclineReason; shouldFallback: boolean }) that returns the declineReason and whether to fall back to Checkout; then have chargeCustomerOffSession call findDefaultPaymentMethodForCustomer, use buildOffSessionParams to create params, call stripeClient.paymentIntents.create, map statuses to OffSessionChargeResult, and delegate catch handling to parseStripeErrorToDeclineReason to build the { kind: "requires_action", declineReason } response or rethrow for unexpected errors—this keeps chargeCustomerOffSession under 20 lines and makes the error-mapping logic reusable and testable.
🤖 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.
Nitpick comments:
In `@lib/stripe/chargeCustomerOffSession.ts`:
- Around line 31-87: The chargeCustomerOffSession function is doing too many
things; split it into focused helpers: extract the PaymentIntent params
construction into a helper (e.g., buildOffSessionParams(customer, totalCents,
metadata)) and move Stripe error parsing/logging into a separate function (e.g.,
parseStripeErrorToDeclineReason(error): { declineReason?: DeclineReason;
shouldFallback: boolean }) that returns the declineReason and whether to fall
back to Checkout; then have chargeCustomerOffSession call
findDefaultPaymentMethodForCustomer, use buildOffSessionParams to create params,
call stripeClient.paymentIntents.create, map statuses to OffSessionChargeResult,
and delegate catch handling to parseStripeErrorToDeclineReason to build the {
kind: "requires_action", declineReason } response or rethrow for unexpected
errors—this keeps chargeCustomerOffSession under 20 lines and makes the
error-mapping logic reusable and testable.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: b3479283-be5d-43ca-a95c-ff01f30e6a66
⛔ Files ignored due to path filters (1)
lib/stripe/__tests__/chargeCustomerOffSession.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**
📒 Files selected for processing (1)
lib/stripe/chargeCustomerOffSession.ts
There was a problem hiding this comment.
0 issues found across 2 files (changes from recent commits).
Requires human review: This PR expands Stripe error handling to prevent 500 errors by falling back to Checkout for all card-level failures, and as this modifies a critical payment path, it requires a human review.
* fix(stripe): fall back to Checkout for any card-level off-session error Surfaced during open-agents preview UI testing of #559: an off-session charge against an account that has a card on file but where Stripe returned a non-authentication_required error (declined / fraud check / expired / invalid request) bubbled out as a 500 instead of falling through to Checkout. Before: only StripeCardError with code === "authentication_required" returned kind: "requires_action". Everything else rethrew → handler returned 500 → UI showed "Couldn't create a checkout session". After: any StripeCardError or StripeInvalidRequestError returns kind: "requires_action" so the caller falls back to a Stripe Checkout Session — the customer can update the card, complete 3DS, or pick a different payment method interactively. Only genuinely unexpected errors (network, our own bugs) still bubble as 500. Tests: existing 9 cases plus a parameterized check across 4 common card error codes plus an InvalidRequestError case. * feat(stripe): surface Stripe decline reason in the API response Per real-world finding: when an off-session charge fails with insufficient_funds / card_declined / expired_card / etc., the API was silently falling through to Checkout with no signal of why. UI and LLM-driven callers couldn't tell their human "your card has insufficient funds" — they just saw a new Checkout URL. Adds optional declineReason to the Checkout-fallback response shape: { id: "cs_…", url: "…", declineReason: { code: "card_declined", // Stripe error code declineCode: "insufficient_funds", // Stripe decline_code on card errors message: "Your card has insufficient funds." } } declineReason is omitted when fallback is from no_payment_method (no prior off-session attempt was made), so fresh users still see the clean { id, url } shape. * refactor(stripe): narrow Stripe errors via instanceof, drop ad-hoc cast DRY: reuse Stripe SDK's typed error classes (StripeCardError / StripeInvalidRequestError) instead of defining our own structural type to read `type`, `code`, `decline_code`, `message`. TypeScript narrows each branch automatically; `decline_code` is only accessed on the StripeCardError branch where the SDK guarantees it. Tests updated to construct real Stripe error instances with the StripeCardError / StripeInvalidRequestError constructors so the `instanceof` path is what's exercised — the previous plain-object fixtures relied on duck-typing that no longer matches the impl. Addresses CodeRabbit nitpick on PR #561. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with recoupable/api#561, which now returns an optional declineReason ({ code, declineCode, message }) on the Checkout-fallback response when Stripe declined an off-session charge against the saved card (insufficient_funds, expired_card, card_declined, …). Previously this dialog silently opened Checkout in a new tab on any Checkout-fallback response, so users had no idea *why* their saved card wasn't being used. Now: - parseCreditsTopupResponse threads declineReason through to the parsed { kind: "checkout" } variant. - useCreditsTopupDialog routes the two checkout sub-cases differently: with declineReason → set checkoutFallback state (dialog stays open). without declineReason (no card on file) → auto-open Checkout + close, same as today. - New CreditsTopupDeclineView renders the decline message and an "Update payment method" CTA that opens the Checkout URL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(profile): tolerant credits top-up dialog
Defensive prep for the upcoming auto-charge flow (api PR 2b). The
api's POST /api/credits/sessions will start returning one of two
shapes — Checkout fallback `{ id, url }` or auto-charge
`{ paymentIntentId, creditsPurchased, totalCents }` — and the existing
client only handled the former. Without this PR, Pro subscribers'
top-ups would surface a 'Checkout URL missing' error once the api
side ships.
Changes:
1. New `lib/stripe/parse-credits-topup-response.ts` — discriminated
parser. Returns `{ kind: 'checkout', sessionId, url }` or
`{ kind: 'charged', paymentIntentId, creditsPurchased, totalCents }`,
null on unknown payload. Checkout wins on ambiguous payloads (user
always retains the chance to confirm). 4 unit tests, all green.
2. `lib/stripe/create-client-credits-session.ts` — no longer opens
the URL itself. Returns the parsed discriminated result for the
caller to dispatch on.
3. `hooks/use-credits-topup-dialog.ts` — adds `chargedSuccess` state.
`handleContinue` dispatches: checkout -> window.open + close;
charged -> set success state + revalidate `useAccountCredits` SWR
key so the meter refetches.
4. New `components/credits-topup-success-view.tsx` — celebratory
success view (CheckCircle2 + receipt of charged credits and total
billed) shown when the auto-charge path completes. Done button
closes the dialog.
5. New `components/credits-topup-form.tsx` — extracted form body so
the main dialog stays ≤100 LOC and clearly orchestrates the
form/success branch.
6. `components/credits-topup-dialog.tsx` — renders success view or
form based on hook state. Trimmed to 51 LOC.
Quality gates:
- bun run check ✅
- bun run --cwd apps/web typecheck ✅
- bun test (changed scope) ✅ 12/12
All new/modified files ≤100 LOC per the repo style rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(credits): surface Stripe declineReason on the topup dialog
Pairs with recoupable/api#561, which now returns an optional
declineReason ({ code, declineCode, message }) on the Checkout-fallback
response when Stripe declined an off-session charge against the saved
card (insufficient_funds, expired_card, card_declined, …).
Previously this dialog silently opened Checkout in a new tab on any
Checkout-fallback response, so users had no idea *why* their saved
card wasn't being used. Now:
- parseCreditsTopupResponse threads declineReason through to the
parsed { kind: "checkout" } variant.
- useCreditsTopupDialog routes the two checkout sub-cases differently:
with declineReason → set checkoutFallback state (dialog stays open).
without declineReason (no card on file) → auto-open Checkout + close,
same as today.
- New CreditsTopupDeclineView renders the decline message and an
"Update payment method" CTA that opens the Checkout URL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(credits): spread parsed data instead of re-listing fields
Per review on PR #36 — KISS: the manual destructuring of
paymentIntentId / creditsPurchased / totalCents added no value over
spreading charged.data directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Surfaced during open-agents preview UI testing of #559 (the credits auto-charge handler). An off-session charge against an account with a card on file failed with HTTP 500 instead of falling through to Checkout, leaving the UI stuck on "Couldn't create a checkout session."
Root cause
`chargeCustomerOffSession` only caught `StripeCardError` with `code === "authentication_required"` (3-D Secure). Any other Stripe error — declined card, expired, fraud check, insufficient funds, `StripeInvalidRequestError` ("Customer has no attached payment source") — rethrew. The handler's outer try/catch returned 500.
Fix
Broaden the catch to handle any `StripeCardError` or `StripeInvalidRequestError` → return `kind: "requires_action"` so the caller falls back to a Stripe Checkout Session. The customer can then update their card, complete 3DS, or pick a different payment method via Stripe's hosted page.
Only genuinely unexpected errors (network failures, our own bugs) still bubble as 500.
Verified
Reproduced the 500 by hitting the preview with a card that triggered a non-3DS Stripe error path:
```
POST https://test-recoup-api.vercel.app/api/credits/sessions
→ HTTP 500 { "error": "Internal server error" }
```
After the fix (tested locally via the new test cases — parameterized across `card_declined`, `expired_card`, `fraudulent`, `insufficient_funds`, plus the InvalidRequest case): the function returns `{ kind: "requires_action" }` for every variant, and the handler emits a Checkout Session response.
Tests
Full suite: 478 files / 2807 tests ✅; lint clean.
Rollout
Standard `test → main` after CI is green.
Summary by cubic
Fix off-session charges that returned 500 by falling back to Stripe Checkout on any card-level or invalid-request error, and include an optional
declineReasonso users see why. Also switch toStripe.errors.*classes withinstanceofchecks for safer error handling.Bug Fixes
chargeCustomerOffSession, handle anyStripe.errors.StripeCardErrororStripe.errors.StripeInvalidRequestErroras{ kind: "requires_action", declineReason }withcode, optionaldeclineCode, andmessage.createCreditsSessionHandler, includedeclineReasonin Checkout-fallback responses; omit it whenkind: "no_payment_method". Unexpected errors still return 500.Refactors
instanceofagainstStripe.errors.StripeCardError/Stripe.errors.StripeInvalidRequestErrorinstead of ad-hoc casts; tests updated to construct real Stripe error instances.Written for commit 3d75897. Summary will update on new commits.
Summary by CodeRabbit