-
Notifications
You must be signed in to change notification settings - Fork 2.8k
External payouts #3033
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
External payouts #3033
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds multi-mode payout support (internal/external/hybrid): Prisma schema changes, eligibility/filter helpers, mode-aware payout selection and processing, external webhook publish/receipt flow, UI indicators/settings, templates, webhook validation, migration scripts, and queuing of external payouts from the charge-succeeded route. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Client as Charge-Succeeded POST
participant Route as /cron/payouts/charge-succeeded
participant Processor as processPayouts
participant StripeQueue as queueStripePayouts
participant PayPalQueue as sendPaypalPayouts
participant ExternalQueue as queueExternalPayouts
participant Webhooks as Workspace Webhooks
participant Partner as Partner emails
Client->>Route: POST invoice payload
Route->>Processor: verify signature & load invoice
Processor->>Processor: determine invoice.payoutMode
alt invoice.payoutMode == internal
Processor->>StripeQueue: queue internal Stripe payouts
Processor->>PayPalQueue: queue internal PayPal payouts
StripeQueue->>Partner: execute & notify
PayPalQueue->>Partner: execute & notify
else invoice.payoutMode != internal
Processor->>ExternalQueue: queueExternalPayouts(invoice)
ExternalQueue->>Webhooks: publish payout.confirmed per partner
Webhooks->>Route: callback -> handleExternalPayoutEvent
Route->>Partner: enqueue external payout emails
end
sequenceDiagram
autonumber
participant Program as Program config
participant Filter as getPayoutEligibilityFilter
participant DB as Prisma
participant Service as getEligiblePayouts
Program->>Filter: provide id/minPayoutAmount/payoutMode
Filter->>DB: build WHERE per mode (internal/external/hybrid)
DB-->>Service: payouts
Service->>Service: compute amounts, apply cutoff, annotate mode
Service-->>Caller: eligible payouts (mode-annotated)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes
Possibly related PRs
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 |
|
/bug0 run |
- Updated payout eligibility logic to include payoutMode in the API response. - Modified ConfirmPayoutsSheet to calculate and display external amounts based on payout modes (external and hybrid). - Improved UI components for partner display and payout information, including the use of CircleArrowRight icon for better visual representation.
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: 6
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/cron/payouts/process/process-payouts.ts (1)
338-365: Audit log records incorrect completion status for external payouts.External payouts are marked as
"processing"in the database (line 305) but logged as"completed"in the audit metadata (line 357-359). However, external payouts only transition to"completed"later when webhooks arrive (see PayPal handler inapps/web/app/(ee)/api/paypal/webhook/payouts-item-succeeded.tsand Stripe handler inapps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts).The audit log should reflect the actual database state at the time of confirmation, which is
"processing". Update line 357-359 to consistently use"processing"for external payouts.
🧹 Nitpick comments (3)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (3)
157-166: Add validation to ensure amount consistency.Consider adding an assertion to validate that the sum of internal and external payout amounts equals the total payout amount. This would catch potential logic errors in the mode-based splitting.
Apply this diff to add validation:
const externalPayoutAmount = externalPayouts.reduce( (total, payout) => total + payout.amount, 0, ); + +const internalPayoutAmount = internalPayouts.reduce( + (total, payout) => total + payout.amount, + 0, +); + +// Validate amount consistency +if (internalPayoutAmount + externalPayoutAmount !== totalPayoutAmount) { + throw new Error( + `Amount mismatch: internal (${internalPayoutAmount}) + external (${externalPayoutAmount}) !== total (${totalPayoutAmount})` + ); +}
168-182: Replace console.log with structured logging.For production code, prefer structured logging (using the
logutility) overconsole.logfor better observability and consistency.Apply this diff:
-console.log({ - internalPayouts: internalPayouts.map((p) => { - return { - id: p.id, - amount: p.amount, - }; - }), - - externalPayouts: externalPayouts.map((p) => { - return { - id: p.id, - amount: p.amount, - }; - }), -}); +await log({ + message: `Processing payouts - Internal: ${internalPayouts.length} (${currencyFormatter(internalPayouts.reduce((sum, p) => sum + p.amount, 0) / 100)}), External: ${externalPayouts.length} (${currencyFormatter(externalPayoutAmount / 100)})`, + type: "payouts", +});
312-321: Consider including workspace usage update in the transaction.The workspace
payoutsUsageincrement (line 318) should ideally be part of the same transaction as the payout status updates to maintain consistency. If payout updates fail, the usage increment should also be rolled back.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts(1 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts(3 hunks)apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts(13 hunks)apps/web/app/api/webhooks/callback/route.ts(4 hunks)apps/web/lib/webhook/handle-external-payout-event.ts(1 hunks)apps/web/lib/webhook/qstash.ts(3 hunks)packages/prisma/schema/invoice.prisma(1 hunks)packages/prisma/schema/payout.prisma(2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/prisma/schema/invoice.prisma
🧰 Additional context used
🧠 Learnings (4)
📓 Common learnings
Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2025-06-25T21:20:59.837Z
Learning: In the Dub codebase, payout limit validation uses a two-stage pattern: server actions perform quick sanity checks (payoutsUsage > payoutsLimit) for immediate user feedback, while the cron job (/cron/payouts) performs authoritative validation (payoutsUsage + payoutAmount > payoutsLimit) with actual calculated amounts before processing. This design provides fast user feedback while ensuring accurate limit enforcement at transaction time.
Learnt from: devkiran
Repo: dubinc/dub PR: 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.
📚 Learning: 2025-06-19T01:46:45.723Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2025-06-19T01:46:45.723Z
Learning: PayPal webhook verification in the Dub codebase is handled at the route level in `apps/web/app/(ee)/api/paypal/webhook/route.ts` using the `verifySignature` function. Individual webhook handlers like `payoutsItemFailed` don't need to re-verify signatures since they're only called after successful verification.
Applied to files:
apps/web/lib/webhook/handle-external-payout-event.tsapps/web/app/api/webhooks/callback/route.ts
📚 Learning: 2025-07-11T16:28:55.693Z
Learnt from: devkiran
Repo: dubinc/dub PR: 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/payout.prismaapps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
📚 Learning: 2025-06-25T21:20:59.837Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2025-06-25T21:20:59.837Z
Learning: In the Dub codebase, payout limit validation uses a two-stage pattern: server actions perform quick sanity checks (payoutsUsage > payoutsLimit) for immediate user feedback, while the cron job (/cron/payouts) performs authoritative validation (payoutsUsage + payoutAmount > payoutsLimit) with actual calculated amounts before processing. This design provides fast user feedback while ensuring accurate limit enforcement at transaction time.
Applied to files:
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
🧬 Code graph analysis (5)
apps/web/lib/webhook/handle-external-payout-event.ts (2)
apps/web/lib/zod/schemas/payouts.ts (1)
payoutWebhookEventSchema(100-112)packages/prisma/index.ts (1)
prisma(3-9)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts (5)
packages/prisma/index.ts (1)
prisma(3-9)apps/web/lib/zod/schemas/payouts.ts (1)
payoutWebhookEventSchema(100-112)apps/web/lib/webhook/publish.ts (1)
sendWorkspaceWebhook(8-45)apps/web/lib/email/queue-batch-email.ts (1)
queueBatchEmail(18-84)packages/email/src/templates/partner-payout-confirmed.tsx (1)
PartnerPayoutConfirmed(18-141)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts (1)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts (1)
queueExternalPayouts(8-116)
apps/web/app/api/webhooks/callback/route.ts (3)
packages/utils/src/functions/urls.ts (1)
getSearchParams(40-49)packages/prisma/index.ts (1)
prisma(3-9)apps/web/lib/webhook/handle-external-payout-event.ts (1)
handleExternalPayoutEvent(16-104)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (5)
apps/web/lib/api/payouts/payout-eligibility-filter.ts (1)
getPayoutEligibilityFilter(3-72)apps/web/lib/api/payouts/get-effective-payout-mode.ts (1)
getEffectivePayoutMode(3-20)apps/web/lib/partners/constants.ts (2)
FOREX_MARKUP_RATE(14-14)DIRECT_DEBIT_PAYMENT_METHOD_TYPES(64-68)apps/web/lib/email/queue-batch-email.ts (1)
queueBatchEmail(18-84)packages/email/src/templates/partner-payout-confirmed.tsx (1)
PartnerPayoutConfirmed(18-141)
⏰ 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/lib/webhook/qstash.ts (1)
102-105: Returning the event id simplifies downstream reconciliation.Keeping the original response spread while adding
webhookEventIdgives callers a built-in link back to our internal webhook record without breaking existing consumers—great observability win.apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (5)
80-123: LGTM!The payout eligibility filtering and data fetching logic is well-structured. The centralized
getPayoutEligibilityFilterintegration and the extended query for partner program metadata align well with the multi-mode payout requirements.
212-214: Clarify fee calculation for external payouts.The fee is calculated based on
totalPayoutAmount(including external payouts), buttotalToSendto Stripe excludes external payouts. This means the workspace is charged fees for external payouts even though they're processed externally. Verify that this is the intended business logic.
240-250: LGTM!The invoice update correctly sets all calculated amounts including the new
externalAmountfield for tracking external payouts separately.
367-404: LGTM!The email notification logic correctly sends confirmations only for internal payouts with Direct Debit methods. The idempotency key pattern and template props are well-structured.
138-155: The original review comment is incorrect.The code is sound. Invoice and Program models both use the same
ProgramPayoutModetype for theirpayoutModefields, which is compatible with theProgramPayoutMode | nullparameter accepted bygetEffectivePayoutMode. The mode-based splitting logic correctly handles all three modes ("internal", "external", "hybrid"), and callinggetEffectivePayoutModewithin the hybrid block withinvoice.payoutModeis appropriate and type-safe.Likely an incorrect or invalid review comment.
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts
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
🧹 Nitpick comments (2)
packages/ui/src/icons/nucleo/circle-dollar-out.tsx (1)
16-37: Consider usingcurrentColorfor stroke values to improve reusability.The stroke color is hardcoded to
#171717across all paths, preventing color customization via CSS or parent context. Icon components typically usestroke="currentColor"to inherit the text color from their container.Apply this diff to make the icon color customizable:
<path d="M9.55575 5.55606H7.44446C6.76952 5.55606 6.22241 6.10317 6.22241 6.7781C6.22241 7.45304 6.76952 8.0005 7.44446 8.0005H8.55583C9.23077 8.0005 9.77788 8.54761 9.77788 9.22255C9.77788 9.89748 9.23077 10.4447 8.55583 10.4447H6.44455M8.0001 4.66699V5.55606M8.0001 11.3337V10.4448" - stroke="#171717" + stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> <path d="M13.1113 10.8887L15.3336 13.1109L13.1113 15.3331" - stroke="#171717" + stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> <path d="M14.4155 8.57602C14.4324 8.38589 14.4446 8.19451 14.4446 8.00011C14.4446 4.44109 11.5593 1.55566 8.00011 1.55566C4.44091 1.55566 1.55566 4.44109 1.55566 8.00011C1.55566 11.5591 4.44091 14.4446 8.00011 14.4446C8.19282 14.4446 8.3826 14.4324 8.57104 14.4157" - stroke="#171717" + stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> <path d="M15.1111 13.1113H10.8889" - stroke="#171717" + stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx (1)
187-227: Consider adding loading state handling for webhooks.The
ExternalPayoutMethodscomponent doesn't handle the loading state for webhooks, which could cause a brief visual flash when data loads. While not critical (the parent component shows loading skeletons for payment methods), it's inconsistent UX.If you want consistent loading behavior, consider:
function ExternalPayoutMethods() { const { slug } = useWorkspace(); - const { webhooks } = useWebhooks(); + const { webhooks, loading } = useWebhooks(); + if (loading) { + return <div className="h-12 animate-pulse rounded-lg bg-neutral-100" />; + } // Filter webhooks with payout.confirmed trigger const externalPayoutWebhooks = useMemo(() => {
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (7)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx(5 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx(1 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx(1 hunks)apps/web/ui/partners/confirm-payouts-sheet.tsx(13 hunks)apps/web/ui/partners/external-payouts-indicator.tsx(1 hunks)packages/ui/src/icons/nucleo/circle-dollar-out.tsx(1 hunks)packages/ui/src/icons/nucleo/index.ts(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/web/ui/partners/external-payouts-indicator.tsx
- packages/ui/src/icons/nucleo/index.ts
🧰 Additional context used
🧠 Learnings (8)
📓 Common learnings
Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2025-06-25T21:20:59.837Z
Learning: In the Dub codebase, payout limit validation uses a two-stage pattern: server actions perform quick sanity checks (payoutsUsage > payoutsLimit) for immediate user feedback, while the cron job (/cron/payouts) performs authoritative validation (payoutsUsage + payoutAmount > payoutsLimit) with actual calculated amounts before processing. This design provides fast user feedback while ensuring accurate limit enforcement at transaction time.
📚 Learning: 2025-10-15T01:05:43.266Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.266Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx
📚 Learning: 2025-09-24T16:10:37.349Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/ui/partners/partner-about.tsx:11-11
Timestamp: 2025-09-24T16:10:37.349Z
Learning: In the Dub codebase, the team prefers to import Icon as a runtime value from "dub/ui" and uses Icon as both a type and variable name in component props, even when this creates shadowing. This is their established pattern and should not be suggested for refactoring.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx
📚 Learning: 2025-10-15T01:52:37.048Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.048Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx
📚 Learning: 2025-09-24T16:09:52.724Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/ui/partners/online-presence-form.tsx:181-186
Timestamp: 2025-09-24T16:09:52.724Z
Learning: The cn utility function in this codebase uses tailwind-merge, which automatically resolves conflicting Tailwind classes by giving precedence to later classes in the className string. Therefore, patterns like `cn("gap-6", variant === "settings" && "gap-4")` are valid and will correctly apply gap-4 when the condition is true.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-12T17:31:10.548Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2833
File: apps/web/lib/actions/partners/approve-bounty-submission.ts:53-61
Timestamp: 2025-09-12T17:31:10.548Z
Learning: In approve-bounty-submission.ts, the logic `bounty.rewardAmount ?? rewardAmount` is intentional. Bounties with preset reward amounts should use those fixed amounts, and the rewardAmount override parameter is only used when bounty.rewardAmount is null/undefined (for custom reward bounties). This follows the design pattern where bounties are either "flat rate" (fixed amount) or "custom" (variable amount set during approval).
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-05-29T09:49:19.604Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2433
File: apps/web/ui/modals/add-payment-method-modal.tsx:60-62
Timestamp: 2025-05-29T09:49:19.604Z
Learning: The `/api/workspaces/${slug}/billing/payment-methods` POST endpoint in the billing API returns either an error (handled by response.ok check) or a response object containing a `url` property for successful requests.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx
🧬 Code graph analysis (4)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx (3)
apps/web/lib/swr/use-program.ts (1)
useProgram(6-40)packages/ui/src/tooltip.tsx (2)
DynamicTooltipWrapper(280-294)TooltipContent(90-128)apps/web/ui/partners/external-payouts-indicator.tsx (1)
ExternalPayoutsIndicator(4-34)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx (4)
apps/web/lib/swr/use-program.ts (1)
useProgram(6-40)packages/prisma/client.ts (1)
ProgramPayoutMode(31-31)packages/ui/src/icons/nucleo/circle-dollar-out.tsx (1)
CircleDollarOut(3-50)apps/web/lib/swr/use-workspace.ts (1)
useWorkspace(6-46)
apps/web/ui/partners/confirm-payouts-sheet.tsx (5)
apps/web/lib/swr/use-program.ts (1)
useProgram(6-40)apps/web/ui/shared/upgrade-required-toast.tsx (1)
UpgradeRequiredToast(8-50)packages/ui/src/icons/nucleo/circle-arrow-right.tsx (1)
CircleArrowRight(3-43)apps/web/ui/partners/partner-row-item.tsx (1)
PartnerRowItem(127-172)apps/web/ui/partners/external-payouts-indicator.tsx (1)
ExternalPayoutsIndicator(4-34)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx (3)
apps/web/lib/swr/use-workspace.ts (1)
useWorkspace(6-46)apps/web/lib/swr/use-program.ts (1)
useProgram(6-40)apps/web/lib/stripe/payment-methods.ts (1)
STRIPE_PAYMENT_METHODS(27-58)
⏰ 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 (11)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx (1)
1-8: LGTM!The imports are appropriate, and the
"use client"directive is correctly placed for this component that uses React hooks.apps/web/ui/partners/confirm-payouts-sheet.tsx (7)
66-66: Verify thatprogramloading state doesn't cause UI flicker.The
programis destructured fromuseProgram()but theloadingstate is not explicitly checked. While the calculations at lines 276-280 guard againstprogram?.payoutMode === undefined, the UI might briefly show incorrect information (e.g., no external payout indicators) whileprogramis loading, then update once loaded.Consider destructuring and using the loading state if a flicker is observed:
-const { program } = useProgram(); +const { program, loading: programLoading } = useProgram();Then incorporate
programLoadinginto the appropriate loading checks or guards.
119-151: Good error handling for external webhook requirements.The custom error mapping provides a clear CTA directing users to set up webhooks when external payouts require it. The implementation gracefully falls back to generic error handling for unmapped errors.
258-269: LGTM: Well-structured payout mode detection.The
isExternalPayouthelper correctly implements the three-mode logic (internal/external/hybrid) and safely defaults tofalsewhenprogram?.payoutModeis undefined. The hybrid mode logic—checkingpayoutsEnabledAt === nullto determine external payouts—aligns with the PR's design.
271-311: LGTM: Calculations correctly guard against missing program data.The memoized calculations appropriately check for
program?.payoutMode === undefinedbefore computingexternalAmount, ensuring no runtime errors occur while the program is loading. The dependency array correctly includesprogram?.payoutMode.
425-449: Past review comment addressed: now usesfinalEligiblePayoutsconsistently.The condition at line 425 correctly uses
finalEligiblePayouts.some(isExternalPayout), which matches the data source for theexternalAmountcalculation (lines 291-295). The past review comment flagged the use ofeligiblePayoutsinstead offinalEligiblePayouts, and this has been corrected.
498-505: Good refactor: Consistent partner display withPartnerRowItem.Replacing inline partner rendering with the
PartnerRowItemcomponent improves consistency across the UI and properly delegates avatar, status indicator, and link behavior to a shared component.
520-562: External payout indicator integrated cleanly, but watch for loading flicker.The layout correctly accommodates the external payout indicator and the Exclude/Include control using absolute positioning. However, the positioning calculation
right-[calc(14px+0.375rem)]is tightly coupled to the indicator's size; if the icon or gap changes, this value must be updated manually.Additionally, if
programloads aftereligiblePayouts, the external payout indicators may appear with a brief delay, causing a visual flicker (see comment on line 66).apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx (3)
298-301: LGTM: Improved component API.The refactoring from separate props to a single
payoutobject improves the API and reduces prop drilling. The type constraint ensures all required fields are present.
362-370: LGTM: Clear feedback for partners without payouts enabled.The tooltip provides clear guidance when a partner doesn't have payouts enabled. The optional chaining safely handles missing partner data.
331-360: Verify external payouts always have partner data by checking Prisma schema constraints.Based on code analysis: the
PayoutResponseSchematype requirespartner(no optional modifiers), and the API route accessespartner.payoutsEnabledAtdirectly without null checks. However, I cannot access the Prisma schema file to confirmpartnerIdis non-nullable or that external payouts cannot be created without a valid partner relation.The component's defensive use of
payout.partner?.tenantId(lines 348, 352) is appropriate regardless, but verify:
- External payouts cannot be created without a valid
partnerId- Payout.partner relation is required (not nullable) in schema
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx
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: 4
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/cron/payouts/charge-succeeded/send-paypal-payouts.ts (2)
43-60: Validate batch payout success before updating status.The code updates payouts to
"sent"status immediately aftercreatePayPalBatchPayoutreturns, but there's no validation that the batch was successfully submitted to PayPal. If the batch creation fails partially or PayPal returns an error, the database state will be inconsistent.Consider:
- Validating the
batchPayoutresponse to ensure it indicates success- Using a transaction to atomically update all payout statuses
- Logging any discrepancy if
updatedPayouts.countdoesn't matchpayouts.lengthconst batchPayout = await createPayPalBatchPayout({ payouts, invoiceId: invoice.id, }); console.log("PayPal batch payout created", batchPayout); + // Validate batch payout was successful + if (!batchPayout || batchPayout.batch_header?.batch_status === "DENIED") { + throw new Error(`PayPal batch payout failed: ${JSON.stringify(batchPayout)}`); + } // update the payouts to "sent" status const updatedPayouts = await prisma.payout.updateMany({ where: { id: { in: payouts.map((p) => p.id) }, }, data: { status: "sent", paidAt: new Date(), }, }); console.log(`Updated ${updatedPayouts.count} payouts to "sent" status`); + if (updatedPayouts.count !== payouts.length) { + console.error( + `Expected to update ${payouts.length} payouts but only updated ${updatedPayouts.count}`, + ); + }
62-78: Add error handling for email batch failures.If
sendBatchEmailthrows an exception, the function will fail after the payouts have already been marked as"sent". This could result in partners not receiving notification emails even though the database indicates successful payout processing.- const batchEmails = await sendBatchEmail( - payouts - .filter((payout) => payout.partner.email) - .map((payout) => ({ - variant: "notifications", - to: payout.partner.email!, - subject: "You've been paid!", - react: PartnerPayoutProcessed({ - email: payout.partner.email!, - program: payout.program, - payout, - variant: "paypal", - }), - })), - ); - - console.log("Resend batch emails sent", JSON.stringify(batchEmails, null, 2)); + try { + const batchEmails = await sendBatchEmail( + payouts + .filter((payout) => payout.partner.email) + .map((payout) => ({ + variant: "notifications", + to: payout.partner.email!, + subject: "You've been paid!", + react: PartnerPayoutProcessed({ + email: payout.partner.email!, + program: payout.program, + payout, + variant: "paypal", + }), + })), + ); + + console.log("Resend batch emails sent", JSON.stringify(batchEmails, null, 2)); + } catch (error) { + console.error("Failed to send payout notification emails:", error); + // Emails failed but payouts are already marked sent - log for manual follow-up + }
♻️ Duplicate comments (1)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts (1)
69-91: Handle missing partner enrollment before spreading program data.
payout.partner.programs[0]can beundefined(e.g., partner unenrolled after the payout was created). Spreading...undefinedthrows aTypeError, breaking the webhook dispatch for that payout. The existing try-catch only logs the error message, causing the payout to remain stuck in"processing"status.for (const payout of externalPayouts) { try { + const enrollment = payout.partner.programs[0]; + + if (!enrollment) { + console.error( + `Partner ${payout.partner.id} is missing enrollment for program ${invoice.programId}. Skipping webhook for payout ${payout.id}.`, + ); + continue; + } + const data = payoutWebhookEventSchema.parse({ ...payout, partner: { ...payout.partner, - ...payout.partner.programs[0], + ...enrollment, }, }); await sendWorkspaceWebhook({ workspace: { id: invoice.workspaceId, webhookEnabled: true, }, webhooks, data, trigger: "payout.confirmed", }); } catch (error) { - console.error(error.message); + console.error( + `Failed to send webhook for payout ${payout.id}:`, + error instanceof Error ? error.message : error, + ); } }
🧹 Nitpick comments (1)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts (1)
17-20: Clarify comment to distinguish external-only vs hybrid mode.The comment states "All payouts are processed externally" but only checks for
invoice.payoutMode === "external". This guard is specific to invoices where all payouts are external. Hybrid mode invoices (with both internal and external payouts) correctly pass through this guard and are filtered on line 47.Apply this diff to improve clarity:
- // All payouts are processed externally, hence no need to queue Stripe payouts - if (invoice.payoutMode === "external") { + // Skip Stripe queueing for external-only invoices (all payouts are external) + // Hybrid mode invoices will continue and filter for internal payouts below + if (invoice.payoutMode === "external") { return; }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts(1 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts(3 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts(3 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts(2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2025-06-25T21:20:59.837Z
Learning: In the Dub codebase, payout limit validation uses a two-stage pattern: server actions perform quick sanity checks (payoutsUsage > payoutsLimit) for immediate user feedback, while the cron job (/cron/payouts) performs authoritative validation (payoutsUsage + payoutAmount > payoutsLimit) with actual calculated amounts before processing. This design provides fast user feedback while ensuring accurate limit enforcement at transaction time.
🧬 Code graph analysis (2)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1)
packages/prisma/index.ts (1)
prisma(3-9)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts (5)
packages/prisma/index.ts (1)
prisma(3-9)apps/web/lib/zod/schemas/payouts.ts (1)
payoutWebhookEventSchema(100-112)apps/web/lib/webhook/publish.ts (1)
sendWorkspaceWebhook(8-45)apps/web/lib/email/queue-batch-email.ts (1)
queueBatchEmail(18-84)packages/email/src/templates/partner-payout-confirmed.tsx (1)
PartnerPayoutConfirmed(18-141)
⏰ 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 (5)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts (2)
42-49: Implementation is correct and handles hybrid mode properly.The
mode: "internal"filter correctly ensures that:
- Pure internal invoices queue all internal payouts for Stripe (via guard on line 18)
- Hybrid invoices queue only internal payouts for Stripe (via both the guard and mode filter)
- Pure external invoices skip this function entirely (via the guard on line 18)
The guard-and-filter approach provides defense-in-depth: the
invoice.payoutMode === "external"guard prevents unnecessary execution, while themode: "internal"database filter provides additional correctness assurance for hybrid invoices where both internal and external payouts coexist.
12-20: No changes needed. ThepayoutModefield is required with a default value.The
Invoice.payoutModefield is defined in the schema aspayoutMode ProgramPayoutMode @default(internal), making it a required, non-nullable field that always defaults to"internal"for new records. The code correctly uses this field without a null check at line 18.The original review comment's concern about nullability is not applicable here. The schema enforces that
payoutModewill always have a value, and the Prisma type system ensures this at compile time.apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1)
7-12: LGTM! Function signature and mode filtering are well-structured.The refactoring to accept a typed
Invoiceobject instead of a plaininvoiceIdstring improves type safety. The addition ofmode: "internal"correctly restricts this function to internal PayPal payouts, aligning with the multi-mode architecture.apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts (2)
8-17: LGTM! Function signature and early return logic are correct.The typed
Invoiceparameter ensures type safety, and the early return for"internal"mode correctly prevents unnecessary processing when all payouts are handled internally.
105-134: LGTM! Email idempotency key prevents duplicate notifications.The idempotency key
payout-confirmed-external/${invoice.id}correctly prevents duplicate email sends on retry, and the email template data is properly structured for external payouts.
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts
Show resolved
Hide resolved
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts
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
🧹 Nitpick comments (2)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts (2)
126-126: Usepayout.modeinstead of hardcoding"external".While hardcoding is technically correct since the query filters for
mode: "external", usingpayout.modeis more maintainable and type-safe. If the query logic changes, the hardcoded value could become incorrect.payout: { id: payout.id, amount: payout.amount, startDate: payout.periodStart, endDate: payout.periodEnd, - mode: "external", + mode: payout.mode, paymentMethod: invoice.paymentMethod ?? "ach", },
105-134: Wrap email queueing in try-catch for consistent error handling.Similar to the program fetch issue, if
queueBatchEmailfails after webhooks were sent, the function aborts without completing email notifications. This creates partial completion where webhooks were dispatched but partners don't receive email confirmations.Add error handling to log failures and continue gracefully.
+ try { await queueBatchEmail<typeof PartnerPayoutConfirmed>( externalPayouts .filter((payout) => payout.partner.email) .map((payout) => ({ to: payout.partner.email!, subject: "You've got money coming your way!", variant: "notifications", replyTo: program.supportEmail || "noreply", templateName: "PartnerPayoutConfirmed", templateProps: { email: payout.partner.email!, program: { id: program.id, name: program.name, logo: program.logo, }, payout: { id: payout.id, amount: payout.amount, startDate: payout.periodStart, endDate: payout.periodEnd, mode: "external", paymentMethod: invoice.paymentMethod ?? "ach", }, }, })), { idempotencyKey: `payout-confirmed-external/${invoice.id}`, }, ); + } catch (error) { + console.error( + `Failed to queue payout confirmation emails for invoice ${invoice.id}:`, + error.message, + ); + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts(1 hunks)apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts(13 hunks)
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2025-06-25T21:20:59.837Z
Learning: In the Dub codebase, payout limit validation uses a two-stage pattern: server actions perform quick sanity checks (payoutsUsage > payoutsLimit) for immediate user feedback, while the cron job (/cron/payouts) performs authoritative validation (payoutsUsage + payoutAmount > payoutsLimit) with actual calculated amounts before processing. This design provides fast user feedback while ensuring accurate limit enforcement at transaction time.
📚 Learning: 2025-07-11T16:28:55.693Z
Learnt from: devkiran
Repo: dubinc/dub PR: 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:
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
📚 Learning: 2025-06-25T21:20:59.837Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2025-06-25T21:20:59.837Z
Learning: In the Dub codebase, payout limit validation uses a two-stage pattern: server actions perform quick sanity checks (payoutsUsage > payoutsLimit) for immediate user feedback, while the cron job (/cron/payouts) performs authoritative validation (payoutsUsage + payoutAmount > payoutsLimit) with actual calculated amounts before processing. This design provides fast user feedback while ensuring accurate limit enforcement at transaction time.
Applied to files:
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
🧬 Code graph analysis (2)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts (5)
packages/prisma/index.ts (1)
prisma(3-9)apps/web/lib/zod/schemas/payouts.ts (1)
payoutWebhookEventSchema(100-112)apps/web/lib/webhook/publish.ts (1)
sendWorkspaceWebhook(8-45)apps/web/lib/email/queue-batch-email.ts (1)
queueBatchEmail(18-84)packages/email/src/templates/partner-payout-confirmed.tsx (1)
PartnerPayoutConfirmed(18-141)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (5)
apps/web/lib/api/payouts/payout-eligibility-filter.ts (1)
getPayoutEligibilityFilter(3-72)apps/web/lib/api/payouts/get-effective-payout-mode.ts (1)
getEffectivePayoutMode(3-20)apps/web/lib/partners/constants.ts (2)
FOREX_MARKUP_RATE(14-14)DIRECT_DEBIT_PAYMENT_METHOD_TYPES(64-68)apps/web/lib/email/queue-batch-email.ts (1)
queueBatchEmail(18-84)packages/email/src/templates/partner-payout-confirmed.tsx (1)
PartnerPayoutConfirmed(18-141)
🔇 Additional comments (2)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (2)
371-408: Email logic is correct for internal payouts.The email notification logic appropriately:
- Sends emails only for internal payouts processed through Stripe
- Filters by direct debit payment methods (which take longer to process)
- Uses idempotency key to prevent duplicate emails
- Sets mode to "internal" in the email template
External payouts are excluded from these notifications, which is correct since they're handled by the external system.
212-214: Confirm fee policy for external payouts is intentional or refactor the fee calculation logic.The fee is calculated on
totalPayoutAmount(line 212), which includes both internal and external payouts. However, only internal payouts are processed through Stripe. This means:
- For
externalpayout mode: All payouts are charged fees but none go through Stripe (only fees are sent).- For
hybridpayout mode: External payouts are charged fees even though they bypass Stripe processing.Confirm whether this fee structure is intentional (workspace charges fees on all payout types regardless of processing method) or if fees should only apply to Stripe-processed payouts. If the latter, adjust line 212 to calculate fees only on
internalPayoutAmount.
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.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: 2
♻️ Duplicate comments (5)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (3)
45-45: Remove unusedwebhookEnabledproperty from interface.The
webhookEnabledproperty is declared inProcessPayoutsPropsbut is never referenced in the function body. This was flagged in a previous review and remains unaddressed.Apply this diff:
workspace: Pick< Project, | "id" | "stripeId" | "plan" | "invoicePrefix" | "payoutsUsage" | "payoutsLimit" | "payoutFee" - | "webhookEnabled" >;
256-280: Transaction boundary concern: Payment intent created before database updates.The Stripe payment intent is created before database updates (lines 282-314). If the DB updates fail, the payment intent may be orphaned. While the idempotency key prevents duplicate charges on retry, there's no reconciliation mechanism for partial failures.
Consider:
- Wrapping payment intent creation and DB updates in a transaction pattern with proper error handling
- Implementing reconciliation to detect and handle orphaned payment intents
- Adding retry logic that uses the idempotency key defensively
Based on learnings
299-313: Critical: External payouts should be marked as "completed", not "processing".External payouts are marked as
"processing"at line 309, but the PR objectives state that "external payouts are marked completed inside DB transactions while external transfer occurs outside." Since Dub doesn't process external payouts (they're handled by the external system), they should be marked as"completed"immediately.This creates inconsistency with the audit log at line 362, which already records the correct mode distinction.
Apply this diff:
// Mark external payouts as processing if (externalPayouts.length > 0) { await prisma.payout.updateMany({ where: { id: { in: externalPayouts.map((p) => p.id), }, }, data: { invoiceId: invoice.id, - status: "processing", + status: "completed", userId, mode: "external", }, }); }apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts (2)
96-102: Handle missing partner enrollment before spreading program data.This issue was previously flagged.
payout.partner.programs[0]can beundefinedif the partner was unenrolled after the payout was created, causing aTypeErrorwhen spreading.
104-116: Add status tracking for external payout webhooks.This issue was previously flagged. After sending webhooks, payouts remain in
"processing"status with no retry mechanism or recovery path for failures.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts(1 hunks)apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts(13 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-07-11T16:28:55.693Z
Learnt from: devkiran
Repo: dubinc/dub PR: 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:
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
📚 Learning: 2025-06-25T21:20:59.837Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2025-06-25T21:20:59.837Z
Learning: In the Dub codebase, payout limit validation uses a two-stage pattern: server actions perform quick sanity checks (payoutsUsage > payoutsLimit) for immediate user feedback, while the cron job (/cron/payouts) performs authoritative validation (payoutsUsage + payoutAmount > payoutsLimit) with actual calculated amounts before processing. This design provides fast user feedback while ensuring accurate limit enforcement at transaction time.
Applied to files:
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
🧬 Code graph analysis (2)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (5)
apps/web/lib/api/payouts/payout-eligibility-filter.ts (1)
getPayoutEligibilityFilter(3-72)apps/web/lib/api/payouts/get-effective-payout-mode.ts (1)
getEffectivePayoutMode(3-20)apps/web/lib/partners/constants.ts (2)
FOREX_MARKUP_RATE(14-14)DIRECT_DEBIT_PAYMENT_METHOD_TYPES(64-68)apps/web/lib/email/queue-batch-email.ts (1)
queueBatchEmail(18-84)packages/email/src/templates/partner-payout-confirmed.tsx (1)
PartnerPayoutConfirmed(18-141)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts (5)
packages/prisma/index.ts (1)
prisma(3-9)apps/web/lib/zod/schemas/payouts.ts (1)
payoutWebhookEventSchema(100-112)apps/web/lib/webhook/publish.ts (1)
sendWorkspaceWebhook(8-45)apps/web/lib/email/queue-batch-email.ts (1)
queueBatchEmail(18-84)packages/email/src/templates/partner-payout-confirmed.tsx (1)
PartnerPayoutConfirmed(18-141)
⏰ 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 (8)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (7)
3-4: LGTM!The new imports for
getEffectivePayoutModeandgetPayoutEligibilityFilterare correctly integrated with the multi-mode payout logic, and the Prisma type imports provide proper TypeScript support.Also applies to: 22-28
80-123: LGTM!The payout retrieval correctly uses
getPayoutEligibilityFilterfor mode-aware filtering and includes the necessary fields for determining internal vs external payouts in hybrid mode.
129-155: LGTM!The payout separation logic correctly handles all three modes (internal, external, hybrid) and uses
getEffectivePayoutModeappropriately to determine individual payout modes in hybrid scenarios.
232-240: LGTM!The currency conversion now correctly uses
totalToSend(which already excludes external payouts) as the base amount and the log message accurately reflects the conversion direction.
342-407: LGTM!The audit log correctly uses
externalPayoutsMapto distinguish between internal and external payouts, and email notifications are appropriately sent only for internal payouts using direct debit payment methods with proper idempotency keys.
244-254: LGTM!The invoice update correctly records both
totalPayoutAmountandexternalPayoutAmount, enabling proper tracking and reconciliation of internal vs external payouts.
184-184: Verify business logic: Should external payouts count toward workspace limits and incur fees?The workspace payout limit check (line 184) includes external payouts in
totalPayoutAmount, and fees are calculated on the fulltotalPayoutAmount(line 212). However, external payouts are processed by external systems, not by Dub/Stripe.Clarify the intended behavior:
- Should external payouts count toward the workspace's
payoutsLimit?- Should Dub charge fees on external payouts that are processed externally?
If external payouts should not incur fees or count toward limits, the calculations need adjustment.
Run this script to check if there's documentation or related logic about external payout fee handling:
Also applies to: 212-214
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts (1)
118-148: Consider transaction boundaries for webhook and email operations.Email queueing occurs after webhook sending completes (lines 104-116). If
queueBatchEmailthrows an exception, webhooks have already been dispatched but the function aborts. On retry:
- Payouts remain in
"processing"status (no status update)- Webhooks will be resent (duplicate notifications)
- Emails will be deduplicated via idempotency key
This creates inconsistent state and potential duplicate webhooks. Consider:
- Updating payout status after successful webhook dispatch (as noted in past comments)
- Adding a
webhookSentAttimestamp to prevent resending webhooks on retry- Moving email queueing before webhook sending to fail fast
- Wrapping both operations in a transaction with proper rollback handling
Run the following to check if there's existing retry/deduplication logic for webhooks:
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts
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: 1
♻️ Duplicate comments (3)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (3)
45-45: Remove unusedwebhookEnabledproperty.The
webhookEnabledproperty inProcessPayoutsPropsis never referenced in the function body. No webhook-related logic is present in this payout processing flow.Apply this diff:
- | "webhookEnabled"
248-272: Transaction boundary concern: Payment intent created before database updates.The Stripe payment intent is created (lines 248-272) before payout status updates (lines 274-306). If database updates fail, the payment intent could be orphaned. While the idempotency key prevents duplicate charges on retry, there's still a risk of payment/database state divergence.
Consider either:
- Wrapping the entire flow in a transaction with proper error handling and rollback
- Moving payment intent creation after database updates (with tradeoffs)
- Implementing a reconciliation mechanism to detect and handle partial failures
Based on learnings
291-306: Critical: External payouts should be marked "completed" not "processing".External payouts are marked as
"processing"(line 301), but since they don't go through Stripe's payment flow, they should be marked as"completed"immediately. The PR objectives explicitly state "external payouts are marked completed inside DB transactions."This creates inconsistency with the audit log (line 354) and the semantic meaning—external payouts are already "complete" from this system's perspective since they're handled externally.
Apply this diff:
// Mark external payouts as processing if (externalPayouts.length > 0) { await prisma.payout.updateMany({ where: { id: { in: externalPayouts.map((p) => p.id), }, }, data: { invoiceId: invoice.id, - status: "processing", + status: "completed", userId, mode: "external", }, }); }
🧹 Nitpick comments (1)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (1)
130-147: Consider defensive validation for hybrid mode classification.The hybrid mode classification (lines 135-146) relies entirely on the eligibility filter to ensure external payouts have a valid
tenantId. While the filter should prevent invalid payouts from being selected, adding defensive validation here would make the code more robust and explicit about the requirement.Consider selecting and validating
partner.programsfor external payouts:select: { id: true, amount: true, periodStart: true, periodEnd: true, mode: true, partner: { select: { email: true, payoutsEnabledAt: true, + programs: { + where: { programId: program.id }, + select: { tenantId: true }, + }, }, }, },Then add validation in the classification loop:
const payoutMode = getEffectivePayoutMode({ payoutMode: invoice.payoutMode, payoutsEnabledAt: payout.partner.payoutsEnabledAt, }); if (payoutMode === "external") { + const hasTenantId = payout.partner.programs.some((p) => p.tenantId !== null); + if (!hasTenantId) { + throw new Error(`External payout ${payout.id} missing tenantId`); + } externalPayouts.push(payout); }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts(13 hunks)
🧰 Additional context used
🧠 Learnings (4)
📓 Common learnings
Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2025-06-25T21:20:59.837Z
Learning: In the Dub codebase, payout limit validation uses a two-stage pattern: server actions perform quick sanity checks (payoutsUsage > payoutsLimit) for immediate user feedback, while the cron job (/cron/payouts) performs authoritative validation (payoutsUsage + payoutAmount > payoutsLimit) with actual calculated amounts before processing. This design provides fast user feedback while ensuring accurate limit enforcement at transaction time.
📚 Learning: 2025-07-11T16:28:55.693Z
Learnt from: devkiran
Repo: dubinc/dub PR: 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:
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
📚 Learning: 2025-06-25T21:20:59.837Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2025-06-25T21:20:59.837Z
Learning: In the Dub codebase, payout limit validation uses a two-stage pattern: server actions perform quick sanity checks (payoutsUsage > payoutsLimit) for immediate user feedback, while the cron job (/cron/payouts) performs authoritative validation (payoutsUsage + payoutAmount > payoutsLimit) with actual calculated amounts before processing. This design provides fast user feedback while ensuring accurate limit enforcement at transaction time.
Applied to files:
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (4)
apps/web/lib/api/payouts/payout-eligibility-filter.ts (1)
getPayoutEligibilityFilter(3-72)apps/web/lib/api/payouts/get-effective-payout-mode.ts (1)
getEffectivePayoutMode(3-20)apps/web/lib/partners/constants.ts (2)
FOREX_MARKUP_RATE(14-14)DIRECT_DEBIT_PAYMENT_METHOD_TYPES(64-68)packages/email/src/templates/partner-payout-confirmed.tsx (1)
PartnerPayoutConfirmed(18-141)
⏰ 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 (3)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (3)
204-206: Verify that Stripe fees on external payouts are intentional.The
totalFeeis calculated ontotalPayoutAmount(line 204), which includes both internal and external payouts. However, external payouts don't go through Stripe, so charging Stripe's processing fee on them seems incorrect.If this is intentional business logic (e.g., workspace pays a fee on all payouts regardless of mode), consider adding a comment to clarify. Otherwise, adjust the fee calculation to only apply to internal payouts:
- const totalFee = Math.round(totalPayoutAmount * payoutFee) + fastAchFee; + const internalPayoutAmount = totalPayoutAmount - externalPayoutAmount; + const totalFee = Math.round(internalPayoutAmount * payoutFee) + fastAchFee; const total = totalPayoutAmount + totalFee; - let totalToSend = total - externalPayoutAmount; + let totalToSend = internalPayoutAmount + totalFee;
236-246: LGTM: Invoice update correctly tracks payout amounts.The invoice update properly records both the total payout amount and the external payout amount separately, enabling proper reconciliation.
308-399: LGTM: Workspace update, audit logging, and notifications properly handle mode-aware flows.The implementation correctly:
- Increments workspace usage by total payout amount (including both internal and external)
- Uses
externalPayoutsMapto determine payout mode in audit logs- Sends email notifications only for internal payouts with direct debit payment methods
- Includes proper idempotency keys for email batching
Based on learnings
|
@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: 4
♻️ Duplicate comments (15)
apps/web/lib/zod/schemas/programs.ts (1)
39-39: Allow payoutMode to be nullable until backfilled.The Prisma schema defines
payoutModeas nullable (ProgramPayoutMode?), but the Zod schema requires a non-null value. This will cause parse errors when hydrating existing programs that haven't set a payout mode yet. Keep the field nullable or nullish until the column is backfilled.Apply this diff:
- payoutMode: z.nativeEnum(ProgramPayoutMode).default("internal"), + payoutMode: z.nativeEnum(ProgramPayoutMode).nullish().default("internal"),apps/web/scripts/migrations/update-payout-mode-to-internal.ts (2)
4-17: Add error handling and database cleanup.The migration lacks error handling and doesn't close the Prisma connection. Wrap the logic in try/catch/finally to handle errors and ensure
prisma.$disconnect()is called.Apply this diff:
async function main() { - const result = await prisma.payout.updateMany({ - where: { - status: { - not: "pending", - }, - }, - data: { - mode: "internal", - }, - }); + try { + const result = await prisma.payout.updateMany({ + where: { + status: { + not: "pending", + }, + }, + data: { + mode: "internal", + }, + }); - console.log(`Updated ${result.count} payouts to mode 'internal'`); + console.log(`Updated ${result.count} payouts to mode 'internal'`); + } catch (error) { + console.error("Migration failed:", error); + throw error; + } finally { + await prisma.$disconnect(); + } }
19-19: Handle promise rejection for proper error reporting.The
main()call should handle promise rejection to ensure the process exits with a non-zero code on failure.Apply this diff:
-main(); +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +});apps/web/ui/partners/partner-row-item.tsx (1)
61-93: Wait for program data before computing payout status.The hook computes
statusKeywhileprogrammay still beundefinedduring SWR loading. Whenprogram?.payoutModeis undefined, the switch defaults tofalse, causing partners to incorrectly render as "Payouts disabled" until the program loads—especially problematic for partners who are actually eligible under external or hybrid modes.Short-circuit the computation when program is not yet loaded:
function usePartnerPayoutStatus(partner: PartnerRowItemProps["partner"]) { const { program } = useProgram(); const showPayoutsEnabled = "payoutsEnabledAt" in partner; + + if (!program) { + return { + statusKey: null, + showPayoutsEnabled, + }; + } const isExternalPayoutEnabled =apps/web/ui/partners/external-payouts-indicator.tsx (1)
9-26: Guard slug before building the webhook link
useParams()can returnundefinedor an array forslug. When that happens the template literal will emit/undefined/settings/webhooksor"/a,b/settings/webhooks", giving partners a broken link. Please normalize the param (ensure it’s a single string, encode it, or fall back to a non-link state) before constructing the href.export function ExternalPayoutsIndicator({ side = "top", }: { side?: "top" | "left"; }) { - const { slug } = useParams(); + const params = useParams(); + const slugParam = params?.slug; + const slug = + typeof slugParam === "string" + ? slugParam + : Array.isArray(slugParam) + ? slugParam[0] + : undefined; + const webhookHref = slug + ? `/${encodeURIComponent(slug)}/settings/webhooks` + : undefined; return ( <Tooltip content={ <div className="max-w-xs px-4 py-2 text-center text-sm text-neutral-700"> This payout will be processed externally via the{" "} <code className="rounded-md bg-neutral-100 px-1 py-0.5 font-mono"> payout.confirmed </code>{" "} - <a - href={`/${slug}/settings/webhooks`} - target="_blank" - rel="noopener noreferrer" - className="cursor-alias underline decoration-dotted underline-offset-2" - > - webhook event. - </a> + {webhookHref ? ( + <a + href={webhookHref} + target="_blank" + rel="noopener noreferrer" + className="cursor-alias underline decoration-dotted underline-offset-2" + > + webhook event. + </a> + ) : ( + <span className="underline decoration-dotted underline-offset-2"> + webhook event. + </span> + )} </div> } side={side} > <CircleArrowRight className="size-3.5 text-purple-800" />apps/web/app/(ee)/api/programs/[programId]/payouts/route.ts (1)
63-68: Guard against missing partner-program joins
partner.programs[0]isundefinedwhen the partner lacks a join row for this program (e.g., internal-only partners or historical payouts after a join removal). Spreading...partner.programs[0]throws a runtime TypeError and breaks the whole response. Please guard the spread before merging the join fields.partner: { ...partner, - ...partner.programs[0], + ...(partner.programs[0] ?? {}), },apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx (1)
74-80: Add rel to the external linkThis link opens a new tab without
rel="noopener noreferrer", leavingwindow.openerexposed. Add the standard security attributes.Apply this diff:
<Link href={`/${slug}/settings/webhooks`} target="_blank" + rel="noopener noreferrer" className="font-medium underline underline-offset-2 hover:text-amber-800" >apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-sheet.tsx (2)
49-69: Error handling for invalid payout modes already flagged.The missing try-catch wrapper for
getEffectivePayoutModewas already identified in a previous review comment.
265-329: Duplicate hook calls already flagged.The redundant
useExternalPayoutEnrollmentscalls and the recommendation to pass data as props were already addressed in a previous review comment.apps/web/lib/webhook/handle-external-payout-event.ts (1)
73-101: Race condition on concurrent webhook delivery already flagged.The issue where concurrent webhooks can overwrite each other's updates was already identified and a fix using
updateManywith proper guards was provided in a previous review comment.apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx (2)
34-45: Error handling for payment methods request already flagged.The missing error handling and loading state management for the payment methods fetch was already addressed in a previous review comment.
136-136: Loading state check for program already flagged.The recommendation to check the program loading state before conditionally rendering
ExternalPayoutMethodswas already provided in a previous review comment.apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx (1)
114-131: Invoice access for legacy payouts already flagged.The issue where the
mode === "internal"check hides invoices for legacy payouts withmode === nullwas already identified in a previous review comment.apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts (2)
94-103: Guard missing partner enrollment before building the webhook payloadIf a partner loses their enrollment (or the include comes back empty for any reason),
payout.partner.programs[0]isundefinedand spreading it throwsTypeError: Cannot convert undefined or null to object, aborting the cron run and preventing subsequent payouts from being processed. Please guard for the missing enrollment before constructing the payload.for (const payout of externalPayouts) { - try { - const data = payoutWebhookEventSchema.parse({ - ...payout, - partner: { - ...payout.partner, - ...payout.partner.programs[0], - }, - }); + try { + const enrollment = payout.partner.programs?.[0]; + + if (!enrollment) { + console.error( + `Partner ${payout.partner.id} is missing enrollment for program ${program.id} on invoice ${invoice.id}. Skipping webhook dispatch.`, + ); + continue; + } + + const data = payoutWebhookEventSchema.parse({ + ...payout, + partner: { + ...payout.partner, + ...enrollment, + }, + });
87-147: Do not bail out when no payout webhooks are configuredReturning early when
webhooks.length === 0prevents the email notifications from being queued and leaves the external payouts stuck in"processing"with no follow-up path. Workspaces that haven’t configured a webhook yet would never see their partners notified or their payouts reconciled. Please keep the email flow (and any subsequent state transitions) running even when there are no webhooks—only the webhook dispatch itself should be skipped.
🧹 Nitpick comments (2)
apps/web/lib/actions/parse-action-errors.ts (1)
19-19: LGTM! Appropriate logging level upgrade.Changing from
console.logtoconsole.erroris correct for error scenarios and improves observability.Optionally, consider using a structured logging library (e.g., Pino, Winston) instead of console methods for production environments. This would provide better log aggregation, filtering, and monitoring capabilities.
packages/email/src/templates/partner-payout-confirmed.tsx (1)
31-31: Clarify null mode handling in the template.The type allows
mode: "internal" | "external" | null, but the conditional at line 106 only checkspayout.mode === "external". This meansnullwill fall through to the internal/default branch, which is reasonable for backward compatibility.Consider adding a comment documenting this fallback behavior for future maintainers.
Also applies to: 46-46
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (61)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts(1 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts(3 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts(3 hunks)apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts(2 hunks)apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts(13 hunks)apps/web/app/(ee)/api/cron/payouts/process/split-payouts.ts(3 hunks)apps/web/app/(ee)/api/partner-profile/payouts/route.ts(2 hunks)apps/web/app/(ee)/api/partner-profile/programs/route.ts(1 hunks)apps/web/app/(ee)/api/programs/[programId]/payouts/count/route.ts(3 hunks)apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts(2 hunks)apps/web/app/(ee)/api/programs/[programId]/payouts/route.ts(3 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-details-sheet.tsx(2 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-sheet.tsx(5 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx(6 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/invoices/[payoutId]/route.tsx(1 hunks)apps/web/app/api/webhooks/[webhookId]/route.ts(4 hunks)apps/web/app/api/webhooks/callback/route.ts(4 hunks)apps/web/app/api/webhooks/route.ts(3 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/payouts/page-client.tsx(2 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx(5 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx(1 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx(1 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-button.tsx(1 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-modal.tsx(0 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-sheet.tsx(1 hunks)apps/web/lib/actions/parse-action-errors.ts(1 hunks)apps/web/lib/actions/partners/confirm-payouts.ts(6 hunks)apps/web/lib/actions/partners/update-program.ts(1 hunks)apps/web/lib/api/payouts/get-effective-payout-mode.ts(1 hunks)apps/web/lib/api/payouts/get-eligible-payouts.ts(1 hunks)apps/web/lib/api/payouts/payout-eligibility-filter.ts(1 hunks)apps/web/lib/integrations/slack/transform.ts(3 hunks)apps/web/lib/swr/use-payouts.ts(1 hunks)apps/web/lib/webhook/constants.ts(2 hunks)apps/web/lib/webhook/get-webhooks.ts(1 hunks)apps/web/lib/webhook/handle-external-payout-event.ts(1 hunks)apps/web/lib/webhook/publish.ts(2 hunks)apps/web/lib/webhook/qstash.ts(3 hunks)apps/web/lib/webhook/sample-events/payload.ts(2 hunks)apps/web/lib/webhook/sample-events/payout-confirmed.json(1 hunks)apps/web/lib/webhook/types.ts(2 hunks)apps/web/lib/webhook/validate-webhook.ts(1 hunks)apps/web/lib/zod/schemas/payouts.ts(3 hunks)apps/web/lib/zod/schemas/programs.ts(2 hunks)apps/web/scripts/migrations/update-payout-mode-to-internal.ts(1 hunks)apps/web/tests/webhooks/index.test.ts(3 hunks)apps/web/ui/partners/confirm-payouts-sheet.tsx(13 hunks)apps/web/ui/partners/external-payouts-indicator.tsx(1 hunks)apps/web/ui/partners/partner-row-item.tsx(1 hunks)apps/web/ui/webhooks/add-edit-webhook-form.tsx(3 hunks)packages/email/src/templates/partner-payout-confirmed.tsx(3 hunks)packages/prisma/client.ts(1 hunks)packages/prisma/schema/invoice.prisma(1 hunks)packages/prisma/schema/partner.prisma(1 hunks)packages/prisma/schema/payout.prisma(2 hunks)packages/prisma/schema/program.prisma(1 hunks)packages/ui/src/icons/nucleo/circle-arrow-right.tsx(1 hunks)packages/ui/src/icons/nucleo/circle-dollar-out.tsx(1 hunks)packages/ui/src/icons/nucleo/index.ts(1 hunks)packages/ui/src/slider.tsx(1 hunks)
💤 Files with no reviewable changes (1)
- apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-modal.tsx
🧰 Additional context used
🧠 Learnings (24)
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsxapps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.tsapps/web/ui/partners/partner-row-item.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsxapps/web/ui/partners/confirm-payouts-sheet.tsxapps/web/lib/actions/partners/update-program.tsapps/web/app/(ee)/api/programs/[programId]/payouts/route.tsapps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
📚 Learning: 2025-10-15T01:05:43.266Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.266Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-details-sheet.tsxapps/web/ui/partners/external-payouts-indicator.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/payouts/page-client.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-button.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-sheet.tsx
📚 Learning: 2025-07-11T16:28:55.693Z
Learnt from: devkiran
Repo: dubinc/dub PR: 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/payout.prismaapps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.tsapps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx
📚 Learning: 2025-08-16T11:14:00.667Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2754
File: apps/web/lib/partnerstack/schemas.ts:47-52
Timestamp: 2025-08-16T11:14:00.667Z
Learning: The PartnerStack API always includes the `group` field in partner responses, so the schema should use `.nullable()` rather than `.nullish()` since the field is never omitted/undefined.
Applied to files:
apps/web/lib/zod/schemas/programs.tsapps/web/app/(ee)/api/programs/[programId]/payouts/route.ts
📚 Learning: 2025-06-18T20:26:25.177Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2538
File: apps/web/ui/partners/overview/blocks/commissions-block.tsx:16-27
Timestamp: 2025-06-18T20:26:25.177Z
Learning: In the Dub codebase, components that use workspace data (workspaceId, defaultProgramId) are wrapped in `WorkspaceAuth` which ensures these values are always available, making non-null assertions safe. This is acknowledged as a common pattern in their codebase, though not ideal.
Applied to files:
apps/web/lib/actions/partners/confirm-payouts.ts
📚 Learning: 2025-08-25T17:42:13.600Z
Learnt from: devkiran
Repo: dubinc/dub PR: 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.
Applied to files:
apps/web/lib/actions/partners/confirm-payouts.ts
📚 Learning: 2025-05-29T09:49:19.604Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2433
File: apps/web/ui/modals/add-payment-method-modal.tsx:60-62
Timestamp: 2025-05-29T09:49:19.604Z
Learning: The `/api/workspaces/${slug}/billing/payment-methods` POST endpoint in the billing API returns either an error (handled by response.ok check) or a response object containing a `url` property for successful requests.
Applied to files:
apps/web/lib/actions/partners/confirm-payouts.tsapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx
📚 Learning: 2025-10-17T08:18:19.278Z
Learnt from: devkiran
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2025-10-17T08:18:19.278Z
Learning: In the apps/web codebase, `@/lib/zod` should only be used for places that need OpenAPI extended zod schema. All other places should import from the standard `zod` package directly using `import { z } from "zod"`.
Applied to files:
apps/web/tests/webhooks/index.test.tsapps/web/app/api/webhooks/route.tsapps/web/app/api/webhooks/[webhookId]/route.ts
📚 Learning: 2025-05-29T04:45:18.504Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2448
File: packages/email/src/templates/partner-program-summary.tsx:0-0
Timestamp: 2025-05-29T04:45:18.504Z
Learning: In the PartnerProgramSummary email template (packages/email/src/templates/partner-program-summary.tsx), the stat titles are hardcoded constants ("Clicks", "Leads", "Sales", "Earnings") that will always match the ICONS object keys after toLowerCase() conversion, so icon lookup failures are not possible.
Applied to files:
apps/web/ui/partners/partner-row-item.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-details-sheet.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/payouts/page-client.tsx
📚 Learning: 2025-08-25T21:03:24.285Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2736
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx:1-1
Timestamp: 2025-08-25T21:03:24.285Z
Learning: In Next.js App Router, Server Components that use hooks can work without "use client" directive if they are only imported by Client Components, as they get "promoted" to run on the client side within the Client Component boundary.
Applied to files:
apps/web/ui/webhooks/add-edit-webhook-form.tsx
📚 Learning: 2025-09-18T16:33:17.719Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2858
File: apps/web/ui/partners/partner-application-tabs.tsx:1-1
Timestamp: 2025-09-18T16:33:17.719Z
Learning: When a React component in Next.js App Router uses non-serializable props (like setState functions), adding "use client" directive can cause serialization warnings. If the component is only imported by Client Components, it's better to omit the "use client" directive to avoid these warnings while still getting client-side execution through promotion.
Applied to files:
apps/web/ui/webhooks/add-edit-webhook-form.tsx
📚 Learning: 2025-08-26T15:05:55.081Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2736
File: apps/web/lib/swr/use-bounty.ts:11-16
Timestamp: 2025-08-26T15:05:55.081Z
Learning: In the Dub codebase, workspace authentication and route structures prevent endless loading states when workspaceId or similar route parameters are missing, so gating SWR loading states on parameter availability is often unnecessary.
Applied to files:
apps/web/ui/webhooks/add-edit-webhook-form.tsxapps/web/lib/swr/use-payouts.ts
📚 Learning: 2025-09-12T17:31:10.548Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2833
File: apps/web/lib/actions/partners/approve-bounty-submission.ts:53-61
Timestamp: 2025-09-12T17:31:10.548Z
Learning: In approve-bounty-submission.ts, the logic `bounty.rewardAmount ?? rewardAmount` is intentional. Bounties with preset reward amounts should use those fixed amounts, and the rewardAmount override parameter is only used when bounty.rewardAmount is null/undefined (for custom reward bounties). This follows the design pattern where bounties are either "flat rate" (fixed amount) or "custom" (variable amount set during approval).
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsxapps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-24T16:10:37.349Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/ui/partners/partner-about.tsx:11-11
Timestamp: 2025-09-24T16:10:37.349Z
Learning: In the Dub codebase, the team prefers to import Icon as a runtime value from "dub/ui" and uses Icon as both a type and variable name in component props, even when this creates shadowing. This is their established pattern and should not be suggested for refactoring.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx
📚 Learning: 2025-10-06T15:48:45.956Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2935
File: packages/prisma/schema/workspace.prisma:21-36
Timestamp: 2025-10-06T15:48:45.956Z
Learning: In the Dub repository (dubinc/dub), Prisma schema changes are not managed with separate migration files. Do not flag missing Prisma migration files when schema changes are made to files like `packages/prisma/schema/workspace.prisma` or other schema files.
Applied to files:
packages/prisma/schema/partner.prisma
📚 Learning: 2025-06-06T07:59:03.120Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2177
File: apps/web/lib/api/links/bulk-create-links.ts:66-84
Timestamp: 2025-06-06T07:59:03.120Z
Learning: In apps/web/lib/api/links/bulk-create-links.ts, the team accepts the risk of potential undefined results from links.find() operations when building invalidLinks arrays, because existing links are fetched from the database based on the input links, so matches are expected to always exist.
Applied to files:
apps/web/app/api/webhooks/[webhookId]/route.ts
📚 Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.
Applied to files:
apps/web/lib/actions/partners/update-program.ts
📚 Learning: 2025-07-09T20:52:56.592Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2614
File: apps/web/ui/partners/design/previews/lander-preview.tsx:181-181
Timestamp: 2025-07-09T20:52:56.592Z
Learning: In apps/web/ui/partners/design/previews/lander-preview.tsx, the ellipsis wave animation delay calculation `3 - i * -0.15` is intentionally designed to create negative delays that offset each dot's animation cycle. This pattern works correctly for the intended ellipsis effect and should not be changed to positive incremental delays.
Applied to files:
packages/ui/src/icons/nucleo/circle-arrow-right.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/payouts/page-client.tsx
📚 Learning: 2025-10-15T01:52:37.048Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.048Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.
Applied to files:
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-sheet.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-button.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx
📚 Learning: 2025-09-24T16:09:52.724Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/ui/partners/online-presence-form.tsx:181-186
Timestamp: 2025-09-24T16:09:52.724Z
Learning: The cn utility function in this codebase uses tailwind-merge, which automatically resolves conflicting Tailwind classes by giving precedence to later classes in the className string. Therefore, patterns like `cn("gap-6", variant === "settings" && "gap-4")` are valid and will correctly apply gap-4 when the condition is true.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx
📚 Learning: 2025-06-18T20:23:38.835Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2538
File: apps/web/ui/partners/overview/blocks/traffic-sources-block.tsx:50-82
Timestamp: 2025-06-18T20:23:38.835Z
Learning: Internal links within the same application that use target="_blank" may not require rel="noopener noreferrer" according to the team's security standards, even though it's generally considered a best practice for any target="_blank" link.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx
📚 Learning: 2025-06-19T01:46:45.723Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2025-06-19T01:46:45.723Z
Learning: PayPal webhook verification in the Dub codebase is handled at the route level in `apps/web/app/(ee)/api/paypal/webhook/route.ts` using the `verifySignature` function. Individual webhook handlers like `payoutsItemFailed` don't need to re-verify signatures since they're only called after successful verification.
Applied to files:
apps/web/lib/webhook/validate-webhook.tsapps/web/lib/webhook/handle-external-payout-event.tsapps/web/app/api/webhooks/callback/route.tsapps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts
📚 Learning: 2025-06-25T21:20:59.837Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2025-06-25T21:20:59.837Z
Learning: In the Dub codebase, payout limit validation uses a two-stage pattern: server actions perform quick sanity checks (payoutsUsage > payoutsLimit) for immediate user feedback, while the cron job (/cron/payouts) performs authoritative validation (payoutsUsage + payoutAmount > payoutsLimit) with actual calculated amounts before processing. This design provides fast user feedback while ensuring accurate limit enforcement at transaction time.
Applied to files:
apps/web/lib/webhook/validate-webhook.tsapps/web/lib/webhook/handle-external-payout-event.tsapps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
📚 Learning: 2025-08-14T05:17:51.825Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.825Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.
Applied to files:
apps/web/app/(ee)/api/partner-profile/programs/route.ts
🔇 Additional comments (21)
apps/web/lib/swr/use-payouts.ts (1)
19-19: LGTM: Cleaner ES6 shorthand syntax.The shorthand property syntax is functionally equivalent and more idiomatic.
packages/ui/src/slider.tsx (1)
76-80: LGTM!The conditional rendering of the hint section is implemented correctly and follows React best practices.
apps/web/lib/webhook/sample-events/payout-confirmed.json (1)
6-11: Verify the paidAt value for completed external payouts.The sample shows
status: "completed"butpaidAt: null. For completed payouts,paidAttypically contains a timestamp. Confirm whethernullis the intended value for external payouts (since they're paid outside Dub's system) or if this should be populated with the completion timestamp.apps/web/app/(ee)/partners.dub.co/invoices/[payoutId]/route.tsx (2)
58-64: LGTM!Changing the error code from
unauthorizedtobad_requestis semantically correct, as the issue is with the payout's state rather than authorization.
66-71: LGTM!The guard correctly prevents invoice generation for external payouts, since these are processed outside Dub's payment system.
packages/ui/src/icons/nucleo/circle-arrow-right.tsx (1)
1-43: LGTM!The SVG component correctly uses React camelCase attributes (
clipPath,strokeWidth,strokeLinecap,strokeLinejoin) throughout, ensuring proper type-checking and rendering.packages/prisma/schema/program.prisma (2)
21-25: LGTM!The
ProgramPayoutModeenum is well-structured with clear values and inline documentation explaining each mode.
50-50: Verify migration implementation handles backfill for existing programs.The concern is valid: Prisma's
@default(internal)annotation alone does not automatically backfill existing records. A safe migration requires a 2-3 step process: add the field as nullable or with a DB default, backfill existing rows with an UPDATE statement, then make the column non-nullable.Cannot locate the generated migration file in standard Prisma locations to verify the implementation. Confirm the migration SQL includes proper backfill logic (UPDATE to set default value for existing Program records) before deploying to production. Test on staging with production-like data as recommended.
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx (1)
12-12: LGTM: Clean refactor to partner-specific component.The import path update correctly switches to the partner-specific payout details sheet while maintaining the same usage pattern.
apps/web/app/(ee)/api/partner-profile/programs/route.ts (1)
14-14: LGTM: Good immutability improvement.Changing to
constcorrectly reflects thatprogramEnrollmentsis never reassigned.apps/web/tests/webhooks/index.test.ts (1)
60-74: LGTM: Payout webhook schema follows established patterns.The extended schema correctly handles JSON date serialization with nullable field transformations, consistent with other webhook event schemas in the test suite.
Also applies to: 87-87
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/payouts/page-client.tsx (1)
75-84: LGTM: Clear visual indicator for external payouts.The custom cell renderer appropriately shows the CircleArrowRight icon only for external-mode payouts, providing a clear visual cue that the payout is processed externally.
apps/web/lib/webhook/constants.ts (1)
19-19: LGTM: Clean webhook trigger registration.The
payout.confirmedtrigger is correctly registered as a workspace-level webhook event with an appropriate description, following the established pattern.Also applies to: 40-40
apps/web/app/(ee)/api/partner-profile/payouts/route.ts (1)
32-44: Original review comment concern is not valid.The
getEffectivePayoutModefunction has an explicit return type ofPayoutMode(non-optional) and all code paths either return a valid enum value or throw an error. There is no possibility of it returningundefinedornull. The transformation logic correctly uses the nullish coalescing operator to ensure themodefield is always assigned a valid value, which satisfies the schema validation.packages/email/src/templates/partner-payout-confirmed.tsx (1)
106-124: LGTM!The mode-based conditional rendering is clear and correctly differentiates between external and internal payout messaging. External payouts reference the program name appropriately, and internal payouts provide accurate timing based on payment method.
apps/web/lib/webhook/get-webhooks.ts (1)
1-43: LGTM!The
getWebhooksimplementation is clean and well-structured. The dynamic where clause construction using conditional spreads is idiomatic, and the explicit field selection ensures only necessary data is fetched.apps/web/lib/webhook/publish.ts (1)
2-2: LGTM!The optional
webhooksparameter is a clean optimization that prevents redundant database queries in bulk webhook scenarios (likepayout.confirmedevents). The conditional fetch logic preserves backward compatibility while enabling performance improvements when webhooks are pre-fetched.Also applies to: 12-12, 17-17, 23-44
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-button.tsx (1)
4-4: LGTM!The migration from modal to sheet pattern is implemented consistently. All hook calls and render logic are updated correctly.
Also applies to: 7-8, 12-12, 17-18
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx (1)
187-227: LGTM!The
ExternalPayoutMethodscomponent correctly filters webhooks for thepayout.confirmedtrigger, ensuring only active user-added webhooks are displayed. The null checks appropriately exclude disabled webhooks and app-installed webhooks.apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx (2)
21-21: LGTM!The icon import addition, details sheet path update, and avatar styling change to
rounded-fullare appropriate UI refinements.Also applies to: 30-30, 82-82
224-265: LGTM!The
AmountRowItemcomponent is well-refactored to accept apayoutobject and correctly handles both the minimum payout threshold display and the new external payout indicator. The conditional tooltip messaging appropriately differentiates between pending and completed external payouts.
...web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-sheet.tsx
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
🧹 Nitpick comments (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx (1)
10-34: Consider showing a loading state.While
programdata is loading, the component returnsnull, which causes the section to appear suddenly once loaded. Consider destructuringloadingfromuseProgram()and rendering a skeleton or loading indicator to improve perceived performance.Example:
- const { program } = useProgram(); + const { program, loading } = useProgram(); + + if (loading) { + return ( + <div className="space-y-3"> + <div className="h-6 w-32 animate-pulse rounded bg-neutral-200" /> + <div className="h-32 animate-pulse rounded-lg bg-neutral-100" /> + </div> + ); + }apps/web/scripts/migrations/backfill-payout-mode.ts (1)
24-24: Enhance logging for better observability.The current logging only outputs the result object. Consider logging the count of affected payouts and optionally a sample of IDs for verification purposes.
If not implementing the batch loop from the earlier comment, apply this diff:
- console.log(res); + console.log(`Updated ${res.count} payouts to mode='internal'`); + if (payouts.length > 0) { + console.log(`Sample IDs: ${payouts.slice(0, 5).map(p => p.id).join(', ')}`); + }Note: This is already addressed if you implement the batching loop suggested earlier.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (7)
apps/web/app/api/webhooks/callback/route.ts(5 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx(6 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx(1 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-sheet.tsx(1 hunks)apps/web/scripts/migrations/backfill-payout-mode.ts(1 hunks)apps/web/ui/partners/confirm-payouts-sheet.tsx(13 hunks)apps/web/ui/partners/external-payouts-indicator.tsx(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/web/app/api/webhooks/callback/route.ts
- apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-sheet.tsx
🧰 Additional context used
🧠 Learnings (7)
📚 Learning: 2025-10-15T01:05:43.266Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.266Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsxapps/web/ui/partners/external-payouts-indicator.tsx
📚 Learning: 2025-09-24T16:10:37.349Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/ui/partners/partner-about.tsx:11-11
Timestamp: 2025-09-24T16:10:37.349Z
Learning: In the Dub codebase, the team prefers to import Icon as a runtime value from "dub/ui" and uses Icon as both a type and variable name in component props, even when this creates shadowing. This is their established pattern and should not be suggested for refactoring.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-12T17:31:10.548Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2833
File: apps/web/lib/actions/partners/approve-bounty-submission.ts:53-61
Timestamp: 2025-09-12T17:31:10.548Z
Learning: In approve-bounty-submission.ts, the logic `bounty.rewardAmount ?? rewardAmount` is intentional. Bounties with preset reward amounts should use those fixed amounts, and the rewardAmount override parameter is only used when bounty.rewardAmount is null/undefined (for custom reward bounties). This follows the design pattern where bounties are either "flat rate" (fixed amount) or "custom" (variable amount set during approval).
Applied to files:
apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-10-15T01:52:37.048Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx:270-303
Timestamp: 2025-10-15T01:52:37.048Z
Learning: In React components with dropdowns or form controls that show modals for confirmation (e.g., role selection, delete confirmation), local state should be reverted to match the prop value when the modal is cancelled. This prevents the UI from showing an unconfirmed change. The solution is to either: (1) pass an onClose callback to the modal that resets the local state, or (2) observe modal visibility state and reset on close. Example context: RoleCell component in apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx where role dropdown should revert to user.role when UpdateUserModal is cancelled.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx
📚 Learning: 2025-09-24T16:09:52.724Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/ui/partners/online-presence-form.tsx:181-186
Timestamp: 2025-09-24T16:09:52.724Z
Learning: The cn utility function in this codebase uses tailwind-merge, which automatically resolves conflicting Tailwind classes by giving precedence to later classes in the className string. Therefore, patterns like `cn("gap-6", variant === "settings" && "gap-4")` are valid and will correctly apply gap-4 when the condition is true.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx
📚 Learning: 2025-06-18T20:23:38.835Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2538
File: apps/web/ui/partners/overview/blocks/traffic-sources-block.tsx:50-82
Timestamp: 2025-06-18T20:23:38.835Z
Learning: Internal links within the same application that use target="_blank" may not require rel="noopener noreferrer" according to the team's security standards, even though it's generally considered a best practice for any target="_blank" link.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx
🧬 Code graph analysis (5)
apps/web/scripts/migrations/backfill-payout-mode.ts (1)
packages/prisma/index.ts (1)
prisma(3-9)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx (4)
apps/web/lib/types.ts (1)
PayoutResponse(491-491)apps/web/lib/swr/use-program.ts (1)
useProgram(6-40)packages/ui/src/tooltip.tsx (2)
DynamicTooltipWrapper(280-294)TooltipContent(90-128)apps/web/ui/partners/external-payouts-indicator.tsx (1)
ExternalPayoutsIndicator(4-40)
apps/web/ui/partners/confirm-payouts-sheet.tsx (5)
apps/web/lib/swr/use-program.ts (1)
useProgram(6-40)apps/web/ui/shared/upgrade-required-toast.tsx (1)
UpgradeRequiredToast(8-50)packages/ui/src/icons/nucleo/circle-arrow-right.tsx (1)
CircleArrowRight(3-43)apps/web/ui/partners/partner-row-item.tsx (1)
PartnerRowItem(127-172)apps/web/ui/partners/external-payouts-indicator.tsx (1)
ExternalPayoutsIndicator(4-40)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx (4)
apps/web/lib/swr/use-program.ts (1)
useProgram(6-40)packages/prisma/client.ts (1)
ProgramPayoutMode(31-31)packages/ui/src/icons/nucleo/circle-dollar-out.tsx (1)
CircleDollarOut(3-50)apps/web/lib/swr/use-workspace.ts (1)
useWorkspace(6-46)
apps/web/ui/partners/external-payouts-indicator.tsx (2)
packages/ui/src/tooltip.tsx (1)
Tooltip(32-88)packages/ui/src/icons/nucleo/circle-arrow-right.tsx (1)
CircleArrowRight(3-43)
🪛 Biome (2.1.2)
apps/web/ui/partners/confirm-payouts-sheet.tsx
[error] 447-448: Avoid using target="_blank" without rel="noopener" or rel="noreferrer".
Opening external links in new tabs without rel="noopener" is a security risk. See the explanation for more details.
Safe fix: Add the rel="noopener" attribute.
(lint/security/noBlankTarget)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx
[error] 51-51: Avoid using target="_blank" without rel="noopener" or rel="noreferrer".
Opening external links in new tabs without rel="noopener" is a security risk. See the explanation for more details.
Safe fix: Add the rel="noopener" attribute.
(lint/security/noBlankTarget)
apps/web/ui/partners/external-payouts-indicator.tsx
[error] 29-29: Avoid using target="_blank" without rel="noopener" or rel="noreferrer".
Opening external links in new tabs without rel="noopener" is a security risk. See the explanation for more details.
Safe fix: Add the rel="noopener" attribute.
(lint/security/noBlankTarget)
⏰ 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 (9)
apps/web/scripts/migrations/backfill-payout-mode.ts (1)
7-8: No action required - migration logic is correct.The Prisma schema confirms that
modeis nullable (PayoutMode?), so excluding pending payouts from the backfill is intentional and safe. UI code that accessespayout.mode === "external"safely handles null values (the condition evaluates to false). Pending payouts will receive a mode when they transition to processing/completed status, likely via the cron job that processes payouts. The two-stage pattern—UI feedback from server actions, authoritative processing from cron jobs—allows pending payouts to exist without a mode until finalization.apps/web/ui/partners/confirm-payouts-sheet.tsx (4)
119-151: LGTM!The enhanced error handling provides better UX by mapping specific error codes to actionable toasts with contextual CTAs. The webhook setup link is particularly helpful for the
EXTERNAL_WEBHOOK_REQUIREDerror.
258-269: LGTM!The
isExternalPayouthelper correctly implements mode-based payout classification:
- Internal mode: all payouts are internal
- External mode: all payouts are external
- Hybrid mode: external if partner hasn't enabled internal payouts
271-311: LGTM!The amount calculations correctly compute the external payout amount by filtering payouts through
isExternalPayout. The guard conditions ensure all required values are present before calculation.
526-568: LGTM!The Total column correctly displays the external payout indicator alongside the Include/Exclude control, with appropriate positioning and conditional rendering based on payout mode.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx (4)
298-307: LGTM!Refactoring to pass the entire
payoutobject instead of separate props improves maintainability and aligns with the new mode-based payout logic.
309-329: LGTM!The minimum payout amount check provides clear feedback via tooltip and visual styling. The CTA link correctly navigates to the pending payouts view.
331-360: LGTM!The external payout handling correctly validates the presence of a tenant ID and provides clear visual feedback:
- Warning tooltip when tenant ID is missing
- External payout indicator only shown for valid configurations
- Appropriate styling for valid vs. invalid states
362-370: LGTM!The internal mode handling correctly checks for partners without payouts enabled and provides appropriate visual feedback with an explanatory tooltip.
Summary by CodeRabbit
New Features
Improvements