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

Skip to content

Conversation

@steven-tey
Copy link
Collaborator

@steven-tey steven-tey commented Oct 16, 2025

Summary by CodeRabbit

  • New Features

    • Added "Duplicate payout method detected" and "Connected payout method" emails; conditional notifications when bank accounts are connected.
  • Refactor

    • Unified payouts navigation and CTAs across site and emails to point to /payouts.
  • Bug Fixes

    • Improved webhook handling for payout enable/disable, account deauthorization, payout-paid events, and duplicate payout detection with clearer responses and logging.
  • Chores

    • Added storage for payout method fingerprints and a migration/script to backfill existing records.

@vercel
Copy link
Contributor

vercel bot commented Oct 16, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Oct 16, 2025 2:00am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 16, 2025

Walkthrough

Standardizes payout URLs to /payouts, adds a unique optional payoutMethodHash to Partner, implements/extends Stripe Connect webhook handlers (including deauthorization) to manage payoutsEnabledAt/payoutMethodHash, adds backfill script, and adds connected/duplicate payout email templates.

Changes

Cohort / File(s) Summary
Payouts route & UI
apps/web/app/(ee)/api/paypal/callback/route.ts, apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx, apps/web/lib/actions/partners/generate-stripe-account-link.ts, apps/web/ui/layout/sidebar/payout-stats.tsx, packages/email/src/templates/connect-payout-reminder.tsx, packages/email/src/templates/partner-payout-confirmed.tsx
Replaced /settings/payouts references with /payouts for redirects, links, and Stripe onboarding return/refresh URLs; minor formatting change in PayPal callback route.
Stripe webhook β€” account.updated
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts
New/extended handler: lists Stripe external accounts, selects default fingerprint as payoutMethodHash, updates country/payout fields, clears fields when payouts disabled, handles Prisma P2002 duplicate key, sends duplicate/connected emails, and returns descriptive messages.
Stripe webhook β€” deauthorize & routing
apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.ts, apps/web/app/(ee)/api/stripe/connect/webhook/route.ts
Added account.application.deauthorized handler that clears payoutsEnabledAt and payoutMethodHash; webhook route now dispatches to handlers, captures their return strings, and responds via logAndRespond.
Webhook return/value changes
apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts, apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts, apps/web/app/(ee)/api/stripe/connect/webhook/route.ts
Converted handlers to return descriptive strings on success/error paths (instead of void/log-only); route aggregates and logs/responds with those messages.
Email templates
packages/email/src/templates/duplicate-payout-method.tsx, packages/email/src/templates/connected-payout-method.tsx
Added DuplicatePayoutMethod and ConnectedPayoutMethod React email templates; CTAs point to /payouts.
Schema change
packages/prisma/schema/partner.prisma
Added optional unique field payoutMethodHash: String? @unique to the Partner model.
Backfill migration script
apps/web/scripts/migrations/backfill-payout-method-hash.ts
New script to backfill payoutMethodHash from Stripe external account fingerprints; processes partners, handles missing defaults and P2002 duplicates, logs/skips as needed.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Stripe as Stripe
  participant WebhookRoute as /api/stripe/connect/webhook/route
  participant Handler as Handler (account.updated / account.application.deauthorized)
  participant StripeAPI as Stripe API (external_accounts)
  participant DB as Prisma (Partner)
  participant Mail as Email Service

  Stripe->>WebhookRoute: POST event
  WebhookRoute->>Handler: dispatch by event.type
  alt account.application.deauthorized
    Handler->>DB: find Partner by stripeConnectId
    DB-->>Handler: Partner?
    alt found
      Handler->>DB: update payoutsEnabledAt=null, payoutMethodHash=null
      Handler-->>WebhookRoute: "cleared partner <email>"
    else not found
      Handler-->>WebhookRoute: "no partner found β€” skip"
    end
  else account.updated
    Handler->>DB: find Partner by stripeConnectId
    DB-->>Handler: Partner?
    alt found
      Handler->>StripeAPI: list external_accounts
      StripeAPI-->>Handler: accounts (incl. fingerprint)
      alt payouts_enabled == false
        Handler->>DB: clear payoutsEnabledAt, payoutMethodHash
        Handler-->>WebhookRoute: "payouts disabled"
      else default external account exists
        Handler->>DB: update country, payoutsEnabledAt (if needed), payoutMethodHash=fingerprint
        alt P2002 duplicate on update
          Handler->>Mail: send DuplicatePayoutMethod email
          Handler-->>WebhookRoute: "duplicate notified"
        else success
          Handler-->>WebhookRoute: "updated partner"
        end
      else no default external account
        Handler-->>WebhookRoute: "no default external account"
      end
    else not found
      Handler-->>WebhookRoute: "no partner found β€” skip"
    end
  end
  WebhookRoute-->>Stripe: 200 OK (with logged response)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • TWilson023

Poem

I hopped through code to move a tiny route,
From settings to /payouts I followed every footnote.
Fingerprints linked, duplicates flagged, emails sent with care,
Carrots hashed and partners sorted β€” payouts tidy as a hare.
πŸ₯•πŸ‡

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.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 pull request title "Add DuplicatePayoutMethod check" clearly and specifically refers to a real component of the changeset: the new DuplicatePayoutMethod email template component and the duplicate payout method handling logic added in the Stripe webhook. However, the PR encompasses a significantly broader scope including route migrations from /settings/payouts to /payouts across multiple files, a new ConnectedPayoutMethod email template, payout method tracking via the payoutMethodHash schema field, account deauthorization webhook handling, and various webhook improvements. The title captures a specific, real aspect of the changes but does not summarize the overall scope or main objective comprehensively.
✨ Finishing touches
  • πŸ“ Generate docstrings
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch duplicate-payout-method

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

πŸ“œ Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 0ed9c00 and 705c25a.

πŸ“’ Files selected for processing (9)
  • apps/web/app/(ee)/api/paypal/callback/route.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (3 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx (1 hunks)
  • apps/web/lib/actions/partners/generate-stripe-account-link.ts (1 hunks)
  • apps/web/ui/layout/sidebar/payout-stats.tsx (1 hunks)
  • packages/email/src/templates/connect-payout-reminder.tsx (1 hunks)
  • packages/email/src/templates/duplicate-payout-method.tsx (1 hunks)
  • packages/email/src/templates/partner-payout-confirmed.tsx (1 hunks)
  • packages/prisma/schema/partner.prisma (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
πŸ““ Common learnings
Learnt from: devkiran
PR: dubinc/dub#2635
File: packages/prisma/schema/payout.prisma:24-25
Timestamp: 2025-07-11T16:28:55.693Z
Learning: In the Dub codebase, multiple payout records can now share the same stripeTransferId because payouts are grouped by partner and processed as single Stripe transfers. This is why the unique constraint was removed from the stripeTransferId field in the Payout model - a single transfer can include multiple payouts for the same partner.
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (3)
apps/web/lib/stripe/index.ts (1)
  • stripe (4-10)
packages/email/src/index.ts (1)
  • sendEmail (6-29)
packages/email/src/templates/duplicate-payout-method.tsx (1)
  • DuplicatePayoutMethod (19-175)
⏰ 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 (7)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx (1)

46-46: LGTM! Path update aligns with standardized payout routing.

The navigation target correctly updates to /payouts while preserving the programId query parameter.

packages/email/src/templates/connect-payout-reminder.tsx (1)

113-113: LGTM! Registration redirect updated consistently.

The post-registration redirect now correctly points to /payouts.

apps/web/lib/actions/partners/generate-stripe-account-link.ts (1)

64-65: LGTM! Stripe onboarding flow URLs updated correctly.

Both refresh_url and return_url now consistently point to /payouts, ensuring users are redirected to the correct page after completing or refreshing the Stripe account onboarding.

apps/web/app/(ee)/api/paypal/callback/route.ts (1)

86-86: LGTM! PayPal callback redirect updated consistently.

The redirect target now points to /payouts while correctly preserving error parameter handling.

apps/web/ui/layout/sidebar/payout-stats.tsx (1)

32-32: LGTM! Sidebar payout navigation updated consistently.

The link now correctly directs to /payouts.

packages/email/src/templates/partner-payout-confirmed.tsx (1)

117-117: LGTM! Email CTA link updated consistently.

The "View payout" link now correctly points to /payouts with the payoutId parameter preserved.

packages/email/src/templates/duplicate-payout-method.tsx (1)

1-175: LGTM! Well-structured email template with clear user guidance.

The new DuplicatePayoutMethod email template is well-implemented with:

  • Proper use of react-email components and Tailwind styling
  • Clear structure with payout method details, explanation, and actionable next steps
  • Conditional rendering for optional fields (account holder, bank name, routing number)
  • Appropriate masking of sensitive data (account number)
  • Helpful links to support articles for merging accounts and connecting different payout methods
  • Consistent navigation to /payouts matching the PR's routing standardization

The template provides a good user experience by clearly explaining the issue and offering multiple resolution paths.

@steven-tey
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 16, 2025

βœ… 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: 5

♻️ Duplicate comments (1)
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (1)

42-48: Handle pagination when locating the default external account

As noted in the previous review, listExternalAccounts only returns the first page (default 10 entries). If a partner has >10 external accounts, the default one may be on a later page and will be missed, causing the function to log an error and skip updating payoutMethodHash. Please implement pagination (loop while has_more, passing starting_after) or directly fetch account.default_external_account by ID.

Based on learnings

🧹 Nitpick comments (1)
apps/web/scripts/migrations/backfill-payout-method-hash.ts (1)

21-21: Consider making the batch size configurable or implementing automatic batching.

The hardcoded take: 500 limit requires manual re-runs for repositories with more partners. For a one-time migration this may be acceptable, but consider either:

  1. Adding a command-line argument for batch size
  2. Implementing a loop to process all partners automatically

Example loop implementation:

 async function main() {
   const partnerIdsToSkip: string[] = [];
+  let processed = 0;
+  let hasMore = true;

+  while (hasMore) {
     const partners = await prisma.partner.findMany({
       where: {
         stripeConnectId: {
           not: null,
         },
         payoutsEnabledAt: {
           not: null,
         },
         payoutMethodHash: null,
       },
       orderBy: {
         payoutsEnabledAt: "asc",
       },
       take: 500,
     });

+    hasMore = partners.length === 500;
+    console.log(`Processing batch of ${partners.length} partners (total processed: ${processed})`);

     for (const partner of partners) {
       // ... existing processing logic ...
     }
+    processed += partners.length;
+  }

   console.log(`Partner IDs to skip: "${partnerIdsToSkip.join('", "')}"`);
+  console.log(`Total partners processed: ${processed}`);
 }
πŸ“œ Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 705c25a and c0c88bb.

πŸ“’ Files selected for processing (6)
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (3 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts (3 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/route.ts (3 hunks)
  • apps/web/scripts/migrations/backfill-payout-method-hash.ts (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
πŸ“š Learning: 2025-08-25T21:41:06.073Z
Learnt from: steven-tey
PR: dubinc/dub#2758
File: apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts:43-45
Timestamp: 2025-08-25T21:41:06.073Z
Learning: For Stripe API calls on connected accounts, the stripeAccount parameter should be passed in the first parameter object (e.g., stripe.balance.retrieve({ stripeAccount })), not as request options in the second parameter.

Applied to files:

  • apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts
🧬 Code graph analysis (2)
apps/web/app/(ee)/api/stripe/connect/webhook/route.ts (4)
apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.ts (1)
  • accountApplicationDeauthorized (4-28)
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (1)
  • accountUpdated (8-101)
apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (1)
  • balanceAvailable (9-30)
apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts (1)
  • payoutPaid (6-60)
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (3)
apps/web/lib/stripe/index.ts (1)
  • stripe (4-10)
packages/email/src/index.ts (1)
  • sendEmail (6-29)
packages/email/src/templates/duplicate-payout-method.tsx (1)
  • DuplicatePayoutMethod (19-175)
⏰ 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/connect/webhook/account-application-deauthorized.ts (1)

4-28: LGTM!

The deauthorization handler correctly clears both payoutsEnabledAt and payoutMethodHash, ensuring consistent state when a partner disconnects their Stripe account. The error handling and return messages are appropriate.

apps/web/app/(ee)/api/stripe/connect/webhook/route.ts (1)

41-68: LGTM!

The refactoring to capture handler responses and use logAndRespond improves observability and consistency across all webhook event handlers. The addition of the account.application.deauthorized event handler integrates well with the existing patterns.

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

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/connect/webhook/route.ts (2)

25-26: Return a proper HTTP 400 when signature/secret are missing.

Returning undefined can lead to ambiguous responses. Respond explicitly.

-    if (!sig || !webhookSecret) return;
+    if (!sig || !webhookSecret) {
+      return new Response("Missing Stripe signature or webhook secret", {
+        status: 400,
+      });
+    }

57-66: Type-safe error handling in catch.

error is unknown in TS; .message access can error. Guard or cast.

-  } catch (error) {
-    await log({
-      message: `Stripe webhook failed (${event.type}). Error: ${error.message}`,
-      type: "errors",
-    });
-
-    return new Response(`Webhook error: ${error.message}`, {
+  } catch (error) {
+    const msg = error instanceof Error ? error.message : String(error);
+    await log({
+      message: `Stripe webhook failed (${event.type}). Error: ${msg}`,
+      type: "errors",
+    });
+    return new Response(`Webhook error: ${msg}`, {
       status: 400,
     });
   }
♻️ Duplicate comments (7)
packages/prisma/schema/partner.prisma (1)

38-38: Migration still required for payoutMethodHash field.

As noted in a previous review, this schema change requires a Prisma migration to be generated and committed. The optional unique constraint on payoutMethodHash enables duplicate payout method detection, but without the migration, this field cannot be used in production.

Please refer to the earlier review comment that provided detailed instructions for generating the migration using npx prisma migrate dev --name add-payoutMethodHash.

apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (1)

12-14: Clarify the missing-account message.

-  if (!stripeAccount) {
-    return `Stripe connect account ${stripeAccount} not found. Skipping...`;
-  }
+  if (!stripeAccount) {
+    return "Stripe connect account ID missing from event. Skipping...";
+  }
apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts (1)

9-11: Clarify the missing-account message.

-  if (!stripeAccount) {
-    return `Stripe connect account ${stripeAccount} not found. Skipping...`;
-  }
+  if (!stripeAccount) {
+    return "Stripe connect account ID missing from event. Skipping...";
+  }
apps/web/scripts/migrations/backfill-payout-method-hash.ts (1)

25-28: Handle pagination when listing external accounts.

-    const { data: externalAccounts } =
-      await stripeConnectClient.accounts.listExternalAccounts(
-        partner.stripeConnectId!,
-      );
+    let externalAccounts: any[] = [];
+    let hasMore = true;
+    let startingAfter: string | undefined = undefined;
+    while (hasMore) {
+      const res =
+        await stripeConnectClient.accounts.listExternalAccounts(
+          partner.stripeConnectId!,
+          { limit: 100, starting_after: startingAfter },
+        );
+      externalAccounts.push(...res.data);
+      hasMore = res.has_more;
+      startingAfter = hasMore
+        ? res.data[res.data.length - 1]?.id
+        : undefined;
+    }
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (3)

42-49: Paginate external accounts to reliably find the default.

-  const { data: externalAccounts } = await stripe.accounts.listExternalAccounts(
-    partner.stripeConnectId!,
-  );
+  let externalAccounts: any[] = [];
+  let hasMore = true;
+  let startingAfter: string | undefined = undefined;
+  while (hasMore) {
+    const res = await stripe.accounts.listExternalAccounts(
+      partner.stripeConnectId!,
+      { limit: 100, starting_after: startingAfter },
+    );
+    externalAccounts.push(...res.data);
+    hasMore = res.has_more;
+    startingAfter = hasMore
+      ? res.data[res.data.length - 1]?.id
+      : undefined;
+  }

74-77: Remove the bank_account-only restriction for duplicate detection.

-    if (
-      error.code === "P2002" &&
-      defaultExternalAccount.object === "bank_account"
-    ) {
+    if (error.code === "P2002") {

92-92: Use structured logging instead of console.log.

-      console.log(res);
+      await log({
+        message: `Sent duplicate payout method email to ${partner.email}: ${JSON.stringify(
+          res,
+        )}`,
+        type: "info",
+      });
🧹 Nitpick comments (1)
apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts (1)

54-56: Replace console.log with structured logging for consistency.

-    console.log(
-      `Sent email to partner ${partner.email} (${stripeAccount}): ${JSON.stringify(sentEmail, null, 2)}`,
-    );
+    await log({
+      message: `Sent payout-paid email to partner ${partner.email} (${stripeAccount}): ${JSON.stringify(
+        sentEmail,
+      )}`,
+      type: "info",
+    });

Add the missing import at the top of the file:

import { log } from "@dub/utils";
πŸ“œ Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 0ed9c00 and c0c88bb.

πŸ“’ Files selected for processing (14)
  • apps/web/app/(ee)/api/paypal/callback/route.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (3 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts (3 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/route.ts (3 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx (1 hunks)
  • apps/web/lib/actions/partners/generate-stripe-account-link.ts (1 hunks)
  • apps/web/scripts/migrations/backfill-payout-method-hash.ts (1 hunks)
  • apps/web/ui/layout/sidebar/payout-stats.tsx (1 hunks)
  • packages/email/src/templates/connect-payout-reminder.tsx (1 hunks)
  • packages/email/src/templates/duplicate-payout-method.tsx (1 hunks)
  • packages/email/src/templates/partner-payout-confirmed.tsx (1 hunks)
  • packages/prisma/schema/partner.prisma (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
πŸ“š Learning: 2025-07-11T16:28:55.693Z
Learnt from: devkiran
PR: dubinc/dub#2635
File: packages/prisma/schema/payout.prisma:24-25
Timestamp: 2025-07-11T16:28:55.693Z
Learning: In the Dub codebase, multiple payout records can now share the same stripeTransferId because payouts are grouped by partner and processed as single Stripe transfers. This is why the unique constraint was removed from the stripeTransferId field in the Payout model - a single transfer can include multiple payouts for the same partner.

Applied to files:

  • packages/prisma/schema/partner.prisma
πŸ“š Learning: 2025-08-25T21:41:06.073Z
Learnt from: steven-tey
PR: dubinc/dub#2758
File: apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts:43-45
Timestamp: 2025-08-25T21:41:06.073Z
Learning: For Stripe API calls on connected accounts, the stripeAccount parameter should be passed in the first parameter object (e.g., stripe.balance.retrieve({ stripeAccount })), not as request options in the second parameter.

Applied to files:

  • apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts
⏰ 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: api-tests
πŸ”‡ Additional comments (10)
packages/email/src/templates/connect-payout-reminder.tsx (1)

113-113: LGTM! URL standardization applied correctly.

The redirect URL correctly points to /payouts after partner registration, maintaining the email parameter and next redirect. This aligns with the PR's objective to standardize payout-related URLs.

apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx (1)

46-46: LGTM! Query parameter preserved correctly.

The payouts link correctly maintains the programId query parameter while updating to the new /payouts route.

apps/web/app/(ee)/api/paypal/callback/route.ts (1)

86-86: LGTM! Redirect simplified and updated correctly.

The redirect to /payouts correctly preserves the optional error parameter with proper URL encoding. The single-line syntax is also cleaner than the previous multi-line approach.

packages/email/src/templates/partner-payout-confirmed.tsx (1)

117-117: LGTM! Email template link updated correctly.

The "View payout" link correctly points to the new /payouts route while preserving the payoutId query parameter for direct navigation to the specific payout details.

apps/web/ui/layout/sidebar/payout-stats.tsx (1)

32-32: LGTM! Sidebar navigation updated consistently.

The payouts link in the sidebar now correctly points to /payouts, aligning with the URL standardization across the application.

packages/email/src/templates/duplicate-payout-method.tsx (5)

19-35: LGTM! Props structure is well-designed.

The component props are appropriately typed with:

  • Required email and last4 fields (essential identifiers)
  • Optional account_holder_name, bank_name, and routing_number fields (may vary by country/bank)

The default values provide a complete example for email preview and testing purposes.


36-56: LGTM! Email structure follows best practices.

The email structure appropriately uses:

  • Informative preview text for email clients
  • Clear, non-alarming heading
  • Standard Dub branding with the wordmark

The messaging strikes a good balance between security awareness and user-friendly communication.


58-105: LGTM! Payout method details properly masked and conditionally rendered.

The payout method details card correctly:

  • Masks the account number showing only the last 4 digits for security
  • Conditionally renders optional fields (account_holder_name, bank_name, routing_number)
  • Uses appropriate styling with neutral colors to maintain visual hierarchy

The security-conscious data masking ensures sensitive information is protected while still providing enough context for the user to identify the payout method.


154-174: LGTM! Action button and support messaging are well-implemented.

The primary action button correctly directs users to /payouts (consistent with the PR's URL standardization), and the support text appropriately encourages users to reply to the email for assistance.

The footer component is properly integrated with the email parameter.


107-152: Help article links confirmed
Both β€œMerging your partner accounts” (https://dub.co/help/article/merging-partner-accounts) and β€œReceiving partner payouts on Dub” (https://dub.co/help/article/receiving-payouts) articles are live and accessible.

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

♻️ Duplicate comments (4)
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (4)

87-111: Extend notifications beyond bank accounts or explicitly decide to scope; replace console.log with structured logging.

  • Optional: Also notify on duplicate/connected card payout methods (or document bank-only scope).
  • Use the shared log utility instead of console.log.
-  if (defaultExternalAccount.object === "bank_account") {
+  if (
+    defaultExternalAccount.object === "bank_account" ||
+    defaultExternalAccount.object === "card"
+  ) {
     const EmailTemplate = duplicatePayoutMethod
       ? DuplicatePayoutMethod
       : ConnectedPayoutMethod;
 
     const res = await sendEmail({
       variant: "notifications",
       subject: duplicatePayoutMethod
         ? "Duplicate payout method detected"
         : "Successfully connected payout method",
       to: partner.email!,
       react: EmailTemplate({
         email: partner.email!,
         payoutMethod: {
           account_holder_name: defaultExternalAccount.account_holder_name,
           bank_name: defaultExternalAccount.bank_name,
           last4: defaultExternalAccount.last4,
           routing_number: defaultExternalAccount.routing_number,
         },
       }),
     });
-    console.log(res);
+    await log({
+      message: `Sent ${duplicatePayoutMethod ? "duplicate" : "connected"} payout method email to ${partner.email}`,
+      type: "info",
+    });
 
     return `Notified partner ${partner.email} (${partner.stripeConnectId}) about ${duplicatePayoutMethod ? "duplicate" : "connected"} payout method`;
   }

Note: If you include cards, ensure the email copy is generic (β€œpayout method”) and optional fields are safely undefined (templates already guard conditionally).


43-49: Default external account lookup can miss the default; handle pagination or fetch by ID.

Single-page list risks missing the default method. Iterate pages or fetch the default by ID.

-  const { data: externalAccounts } = await stripe.accounts.listExternalAccounts(
-    partner.stripeConnectId!,
-  );
-
-  const defaultExternalAccount = externalAccounts.find(
-    (account) => account.default_for_currency,
-  );
+  let defaultExternalAccount: Stripe.ExternalAccount | undefined;
+  let starting_after: string | undefined;
+  while (!defaultExternalAccount) {
+    const page = await stripe.accounts.listExternalAccounts(
+      partner.stripeConnectId!,
+      { limit: 100, ...(starting_after ? { starting_after } : {}) },
+    );
+    defaultExternalAccount = page.data.find(
+      // default_for_currency is present on bank accounts; treat loosely for union type
+      (ea: any) => ea?.default_for_currency,
+    );
+    if (defaultExternalAccount || !page.has_more) break;
+    starting_after = page.data[page.data.length - 1]?.id;
+  }

60-75: Guard when fingerprint is missing, and avoid leaking it in the return string.

  • Add a guard before updating to avoid writing undefined/empty hashes.
  • Do not include the fingerprint in responses/logs.
-  let duplicatePayoutMethod = false;
+  let duplicatePayoutMethod = false;
+  if (!("fingerprint" in defaultExternalAccount) || !defaultExternalAccount.fingerprint) {
+    await log({
+      message: `External account ${defaultExternalAccount.id} for ${partner.email} (${partner.stripeConnectId}) has no fingerprint`,
+      type: "errors",
+    });
+    return `External account ${defaultExternalAccount.id} has no fingerprint`;
+  }
   try {
     await prisma.partner.update({
       where: {
         stripeConnectId: account.id,
       },
       data: {
         country,
         payoutsEnabledAt: partner.payoutsEnabledAt
           ? undefined // Don't update if already set
           : new Date(),
         payoutMethodHash: defaultExternalAccount.fingerprint,
       },
     });
-    return `Updated partner ${partner.email} (${partner.stripeConnectId}) with country ${country}, payoutsEnabledAt set, payoutMethodHash ${defaultExternalAccount.fingerprint}`;
+    return `Updated partner ${partner.email} (${partner.stripeConnectId}) with country ${country} and payoutsEnabledAt set`;

75-85: Type-safe error handling and safer logging for unknown errors.

Narrow the error and avoid accessing .code on unknown, and stringify safely.

-  } catch (error) {
-    if (error.code === "P2002") {
+  } catch (error: any) {
+    if (error?.code === "P2002") {
       duplicatePayoutMethod = true;
     } else {
+      const msg = error instanceof Error ? error.message : String(error);
       await log({
-        message: `Error updating partner ${partner.email} (${partner.stripeConnectId}): ${error}`,
+        message: `Error updating partner ${partner.email} (${partner.stripeConnectId}): ${msg}`,
         type: "errors",
       });
-      return `Error updating partner ${partner.email} (${partner.stripeConnectId}): ${error}`;
+      return `Error updating partner ${partner.email} (${partner.stripeConnectId}): ${msg}`;
     }
   }
πŸ“œ Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between c0c88bb and 3d5bc23.

πŸ“’ Files selected for processing (3)
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (3 hunks)
  • packages/email/src/templates/connected-payout-method.tsx (1 hunks)
  • packages/email/src/templates/duplicate-payout-method.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (4)
apps/web/lib/stripe/index.ts (1)
  • stripe (4-10)
packages/email/src/templates/duplicate-payout-method.tsx (1)
  • DuplicatePayoutMethod (19-175)
packages/email/src/templates/connected-payout-method.tsx (1)
  • ConnectedPayoutMethod (19-165)
packages/email/src/index.ts (1)
  • sendEmail (6-29)
⏰ 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

@steven-tey
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 16, 2025

βœ… 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

♻️ Duplicate comments (10)
packages/email/src/templates/duplicate-payout-method.tsx (1)

95-104: Mask the routing number to last 4 digits.

As noted in a previous review, displaying the full routing number in emails is unnecessary and increases privacy risk. Only the last 4 digits should be shown, consistent with the account number masking on line 91.

apps/web/lib/actions/partners/generate-stripe-account-link.ts (1)

64-65: Ensure /payouts handles Stripe onboarding responses.

As noted in a previous review, the /payouts page must handle Stripe onboarding return parameters (e.g., query params for success/failure states). Verify that the page extracts these parameters and displays appropriate UI for completed or incomplete onboarding.

packages/prisma/schema/partner.prisma (1)

38-38: Verify database migration exists for payoutMethodHash.

As noted in a previous review, ensure a Prisma migration has been generated and committed for this new optional unique field. The migration should create the nullable unique column in the partners table.

apps/web/scripts/migrations/backfill-payout-method-hash.ts (2)

25-28: Handle pagination when fetching external accounts.

As noted in a previous review, listExternalAccounts only returns the first page (default limit of 10). For partners with more than 10 external accounts, the default account may be missed. Consider implementing pagination or documenting this limitation.


67-86: Type-safe error handling needed.

As noted in a previous review, directly accessing error.code is unsafe in TypeScript. The error should be narrowed to PrismaClientKnownRequestError before accessing the .code property.

apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (4)

97-114: Replace console.log with structured logging (and avoid dumping full response).

Use the shared log utility; log minimal context.

     const res = await sendEmail({
       variant: "notifications",
       subject: duplicatePayoutMethod
         ? "Duplicate payout method detected"
         : "Successfully connected payout method",
       to: partner.email,
       react: EmailTemplate({
         email: partner.email,
         payoutMethod: {
           account_holder_name: defaultExternalAccount.account_holder_name,
           bank_name: defaultExternalAccount.bank_name,
           last4: defaultExternalAccount.last4,
           routing_number: defaultExternalAccount.routing_number,
         },
       }),
     });
-    console.log(`Resend response: ${JSON.stringify(res, null, 2)}`);
+    await log({
+      message: `Sent ${duplicatePayoutMethod ? "duplicate" : "connected"} payout method email to partner ${partner.id} (${partner.stripeConnectId})`,
+      type: "info",
+    });

43-50: Find the default external account robustly (handle pagination or fetch by ID).

Current code only inspects the first page and may miss the default account.

-  const { data: externalAccounts } = await stripe.accounts.listExternalAccounts(
-    partner.stripeConnectId!,
-  );
-
-  const defaultExternalAccount = externalAccounts.find(
-    (account) => account.default_for_currency,
-  );
+  // Prefer direct fetch by default_external_account if present, otherwise paginate.
+  const defaultExternalAccountId = account.default_external_account as string | null;
+  let defaultExternalAccount: any = null;
+  if (defaultExternalAccountId) {
+    defaultExternalAccount = await stripe.accounts.retrieveExternalAccount(
+      partner.stripeConnectId!,
+      defaultExternalAccountId,
+    );
+  } else {
+    let starting_after: string | undefined;
+    while (true) {
+      const page = await stripe.accounts.listExternalAccounts(partner.stripeConnectId!, {
+        limit: 100,
+        ...(starting_after ? { starting_after } : {}),
+      });
+      defaultExternalAccount = page.data.find((ea) => (ea as any).default_for_currency);
+      if (defaultExternalAccount || !page.has_more) break;
+      starting_after = page.data[page.data.length - 1]?.id;
+    }
+  }

60-74: Guard when fingerprint is missing before updating payoutMethodHash.

Avoid writing undefined/empty hash; log and return.

   let duplicatePayoutMethod = false;
-  try {
+  // Ensure fingerprint exists (for bank_account/card).
+  // @ts-ignore: runtime check for Stripe external account variants
+  const fingerprint = (defaultExternalAccount as any)?.fingerprint;
+  if (!fingerprint) {
+    await log({
+      message: `External account ${defaultExternalAccount.id} for ${partner.stripeConnectId} has no fingerprint`,
+      type: "errors",
+    });
+    return `External account ${defaultExternalAccount.id} has no fingerprint`;
+  }
+
+  try {
     await prisma.partner.update({
       where: {
         stripeConnectId: account.id,
       },
       data: {
         country,
         payoutsEnabledAt: partner.payoutsEnabledAt
           ? undefined // Don't update if already set
           : new Date(),
-        payoutMethodHash: defaultExternalAccount.fingerprint,
+        payoutMethodHash: fingerprint,
       },
     });

74-83: Type-safe error handling and safe logging in catch.

Narrow error before accessing .code; stringify safely for logs/returns.

-  } catch (error) {
-    if (error.code === "P2002") {
+  } catch (error: any) {
+    if (error?.code === "P2002") {
       duplicatePayoutMethod = true;
     } else {
-      await log({
-        message: `Error updating partner ${partner.email} (${partner.stripeConnectId}): ${error}`,
-        type: "errors",
-      });
-      return `Error updating partner ${partner.email} (${partner.stripeConnectId}): ${error}`;
+      const msg = error instanceof Error ? error.message : String(error);
+      await log({
+        message: `Error updating partner ${partner.id} (${partner.stripeConnectId}): ${msg}`,
+        type: "errors",
+      });
+      return `Error updating partner ${partner.id} (${partner.stripeConnectId}): ${msg}`;
     }
   }
packages/email/src/templates/connected-payout-method.tsx (1)

93-102: Mask routing number in the email (privacy).

Don’t render the full routing number. Show only the last 4.

-              {payoutMethod.routing_number && (
+              {payoutMethod.routing_number && (
                 <Row>
                   <Column className="text-sm text-neutral-600">
                     Routing Number
                   </Column>
-                  <Column className="text-right text-sm font-medium text-neutral-800">
-                    {payoutMethod.routing_number}
-                  </Column>
+                  <Column className="text-right text-sm font-medium text-neutral-800">
+                    β€’β€’β€’β€’ {payoutMethod.routing_number?.slice(-4)}
+                  </Column>
                 </Row>
               )}
🧹 Nitpick comments (4)
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (1)

62-66: Consider updating by partner.id (already loaded).

Minor: since you fetched partner, updating by primary id avoids relying on stripeConnectId uniqueness.

-      where: {
-        stripeConnectId: account.id,
-      },
+      where: { id: partner.id },
apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.ts (1)

21-32: Add structured logging and avoid PII in messages.

Emit an info log after updating; prefer ids over emails in messages.

-import { prisma } from "@dub/prisma";
+import { prisma } from "@dub/prisma";
+import { log } from "@dub/utils";
@@
   await prisma.partner.update({
@@
   });
 
-  return `Connected account deauthorized, updated partner ${partner.email} (${partner.stripeConnectId}) with payoutsEnabledAt and payoutMethodHash null`;
+  await log({
+    message: `Connected account deauthorized: updated partner ${partner.id} (${partner.stripeConnectId})`,
+    type: "info",
+  });
+  return `Connected account deauthorized: updated partner ${partner.id} (${partner.stripeConnectId})`;
apps/web/app/(ee)/api/stripe/connect/webhook/route.ts (2)

3-3: Extract logAndRespond into shared utilities
logAndRespond is used by both cron and webhook handlers but is defined under app/(ee)/api/cron/utils. Move it into a generic app/(ee)/api/utils module (e.g. app/(ee)/api/utils/logAndRespond.ts) and update all imports in cron and webhook routes accordingly.


57-68: Use logAndRespond for error responses
Replace the plain new Response in the catch block with:

return logAndRespond(
  `Stripe webhook failed (${event.type}). Error: ${error.message}`, 
  { status: 400, logLevel: "error" }
);
πŸ“œ Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 0ed9c00 and f051975.

πŸ“’ Files selected for processing (15)
  • apps/web/app/(ee)/api/paypal/callback/route.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts (3 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (2 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts (3 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/route.ts (3 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx (1 hunks)
  • apps/web/lib/actions/partners/generate-stripe-account-link.ts (1 hunks)
  • apps/web/scripts/migrations/backfill-payout-method-hash.ts (1 hunks)
  • apps/web/ui/layout/sidebar/payout-stats.tsx (1 hunks)
  • packages/email/src/templates/connect-payout-reminder.tsx (1 hunks)
  • packages/email/src/templates/connected-payout-method.tsx (1 hunks)
  • packages/email/src/templates/duplicate-payout-method.tsx (1 hunks)
  • packages/email/src/templates/partner-payout-confirmed.tsx (1 hunks)
  • packages/prisma/schema/partner.prisma (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
πŸ“š Learning: 2025-07-11T16:28:55.693Z
Learnt from: devkiran
PR: dubinc/dub#2635
File: packages/prisma/schema/payout.prisma:24-25
Timestamp: 2025-07-11T16:28:55.693Z
Learning: In the Dub codebase, multiple payout records can now share the same stripeTransferId because payouts are grouped by partner and processed as single Stripe transfers. This is why the unique constraint was removed from the stripeTransferId field in the Payout model - a single transfer can include multiple payouts for the same partner.

Applied to files:

  • packages/prisma/schema/partner.prisma
πŸ“š Learning: 2025-08-25T21:41:06.073Z
Learnt from: steven-tey
PR: dubinc/dub#2758
File: apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts:43-45
Timestamp: 2025-08-25T21:41:06.073Z
Learning: For Stripe API calls on connected accounts, the stripeAccount parameter should be passed in the first parameter object (e.g., stripe.balance.retrieve({ stripeAccount })), not as request options in the second parameter.

Applied to files:

  • apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts
πŸ”‡ Additional comments (13)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx (1)

46-46: LGTM! URL standardization is consistent.

The payout link update from /settings/payouts to /payouts aligns with the broader PR objective of standardizing payout-related URLs across the codebase.

packages/email/src/templates/connect-payout-reminder.tsx (1)

113-113: LGTM! Registration redirect updated.

The next parameter now correctly redirects to /payouts after registration, consistent with the URL standardization across the PR.

packages/email/src/templates/partner-payout-confirmed.tsx (1)

117-117: LGTM! Payout view link updated.

The link now points to /payouts instead of /settings/payouts, maintaining the payoutId parameter. This aligns with the URL standardization effort.

apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (2)

12-14: LGTM! Error message is now clear.

The new return message "No stripeConnectId found in event. Skipping..." is clear and avoids interpolating null/undefined values.


29-29: LGTM! Return value improves observability.

Returning a descriptive string with the partner account and messageId provides better visibility into the enqueue operation.

apps/web/app/(ee)/api/paypal/callback/route.ts (1)

86-86: LGTM: redirect target standardized to /payouts.

apps/web/ui/layout/sidebar/payout-stats.tsx (1)

32-33: LGTM: sidebar link updated to /payouts.

apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.ts (1)

5-19: Correct source of account id (event.account) and guard.

Good use of event.account with early-return guard; partner lookup is correct.

apps/web/app/(ee)/api/stripe/connect/webhook/route.ts (2)

11-11: LGTM! New webhook event handler follows the established pattern.

The addition of "account.application.deauthorized" to the relevant events and its corresponding handler is implemented consistently with existing webhook handlers.

Also applies to: 44-46


41-41: LGTM! Response capturing improves observability.

The introduction of the response variable and updating all case handlers to capture their return values enables better logging and traceability of webhook event processing.

Also applies to: 47-55

apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts (3)

9-11: LGTM! Error message clarity improved.

The error message no longer interpolates the potentially null/undefined stripeAccount, avoiding confusing output like "Stripe connect account null not found." The message now clearly indicates that the stripeConnectId is missing from the event.


19-21: LGTM! Descriptive error message with safe interpolation.

The error message interpolation is safe here since stripeAccount is guaranteed to be non-null after the previous guard clause. The message provides useful debugging information.


58-60: LGTM! Final return enhances observability.

The descriptive return message provides valuable information about the webhook processing outcome, including the count of updated payouts and partner details. This improves logging and traceability when combined with the response handling in the main webhook route.

@steven-tey steven-tey merged commit 6ad1af2 into main Oct 16, 2025
7 of 8 checks passed
@steven-tey steven-tey deleted the duplicate-payout-method branch October 16, 2025 02:10
This was referenced Dec 4, 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.

2 participants