Skip to content

feat(devpass): 3DS flow for SCA-required upgrades - #3208

Merged
steebchen merged 3 commits into
mainfrom
claude/dev-plan-payment-errors-qdlo65
Jul 24, 2026
Merged

steebchen merged 3 commits into
mainfrom
claude/dev-plan-payment-errors-qdlo65

Conversation

@steebchen

@steebchen steebchen commented Jul 23, 2026 •

Copy link
Copy Markdown
Member

Problem

Production alert (7/14, llmgateway-api): a dev plan tier upgrade failed with a 500 Failed to change dev plan tier logged at ERROR severity. The underlying Stripe error was subscription_payment_intent_requires_action — the cardholder's bank required 3DS/SCA authentication to approve the upgrade charge.

The upgrade path uses payment_behavior: "error_if_incomplete", so Stripe throws and leaves the subscription (and our DB) untouched — nothing was corrupted. But:

  • the tier-change catch blocks only recognized card_declined and invoice_payment_required, so the SCA error fell through to a generic 500 + logger.error, paging on-call for a user-billing outcome, and
  • there was no way for the user to actually complete the 3DS challenge for an upgrade — the requires_action/clientSecret flow existed only for initial subscription checkout, so banks that challenge every off-session charge left users unable to upgrade at all.

Fix

1. Error classification (dev plans + chat plans)
subscription_payment_intent_requires_action is handled like the other expected payment failures: warn-level log and a 402 with an actionable message. This remains the terminal behavior for chat plans and for any dev-plan case where no confirmable payment intent can be produced.

2. In-place 3DS flow for dev plan tier upgrades

  • API (dev-plans.ts): when the atomic error_if_incomplete update throws the SCA error, the change is re-issued as a Stripe pending update (payment_behavior: "pending_if_incomplete"). Nothing — price, cycle anchor, or metadata — applies until the invoice is paid, and if the customer abandons the challenge Stripe voids the invoice and discards the update after at most 23h, leaving the subscription untouched. The endpoint returns { status: "requires_action", clientSecret } (new union member on the 200 response) and releases the upgrade lease.
  • Dashboard (BillingClient.tsx): on requires_action, confirms the payment intent with stripe.confirmCardPayment() (same pattern as the finalize flow), then polls /dev-plans/status until the tier lands.
  • Completion: on successful payment, the existing invoice.payment_succeeded webhook fallback for subscription_update invoices applies the tier change, credit rollover, transaction row, and invoice email idempotently — no webhook changes needed.
  • Safety checks verified: the invoice.payment_failed freeze handler re-fetches the live subscription and only freezes on past_due/unpaid/incomplete statuses; a pending update keeps the subscription active, so failed/abandoned 3DS attempts cannot freeze the user's current paid cycle.

Testing

  • New spec: SCA-required upgrade returns requires_action + client secret, re-issues the update as pending_if_incomplete, applies nothing locally, records no transaction, and releases the lease.
  • Full unit suite: 2970 passed. Full pnpm build passes (typed clients regenerated from the updated OpenAPI spec).

Follow-up

Chat plan tier upgrades keep the 402-only behavior for SCA cards; extending the in-place 3DS flow there needs the equivalent UI work in the playground billing surface.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R6gxRwHbBXgbJdHGnw2yHM

Summary by CodeRabbit

  • New Features

    • Plan upgrades that require additional payment verification now support 3D Secure/SCA customer authentication.
    • Billing flows can complete the verification, then wait for the updated plan status before finalizing the upgrade.
    • Added tailored progress/success messaging for verified upgrades.
  • Bug Fixes

    • When additional verification is required, upgrades now return an actionable 402 response guiding customers to re-add/update their payment method.
    • Prevented local tier/credit/lease state changes from being applied until payment confirmation/webhook completion.
  • Tests

    • Added coverage for the 3DS/SCA “requires_action” upgrade flow.

Stripe throws subscription_payment_intent_requires_action when
error_if_incomplete hits a card that demands 3DS authentication.
The tier-change catch blocks only recognized card_declined and
invoice_payment_required, so this expected user-facing outcome fell
through to the generic 500 and an error-level log, paging on-call for
what is a billing prompt, not a server fault. Handle it like the other
payment failures: warn-level log and a 402 telling the user to update
their payment method.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6gxRwHbBXgbJdHGnw2yHM
Copilot AI review requested due to automatic review settings July 23, 2026 14:50
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a8c0ae5-33b2-483f-aa80-298da8f7a920

📥 Commits

Reviewing files that changed from the base of the PR and between 64d6043 and 53ab234.

📒 Files selected for processing (2)
  • apps/api/src/routes/dev-plans.ts
  • apps/code/src/app/dashboard/(main)/billing/BillingClient.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/api/src/routes/dev-plans.ts
  • apps/code/src/app/dashboard/(main)/billing/BillingClient.tsx

Walkthrough

Changes

3DS tier upgrade

Layer / File(s) Summary
Server-side authentication-required upgrade flow
apps/api/src/routes/dev-plans.ts, apps/api/src/stripe.ts
The tier-change API retries authentication-required Stripe updates with pending behavior, returns a payment client secret, and documents the requires_action response.
Client confirmation and status polling
apps/code/src/app/dashboard/(main)/billing/BillingClient.tsx
The billing client confirms 3DS payments through Stripe, polls for the updated tier, refreshes billing state, and reports completion status.
Error mapping and flow validation
apps/api/src/routes/chat-plans.ts, apps/api/src/routes/dev-plans.ts, apps/api/src/routes/dev-plans.spec.ts
Payment-authentication errors map to HTTP 402 responses, while tests verify the retry requests, response client secret, and unchanged local state before webhook confirmation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BillingClient
  participant DevPlansChangeTier
  participant Stripe
  participant DevPlansStatus
  BillingClient->>DevPlansChangeTier: submit tier change
  DevPlansChangeTier->>Stripe: update subscription
  Stripe-->>DevPlansChangeTier: require payment authentication
  DevPlansChangeTier->>Stripe: retry pending subscription update
  DevPlansChangeTier-->>BillingClient: return clientSecret
  BillingClient->>Stripe: confirm card payment
  BillingClient->>DevPlansStatus: poll for updated tier
  DevPlansStatus-->>BillingClient: return tier status
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding a 3DS/SCA upgrade flow.
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.
✨ 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 claude/dev-plan-payment-errors-qdlo65

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.

When the atomic error_if_incomplete upgrade charge fails with
subscription_payment_intent_requires_action, re-issue the change as a
Stripe pending update (pending_if_incomplete): nothing applies until
the invoice is paid, and Stripe voids the invoice and discards the
update if the customer abandons the challenge. The endpoint returns
the payment intent client secret; the billing dashboard confirms it
with stripe.confirmCardPayment and polls status until the webhook's
existing subscription_update fallback applies the tier change,
rollover, transaction row and invoice email idempotently.

Previously SCA-challenged cards could never complete an upgrade
in-place and got a generic failure toast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6gxRwHbBXgbJdHGnw2yHM
@steebchen steebchen changed the title fix(api): 402 for SCA-required tier change payments feat(devpass): 3DS flow for SCA-required upgrades Jul 24, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
apps/api/src/routes/dev-plans.ts (1)

1239-1245: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate Stripe error-code extraction.

The same manual typeof error === "object" && error !== null && "code" in error narrowing is repeated here and again at Line 1497-1500. Consider extracting a shared getStripeErrorCode(error: unknown): string | undefined helper to avoid the duplication.

♻️ Proposed helper
+function getStripeErrorCode(error: unknown): string | undefined {
+	return typeof error === "object" && error !== null && "code" in error
+		? String((error as { code?: unknown }).code)
+		: undefined;
+}
+
 	} catch (updateError) {
-		const updateErrCode =
-			typeof updateError === "object" &&
-			updateError !== null &&
-			"code" in updateError
-				? String((updateError as { code?: unknown }).code)
-				: undefined;
+		const updateErrCode = getStripeErrorCode(updateError);
🤖 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 `@apps/api/src/routes/dev-plans.ts` around lines 1239 - 1245, Extract the
repeated Stripe error-code narrowing into a shared getStripeErrorCode(error:
unknown): string | undefined helper, then replace the inline extraction near
updateErrCode and the corresponding logic near the later Stripe error handling
with calls to that helper. Preserve the existing undefined behavior when no code
is present and the current comparisons unchanged.
apps/code/src/app/dashboard/(main)/billing/BillingClient.tsx (1)

151-172: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicated post-success handling between the two branches.

The invalidateInvoices/invalidateStatus/posthog-capture sequence is repeated almost verbatim in both the requires_action branch and the normal-success branch. Consider factoring it into a small shared helper.

🤖 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 `@apps/code/src/app/dashboard/`(main)/billing/BillingClient.tsx around lines
151 - 172, Extract the duplicated post-success sequence into a shared helper
near the relevant billing logic, including invoice/status invalidation and the
conditional PostHog capture. Invoke that helper from both the requires_action
path and the immediate-success path, preserving each branch’s existing toast
behavior and comments.
🤖 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 `@apps/api/src/routes/dev-plans.ts`:
- Around line 1219-1299: The pending tier-change path in the subscription update
catch block must retain the upgrade lease while customer authentication is
outstanding. Update the flow around getSubscriptionPaymentConfirmation and the
requires_action response so releaseTierChangeLease is not called when returning
a clientSecret; release the lease only after authentication succeeds or the
pending update is otherwise finalized or discarded, while preserving existing
lease cleanup for non-pending paths.

---

Nitpick comments:
In `@apps/api/src/routes/dev-plans.ts`:
- Around line 1239-1245: Extract the repeated Stripe error-code narrowing into a
shared getStripeErrorCode(error: unknown): string | undefined helper, then
replace the inline extraction near updateErrCode and the corresponding logic
near the later Stripe error handling with calls to that helper. Preserve the
existing undefined behavior when no code is present and the current comparisons
unchanged.

In `@apps/code/src/app/dashboard/`(main)/billing/BillingClient.tsx:
- Around line 151-172: Extract the duplicated post-success sequence into a
shared helper near the relevant billing logic, including invoice/status
invalidation and the conditional PostHog capture. Invoke that helper from both
the requires_action path and the immediate-success path, preserving each
branch’s existing toast behavior and comments.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: eae7a3a5-6704-4b9c-a7f2-60b484a0d449

📥 Commits

Reviewing files that changed from the base of the PR and between 4db51a0 and 64d6043.

📒 Files selected for processing (5)
  • apps/api/src/routes/chat-plans.ts
  • apps/api/src/routes/dev-plans.spec.ts
  • apps/api/src/routes/dev-plans.ts
  • apps/api/src/stripe.ts
  • apps/code/src/app/dashboard/(main)/billing/BillingClient.tsx

Comment on lines +1219 to +1299
let updated: Stripe.Subscription;
try {
updated = await getStripe().subscriptions.update(subscriptionId, {
items: [
{
id: subscriptionItemId,
price: newPriceId,
},
],
proration_behavior: "none",
billing_cycle_anchor: "now",
payment_behavior: "error_if_incomplete",
expand: ["latest_invoice.payment_intent"],
metadata: {
...subscription.metadata,
devPlan: newTier,
devPlanCycle: existingCycle,
},
});
} catch (updateError) {
const updateErrCode =
typeof updateError === "object" &&
updateError !== null &&
"code" in updateError
? String((updateError as { code?: unknown }).code)
: undefined;
if (updateErrCode !== "subscription_payment_intent_requires_action") {
throw updateError;
}
// The bank demands 3DS/SCA authentication, which only the customer can
// complete in the browser. Re-issue the update as a Stripe pending
// update (`pending_if_incomplete`): nothing — price, cycle anchor, or
// metadata — is applied until the invoice is paid, and if the customer
// abandons the challenge Stripe voids the invoice and discards the
// update after at most 23 hours, leaving the subscription untouched.
// The client confirms the returned payment intent secret with
// Stripe.js; on success the `invoice.payment_succeeded` webhook's
// `subscription_update` fallback applies the tier change, credit
// rollover, transaction row and invoice email idempotently.
const pending = await getStripe().subscriptions.update(subscriptionId, {
items: [
{
id: subscriptionItemId,
price: newPriceId,
},
],
proration_behavior: "none",
billing_cycle_anchor: "now",
payment_behavior: "pending_if_incomplete",
expand: ["latest_invoice.payment_intent"],
metadata: {
...subscription.metadata,
devPlan: newTier,
devPlanCycle: existingCycle,
},
});
const { clientSecret } =
await getSubscriptionPaymentConfirmation(pending);
if (!clientSecret) {
// No confirmable payment intent to hand to the client; surface the
// original failure through the shared catch below.
throw updateError;
}
logger.info("Dev plan tier change pending customer authentication", {
organizationId: personalOrg.id,
subscriptionId,
newTier,
});
// Release the lease: the charge is no longer in flight server-side, and
// the webhook completes (or Stripe discards) the change out-of-band.
if (claimedLeaseThisCall) {
await releaseTierChangeLease(personalOrg.id);
}
return c.json(
{
id: subscriptionItemId,
price: newPriceId,
status: "requires_action" as const,
clientSecret,
},
],
proration_behavior: "none",
billing_cycle_anchor: "now",
payment_behavior: "error_if_incomplete",
expand: ["latest_invoice.payment_intent"],
metadata: {
...subscription.metadata,
devPlan: newTier,
devPlanCycle: existingCycle,
},
});
200,
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Stripe subscription pending update attribute allow-list billing_cycle_anchor supported list

💡 Result:

Stripe supports billing_cycle_anchor as a field within subscription pending updates [1][2][3]. When you update a subscription using payment_behavior=pending_if_incomplete, Stripe allows you to specify a set of attributes that will be applied to the subscription once the latest invoice is paid [4][5]. The billing_cycle_anchor is explicitly included in the allow-list of attributes supported for these pending updates [1][3]. When a pending update containing this attribute is applied, it determines the date of the first full invoice and, for plans with month or year intervals, the day of the month for subsequent invoices [2][6][7]. For reference, the following attributes are generally supported for pending updates via the Update a Subscription endpoint: - billing_cycle_anchor [1][3] - items (including price and quantity) [1][3] - proration_behavior [1][3] - proration_date [1][3] - payment_behavior [1][3] - discounts [1][3] - coupon [1][3] - promotion_code [1][3] - trial_end [1][3] - trial_from_plan [1][3] - metadata [1][3] - add_invoice_items [1][3] - expand [1][3] To utilize this, you must set payment_behavior to pending_if_incomplete in your update request [4][5]. If the payment for the current invoice fails, Stripe creates a pending update that only applies if the payment eventually succeeds [5]. Once applied, the pending update can be inspected on the Subscription object under the pending_update attribute [2][6].

Citations:


🌐 Web query:

stripe pending updates duplicate subscription update payment behavior pending_if_incomplete existing pending update behavior

💡 Result:

When you use payment_behavior=pending_if_incomplete for a subscription update, Stripe attempts to charge the customer if the update requires payment (e.g., due to proration) [1][2]. If the payment succeeds, the subscription is updated immediately [1]. If the payment fails, the update is not applied, and Stripe instead creates a pending update, which is stored in the pending_update hash on the subscription object [1][3]. If you attempt to apply a new update to a subscription that already has an existing pending update, the following behavior occurs: 1. Replacement: The new update replaces the existing values within the pending_update hash [1][3]. 2. Invoice Handling: Stripe voids the invoice associated with the previous pending update and creates a new invoice that reflects the new set of updates [1][3]. 3. Resolution: If the payment for this new invoice succeeds, the most recent updates (those in the updated pending_update hash) are applied to the subscription [1][3]. 4. Failure: If the payment for the new invoice fails, a new pending update is generated with a new expiry date, replacing the previous one [1][3]. You can track these states by checking the pending_update hash on the subscription object or by listening for webhook events such as customer.subscription.updated [1][3]. Additionally, you can include metadata in these updates, which will be stored in pending_update.metadata and merged into the subscription metadata upon successful resolution of the update [4].

Citations:


🌐 Web query:

stripe api subscriptions update with payment_behavior pending_if_incomplete already pending update

💡 Result:

When you update a Stripe subscription using payment_behavior=pending_if_incomplete, Stripe creates a pending update if the required payment fails [1][2]. A subscription can only have one pending update at a time [3]. If you attempt to apply a new update to a subscription that already has a pending update, the behavior depends on the specific changes being requested and how you construct the API call: 1. Pending Update Lifecycle: The pending update is held in the pending_update hash on the Subscription object [2][3]. It is only applied if the associated invoice is paid [2][3]. If the update expires or is discarded, the pending_update hash is cleared. 2. Replacing or Updating Pending Changes: Generally, sending a new update request for a subscription that already has a pending update will attempt to overwrite or merge the changes into the existing pending update, depending on the parameters provided. Stripe's API documentation indicates that for supported attributes (such as items, quantities, or metadata), new updates are generally merged or reconciled within the pending update state [2][4]. 3. Handling Conflicts: If you send a request that conflicts with the existing pending state or includes unsupported attributes, the API may return an error. Because Stripe explicitly manages the pending_update hash as a state, it is recommended to first check if pending_update is populated on the Subscription object before sending a new update [2]. If you need to change the intent of an existing pending update, you should typically perform a new update call with the desired parameters. If the update is rejected because of the existing state, you may need to either wait for the current pending update to be resolved (paid) or expire/cancel the existing update if your workflow allows it. For the most robust integration, listen for customer.subscription.updated webhooks to track the lifecycle of the pending_update hash and ensure your application state remains synchronized with Stripe [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant file around the lease/race area and surrounding logic.
if [ -f apps/api/src/routes/dev-plans.ts ]; then
  echo "=== file stats ==="
  wc -l apps/api/src/routes/dev-plans.ts
  echo
  echo "=== relevant lease/race comments and line references ==="
  rg -n "lease|pending_update|pending_update_exists|subscription_update|charge|billing_cycle|devPlanExpires|payment_intent_requires_action|releaseTierChangeLease|change-tier|tier" apps/api/src/routes/dev-plans.ts | head -n 220
  echo
  echo "=== surrounding code 1100-1325 ==="
  sed -n '1100,1325p' apps/api/src/routes/dev-plans.ts | nl -ba -v1100
else
  echo "apps/api/src/routes/dev-plans.ts not found"
fi

Repository: theopenco/llmgateway

Length of output: 9466


Keep the in-flight upgrade lease held until subscription authentication succeeds.

billing_cycle_anchor: "now" is supported on Stripe subscription pending updates, but the lease is released as soon as the 3DS clientSecret is returned. A second /change-tier request before the customer completes/authenticates payment can still acquire the lease and call Stripe again, potentially creating another pending update/invoice while one is already in flight. Keep the upgrade lease held during the 3DA wait, or otherwise track in-flight client-side authentication to avoid reopening the double-charge/race window.

🤖 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 `@apps/api/src/routes/dev-plans.ts` around lines 1219 - 1299, The pending
tier-change path in the subscription update catch block must retain the upgrade
lease while customer authentication is outstanding. Update the flow around
getSubscriptionPaymentConfirmation and the requires_action response so
releaseTierChangeLease is not called when returning a clientSecret; release the
lease only after authentication succeeds or the pending update is otherwise
finalized or discarded, while preserving existing lease cleanup for non-pending
paths.

Extract the repeated Stripe error-code narrowing into a shared
getStripeErrorCode() helper (dev-plans.ts), and factor the duplicated
post-tier-change refresh (invoice/status invalidation + analytics
capture) into refreshAfterTierChange() in the billing dashboard.

Also document why the upgrade lease is released when returning the 3DS
client secret rather than held until the webhook resolves: holding it
would block the common abandon-then-retry path and lock further tier
changes for the 15-minute staleness window (the completion webhook never
touches the lease), while a concurrent retry cannot double-charge — a
subscription holds at most one pending update, so re-issuing voids the
prior invoice and replaces it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@steebchen
steebchen enabled auto-merge July 24, 2026 17:05
@steebchen
steebchen added this pull request to the merge queue Jul 24, 2026
Merged via the queue into main with commit 247e36b Jul 24, 2026
12 checks passed
@steebchen
steebchen deleted the claude/dev-plan-payment-errors-qdlo65 branch July 24, 2026 17:22
@coderabbitai coderabbitai Bot mentioned this pull request Jul 24, 2026
4 tasks done
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.

3 participants