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

Skip to content

Conversation

@steven-tey
Copy link
Collaborator

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

Summary by CodeRabbit

  • New Features

    • Force withdrawal flow to allow payouts below minimum with confirmation modal.
    • Confirmation modal for retrying payouts.
  • Improvements

    • Minimum withdrawal threshold lowered (from $100 to $10) and below-minimum fee reduced (to $0.50).
    • Payout details redesigned with sticky header/footer and centered loading state.
    • Removed minimum-withdrawal setting from partner UI and public partner data; updated status text and payout email copy.
  • Other

    • Payout table row count and actions improved for clarity.

@vercel
Copy link
Contributor

vercel bot commented Oct 20, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Oct 20, 2025 6:14am

💡 Enable Vercel Agent with $100 free credit for automated AI reviews

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 20, 2025

Walkthrough

Removed partner-level minWithdrawalAmount from schema, APIs, and UI; replaced with global constants and a force-withdrawal flow (server action + confirm modal). Updated transfer logic to use MIN_WITHDRAWAL_AMOUNT_CENTS and a forceWithdrawal flag, adjusted payout UI/layouts, and updated related email and descriptions.

Changes

Cohort / File(s) Summary
Schema & Constants
packages/prisma/schema/partner.prisma, apps/web/lib/zod/schemas/partners.ts, apps/web/lib/partners/constants.ts
Removed minWithdrawalAmount from Partner model and public schemas; updated MIN_WITHDRAWAL_AMOUNT_CENTS (10000 → 1000) and BELOW_MIN_WITHDRAWAL_FEE_CENTS (200 → 50).
Transfer Logic
apps/web/lib/partners/create-stripe-transfer.ts, apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
createStripeTransfer signature adds forceWithdrawal?: boolean, no longer depends on partner.minWithdrawalAmount; uses MIN_WITHDRAWAL_AMOUNT_CENTS and applies BELOW_MIN_WITHDRAWAL_FEE_CENTS when forcing. Removed minWithdrawalAmount from partner include in payout cron caller.
Server Action: Force Withdrawal
apps/web/lib/actions/partners/force-withdrawal.ts
Added forceWithdrawalAction server action with permission checks, payout config validation, 5-per-24h rate limiting, fetch of processed payouts, and invocation of createStripeTransfer({ forceWithdrawal: true }).
Update Partner Payout Settings Action
apps/web/lib/actions/partners/update-partner-payout-settings.ts
Removed handling of minWithdrawalAmount from parsed input and update; removed post-update payout side effects / waitUntil logic.
Partner Payout Settings UI
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-sheet.tsx
Removed UI, state, and controls related to minimum withdrawal amount (NumberFlow/Slider and associated imports/logic).
Payout Details & Layout
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-details-sheet.tsx, apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx
Reworked details sheet layout to sticky header + bottom bar, updated loading/earnings rendering; added payoutsCount hook usage and minor Filter.Select layout wrapper.
Payout Stats & Force Flow UI
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-stats.tsx
Added force-withdrawal confirm modal, "Pay out now" action on Processed stat, wired useAction + toasts, and propagated setShowForceWithdrawalModal through stat components.
Payout Row & Status UI
apps/web/ui/partners/payout-row-menu.tsx, apps/web/ui/partners/payout-status-descriptions.ts
Replaced inline confirm with useConfirmModal for retry flow; updated processed status description to include formatted MIN_WITHDRAWAL_AMOUNT_CENTS.
Email Template
packages/email/src/templates/partner-payout-processed.tsx
Adjusted currencyFormatter usage (strip trailing zeros), conditional message for amounts >= 1000, link now includes payoutId and text changed to "View payout".
Cron caller small change
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
Removed minWithdrawalAmount from partner.include selection (fewer fields fetched).

Sequence Diagram(s)

sequenceDiagram
    actor Partner
    participant UI as Payout Stats UI
    participant Modal as Confirm Modal
    participant Action as forceWithdrawalAction
    participant DB as Database
    participant Transfer as createStripeTransfer / Stripe

    Partner->>UI: Click "Pay out now"
    UI->>Modal: Open force-withdrawal modal
    Modal->>Partner: Show processed amount + fee
    Partner->>Modal: Confirm
    Modal->>Action: Execute forceWithdrawalAction

    Action->>DB: permission & payout config checks
    Action->>DB: enforce 5-per-24h rate limit
    Action->>DB: fetch processed payouts
    Action->>Transfer: createStripeTransfer(forceWithdrawal: true)

    alt success
        Transfer-->>Action: success
        Action-->>UI: show success toast
    else failure
        Transfer-->>Action: error
        Action-->>UI: show error toast
    end
Loading
sequenceDiagram
    actor Partner
    participant RowMenu as Payout Row Menu
    participant ConfirmModal as Retry Confirm Modal
    participant RetryAction as Retry Payout Action

    Partner->>RowMenu: Select "Retry payout"
    RowMenu->>ConfirmModal: Open confirmation modal
    Partner->>ConfirmModal: Confirm
    ConfirmModal->>RetryAction: executeRetryPayout(payoutId)
    RetryAction-->>Partner: show success/error toast
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • devkiran
  • TWilson023

Poem

🐇 I hopped through fields and nibbled a flag,
min gone from the schema, constants in bag.
A modal, a button — "Pay out now!" with a cheer,
Transfers hum softly, retries draw near.
🥕✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Add Instant payouts feature" is directly related to the changeset, which introduces a new forced withdrawal mechanism (forceWithdrawalAction) that enables partners to withdraw processed payouts even when below the minimum withdrawal threshold by paying a small additional fee. The title accurately reflects that a new payout feature is being added, though "instant payouts" is somewhat general and doesn't specifically convey the forced-withdrawal-with-fee mechanism that distinguishes this work. The title is clear enough for a teammate scanning history to understand that payout functionality is being enhanced.
✨ 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 instant-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.

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

🧹 Nitpick comments (2)
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-stats.tsx (1)

95-102: Verify button placement and consider accessibility.

The "Pay out now" button is conditionally rendered inline with the amount display. While the logic is correct (only showing for "Processed" status with amount > 0), consider:

  1. The inline placement with ml-2 might cause layout shifts on smaller screens.
  2. Adding an aria-label would improve accessibility for screen readers.

Consider this enhancement:

 {label === "Processed" && amount > 0 && (
   <Button
     variant="secondary"
     text="Pay out now"
     className="ml-2 h-7 px-2 py-1"
     onClick={() => setShowForceWithdrawalModal(true)}
+    aria-label="Initiate instant payout for processed funds"
   />
 )}
apps/web/lib/actions/partners/force-withdrawal.ts (1)

35-48: Add check for empty processed payouts.

If there are no processed payouts available, the user will receive a generic "Failed to force withdrawal" error. A more specific error message would improve the user experience.

 const previouslyProcessedPayouts = await prisma.payout.findMany({
   where: {
     partnerId: partner.id,
     status: "processed",
     stripeTransferId: null,
   },
   include: {
     program: {
       select: {
         name: true,
       },
     },
   },
 });

+if (previouslyProcessedPayouts.length === 0) {
+  throw new Error(
+    "No processed payouts available for withdrawal. Please wait for your payouts to be processed.",
+  );
+}
+
 try {
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 067f81c and 9ae449e.

📒 Files selected for processing (14)
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts (0 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-sheet.tsx (1 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-details-sheet.tsx (1 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-stats.tsx (5 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx (2 hunks)
  • apps/web/lib/actions/partners/force-withdrawal.ts (1 hunks)
  • apps/web/lib/actions/partners/update-partner-payout-settings.ts (2 hunks)
  • apps/web/lib/partners/constants.ts (1 hunks)
  • apps/web/lib/partners/create-stripe-transfer.ts (2 hunks)
  • apps/web/lib/zod/schemas/partners.ts (1 hunks)
  • apps/web/ui/partners/payout-row-menu.tsx (2 hunks)
  • apps/web/ui/partners/payout-status-descriptions.ts (1 hunks)
  • packages/email/src/templates/partner-payout-processed.tsx (2 hunks)
  • packages/prisma/schema/partner.prisma (0 hunks)
💤 Files with no reviewable changes (2)
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
  • packages/prisma/schema/partner.prisma
🧰 Additional context used
🧬 Code graph analysis (7)
packages/email/src/templates/partner-payout-processed.tsx (1)
packages/email/src/react-email.d.ts (3)
  • Text (15-15)
  • Section (8-8)
  • Link (14-14)
apps/web/ui/partners/payout-status-descriptions.ts (1)
apps/web/lib/partners/constants.ts (1)
  • MIN_WITHDRAWAL_AMOUNT_CENTS (11-11)
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-details-sheet.tsx (2)
packages/ui/src/sheet.tsx (1)
  • Sheet (74-78)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-details-sheet.tsx (1)
  • PayoutDetailsSheetContent (40-272)
apps/web/lib/actions/partners/force-withdrawal.ts (4)
apps/web/lib/actions/safe-action.ts (1)
  • authPartnerActionClient (84-124)
apps/web/lib/auth/partner-user-permissions.ts (1)
  • throwIfNoPermission (35-52)
apps/web/lib/upstash/ratelimit.ts (1)
  • ratelimit (5-21)
apps/web/lib/partners/create-stripe-transfer.ts (1)
  • createStripeTransfer (16-156)
apps/web/lib/partners/create-stripe-transfer.ts (1)
apps/web/lib/partners/constants.ts (1)
  • MIN_WITHDRAWAL_AMOUNT_CENTS (11-11)
apps/web/ui/partners/payout-row-menu.tsx (2)
apps/web/lib/actions/partners/retry-failed-paypal-payouts.ts (1)
  • retryFailedPaypalPayoutsAction (16-103)
apps/web/ui/modals/confirm-modal.tsx (1)
  • useConfirmModal (107-120)
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-stats.tsx (3)
apps/web/lib/actions/partners/force-withdrawal.ts (1)
  • forceWithdrawalAction (10-62)
apps/web/ui/modals/confirm-modal.tsx (1)
  • useConfirmModal (107-120)
apps/web/lib/partners/constants.ts (2)
  • MIN_WITHDRAWAL_AMOUNT_CENTS (11-11)
  • BELOW_MIN_WITHDRAWAL_FEE_CENTS (12-12)
⏰ 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 (22)
apps/web/lib/actions/partners/update-partner-payout-settings.ts (2)

14-14: No issues found — schema updated correctly and function behavior is appropriate.

Verified that partnerPayoutSettingsSchema only includes companyName, address, and taxId. The destructuring at line 14 aligns with the updated schema after minWithdrawalAmount removal. The function correctly performs a straightforward database update without a return value, which is the expected Server Action pattern. The removed Stripe payout logic is handled by the separate force-withdrawal.ts action.


26-34: Original review comment is incorrect.

The only caller of updatePartnerPayoutSettingsAction (in partner-payout-settings-sheet.tsx) does not rely on a return value. The onSuccess callback refreshes the partner profile via mutatePrefix("/api/partner-profile"), properly handling the post-update state without needing the function to return data.

Additionally, the function contains no Stripe logic—it only updates invoice settings (companyName, address, taxId). The forceWithdrawalAction is a separate, intentional flow that independently handles Stripe transfers. This is appropriate separation of concerns, not removed logic that needs to be handled elsewhere.

Likely an incorrect or invalid review comment.

packages/email/src/templates/partner-payout-processed.tsx (2)

44-46: LGTM! Currency formatting improvement.

The trailingZeroDisplay: "stripIfInteger" option improves readability by displaying whole dollar amounts without unnecessary decimal places.


108-110: LGTM! Deep linking improvement.

Adding the payoutId query parameter enables direct navigation to the specific payout, and the singular "View payout" text is semantically accurate.

apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-sheet.tsx (1)

16-16: LGTM! Clean removal of minimum withdrawal UI.

The removal of minWithdrawalAmount settings and cleanup of unused imports (NumberFlow, Slider) correctly implements the shift to a constant-based withdrawal threshold approach.

apps/web/lib/partners/constants.ts (1)

11-12: Verify the business impact of threshold reduction.

The withdrawal threshold has been reduced from $100 to $10 (10x decrease), and the fee from $2.00 to $0.50 (4x decrease). This change will:

  • Significantly increase payout frequency and operational overhead
  • Potentially reduce fee revenue from force withdrawals
  • Improve partner experience with faster access to funds

Ensure this aligns with business model, operational capacity, and transaction cost analysis.

apps/web/ui/partners/payout-row-menu.tsx (1)

27-40: LGTM! Confirmation modal improves safety.

The addition of a confirmation modal before retrying payouts prevents accidental actions and appropriately warns users about the 5-retry-per-day rate limit.

apps/web/ui/partners/payout-status-descriptions.ts (1)

1-10: LGTM! Proper use of constants.

Correctly uses MIN_WITHDRAWAL_AMOUNT_CENTS constant for dynamic description, maintaining consistency with the broader refactoring and ensuring a single source of truth.

apps/web/lib/zod/schemas/partners.ts (1)

11-11: LGTM! Schema cleanup aligns with data model changes.

The removal of minWithdrawalAmount from schemas and cleanup of unused imports correctly implements the shift to constant-based withdrawal thresholds.

apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx (2)

40-40: LGTM! Pagination count implementation.

Adding usePartnerPayoutsCount hook enables proper pagination and row count display in the table.


189-197: LGTM! Layout container addition.

The wrapper div with flex layout prepares for potential filter bar enhancements while maintaining existing functionality.

apps/web/lib/partners/create-stripe-transfer.ts (3)

21-27: LGTM! API signature updated for force withdrawal.

The addition of the forceWithdrawal parameter with a safe default and the simplified partner type correctly implement the constant-based threshold approach.


57-85: LGTM! Minimum withdrawal logic correctly implements force withdrawal.

The conditional logic properly handles two scenarios:

  1. Below minimum without forcing: updates payouts to "processed" status for accumulation and returns early
  2. Below minimum with forcing: applies the fee and proceeds with transfer

The implementation correctly uses constants and maintains proper state transitions.


62-73: LGTM! Safe handling of optional payouts.

The null check on currentInvoicePayouts before the batch status update correctly handles the optional parameter.

apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-stats.tsx (3)

3-25: LGTM: Imports are well-organized.

All necessary imports for the force withdrawal feature are present and correctly sourced.


256-289: LGTM: Consistent prop passing across layouts.

The setShowForceWithdrawalModal prop is correctly passed to all PayoutStatsCard instances in both mobile and desktop layouts, ensuring consistent behavior.


193-203: Code is correct as-is.

The error property path error.serverError is the documented and correct approach for next-safe-action v7.10.1. serverError is a property in the action result object that contains the server error value (generic-typed, default string). The code properly handles it with a fallback message.

apps/web/lib/actions/partners/force-withdrawal.ts (1)

14-23: LGTM: Proper permission and validation checks.

The permission check for payout_settings.update is correctly placed before business logic, and the payout enablement validation provides clear user guidance.

apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-details-sheet.tsx (4)

178-194: LGTM: Clean sticky header implementation.

The refactored sticky header with proper z-index positioning improves the sheet UX by keeping the title and close button accessible during scroll.


195-221: LGTM: Content area properly structured.

The content area correctly uses flex-1 to grow and fill available space, with proper loading states and conditional rendering. The grid column change from 3 to 2 provides a cleaner 1:1 label-to-value layout.


223-233: LGTM: Sticky footer enhances navigation.

The sticky footer with the "View all" link provides consistent access to the full earnings view, improving the user experience when scrolling through payout details.


238-256: LGTM: Clean component wrapper with proper state management.

The exported PayoutDetailsSheet component properly encapsulates sheet behavior and handles query parameter cleanup on close.

@steven-tey
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 20, 2025

✅ Actions performed

Full review triggered.

1 similar comment
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 20, 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: 2

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)/partners.dub.co/(dashboard)/payouts/payout-details-sheet.tsx (1)

55-106: Remove unnecessary dependency from useMemo.

The useMemo hook includes earnings in its dependency array (line 106), but earnings is not actually used within the memoized computation. This causes unnecessary recalculations whenever earnings changes.

Apply this diff:

-  }, [payout, earnings]);
+  }, [payout]);
♻️ Duplicate comments (4)
packages/email/src/templates/partner-payout-processed.tsx (1)

99-101: Replace hardcoded values with constants.

The hardcoded threshold 1000 and string literals "$10" and "$0.50" should reference MIN_WITHDRAWAL_AMOUNT_CENTS and BELOW_MIN_WITHDRAWAL_FEE_CENTS constants to maintain consistency with the updated values in apps/web/lib/partners/constants.ts.

Apply this diff:

+import { MIN_WITHDRAWAL_AMOUNT_CENTS, BELOW_MIN_WITHDRAWAL_FEE_CENTS } from "@dub/utils";

  {variant === "stripe"
-   ? payout.amount >= 1000
+   ? payout.amount >= MIN_WITHDRAWAL_AMOUNT_CENTS
      ? "The funds will begin transferring to your connected bank account shortly. You will receive another email when the funds are on their way."
-     : "Since this payout is below the minimum withdrawal amount of $10, it will remain in processed status. If you'd like to receive your payout now, you can do so with a $0.50 withdrawal fee."
+     : `Since this payout is below the minimum withdrawal amount of ${currencyFormatter(MIN_WITHDRAWAL_AMOUNT_CENTS / 100, { trailingZeroDisplay: "stripIfInteger" })}, it will remain in processed status. If you'd like to receive your payout now, you can do so with a ${currencyFormatter(BELOW_MIN_WITHDRAWAL_FEE_CENTS / 100, { trailingZeroDisplay: "stripIfInteger" })} withdrawal fee.`
    : "Your payout is on its way to your PayPal account. You'll receive an email from PayPal when it's complete."}
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-stats.tsx (1)

205-247: Fix NaN in currency calculations - critical UX issue.

The calculation of processedPayoutAmountInUsd (lines 206-207) and the modal description amounts (lines 234-236) will produce NaN if payoutStatusMap?.processed?.amount is undefined. This results in user-facing text like "Pay out NaN" and breaks the modal's readability.

Apply safe fallbacks:

 const processedPayoutAmountInUsd = currencyFormatter(
-  payoutStatusMap?.processed?.amount / 100,
+  (payoutStatusMap?.processed?.amount ?? 0) / 100,
   {
     trailingZeroDisplay: "stripIfInteger",
   },
 );

And in the modal description:

         {currencyFormatter(
-          (payoutStatusMap?.processed?.amount -
+          ((payoutStatusMap?.processed?.amount ?? 0) -
             BELOW_MIN_WITHDRAWAL_FEE_CENTS) /
             100,
           { trailingZeroDisplay: "stripIfInteger" },
         )}
apps/web/lib/actions/partners/force-withdrawal.ts (2)

25-33: Clarify rate limit error message.

The error message refers to "retry attempts," but this rate limit applies to force withdrawal initiations, not retries of failed operations. Users will be confused about what action is being limited.

  if (!success) {
    throw new Error(
-      "You've reached the maximum number of retry attempts for the past 24 hours. Please wait and try again later.",
+      "You've reached the maximum number of instant payout requests (5) for the past 24 hours. Please wait and try again later.",
    );
  }

50-60: Log the original error before throwing generic message.

The catch block discards the original error, making debugging production issues nearly impossible. The error should be logged with context (partner ID, payout details) before throwing the user-facing message.

  try {
    await createStripeTransfer({
      partner,
      previouslyProcessedPayouts,
      forceWithdrawal: true,
    });
  } catch (error) {
+   console.error(
+     `Force withdrawal failed for partner ${partner.id}:`,
+     error,
+   );
    throw new Error(
      "Failed to force withdrawal. Please try again or contact support.",
    );
  }
🧹 Nitpick comments (1)
apps/web/lib/partners/create-stripe-transfer.ts (1)

88-96: Consider logging withdrawal fee details.

When a withdrawal fee is applied (line 84), the subsequent logs (lines 91-93, 124-129) don't indicate that a fee was deducted. This could make debugging fee-related issues difficult.

Add fee information to the log:

   if (finalTransferableAmount <= 0) {
     console.log(
-      `Final transferable amount after deducting withdrawal fee (${currencyFormatter(finalTransferableAmount / 100)}) is less than or equal to 0, skipping...`,
+      `Final transferable amount (${currencyFormatter(finalTransferableAmount / 100)}) after deducting ${currencyFormatter(withdrawalFee / 100)} fee is less than or equal to 0, skipping...`,
     );

     return;
   }

And enhance the success log around line 124:

   console.log(
-    `Transfer of ${currencyFormatter(finalTransferableAmount / 100)} (${transfer.id}) created for partner ${partner.id} for ${pluralize(
+    `Transfer of ${currencyFormatter(finalTransferableAmount / 100)}${withdrawalFee > 0 ? ` (after ${currencyFormatter(withdrawalFee / 100)} fee)` : ""} (${transfer.id}) created for partner ${partner.id} for ${pluralize(
       "payout",
       allPayouts.length,
     )} ${allPayouts.map((p) => p.id).join(", ")}`,
   );
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 067f81c and 9ae449e.

📒 Files selected for processing (14)
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts (0 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-sheet.tsx (1 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-details-sheet.tsx (1 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-stats.tsx (5 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx (2 hunks)
  • apps/web/lib/actions/partners/force-withdrawal.ts (1 hunks)
  • apps/web/lib/actions/partners/update-partner-payout-settings.ts (2 hunks)
  • apps/web/lib/partners/constants.ts (1 hunks)
  • apps/web/lib/partners/create-stripe-transfer.ts (2 hunks)
  • apps/web/lib/zod/schemas/partners.ts (1 hunks)
  • apps/web/ui/partners/payout-row-menu.tsx (2 hunks)
  • apps/web/ui/partners/payout-status-descriptions.ts (1 hunks)
  • packages/email/src/templates/partner-payout-processed.tsx (2 hunks)
  • packages/prisma/schema/partner.prisma (0 hunks)
💤 Files with no reviewable changes (2)
  • packages/prisma/schema/partner.prisma
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
🔇 Additional comments (13)
packages/email/src/templates/partner-payout-processed.tsx (2)

44-46: LGTM! Currency formatting enhancement.

Good addition of trailingZeroDisplay: "stripIfInteger" to avoid unnecessary trailing zeros in currency display.


108-110: LGTM! Improved payout navigation.

The addition of payoutId query parameter and the updated link text from "View payouts" to "View payout" creates a better user experience by directly navigating to the specific payout details.

apps/web/ui/partners/payout-row-menu.tsx (2)

3-3: LGTM! Modal-based confirmation improves UX.

Good refactor from window.confirm to useConfirmModal. The modal provides better UX with clear messaging about the 5 retry attempts per day limit, helping users make informed decisions before retrying failed payouts.

Also applies to: 27-40


16-25: LGTM! Improved variable naming.

Renaming executeAsync to executeRetryPayout and isPending to isRetryPayoutPending improves code clarity, especially since multiple actions may be introduced in future iterations.

apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx (2)

5-5: LGTM! Pagination enhancement.

Good addition of usePartnerPayoutsCount to provide accurate row count for pagination. This aligns with the table's pagination functionality and improves the user experience by showing correct page counts.

Also applies to: 40-40, 173-173


189-197: LGTM! Layout wrapper addition.

The wrapper div with flex layout classes provides consistent spacing and alignment for the filter controls without affecting the Filter.Select component's existing behavior.

apps/web/ui/partners/payout-status-descriptions.ts (1)

1-2: LGTM! Proper use of constants.

Excellent implementation using MIN_WITHDRAWAL_AMOUNT_CENTS constant and currencyFormatter to dynamically generate the payout status description. This ensures consistency across the codebase and eliminates the maintenance burden of hardcoded values.

Also applies to: 10-10

apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-sheet.tsx (1)

16-16: LGTM! Clean removal of minWithdrawalAmount UI.

The removal of minWithdrawalAmount from the payout settings UI is consistent with the PR's objective to replace partner-level withdrawal amounts with fixed constants. The form structure remains intact with business details (company name, address, tax ID) and connected payout account settings.

Also applies to: 68-73

apps/web/lib/actions/partners/update-partner-payout-settings.ts (1)

14-14: LGTM! Simplified action aligns with schema changes.

The removal of minWithdrawalAmount handling and post-update Stripe payout logic is consistent with the PR's approach of using fixed constants and dedicated force withdrawal flows. The action now focuses solely on updating business/invoice settings, which improves separation of concerns.

Also applies to: 26-34

apps/web/lib/zod/schemas/partners.ts (1)

11-11: LGTM! Schema updated to reflect constant-based approach.

The removal of minWithdrawalAmount from partnerPayoutSettingsSchema and the cleanup of related imports (currencyFormatter, ALLOWED_MIN_WITHDRAWAL_AMOUNTS) correctly implements the shift to fixed withdrawal constants. This ensures type safety across the stack.

Also applies to: 755-759

apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-details-sheet.tsx (1)

179-234: LGTM! Clean sticky header/footer layout.

The restructured layout with distinct sticky header and footer regions provides a clear visual hierarchy and keeps key actions accessible during scrolling. The conditional rendering logic properly handles loading and empty states.

apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-stats.tsx (1)

95-102: Verify "Pay out now" button visibility logic.

The button appears whenever amount > 0 (line 95), but the force withdrawal flow is specifically designed for amounts below the minimum threshold. Should this button only show when amount > 0 && amount < MIN_WITHDRAWAL_AMOUNT_CENTS?

Consider adding a boundary check:

-              {label === "Processed" && amount > 0 && (
+              {label === "Processed" && amount > 0 && amount < MIN_WITHDRAWAL_AMOUNT_CENTS && (
                 <Button
                   variant="secondary"
                   text="Pay out now"

Or clarify if the instant payout feature is intended to be available for all processed amounts.

apps/web/lib/partners/create-stripe-transfer.ts (1)

57-85: LGTM! Proper handling of force withdrawal logic.

The refactored minimum withdrawal check correctly:

  • Uses the new constant MIN_WITHDRAWAL_AMOUNT_CENTS instead of the removed partner field
  • Updates current invoice payouts to "processed" status when below threshold and not forcing
  • Applies the withdrawal fee only when forcing a below-minimum withdrawal
  • Maintains clear control flow with early returns

Comment on lines +35 to +48
const previouslyProcessedPayouts = await prisma.payout.findMany({
where: {
partnerId: partner.id,
status: "processed",
stripeTransferId: null,
},
include: {
program: {
select: {
name: true,
},
},
},
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Handle empty processed payouts case explicitly.

If previouslyProcessedPayouts is empty, createStripeTransfer returns early (line 44-46 in create-stripe-transfer.ts), but the user receives no feedback. The success toast (line 197 in payout-stats.tsx) will still fire, misleading users that a withdrawal was initiated.

Add validation before calling the transfer function:

  const previouslyProcessedPayouts = await prisma.payout.findMany({
    where: {
      partnerId: partner.id,
      status: "processed",
      stripeTransferId: null,
    },
    include: {
      program: {
        select: {
          name: true,
        },
      },
    },
  });

+ if (previouslyProcessedPayouts.length === 0) {
+   throw new Error(
+     "No processed payouts found to withdraw.",
+   );
+ }

  try {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const previouslyProcessedPayouts = await prisma.payout.findMany({
where: {
partnerId: partner.id,
status: "processed",
stripeTransferId: null,
},
include: {
program: {
select: {
name: true,
},
},
},
});
const previouslyProcessedPayouts = await prisma.payout.findMany({
where: {
partnerId: partner.id,
status: "processed",
stripeTransferId: null,
},
include: {
program: {
select: {
name: true,
},
},
},
});
if (previouslyProcessedPayouts.length === 0) {
throw new Error(
"No processed payouts found to withdraw.",
);
}
try {
🤖 Prompt for AI Agents
In apps/web/lib/actions/partners/force-withdrawal.ts around lines 35 to 48, the
code fetches previouslyProcessedPayouts but doesn’t handle the empty-array case,
causing createStripeTransfer to return early and the UI to still show a success
toast; add an explicit check for previouslyProcessedPayouts.length === 0 and
return or throw a clear error/result (e.g., throw new Error or return { success:
false, message: 'No processed payouts to transfer' }) so the caller/UI can show
a failure/notification instead of the success toast.

@steven-tey steven-tey merged commit 322963b into main Oct 20, 2025
8 checks passed
@steven-tey steven-tey deleted the instant-payouts branch October 20, 2025 17:08
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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-details-sheet.tsx (1)

106-106: Remove unused dependency from useMemo.

The earnings variable is included in the dependency array but is never used in the calculation of invoiceData. This causes unnecessary recomputation whenever earnings changes.

Apply this diff to fix the dependency array:

-  }, [payout, earnings]);
+  }, [payout]);
apps/web/lib/partners/create-stripe-transfer.ts (3)

16-28: Return a boolean to signal whether a transfer was created.

Callers need a definitive success/failure to drive UI toasts.

-export const createStripeTransfer = async ({
+export const createStripeTransfer = async ({
   partner,
   previouslyProcessedPayouts,
   currentInvoicePayouts,
   chargeId,
   forceWithdrawal = false,
 }: {
   partner: Pick<Partner, "id" | "stripeConnectId">;
   previouslyProcessedPayouts: PayoutWithProgramName[];
   currentInvoicePayouts?: PayoutWithProgramName[];
   chargeId?: string;
   forceWithdrawal?: boolean;
-}) => {
+}): Promise<boolean> => {

29-46: Early exits should return false (not silent void).

Avoid ambiguous success in callers.

   if (!partner.stripeConnectId) {
     console.log(`Partner ${partner.id} has no stripeConnectId, skipping...`);
-    return;
+    return false;
   }
@@
   if (allPayouts.length === 0) {
     console.log(`No payouts found for partner ${partner.id}, skipping...`);
-    return;
+    return false;
   }

90-96: Return false when net amount ≤ 0.

Prevents callers from assuming success.

   if (finalTransferableAmount <= 0) {
     console.log(
       `Final transferable amount after deducting withdrawal fee (${currencyFormatter(finalTransferableAmount / 100)}) is less than or equal to 0, skipping...`,
     );
 
-    return;
+    return false;
   }
♻️ Duplicate comments (6)
apps/web/lib/actions/partners/force-withdrawal.ts (4)

29-33: Clarify rate‑limit message to reference instant payout requests.

Current copy says “retry attempts,” which is misleading.

-      throw new Error(
-        "You've reached the maximum number of retry attempts for the past 24 hours. Please wait and try again later.",
-      );
+      throw new Error(
+        "You've reached the maximum number of instant payout requests (5) in the past 24 hours. Please wait and try again later.",
+      );

50-60: Log original error for debuggability.

The catch swallows root cause.

 } catch (error) {
-  throw new Error(
+  console.error(`Force withdrawal failed for partner ${partner.id}:`, {
+    error,
+    processedPayoutCount: previouslyProcessedPayouts.length,
+  });
+  throw new Error(
     "Failed to force withdrawal. Please try again or contact support.",
   );
 }

35-48: Guard empty processed payouts to avoid false “success.”

If none exist, the transfer call is a no‑op and the UI shows a success toast.

   const previouslyProcessedPayouts = await prisma.payout.findMany({
@@
   });
 
+  if (previouslyProcessedPayouts.length === 0) {
+    throw new Error("No processed payouts found to withdraw.");
+  }

50-56: Surface transfer outcome; don’t toast success on no‑op.

createStripeTransfer can early‑return (e.g., final amount ≤ 0) without throwing, but this action treats it as success. Make the helper return a boolean and act accordingly.

-    try {
-      await createStripeTransfer({
+    try {
+      const transferred = await createStripeTransfer({
         partner,
         previouslyProcessedPayouts,
         forceWithdrawal: true,
       });
+      if (!transferred) {
+        throw new Error("Nothing to transfer.");
+      }

Follow-up change needed in create-stripe-transfer.ts to return boolean; see related comment. Based on learnings.

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

98-101: Replace hardcoded $10 / $0.50 and 1000 with shared constants.

Use MIN_WITHDRAWAL_AMOUNT_CENTS and BELOW_MIN_WITHDRAWAL_FEE_CENTS with currencyFormatter for consistency and single‑source‑of‑truth.

@@
-              {variant === "stripe"
-                ? payout.amount >= 1000
-                  ? "The funds will begin transferring to your connected bank account shortly. You will receive another email when the funds are on their way."
-                  : "Since this payout is below the minimum withdrawal amount of $10, it will remain in processed status. If you'd like to receive your payout now, you can do so with a $0.50 withdrawal fee."
+              {variant === "stripe"
+                ? payout.amount >= MIN_WITHDRAWAL_AMOUNT_CENTS
+                  ? "The funds will begin transferring to your connected bank account shortly. You will receive another email when the funds are on their way."
+                  : `Since this payout is below the minimum withdrawal amount of ${currencyFormatter(
+                      MIN_WITHDRAWAL_AMOUNT_CENTS / 100,
+                      { trailingZeroDisplay: "stripIfInteger" },
+                    )}, it will remain in processed status. If you'd like to receive your payout now, you can do so with a ${currencyFormatter(
+                      BELOW_MIN_WITHDRAWAL_FEE_CENTS / 100,
+                      { trailingZeroDisplay: "stripIfInteger" },
+                    )} withdrawal fee.`
                 : "Your payout is on its way to your PayPal account. You'll receive an email from PayPal when it's complete."}

Add import at top (if not already exported here, re‑export from @dub/utils as a follow‑up):

+import { MIN_WITHDRAWAL_AMOUNT_CENTS, BELOW_MIN_WITHDRAWAL_FEE_CENTS } from "@dub/utils";

Please confirm these constants are exported from @dub/utils for the email package; if not, move them to @dub/utils (or re-export) to avoid apps/web cross‑package coupling.

apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-stats.tsx (1)

93-103: Fix NaN risk, correct copy, show net amount, and gate button appropriately.

  • Use null‑safe amounts to avoid NaN in currencyFormatter.
  • If processed ≥ MIN, no fee and copy should not claim “below minimum.”
  • Confirm button should show the net amount when fee applies.
  • Disable actions while pending; refresh stats after success.
-              {label === "Processed" && amount > 0 && (
+              {label === "Processed" && amount > 0 && amount < MIN_WITHDRAWAL_AMOUNT_CENTS && (
                 <Button
                   variant="secondary"
                   text="Pay out now"
                   className="ml-2 h-7 px-2 py-1"
                   onClick={() => setShowForceWithdrawalModal(true)}
                 />
               )}
@@
-  const { executeAsync: executeForceWithdrawal } = useAction(
+  const { executeAsync: executeForceWithdrawal, isPending: isWithdrawing } = useAction(
     forceWithdrawalAction,
     {
       onSuccess: () => {
         toast.success("Withdrawal initiated successfully");
+        // TODO: revalidate payout counts; replace key with the one used by usePartnerPayoutsCount
+        // mutatePrefix("/api/partner-payouts");
       },
       onError: ({ error }) => {
         toast.error(error.serverError || "Failed to initiate withdrawal");
       },
     },
   );
 
-  const processedPayoutAmountInUsd = currencyFormatter(
-    payoutStatusMap?.processed?.amount / 100,
+  const processedAmountCents = payoutStatusMap?.processed?.amount ?? 0;
+  const isBelowMin = processedAmountCents > 0 && processedAmountCents < MIN_WITHDRAWAL_AMOUNT_CENTS;
+  const netAmountCents = isBelowMin
+    ? Math.max(processedAmountCents - BELOW_MIN_WITHDRAWAL_FEE_CENTS, 0)
+    : processedAmountCents;
+  const processedPayoutAmountInUsd = currencyFormatter(
+    processedAmountCents / 100,
     {
       trailingZeroDisplay: "stripIfInteger",
     },
   );
+  const netPayoutAmountInUsd = currencyFormatter(netAmountCents / 100, {
+    trailingZeroDisplay: "stripIfInteger",
+  });
@@
   } = useConfirmModal({
     title: "Pay out funds instantly",
-    description: (
+    description: (
       <>
-        Since your total processed earnings (
-        <strong className="text-black">{processedPayoutAmountInUsd}</strong>)
-        are below the minimum requirement of{" "}
-        <strong className="text-black">
-          {currencyFormatter(MIN_WITHDRAWAL_AMOUNT_CENTS / 100, {
-            trailingZeroDisplay: "stripIfInteger",
-          })}
-        </strong>
-        , you will be charged a fee of{" "}
-        <strong className="text-black">
-          {currencyFormatter(BELOW_MIN_WITHDRAWAL_FEE_CENTS / 100)}
-        </strong>{" "}
-        for this payout, which means you will receive{" "}
-        <strong className="text-black">
-          {currencyFormatter(
-            (payoutStatusMap?.processed?.amount -
-              BELOW_MIN_WITHDRAWAL_FEE_CENTS) /
-              100,
-            { trailingZeroDisplay: "stripIfInteger" },
-          )}
-        </strong>
-        .
+        {isBelowMin ? (
+          <>
+            Since your total processed earnings (
+            <strong className="text-black">{processedPayoutAmountInUsd}</strong>
+            ) are below the minimum requirement of{" "}
+            <strong className="text-black">
+              {currencyFormatter(MIN_WITHDRAWAL_AMOUNT_CENTS / 100, {
+                trailingZeroDisplay: "stripIfInteger",
+              })}
+            </strong>
+            , a{" "}
+            <strong className="text-black">
+              {currencyFormatter(BELOW_MIN_WITHDRAWAL_FEE_CENTS / 100)}
+            </strong>{" "}
+            fee will apply. You will receive{" "}
+            <strong className="text-black">{netPayoutAmountInUsd}</strong>.
+          </>
+        ) : (
+          <>
+            Your processed earnings (
+            <strong className="text-black">{processedPayoutAmountInUsd}</strong>
+            ) will be paid out now. No fee applies.
+          </>
+        )}
       </>
     ),
     onConfirm: async () => {
-      await executeForceWithdrawal();
+      await executeForceWithdrawal();
     },
-    confirmText: `Pay out ${processedPayoutAmountInUsd}`,
+    confirmText: `Pay out ${isBelowMin ? netPayoutAmountInUsd : processedPayoutAmountInUsd}`,
+    confirmDisabled: isWithdrawing,
   });

Optionally, pass loading/disabled to the “Pay out now” button using isWithdrawing. Based on learnings.

Also applies to: 193-247

🧹 Nitpick comments (8)
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx (2)

189-197: Remove unnecessary flexbox classes or clarify intent.

The wrapper div uses justify-between with only a single child (Filter.Select). Flexbox properties like justify-between, items-center, and gap-2 have no effect with a single child element.

If no additional elements are planned, simplify to:

-<div className="flex items-center justify-between gap-2">
-  <Filter.Select
-    className="w-full md:w-fit"
-    filters={filters}
-    activeFilters={activeFilters}
-    onSelect={onSelect}
-    onRemove={onRemove}
-  />
-</div>
+<Filter.Select
+  className="w-full md:w-fit"
+  filters={filters}
+  activeFilters={activeFilters}
+  onSelect={onSelect}
+  onRemove={onRemove}
+/>

Otherwise, if additional UI elements (e.g., action buttons) are planned for this row, please clarify with a comment.


40-40: Destructure error from usePartnerPayoutsCount for consistency and visibility.

The hook returns { payoutsCount, error, loading }, but only payoutsCount is destructured. While the Table component safely handles undefined rowCount via its !!rowCount check, other files using this hook (e.g., payout-stats.tsx) destructure the error state. This pattern inconsistency masks potential API failures silently.

Recommendation: Align with the pattern used in payout-stats.tsx by adding:

const { payoutsCount, error } = usePartnerPayoutsCount<number>();

This improves error visibility for debugging and maintains consistency across the codebase.

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

44-46: Nit: rename saleAmountInDollars for clarity.

It’s a payout amount, not sale amount.

-const saleAmountInDollars = currencyFormatter(payout.amount / 100, {
+const payoutAmountInUsd = currencyFormatter(payout.amount / 100, {
   trailingZeroDisplay: "stripIfInteger",
 });
@@
-<strong className="text-black">{saleAmountInDollars}</strong>
+<strong className="text-black">{payoutAmountInUsd}</strong>

Also applies to: 84-85

apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-sheet.tsx (3)

16-17: Avoid deep import; use stable utils surface.

Import COUNTRY_CURRENCY_CODES from @dub/utils, not @dub/utils/src, to prevent bundling/resolution issues.

-import { CONNECT_SUPPORTED_COUNTRIES, COUNTRIES } from "@dub/utils";
-import { COUNTRY_CURRENCY_CODES } from "@dub/utils/src";
+import { CONNECT_SUPPORTED_COUNTRIES, COUNTRIES, COUNTRY_CURRENCY_CODES } from "@dub/utils";

61-73: Keep form in sync with async partner data; remove unused vars.

defaultValues are only used on mount; without reset the form can render stale/empty values. Also watch and setValue are unused.

-  const {
-    register,
-    handleSubmit,
-    watch,
-    setValue,
-    formState: { isDirty },
-  } = useForm<PartnerPayoutSettingsFormData>({
+  const {
+    register,
+    handleSubmit,
+    reset,
+    formState: { isDirty },
+  } = useForm<PartnerPayoutSettingsFormData>({
     defaultValues: {
       companyName: partner?.companyName || undefined,
       address: partner?.invoiceSettings?.address || undefined,
       taxId: partner?.invoiceSettings?.taxId || undefined,
     },
   });
+
+  // Sync when partner loads/changes
+  useEffect(() => {
+    if (!partner) return;
+    reset({
+      companyName: partner.companyName || undefined,
+      address: partner.invoiceSettings?.address || undefined,
+      taxId: partner.invoiceSettings?.taxId || undefined,
+    });
+  }, [partner, reset]);

226-232: Disable Save while pending.

Prevent double submits.

-        <Button
+        <Button
           text="Save"
           className="h-8 w-fit px-3"
           loading={isPending}
-          disabled={!isDirty}
+          disabled={isPending || !isDirty}
           type="submit"
         />
apps/web/lib/partners/create-stripe-transfer.ts (1)

102-122: Guard invoiceId presence and return true on success.

Avoid non‑null assertion risk; finish with an explicit true.

-  const finalPayoutInvoiceId = allPayouts[allPayouts.length - 1].invoiceId;
+  const finalPayoutInvoiceId = allPayouts[allPayouts.length - 1].invoiceId;
+  if (!finalPayoutInvoiceId) {
+    console.log(`Missing invoiceId on payouts for partner ${partner.id}, skipping...`);
+    return false;
+  }
@@
-  const transfer = await stripe.transfers.create(
+  const transfer = await stripe.transfers.create(
     {
       amount: finalTransferableAmount,
       currency: "usd",
@@
-      transfer_group: finalPayoutInvoiceId!,
+      transfer_group: finalPayoutInvoiceId,
@@
   );
@@
   await Promise.allSettled([
@@
   ]);
+  return true;

Also applies to: 124-156

apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-stats.tsx (1)

16-17: Optional: allow instant payout even when ≥ min (if intended).

If you want the button always visible, keep the button condition but use the modal copy above (no fee when ≥ min). Otherwise, gating to < MIN (diff above) matches current copy.

Confirm the intended UX: Should “Pay out now” be offered when processed ≥ minimum (to avoid waiting background runs)?

Also applies to: 96-102

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 067f81c and 9ae449e.

📒 Files selected for processing (14)
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts (0 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-settings-sheet.tsx (1 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-details-sheet.tsx (1 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-stats.tsx (5 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx (2 hunks)
  • apps/web/lib/actions/partners/force-withdrawal.ts (1 hunks)
  • apps/web/lib/actions/partners/update-partner-payout-settings.ts (2 hunks)
  • apps/web/lib/partners/constants.ts (1 hunks)
  • apps/web/lib/partners/create-stripe-transfer.ts (2 hunks)
  • apps/web/lib/zod/schemas/partners.ts (1 hunks)
  • apps/web/ui/partners/payout-row-menu.tsx (2 hunks)
  • apps/web/ui/partners/payout-status-descriptions.ts (1 hunks)
  • packages/email/src/templates/partner-payout-processed.tsx (2 hunks)
  • packages/prisma/schema/partner.prisma (0 hunks)
💤 Files with no reviewable changes (2)
  • packages/prisma/schema/partner.prisma
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts
🔇 Additional comments (9)
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-details-sheet.tsx (1)

179-234: LGTM! Well-structured layout with sticky header and footer.

The layout refactor properly implements a sticky header and footer pattern with a scrollable content area. The z-index layering, flex properties, and border styling are all appropriate. This improves UX by keeping navigation elements visible while scrolling through payout details.

apps/web/ui/partners/payout-row-menu.tsx (3)

3-3: Excellent UX improvement—modal-based confirmation is clearer than inline confirm.

The refactor from an inline confirmation (likely window.confirm) to a dedicated modal provides better context and aligns with modern UX patterns. The renamed action hook outputs (executeRetryPayout, isRetryPayoutPending) improve code clarity.

Also applies to: 16-25, 27-40, 48-81


60-64: Clean integration—disabled state prevents double-execution.

The MenuItem correctly uses isRetryPayoutPending to prevent users from triggering multiple retry attempts while one is in flight. The flow (menu click → modal → confirm → execute) is intuitive.


27-40: All concerns verified—no action required.

  1. Rate limit enforcement (lines 32-33): Confirmed via retry-failed-paypal-payouts.ts line 37 — the server-side action enforces exactly 5 attempts per 24h using Upstash ratelimit. The messaging is accurate.

  2. Async onConfirm handling (lines 34-38): Confirmed via confirm-modal.tsx — the useConfirmModal properly:

    • Manages loading state during async execution
    • Disables the confirm button while onConfirm() is pending (via loading={isLoading} prop)
    • Auto-closes the modal after success (setShowConfirmModal(false) in line 47)

The implementation correctly prevents UX confusion from error states while the modal remains open.

apps/web/lib/actions/partners/update-partner-payout-settings.ts (3)

21-24: LGTM!

The invoiceSettings construction correctly handles optional fields and properly types the object for Prisma's JSON column.


26-34: No changes required—Stripe logic is properly migrated and callers don't depend on return value.

Verification confirmed:

  • Return value removal: Not a breaking change. The single caller in partner-payout-settings-sheet.tsx uses the action through the useAction hook and relies only on the onSuccess callback; it does not capture or reference any returned value.
  • Stripe logic migration: Complete. createStripeTransfer() in create-stripe-transfer.ts handles transfer creation and status updates. forceWithdrawalAction in force-withdrawal.ts handles forced withdrawals. No remaining waitUntil payout logic found.

The refactoring is safe and ready.


14-14: No issues found. Schema updated correctly; return value removal is appropriate; Stripe logic properly isolated.

Verification complete:

  • Schema consistency: ✅ partnerPayoutSettingsSchema correctly removed minWithdrawalAmount and now only validates companyName, address, taxId
  • Return value: ✅ Caller (partner-payout-settings-sheet.tsx) uses useAction hook without capturing return value; side effects handled via onSuccess/onError callbacks
  • Stripe logic: ✅ Withdrawal processing properly separated into force-withdrawal.ts action; minWithdrawalAmount removed entirely (no references remain in codebase)

The refactoring cleanly separates invoice settings updates from withdrawal processing concerns. No breaking changes or inconsistencies detected.

apps/web/ui/partners/payout-status-descriptions.ts (1)

1-11: LGTM: dynamic minimum amount formatting.

Description now references MIN_WITHDRAWAL_AMOUNT_CENTS via currencyFormatter.

apps/web/lib/zod/schemas/partners.ts (1)

11-12: LGTM: removed minWithdrawalAmount from schemas.

Imports cleaned; payout settings schema reflects only invoice details.

Also applies to: 755-759

Comment on lines +11 to +12
export const MIN_WITHDRAWAL_AMOUNT_CENTS = 1000; // $10
export const BELOW_MIN_WITHDRAWAL_FEE_CENTS = 50; // $0.50
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

LGTM; constants align with new instant‑payout flow.

Ensure help docs and UI copy reference these values and avoid hardcoded strings elsewhere.

Run a quick scan to catch stray hardcoded $10 / $0.50:


🏁 Script executed:

#!/bin/bash
rg -nC2 '\$10|\$0\.50|\b1000\b|\b50\b' -g '!**/node_modules/**' -g '*.ts' -g '*.tsx'

Length of output: 290537


Critical: Remove hardcoded monetary values from email template and import from constants

The constants are correctly defined, but the email template at packages/email/src/templates/partner-payout-processed.tsx:101 contains hardcoded strings: "Since this payout is below the minimum withdrawal amount of $10, it will remain in processed status. If you'd like to receive your payout now, you can do so with a $0.50 withdrawal fee." These should reference the constants instead to maintain a single source of truth. Update the template to import MIN_WITHDRAWAL_AMOUNT_CENTS and BELOW_MIN_WITHDRAWAL_FEE_CENTS and compute or format the display values dynamically rather than hardcoding them.

🤖 Prompt for AI Agents
In packages/email/src/templates/partner-payout-processed.tsx around line 101,
the email currently hardcodes "$10" and "$0.50"; import
MIN_WITHDRAWAL_AMOUNT_CENTS and BELOW_MIN_WITHDRAWAL_FEE_CENTS from
apps/web/lib/partners/constants and replace the hardcoded strings by computing
display values (e.g., cents / 100 formatted to two decimal places or using the
project’s currency formatter helper) so the message shows the dynamic dollar
amounts derived from those constants; ensure imports are added at the top and
the JSX uses the formatted variables.

Comment on lines +57 to +81
// If the total transferable amount is less than the minimum withdrawal amount
if (totalTransferableAmount < MIN_WITHDRAWAL_AMOUNT_CENTS) {
// if we're not forcing a withdrawal
if (!forceWithdrawal) {
// we need to update current invoice payouts to "processed" status
if (currentInvoicePayouts) {
await prisma.payout.updateMany({
where: {
id: {
in: currentInvoicePayouts.map((p) => p.id),
},
},
},
data: {
status: "processed",
},
});
}
data: {
status: "processed",
},
});
}

console.log(
`Total processed payouts (${currencyFormatter(totalTransferableAmount / 100)}) for partner ${partner.id} are below the minWithdrawalAmount (${currencyFormatter(partner.minWithdrawalAmount / 100)}), skipping...`,
);
console.log(
`Total processed payouts (${currencyFormatter(totalTransferableAmount / 100)}) for partner ${partner.id} are below ${currencyFormatter(MIN_WITHDRAWAL_AMOUNT_CENTS / 100)}, skipping...`,
);

return;
}
// skip creating a transfer
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Below‑minimum branch should return false when skipping.

Currently logs and returns undefined; standardize outcome.

   if (totalTransferableAmount < MIN_WITHDRAWAL_AMOUNT_CENTS) {
     if (!forceWithdrawal) {
@@
-      // skip creating a transfer
-      return;
+      // skip creating a transfer
+      return false;
     }
 
     // else, if we're forcing a withdrawal, we need to charge a withdrawal fee
     withdrawalFee = BELOW_MIN_WITHDRAWAL_FEE_CENTS;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// If the total transferable amount is less than the minimum withdrawal amount
if (totalTransferableAmount < MIN_WITHDRAWAL_AMOUNT_CENTS) {
// if we're not forcing a withdrawal
if (!forceWithdrawal) {
// we need to update current invoice payouts to "processed" status
if (currentInvoicePayouts) {
await prisma.payout.updateMany({
where: {
id: {
in: currentInvoicePayouts.map((p) => p.id),
},
},
},
data: {
status: "processed",
},
});
}
data: {
status: "processed",
},
});
}
console.log(
`Total processed payouts (${currencyFormatter(totalTransferableAmount / 100)}) for partner ${partner.id} are below the minWithdrawalAmount (${currencyFormatter(partner.minWithdrawalAmount / 100)}), skipping...`,
);
console.log(
`Total processed payouts (${currencyFormatter(totalTransferableAmount / 100)}) for partner ${partner.id} are below ${currencyFormatter(MIN_WITHDRAWAL_AMOUNT_CENTS / 100)}, skipping...`,
);
return;
}
// skip creating a transfer
return;
}
// If the total transferable amount is less than the minimum withdrawal amount
if (totalTransferableAmount < MIN_WITHDRAWAL_AMOUNT_CENTS) {
// if we're not forcing a withdrawal
if (!forceWithdrawal) {
// we need to update current invoice payouts to "processed" status
if (currentInvoicePayouts) {
await prisma.payout.updateMany({
where: {
id: {
in: currentInvoicePayouts.map((p) => p.id),
},
},
data: {
status: "processed",
},
});
}
console.log(
`Total processed payouts (${currencyFormatter(totalTransferableAmount / 100)}) for partner ${partner.id} are below ${currencyFormatter(MIN_WITHDRAWAL_AMOUNT_CENTS / 100)}, skipping...`,
);
// skip creating a transfer
return false;
}
🤖 Prompt for AI Agents
In apps/web/lib/partners/create-stripe-transfer.ts around lines 57 to 81 the
branch that skips creating a transfer when totalTransferableAmount <
MIN_WITHDRAWAL_AMOUNT_CENTS currently logs and returns undefined; change the
early return to return false so the function consistently returns a boolean
outcome when skipping, and ensure the function signature/return type reflects
Promise<boolean> if necessary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants