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

Skip to content

Conversation

@TWilson023
Copy link
Collaborator

@TWilson023 TWilson023 commented Nov 17, 2025

Summary by CodeRabbit

  • New Features

    • Paginated eligible payouts with client-side controls and a max page size.
    • New endpoint returning total eligible payouts and total amount for accurate row/amount displays.
    • Batched payout update endpoint for background processing and partner notifications.
  • Improvements

    • Invoice-centric payout processing with consolidated updates and simplified lifecycle.
    • Exclude/Include payout actions persisted in the URL and count-aware UI flows.
    • Configurable minimum payout amount and eligibility safeguards.

@vercel
Copy link
Contributor

vercel bot commented Nov 17, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Nov 18, 2025 2:40am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 17, 2025

Warning

Rate limit exceeded

@steven-tey has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 0 minutes and 53 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between f93760e and 5c838fe.

📒 Files selected for processing (2)
  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (6 hunks)
  • apps/web/app/(ee)/api/cron/payouts/process/route.ts (2 hunks)

Walkthrough

Adds a paginated eligible-payouts listing and a separate count endpoint, threads pagination and excluded IDs through backend and UI, introduces payout constants and schema pagination, and rewrites cron payout processing to an invoice-driven, batched flow with a new batched updates endpoint; also reorders a Prisma Partner index.

Changes

Cohort / File(s) Summary
Eligible payouts count endpoint
apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/count/route.ts
New GET route: validates query, resolves program and cutoffPeriod, returns eligible payouts count and amount either by fetching all eligible payouts or via Prisma aggregate depending on cutoff resolution.
Eligible payouts list route
apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts
Forwards parsed query object to getEligiblePayouts (now accepts pagination), simplifying search param handling.
Eligible payouts API & pagination
apps/web/lib/api/payouts/get-eligible-payouts.ts
Adds page/pageSize parameters, updates GetEligiblePayoutsProps (omits excludedPayoutIds from inherited schema), and applies skip/take when pageSize is finite.
Zod schemas & payout constants
apps/web/lib/zod/schemas/payouts.ts, apps/web/lib/constants/payouts.ts
Introduces ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE, INVOICE_MIN_PAYOUT_AMOUNT_CENTS, CUTOFF_PERIOD_MAX_PAYOUTS; merges pagination into eligiblePayoutsQuerySchema; adds eligiblePayoutsCountQuerySchema.
Confirm payouts UI — pagination & exclusions
apps/web/ui/partners/confirm-payouts-sheet.tsx
Adds pagination state, requests paginated payouts and separate count endpoint, supports excludedPayoutIds via URL, toggles exclusions, and uses count.amount for totals.
Confirm payouts action — validation & fetch-all
apps/web/lib/actions/partners/confirm-payouts.ts
Replaces hard-coded min with INVOICE_MIN_PAYOUT_AMOUNT_CENTS, validates cutoff using eligibility count, and calls getEligiblePayouts with page:1 and pageSize: Infinity when fetching all eligible payouts.
Cron processing — invoice-centric & batched
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
Rewrote to compute grouped sums by payout mode, update invoice totals early, mark payouts processing in a single update, handle hybrid mode, compute FX/charge totals, and trigger batched updates via QStash (removes per-payout side effects).
Cron updates batching endpoint
apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts
New POST route (QStash-verified) that processes payouts in 100-item batches, creates audit logs, sends partner emails for internal payouts (non-card), and enqueues subsequent batches via QStash; exports dynamic = "force-dynamic".
Cron route response logging
apps/web/app/(ee)/api/cron/payouts/process/route.ts
Replaces plain success string with logAndRespond(...) for standardized logging/response.
Audit log return value
apps/web/lib/api/audit-logs/record-audit-log.ts
Now returns the result of the underlying recordAuditLogTB call (return await) instead of only awaiting it.
Prisma schema index reorder
packages/prisma/schema/partner.prisma
Moved the @@index(country) placement within the Partner model; index declarations otherwise unchanged.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant UI as ConfirmPayoutsSheet
    participant API_Count as GET /api/.../eligible/count
    participant API_List as GET /api/.../eligible
    participant Lib_Count as Prisma aggregate / getEligiblePayouts (full)
    participant Lib_List as getEligiblePayouts (paged)

    UI->>API_Count: request commonQuery (workspace, cutoff, excludedPayoutIds)
    API_Count->>Lib_Count: resolve cutoff -> either aggregate or fetch-all
    Lib_Count-->>API_Count: { count, amount }
    API_Count-->>UI: totals

    UI->>API_List: request page (commonQuery + page)
    API_List->>Lib_List: apply where, skip/take -> fetch page
    Lib_List-->>API_List: payouts[]
    API_List-->>UI: page of payouts
Loading
sequenceDiagram
    autonumber
    participant Cron as processPayouts
    participant DB as Database/Prisma
    participant Q as QStash
    participant Updates as POST /cron/.../updates
    participant Email as PartnerEmailService

    Cron->>DB: query payouts for invoice, group by mode -> sums & ids
    DB-->>Cron: grouped sums, payout ids
    Cron->>DB: update invoice (amounts/fee/total)
    Cron->>DB: mark payouts status = processing (single update, set invoiceId)
    Cron->>Q: enqueue Updates with invoiceId (+ startingAfter if needed)
    Q-->>Updates: POST callback (batched)
    Updates->>DB: fetch batch (limit 100), create audit logs
    alt internal payouts exist & paymentMethod != card
        Updates->>Email: send batch PartnerPayoutConfirmed
    end
    Updates->>Q: if full batch -> enqueue next batch with cursor
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Areas needing extra attention:
    • Parity between the count path (Prisma aggregate) and getEligiblePayouts where-clause (filters, excludedPayoutIds, selectedPayoutId, cutoff logic).
    • Pagination edge cases: handling of pageSize: Infinity, enforcement of ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE, and correct skip/take math.
    • Cron refactor: correctness of invoice accounting (amount/fee/total), grouped sums by mode, and hybrid-mode behavior.
    • QStash batching: idempotency, cursor/startingAfter correctness, and error/retry behavior in the updates endpoint.

Possibly related PRs

Suggested reviewers

  • devkiran

Poem

🐰 I hopped through queries, pages, and counts,
Batched my carrots in tidy mounts,
Invoices rounded, queues set right,
Payouts march from day to night,
A rabbit cheers this deploy delight 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.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 accurately describes the main change: adding pagination to eligible payouts in the Confirm payouts sheet, which is reflected across multiple files including the UI component, API routes, and schema updates.

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

❤️ Share

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts (1)

22-32: Route now supports pagination, but the comment above is stale

Parsing the full eligiblePayoutsQuerySchema and spreading ...query into getEligiblePayouts means this endpoint now honours pagination parameters (page/pageSize) and any other schema-defined filters, which is great for reuse.

The block comment above still states “no pagination or filtering (we retrieve all pending payouts by default)”, which no longer matches the implementation. Consider updating that comment to describe the current behavior (e.g., default sort plus optional pagination and cutoff filtering) to avoid confusion for future readers.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c98585f and fb6735f.

📒 Files selected for processing (5)
  • apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/count/route.ts (1 hunks)
  • apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts (2 hunks)
  • apps/web/lib/api/payouts/get-eligible-payouts.ts (3 hunks)
  • apps/web/lib/zod/schemas/payouts.ts (1 hunks)
  • apps/web/ui/partners/confirm-payouts-sheet.tsx (5 hunks)
🧰 Additional context used
🧠 Learnings (4)
📓 Common learnings
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx:180-189
Timestamp: 2025-09-24T15:50:16.414Z
Learning: TWilson023 prefers to keep security vulnerability fixes separate from refactoring PRs when the vulnerable code is existing and was only moved/relocated rather than newly introduced.
📚 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/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification. The implementation uses: type={onClick ? "button" : type}

Applied to files:

  • apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification.

Applied to files:

  • apps/web/ui/partners/confirm-payouts-sheet.tsx
🧬 Code graph analysis (5)
apps/web/lib/api/payouts/get-eligible-payouts.ts (1)
apps/web/lib/api/payouts/payout-eligibility-filter.ts (1)
  • getPayoutEligibilityFilter (3-72)
apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts (1)
apps/web/lib/zod/schemas/payouts.ts (1)
  • eligiblePayoutsQuerySchema (117-124)
apps/web/lib/zod/schemas/payouts.ts (1)
apps/web/lib/zod/schemas/misc.ts (1)
  • getPaginationQuerySchema (32-55)
apps/web/ui/partners/confirm-payouts-sheet.tsx (2)
apps/web/lib/zod/schemas/payouts.ts (1)
  • ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE (115-115)
apps/web/lib/types.ts (1)
  • PayoutResponse (494-494)
apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/count/route.ts (5)
apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts (1)
  • GET (19-35)
apps/web/lib/auth/workspace.ts (1)
  • withWorkspace (55-494)
apps/web/lib/zod/schemas/payouts.ts (1)
  • eligiblePayoutsCountQuerySchema (126-129)
apps/web/lib/api/programs/get-program-or-throw.ts (1)
  • getProgramOrThrow (6-29)
apps/web/lib/api/payouts/get-eligible-payouts.ts (1)
  • getEligiblePayoutsCount (138-152)
⏰ 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 (4)
apps/web/lib/zod/schemas/payouts.ts (1)

115-129: Eligible payouts pagination schema is consistent and well‑factored

Using ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE with getPaginationQuerySchema keeps pagination constraints centralized, and deriving eligiblePayoutsCountQuerySchema by omitting page/pageSize ensures the count endpoint can’t accidentally receive pagination params. No issues from this change.

apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/count/route.ts (1)

1-27: Count route wiring matches list route; confirm ignoring [programId] is intentional

The handler correctly reuses workspace scoping, schema validation, and the shared getEligiblePayoutsCount helper, so the count result should stay aligned with the list filters. It always derives programId from getDefaultProgramIdOrThrow(workspace) and ignores the [programId] path param, mirroring the main /eligible route—please confirm that this is the intended behavior for all callers.

apps/web/ui/partners/confirm-payouts-sheet.tsx (1)

7-37: New imports for limit error and pagination are aligned with existing patterns

Importing exceededLimitError, ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE, and useTablePagination centralizes limit messaging and keeps the UI in sync with backend pagination constraints for eligible payouts. This reuse matches the payout‑limit pattern used elsewhere.

Based on learnings

apps/web/lib/api/payouts/get-eligible-payouts.ts (1)

59-60: Backend assumes 1‑based page; ensure all callers follow that convention

getEligiblePayouts uses skip: (page - 1) * pageSize and, via the shared Zod pagination schema, enforces page > 0. This makes page a 1‑based index by design.

That’s fine, but it means all callers (such as the confirm‑payouts sheet) must convert any 0‑based UI page indices to 1‑based before invoking this function or its API route. With the suggested frontend fix to send page = pageIndex + 1, this function’s behavior remains correct; just keep this contract in mind for future consumers.

Also applies to: 67-72, 99-100

@steven-tey
Copy link
Collaborator

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 18, 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/ui/partners/confirm-payouts-sheet.tsx (1)

300-338: Fix stale invoice metrics by adding eligiblePayoutsCount to memo deps

Both useMemo blocks that drive invoice-level metrics depend on eligiblePayoutsCount, but it’s missing from their dependency arrays:

const { amount, fee, total, fastAchFee, externalAmount } = useMemo(() => {
  const amount = eligiblePayoutsCount?.amount ?? 0;
  // ...
}, [finalEligiblePayouts, selectedPaymentMethod, program?.payoutMode]);

const invoiceData = useMemo(() => {
  return [
    // ...
    ...(eligiblePayoutsCount && eligiblePayoutsCount.count <= 1000 ? [/* ... */] : []),
    {
      key: "Partners",
      value:
        eligiblePayoutsCount !== undefined ? (
          nFormatter(eligiblePayoutsCount.count, { full: true })
        ) : (
          // skeleton
        ),
    },
    // ...
  ];
}, [
  amount,
  externalAmount,
  paymentMethods,
  selectedPaymentMethod,
  cutoffPeriod,
  cutoffPeriodOptions,
  selectedCutoffPeriodOption,
]);

As a result, changes to eligiblePayoutsCount (e.g., when cutoff period or exclusions change) won’t recompute amount, Partners, or the cutoff-period visibility unless one of the listed deps also changes.

Recommend including eligiblePayoutsCount in both dependency arrays, e.g.:

-}, [finalEligiblePayouts, selectedPaymentMethod, program?.payoutMode]);
+}, [
+  finalEligiblePayouts,
+  selectedPaymentMethod,
+  program?.payoutMode,
+  eligiblePayoutsCount,
+]);

-}, [
-  amount,
-  externalAmount,
-  paymentMethods,
-  selectedPaymentMethod,
-  cutoffPeriod,
-  cutoffPeriodOptions,
-  selectedCutoffPeriodOption,
-]);
+}, [
+  amount,
+  externalAmount,
+  paymentMethods,
+  selectedPaymentMethod,
+  cutoffPeriod,
+  cutoffPeriodOptions,
+  selectedCutoffPeriodOption,
+  eligiblePayoutsCount,
+]);

This will keep the displayed totals and partner count in sync with the latest count endpoint data.

Also applies to: 407-447, 505-513

🧹 Nitpick comments (3)
apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/count/route.ts (1)

1-64: Count route correctly mirrors eligible payouts logic

The count endpoint’s two-path flow (cutoff-aware via getEligiblePayouts vs. plain aggregate without cutoff recompute) is consistent with the underlying eligibility logic and correctly honors selectedPayoutId and excludedPayoutIds.

The pageSize: Infinity usage is safe given the isFinite(pageSize) guard in getEligiblePayouts; just be aware this will load all eligible payouts into memory when a cutoff period is active, which may become expensive for very large programs.

apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts (1)

1-148: Batch processing flow is solid; consider tightening logs

The batched processing (cursor pagination, audit logs, partner emails, QStash re-enqueue) looks correct and aligns with the invoice-centric flow.

Two minor cleanups you might consider:

  • recordAuditLog doesn’t return anything meaningful, so console.log({ auditLogResponse }); will always log { auditLogResponse: undefined }. You can drop that log or replace it with a more explicit success/failure signal.
  • In the catch block, the message "Error sending Stripe payout" doesn’t match this route’s responsibility (audit logs + emails). Renaming it to something like "Error processing payout updates" would make debugging clearer.
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (1)

2-3: Invoice-centric payout processing and QStash handoff look consistent

The refactor to:

  • mark eligible payouts in bulk as processing and attach them to the invoice,
  • normalize mode for external vs hybrid programs (with a follow-up update for hybrid),
  • compute internal/external totals via groupBy,
  • persist amount, externalAmount, fee, and total on the invoice, and
  • charge totalToCharge = invoiceTotal - totalExternalPayoutAmount before enqueueing the updates job to QStash

is coherent and aligns with the new batched updates route. Using FOREX_MARKUP_RATE in the FX conversion and logging via currencyFormatter helps keep accounting and observability clear.

No blocking issues spotted in this segment.

Also applies to: 18-20, 71-107, 119-149, 166-185, 202-204, 250-266

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 66b2838 and a23748d.

📒 Files selected for processing (8)
  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (6 hunks)
  • apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts (1 hunks)
  • apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/count/route.ts (1 hunks)
  • apps/web/lib/api/payouts/get-eligible-payouts.ts (3 hunks)
  • apps/web/lib/constants/payouts.ts (1 hunks)
  • apps/web/lib/zod/schemas/payouts.ts (2 hunks)
  • apps/web/ui/partners/confirm-payouts-sheet.tsx (8 hunks)
  • packages/prisma/schema/partner.prisma (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/lib/api/payouts/get-eligible-payouts.ts
🧰 Additional context used
🧠 Learnings (8)
📓 Common learnings
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.
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx:180-189
Timestamp: 2025-09-24T15:50:16.414Z
Learning: TWilson023 prefers to keep security vulnerability fixes separate from refactoring PRs when the vulnerable code is existing and was only moved/relocated rather than newly introduced.
📚 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/partner.prisma
  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
📚 Learning: 2025-09-24T16:13:00.387Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: packages/prisma/schema/partner.prisma:151-153
Timestamp: 2025-09-24T16:13:00.387Z
Learning: In the Dub codebase, Prisma schemas use single-column indexes without brackets (e.g., `@index(partnerId)`) and multi-column indexes with brackets (e.g., `@index([programId, partnerId])`). This syntax pattern is consistently used throughout their schema files and works correctly with their Prisma version.

Applied to files:

  • packages/prisma/schema/partner.prisma
📚 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/app/(ee)/api/cron/payouts/process/updates/route.ts
📚 Learning: 2025-09-24T15:50:16.414Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx:180-189
Timestamp: 2025-09-24T15:50:16.414Z
Learning: TWilson023 prefers to keep security vulnerability fixes separate from refactoring PRs when the vulnerable code is existing and was only moved/relocated rather than newly introduced.

Applied to files:

  • apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 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/ui/partners/confirm-payouts-sheet.tsx
  • apps/web/lib/zod/schemas/payouts.ts
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification. The implementation uses: type={onClick ? "button" : type}

Applied to files:

  • apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification.

Applied to files:

  • apps/web/ui/partners/confirm-payouts-sheet.tsx
🧬 Code graph analysis (5)
apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/count/route.ts (7)
apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts (1)
  • GET (19-35)
apps/web/lib/auth/workspace.ts (1)
  • withWorkspace (55-494)
apps/web/lib/zod/schemas/payouts.ts (1)
  • eligiblePayoutsCountQuerySchema (125-134)
apps/web/lib/api/programs/get-program-or-throw.ts (1)
  • getProgramOrThrow (6-29)
apps/web/lib/api/payouts/get-eligible-payouts.ts (1)
  • getEligiblePayouts (21-120)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/lib/api/payouts/payout-eligibility-filter.ts (1)
  • getPayoutEligibilityFilter (3-72)
apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts (5)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/lib/api/audit-logs/record-audit-log.ts (1)
  • recordAuditLog (47-74)
packages/email/src/index.ts (1)
  • sendBatchEmail (31-70)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (7-22)
apps/web/lib/api/errors.ts (1)
  • handleAndReturnErrorResponse (162-165)
apps/web/ui/partners/confirm-payouts-sheet.tsx (3)
apps/web/lib/constants/payouts.ts (1)
  • ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE (14-14)
apps/web/lib/types.ts (1)
  • PayoutResponse (494-494)
packages/ui/src/combobox/index.tsx (2)
  • Combobox (86-387)
  • ComboboxOption (29-37)
apps/web/lib/zod/schemas/payouts.ts (2)
apps/web/lib/zod/schemas/misc.ts (1)
  • getPaginationQuerySchema (32-55)
apps/web/lib/constants/payouts.ts (1)
  • ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE (14-14)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (3)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/lib/constants/payouts.ts (1)
  • FOREX_MARKUP_RATE (7-7)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (7-22)
⏰ 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)
packages/prisma/schema/partner.prisma (1)

40-40: Intentional design change to support invoice-centric batched payout processing.

Removing the @unique constraint on payoutMethodHash is the correct change for allowing multiple payout records to share the same payout method hash under the new batched payout flow. This aligns with the pattern established in the payout schema where stripeTransferId is no longer unique. Based on learnings

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

14-15: Centralized eligible payouts page-size limit looks good

Defining ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE = 500 here and reusing it in schemas/UI is a clean way to keep pagination limits consistent.

apps/web/ui/partners/confirm-payouts-sheet.tsx (1)

91-135: Pagination + count wiring looks coherent

The way you:

  • derive excludedPayoutIds from searchParamsObj,
  • centralize query params in commonQuery,
  • call both the list and count endpoints with consistent params, and
  • feed pagination, onPaginationChange, and rowCount: eligiblePayoutsCount?.count ?? 0 into Table

all lines up nicely with the new backend schemas and count route. The exclusions propagated via queryParams and confirmed in the action payload also look correct.

Also applies to: 515-620

@steven-tey
Copy link
Collaborator

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 18, 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: 0

🧹 Nitpick comments (3)
apps/web/lib/zod/schemas/payouts.ts (1)

1-1: Eligible payouts pagination/count schemas are consistent; consider normalizing excluded IDs shape

The new eligiblePayoutsQuerySchema + getPaginationQuerySchema({ pageSize: ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE }) wiring looks solid and keeps client/server aligned on page size. Making eligiblePayoutsCountQuerySchema extend that base and then omit page/pageSize is also a clean approach.

For excludedPayoutIds, the schema:

excludedPayoutIds: z
  .union([z.string(), z.array(z.string())])
  .transform((v) => (Array.isArray(v) ? v : v.split(",")))
  .optional()

means:

  • When the query param is present, downstream always sees a string[] (nice).
  • When the param is absent, the field is undefined (transform is skipped).

If the count route and any shared helpers already treat undefined as “no exclusions”, this is perfectly fine. If they instead expect to always work with an array, you might want to normalize missing values to [] (e.g., by applying .optional() first and transforming null/undefined to an empty array).

Not a blocker, but worth double‑checking the downstream expectations.

Also applies to: 116-135

apps/web/ui/partners/confirm-payouts-sheet.tsx (2)

300-338: External amount is now page‑local while total amount is global

amount is now taken from eligiblePayoutsCount.amount (all included payouts across pages), but externalAmount is still computed from finalEligiblePayouts (the current page only) and the “External Amount” row is gated by finalEligiblePayouts.some(isExternalPayout).

For multi‑page results, this means:

  • “Amount” reflects the full invoice total.
  • “External Amount” reflects only the external portion of the current page, not the entire invoice.

That can be confusing for users interpreting the header as a global summary.

If you care about global accuracy here, consider one of:

  • Extending the count endpoint (or adding a companion endpoint) to return global externalAmount and using that instead of a per‑page reduction, or
  • Explicitly scoping the label/tooltip to “current page” or hiding the row when there are multiple pages.

Not critical for basic pagination, but worth aligning semantics so the invoice summary is either fully global or clearly labeled as per‑page.

Also applies to: 458-474


300-338: Tighten useMemo dependencies to avoid subtle staleness

Both the amount/externalAmount computation and invoiceData assembly now depend (directly or indirectly) on eligiblePayoutsCount, but that object isn’t in the dependency arrays:

  • amount is derived from eligiblePayoutsCount?.amount in a useMemo that currently depends on [finalEligiblePayouts, selectedPaymentMethod, program?.payoutMode].
  • invoiceData uses eligiblePayoutsCount for the “Partners” row and Cutoff Period visibility but only lists [amount, externalAmount, paymentMethods, selectedPaymentMethod, cutoffPeriod, cutoffPeriodOptions, selectedCutoffPeriodOption] as dependencies.

This will usually work because the same actions that change eligiblePayoutsCount also change other dependencies, but it’s brittle and can lead to stale header values if the count is revalidated independently of those other inputs.

Consider adding eligiblePayoutsCount (or at least its .count/.amount fields) to the relevant dependency arrays so React’s memoization reflects the actual data dependencies more explicitly.

Also applies to: 340-505

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a23748d and f8a0def.

📒 Files selected for processing (4)
  • apps/web/app/(ee)/api/cron/payouts/process/route.ts (2 hunks)
  • apps/web/lib/zod/schemas/payouts.ts (2 hunks)
  • apps/web/ui/partners/confirm-payouts-sheet.tsx (8 hunks)
  • packages/prisma/schema/partner.prisma (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/prisma/schema/partner.prisma
🧰 Additional context used
🧠 Learnings (6)
📓 Common learnings
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.
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: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx:180-189
Timestamp: 2025-09-24T15:50:16.414Z
Learning: TWilson023 prefers to keep security vulnerability fixes separate from refactoring PRs when the vulnerable code is existing and was only moved/relocated rather than newly introduced.
📚 Learning: 2025-09-24T15:50:16.414Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx:180-189
Timestamp: 2025-09-24T15:50:16.414Z
Learning: TWilson023 prefers to keep security vulnerability fixes separate from refactoring PRs when the vulnerable code is existing and was only moved/relocated rather than newly introduced.

Applied to files:

  • apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 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/ui/partners/confirm-payouts-sheet.tsx
  • apps/web/lib/zod/schemas/payouts.ts
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification. The implementation uses: type={onClick ? "button" : type}

Applied to files:

  • apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification.

Applied to files:

  • apps/web/ui/partners/confirm-payouts-sheet.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/lib/zod/schemas/payouts.ts
🧬 Code graph analysis (2)
apps/web/ui/partners/confirm-payouts-sheet.tsx (2)
apps/web/lib/constants/payouts.ts (1)
  • ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE (14-14)
packages/ui/src/combobox/index.tsx (2)
  • Combobox (86-387)
  • ComboboxOption (29-37)
apps/web/lib/zod/schemas/payouts.ts (2)
apps/web/lib/zod/schemas/misc.ts (1)
  • getPaginationQuerySchema (32-55)
apps/web/lib/constants/payouts.ts (1)
  • ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE (14-14)
⏰ 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 (4)
apps/web/app/(ee)/api/cron/payouts/process/route.ts (1)

8-8: Centralized success logging/response looks good

Switching the success path to logAndRespond keeps behavior consistent while centralizing logging/response shaping. The new import and usage are coherent with the existing error handling pattern.

Also applies to: 76-76

apps/web/ui/partners/confirm-payouts-sheet.tsx (3)

5-8: Pagination wiring with shared page size constant looks solid

Importing ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE from the constants module and passing it into both useTablePagination and the backend Zod pagination schema keeps the list and API in sync on page size. The useTablePagination({ pageSize, page, onPageChange }) setup and passing pagination, onPaginationChange: setPagination, and rowCount through to <Table /> follow the expected pattern and should give consistent pagination behavior across the sheet.

Also applies to: 37-38, 115-120, 617-619


91-103: Count endpoint + exclusions integration appears correct

The new excludedPayoutIds handling and commonQuery composition:

  • Deriving excludedPayoutIds from searchParamsObj and only adding the param when there are exclusions avoids forcing the API to deal with empty values.
  • Reusing commonQuery for both /eligible/count and /eligible keeps the two endpoints aligned on filters (workspaceId, cutoffPeriod, selectedPayoutId, exclusions).
  • Sourcing amount from eligiblePayoutsCount.amount and the “Partners” row from eligiblePayoutsCount.count ensures invoice totals and partner counts reflect all included payouts, not just the current page.
  • Passing excludedPayoutIds as an array into confirmPayoutsAction matches how the backend cron/process route now accepts excludedPayoutIds, so the invoice/payout data should stay consistent with the UI summary and filters.
  • The disabled tooltip logic for limit checks and minimum invoice amount still matches the previous behavior, now based on the count‑backed amount.

Overall this new count + pagination + exclusion flow looks coherent with the backend changes and invoice confirmation semantics.

Also applies to: 104-113, 127-135, 441-447, 687-747


552-589: Exclude/Include behavior and URL syncing are well‑structured

The updated Exclude/Include button logic:

  • Drives exclusions purely from excludedPayoutIds in searchParamsObj, building newExcludedPayoutIds on click and then writing/removing the excludedPayoutIds query param via queryParams.
  • Relies on the next render’s searchParamsObj to recompute excludedPayoutIds, keeping UI, count endpoint, and confirmPayoutsAction all in sync via a single source of truth.
  • Feeds the same excludedPayoutIds array into confirmPayoutsAction, which matches the revised cron/process schema and ensures the invoice/payout processing respects the UI’s exclusions.

This is a clean pattern for keeping filters, URL state, and server behavior aligned.

Also applies to: 687-704

@steven-tey
Copy link
Collaborator

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 18, 2025

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
apps/web/ui/partners/confirm-payouts-sheet.tsx (1)

302-340: Include eligiblePayoutsCount in the totals useMemo dependencies

This block calculates amount, fee, total, etc. from eligiblePayoutsCount?.amount:

const { amount, fee, total, fastAchFee, externalAmount } = useMemo(() => {
  const amount = eligiblePayoutsCount?.amount;
  ...
}, [finalEligiblePayouts, selectedPaymentMethod, program?.payoutMode]);

but eligiblePayoutsCount isn’t in the dependency array. If the count/amount SWR updates while finalEligiblePayouts and the other deps stay the same, the memo will keep returning stale totals.

Recommend adding eligiblePayoutsCount (or eligiblePayoutsCount?.amount) as a dependency:

-  }, [finalEligiblePayouts, selectedPaymentMethod, program?.payoutMode]);
+  }, [
+    finalEligiblePayouts,
+    selectedPaymentMethod,
+    program?.payoutMode,
+    eligiblePayoutsCount,
+  ]);

This ensures invoice totals always reflect the latest backend-computed amount.

♻️ Duplicate comments (1)
apps/web/ui/partners/confirm-payouts-sheet.tsx (1)

97-123: Pagination + common query wiring looks correct (no off-by-one)

Building commonQuery once and then passing it into:

  • the count endpoint, and
  • the eligible payouts endpoint with page: pagination.pageIndex.toString()

works fine with your useTablePagination hook, since it already uses a 1-based pageIndex. That keeps the frontend and backend pagination in sync without extra conversion and matches the behavior you confirmed in manual testing.

Also applies to: 129-137

🧹 Nitpick comments (5)
apps/web/lib/actions/partners/confirm-payouts.ts (2)

91-108: Align cutoff-period count query with actual eligible-at-cutoff filter

The cutoff-period guard currently counts payouts using only getPayoutEligibilityFilter(program) plus selection/exclusion:

where: {
  ...(selected/excluded),
  ...getPayoutEligibilityFilter(program),
},

but does not apply the cutoff-specific periodStart/periodEnd conditions that getEligiblePayouts uses. That means the error:

“You cannot specify a cutoff period when the number of eligible payouts is greater than …”

may trigger based on all pending payouts for the program, not just the subset that would actually be included for the chosen cutoff date.

If the intent is to cap on “eligible under this cutoff,” consider reusing the same cutoff conditions as getEligiblePayouts (i.e., the cutoffPeriodValue-based OR on periodStart/periodEnd) so the count matches the eventual invoice set. Otherwise, the current implementation is stricter than the message suggests.


111-119: Consider a lighter check for external payouts instead of pageSize: Infinity

For non-internal programs, getEligiblePayouts is called with page: 1 and pageSize: Infinity solely to detect whether any external payout is present:

const [eligiblePayouts, payoutWebhooks] = await Promise.all([
  getEligiblePayouts({ ..., page: 1, pageSize: Infinity }),
  getWebhooks(...),
]);
const hasExternalPayouts = eligiblePayouts.find((p) => p.mode === "external");

If a program accumulates a large number of eligible payouts, this will load the entire set into memory just to compute a boolean. You could instead:

  • Add a small helper query (e.g., existsExternalEligiblePayout) that mirrors the eligibility filter and returns true/false, or
  • Call getEligiblePayouts with a small pageSize (e.g., 1) and an ordering that surfaces external payouts first, if that’s feasible.

Not urgent, but worth considering to keep this path cheap as programs scale.

apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts (1)

140-149: Fix misleading error log message in updates route

The catch block logs:

await log({
  message: `Error sending Stripe payout: ${errorMessage}`,
  ...
});

but this route only handles audit logs and emails for already-processed payouts. Renaming the message to something like “Error processing payout updates” will make debugging much clearer and avoid confusion with the actual payout-processing step.

apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (1)

157-202: Verify FX quote semantics match the conversion formula

The non-USD branch:

const fxQuote = await createFxQuote({
  fromCurrency: currency,
  toCurrency: "usd",
});
const exchangeRate = fxQuote.rates[currency].exchange_rate;
const convertedTotal = Math.round(
  (totalToCharge / exchangeRate) * (1 + FOREX_MARKUP_RATE),
);
totalToCharge = convertedTotal;

assumes exchange_rate is expressed as USD per 1 unit of currency, so dividing a USD-denominated totalToCharge by that rate yields the amount in currency. If createFxQuote (or Stripe’s API) instead returns the inverse (target-per-USD), this would invert the conversion.

Worth double-checking createFxQuote’s contract to ensure the direction matches this formula; if not, the division vs multiplication would need to be swapped.

apps/web/ui/partners/confirm-payouts-sheet.tsx (1)

461-477: External Amount remains page-scoped while totals are global

externalAmount is still computed via a reduce over finalEligiblePayouts, i.e. the currently loaded page:

const externalAmount = finalEligiblePayouts?.reduce(
  (acc, payout) => (isExternalPayout(payout) ? acc + payout.amount : acc),
  0,
);

while amount/Partners are now based on the global eligiblePayoutsCount. If the invoice spans multiple pages, the “External Amount” row will under-report relative to the final invoice.

If you’d prefer all figures in the summary to be global, consider extending the count endpoint to return an externalAmount aggregate as well and using that instead of the per-page reduce.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f8a0def and 205628c.

📒 Files selected for processing (5)
  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (6 hunks)
  • apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts (1 hunks)
  • apps/web/lib/actions/partners/confirm-payouts.ts (3 hunks)
  • apps/web/lib/constants/payouts.ts (1 hunks)
  • apps/web/ui/partners/confirm-payouts-sheet.tsx (9 hunks)
🧰 Additional context used
🧠 Learnings (7)
📓 Common learnings
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.
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: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx:180-189
Timestamp: 2025-09-24T15:50:16.414Z
Learning: TWilson023 prefers to keep security vulnerability fixes separate from refactoring PRs when the vulnerable code is existing and was only moved/relocated rather than newly introduced.
📚 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/app/(ee)/api/cron/payouts/process/updates/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/app/(ee)/api/cron/payouts/process/process-payouts.ts
  • apps/web/lib/actions/partners/confirm-payouts.ts
  • apps/web/ui/partners/confirm-payouts-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:

  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
📚 Learning: 2025-09-24T15:50:16.414Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx:180-189
Timestamp: 2025-09-24T15:50:16.414Z
Learning: TWilson023 prefers to keep security vulnerability fixes separate from refactoring PRs when the vulnerable code is existing and was only moved/relocated rather than newly introduced.

Applied to files:

  • apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification. The implementation uses: type={onClick ? "button" : type}

Applied to files:

  • apps/web/ui/partners/confirm-payouts-sheet.tsx
📚 Learning: 2025-09-24T15:47:40.293Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/about-you-form.tsx:152-157
Timestamp: 2025-09-24T15:47:40.293Z
Learning: The Button component from dub/ui automatically adds type="button" when an onClick prop is passed, preventing accidental form submissions without requiring explicit type specification.

Applied to files:

  • apps/web/ui/partners/confirm-payouts-sheet.tsx
🧬 Code graph analysis (4)
apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts (6)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/lib/api/audit-logs/record-audit-log.ts (1)
  • recordAuditLog (47-74)
packages/email/src/index.ts (1)
  • sendBatchEmail (31-70)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (7-22)
packages/email/src/templates/partner-payout-confirmed.tsx (1)
  • PartnerPayoutConfirmed (18-147)
apps/web/lib/api/errors.ts (1)
  • handleAndReturnErrorResponse (162-165)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (4)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/lib/constants/payouts.ts (1)
  • FOREX_MARKUP_RATE (7-7)
apps/web/lib/stripe/index.ts (1)
  • stripe (4-10)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (7-22)
apps/web/lib/actions/partners/confirm-payouts.ts (3)
apps/web/lib/constants/payouts.ts (2)
  • INVOICE_MIN_PAYOUT_AMOUNT_CENTS (11-11)
  • CUTOFF_PERIOD_MAX_PAYOUTS (16-16)
apps/web/lib/api/payouts/payout-eligibility-filter.ts (1)
  • getPayoutEligibilityFilter (3-72)
apps/web/lib/api/payouts/get-eligible-payouts.ts (1)
  • getEligiblePayouts (21-120)
apps/web/ui/partners/confirm-payouts-sheet.tsx (3)
apps/web/lib/constants/payouts.ts (3)
  • ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE (15-15)
  • CUTOFF_PERIOD_MAX_PAYOUTS (16-16)
  • INVOICE_MIN_PAYOUT_AMOUNT_CENTS (11-11)
apps/web/lib/types.ts (1)
  • PayoutResponse (494-494)
packages/ui/src/combobox/index.tsx (2)
  • Combobox (86-387)
  • ComboboxOption (29-37)
⏰ 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/lib/constants/payouts.ts (1)

11-16: New payout constants are consistent and well-scoped

Centralizing the invoice min amount and pagination/threshold limits here keeps the payouts flow configurable and avoids hard-coded numbers in actions/UI. The values and units align with existing payout constants.

apps/web/lib/actions/partners/confirm-payouts.ts (1)

80-84: Server-side min invoice amount check matches shared constant

Using INVOICE_MIN_PAYOUT_AMOUNT_CENTS here keeps the server validation in sync with the UI disablement logic and avoids hard-coding 1000. This is a clean way to enforce the $10 minimum at the action boundary.

apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (2)

60-141: Invoice-centric batch update and groupBy totals look solid

The flow of:

  • updateMany to attach all eligible payouts to the invoice and mark them "processing",
  • hybrid-specific updateMany to flip mode to "external" for partners without payoutsEnabledAt,
  • groupBy({ by: ["mode"], where: { invoiceId }, _sum: { amount: true } }) to derive internal/external totals,

is a clean, DB-driven way to compute invoice amounts and avoid per-payout loops. Combined with the subsequent invoice update and project.payoutsUsage increment, this aligns well with the two-stage validation pattern (fast check in the action, authoritative amounts here).
Based on learnings


246-257: QStash trigger wiring for updates endpoint is appropriate

Publishing a single JSON message to /api/cron/payouts/process/updates with just the invoiceId and then letting that route batch over payouts by cursor keeps the main processing flow focused on Stripe+DB work, while delegating emails/audit logs to a separate, retryable path. The console logging on messageId is also useful for correlating QStash deliveries in logs.

apps/web/ui/partners/confirm-payouts-sheet.tsx (5)

409-452: Cutoff-period visibility gating based on total count is a nice safety valve

Conditionally showing the “Cutoff Period” combobox only when eligiblePayoutsCount exists and eligiblePayoutsCount.count <= CUTOFF_PERIOD_MAX_PAYOUTS keeps the UI aligned with the backend safeguard that rejects cutoffs for very large eligible sets. That should prevent users from selecting a cutoff that the server will immediately refuse.


444-448: Using global count for “Partners” is aligned with paginated listing

Switching the “Partners” value from eligiblePayouts.length (page-scoped) to eligiblePayoutsCount.count makes the invoice summary reflect the total number of eligible partners across all pages, which is what users generally care about when confirming an invoice.


555-592: Exclude/Include toggle correctly syncs with URL state and filters

The Exclude/Include button:

  • toggles the row’s ID in excludedPayoutIds,
  • writes the updated list back into the excludedPayoutIds query param (or deletes it when empty),
  • and relies on commonQuery + backend filtering for both count and eligible payouts.

That keeps the table, invoice summary, and URL state in sync without doing extra filtering work on the client beyond the small finalEligiblePayouts fallback.


619-622: Table pagination integration is wired correctly

Passing pagination, onPaginationChange: setPagination, and rowCount: eligiblePayoutsCount?.count ?? 0 into the Table component matches how you’ve set up useTablePagination and the count endpoint. The resourceName callback is also a nice touch for consistent empty/loading copy.


744-746: Min-invoice tooltip text correctly mirrors server validation

The disabled tooltip branch:

) : amount && amount < INVOICE_MIN_PAYOUT_AMOUNT_CENTS ? (
  "Your payout total is less than the minimum invoice amount of $10."
) : (

matches the server-side INVOICE_MIN_PAYOUT_AMOUNT_CENTS guard in confirmPayoutsAction, so users get a clear, pre-submit explanation of why they can’t confirm. Good reuse of the shared constant to avoid drift between UI and backend behavior.

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

🧹 Nitpick comments (4)
apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts (2)

63-83: Audit‑log actor fallback looks good; consider explicit actor.type for system events

Using actor: { id: payout.userId ?? "system" } is a solid improvement over the earlier non‑null assertion and makes this batch more tolerant of missing userId. One small enhancement to keep analytics and querying clean would be to set an explicit actor.type when you fall back to "system", e.g. "system" or "cron", instead of relying on the default "user" in transformAuditLogTB.


90-151: Align error log message with this route’s responsibility

In the catch block, the log message is "Error sending Stripe payout", but this route is only handling audit‑log writes, partner payout emails, and QStash pagination for updates. Renaming this message to something like "Error processing payout updates" would make debugging clearer.

apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (2)

247-258: Consider adding a comment for clarity and consistency

The QStash TypeScript client's publishJSON uses POST by default when method is omitted, so the code is correct. To improve clarity and maintain parity with the explicit method: "POST" in the updates route, consider adding a brief comment like // QStash defaults to POST above or alongside the qstash.publishJSON call.


158-203: Optional robustness improvements confirmed by Stripe API documentation

Stripe's FX Quote API returns a rates object keyed by the fromCurrency with an exchange_rate field, so the current code is safe when the API succeeds. However, the two robustness suggestions remain worthwhile:

  1. Add a guard checking that currency exists in fxQuote.rates before accessing exchange_rate (line ~178)
  2. Use a typed predicate at line ~175: if (paymentMethod.type in nonUsdPaymentMethodTypes) instead of Object.keys(nonUsdPaymentMethodTypes).includes(paymentMethod.type) for better type safety

These are optional improvements to harden the code against edge cases and improve maintainability.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 205628c and f93760e.

📒 Files selected for processing (4)
  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (6 hunks)
  • apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts (1 hunks)
  • apps/web/lib/actions/partners/confirm-payouts.ts (3 hunks)
  • apps/web/lib/api/audit-logs/record-audit-log.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/lib/actions/partners/confirm-payouts.ts
🧰 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.
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.
Learnt from: TWilson023
Repo: dubinc/dub PR: 2872
File: apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx:180-189
Timestamp: 2025-09-24T15:50:16.414Z
Learning: TWilson023 prefers to keep security vulnerability fixes separate from refactoring PRs when the vulnerable code is existing and was only moved/relocated rather than newly introduced.
📚 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/app/(ee)/api/cron/payouts/process/updates/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:

  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
🧬 Code graph analysis (2)
apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts (6)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/lib/api/audit-logs/record-audit-log.ts (1)
  • recordAuditLog (47-74)
packages/email/src/index.ts (1)
  • sendBatchEmail (31-70)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (7-22)
packages/email/src/templates/partner-payout-confirmed.tsx (1)
  • PartnerPayoutConfirmed (18-147)
apps/web/lib/api/errors.ts (1)
  • handleAndReturnErrorResponse (162-165)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (4)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/lib/constants/payouts.ts (1)
  • FOREX_MARKUP_RATE (7-7)
apps/web/lib/stripe/index.ts (1)
  • stripe (4-10)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (7-22)
⏰ 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 (1)

60-97: ****

The concern raised in the original review is incorrect. getPayoutEligibilityFilter explicitly enforces invoiceId: null in all payout modes (internal, external, and hybrid) as part of its commonWhere filter that is spread into every branch. This guarantee means already-invoiced payouts cannot be reassigned to a new invoice. The suggested diff to add explicit invoiceId: null at the call site is unnecessary.

Likely an incorrect or invalid review comment.

apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts (1)

86-120: Non-null assertion is safe due to intentional sendBatchEmail filtering pattern

The Partner model's email field is nullable (email String?), but the review comment overlooks how this codebase is designed: sendBatchEmail (in packages/email/src/send-via-resend.ts) intentionally filters out entries with null/undefined to addresses internally. Call sites safely use non-null assertions because the email sending layer handles null values before sending.

apps/web/lib/api/audit-logs/record-audit-log.ts (1)

58-74: All call sites are compatible with the return type change—no fixes needed

Verified all 40+ call sites of recordAuditLog: the vast majority use fire-and-forget pattern (no await), which is universally compatible with any Promise type. The three call sites that do await are all compatible—two don't capture the result, and one logs it (JSON-serializable with no issues). No call site has type annotations expecting void, so the change from Promise<void> to Promise<IngestResult | undefined> is fully backward compatible and safe.

@steven-tey steven-tey merged commit 374e201 into main Nov 18, 2025
7 of 8 checks passed
@steven-tey steven-tey deleted the confirm-payouts-pagination branch November 18, 2025 02:44
@coderabbitai coderabbitai bot mentioned this pull request Nov 24, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants