Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
Comment on lines +68 to +112

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.

}

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;
}
5 changes: 5 additions & 0 deletions apps/web/app/(ee)/api/stripe/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { chargeSucceeded } from "./charge-succeeded";
import { checkoutSessionCompleted } from "./checkout-session-completed";
import { customerSubscriptionCreated } from "./customer-subscription-created";
import { customerSubscriptionDeleted } from "./customer-subscription-deleted";
import { customerSubscriptionTrialWillEnd } from "./customer-subscription-trial-will-end";
import { customerSubscriptionUpdated } from "./customer-subscription-updated";
import { invoicePaymentFailed } from "./invoice-payment-failed";
import { paymentIntentRequiresAction } from "./payment-intent-requires-action";
Expand All @@ -20,6 +21,7 @@ const relevantEvents = new Set([
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.trial_will_end",
"customer.subscription.deleted",
"invoice.payment_failed",
"payment_intent.requires_action",
Expand Down Expand Up @@ -70,6 +72,9 @@ export const POST = async (req: Request) => {
case "customer.subscription.updated":
response = await customerSubscriptionUpdated(event);
break;
case "customer.subscription.trial_will_end":
response = await customerSubscriptionTrialWillEnd(event);
break;
case "customer.subscription.deleted":
response = await customerSubscriptionDeleted(event);
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ export const POST = withWorkspace(
? {
subscription_data: {
trial_period_days: DUB_TRIAL_PERIOD_DAYS,
trial_settings: {
end_behavior: {
missing_payment_method: "cancel",
},
},
},
}
: {}),
Expand Down
Loading