-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Improve handling of 3DS verification charge failures for domain renewal #2887
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
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughExtracts in-file payout and domain-renewal failure logic into new helper modules, adds a new handler for the payment_intent.requires_action webhook, updates webhook routing to include it, and splits subscription-related utilities into smaller modules; invoice resolution from Stripe transfer_group is consolidated before delegating failure handling. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Stripe as Stripe
participant Router as Webhook Router
participant CF as charge-failed
participant RA as payment_intent.requires_action
participant PFail as processPayoutInvoiceFailure
participant DRFail as processDomainRenewalFailure
Stripe->>Router: POST /webhook (event)
alt event.type == charge.failed
Router->>CF: chargeFailed(event)
CF->>CF: extract transfer_group -> invoiceId
CF-->>CF: return if missing
CF->>CF: update invoice.status = failed, failedReason, ++failedAttempts
alt invoice.type == payout
CF->>PFail: processPayoutInvoiceFailure({ invoice, charge })
else invoice.type == domainRenewal
CF->>DRFail: processDomainRenewalFailure({ invoice })
end
else event.type == payment_intent.requires_action
Router->>RA: paymentIntentRequiresAction(event)
RA->>RA: extract invoiceId, validate invoice & type
RA->>RA: set invoice.status = failed, failedReason, ++failedAttempts
RA->>DRFail: processDomainRenewalFailure({ invoice })
end
sequenceDiagram
autonumber
participant DRFail as processDomainRenewalFailure
participant DB as Prisma
participant DNS as Registrar API
participant Q as QStash
participant Mail as Email Service
DRFail->>DB: fetch domains + workspace owners
alt failedAttempts >= 3
DRFail->>DNS: setRenewOption(autoRenew=false) (parallel)
DRFail->>DB: update registeredDomain.autoRenewalDisabledAt
DRFail->>Mail: send DomainExpired emails to owners
else failedAttempts < 3
DRFail->>Q: schedule retry (+3 days)
DRFail->>Mail: send DomainRenewalFailed emails to owners
end
sequenceDiagram
autonumber
participant PFail as processPayoutInvoiceFailure
participant DB as Prisma
participant Stripe as Stripe
participant Mail as Email Service
PFail->>DB: revert payouts, decrement workspace.payoutsUsage
alt workspace has stripeId & fee applies
PFail->>Stripe: create PaymentIntent for failure fee
Stripe-->>PFail: payment result (card last4 optional)
end
PFail->>Mail: send payout-failed emails (async via waitUntil)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 8
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts(2 hunks)apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/process-domain-renewal-failure.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/route.ts(3 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
apps/web/app/(ee)/api/stripe/webhook/route.ts (1)
apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts (1)
paymentIntentRequiresAction(5-47)
apps/web/app/(ee)/api/stripe/webhook/process-domain-renewal-failure.ts (6)
apps/web/lib/dynadot/set-renew-option.ts (1)
setRenewOption(12-61)packages/email/src/index.ts (1)
sendBatchEmail(32-70)packages/email/src/templates/domain-expired.tsx (1)
DomainExpired(19-124)packages/utils/src/constants/main.ts (1)
APP_DOMAIN_WITH_NGROK(20-25)packages/email/src/templates/domain-renewal-failed.tsx (1)
DomainRenewalFailed(20-124)apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1)
processDomainRenewalInvoice(91-169)
apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts (1)
apps/web/app/(ee)/api/stripe/webhook/process-domain-renewal-failure.ts (1)
processDomainRenewalFailure(10-121)
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (1)
apps/web/app/(ee)/api/stripe/webhook/process-domain-renewal-failure.ts (1)
processDomainRenewalFailure(10-121)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (2)
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (1)
6-6: LGTM: using sendEmail for partner payout notificationsImport looks correct and is used under waitUntil to avoid blocking the request.
apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts (1)
1-7: Confirm transfer_group usage in PaymentIntent creation
Allstripe.paymentIntents.createandcreatePaymentIntentcalls includetransfer_group: invoiceId. Continue usingtransfer_group, or if migrating to metadata-based lookup, addmetadata.invoiceIdat all creation sites.
apps/web/app/(ee)/api/stripe/webhook/utils/process-domain-renewal-failure.ts
Show resolved
Hide resolved
apps/web/app/(ee)/api/stripe/webhook/utils/process-domain-renewal-failure.ts
Show resolved
Hide resolved
apps/web/app/(ee)/api/stripe/webhook/utils/process-domain-renewal-failure.ts
Show resolved
Hide resolved
|
@coderabbitai full review – did we miss anything here? |
|
@steven-tey I'll conduct a comprehensive review of this PR to ensure we haven't missed anything important. This change introduces significant improvements to 3DS verification failure handling for domain renewals, including new webhook processing, refactored failure logic, and enhanced retry mechanisms. I'll examine:
✅ Actions performedFull review triggered. |
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.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (1)
27-44: Prevent double-counting failed attempts
charge.failednow incrementsfailedAttemptsand schedules retries viaprocessDomainRenewalFailure, but the newpayment_intent.requires_actionhandler also increments attempts and enqueues the same retry, so a single failure can advance the counter twice. Keep the state change centralized in one path and let the other handler be notification-only, or add an idempotent guard (e.g., skip if already marked failed during the last X minutes). Otherwise we burn through the three retry attempts after just 1–2 events.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts(2 hunks)apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/utils/process-domain-renewal-failure.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/utils/process-payout-invoice-failure.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/utils/send-cancellation-feedback.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts(0 hunks)
💤 Files with no reviewable changes (1)
- apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts
🧰 Additional context used
🧬 Code graph analysis (4)
apps/web/app/(ee)/api/stripe/webhook/utils/process-payout-invoice-failure.ts (4)
apps/web/lib/partners/constants.ts (2)
DIRECT_DEBIT_PAYMENT_METHOD_TYPES(57-61)PAYOUT_FAILURE_FEE_CENTS(8-8)apps/web/lib/stripe/create-payment-intent.ts (1)
createPaymentIntent(4-76)packages/email/src/index.ts (1)
sendEmail(7-30)packages/email/src/templates/partner-payout-failed.tsx (1)
PartnerPayoutFailed(17-118)
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (2)
apps/web/app/(ee)/api/stripe/webhook/utils/process-payout-invoice-failure.ts (1)
processPayoutInvoiceFailure(14-139)apps/web/app/(ee)/api/stripe/webhook/utils/process-domain-renewal-failure.ts (1)
processDomainRenewalFailure(10-121)
apps/web/app/(ee)/api/stripe/webhook/utils/send-cancellation-feedback.ts (2)
packages/email/src/index.ts (1)
sendEmail(7-30)apps/web/app/(ee)/api/stripe/webhook/utils.ts (1)
sendCancellationFeedback(22-47)
apps/web/app/(ee)/api/stripe/webhook/utils/process-domain-renewal-failure.ts (6)
apps/web/lib/dynadot/set-renew-option.ts (1)
setRenewOption(12-61)packages/email/src/index.ts (1)
sendBatchEmail(32-70)packages/email/src/templates/domain-expired.tsx (1)
DomainExpired(19-124)packages/utils/src/constants/main.ts (1)
APP_DOMAIN_WITH_NGROK(20-25)packages/email/src/templates/domain-renewal-failed.tsx (1)
DomainRenewalFailed(20-124)apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts (1)
processDomainRenewalInvoice(91-169)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (6)
apps/web/app/(ee)/api/stripe/webhook/utils/send-cancellation-feedback.ts (1)
14-38: Nice extraction of the cancellation feedback utilityThe helper carries over the prior logic cleanly and keeps email composition in one focused place. LGTM.
apps/web/app/(ee)/api/stripe/webhook/utils/process-payout-invoice-failure.ts (1)
105-120: Guard against missing partner programs before formatting the email
workspace.programs[0]can beundefined(e.g., data drift or a cleanup race), which would crash this handler the moment it tries to read.name. Please add a safe fallback so the webhook continues to run even if the programs relation is empty.- program: { - name: workspace.programs[0].name, - }, + program: { + name: workspace.programs[0]?.name ?? workspace.slug, + },⛔ Skipped due to learnings
Learnt from: devkiran PR: dubinc/dub#2736 File: apps/web/lib/api/get-workspace-users.ts:76-83 Timestamp: 2025-08-25T17:42:13.600Z Learning: Business rule confirmed: Each workspace has exactly one program. The code should always return workspace.programs[0] since there's only one program per workspace.apps/web/app/(ee)/api/stripe/webhook/utils/process-domain-renewal-failure.ts (2)
15-25: Reintroduce the registeredDomains guard before querying
invoice.registeredDomainscan be null/undefined. Passing that straight intoin:will make Prisma throw and crash the webhook, which was already called out earlier. Please restore the early return guard so we only proceed when we actually have domains, and drop the unsafe cast.Apply this diff:
+ if ( + !Array.isArray(invoice.registeredDomains) || + invoice.registeredDomains.length === 0 + ) { + console.log( + `No registered domains on invoice ${invoice.id}, skipping domain-renewal failure handling.`, + ); + return; + } + const domains = await prisma.registeredDomain.findMany({ where: { slug: { - in: invoice.registeredDomains as string[], + in: invoice.registeredDomains, }, },
74-90: Gate the “Domain expired” email on actual updatesWhen a webhook replay runs after the domains are already disabled,
updateDomains.countcomes back 0 but we still email the owners, causing duplicates. Please keep the send inside aupdateDomains.count > 0check as previously requested.- if (workspaceOwners.length > 0) { + if (updateDomains.count > 0 && workspaceOwners.length > 0) { await sendBatchEmail(apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts (1)
5-84: Scoped imports align with the new utility splitThe new direct imports from
./utils/send-cancellation-feedbackand./utils/update-workspace-planlook correct, and the rest of the handler stays untouched.apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts (1)
12-194: Granular utility imports look goodSwitching to the dedicated utility modules keeps the handler logic the same and prevents
updateWorkspacePlanfrom carrying unrelated email concerns.
apps/web/app/(ee)/api/stripe/webhook/utils/process-payout-invoice-failure.ts
Outdated
Show resolved
Hide resolved
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.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (1)
27-44: Stop double-counting renewal failures coming from different webhooks.
chargeFailedalready marks the invoice failed and incrementsfailedAttempts, andprocessDomainRenewalFailure(invoked here) handles retry scheduling. The newpayment_intent.requires_actionhandler performs the exact same update path, so a single customer SCA failure now bumps attempts twice and schedules duplicate renewals. Consolidate the “fail + increment + retry” flow behind one event (preferablycharge.failed) and make the other handler notification-only, or add an idempotent guard keyed on invoice/event so the helper runs once.apps/web/app/(ee)/api/stripe/webhook/route.ts (1)
48-74: Guard against duplicate processing when multiple events describe the same failure.With both charge-level and payment-intent-level events routed into the renewal failure helper, Stripe’s at-least-once delivery (and now the redundant event types) can trigger the same logic repeatedly. Persist processed event IDs or protect the helper with an idempotency check so retries aren’t scheduled twice and counters stay accurate.
🧹 Nitpick comments (1)
apps/web/app/(ee)/api/stripe/webhook/utils/send-cancellation-feedback.ts (1)
26-38: Filter out owners without emails before dispatchingLine 29 currently pushes the
owner.email && …expression into thePromise.allarray, so owners without an email contribute a literalfalse. That widens the inferred return type ofsendCancellationFeedbacktoPromise<(false | …)[]>, which then leaks into the call sites and leaves us awaiting an array we never read. Filtering first keeps the helper nicely typed asPromise<void>and avoids sprinkling falsy placeholders into the promise list.- return await Promise.all( - owners.map( - (owner) => - owner.email && - sendEmail({ - to: owner.email, - from: "Steven Tey <[email protected]>", - replyTo: "[email protected]", - subject: "Feedback for Dub.co?", - text: `Hey ${owner.name ? owner.name.split(" ")[0] : "there"}!\n\nSaw you canceled your Dub subscription${reasonText ? ` and mentioned that ${reasonText}` : ""} – do you mind sharing if there's anything we could've done better on our side?\n\nWe're always looking to improve our product offering so any feedback would be greatly appreciated!\n\nThank you so much in advance!\n\nBest,\nSteven Tey\nFounder, Dub.co`, - }), - ), - ); + const recipients = owners.filter( + (owner): owner is { name: string | null; email: string } => + typeof owner.email === "string" && owner.email.length > 0, + ); + + await Promise.all( + recipients.map(({ email, name }) => + sendEmail({ + to: email, + from: "Steven Tey <[email protected]>", + replyTo: "[email protected]", + subject: "Feedback for Dub.co?", + text: `Hey ${name ? name.split(" ")[0] : "there"}!\n\nSaw you canceled your Dub subscription${reasonText ? ` and mentioned that ${reasonText}` : ""} – do you mind sharing if there's anything we could've done better on our side?\n\nWe're always looking to improve our product offering so any feedback would be greatly appreciated!\n\nThank you so much in advance!\n\nBest,\nSteven Tey\nFounder, Dub.co`, + }), + ), + );
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (9)
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts(2 hunks)apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/route.ts(3 hunks)apps/web/app/(ee)/api/stripe/webhook/utils/process-domain-renewal-failure.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/utils/process-payout-invoice-failure.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/utils/send-cancellation-feedback.ts(1 hunks)apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts(0 hunks)
💤 Files with no reviewable changes (1)
- apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts
🔇 Additional comments (8)
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts (1)
5-6: Import split matches the new utility layoutThe dedicated imports for the cancellation feedback helper and workspace-plan updater align with the refactor and keep this handler tidy.
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts (1)
12-13: Import split looks goodThe updated import paths mirror the new utility modules without altering runtime behavior.
apps/web/app/(ee)/api/stripe/webhook/utils/process-domain-renewal-failure.ts (2)
15-25: GuardregisteredDomainsbefore queryingLine 18 passes
invoice.registeredDomains as string[]straight into Prisma. When that field isnullor an empty array (we have invoices in that state), Prisma throws “The values in the in() filter must be a nonempty list”, turning the webhook into a 500 and skipping notification/cleanup. Please bail out early when there’s nothing to process.export async function processDomainRenewalFailure({ invoice, }: { invoice: Invoice; }) { + if ( + !Array.isArray(invoice.registeredDomains) || + invoice.registeredDomains.length === 0 + ) { + console.log( + `No registered domains on invoice ${invoice.id}, skipping domain-renewal failure handling.`, + ); + return; + } const domains = await prisma.registeredDomain.findMany({
74-90: Avoid duplicate “Domain expired” emails on retriesLine 74 sends the “Domain expired” email even when
updateDomains.countis 0. On webhook replays or idempotent reprocessing, nothing changes in the database but users still get spammed. Only send when at least one domain record was updated.- if (workspaceOwners.length > 0) { + if (updateDomains.count > 0 && workspaceOwners.length > 0) { await sendBatchEmail( workspaceOwners.map(({ user }) => ({apps/web/app/(ee)/api/stripe/webhook/utils/process-payout-invoice-failure.ts (1)
127-135: Return thesendEmailpromisesLine 128’s mapper doesn’t return anything, so
Promise.allresolves immediately andwaitUntilwon’t hold the task open for the email sends (any rejection becomes unhandled). Return the promise from the mapper so failures propagate correctly.- await Promise.all( - emailData.map((data) => { - sendEmail({ + await Promise.all( + emailData.map((data) => + sendEmail({ subject: "Partner payout failed", to: data.email, react: PartnerPayoutFailed(data), variant: "notifications", - }); - }), + }), + ), );apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts (2)
5-12: Fallback to metadata when resolvinginvoiceIdLine 6 relies solely on
transfer_group, but for many PaymentIntents that field is unset; we usually stash the invoice id in metadata. Without the fallback, we silently skip legitimate events. Please check metadata first.-export async function paymentIntentRequiresAction(event: Stripe.Event) { - const { transfer_group: invoiceId } = event.data - .object as Stripe.PaymentIntent; +export async function paymentIntentRequiresAction(event: Stripe.Event) { + const paymentIntent = event.data.object as Stripe.PaymentIntent; + const invoiceId = + (paymentIntent.metadata as Record<string, string> | undefined)?.invoiceId ?? + paymentIntent.transfer_group;
32-46: Avoid double-counting failed attempts fromrequires_actionLine 41 increments
failedAttemptsand callsprocessDomainRenewalFailure, butcharge.failedalready performs both operations. On a single 3DS failure the two handlers run back-to-back, bump the counter twice (1 → 2), schedule attempt “3”, and we end up disabling auto-renew a full cycle early. Keepcharge.failedas the single source of truth for increments/retry scheduling and make this handler notification-only.- invoice = await prisma.invoice.update({ + await prisma.invoice.update({ where: { id: invoiceId, }, data: { - status: "failed", failedReason: "Your payment requires additional authentication to complete.", - failedAttempts: { - increment: 1, - }, }, }); - return await processDomainRenewalFailure({ invoice }); + // Let charge.failed own the attempt counter and retry scheduling. + return;apps/web/app/(ee)/api/stripe/webhook/route.ts (1)
12-23: Stripe never emitspayment_intent.requires_action.Stripe’s webhooks use
payment_intent.payment_failed(withcode === "authentication_required") for off-session 3DS failures; there is nopayment_intent.requires_actionevent. This branch will never fire, so the renewal fallback logic won’t run. Swap the subscription and handler topayment_intent.payment_failedand gate on the auth-required error code instead.
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.
Actionable comments posted: 1
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/app/(ee)/api/stripe/webhook/utils/process-payout-invoice-failure.ts(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/stripe/webhook/utils/process-payout-invoice-failure.ts (5)
apps/web/lib/partners/constants.ts (2)
DIRECT_DEBIT_PAYMENT_METHOD_TYPES(57-61)PAYOUT_FAILURE_FEE_CENTS(8-8)apps/web/lib/stripe/create-payment-intent.ts (1)
createPaymentIntent(4-76)packages/email/src/index.ts (1)
sendBatchEmail(32-70)packages/email/src/templates/partner-payout-failed.tsx (1)
PartnerPayoutFailed(17-118)apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts (1)
processPayoutInvoice(59-184)
…on webhook
Summary by CodeRabbit
New Features
Refactor
Chores