Skip to content

fix(stripe): off-session fallback for any card-level error#561

Merged
sweetmantech merged 4 commits into
testfrom
fix/off-session-fallback-on-any-card-error
May 13, 2026
Merged

fix(stripe): off-session fallback for any card-level error#561
sweetmantech merged 4 commits into
testfrom
fix/off-session-fallback-on-any-card-error

Conversation

@sweetmantech
Copy link
Copy Markdown
Contributor

@sweetmantech sweetmantech commented May 13, 2026

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

Test file New cases
`chargeCustomerOffSession.test.ts` 4 parameterized card error codes + 1 InvalidRequestError case

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 declineReason so users see why. Also switch to Stripe.errors.* classes with instanceof checks for safer error handling.

  • Bug Fixes

    • In chargeCustomerOffSession, handle any Stripe.errors.StripeCardError or Stripe.errors.StripeInvalidRequestError as { kind: "requires_action", declineReason } with code, optional declineCode, and message.
    • In createCreditsSessionHandler, include declineReason in Checkout-fallback responses; omit it when kind: "no_payment_method". Unexpected errors still return 500.
  • Refactors

    • Use instanceof against Stripe.errors.StripeCardError/Stripe.errors.StripeInvalidRequestError instead 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

  • Bug Fixes
    • Improved payment decline handling: when an off-session charge requires interactive Checkout, the system now surfaces a Stripe decline reason (when available) in responses and logs enriched decline details to aid troubleshooting.

Review Change Stack

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

vercel Bot commented May 13, 2026

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

Project Deployment Actions Updated (UTC)
api Ready Ready Preview May 13, 2026 11:38pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

📝 Walkthrough

Walkthrough

The PR extends the off-session charge failure path to capture and propagate structured Stripe decline information. It introduces a DeclineReason type, broadens the error handler to parse both StripeCardError and StripeInvalidRequestError for decline fields, and wires the enriched result through the Checkout session handler response.

Changes

Off-Session Charge Decline Information Flow

Layer / File(s) Summary
Decline reason type and result union extension
lib/stripe/chargeCustomerOffSession.ts
New DeclineReason type exported with Stripe code, optional declineCode, and message. The OffSessionChargeResult union's requires_action variant now optionally carries declineReason.
Off-session charge error parsing and fallback
lib/stripe/chargeCustomerOffSession.ts
Switches to a runtime Stripe import, handles StripeCardError and StripeInvalidRequestError, extracts code, decline_code, and message, logs a warning with those fields, and returns requires_action with declineReason when present.
Handler comment and response integration with decline reason
lib/stripe/createCreditsSessionHandler.ts
Clarifies Checkout fallback behavior in comments. The Checkout-session success JSON conditionally includes declineReason from the off-session charge result when kind is requires_action and a reason exists; otherwise returns only id and url.

Sequence Diagram

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

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

  • recoupable/api#559: Prior related work on the same chargeCustomerOffSession and createCreditsSessionHandler functions that this PR extends with parsed Stripe decline reason details.
  • recoupable/api#553: Overlaps with changes to the credits-session Checkout handler behavior and response shape.

Poem

When Stripe says “no” with a coded line,
We tuck the reason in a tidy sign.
DeclineReason travels from charge to view,
So Checkout can show the why and do. ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 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.
Solid & Clean Code ✅ Passed Adheres to SOLID principles with focused SRP, safe OCP extension via discriminated unions, no duplication, and clean code practices including appropriate function lengths and nesting.

✏️ 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 fix/off-session-fallback-on-any-card-error

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.

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
Loading

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

🧹 Nitpick comments (1)
lib/stripe/chargeCustomerOffSession.ts (1)

60-85: ⚡ Quick win

Prefer 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. Using Stripe.errors.StripeCardError / Stripe.errors.StripeInvalidRequestError (or the instanceof checks against them) gives you:

  • Compile-time guarantees that code, decline_code, and message are real fields on those error subclasses (no silent typos like declineCode vs decline_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

📥 Commits

Reviewing files that changed from the base of the PR and between 8bc3d63 and c7c4411.

⛔ Files ignored due to path filters (2)
  • lib/stripe/__tests__/chargeCustomerOffSession.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/stripe/__tests__/createCreditsSessionHandler.fallback.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (2)
  • lib/stripe/chargeCustomerOffSession.ts
  • lib/stripe/createCreditsSessionHandler.ts

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

@sweetmantech
Copy link
Copy Markdown
Contributor Author

Re-tested on the rebased preview deployment

After gh pr update-branch merged latest test into this branch, re-tested both code paths against the fresh preview at:
https://api-git-fix-off-session-fallback-on-any-card-error-recoup.vercel.app/api/credits/sessions

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: fb678396-a68f-4294-ae50-b8cacf9ce77b (my personal account; default PM declines with insufficient_funds).

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).
declineReason block is populated with the actual Stripe failure reason — exactly the info UI/LLM callers need to tell the human why their card was rejected.

Stripe Dashboard PaymentIntent for the failed off-session attempt: visible under Customer cus_… for account fb678396-… — the PaymentIntent shows last_payment_error.code = card_declined, decline_code = insufficient_funds, matching the response above.

Test 2 — Account WITHOUT a card on file (clean Checkout fallback)

Account: bdc80f91-d178-47a6-91e0-34012ba87303 (no default PM stamped).

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 { id, url } shape — declineReason is correctly omitted when there was never an off-session attempt to decline (i.e. kind: "no_payment_method"). Fresh users who've never added a card see no behavioral change.

Summary

Scenario Before this PR After this PR
Saved card → off-session error 500 Internal Server Error 200 + Checkout URL + declineReason
Saved card → 3-D Secure required 200 + Checkout URL 200 + Checkout URL (unchanged)
No card on file 200 + Checkout URL 200 + Checkout URL (unchanged, no declineReason key)

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

🧹 Nitpick comments (1)
lib/stripe/chargeCustomerOffSession.ts (1)

31-87: ⚡ Quick win

Split chargeCustomerOffSession into 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

📥 Commits

Reviewing files that changed from the base of the PR and between c7c4411 and 3d75897.

⛔ Files ignored due to path filters (1)
  • lib/stripe/__tests__/chargeCustomerOffSession.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (1)
  • lib/stripe/chargeCustomerOffSession.ts

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

@sweetmantech sweetmantech merged commit 0de5b1e into test May 13, 2026
6 checks passed
@sweetmantech sweetmantech deleted the fix/off-session-fallback-on-any-card-error branch May 13, 2026 23:44
sweetmantech added a commit that referenced this pull request May 13, 2026
* 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>
sweetmantech added a commit to recoupable/open-agents that referenced this pull request May 13, 2026
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>
sweetmantech added a commit to recoupable/open-agents that referenced this pull request May 14, 2026
* 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>
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