-
Notifications
You must be signed in to change notification settings - Fork 3.2k
Cancel trialing subscriptions when trial payment validation fails #4136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
pepeladeira
wants to merge
2
commits into
main
Choose a base branch
from
trial-will-end-payment-validation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+167
−0
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
157 changes: 157 additions & 0 deletions
157
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| import { prisma } from "@/lib/prisma"; | ||
| import { stripe } from "@/lib/stripe"; | ||
| import { getPlanAndTierFromPriceId, log } from "@dub/utils"; | ||
| import Stripe from "stripe"; | ||
|
|
||
| export async function customerSubscriptionTrialWillEnd( | ||
| event: Stripe.CustomerSubscriptionTrialWillEndEvent, | ||
| ) { | ||
| const subscription = event.data.object; | ||
|
|
||
| if (subscription.status !== "trialing") { | ||
| return `Subscription ${subscription.id} is not trialing, skipping...`; | ||
| } | ||
|
|
||
| const priceId = subscription.items.data[0]?.price.id; | ||
| if (!priceId) { | ||
| return `Subscription ${subscription.id} has no price, skipping...`; | ||
| } | ||
|
|
||
| const { plan } = getPlanAndTierFromPriceId({ priceId }); | ||
| if (!plan) { | ||
| return `Invalid price ID in trial_will_end event: ${priceId}, skipping...`; | ||
| } | ||
|
|
||
| const stripeId = subscription.customer.toString(); | ||
|
|
||
| const workspace = await prisma.project.findUnique({ | ||
| where: { stripeId }, | ||
| select: { slug: true }, | ||
| }); | ||
|
|
||
| if (!workspace) { | ||
| return `Workspace with Stripe ID ${stripeId} not found, skipping...`; | ||
| } | ||
|
|
||
| const customer = await stripe.customers.retrieve(stripeId); | ||
| const paymentMethodId = getPaymentMethodId(subscription, customer); | ||
|
|
||
| if (!paymentMethodId) { | ||
| const skipMessage = await cancelTrialingSubscription({ | ||
| subscriptionId: subscription.id, | ||
| comment: "Trial ended without a payment method on file.", | ||
| }); | ||
| if (skipMessage) { | ||
| return skipMessage; | ||
| } | ||
|
|
||
| await log({ | ||
| message: `Canceled trialing subscription ${subscription.id} for workspace ${workspace.slug} (missing payment method).`, | ||
| type: "cron", | ||
| }); | ||
|
|
||
| return `Canceled subscription ${subscription.id} for workspace ${workspace.slug} due to missing payment method.`; | ||
| } | ||
|
|
||
| try { | ||
| const upcomingInvoice = await stripe.invoices.createPreview({ | ||
| customer: stripeId, | ||
| subscription: subscription.id, | ||
| }); | ||
|
|
||
| const { amount_due: amount, currency } = upcomingInvoice; | ||
|
|
||
| if (!amount || amount <= 0) { | ||
| return `Subscription ${subscription.id} has no amount due on upcoming invoice, skipping validation.`; | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
| } | ||
|
|
||
| function getPaymentMethodId( | ||
| subscription: Stripe.Subscription, | ||
| customer: Stripe.Customer | Stripe.DeletedCustomer, | ||
| ): string | null { | ||
| if (subscription.default_payment_method) { | ||
| return typeof subscription.default_payment_method === "string" | ||
| ? subscription.default_payment_method | ||
| : subscription.default_payment_method.id; | ||
| } | ||
|
|
||
| if (customer.deleted) { | ||
| return null; | ||
| } | ||
|
|
||
| const defaultPaymentMethod = | ||
| customer.invoice_settings?.default_payment_method; | ||
| if (!defaultPaymentMethod) { | ||
| return null; | ||
| } | ||
|
|
||
| return typeof defaultPaymentMethod === "string" | ||
| ? defaultPaymentMethod | ||
| : defaultPaymentMethod.id; | ||
| } | ||
|
|
||
| async function cancelTrialingSubscription({ | ||
| subscriptionId, | ||
| comment, | ||
| }: { | ||
| subscriptionId: string; | ||
| comment: string; | ||
| }) { | ||
| const current = await stripe.subscriptions.retrieve(subscriptionId); | ||
| if (current.status !== "trialing") { | ||
| return `Subscription ${subscriptionId} is no longer trialing, skipping cancel.`; | ||
| } | ||
|
|
||
| await stripe.subscriptions.cancel(subscriptionId, { | ||
| cancellation_details: { comment }, | ||
| }); | ||
|
|
||
| return null; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
Repository: dubinc/dub
Length of output: 6704
🏁 Script executed:
Repository: dubinc/dub
Length of output: 5973
🏁 Script executed:
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_sessionpayments, manual capture, and potential authentication requirements, use the following guidelines: 1. Off-Session Payments: Use theoff_sessionparameter set totruewhen the customer is not present in your checkout flow and cannot perform authentication [1][2]. This parameter is used in conjunction withconfirm=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 torequires_action[4][2]. 2. Handling Authentication Requirements: - If the PaymentIntent transitions torequires_action, it cannot be completed without customer intervention [4][2]. - For simple integrations, you can useerror_on_requires_action=true(when used withconfirm=true) to fail the payment immediately if authentication is required, rather than moving torequires_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 withoff_session=falseto collect the required authentication [5]. 3. Manual Capture: - To implement manual capture, setcapture_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 torequires_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 torequires_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 therequires_actionstate by transitioning to an on-session flow to complete the authentication [4][5].Citations:
🏁 Script executed:
Repository: dubinc/dub
Length of output: 4434
🏁 Script executed:
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: truecan surface a recoverablerequires_action/authentication_requiredresult 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