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

Skip to content

Conversation

@steven-tey
Copy link
Collaborator

@steven-tey steven-tey commented Nov 17, 2025

Summary by CodeRabbit

  • New Features
    • Payouts now store an initiation timestamp and include it in audit records for clearer processing history.
  • UI Improvements
    • Dashboard and payout lists/details show "Initiated" and "Paid" timestamps with contextual tooltips and smart formatting.
  • Chores
    • Migration backfilled historical initiation timestamps; some small auto-processed payouts now record completion timestamps when finalized automatically.

@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 17, 2025 3:18am

@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 17 minutes and 8 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 aeb42f1 and 5c53c38.

๐Ÿ“’ Files selected for processing (2)
  • apps/web/lib/webhook/sample-events/payout-confirmed.json (1 hunks)
  • apps/web/tests/webhooks/index.test.ts (1 hunks)

Walkthrough

Adds an optional initiatedAt timestamp to Payout records, sets initiatedAt when payouts move to processing (and includes it in audit log targets), provides a backfill migration to populate existing nulls, and surfaces initiated/paid timestamps in multiple payout UIs.

Changes

Cohort / File(s) Change Summary
Prisma schema
packages/prisma/schema/payout.prisma
Adds optional initiatedAt (DateTime?) to Payout and moves userId declaration position.
Payout processing (cron)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
When marking internal/external payouts processing, sets initiatedAt: new Date() and includes initiatedAt in audit log payloads and target metadata.
Backfill migration
apps/web/scripts/migrations/backfill-payout-initiated-at.ts
New migration: finds partnerPayout invoices with payouts missing initiatedAt and updates those payouts to the invoice createdAt; logs counts.
Server payout logic
apps/web/lib/partners/create-stripe-transfer.ts
When skipping transfers due to low amount (and not forcing), marks current payouts status: "processed" and sets paidAt: new Date().
Zod schema
apps/web/lib/zod/schemas/payouts.ts
Adds initiatedAt: z.date().nullable() to the public Payout schema.
Dashboard UI โ€” partner
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-details-sheet.tsx, apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx, apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx
Surfaces initiatedAt and paidAt with TimestampTooltip and smart date formatting; adds an "Initiated" column/cell and conditional tooltip rendering.
Dashboard UI โ€” app.dub.co
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-details-sheet.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx
Replaces Total with Initiated/Paid/Amount cells, uses initiatedAt to drive Paid tooltip content and compact display, adds icons/tooltips, layout adjustments, and includes slug in useMemo deps.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Cron as Cron job
  participant DB as Database (Payout / Invoice)
  participant Audit as Audit service
  participant UI as Dashboard UI

  Cron->>DB: Query eligible payouts (internal/external)
  Cron->>DB: Update payout.status -> processing\nset initiatedAt = now()
  Cron->>Audit: Create audit log with target metadata (includes initiatedAt)
  DB-->>Cron: Updated payout rows
  Note right of UI `#dfe7ff`: UIs read `initiatedAt` / `paidAt` and\nrender via TimestampTooltip
  UI->>DB: Fetch payouts -> render Initiated / Paid cells/tooltips
Loading

Estimated code review effort

๐ŸŽฏ 3 (Moderate) | โฑ๏ธ ~25 minutes

  • Files needing extra attention:
    • process-payouts.ts: ensure every update branch sets initiatedAt consistently and audit payload shape matches consumers.
    • backfill-payout-initiated-at.ts: validate idempotency, selection limits, and production safety.
    • packages/prisma/schema/payout.prisma: confirm migration ordering and that the userId repositioning is intentional.
    • UI components: verify tooltip accessibility and consistent imports of TimestampTooltip and date utilities.

Possibly related PRs

  • Add Instant payouts featureย #2984 โ€” Modifies payout transfer logic in apps/web/lib/partners/create-stripe-transfer.ts; related due to overlapping low-amount/forceWithdrawal and paidAt handling.
  • Add timestamp tooltipย #2732 โ€” Introduced TimestampTooltip usage and related UI/time formatting changes; closely tied to how initiatedAt/paidAt are surfaced.
  • Improve payout success stateย #2695 โ€” Prior edits to payout processing/invoice update logic; affects the same cron processing path and payload shapes.

Suggested reviewers

  • devkiran
  • TWilson023

Poem

๐Ÿ‡ I hopped through ledgers, keen and spry,

Stamped the moment payouts learned to fly,
Initiated first, then paid on cue,
Tiny timestamps, tidy and true.
๐Ÿฅ•

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 accurately describes the main change: adding a new initiatedAt column to display payout initiation timestamps in the Payout table UI.

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: 1

๐Ÿงน Nitpick comments (1)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (1)

338-363: Consider using a shared timestamp for consistency.

The audit log creates a new new Date() at line 357, which may differ slightly from the timestamps recorded in the database at lines 286 and 304. While the difference is minimal, it could cause minor inconsistencies in audit trails.

Consider capturing a single timestamp before the updates:

+ const initiatedAt = new Date();
+
  // Mark internal payouts as processing
  if (internalPayouts.length > 0) {
    await prisma.payout.updateMany({
      where: {
        id: {
          in: internalPayouts.map((p) => p.id),
        },
      },
      data: {
        invoiceId: invoice.id,
        status: "processing",
        userId,
-       initiatedAt: new Date(),
+       initiatedAt,
        mode: "internal",
      },
    });
  }

  // Mark external payouts as processing
  if (externalPayouts.length > 0) {
    await prisma.payout.updateMany({
      where: {
        id: {
          in: externalPayouts.map((p) => p.id),
        },
      },
      data: {
        invoiceId: invoice.id,
        status: "processing",
        userId,
-       initiatedAt: new Date(),
+       initiatedAt,
        mode: "external",
      },
    });
  }

  // ... later in audit log ...
  
  await recordAuditLog(
    payouts.map((payout) => ({
      workspaceId: workspace.id,
      programId: program.id,
      action: "payout.confirmed",
      description: `Payout ${payout.id} confirmed`,
      actor: userWhoInitiatedPayout ?? {
        id: userId,
        name: "Unknown user",
      },
      targets: [
        {
          type: "payout",
          id: payout.id,
          metadata: {
            ...payout,
            invoiceId: invoice.id,
            status: "processing",
            userId,
-           initiatedAt: new Date(),
+           initiatedAt,
            mode: externalPayoutsMap.has(payout.id) ? "external" : "internal",
          },
        },
      ],
    })),
  );

This ensures all payouts and audit logs share the exact same initiation timestamp.

Based on learnings

๐Ÿ“œ Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

๐Ÿ“ฅ Commits

Reviewing files that changed from the base of the PR and between c0ce7f9 and 5531b5a.

๐Ÿ“’ Files selected for processing (3)
  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (3 hunks)
  • apps/web/scripts/migrations/backfill-payout-initiated-at.ts (1 hunks)
  • packages/prisma/schema/payout.prisma (1 hunks)
๐Ÿงฐ Additional context used
๐Ÿง  Learnings (1)
๐Ÿ“š Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.

Applied to files:

  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts
๐Ÿงฌ Code graph analysis (1)
apps/web/scripts/migrations/backfill-payout-initiated-at.ts (1)
packages/prisma/index.ts (1)
  • prisma (3-9)
โฐ 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/scripts/migrations/backfill-payout-initiated-at.ts (1)

14-15: Verify batch processing strategy for complete migration.

The script limits processing to 10 invoices per run. If there are more than 10 invoices with payouts needing backfill, this script will need to be run multiple times manually.

Consider one of these approaches:

  1. If manual batch runs are intended, document this in a comment
  2. Add a loop to process all invoices in batches:
 async function main() {
+  let processedCount = 0;
+  let hasMore = true;
+
+  while (hasMore) {
     const invoices = await prisma.invoice.findMany({
       where: {
         type: "partnerPayout",
         payouts: {
           some: {
             initiatedAt: null,
           },
         },
       },
       take: 10,
     });
+
+    if (invoices.length === 0) {
+      hasMore = false;
+      break;
+    }

     for (const invoice of invoices) {
       const res = await prisma.payout.updateMany({
         where: {
           invoiceId: invoice.id,
         },
         data: {
           initiatedAt: invoice.createdAt,
         },
       });
       console.log(`Updated ${res.count} payouts for invoice ${invoice.id}`);
+      processedCount += res.count;
     }
+  }
+
+  console.log(`Migration complete. Total payouts updated: ${processedCount}`);
 }
packages/prisma/schema/payout.prisma (1)

35-37: LGTM! Schema changes are well-structured.

The addition of initiatedAt and reintroduction of userId are properly implemented:

  • Both fields are nullable, maintaining backward compatibility with existing records
  • The userId field has a proper relation to the User model
  • An index on userId (line 50) ensures efficient queries
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (2)

276-289: LGTM! Correctly sets initiation timestamp for internal payouts.

The initiatedAt field is appropriately set when marking payouts as processing. The update includes all necessary fields: invoiceId, status, userId, initiatedAt, and mode.


294-308: LGTM! Correctly sets initiation timestamp for external payouts.

The initiatedAt field is appropriately set for external payouts, mirroring the internal payout logic.

Note: The new Date() call at line 304 is separate from line 286, so internal and external payouts may have slightly different timestamps (milliseconds apart). This is acceptable since they are processed in separate database operations.

@steven-tey
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 17, 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/scripts/migrations/backfill-payout-initiated-at.ts (1)

14-14: Consider removing the take: 10 limit or adding batch processing logic.

The script only processes 10 invoices per execution, requiring multiple manual runs to backfill all records. For a migration script, consider either removing the limit to process all records in one run, or implementing a loop with pagination to automatically process all invoices in batches.

Example with pagination:

 async function main() {
-  const invoices = await prisma.invoice.findMany({
-    where: {
-      type: "partnerPayout",
-      payouts: {
-        some: {
-          initiatedAt: null,
-        },
-      },
-    },
-    take: 10,
-  });
-
-  for (const invoice of invoices) {
-    const res = await prisma.payout.updateMany({
-      where: {
-        invoiceId: invoice.id,
-      },
-      data: {
-        initiatedAt: invoice.createdAt,
-      },
-    });
-    console.log(`Updated ${res.count} payouts for invoice ${invoice.id}`);
+  let totalUpdated = 0;
+  let hasMore = true;
+  
+  while (hasMore) {
+    const invoices = await prisma.invoice.findMany({
+      where: {
+        type: "partnerPayout",
+        payouts: {
+          some: {
+            initiatedAt: null,
+          },
+        },
+      },
+      take: 10,
+    });
+    
+    if (invoices.length === 0) {
+      hasMore = false;
+      break;
+    }
+    
+    for (const invoice of invoices) {
+      const res = await prisma.payout.updateMany({
+        where: {
+          invoiceId: invoice.id,
+        },
+        data: {
+          initiatedAt: invoice.createdAt,
+        },
+      });
+      totalUpdated += res.count;
+      console.log(`Updated ${res.count} payouts for invoice ${invoice.id}`);
+    }
   }
+  
+  console.log(`Migration complete. Total payouts updated: ${totalUpdated}`);
 }
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (1)

286-286: Consider using a single timestamp variable for consistency.

The initiatedAt field is set using new Date() at three different points (lines 286, 304, 357), which could result in slightly different timestamps. For better consistency and accuracy, consider capturing the timestamp once at the beginning of the operation.

Example:

+ const initiatedAt = new Date();
+
  // Mark internal payouts as processing
  if (internalPayouts.length > 0) {
    await prisma.payout.updateMany({
      where: {
        id: {
          in: internalPayouts.map((p) => p.id),
        },
      },
      data: {
        invoiceId: invoice.id,
        status: "processing",
        userId,
-       initiatedAt: new Date(),
+       initiatedAt,
        mode: "internal",
      },
    });
  }

  // Mark external payouts as processing
  if (externalPayouts.length > 0) {
    await prisma.payout.updateMany({
      where: {
        id: {
          in: externalPayouts.map((p) => p.id),
        },
      },
      data: {
        invoiceId: invoice.id,
        status: "processing",
        userId,
-       initiatedAt: new Date(),
+       initiatedAt,
        mode: "external",
      },
    });
  }
  
  // ... later in audit log
  await recordAuditLog(
    payouts.map((payout) => ({
      // ...
      targets: [
        {
          type: "payout",
          id: payout.id,
          metadata: {
            ...payout,
            invoiceId: invoice.id,
            status: "processing",
            userId,
-           initiatedAt: new Date(),
+           initiatedAt,
            mode: externalPayoutsMap.has(payout.id) ? "external" : "internal",
          },
        },
      ],
    })),
  );

Also applies to: 304-304, 357-357

packages/prisma/schema/payout.prisma (1)

37-37: Consider adding an index if filtering by initiatedAt is planned.

While not currently needed based on the provided code, if you plan to add queries that filter or sort by initiatedAt in the future, consider adding an index.

Example:

  @@index(programId)
  @@index(partnerId)
  @@index(invoiceId)
  @@index(status)
  @@index(userId)
+ @@index(initiatedAt)
๐Ÿ“œ Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

๐Ÿ“ฅ Commits

Reviewing files that changed from the base of the PR and between c0ce7f9 and 5531b5a.

๐Ÿ“’ Files selected for processing (3)
  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (3 hunks)
  • apps/web/scripts/migrations/backfill-payout-initiated-at.ts (1 hunks)
  • packages/prisma/schema/payout.prisma (1 hunks)
๐Ÿ”‡ Additional comments (2)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (1)

358-358: LGTM: Mode determination is correct.

The logic correctly determines the payout mode by checking if the payout ID exists in the externalPayoutsMap, which was built from the externalPayouts array on line 336.

packages/prisma/schema/payout.prisma (1)

35-37: LGTM: Schema changes are correct and backward compatible.

The addition of the optional initiatedAt field is properly implemented as nullable, ensuring backward compatibility with existing records. The repositioning of the userId field is a cosmetic change with no functional impact.

@steven-tey
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 17, 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

Caution

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

โš ๏ธ Outside diff range comments (2)
apps/web/lib/partners/create-stripe-transfer.ts (1)

57-86: Remove paidAt from "processed" payouts that haven't been paid yet.

Setting paidAt at line 71 for payouts with status "processed" is semantically incorrect. These payouts have not been paid out yetโ€”they're merely marked as processed because they're below the minimum withdrawal threshold. They will be accumulated with future payouts and only actually paid when the combined total exceeds MIN_WITHDRAWAL_AMOUNT_CENTS.

Only payouts with status "sent" (line 141) should receive a paidAt timestamp, as those represent actual fund transfers.

Apply this diff to remove the incorrect paidAt assignment:

         data: {
           status: "processed",
-          paidAt: new Date(),
         },
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx (1)

144-223: Handle payouts with paidAt but missing initiatedAt, and align Paid column semantics

Right now the "Paid" cell returns "-" whenever initiatedAt is null, even if paidAt is set, and the column is still sorted by paidAt while visually showing initiatedAt. That can hide useful data for legacy payouts and make the sort order feel inconsistent with the displayed date.

You can make the cell more robust by:

  • Treating the presence of either initiatedAt or paidAt as enough to render the tooltip.
  • Only rendering the "Payment initiated at" row when initiatedAt exists.
  • Using initiatedAt ?? paidAt for the short date in the trigger.

For example:

-      {
-          header: "Paid",
-          cell: ({ row }) => {
-            const ProcessingIcon = PayoutStatusBadges.processing.icon;
-            const CompletedIcon = PayoutStatusBadges.completed.icon;
-
-            return row.original.initiatedAt ? (
+      {
+        header: "Paid",
+        cell: ({ row }) => {
+          const ProcessingIcon = PayoutStatusBadges.processing.icon;
+          const CompletedIcon = PayoutStatusBadges.completed.icon;
+
+          const { initiatedAt, paidAt, user } = row.original;
+          const hasTimestamp = initiatedAt || paidAt;
+
+          if (!hasTimestamp) {
+            return "-";
+          }
+
+          return (
             <Tooltip
               content={
                 <div className="flex flex-col gap-1 p-2.5">
-                    {row.original.user && (
+                  {user && (
                     <div className="flex flex-col gap-2">
                       <img
                         src={
-                            row.original.user.image ||
-                            `${OG_AVATAR_URL}${row.original.user.name}`
+                          user.image || `${OG_AVATAR_URL}${user.name}`
                         }
-                          alt={row.original.user.name ?? row.original.user.id}
+                        alt={user.name ?? user.id}
                         className="size-6 shrink-0 rounded-full"
                       />
                       <p className="text-sm font-medium">
-                          {row.original.user.name}
+                        {user.name}
                       </p>
                     </div>
                   )}
-                    <div className="flex items-center gap-1.5 text-xs text-neutral-500">
-                      <ProcessingIcon className="size-3 shrink-0 text-blue-600" />
-                      <span>
-                        Payment initiated at{" "}
-                        <span className="font-medium text-neutral-700">
-                          {formatDateTime(row.original.initiatedAt, {
-                            month: "short",
-                            day: "numeric",
-                            year: "numeric",
-                            hour: "numeric",
-                            minute: "numeric",
-                          })}
-                        </span>
-                      </span>
-                    </div>
-                    {row.original.paidAt && (
-                      <div className="flex items-center gap-1.5 text-xs text-neutral-500">
-                        <CompletedIcon className="size-3 shrink-0 text-green-600" />
-                        <span>
-                          Payment completed at{" "}
-                          <span className="font-medium text-neutral-700">
-                            {formatDateTime(row.original.paidAt, {
-                              month: "short",
-                              day: "numeric",
-                              year: "numeric",
-                              hour: "numeric",
-                              minute: "numeric",
-                            })}
-                          </span>
-                        </span>
-                      </div>
-                    )}
+                  {initiatedAt && (
+                    <div className="flex items-center gap-1.5 text-xs text-neutral-500">
+                      <ProcessingIcon className="size-3 shrink-0 text-blue-600" />
+                      <span>
+                        Payment initiated at{" "}
+                        <span className="font-medium text-neutral-700">
+                          {formatDateTime(initiatedAt, {
+                            month: "short",
+                            day: "numeric",
+                            year: "numeric",
+                            hour: "numeric",
+                            minute: "numeric",
+                          })}
+                        </span>
+                      </span>
+                    </div>
+                  )}
+                  {paidAt && (
+                    <div className="flex items-center gap-1.5 text-xs text-neutral-500">
+                      <CompletedIcon className="size-3 shrink-0 text-green-600" />
+                      <span>
+                        Payment completed at{" "}
+                        <span className="font-medium text-neutral-700">
+                          {formatDateTime(paidAt, {
+                            month: "short",
+                            day: "numeric",
+                            year: "numeric",
+                            hour: "numeric",
+                            minute: "numeric",
+                          })}
+                        </span>
+                      </span>
+                    </div>
+                  )}
                 </div>
               }
             >
               <div className="flex items-center gap-2">
-                  {row.original.user && (
+                {user && (
                   <img
                     src={
-                        row.original.user.image ||
-                        `${OG_AVATAR_URL}${row.original.user.name}`
+                      user.image || `${OG_AVATAR_URL}${user.name}`
                     }
-                      alt={row.original.user.name ?? row.original.user.id}
+                    alt={user.name ?? user.id}
                     className="size-5 shrink-0 rounded-full"
                   />
                 )}
-                  {formatDate(row.original.initiatedAt, {
+                {formatDate(initiatedAt ?? paidAt, {
                   month: "short",
                   year: undefined,
                 })}
               </div>
             </Tooltip>
-            ) : (
-              "-"
-            );
-          },
-        },
+          );
+        },
+      },

You may also want to revisit whether this "Paid" column should sort by initiatedAt instead of paidAt, to better match the primary date being displayed.

๐Ÿงน Nitpick comments (1)
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx (1)

15-16: Initiated/Paid columns for partner payouts are well-structured

The new "Initiated" column and the updated "Paid" column use TimestampTooltip + formatDateSmart consistently, with sensible header tooltips and "-" fallbacks when timestamps are absent. If you later want to sort by initiation time, you could consider adding initiatedAt to the sortable columns and backing API schema, but the current behavior is coherent as-is.

Also applies to: 25-27, 97-118, 120-139

๐Ÿ“œ Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

๐Ÿ“ฅ Commits

Reviewing files that changed from the base of the PR and between 5531b5a and aeb42f1.

๐Ÿ“’ Files selected for processing (7)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-details-sheet.tsx (4 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx (3 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-details-sheet.tsx (7 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx (3 hunks)
  • apps/web/lib/partners/create-stripe-transfer.ts (1 hunks)
  • apps/web/lib/zod/schemas/payouts.ts (1 hunks)
๐Ÿงฐ Additional context used
๐Ÿง  Learnings (4)
๐Ÿ“š Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.

Applied to files:

  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx
  • apps/web/lib/partners/create-stripe-transfer.ts
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-details-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/lib/partners/create-stripe-transfer.ts
๐Ÿ“š Learning: 2025-10-15T01:05:43.266Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.266Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-details-sheet.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx
๐Ÿ“š Learning: 2025-05-29T04:45:18.504Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2448
File: packages/email/src/templates/partner-program-summary.tsx:0-0
Timestamp: 2025-05-29T04:45:18.504Z
Learning: In the PartnerProgramSummary email template (packages/email/src/templates/partner-program-summary.tsx), the stat titles are hardcoded constants ("Clicks", "Leads", "Sales", "Earnings") that will always match the ICONS object keys after toLowerCase() conversion, so icon lookup failures are not possible.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx
๐Ÿงฌ Code graph analysis (5)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx (1)
packages/ui/src/timestamp-tooltip.tsx (1)
  • TimestampTooltip (28-50)
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-details-sheet.tsx (2)
packages/ui/src/timestamp-tooltip.tsx (1)
  • TimestampTooltip (28-50)
packages/ui/src/tooltip.tsx (1)
  • Tooltip (64-115)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-details-sheet.tsx (5)
packages/ui/src/timestamp-tooltip.tsx (1)
  • TimestampTooltip (28-50)
packages/ui/src/tooltip.tsx (1)
  • Tooltip (64-115)
packages/prisma/client.ts (1)
  • PayoutStatus (27-27)
packages/ui/src/icons/nucleo/circle-arrow-right.tsx (1)
  • CircleArrowRight (3-43)
apps/web/lib/constants/payouts.ts (1)
  • INVOICE_AVAILABLE_PAYOUT_STATUSES (98-102)
apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx (1)
packages/ui/src/timestamp-tooltip.tsx (1)
  • TimestampTooltip (28-50)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx (1)
packages/ui/src/tooltip.tsx (1)
  • Tooltip (64-115)
โฐ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
๐Ÿ”‡ Additional comments (7)
apps/web/lib/partners/create-stripe-transfer.ts (1)

132-156: No issues found. The current implementation is correct.

The review comment questions whether initiatedAt should be set when payouts transition to "sent" status. However, verification shows this concern is unfounded:

  • initiatedAt is intentionally set only once when payouts enter the "processing" status (in process-payouts.ts)
  • paidAt is correctly set when payouts transition to "sent" status (actual payment time)
  • These fields serve distinct semantic purposes: initiatedAt marks process initiation, while paidAt marks actual payment completion
  • The schema defines both as nullable DateTime? fields, allowing for historical data without these timestamps
  • This design pattern is consistent across all payout transitions in the codebase

No changes are needed.

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

53-67: initiatedAt added consistently to payout Zod schema

The new initiatedAt: z.date().nullable() field is consistent with the other nullable timestamps (periodStart, periodEnd, paidAt) and with the intended optional nature of the initiation time.

apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx (1)

8-10: Partner payouts card initiated/paid tooltip UI looks solid

The conditional rendering for paidAt / initiatedAt with TimestampTooltip and formatDateSmart is clear, matches other payout UIs, and keeps the old formatPeriod(payout) fallback for cases with no timestamps.

Also applies to: 70-98

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

21-32: Initiated/Paid values in partner payout details are wired correctly

Using TimestampTooltip plus formatDateTimeSmart for payout.initiatedAt / payout.paidAt, with a simple "-" fallback, matches patterns elsewhere and safely handles missing timestamps.

Also applies to: 87-113


245-262: Helpful tooltips on Initiated/Paid labels

Conditionally wrapping the "Initiated" and "Paid" labels in Tooltip with clear descriptions improves clarity without impacting other fields that still render as plain labels.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-details-sheet.tsx (2)

1-4: Program payout invoice data (Initiated/Paid/Amount) is wired cleanly

The expanded invoiceData with Initiated/Paid using TimestampTooltip + formatDateTimeSmart, and the richer Amount cell (external payout tooltip, internal invoice icon with APP_DOMAIN URL, and fallback from invoiceId to payout.id) all look consistent and resilient to missing fields.

Also applies to: 13-27, 29-38, 67-169


270-285: Sticky header/footer for payout details sheet improve UX

The sticky header with a status-based title and the sticky footer that switches between "Confirm payout" (pending) and "View all" behaviors are wired correctly through queryParams and make the sheet easier to use without introducing routing edge cases.

Also applies to: 320-340

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

Caution

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

โš ๏ธ Outside diff range comments (3)
apps/web/lib/partners/create-stripe-transfer.ts (1)

63-73: Remove paidAt: new Date() from line 71 or reconsider the field being set.

The semantic error is confirmed. When payouts are marked "processed" on line 71, they are queued below the minimum withdrawal thresholdโ€”they have not been paid or sent. Setting paidAt at this point contradicts the established pattern throughout the codebase where paidAt is only set when payouts transition to "sent" (after successful Stripe transfer) or "completed" (after webhook confirmation). The initiatedAt field is properly set when payouts first become "processing" status (in process-payouts.ts), so no timestamp is needed when they transition to "processed" status.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx (2)

233-233: Sorting column mismatch with displayed data.

The "Paid" column displays initiatedAt (line 214) but is sortable by paidAt (line 233). This creates a UX inconsistency where users see initiated timestamps but sorting is based on paid timestamps.

Consider one of these approaches:

  1. Sort by initiatedAt to match the displayed data:
-      sortableColumns: ["periodEnd", "amount", "paidAt"],
+      sortableColumns: ["periodEnd", "amount", "initiatedAt"],
  1. Or add both as sortable columns if users need both options:
-      sortableColumns: ["periodEnd", "amount", "paidAt"],
+      sortableColumns: ["periodEnd", "amount", "initiatedAt", "paidAt"],

149-222: Add initiatedAt to all payout creation paths.

Two production payout creation routes are missing initiatedAt, causing new payouts to display "-" instead of payment status:

  • apps/web/app/(ee)/api/cron/payouts/process/split-payouts.ts:87 โ€” add initiatedAt: new Date() to the payout data object
  • apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts:175 โ€” add initiatedAt: new Date() to the payout data object

The backfill migration handles existing payouts, but these routes must set initiatedAt for all future payouts to prevent breaking the UI.

โ™ป๏ธ Duplicate comments (1)
apps/web/scripts/migrations/backfill-payout-initiated-at.ts (1)

1-30: Add error handling and ensure Prisma disconnects after the migration

The script currently calls main() directly without handling failures or closing the Prisma connection. That can hide migration errors and leave connections open in some environments.

Consider wrapping the entrypoint with error handling and disconnect logic:

 async function main() {
   const invoices = await prisma.invoice.findMany({
@@
   for (const invoice of invoices) {
@@
     console.log(`Updated ${res.count} payouts for invoice ${invoice.id}`);
   }
 }

-main();
+main()
+  .catch((error) => {
+    console.error("Migration failed:", error);
+    process.exit(1);
+  })
+  .finally(async () => {
+    await prisma.$disconnect();
+  });
๐Ÿงน Nitpick comments (2)
apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (1)

274-307: Reuse a single initiatedAt timestamp for DB updates and audit log

Youโ€™re calling new Date() three times (internal update, external update, audit metadata). While functionally fine, capturing it once improves consistency and makes it clear that all records/logs share the same initiation instant.

For example:

-  // Mark internal payouts as processing
-  if (internalPayouts.length > 0) {
-    await prisma.payout.updateMany({
+  const initiatedAt = new Date();
+
+  // Mark internal payouts as processing
+  if (internalPayouts.length > 0) {
+    await prisma.payout.updateMany({
@@
-        userId,
-        initiatedAt: new Date(),
+        userId,
+        initiatedAt,
@@
-  // Mark external payouts as processing
+  // Mark external payouts as processing
   if (externalPayouts.length > 0) {
@@
-        userId,
-        initiatedAt: new Date(),
+        userId,
+        initiatedAt,
@@
-            status: "processing",
-            userId,
-            initiatedAt: new Date(),
+            status: "processing",
+            userId,
+            initiatedAt,
             mode: externalPayoutsMap.has(payout.id) ? "external" : "internal",

Also applies to: 336-359

packages/prisma/schema/payout.prisma (1)

35-37: Consider adding an index for query performance.

The initiatedAt field may benefit from a database index if it will be used for sorting or filtering payouts, similar to how other temporal fields are used in the UI.

Apply this diff to add an index:

   @@index(programId)
   @@index(partnerId)
   @@index(invoiceId)
   @@index(status)
   @@index(userId)
+  @@index(initiatedAt)
 }
๐Ÿ“œ Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

๐Ÿ“ฅ Commits

Reviewing files that changed from the base of the PR and between c0ce7f9 and aeb42f1.

๐Ÿ“’ Files selected for processing (10)
  • apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts (3 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-details-sheet.tsx (4 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx (3 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-details-sheet.tsx (7 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx (3 hunks)
  • apps/web/lib/partners/create-stripe-transfer.ts (1 hunks)
  • apps/web/lib/zod/schemas/payouts.ts (1 hunks)
  • apps/web/scripts/migrations/backfill-payout-initiated-at.ts (1 hunks)
  • packages/prisma/schema/payout.prisma (1 hunks)
๐Ÿงฐ Additional context used
๐Ÿง  Learnings (4)
๐Ÿ“š Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.

Applied to files:

  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/partner-payout-details-sheet.tsx
๐Ÿ“š Learning: 2025-10-15T01:05:43.266Z
Learnt from: steven-tey
Repo: dubinc/dub PR: 2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.266Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.

Applied to files:

  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-details-sheet.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx
๐Ÿ“š Learning: 2025-05-29T04:45:18.504Z
Learnt from: devkiran
Repo: dubinc/dub PR: 2448
File: packages/email/src/templates/partner-program-summary.tsx:0-0
Timestamp: 2025-05-29T04:45:18.504Z
Learning: In the PartnerProgramSummary email template (packages/email/src/templates/partner-program-summary.tsx), the stat titles are hardcoded constants ("Clicks", "Leads", "Sales", "Earnings") that will always match the ICONS object keys after toLowerCase() conversion, so icon lookup failures are not possible.

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.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/lib/partners/create-stripe-transfer.ts
๐Ÿ”‡ Additional comments (6)
apps/web/lib/zod/schemas/payouts.ts (1)

53-67: initiatedAt field wiring in zod schema looks correct

The new initiatedAt: z.date().nullable() slotting between createdAt and paidAt is consistent with the data model and keeps all derived response schemas in sync.

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

15-27: Nice initiated/paid timestamp UX; implementation is solid

The Initiated column and the updated Paid column both correctly guard on nullable timestamps, use TimestampTooltip + formatDateSmart consistently, and degrade cleanly to "-" when absent. Header tooltips read well and match the semantics.

Also applies to: 97-139

apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/payouts-card.tsx (1)

8-9: Clear โ€œPaid/Initiatedโ€ labeling with sensible fallbacks

The conditional rendering (Paid โ†’ Initiated โ†’ period) is intuitive, uses TimestampTooltip and formatDateSmart consistently, and preserves the old formatPeriod fallback when no timestamps are available.

Also applies to: 70-98

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

21-35: Invoice Initiated/Paid fields and label tooltips are well-integrated

The new Initiated/Paid entries correctly handle nullable timestamps, use TimestampTooltip + formatDateTimeSmart for detail, and the per-label tooltips in the invoice details grid add useful context without changing existing layout.

Also applies to: 87-113, 248-262

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-details-sheet.tsx (2)

95-121: Excellent UX improvement with separate Initiated and Paid fields.

The distinct "Initiated" and "Paid" fields with TimestampTooltip provide clear visibility into the payout lifecycle. The hover tooltips showing both local and UTC times are helpful for users in different time zones.


170-170: Correct dependency fix.

Good catch including slug in the dependencies. It's used in the ConditionalLink href on line 73, so this prevents potential stale closure issues.

@steven-tey steven-tey merged commit ffc48d3 into main Nov 17, 2025
7 of 8 checks passed
@steven-tey steven-tey deleted the initiatedat branch November 17, 2025 03:24
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