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

Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Oct 30, 2025

Summary by CodeRabbit

  • New Features

    • Program payout modes (internal/hybrid/external), payout.confirmed webhook & sample event, external payout confirmation emails, Program Payout Methods UI, and a Sheet-based Program Payout Settings panel.
  • Improvements

    • UI distinguishes external vs internal payouts (icons, tooltips, invoice/link behavior), surfaces external amounts in confirmations, hides invoices for external payouts, adds external payout indicators, and centralizes webhook validation/handling with Slack template support.

@vercel
Copy link
Contributor

vercel bot commented Oct 30, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Nov 6, 2025 7:53pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 30, 2025

Walkthrough

Adds 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

Cohort / File(s) Summary
DB & Prisma exports
packages/prisma/schema/payout.prisma, packages/prisma/schema/program.prisma, packages/prisma/schema/invoice.prisma, packages/prisma/client.ts
New enums/fields: ProgramPayoutMode/PayoutMode; add Payout.mode, Payout.webhookEventId; add Program.payoutMode; add Invoice.payoutMode and Invoice.externalAmount; re-export enums.
Eligibility & helpers
apps/web/lib/api/payouts/get-effective-payout-mode.ts, apps/web/lib/api/payouts/payout-eligibility-filter.ts, apps/web/lib/api/payouts/get-eligible-payouts.ts
New helpers: compute effective payout mode, build Prisma eligibility filter per program payoutMode, and fetch/transform eligible payouts (cutoff logic, amount aggregation, annotate mode).
Payout processing core
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts, apps/web/app/(ee)/api/cron/payouts/process/split-payouts.ts
Refactor to support multi-mode payouts: new types/props, total vs external aggregation, mode-aware splitting, updated capacity/FX/fee math, state transitions, logs/audit metadata, idempotency changes.
Charge-succeeded & queuing
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts, apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts, .../queue-stripe-payouts.ts, .../send-paypal-payouts.ts
Route now enqueues external payouts alongside Stripe/PayPal flows; new queueExternalPayouts publishes payout.confirmed events and queues emails; Stripe/PayPal handlers now target internal-mode payouts only.
External webhook lifecycle
apps/web/lib/webhook/handle-external-payout-event.ts, apps/web/app/api/webhooks/callback/route.ts
New handler processes external payout webhook events (success/failure), updating payout and commission state; callback route integrates delivery-failure flag handling.
Webhook infra & validation
apps/web/lib/webhook/constants.ts, apps/web/lib/webhook/get-webhooks.ts, apps/web/lib/webhook/publish.ts, apps/web/lib/webhook/qstash.ts, apps/web/lib/webhook/validate-webhook.ts, apps/web/lib/webhook/types.ts, apps/web/lib/webhook/sample-events/*
Add payout.confirmed trigger/schema/sample; getWebhooks helper; optional webhooks arg in publisher; callback URL builder with failure callback; centralized validateWebhook enforces external-webhook requirement and link validation.
Confirm & trigger actions
apps/web/lib/actions/partners/confirm-payouts.ts, apps/web/lib/actions/partners/update-program.ts
confirm-payouts derives program via helper, enforces external webhook presence when needed, includes payoutMode & dedupeId in QStash payloads; update-program removes landerData.
Partner & program routes
apps/web/app/(ee)/api/partner-profile/payouts/route.ts, apps/web/app/(ee)/api/partner-profile/programs/route.ts, apps/web/app/(ee)/api/programs/[programId]/payouts/*, apps/web/app/(ee)/api/programs/[programId]/payouts/count/route.ts, apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts
Per-payout mode computed via getEffectivePayoutMode; routes delegate eligibility/filtering to centralized helpers and include mode and partner.program tenantId in responses.
UI — Partner dashboard
apps/web/app/(ee)/partners.dub.co/.../payouts/*, apps/web/ui/partners/*, apps/web/ui/partners/external-payouts-indicator.tsx, apps/web/ui/partners/partner-row-item.tsx
Add external payout indicators (CircleArrowRight), ConnectedExternalAccounts, ExternalPayoutsIndicator, refactor PartnerRowItem and payout status UI; confirm-payouts sheet shows externalAmount; invoice link behavior adjusted for external payouts.
UI — Program dashboard & settings
apps/web/app/app.dub.co/.../program/payouts/*, program-payout-settings-sheet.tsx, program-payout-methods.tsx, program-payout-mode-section.tsx
New ProgramPayoutMethods, sheet-based ProgramPayoutSettingsSheet + hook (public export), ProgramPayoutModeSection; AmountRowItem signature changed to accept a payout object; external payout UI flows added.
Emails & Slack
packages/email/src/templates/partner-payout-confirmed.tsx, apps/web/lib/integrations/slack/transform.ts
Partner payout email now branches on mode (internal vs external); Slack templates include payout.confirmed mapping.
Zod schemas & tests
apps/web/lib/zod/schemas/payouts.ts, apps/web/lib/zod/schemas/programs.ts, apps/web/tests/webhooks/index.test.ts
Add mode to Payout schema, new payoutWebhookEventSchema and eligiblePayoutsQuerySchema; add payoutMode to Program schema; tests register payout event schema.
Icons & UI tweaks
packages/ui/src/icons/nucleo/*, packages/ui/src/slider.tsx, apps/web/lib/swr/use-payouts.ts, apps/web/lib/actions/parse-action-errors.ts
Add CircleArrowRight and CircleDollarOut icons/exports; conditional Slider hint; minor logging tweak (console.error); small URL param shorthand in swr hook.
Migration & scripts
apps/web/scripts/migrations/update-payout-mode-to-internal.ts, apps/web/scripts/migrations/backfill-payout-mode.ts
New migration/backfill scripts to set existing payouts' mode to internal where appropriate.

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

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Areas needing extra attention:
    • processPayouts: totals/FX/fee math, invoice updates, idempotency keys, and audit metadata.
    • getPayoutEligibilityFilter and hybrid-mode predicates correctness.
    • validateWebhook: enforcement of external webhook requirement and linkId validation.
    • QStash callback/failure URL builder, dedupe semantics.
    • Transactional updates in handleExternalPayoutEvent (payout → commission state).
    • Prisma schema additions and migration/backfill scripts.

Possibly related PRs

Poem

🐰 I hop through ledgers with a tidy little stride,
Internal or external, each payout I guide,
Webhooks sing softly when confirmations land,
Partners and programs get messages on hand,
A rabbit nods happily — payouts routed right.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.33% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'External payouts' clearly and concisely summarizes the main feature addition in the changeset, which implements support for external payout modes across Prisma schemas, API routes, cron jobs, and UI components.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch external-payouts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@devkiran devkiran changed the title Refactor payout settings modal to use a sheet component External payout Oct 30, 2025
@panda-sandeep
Copy link

/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.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 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 in apps/web/app/(ee)/api/paypal/webhook/payouts-item-succeeded.ts and Stripe handler in apps/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 log utility) over console.log for 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 payoutsUsage increment (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

📥 Commits

Reviewing files that changed from the base of the PR and between b6b44ac and 8f63f9d.

📒 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.ts
  • apps/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.prisma
  • 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 (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 webhookEventId gives 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 getPayoutEligibilityFilter integration 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), but totalToSend to 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 externalAmount field 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 ProgramPayoutMode type for their payoutMode fields, which is compatible with the ProgramPayoutMode | null parameter accepted by getEffectivePayoutMode. The mode-based splitting logic correctly handles all three modes ("internal", "external", "hybrid"), and calling getEffectivePayoutMode within the hybrid block with invoice.payoutMode is appropriate and type-safe.

Likely an incorrect or invalid review comment.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
packages/ui/src/icons/nucleo/circle-dollar-out.tsx (1)

16-37: Consider using currentColor for stroke values to improve reusability.

The stroke color is hardcoded to #171717 across all paths, preventing color customization via CSS or parent context. Icon components typically use stroke="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 ExternalPayoutMethods component 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8f63f9d and 349d2cc.

📒 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.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx
  • apps/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 that program loading state doesn't cause UI flicker.

The program is destructured from useProgram() but the loading state is not explicitly checked. While the calculations at lines 276-280 guard against program?.payoutMode === undefined, the UI might briefly show incorrect information (e.g., no external payout indicators) while program is 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 programLoading into 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 isExternalPayout helper correctly implements the three-mode logic (internal/external/hybrid) and safely defaults to false when program?.payoutMode is undefined. The hybrid mode logic—checking payoutsEnabledAt === null to 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 === undefined before computing externalAmount, ensuring no runtime errors occur while the program is loading. The dependency array correctly includes program?.payoutMode.


425-449: Past review comment addressed: now uses finalEligiblePayouts consistently.

The condition at line 425 correctly uses finalEligiblePayouts.some(isExternalPayout), which matches the data source for the externalAmount calculation (lines 291-295). The past review comment flagged the use of eligiblePayouts instead of finalEligiblePayouts, and this has been corrected.


498-505: Good refactor: Consistent partner display with PartnerRowItem.

Replacing inline partner rendering with the PartnerRowItem component 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 program loads after eligiblePayouts, 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 payout object 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 PayoutResponseSchema type requires partner (no optional modifiers), and the API route accesses partner.payoutsEnabledAt directly without null checks. However, I cannot access the Prisma schema file to confirm partnerId is 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:

  1. External payouts cannot be created without a valid partnerId
  2. Payout.partner relation is required (not nullable) in schema

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 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 after createPayPalBatchPayout returns, 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:

  1. Validating the batchPayout response to ensure it indicates success
  2. Using a transaction to atomically update all payout statuses
  3. Logging any discrepancy if updatedPayouts.count doesn't match payouts.length
  const 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 sendBatchEmail throws 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 be undefined (e.g., partner unenrolled after the payout was created). Spreading ...undefined throws a TypeError, 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

📥 Commits

Reviewing files that changed from the base of the PR and between 349d2cc and b047881.

📒 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 the mode: "internal" database filter provides additional correctness assurance for hybrid invoices where both internal and external payouts coexist.


12-20: No changes needed. The payoutMode field is required with a default value.

The Invoice.payoutMode field is defined in the schema as payoutMode 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 payoutMode will 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 Invoice object instead of a plain invoiceId string improves type safety. The addition of mode: "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 Invoice parameter 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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts (2)

126-126: Use payout.mode instead of hardcoding "external".

While hardcoding is technically correct since the query filters for mode: "external", using payout.mode is 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 queueBatchEmail fails 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

📥 Commits

Reviewing files that changed from the base of the PR and between 81100ba and 1e352a5.

📒 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 external payout mode: All payouts are charged fees but none go through Stripe (only fees are sent).
  • For hybrid payout 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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (5)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (3)

45-45: Remove unused webhookEnabled property from interface.

The webhookEnabled property is declared in ProcessPayoutsProps but 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:

  1. Wrapping payment intent creation and DB updates in a transaction pattern with proper error handling
  2. Implementing reconciliation to detect and handle orphaned payment intents
  3. 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 be undefined if the partner was unenrolled after the payout was created, causing a TypeError when 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

📥 Commits

Reviewing files that changed from the base of the PR and between 1e352a5 and 7db7816.

📒 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 getEffectivePayoutMode and getPayoutEligibilityFilter are 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 getPayoutEligibilityFilter for 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 getEffectivePayoutMode appropriately 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 externalPayoutsMap to 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 totalPayoutAmount and externalPayoutAmount, 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 full totalPayoutAmount (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 queueBatchEmail throws an exception, webhooks have already been dispatched but the function aborts. On retry:

  1. Payouts remain in "processing" status (no status update)
  2. Webhooks will be resent (duplicate notifications)
  3. Emails will be deduplicated via idempotency key

This creates inconsistent state and potential duplicate webhooks. Consider:

  1. Updating payout status after successful webhook dispatch (as noted in past comments)
  2. Adding a webhookSentAt timestamp to prevent resending webhooks on retry
  3. Moving email queueing before webhook sending to fail fast
  4. Wrapping both operations in a transaction with proper rollback handling

Run the following to check if there's existing retry/deduplication logic for webhooks:

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (3)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (3)

45-45: Remove unused webhookEnabled property.

The webhookEnabled property in ProcessPayoutsProps is 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:

  1. Wrapping the entire flow in a transaction with proper error handling and rollback
  2. Moving payment intent creation after database updates (with tradeoffs)
  3. 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.programs for 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

📥 Commits

Reviewing files that changed from the base of the PR and between 7db7816 and de0e16a.

📒 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 totalFee is calculated on totalPayoutAmount (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 externalPayoutsMap to 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

@steven-tey
Copy link
Collaborator

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 6, 2025

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 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 payoutMode as 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 statusKey while program may still be undefined during SWR loading. When program?.payoutMode is undefined, the switch defaults to false, 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 return undefined or an array for slug. When that happens the template literal will emit /undefined/settings/webhooks or "/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] is undefined when 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 link

This link opens a new tab without rel="noopener noreferrer", leaving window.opener exposed. 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 getEffectivePayoutMode was already identified in a previous review comment.


265-329: Duplicate hook calls already flagged.

The redundant useExternalPayoutEnrollments calls 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 updateMany with 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 ExternalPayoutMethods was 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 with mode === null was 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 payload

If a partner loses their enrollment (or the include comes back empty for any reason), payout.partner.programs[0] is undefined and spreading it throws TypeError: 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 configured

Returning early when webhooks.length === 0 prevents 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.log to console.error is 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 checks payout.mode === "external". This means null will 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

📥 Commits

Reviewing files that changed from the base of the PR and between aaba109 and de0e16a.

📒 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.tsx
  • apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts
  • apps/web/ui/partners/partner-row-item.tsx
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx
  • apps/web/ui/partners/confirm-payouts-sheet.tsx
  • apps/web/lib/actions/partners/update-program.ts
  • apps/web/app/(ee)/api/programs/[programId]/payouts/route.ts
  • apps/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.tsx
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-details-sheet.tsx
  • apps/web/ui/partners/external-payouts-indicator.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/payouts/page-client.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-button.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-methods.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx
  • apps/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.prisma
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts
  • apps/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.ts
  • apps/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.ts
  • apps/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.ts
  • apps/web/app/api/webhooks/route.ts
  • apps/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.tsx
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-details-sheet.tsx
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx
  • apps/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.tsx
  • apps/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.tsx
  • apps/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.tsx
  • apps/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.tsx
  • apps/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.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-button.tsx
  • apps/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.ts
  • apps/web/lib/webhook/handle-external-payout-event.ts
  • apps/web/app/api/webhooks/callback/route.ts
  • apps/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.ts
  • apps/web/lib/webhook/handle-external-payout-event.ts
  • apps/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" but paidAt: null. For completed payouts, paidAt typically contains a timestamp. Confirm whether null is 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 unauthorized to bad_request is 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 ProgramPayoutMode enum 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 const correctly reflects that programEnrollments is 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.confirmed trigger 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 getEffectivePayoutMode function has an explicit return type of PayoutMode (non-optional) and all code paths either return a valid enum value or throw an error. There is no possibility of it returning undefined or null. The transformation logic correctly uses the nullish coalescing operator to ensure the mode field 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 getWebhooks implementation 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 webhooks parameter is a clean optimization that prevents redundant database queries in bulk webhook scenarios (like payout.confirmed events). 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 ExternalPayoutMethods component correctly filters webhooks for the payout.confirmed trigger, 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-full are appropriate UI refinements.

Also applies to: 30-30, 82-82


224-265: LGTM!

The AmountRowItem component is well-refactored to accept a payout object 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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 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 program data is loading, the component returns null, which causes the section to appear suddenly once loaded. Consider destructuring loading from useProgram() 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

📥 Commits

Reviewing files that changed from the base of the PR and between de0e16a and b601d74.

📒 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.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-mode-section.tsx
  • apps/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 mode is nullable (PayoutMode?), so excluding pending payouts from the backfill is intentional and safe. UI code that accesses payout.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_REQUIRED error.


258-269: LGTM!

The isExternalPayout helper 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 payout object 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.

@steven-tey steven-tey merged commit 2cb20af into main Nov 6, 2025
7 of 8 checks passed
@steven-tey steven-tey deleted the external-payouts branch November 6, 2025 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants