Skip to content

Cancel trialing subscriptions when trial payment validation fails#4136

Draft
pepeladeira wants to merge 2 commits into
mainfrom
trial-will-end-payment-validation
Draft

Cancel trialing subscriptions when trial payment validation fails#4136
pepeladeira wants to merge 2 commits into
mainfrom
trial-will-end-payment-validation

Conversation

@pepeladeira

@pepeladeira pepeladeira commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features

    • Added handling for Stripe trial-ending events to automatically review payment readiness.
    • Trial subscriptions now explicitly cancel if no valid payment method is available.
  • Bug Fixes

    • Improved trial checkout behavior by validating payment methods before attempting to continue billing.
    • Added safer handling for unexpected Stripe payment outcomes during trial conversion.

@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Jul 12, 2026 4:56am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a Stripe webhook handler for customer.subscription.trial_will_end that checks for a payment method, previews the upcoming invoice, and creates a payment intent to validate or cancels the trial subscription accordingly. Wires the handler into the webhook route and configures new trials to cancel on missing payment method.

Changes

Trial-will-end webhook and cancellation behavior

Layer / File(s) Summary
Trial-will-end webhook handler
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts
Implements customerSubscriptionTrialWillEnd to resolve the workspace and payment method, preview the upcoming invoice, create an off-session manual-capture payment intent, and cancel or validate the trial subscription based on outcome; adds getPaymentMethodId and cancelTrialingSubscription helpers.
Webhook route dispatch
apps/web/app/(ee)/api/stripe/webhook/route.ts
Imports the new handler, adds customer.subscription.trial_will_end to relevantEvents, and adds a switch case to dispatch to it.
Trial end-behavior configuration
apps/web/app/api/workspaces/[idOrSlug]/billing/upgrade/route.ts
Extends subscription_data to set trial_settings.end_behavior.missing_payment_method to "cancel" when provisioning a trial.

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
Loading

Possibly related PRs

  • dubinc/dub#3840: Both modify the workspace billing upgrade route's Stripe trial/subscription handling logic.
  • dubinc/dub#3914: Both modify subscription_data trial provisioning in the same upgrade flow.

Suggested reviewers: steven-tey

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: trialing subscriptions are canceled when payment validation fails.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 trial-will-end-payment-validation

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.

@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 (1)
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts (1)

68-81: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Consider a SetupIntent instead of a manual-capture PaymentIntent for validation.

A manual-capture PaymentIntent for amount_due places 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

📥 Commits

Reviewing files that changed from the base of the PR and between 30105c5 and c3d49c2.

📒 Files selected for processing (3)
  • apps/web/app/(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts
  • apps/web/app/(ee)/api/stripe/webhook/route.ts
  • apps/web/app/api/workspaces/[idOrSlug]/billing/upgrade/route.ts

Comment on lines +68 to +112
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;
}

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.

🎯 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' -S

Repository: 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' -S

Repository: 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:


🏁 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:


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.

@steven-tey

Copy link
Copy Markdown
Collaborator

@pepeladeira just spoke with Stripe and they're planning to improve billing analytics, so we might not actually need this PR 👀

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.

2 participants