-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Add DuplicatePayoutMethod check #2962
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.
|
WalkthroughStandardizes payout URLs to Changes
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)
Estimated code review effortπ― 4 (Complex) | β±οΈ ~60 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 (beta)
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: 2
π Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
π 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
/payoutswhile 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_urlandreturn_urlnow 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
/payoutswhile 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
/payoutswith 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
/payoutsmatching the PR's routing standardizationThe template provides a good user experience by clearly explaining the issue and offering multiple resolution paths.
β¦uthorized event
|
@coderabbitai full review |
β 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: 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 accountAs noted in the previous review,
listExternalAccountsonly 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 updatingpayoutMethodHash. Please implement pagination (loop whilehas_more, passingstarting_after) or directly fetchaccount.default_external_accountby 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: 500limit requires manual re-runs for repositories with more partners. For a one-time migration this may be acceptable, but consider either:
- Adding a command-line argument for batch size
- 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
π 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
payoutsEnabledAtandpayoutMethodHash, 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
logAndRespondimproves observability and consistency across all webhook event handlers. The addition of theaccount.application.deauthorizedevent handler integrates well with the existing patterns.
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts
Outdated
Show resolved
Hide resolved
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.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: 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
undefinedcan 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.
errorisunknownin TS;.messageaccess 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
payoutMethodHashenables 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
π 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
/payoutsafter 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
programIdquery parameter while updating to the new/payoutsroute.apps/web/app/(ee)/api/paypal/callback/route.ts (1)
86-86: LGTM! Redirect simplified and updated correctly.The redirect to
/payoutscorrectly 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
/payoutsroute while preserving thepayoutIdquery 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
last4fields (essential identifiers)- Optional
account_holder_name,bank_name, androuting_numberfields (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.
apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.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: 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
logutility 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
.codeon 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
π 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
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts
Outdated
Show resolved
Hide resolved
|
@coderabbitai full review |
β 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
β»οΈ 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/payoutshandles Stripe onboarding responses.As noted in a previous review, the
/payoutspage 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,
listExternalAccountsonly 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.codeis unsafe in TypeScript. The error should be narrowed toPrismaClientKnownRequestErrorbefore accessing the.codeproperty.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: ExtractlogAndRespondinto shared utilities
logAndRespondis used by both cron and webhook handlers but is defined underapp/(ee)/api/cron/utils. Move it into a genericapp/(ee)/api/utilsmodule (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 plainnew Responsein 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
π 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/payoutsto/payoutsaligns 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
nextparameter now correctly redirects to/payoutsafter 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
/payoutsinstead of/settings/payouts, maintaining thepayoutIdparameter. 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
responsevariable 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
stripeAccountis 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.
Summary by CodeRabbit
New Features
Refactor
Bug Fixes
Chores