Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Sep 25, 2025

…on webhook

Summary by CodeRabbit

  • New Features

    • Handle payments requiring additional authentication: mark invoices as failed and trigger domain renewal failure handling.
    • Improved domain renewal failure flow: retry scheduling, clearer failure emails, and auto-renew disabled after three failed attempts with expiration notices.
    • Enhanced payout failure handling: reset payout state, notify workspace users, and optionally apply a direct-debit failure fee.
  • Refactor

    • Delegated complex renewal, payout, and subscription logic to dedicated helper modules.
  • Chores

    • Webhook now listens to an additional Stripe event; minor import organization.

@vercel
Copy link
Contributor

vercel bot commented Sep 25, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Sep 26, 2025 1:07am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 25, 2025

Walkthrough

Extracts 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

Cohort / File(s) Summary
Webhook routing
apps/web/app/(ee)/api/stripe/webhook/route.ts
Adds payment_intent.requires_action to handled events and routes it to the new handler.
Charge failed refactor
apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts
Replaces inline payout/domain-renewal handlers with external processPayoutInvoiceFailure and processDomainRenewalFailure; extracts transfer_group as invoiceId, updates invoice status/failedAttempts, and delegates processing.
New requires_action handler
apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts
New handler that extracts invoiceId from transfer_group, validates invoice/type, marks invoice failed with a message and increments failedAttempts, then calls processDomainRenewalFailure.
Payout failure util
apps/web/app/(ee)/api/stripe/webhook/utils/process-payout-invoice-failure.ts
New helper to handle partner payout failures: revert payout records, decrement workspace payoutsUsage, optionally charge a direct-debit failure fee via Stripe, and asynchronously send payout-failed emails to workspace users.
Domain renewal failure util
apps/web/app/(ee)/api/stripe/webhook/utils/process-domain-renewal-failure.ts
New helper that fetches domains and workspace owners, branches on failedAttempts (>=3 disables auto-renew and marks DB; <3 schedules a retry via QStash), and sends owner notifications.
Subscription utils split
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts, apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts, apps/web/app/(ee)/api/stripe/webhook/utils/send-cancellation-feedback.ts, apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts
Moves sendCancellationFeedback into its own module and updates imports; removes sendCancellationFeedback export from update-workspace-plan. Subscription handler logic unchanged.

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
Loading
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
Loading
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)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • TWilson023

Poem

A twitch in my whiskers, a hop through the code,
I moved handlers to helpers and lightened the load.
When charges go sideways or domains need a nudge,
Helpers take over — no rabbit will judge.
Hop, hop, webhook — sent with a carrot-yudge. 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title concisely and accurately describes the primary change, focusing on improving 3DS verification charge failures for domain renewal, which aligns with the main webhook and domain renewal updates in the PR.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-domain-renewal

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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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

📥 Commits

Reviewing files that changed from the base of the PR and between 6544642 and 9e36a90.

📒 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 notifications

Import 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
All stripe.paymentIntents.create and createPaymentIntent calls include transfer_group: invoiceId. Continue using transfer_group, or if migrating to metadata-based lookup, add metadata.invoiceId at all creation sites.

@steven-tey
Copy link
Collaborator

@coderabbitai full review – did we miss anything here?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 26, 2025

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

  • New payment_intent.requires_action webhook handling
  • The refactored domain renewal failure processing
  • Database operations and transaction safety
  • Integration with Stripe, qstash, and email systems
  • Error handling and edge cases
  • Security considerations for webhook endpoints
✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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.failed now increments failedAttempts and schedules retries via processDomainRenewalFailure, but the new payment_intent.requires_action handler 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

📥 Commits

Reviewing files that changed from the base of the PR and between 9e36a90 and 7167f24.

📒 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 utility

The 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 be undefined (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.registeredDomains can be null/undefined. Passing that straight into in: 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 updates

When a webhook replay runs after the domains are already disabled, updateDomains.count comes back 0 but we still email the owners, causing duplicates. Please keep the send inside a updateDomains.count > 0 check 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 split

The new direct imports from ./utils/send-cancellation-feedback and ./utils/update-workspace-plan look 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 good

Switching to the dedicated utility modules keeps the handler logic the same and prevents updateWorkspacePlan from carrying unrelated email concerns.

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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.

chargeFailed already marks the invoice failed and increments failedAttempts, and processDomainRenewalFailure (invoked here) handles retry scheduling. The new payment_intent.requires_action handler 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 (preferably charge.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 dispatching

Line 29 currently pushes the owner.email && … expression into the Promise.all array, so owners without an email contribute a literal false. That widens the inferred return type of sendCancellationFeedback to Promise<(false | …)[]>, which then leaks into the call sites and leaves us awaiting an array we never read. Filtering first keeps the helper nicely typed as Promise<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

📥 Commits

Reviewing files that changed from the base of the PR and between 5e2fd21 and 7167f24.

📒 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 layout

The 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 good

The 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: Guard registeredDomains before querying

Line 18 passes invoice.registeredDomains as string[] straight into Prisma. When that field is null or 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 retries

Line 74 sends the “Domain expired” email even when updateDomains.count is 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 the sendEmail promises

Line 128’s mapper doesn’t return anything, so Promise.all resolves immediately and waitUntil won’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 resolving invoiceId

Line 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 from requires_action

Line 41 increments failedAttempts and calls processDomainRenewalFailure, but charge.failed already 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. Keep charge.failed as 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 emits payment_intent.requires_action.

Stripe’s webhooks use payment_intent.payment_failed (with code === "authentication_required") for off-session 3DS failures; there is no payment_intent.requires_action event. This branch will never fire, so the renewal fallback logic won’t run. Swap the subscription and handler to payment_intent.payment_failed and gate on the auth-required error code instead.

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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

📥 Commits

Reviewing files that changed from the base of the PR and between 7167f24 and e3fb652.

📒 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)

@steven-tey steven-tey merged commit c8003d1 into main Sep 26, 2025
8 checks passed
@steven-tey steven-tey deleted the fix-domain-renewal branch September 26, 2025 01:13
@coderabbitai coderabbitai bot mentioned this pull request Nov 15, 2025
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.

3 participants