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

Skip to content

Conversation

@steven-tey
Copy link
Collaborator

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

Summary by CodeRabbit

  • Refactor

    • Payout generation now groups pending commissions by program and partner, consolidating and reusing pending payouts so totals and period ranges stay accurate.
    • Processing iterates grouped commissions to compute payout periods and update amounts atomically.
  • Bug Fixes

    • Cancels commissions for banned partners.
    • Honors holding periods and includes clawbacks in calculations.
    • API response now reports "Commissions payout created."

@vercel
Copy link
Contributor

vercel bot commented Oct 6, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Oct 6, 2025 10:12pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 6, 2025

Walkthrough

Removed the standalone createPayout implementation and moved payout grouping, filtering (banned partners, holding period), creation/reuse, assignment, and aggregation into the route handler which now processes pending commissions grouped by programId and partnerId.

Changes

Cohort / File(s) Summary
Removal: Legacy payout creator
apps/web/app/(ee)/api/cron/payouts/create-payout.ts
Deleted the exported createPayout function and its full payout-consolidation workflow (enrollment lookup, banned-partner handling, holding-period filters, commission/clawback selection, transactional creation/updates, and logging).
Consolidation: Route-based payout aggregation
apps/web/app/(ee)/api/cron/payouts/route.ts
Reworked route to group pending commissions by programId/partnerId, cancel for banned partners, apply holding-period/custom rules, compute periodStart/periodEnd (end-of-month), create or reuse pending payouts (createId), assign commissions to payouts, re-aggregate/update payout amounts, and emit logs.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Cron as CRON
  participant Route as Payouts Route (route.ts)
  participant DB as Database

  Cron->>Route: Trigger payouts cron
  Route->>DB: Query pending commissions grouped by programId, partnerId
  Route->>DB: Query program enrollments / banned partners

  alt Partner banned
    Route->>DB: Update matching pending commissions -> canceled
    Route-->>Cron: Log cancellations
  else Partner eligible
    loop per (programId, partnerId)
      Route->>DB: Apply holding-period filter & select commissions/clawbacks
      Route->>DB: Find existing pending payout (program, partner)
      alt Existing pending payout
        Route->>DB: Assign commissions -> set payoutId, mark processed
        Route->>DB: Re-aggregate and update payout.amount and periodEnd
      else No existing payout
        Route->>DB: Create payout (id=createId, periodStart, periodEnd=endOfMonth)
        Route->>DB: Assign commissions -> set payoutId, mark processed
        Route->>DB: Aggregate and set payout.amount
      end
      Route-->>Cron: Log created/updated payout
    end
  end
  note over Route,DB #D8EEF8: PeriodStart = earliest commission createdAt<br/>PeriodEnd = end-of-month of latest commission
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • devkiran

Poem

In tunnels warm I count each hop,
I group the carrots, never stop.
If one is barred, I let it drop—
Month’s moon ends, the totals pop. 🥕🌕

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title “Optimize /cron/payouts” succinctly captures the primary change in this pull request, which focuses on improving the performance and structure of the /cron/payouts workflow. It is clear, concise, and directly references the affected endpoint without unnecessary detail. Team members scanning the commit history will immediately understand the core purpose of these updates.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch optimize-payouts-cron

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.

@steven-tey
Copy link
Collaborator Author

@coderabbitai do a full review please – do you see any discrepancies from our old approach?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 6, 2025

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

# Get PR details including the diff
gh pr view 2939 --json title,body,files,additions,deletions

# Get the diff for the PR
gh pr diff 2939

# List all changed files
gh pr view 2939 --json files --jq '.files[].path'

Length of output: 13800


@steven-tey: I'll do a full review comparing the old and new approaches. I've identified several significant discrepancies:

Critical Issues

1. Commission Status Not Updated ⚠️

Old approach:

await tx.commission.updateMany({
  where: { id: { in: allCommissions.map(({ id }) => id) } },
  data: {
    status: "processed",  // ← Status was changed
    payoutId: payout.id,
  },
});

New approach (Line 192-199):

const updatedCommissions = await prisma.commission.updateMany({
  where: { id: { in: commissions.map((c) => c.id) } },
  data: {
    payoutId: payoutToUse.id,  // ← Missing status update!
  },
});

Impact: Commissions remain in "pending" status even after being assigned to a payout. This will cause them to be processed again in the next cron run!

2. No Transaction Wrapper ⚠️

The old approach wrapped all payout operations in prisma.$transaction() to ensure atomicity. The new approach has no transaction, which means:

  • If creating a payout succeeds but updating commissions fails, you'll have orphaned payouts
  • Race conditions possible if the cron runs concurrently
  • No rollback on partial failures

3. Period End Not Updated for Existing Payouts

Old approach (Line 213-220):

await tx.payout.update({
  where: { id: payout.id },
  data: {
    amount: newPayoutAmount,
    periodEnd,  // ← Updated period end
  },
});

New approach (Line 203-214): Only increments amount, doesn't update periodEnd when adding commissions to an existing payout.

Design Changes

4. Clawback Handling Changed

Old: Explicitly fetched clawbacks separately (earnings: { lt: 0 })
New: Relies on type: "custom" to include clawbacks (per comment on Line 100)

This assumes all clawbacks are marked as "custom" type. If there are non-custom clawbacks, they won't be included unless they're past the holding period.

5. Payout Amount Calculation

Old: Used aggregate query to calculate the sum after assignment
New: Calculates in memory before assignment

The new approach is faster but the old approach was more reliable (guaranteed to match what's actually in the database).

6. Batching Strategy

Old: Per partner/program pair in a loop
New: Batch by program, then iterate partners

The new approach is more efficient (fewer DB queries) but loses transactional guarantees.

Minor Issues

7. Typo in Console Log (Line 188)

console.log(
  `Noe existing payout found, created new one ${payoutToUse.id} for partner ${partnerId}`,
  //  ^ Should be "No"
);

Recommendations

  1. Add status update to Line 194:
data: {
  status: "processed",
  payoutId: payoutToUse.id,
}
  1. Wrap in transactions - Either per-program or per-partner to maintain data consistency

  2. Update periodEnd when reusing payouts (Line 206):

data: {
  amount: { increment: totalEarnings },
  periodEnd,  // Add this
}
  1. Fix typo "Noe" → "No"

Would you like me to provide a code example with these fixes?

✅ 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

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 95f04d2 and de44ac5.

📒 Files selected for processing (2)
  • apps/web/app/(ee)/api/cron/payouts/create-payout.ts (0 hunks)
  • apps/web/app/(ee)/api/cron/payouts/route.ts (2 hunks)
💤 Files with no reviewable changes (1)
  • apps/web/app/(ee)/api/cron/payouts/create-payout.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/cron/payouts/route.ts (1)
apps/web/lib/api/create-id.ts (1)
  • createId (66-71)
⏰ 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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (2)
apps/web/app/(ee)/api/cron/payouts/route.ts (2)

36-43: Fix partner aggregation map.

Current reducer overwrites acc[programId] with a single string, so programs with multiple partners either blow up (partnerId.in expects an array) or silently drop partners. Revert to collecting arrays of partnerIds (deduping as needed) before issuing the Prisma query.


70-83: Map banned partners by partnerId.

bannedPartners.map((partner) => partner.id) grabs the join row id, leaving the actual commissions for banned partners untouched. Use partner.partnerId so the update cancels the intended records.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 95f04d2 and de44ac5.

📒 Files selected for processing (2)
  • apps/web/app/(ee)/api/cron/payouts/create-payout.ts (0 hunks)
  • apps/web/app/(ee)/api/cron/payouts/route.ts (2 hunks)
💤 Files with no reviewable changes (1)
  • apps/web/app/(ee)/api/cron/payouts/create-payout.ts

@steven-tey
Copy link
Collaborator Author

@coderabbitai I've addressed all of your feedback, thank you! can you double check now?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 6, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get the latest diff for the PR to see the current state
gh pr diff 2939

# Also get the current file content to verify the fixes
gh pr view 2939 --json files --jq '.files[].path'

Length of output: 13941


@steven-tey: Great progress! I can confirm that you've addressed 3 out of 4 critical issues:

Status Update - Fixed (Line 195)
PeriodEnd Update - Fixed (Line 213)
Typo - Fixed (Line 188)

However, there's still one critical issue remaining:

❌ Missing Transaction Wrapper

The operations are still not wrapped in a transaction. This means if any step fails partway through, you'll have inconsistent data. Specifically, the risk is:

Lines 173-214: Creating/reusing payout → updating commissions → updating payout amount are three separate database operations without atomicity.

What could go wrong:

  • Payout gets created, but commission update fails → orphaned payout with $0
  • Commissions get updated, but payout amount update fails → payout shows wrong total
  • Two cron jobs run simultaneously → race condition, commissions assigned to wrong payout

Recommended fix:

Wrap the per-partner logic in a transaction:

for (const { partnerId, commissions } of partnerIdsToCommissionsArray) {
  await prisma.$transaction(async (tx) => {
    // All your existing logic here, but replace `prisma` with `tx`
    const sortedCommissions = commissions.sort(/* ... */);
    const totalEarnings = sortedCommissions.reduce(/* ... */);
    const periodStart = sortedCommissions[0].createdAt;
    const periodEnd = endOfMonth(/* ... */);

    let payoutToUse = existingPendingPayouts.find(/* ... */);

    if (!payoutToUse) {
      payoutToUse = await tx.payout.create({/* ... */});
    }

    await tx.commission.updateMany({/* ... */});

    if (existingPendingPayouts.find((p) => p.id === payoutToUse.id)) {
      await tx.payout.update({/* ... */});
    }
  });
}

This ensures all operations succeed or fail together. Would you like me to provide the complete updated code block?

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
apps/web/app/(ee)/api/cron/payouts/route.ts (2)

221-237: Fix period boundary logic when reusing payouts.

When appending commissions to an existing payout, the code updates periodEnd to the new value but doesn't account for the existing period boundaries. This can cause reports to show incorrect date ranges:

  1. periodStart is never updated, so if the new batch has earlier commissions, the payout won't reflect them
  2. periodEnd should use MAX(existing, new) logic rather than blindly overwriting, otherwise a new batch from an earlier month could move it backwards

Update the payout logic to properly expand period boundaries:

         // if we're reusing a pending payout, we need to update the amount
         if (existingPendingPayouts.find((p) => p.id === payoutToUse.id)) {
           const updatedPayout = await prisma.payout.update({
             where: {
               id: payoutToUse.id,
             },
             data: {
               amount: {
                 increment: totalEarnings,
               },
-              periodEnd,
+              periodStart: {
+                set: periodStart < payoutToUse.periodStart ? periodStart : payoutToUse.periodStart,
+              },
+              periodEnd: {
+                set: periodEnd > payoutToUse.periodEnd ? periodEnd : payoutToUse.periodEnd,
+              },
             },
           });
           console.log(
             `Since we're reusing payout ${payoutToUse.id}, add the new earnings of ${totalEarnings} to the payout amount, making it ${updatedPayout.amount}`,
           );
         }

191-237: Wrap payout operations in a transaction.

The payout creation/update and commission updates are separate database calls without transaction protection. If any operation fails mid-sequence, you'll end up with orphaned payouts (no commissions assigned), commissions pointing to payouts with incorrect totals, or inconsistent state from concurrent cron runs.

Wrap the per-partner block in prisma.$transaction:

       for (const { partnerId, commissions } of partnerIdsToCommissionsArray) {
-        // sort the commissions by createdAt
-        const sortedCommissions = commissions.sort(
-          (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
-        );
-
-        // sum the earnings of the commissions
-        const totalEarnings = sortedCommissions.reduce(
-          (total, commission) => total + commission.earnings,
-          0,
-        );
-
-        // earliest commission date
-        const periodStart = sortedCommissions[0].createdAt;
-
-        // end of the month of the latest commission date
-        // e.g. if the latest sale is 2024-12-16, the periodEnd should be 2024-12-31
-        const periodEnd = endOfMonth(
-          sortedCommissions[sortedCommissions.length - 1].createdAt,
-        );
-
-        let payoutToUse = existingPendingPayouts.find(
-          (p) => p.partnerId === partnerId,
-        );
-
-        if (!payoutToUse) {
-          payoutToUse = await prisma.payout.create({
-            data: {
-              id: createId({ prefix: "po_" }),
-              programId,
-              partnerId,
-              periodStart,
-              periodEnd,
-              amount: totalEarnings,
-              description: `Dub Partners payout (${program.name})`,
-            },
-          });
-          console.log(
-            `No existing payout found, created new one ${payoutToUse.id} for partner ${partnerId}`,
-          );
-        }
-
-        const updatedCommissions = await prisma.commission.updateMany({
-          where: {
-            id: { in: commissions.map((c) => c.id) },
-          },
-          data: {
-            status: "processed",
-            payoutId: payoutToUse.id,
-          },
-        });
-        console.log(
-          `Updated ${updatedCommissions.count} commissions to have payoutId ${payoutToUse.id}`,
-        );
-
-        // if we're reusing a pending payout, we need to update the amount
-        if (existingPendingPayouts.find((p) => p.id === payoutToUse.id)) {
-          const updatedPayout = await prisma.payout.update({
-            where: {
-              id: payoutToUse.id,
-            },
-            data: {
-              amount: {
-                increment: totalEarnings,
-              },
-              periodEnd,
-            },
-          });
-          console.log(
-            `Since we're reusing payout ${payoutToUse.id}, add the new earnings of ${totalEarnings} to the payout amount, making it ${updatedPayout.amount}`,
-          );
-        }
+        await prisma.$transaction(async (tx) => {
+          // sort the commissions by createdAt
+          const sortedCommissions = commissions.sort(
+            (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
+          );
+
+          // sum the earnings of the commissions
+          const totalEarnings = sortedCommissions.reduce(
+            (total, commission) => total + commission.earnings,
+            0,
+          );
+
+          // earliest commission date
+          const periodStart = sortedCommissions[0].createdAt;
+
+          // end of the month of the latest commission date
+          // e.g. if the latest sale is 2024-12-16, the periodEnd should be 2024-12-31
+          const periodEnd = endOfMonth(
+            sortedCommissions[sortedCommissions.length - 1].createdAt,
+          );
+
+          let payoutToUse = existingPendingPayouts.find(
+            (p) => p.partnerId === partnerId,
+          );
+
+          if (!payoutToUse) {
+            payoutToUse = await tx.payout.create({
+              data: {
+                id: createId({ prefix: "po_" }),
+                programId,
+                partnerId,
+                periodStart,
+                periodEnd,
+                amount: totalEarnings,
+                description: `Dub Partners payout (${program.name})`,
+              },
+            });
+            console.log(
+              `No existing payout found, created new one ${payoutToUse.id} for partner ${partnerId}`,
+            );
+          }
+
+          await tx.commission.updateMany({
+            where: {
+              id: { in: commissions.map((c) => c.id) },
+            },
+            data: {
+              status: "processed",
+              payoutId: payoutToUse.id,
+            },
+          });
+          console.log(
+            `Updated commissions to have payoutId ${payoutToUse.id}`,
+          );
+
+          // if we're reusing a pending payout, we need to update the amount
+          if (existingPendingPayouts.find((p) => p.id === payoutToUse.id)) {
+            const updatedPayout = await tx.payout.update({
+              where: {
+                id: payoutToUse.id,
+              },
+              data: {
+                amount: {
+                  increment: totalEarnings,
+                },
+                periodEnd,
+              },
+            });
+            console.log(
+              `Since we're reusing payout ${payoutToUse.id}, add the new earnings of ${totalEarnings} to the payout amount, making it ${updatedPayout.amount}`,
+            );
+          }
+        });
       }
🧹 Nitpick comments (1)
apps/web/app/(ee)/api/cron/payouts/route.ts (1)

168-170: Remove redundant sorting.

The commissions are already ordered by createdAt ascending from the database query (lines 130-132), so sorting them again in memory is unnecessary.

-        // sort the commissions by createdAt
-        const sortedCommissions = commissions.sort(
-          (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
-        );
-
         // sum the earnings of the commissions
-        const totalEarnings = sortedCommissions.reduce(
+        const totalEarnings = commissions.reduce(
           (total, commission) => total + commission.earnings,
           0,
         );
 
         // earliest commission date
-        const periodStart = sortedCommissions[0].createdAt;
+        const periodStart = commissions[0].createdAt;
 
         // end of the month of the latest commission date
         // e.g. if the latest sale is 2024-12-16, the periodEnd should be 2024-12-31
         const periodEnd = endOfMonth(
-          sortedCommissions[sortedCommissions.length - 1].createdAt,
+          commissions[commissions.length - 1].createdAt,
         );
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between de44ac5 and 5b594d6.

📒 Files selected for processing (1)
  • apps/web/app/(ee)/api/cron/payouts/route.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/cron/payouts/route.ts (1)
apps/web/lib/api/create-id.ts (1)
  • createId (66-71)
⏰ 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

@steven-tey steven-tey merged commit 350f8dc into main Oct 6, 2025
8 of 10 checks passed
@steven-tey steven-tey deleted the optimize-payouts-cron branch October 6, 2025 22:40
This was referenced Nov 26, 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.

2 participants