Cancel trialing subscriptions when trial payment validation fails#4136
Cancel trialing subscriptions when trial payment validation fails#4136pepeladeira wants to merge 2 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds a Stripe webhook handler for ChangesTrial-will-end webhook and cancellation behavior
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Stripe
participant WebhookRoute
participant TrialWillEndHandler
participant StripeAPI
Stripe->>WebhookRoute: customer.subscription.trial_will_end event
WebhookRoute->>TrialWillEndHandler: customerSubscriptionTrialWillEnd(event)
TrialWillEndHandler->>StripeAPI: retrieve customer / payment method
alt No payment method found
TrialWillEndHandler->>StripeAPI: cancel trialing subscription
TrialWillEndHandler-->>WebhookRoute: cancellation message
else Payment method exists
TrialWillEndHandler->>StripeAPI: preview upcoming invoice
TrialWillEndHandler->>StripeAPI: create off-session payment intent
alt requires_capture
TrialWillEndHandler->>StripeAPI: cancel payment intent
TrialWillEndHandler-->>WebhookRoute: validated message
else unexpected status or card error
TrialWillEndHandler->>StripeAPI: cancel trialing subscription
TrialWillEndHandler-->>WebhookRoute: cancellation message
end
end
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ 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.
Actionable comments posted: 1
🧹 Nitpick comments (1)
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts (1)
68-81: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoffConsider a SetupIntent instead of a manual-capture PaymentIntent for validation.
A manual-capture PaymentIntent for
amount_dueplaces an authorization hold for the full subscription amount on the customer's card before it's canceled, which can appear on statements and may be declined for insufficient available balance despite a valid card.stripe.setupIntents.create(off_session) is the idiomatic way to validate a payment method without holding the charge amount.🤖 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/web/app/`(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts around lines 68 - 81, The validation flow in customer-subscription-trial-will-end should not use stripe.paymentIntents.create with capture_method: "manual" for card verification because it places a hold on the full amount. Update the logic in the webhook handler to use a SetupIntent-based validation path instead, keeping the existing off_session/customer/payment_method context and returning the same success message from the surrounding subscription workflow.
🤖 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/web/app/`(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts:
- Around line 68-112: The payment validation flow in
customer-subscription-trial-will-end currently treats all non-capture and Stripe
card errors as trial failures, but `paymentIntent.status === "requires_action"`
and `authentication_required` should be handled as a recoverable
re-authentication path instead of canceling the subscription. Update the
`paymentIntents.create` / `paymentIntent` status handling and the `catch` block
in `cancelTrialingSubscription` so `requires_action` (or the corresponding
Stripe card error) routes to a payment-update or authentication-required
response, while only unrecoverable failures still cancel the trialing
subscription.
---
Nitpick comments:
In
`@apps/web/app/`(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts:
- Around line 68-81: The validation flow in customer-subscription-trial-will-end
should not use stripe.paymentIntents.create with capture_method: "manual" for
card verification because it places a hold on the full amount. Update the logic
in the webhook handler to use a SetupIntent-based validation path instead,
keeping the existing off_session/customer/payment_method context and returning
the same success message from the surrounding subscription workflow.
🪄 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: defaults
Review profile: CHILL
Plan: Pro
Run ID: b2de1d56-d880-4d29-85f7-f6392955956b
📒 Files selected for processing (3)
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-trial-will-end.tsapps/web/app/(ee)/api/stripe/webhook/route.tsapps/web/app/api/workspaces/[idOrSlug]/billing/upgrade/route.ts
| const paymentIntent = await stripe.paymentIntents.create({ | ||
| amount, | ||
| currency, | ||
| customer: stripeId, | ||
| payment_method: paymentMethodId, | ||
| confirm: true, | ||
| off_session: true, | ||
| capture_method: "manual", | ||
| }); | ||
|
|
||
| if (paymentIntent.status === "requires_capture") { | ||
| await stripe.paymentIntents.cancel(paymentIntent.id); | ||
| return `Validated payment method for subscription ${subscription.id} (workspace ${workspace.slug}).`; | ||
| } | ||
|
|
||
| const skipMessage = await cancelTrialingSubscription({ | ||
| subscriptionId: subscription.id, | ||
| comment: `Trial payment validation returned status: ${paymentIntent.status}`, | ||
| }); | ||
| if (skipMessage) { | ||
| return skipMessage; | ||
| } | ||
|
|
||
| return `Canceled subscription ${subscription.id} due to unexpected payment validation status (${paymentIntent.status}).`; | ||
| } catch (error) { | ||
| if (error instanceof Stripe.errors.StripeCardError) { | ||
| const skipMessage = await cancelTrialingSubscription({ | ||
| subscriptionId: subscription.id, | ||
| comment: `Trial payment validation failed: ${error.message}`, | ||
| }); | ||
| if (skipMessage) { | ||
| return skipMessage; | ||
| } | ||
|
|
||
| await log({ | ||
| message: `Canceled trialing subscription ${subscription.id} for workspace ${workspace.slug} after failed payment validation (${error.decline_code ?? error.code}).`, | ||
| type: "cron", | ||
| mention: true, | ||
| }); | ||
|
|
||
| return `Canceled subscription ${subscription.id} for workspace ${workspace.slug} due to failed payment validation.`; | ||
| } | ||
|
|
||
| throw error; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the target file around the relevant lines.
sed -n '1,220p' 'apps/web/app/(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts'
# Find the helper used to cancel trialing subscriptions.
rg -n "function cancelTrialingSubscription|const cancelTrialingSubscription|export .*cancelTrialingSubscription|cancelTrialingSubscription\(" 'apps/web' -S
# Search for other Stripe error handling patterns in the repo.
rg -n 'authentication_required|StripeCardError|decline_code|paymentIntent\.status|requires_action|requires_confirmation' 'apps/web' -SRepository: dubinc/dub
Length of output: 6704
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Read the Stripe webhook file with line numbers for precise context.
cat -n 'apps/web/app/(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts' | sed -n '1,220p'Repository: dubinc/dub
Length of output: 5973
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the target file around the relevant lines.
cat -n 'apps/web/app/(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts' | sed -n '1,220p'
# Find the helper used to cancel trialing subscriptions.
rg -n "function cancelTrialingSubscription|const cancelTrialingSubscription|export .*cancelTrialingSubscription|cancelTrialingSubscription\(" 'apps/web' -S
# Search for other Stripe error handling patterns in the repo.
rg -n 'authentication_required|StripeCardError|decline_code|paymentIntent\.status|requires_action|requires_confirmation' 'apps/web' -SRepository: dubinc/dub
Length of output: 7803
🌐 Web query:
Stripe PaymentIntent create confirm off_session authentication_required requires_action manual capture docs
💡 Result:
When working with Stripe PaymentIntents involving off_session payments, manual capture, and potential authentication requirements, use the following guidelines: 1. Off-Session Payments: Use the off_session parameter set to true when the customer is not present in your checkout flow and cannot perform authentication [1][2]. This parameter is used in conjunction with confirm=true [1][3]. If a payment requires additional authentication (e.g., due to SCA or network rules) and you are off-session, the PaymentIntent will transition to requires_action [4][2]. 2. Handling Authentication Requirements: - If the PaymentIntent transitions to requires_action, it cannot be completed without customer intervention [4][2]. - For simple integrations, you can use error_on_requires_action=true (when used with confirm=true) to fail the payment immediately if authentication is required, rather than moving to requires_action [2][3]. - If an off-session payment fails due to authentication requirements, you must prompt the customer to perform the action (on-session) [5]. You can then update the PaymentIntent (if necessary) and re-confirm it with off_session=false to collect the required authentication [5]. 3. Manual Capture: - To implement manual capture, set capture_method='manual' when creating the PaymentIntent [6]. This places a hold on the funds upon successful authorization [1][6]. - Upon successful authorization, the PaymentIntent status will transition to requires_capture [6][4]. - You can then capture the funds (in full or partially) by calling the Capture a PaymentIntent endpoint [6][7]. If you combine these, an off-session, manual-capture PaymentIntent is authorized when confirmed, moves to requires_capture, and remains there until you explicitly perform the capture operation [6][4]. If authentication is required during the initial off-session confirmation, you must handle the requires_action state by transitioning to an on-session flow to complete the authentication [4][5].
Citations:
- 1: https://docs.stripe.com/api/payment_intents/create?api-version=2026-02-25.clover
- 2: https://docs.stripe.com/api/payment_intents/confirm?api-version=2026-03-25.dahlia
- 3: https://docs.stripe.com/api/payment_intents/create?api-version=2026-01-28.preview
- 4: https://docs.stripe.com/api/payment_intents/confirm?api-version=2025-08-27.basil
- 5: https://support.stripe.com/questions/manual-confirmation-for-off-session-payments-requiring-strong-customer-authentication-%28sca%29
- 6: https://docs.stripe.com/payments/place-a-hold-on-a-payment-method
- 7: https://docs.stripe.com/api/payment_intents/capture
🏁 Script executed:
#!/bin/bash
set -euo pipefail
cat -n 'apps/web/app/(ee)/api/stripe/webhook/route.ts' | sed -n '1,180p'Repository: dubinc/dub
Length of output: 4434
🏁 Script executed:
#!/bin/bash
set -euo pipefail
cat -n 'apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts' | sed -n '1,220p'Repository: dubinc/dub
Length of output: 1851
🌐 Web query:
Stripe off_session PaymentIntent confirm requires_action StripeCardError authentication_required docs
💡 Result:
When a Stripe PaymentIntent set to off_session enters the requires_action status, it indicates that the payment requires Strong Customer Authentication (SCA) [1][2][3]. Because the customer is not present (off-session), they cannot perform the required authentication in real-time [4][3]. In this scenario, the payment cannot be completed automatically [2]. To resolve this, you must notify the customer to return to your application and complete the authentication (e.g., via 3D Secure) while they are back on-session [2][5]. Key points regarding this behavior: 1. Off-session defined: An off_session parameter set to true indicates the customer is not present in the checkout flow to authenticate [4][3]. 2. Authentication requirement: If the payment requires SCA, it transitions to requires_action [4][3]. You cannot force an off-session authentication for these transactions [2]. 3. Handling the state: When the PaymentIntent is in the requires_action status, you should prompt the customer to interact with your site (e.g., via email or a notification) to complete the payment [2]. Once the customer is back on-session, you can use the Stripe client-side SDKs (like stripe.confirmCardPayment or stripe.handleCardAction) to trigger the authentication flow [5][6]. 4. Manual confirmation: After the customer completes the required actions on the client side, the PaymentIntent moves to the requires_confirmation state [7][6]. You must then explicitly confirm the PaymentIntent again on your server to finalize the payment [7][6]. For recurring payments or future transactions, you can avoid these interruptions by ensuring you have successfully set up the payment method for future usage (setup_future_usage) during an initial on-session payment [2][8][5]. Payments made after proper setup typically no longer require authentication for off-session attempts [2].
Citations:
- 1: https://support.stripe.com/questions/manual-confirmation-for-off-session-payments-requiring-strong-customer-authentication-%28sca%29
- 2: https://docs.stripe.com/payments/3d-secure/authentication-flow
- 3: https://docs.stripe.com/api/payment_intents/confirm?api-version=2026-03-25.dahlia
- 4: https://docs.stripe.com/api/payment_intents/confirm?api-version=2024-11-20.acacia
- 5: https://docs.stripe.com/js/payment_intents/confirm_card_payment?api-version=2026-03-25.dahlia
- 6: https://docs.stripe.com/payments/payment-intents/upgrade-to-handle-actions?locale=en-GB
- 7: https://docs.stripe.com/api/payment_intents/confirm
- 8: https://docs.stripe.com/payments/checkout/save-and-reuse
Handle authentication-required trials separately
confirm: true + off_session: true can surface a recoverable requires_action/authentication_required result for 3DS/SCA cards. This path treats that as a hard failure and cancels the trialing subscription, which will churn customers who only need to re-authenticate. Route that case into a payment-update / re-auth flow instead.
🤖 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/web/app/`(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts
around lines 68 - 112, The payment validation flow in
customer-subscription-trial-will-end currently treats all non-capture and Stripe
card errors as trial failures, but `paymentIntent.status === "requires_action"`
and `authentication_required` should be handled as a recoverable
re-authentication path instead of canceling the subscription. Update the
`paymentIntents.create` / `paymentIntent` status handling and the `catch` block
in `cancelTrialingSubscription` so `requires_action` (or the corresponding
Stripe card error) routes to a payment-update or authentication-required
response, while only unrecoverable failures still cancel the trialing
subscription.
|
@pepeladeira just spoke with Stripe and they're planning to improve billing analytics, so we might not actually need this PR 👀 |
Summary by CodeRabbit
New Features
Bug Fixes