Skip to content

Fix partners with payoutsEnabledAt set but defaultPayoutMethod null#3973

Merged
steven-tey merged 5 commits into
mainfrom
fix-payouts-enabled-without-default-method
Jun 6, 2026
Merged

Fix partners with payoutsEnabledAt set but defaultPayoutMethod null#3973
steven-tey merged 5 commits into
mainfrom
fix-payouts-enabled-without-default-method

Conversation

@pepeladeira

@pepeladeira pepeladeira commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Enhanced payout method handling when payment accounts are closed or become invalid
    • Improved cleanup of payment details during partner account removal
  • Chores

    • Added database reconciliation to ensure payout method consistency across records

@vercel

vercel Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Jun 4, 2026 1:30pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Centralizes payout-state change detection (new hasPayoutStateChanged), updates the Stripe webhook to use it, clears defaultPayoutMethod consistently when disabling payouts across codepaths, and adds a migration to reconcile existing partner records.

Changes

Payout State Management and Partner Account Sync

Layer / File(s) Summary
Recompute payout-state logic and change detection
apps/web/lib/payouts/recompute-partner-payout-state.ts
Computes defaultPayoutMethod via explicit hasValidDefaultPayoutMethod branching, refactors payoutsEnabledAt assignment to if/else (set to today when default changes; else reuse partner date or fall back to today), and returns hasPayoutStateChanged comparing recomputed values to current ones.
Webhook payout state consolidation
apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts
Account-updated webhook consumes hasPayoutStateChanged to gate the initial payout-state update and early-return logic, avoiding redundant writes. After fetching bank account, the follow-up update only sets payoutMethodHash (no conditional payoutsEnabledAt backfill).
Distributed defaultPayoutMethod clearing
apps/web/app/(ee)/api/admin/partners/delete-account/route.ts, apps/web/lib/partners/create-stablecoin-payout.ts, apps/web/lib/partners/create-stripe-transfer.ts
Partner deletion, stablecoin payout closure (closed Stripe recipient), and Stripe transfer ineligibility paths now clear defaultPayoutMethod (set to null) alongside existing fields when disabling payouts.
Data repair migration for inconsistent payout states
apps/web/scripts/migrations/backfill-default-payout-method.ts
Two-step migration: (1) bulk-set defaultPayoutMethod = "connect" for partners with payoutsEnabledAt and Connect identifiers but missing default; (2) batch-clear defaultPayoutMethod where payoutsEnabledAt is null, looping until no matches, with logging.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • dubinc/dub#3696: Modifies the same Stripe account.updated webhook handler around payout-state change detection and write logic.
  • dubinc/dub#3927: Modifies recompute-partner-payout-state.ts changing defaultPayoutMethod recomputation and return fields.
  • dubinc/dub#3841: Conflicts directly in the backfill migration script by removing it entirely.

Suggested reviewers

  • devkiran
  • steven-tey

Poem

🐰 I hopped through code to tidy pays,
Cleared defaults and brightened days,
A webhook now knows when to write,
Migrations mend the shadowed night,
Hooray — payouts sleeping tight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main problem being addressed: fixing an inconsistent state where partners have payoutsEnabledAt set but defaultPayoutMethod is null.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-payouts-enabled-without-default-method

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/scripts/migrations/fix-payouts-enabled-without-default-method.ts`:
- Around line 50-72: The migration runs Promise.allSettled over a batch which
may trigger Stripe rate limits because recomputePartnerPayoutState performs
multiple Stripe calls per partner; modify the batching so you process chunks of
partners sequentially (use BATCH_SIZE) instead of firing all batches
concurrently: iterate over chunks, await
Promise.allSettled(partnersChunk.map(...recomputePartnerPayoutState...)) to
finish each chunk, then await a small sleep/delay (e.g., 200–1000ms) before
continuing to the next chunk; also consider reducing BATCH_SIZE to a safer value
and return/collect results as before.
- Around line 52-63: The migration updates payoutsEnabledAt and
defaultPayoutMethod from recomputePartnerPayoutState but omits the returned
cryptoWalletAddress; modify the prisma.partner.update call in this migration to
also set cryptoWalletAddress to the value returned by
recomputePartnerPayoutState (use the cryptoWalletAddress variable alongside
payoutsEnabledAt and defaultPayoutMethod) so the DB state matches the
recipient-configuration-updated webhook behavior and prevents stale wallet
addresses.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 628e4bb5-51cc-407d-9324-b087059fd3c8

📥 Commits

Reviewing files that changed from the base of the PR and between d536779 and 9353ff3.

📒 Files selected for processing (2)
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts
  • apps/web/scripts/migrations/fix-payouts-enabled-without-default-method.ts
💤 Files with no reviewable changes (1)
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts

Comment thread apps/web/scripts/migrations/fix-payouts-enabled-without-default-method.ts Outdated
Comment thread apps/web/scripts/migrations/fix-payouts-enabled-without-default-method.ts Outdated
@pepeladeira pepeladeira requested a review from devkiran June 2, 2026 14:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/web/scripts/migrations/fix-payouts-enabled-without-default-method.ts (1)

107-107: ⚡ Quick win

Add top-level error handling and teardown for the script entrypoint.

main() is invoked without awaiting completion, catching errors, or disconnecting Prisma. On rejection this surfaces as an unhandled promise rejection, and on success the open Prisma connection can keep the process from exiting cleanly.

♻️ Proposed entrypoint handling
-main();
+main()
+  .catch((error) => {
+    console.error(error);
+    process.exitCode = 1;
+  })
+  .finally(async () => {
+    await prisma.$disconnect();
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/scripts/migrations/fix-payouts-enabled-without-default-method.ts` at
line 107, The script currently calls main() with no awaiting, error handling, or
teardown; update the entrypoint to await main(), catch and log errors, ensure
Prisma is disconnected (call prisma.$disconnect() or equivalent) in a finally
block, and exit with non-zero code on error; specifically modify the top-level
invocation of main() to an async wrapper or Promise chain that uses
try/catch/finally, references main() and the Prisma client (e.g., prisma), logs
errors via the existing logger or console, calls prisma.$disconnect(), and calls
process.exit(1) on failure and process.exit(0) on success to ensure clean
shutdown.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/web/scripts/migrations/fix-payouts-enabled-without-default-method.ts`:
- Line 107: The script currently calls main() with no awaiting, error handling,
or teardown; update the entrypoint to await main(), catch and log errors, ensure
Prisma is disconnected (call prisma.$disconnect() or equivalent) in a finally
block, and exit with non-zero code on error; specifically modify the top-level
invocation of main() to an async wrapper or Promise chain that uses
try/catch/finally, references main() and the Prisma client (e.g., prisma), logs
errors via the existing logger or console, calls prisma.$disconnect(), and calls
process.exit(1) on failure and process.exit(0) on success to ensure clean
shutdown.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7a9a4603-ab7b-4263-a5a3-b59e732cb3af

📥 Commits

Reviewing files that changed from the base of the PR and between 9353ff3 and 02450f9.

📒 Files selected for processing (1)
  • apps/web/scripts/migrations/fix-payouts-enabled-without-default-method.ts

Return hasPayoutStateChanged from recomputePartnerPayoutState and use it in account-updated webhook.
@devkiran

devkiran commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

@coderabbitai full review please

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/web/lib/payouts/recompute-partner-payout-state.ts (1)

116-118: ⚡ Quick win

hasPayoutStateChanged compares Dates by reference, not value.

partner.payoutsEnabledAt !== payoutsEnabledAt is a reference comparison. It happens to be correct today only because Line 93 reuses the exact same Date instance (payoutsEnabledAt = partner.payoutsEnabledAt) in the no-change path. If that branch is ever refactored to clone/normalize the date (e.g. new Date(partner.payoutsEnabledAt)), this flag would always report a change and defeat its purpose of suppressing unnecessary writes.

Suggest comparing by time value to make the contract robust:

♻️ Compare timestamps instead of references
-  const hasPayoutStateChanged =
-    partner.payoutsEnabledAt !== payoutsEnabledAt ||
-    partner.defaultPayoutMethod !== defaultPayoutMethod;
+  const hasPayoutStateChanged =
+    (partner.payoutsEnabledAt?.getTime() ?? null) !==
+      (payoutsEnabledAt?.getTime() ?? null) ||
+    partner.defaultPayoutMethod !== defaultPayoutMethod;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/payouts/recompute-partner-payout-state.ts` around lines 116 -
118, The equality check for hasPayoutStateChanged currently compares Date
objects by reference (partner.payoutsEnabledAt !== payoutsEnabledAt), which is
fragile; change the comparison to compare their time values instead (e.g.,
compare partner.payoutsEnabledAt?.getTime() to payoutsEnabledAt?.getTime() or
use numeric coercion) while keeping the existing defaultPayoutMethod inequality
check, so hasPayoutStateChanged becomes true only when the actual timestamp
value or defaultPayoutMethod differ.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/web/lib/payouts/recompute-partner-payout-state.ts`:
- Around line 116-118: The equality check for hasPayoutStateChanged currently
compares Date objects by reference (partner.payoutsEnabledAt !==
payoutsEnabledAt), which is fragile; change the comparison to compare their time
values instead (e.g., compare partner.payoutsEnabledAt?.getTime() to
payoutsEnabledAt?.getTime() or use numeric coercion) while keeping the existing
defaultPayoutMethod inequality check, so hasPayoutStateChanged becomes true only
when the actual timestamp value or defaultPayoutMethod differ.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dbd6d697-17fd-48a9-a334-46211b18f5b0

📥 Commits

Reviewing files that changed from the base of the PR and between 02450f9 and 1bed7bc.

📒 Files selected for processing (2)
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts
  • apps/web/lib/payouts/recompute-partner-payout-state.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== recompute-partner-payout-state.ts ==="
cat apps/web/lib/payouts/recompute-partner-payout-state.ts

echo ""
echo "=== account-updated.ts ==="
cat "apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts"

echo ""
echo "=== fix-payouts-enabled-without-default-method.ts ==="
cat apps/web/scripts/migrations/fix-payouts-enabled-without-default-method.ts

Length of output: 14733



@devkiran Here's the full review of PR #3973:


Review: Fix partners with payoutsEnabledAt set but defaultPayoutMethod null

The core idea is sound — recomputePartnerPayoutState now returns a hasPayoutStateChanged flag so callers can skip unnecessary DB writes and early-return efficiently. However, I found a few issues of varying severity.


🔴 Critical — payoutMethodHash fraud detection now skipped on bank-account-only changes

File: apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts

In account-updated.ts, the entire bank-account section (including payoutMethodHash update, fraud detection, and the "connected payout method" email) now sits after the !hasPayoutStateChanged early return:

if (!hasPayoutStateChanged) {
  return `No change in payout state...`;  // ← everything below is skipped
}

// ...bank account fetch, payoutMethodHash update, fraud detection...

The problem: If a partner replaces their bank account (new account fingerprint) but continues using the same defaultPayoutMethod (e.g., still connect, still payout-enabled), then hasPayoutStateChanged will be false — and the fraud hash will never be updated. This is a regression from the previous behaviour where the hash was always refreshed.

The fix is to separate the "payout state changed" early return from the bank-account/fraud-detection logic:

if (!hasPayoutStateChanged) {
  // Still need to refresh the bank account hash for fraud detection
  const bankAccount = await getPartnerBankAccount(partner.stripeConnectId!);
  if (bankAccount) {
    await prisma.partner.update({
      where: { stripeConnectId: account.id },
      data: { payoutMethodHash: bankAccount.fingerprint },
    });
    // run fraud detection...
  }
  return `No change in payout state for partner ${partner.email}...`;
}

🟡 Medium — Migration script can zero out payoutsEnabledAt on Stripe transient errors

File: apps/web/scripts/migrations/fix-payouts-enabled-without-default-method.ts

recomputePartnerPayoutState makes multiple live Stripe API calls. If a transient Stripe error causes connectActive and stablecoinActive to both be false, the function returns defaultPayoutMethod: null and payoutsEnabledAt: null. The migration script then unconditionally writes these nulls back:

await prisma.partner.update({
  where: { id: partner.id },
  data: {
    payoutsEnabledAt,       // could be null due to Stripe error
    defaultPayoutMethod,    // could be null due to Stripe error
    cryptoWalletAddress,
  },
});

This could silently disable a legitimately active partner's payout capability. Consider using hasPayoutStateChanged (already returned by the helper) to guard the write, and/or logging a warning when the result is still defaultPayoutMethod: null:

const { payoutsEnabledAt, defaultPayoutMethod, cryptoWalletAddress, hasPayoutStateChanged } =
  await recomputePartnerPayoutState(partner);

if (!hasPayoutStateChanged) {
  console.warn(`Partner ${partner.email} (${partner.id}): no change after recompute — possibly no active payout methods`);
  return { id: partner.id, email: partner.email, payoutsEnabledAt, defaultPayoutMethod };
}

await prisma.partner.update({ ... });

🟡 Medium — Date reference equality is implicit and fragile

File: apps/web/lib/payouts/recompute-partner-payout-state.ts

const hasPayoutStateChanged =
  partner.payoutsEnabledAt !== payoutsEnabledAt ||   // Date reference comparison
  partner.defaultPayoutMethod !== defaultPayoutMethod;

This works correctly only because payoutsEnabledAt is either assigned as partner.payoutsEnabledAt (same reference) or a fresh new Date(). A comment explaining this would prevent future regressions:

// Note: payoutsEnabledAt equality is checked by reference intentionally.
// When unchanged, we assign `partner.payoutsEnabledAt` directly (same ref).
// When changed, we assign `new Date()` which is always a new reference.
const hasPayoutStateChanged =
  partner.payoutsEnabledAt !== payoutsEnabledAt ||
  partner.defaultPayoutMethod !== defaultPayoutMethod;

🟡 Medium — totalUpdated counter is misleading in the migration script

totalUpdated increments on every fulfilled settlement, even when the recomputed state is identical to what was already in the DB (e.g., if recomputePartnerPayoutState returns defaultPayoutMethod: null again because the partner genuinely has no active methods). Consider renaming it to totalAttempted or gating the increment on hasPayoutStateChanged.


🟢 Minor — No dry-run mode for the migration script

A --dry-run flag is standard practice for one-shot migration scripts that touch production data. Without it, there's no safe way to inspect what would change before committing:

const DRY_RUN = process.argv.includes("--dry-run");

if (!DRY_RUN) {
  await prisma.partner.update({ ... });
}

✅ What looks good

  • The logic in recomputePartnerPayoutState for computing defaultPayoutMethod via hasValidDefaultPayoutMethod is clean and correct.
  • Keeping the existing partner.defaultPayoutMethod when it's still active (rather than always resetting to priority order) is the right behaviour.
  • Cursor-based pagination with Promise.allSettled + per-batch delay is well-structured.
  • The account-updated.ts simplification of always syncing country/profileType but conditionally syncing payout state is a nice improvement.

The critical item is the payoutMethodHash/fraud-detection bypass — that one should be addressed before merging.

✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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/app/(ee)/api/stripe/connect/webhook/account-updated.ts (1)

109-131: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Critical: early return on !hasPayoutStateChanged skips payoutMethodHash refresh and fraud detection for bank-account-only updates.

When a partner updates their bank account but defaultPayoutMethod/payoutsEnabledAt are unchanged (e.g., a new bank fingerprint for the same connect method), hasPayoutStateChanged is false, so we return at Line 109-111 before reaching the getPartnerBankAccount fetch (Line 117), the payoutMethodHash update (Line 124), and detectDuplicatePayoutMethodFraud (Line 144). This is a regression: payoutMethodHash goes stale and duplicate-payout-method fraud detection no longer fires on bank changes.

The bank fingerprint refresh and fraud detection should run whenever a usable bank account is present, independent of hasPayoutStateChanged. Consider running the bank-account hash refresh + fraud detection branch first (gating only the downstream payout-retry/queue logic on hasPayoutStateChanged), rather than returning early before it.

Want me to draft a restructured version that always refreshes payoutMethodHash and runs fraud detection, while keeping the payout-retry queueing gated on hasPayoutStateChanged?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(ee)/api/stripe/connect/webhook/account-updated.ts around lines
109 - 131, The early return when !hasPayoutStateChanged prevents bank
fingerprint refresh and fraud checks; refactor so
getPartnerBankAccount(partner.stripeConnectId!), updating payoutMethodHash via
prisma.partner.update(... payoutMethodHash: bankAccount.fingerprint ...), and
calling detectDuplicatePayoutMethodFraud(...) always run when a usable bank
account exists, but keep the existing payout-retry/queue logic gated behind
hasPayoutStateChanged (i.e., move the bank-account fetch/update and
detectDuplicatePayoutMethodFraud above the hasPayoutStateChanged return and only
bail out or run downstream retry/queue code based on hasPayoutStateChanged).
Ensure you still handle the no-bank-account case (return/skip) and preserve the
payoutsEnabledAt null branch behavior for disabling payouts.
🧹 Nitpick comments (1)
apps/web/lib/payouts/recompute-partner-payout-state.ts (1)

116-118: 💤 Low value

payoutsEnabledAt equality depends on intentional Date reference reuse — add a clarifying comment.

hasPayoutStateChanged compares Date objects with !==, which is reference equality. This is currently correct only because the unchanged branch (Line 92-93) reuses partner.payoutsEnabledAt by reference rather than constructing a new Date. A future refactor that clones the date (e.g., new Date(partner.payoutsEnabledAt)) would silently make this always report a change. A short comment documenting the intentional reference comparison would protect against that.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/payouts/recompute-partner-payout-state.ts` around lines 116 -
118, The reference-equality comparison in hasPayoutStateChanged
(partner.payoutsEnabledAt !== payoutsEnabledAt || partner.defaultPayoutMethod
!== defaultPayoutMethod) relies on intentionally reusing the original Date
object rather than cloning it; add a short clarifying comment directly above
this expression (or on the payoutsEnabledAt assignment) stating that
partner.payoutsEnabledAt is intentionally reused for reference equality and
warning that creating a new Date(partner.payoutsEnabledAt) would break this
check — alternatively note that switching to value equality (e.g., comparing
getTime()) is the safe refactor if cloning is ever required.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/scripts/migrations/fix-payouts-enabled-without-default-method.ts`:
- Around line 53-65: The migration currently always writes recomputed payouts
(payoutsEnabledAt, defaultPayoutMethod, cryptoWalletAddress) which can clobber
live state on transient Stripe failures; change the logic around
recomputePartnerPayoutState to read and use the returned hasPayoutStateChanged
flag and only call prisma.partner.update when hasPayoutStateChanged is true,
skip the update when false, and emit a warning log if the recomputed values are
null (or indicate missing Stripe data) so transient failures are visible rather
than persisted; reference recomputePartnerPayoutState, hasPayoutStateChanged,
and prisma.partner.update to implement this guard and conditional logging.

---

Outside diff comments:
In `@apps/web/app/`(ee)/api/stripe/connect/webhook/account-updated.ts:
- Around line 109-131: The early return when !hasPayoutStateChanged prevents
bank fingerprint refresh and fraud checks; refactor so
getPartnerBankAccount(partner.stripeConnectId!), updating payoutMethodHash via
prisma.partner.update(... payoutMethodHash: bankAccount.fingerprint ...), and
calling detectDuplicatePayoutMethodFraud(...) always run when a usable bank
account exists, but keep the existing payout-retry/queue logic gated behind
hasPayoutStateChanged (i.e., move the bank-account fetch/update and
detectDuplicatePayoutMethodFraud above the hasPayoutStateChanged return and only
bail out or run downstream retry/queue code based on hasPayoutStateChanged).
Ensure you still handle the no-bank-account case (return/skip) and preserve the
payoutsEnabledAt null branch behavior for disabling payouts.

---

Nitpick comments:
In `@apps/web/lib/payouts/recompute-partner-payout-state.ts`:
- Around line 116-118: The reference-equality comparison in
hasPayoutStateChanged (partner.payoutsEnabledAt !== payoutsEnabledAt ||
partner.defaultPayoutMethod !== defaultPayoutMethod) relies on intentionally
reusing the original Date object rather than cloning it; add a short clarifying
comment directly above this expression (or on the payoutsEnabledAt assignment)
stating that partner.payoutsEnabledAt is intentionally reused for reference
equality and warning that creating a new Date(partner.payoutsEnabledAt) would
break this check — alternatively note that switching to value equality (e.g.,
comparing getTime()) is the safe refactor if cloning is ever required.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b4c47d16-88b0-47ac-84ef-4af3508c586c

📥 Commits

Reviewing files that changed from the base of the PR and between d536779 and 1bed7bc.

📒 Files selected for processing (3)
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts
  • apps/web/lib/payouts/recompute-partner-payout-state.ts
  • apps/web/scripts/migrations/fix-payouts-enabled-without-default-method.ts

Comment thread apps/web/scripts/migrations/fix-payouts-enabled-without-default-method.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/web/scripts/migrations/backfill-default-payout-method.ts (1)

12-57: 💤 Low value

Consider batching for consistency and safety.

Unlike scriptTwo, this function loads all matching partners into memory at once. While the specific constraints likely yield a small dataset, adding batch processing (similar to scriptTwo) would make the script more resilient to unexpected data volumes and provide consistent progress logging.

🔧 Suggested batch processing pattern
 async function scriptOne() {
-  const partners = await prisma.partner.findMany({
-    where: {
-      payoutsEnabledAt: {
-        not: null,
+  let totalUpdated = 0;
+
+  while (true) {
+    const partners = await prisma.partner.findMany({
+      where: {
+        payoutsEnabledAt: {
+          not: null,
+        },
+        stripeConnectId: {
+          not: null,
+        },
+        defaultPayoutMethod: null,
+        paypalEmail: null,
+        stripeRecipientId: null,
       },
-      stripeConnectId: {
-        not: null,
-      },
-      defaultPayoutMethod: null,
-      paypalEmail: null,
-      stripeRecipientId: null,
-    },
-    select: {
-      id: true,
-      stripeConnectId: true,
-      stripeRecipientId: true,
-      paypalEmail: true,
-      payoutsEnabledAt: true,
-      defaultPayoutMethod: true,
-    },
-    orderBy: {
-      id: "asc",
-    },
-  });
+      select: {
+        id: true,
+      },
+      take: 500,
+    });
 
-  console.table(partners);
+    if (partners.length === 0) {
+      break;
+    }
 
-  if (partners.length === 0) {
-    console.log("No partners found");
-    return;
-  }
+    const { count } = await prisma.partner.updateMany({
+      where: {
+        id: {
+          in: partners.map((partner) => partner.id),
+        },
+      },
+      data: {
+        defaultPayoutMethod: "connect",
+      },
+    });
 
-  const { count } = await prisma.partner.updateMany({
-    where: {
-      id: {
-        in: partners.map((partner) => partner.id),
-      },
-    },
-    data: {
-      defaultPayoutMethod: "connect",
-    },
-  });
+    totalUpdated += count;
+    console.log(`Updated ${count} partners (total: ${totalUpdated})`);
+  }
 
-  console.log(`Updated ${count} partners`);
+  console.log(`Completed scriptOne: ${totalUpdated} partners updated`);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/scripts/migrations/backfill-default-payout-method.ts` around lines
12 - 57, scriptOne currently loads all matching partners into memory; change it
to batch-process to avoid large-memory loads and to log progress: iterate using
a paginated loop (use prisma.partner.findMany with take and cursor (or
skip/take) to fetch e.g., 100–1000 partners per batch, selecting id and the same
fields), for each batch call prisma.partner.updateMany with ids from that batch
to set defaultPayoutMethod to "connect", and accumulate/log the count after each
batch until no more results. Ensure you keep the same selection/filter logic and
preserve ordering (orderBy id asc) so cursor pagination with id works correctly
and stop when a batch returns an empty array.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/web/scripts/migrations/backfill-default-payout-method.ts`:
- Around line 12-57: scriptOne currently loads all matching partners into
memory; change it to batch-process to avoid large-memory loads and to log
progress: iterate using a paginated loop (use prisma.partner.findMany with take
and cursor (or skip/take) to fetch e.g., 100–1000 partners per batch, selecting
id and the same fields), for each batch call prisma.partner.updateMany with ids
from that batch to set defaultPayoutMethod to "connect", and accumulate/log the
count after each batch until no more results. Ensure you keep the same
selection/filter logic and preserve ordering (orderBy id asc) so cursor
pagination with id works correctly and stop when a batch returns an empty array.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 54a22aa7-7bf5-4aae-a87b-acb02cbf2a39

📥 Commits

Reviewing files that changed from the base of the PR and between 1bed7bc and 7dc5b1c.

📒 Files selected for processing (4)
  • apps/web/app/(ee)/api/admin/partners/delete-account/route.ts
  • apps/web/lib/partners/create-stablecoin-payout.ts
  • apps/web/lib/partners/create-stripe-transfer.ts
  • apps/web/scripts/migrations/backfill-default-payout-method.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/web/lib/partners/create-stablecoin-payout.ts

@devkiran

devkiran commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

@coderabbitai full review please

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

@devkiran Sure! Triggering a full review of PR #3973 now.


🧠 Learnings used
Learnt from: devkiran
Repo: dubinc/dub PR: 3919
File: apps/web/lib/sandbox/mock-payout-completion.ts:139-165
Timestamp: 2026-05-22T06:06:02.038Z
Learning: In `apps/web/lib/sandbox/mock-payout-completion.ts`, the grouping logic that reduces multiple payouts per partner to one entry (`partnerPayouts`) and then uses only `partnerPayoutList[0]` when constructing the `sendBatchEmail` payload is intentional. The design deliberately sends at most one email per partner, even when that partner has multiple payouts in the same invoice. Do not flag this as a data-loss issue in future reviews.

Learnt from: devkiran
Repo: dubinc/dub PR: 3449
File: apps/web/lib/partners/create-stablecoin-payout.ts:99-137
Timestamp: 2026-02-23T11:40:48.138Z
Learning: In apps/web/lib/partners/create-stablecoin-payout.ts, when handling Stripe recipient account issues: (1) closed account is treated as a permanent failure—clears stripeRecipientId, marks payouts as processed, and returns silently; (2) missing crypto wallet capability is treated as a temporary/retriable failure—clears only payoutsEnabledAt, keeps stripeRecipientId for retry, throws an error to signal the issue. The throw vs. return difference is intentional to enable retry after the partner fixes their payout method in case 2.
<!-- [/add_learning]

Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2026-06-03T20:04:17.294Z
Learning: In dubinc/dub PR `#3824`, replacing `context.sale.productId` with `context.sale.products` (array) in `checkout-session-completed.ts` and `invoice-paid.ts` is NOT a breaking change for existing reward conditions. The updated `create-partner-commission/route.ts` workflow mediates this: when `context.sale.products` is present and a `sale.productId` modifier is detected, the workflow iterates through each product, reconstructs `productId` from each product entry, and calls `determinePartnerReward` with a per-product context. So `evaluate-reward-conditions.ts` still receives `productId` correctly — it just comes from the `products` array iteration rather than a top-level `context.sale.productId`. Do not flag this as a breaking change in future reviews.

Learnt from: steven-tey
Repo: dubinc/dub PR: 3732
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx:262-305
Timestamp: 2026-04-10T03:43:25.907Z
Learning: In `apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx`, the `disabledTooltip` for the "Confirm selected" button intentionally omits edge-case ineligibility reasons (already-paid/failed status, `mode == null`) for brevity. The tooltip only surfaces the three most common/actionable reasons: below minimum payout amount, partner has not connected payouts, and fraud hold. Do not flag this omission in future reviews.

Learnt from: pepeladeira
Repo: dubinc/dub PR: 3923
File: apps/web/lib/actions/partners/update-reward.ts:160-168
Timestamp: 2026-05-18T22:18:20.575Z
Learning: In dubinc/dub, `updateRewardAction` (apps/web/lib/actions/partners/update-reward.ts) only updates the `Reward` row and does NOT write to `programEnrollment` rows. The enrollment sync cron (`/api/cron/rewards/sync-enrollments`) only handles `create` and `delete` actions — there is no "update" enrollment sync. Therefore, calling `notifyPartnersRewardChanged` directly inside `waitUntil(Promise.allSettled([...]))` within `updateRewardAction` is the correct and complete pattern for reward-update partner notifications. Do NOT flag this as premature notification or potential duplication.

Learnt from: pepeladeira
Repo: dubinc/dub PR: 3923
File: apps/web/lib/actions/partners/update-reward.ts:160-168
Timestamp: 2026-05-18T22:18:20.575Z
Learning: In dubinc/dub, `updateRewardAction` (apps/web/lib/actions/partners/update-reward.ts) only updates the `Reward` row and does NOT touch `programEnrollment` rows or queue any enrollment sync cron job. The enrollment sync cron at `/api/cron/rewards/sync-enrollments` only handles `create` and `delete` actions — there is no update-action cron path. Therefore, calling `notifyPartnersRewardChanged` directly inside `waitUntil(Promise.allSettled([...]))` within `updateRewardAction` is the correct pattern. Do NOT flag this as "premature notification before enrollment sync finalization" or as a duplication risk.

Learnt from: devkiran
Repo: dubinc/dub PR: 3952
File: apps/web/lib/tremendous/send-tremendous-payouts.ts:201-221
Timestamp: 2026-05-29T08:18:35.355Z
Learning: In dubinc/dub, the partner payout notification email subject intentionally uses `payouts[0].program.name` (the first payout's program) rather than a deduped list of all program names. This mirrors the pattern in `apps/web/lib/partners/create-stripe-transfer.ts`, which also uses `currentInvoicePayouts[0].program.name` in the email subject while separately deduping program names only for the Stripe transfer description. Do not flag this as an inconsistency in future reviews.

Learnt from: devkiran
Repo: dubinc/dub PR: 3824
File: apps/web/lib/partners/create-partner-commission.ts:193-195
Timestamp: 2026-04-30T05:05:45.091Z
Learning: In `apps/web/lib/partners/create-partner-commission.ts`, there can only be one reward per partner + program pair. When iterating over multiple products (Stripe line items) to build the `rewards` array, `determinePartnerReward` will always resolve to the same reward object for all products. Therefore, using `rewards[0].reward` as the canonical reward is safe and not order-dependent. Eligibility checks such as `firstCommission.rewardId` and `maxDuration` are safe to run against `rewards[0].reward`.

Learnt from: devkiran
Repo: dubinc/dub PR: 3614
File: apps/web/app/api/veriff/webhook/handle-decision-event.ts:61-84
Timestamp: 2026-03-26T16:15:04.514Z
Learning: In `apps/web/app/api/veriff/webhook/handle-decision-event.ts`, when `computeIdentityHash` returns `null` for an approved Veriff decision (because Veriff didn't return sufficient person/document data), the duplicate-identity check is intentionally skipped and the partner is approved with `veriffIdentityHash = null`. This is by design — the hash computation failure is Veriff's data gap, not the partner's fault, so declining or flagging for review is considered wrong here.

Learnt from: steven-tey
Repo: dubinc/dub PR: 3584
File: apps/web/app/(ee)/api/admin/delete-partner-account/route.ts:120-134
Timestamp: 2026-03-14T20:01:18.394Z
Learning: In the dubinc/dub codebase, `prisma.partner.delete()` throws a Prisma error because `relationMode = "prisma"` enforces Restrict (default) on models without explicit `onDelete`: Payout, PartnerRewind, PartnerReferral, DiscountCode, and NotificationEmail all reference Partner without onDelete Cascade. The workaround is to explicitly `deleteMany` those non-cascade relations before calling `prisma.partner.delete()`.

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.
✅ Action performed

Full review finished.

@devkiran devkiran requested a review from steven-tey June 4, 2026 13:45

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 (3)
apps/web/scripts/migrations/backfill-default-payout-method.ts (2)

63-71: ⚡ Quick win

Consider adding select: { id: true } to reduce query payload.

The query fetches full partner records but only uses partner.id. Adding a select clause would reduce database and network overhead for potentially large batches.

     const partners = await prisma.partner.findMany({
       where: {
         payoutsEnabledAt: null,
         defaultPayoutMethod: {
           not: null,
         },
       },
+      select: {
+        id: true,
+      },
       take: 500,
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/scripts/migrations/backfill-default-payout-method.ts` around lines
63 - 71, The query using prisma.partner.findMany currently loads full partner
records into the partners array but only uses partner.id; update the call to
include a select clause (select: { id: true }) so only the id is returned—keep
the existing where filter and take: 500 and adjust any downstream usage that
expects full partner objects to use the id property from the now-selected shape.

5-8: ⚡ Quick win

Consider adding error handling and a summary.

The script modifies production data but lacks error handling. If a batch fails mid-execution, there's no indication of progress or which partners were affected.

Suggested improvement
 async function main() {
-  await scriptOne();
-  await scriptTwo();
+  try {
+    await scriptOne();
+    await scriptTwo();
+    console.log("Migration completed successfully");
+  } catch (error) {
+    console.error("Migration failed:", error);
+    process.exit(1);
+  }
 }

-main();
+main().finally(() => prisma.$disconnect());

Also applies to: 92-92

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/scripts/migrations/backfill-default-payout-method.ts` around lines 5
- 8, The main function lacks error handling and progress reporting; wrap the
calls to scriptOne and scriptTwo in a try/catch and ensure each script call is
awaited with per-batch logging and error capture (reference functions scriptOne,
scriptTwo, and main). Inside each script implement progress checkpoints (e.g.,
log partner IDs or batch ranges before/after processing) and throw or return
structured errors so main can log full error details and which batch/partner
failed. In main, on success log a concise summary of processed counts and on
failure log the error, the last successful checkpoint, and exit non-zero so
CI/ops know it failed. Ensure any resources are cleaned up in a finally block if
needed.
apps/web/lib/payouts/recompute-partner-payout-state.ts (1)

116-118: 💤 Low value

Document the Date reference equality invariant.

hasPayoutStateChanged relies on reference equality (!==) for payoutsEnabledAt, which only works because unchanged paths reuse partner.payoutsEnabledAt directly (line 93) while changed paths create new Date(). This is fragile—if someone refactors to clone the date, the comparison breaks silently.

Consider adding a brief comment above this block to preserve the invariant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/payouts/recompute-partner-payout-state.ts` around lines 116 -
118, The comparison that computes hasPayoutStateChanged uses reference equality
for dates and depends on unchanged control flow reusing partner.payoutsEnabledAt
rather than cloning it; add a short comment immediately above the
hasPayoutStateChanged declaration explaining the Date reference-equality
invariant (i.e., that unchanged code paths reuse partner.payoutsEnabledAt while
changed paths create a new Date()), referencing partner.payoutsEnabledAt,
payoutsEnabledAt and defaultPayoutMethod so future readers/maintainers know not
to clone the Date or the check will break.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/app/`(ee)/api/stripe/connect/webhook/account-updated.ts:
- Around line 109-111: The early return when hasPayoutStateChanged is false
skips fetching the bank account and running detectDuplicatePayoutMethodFraud,
leaving payoutMethodHash stale and bypassing fraud/email logic; refactor the
handler so that when partner.stripeConnectId is present you always fetch the
bank account and run detectDuplicatePayoutMethodFraud and update
payoutMethodHash (and send the connected payout method email) first, then only
after that block check hasPayoutStateChanged and return early if still false
(i.e., postpone the early return until after payoutMethodHash/fraud/email
processing and only prevent queuing payout retries when appropriate).

---

Nitpick comments:
In `@apps/web/lib/payouts/recompute-partner-payout-state.ts`:
- Around line 116-118: The comparison that computes hasPayoutStateChanged uses
reference equality for dates and depends on unchanged control flow reusing
partner.payoutsEnabledAt rather than cloning it; add a short comment immediately
above the hasPayoutStateChanged declaration explaining the Date
reference-equality invariant (i.e., that unchanged code paths reuse
partner.payoutsEnabledAt while changed paths create a new Date()), referencing
partner.payoutsEnabledAt, payoutsEnabledAt and defaultPayoutMethod so future
readers/maintainers know not to clone the Date or the check will break.

In `@apps/web/scripts/migrations/backfill-default-payout-method.ts`:
- Around line 63-71: The query using prisma.partner.findMany currently loads
full partner records into the partners array but only uses partner.id; update
the call to include a select clause (select: { id: true }) so only the id is
returned—keep the existing where filter and take: 500 and adjust any downstream
usage that expects full partner objects to use the id property from the
now-selected shape.
- Around line 5-8: The main function lacks error handling and progress
reporting; wrap the calls to scriptOne and scriptTwo in a try/catch and ensure
each script call is awaited with per-batch logging and error capture (reference
functions scriptOne, scriptTwo, and main). Inside each script implement progress
checkpoints (e.g., log partner IDs or batch ranges before/after processing) and
throw or return structured errors so main can log full error details and which
batch/partner failed. In main, on success log a concise summary of processed
counts and on failure log the error, the last successful checkpoint, and exit
non-zero so CI/ops know it failed. Ensure any resources are cleaned up in a
finally block if needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db6c0210-c6ad-4104-954d-46f7292f0663

📥 Commits

Reviewing files that changed from the base of the PR and between b1cc729 and 7dc5b1c.

📒 Files selected for processing (6)
  • apps/web/app/(ee)/api/admin/partners/delete-account/route.ts
  • apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts
  • apps/web/lib/partners/create-stablecoin-payout.ts
  • apps/web/lib/partners/create-stripe-transfer.ts
  • apps/web/lib/payouts/recompute-partner-payout-state.ts
  • apps/web/scripts/migrations/backfill-default-payout-method.ts

Comment thread apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts
@steven-tey

Copy link
Copy Markdown
Collaborator

@coderabbitai full review, any breaking changes? incorrect logic?

@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== recompute-partner-payout-state.ts ==="
cat apps/web/lib/payouts/recompute-partner-payout-state.ts

echo ""
echo "=== account-updated.ts ==="
cat "apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts"

echo ""
echo "=== fix-payouts-enabled-without-default-method.ts (migration) ==="
fd "fix-payouts-enabled-without-default-method" --type f | xargs cat 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== backfill-default-payout-method.ts ==="
fd "backfill-default-payout-method" --type f | xargs cat 2>/dev/null || echo "NOT FOUND"

echo ""
echo "=== create-stablecoin-payout.ts (relevant section) ==="
grep -n "defaultPayoutMethod\|payoutsEnabledAt\|stripeRecipientId" apps/web/lib/partners/create-stablecoin-payout.ts | head -40

echo ""
echo "=== create-stripe-transfer.ts (relevant section) ==="
grep -n "defaultPayoutMethod\|payoutsEnabledAt" apps/web/lib/partners/create-stripe-transfer.ts | head -20

echo ""
echo "=== delete-account route ==="
fd "delete-account" --type f -g "*.ts" | xargs grep -l "defaultPayoutMethod" 2>/dev/null | xargs cat 2>/dev/null | head -80

echo ""
echo "=== getPartnerBankAccount usages ==="
rg -n "getPartnerBankAccount" --type ts -C 3

Length of output: 21940



@steven-tey Here's a focused full review of PR #3973 — calling out the one confirmed breaking change and two logic issues that need attention before merging.


🔴 Breaking Change — payoutMethodHash / fraud detection bypassed on bank-account-only updates

File: apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts

This is confirmed still present. When a partner replaces their bank account (new fingerprint) but defaultPayoutMethod and payoutsEnabledAt stay the same, hasPayoutStateChanged is false and the function returns early at line ~108 — before the bank account fetch, payoutMethodHash write, and detectDuplicatePayoutMethodFraud call. Fraud detection for changed bank accounts is completely silenced.

if (!hasPayoutStateChanged) {
  return `No change in payout state...`;  // ← everything below skipped
}

// ...bank account fetch, payoutMethodHash update, fraud detection... (never reached)

Fix: move the payoutMethodHash / fraud block before (or outside of) the hasPayoutStateChanged gate:

// Always refresh bank account hash and run fraud detection
const bankAccount = await getPartnerBankAccount(partner.stripeConnectId!);
if (bankAccount) {
  const { payoutMethodHash } = await prisma.partner.update({
    where: { stripeConnectId: account.id },
    data: { payoutMethodHash: bankAccount.fingerprint },
  });
  if (payoutMethodHash) {
    await detectDuplicatePayoutMethodFraud({ payoutMethodHash });
    // ... duplicate / first-time email logic ...
  }
}

if (!hasPayoutStateChanged) {
  return `No change in payout state for partner ${partner.email}...`;
}

🟡 Logic Issue — scriptOne() in backfill-default-payout-method.ts skips live Stripe verification

scriptOne() bulk-sets defaultPayoutMethod = "connect" for every partner that has payoutsEnabledAt != null, stripeConnectId != null, defaultPayoutMethod = null, and no PayPal/stablecoin IDs. It never calls recomputePartnerPayoutState or touches the Stripe API. If a partner's Stripe Connect account is currently restricted (but payoutsEnabledAt was set historically and never cleared), the script will incorrectly mark them as "connect" again.

Consider adding a live Stripe verification step before the bulk update, or at least log all partners whose Stripe accounts are currently not payouts_enabled and skip them.


🟡 Fragile hasPayoutStateChanged comparison (no docs, silent risk)

File: apps/web/lib/payouts/recompute-partner-payout-state.ts

const hasPayoutStateChanged =
  partner.payoutsEnabledAt !== payoutsEnabledAt ||   // Date reference equality
  partner.defaultPayoutMethod !== defaultPayoutMethod;

This is correct now because payoutsEnabledAt is either the same object reference (partner.payoutsEnabledAt) or a fresh new Date(). But the next person to touch this file will not see that contract. Please add a comment:

// payoutsEnabledAt equality is intentionally reference-based:
// unchanged → we re-assign partner.payoutsEnabledAt (same ref, === holds)
// changed   → we assign new Date() (new ref, !== fires)
const hasPayoutStateChanged = ...

✅ What looks correct

Area Status
create-stablecoin-payout.ts — now clears defaultPayoutMethod on closed account
create-stripe-transfer.ts — now clears defaultPayoutMethod when payout disabled
recomputePartnerPayoutState — preserves existing default when still active
account-updated.ts — always syncs country/profileType unconditionally
backfill-default-payout-method.ts scriptTwo() — clears orphan defaultPayoutMethod values
First-time "connected payout method" email — correctly reads old partner.payoutsEnabledAt

TL;DR: The critical regression is the fraud detection bypass in account-updated.ts. The scriptOne Stripe-verification gap is a data-integrity risk for the one-time migration. Both should be addressed before merging.


🧠 Learnings used
Learnt from: devkiran
Repo: dubinc/dub PR: 3919
File: apps/web/lib/sandbox/mock-payout-completion.ts:139-165
Timestamp: 2026-05-22T06:06:02.038Z
Learning: In `apps/web/lib/sandbox/mock-payout-completion.ts`, the grouping logic that reduces multiple payouts per partner to one entry (`partnerPayouts`) and then uses only `partnerPayoutList[0]` when constructing the `sendBatchEmail` payload is intentional. The design deliberately sends at most one email per partner, even when that partner has multiple payouts in the same invoice. Do not flag this as a data-loss issue in future reviews.

Learnt from: steven-tey
Repo: dubinc/dub PR: 0
File: :0-0
Timestamp: 2026-06-03T20:04:17.294Z
Learning: In dubinc/dub PR `#3824`, replacing `context.sale.productId` with `context.sale.products` (array) in `checkout-session-completed.ts` and `invoice-paid.ts` is NOT a breaking change for existing reward conditions. The updated `create-partner-commission/route.ts` workflow mediates this: when `context.sale.products` is present and a `sale.productId` modifier is detected, the workflow iterates through each product, reconstructs `productId` from each product entry, and calls `determinePartnerReward` with a per-product context. So `evaluate-reward-conditions.ts` still receives `productId` correctly — it just comes from the `products` array iteration rather than a top-level `context.sale.productId`. Do not flag this as a breaking change in future reviews.

Learnt from: devkiran
Repo: dubinc/dub PR: 3952
File: apps/web/lib/tremendous/send-tremendous-payouts.ts:201-221
Timestamp: 2026-05-29T08:18:35.355Z
Learning: In dubinc/dub, the partner payout notification email subject intentionally uses `payouts[0].program.name` (the first payout's program) rather than a deduped list of all program names. This mirrors the pattern in `apps/web/lib/partners/create-stripe-transfer.ts`, which also uses `currentInvoicePayouts[0].program.name` in the email subject while separately deduping program names only for the Stripe transfer description. Do not flag this as an inconsistency in future reviews.

Learnt from: pepeladeira
Repo: dubinc/dub PR: 3923
File: apps/web/lib/actions/partners/update-reward.ts:160-168
Timestamp: 2026-05-18T22:18:20.575Z
Learning: In dubinc/dub, `updateRewardAction` (apps/web/lib/actions/partners/update-reward.ts) only updates the `Reward` row and does NOT write to `programEnrollment` rows. The enrollment sync cron (`/api/cron/rewards/sync-enrollments`) only handles `create` and `delete` actions — there is no "update" enrollment sync. Therefore, calling `notifyPartnersRewardChanged` directly inside `waitUntil(Promise.allSettled([...]))` within `updateRewardAction` is the correct and complete pattern for reward-update partner notifications. Do NOT flag this as premature notification or potential duplication.

Learnt from: pepeladeira
Repo: dubinc/dub PR: 3923
File: apps/web/lib/actions/partners/update-reward.ts:160-168
Timestamp: 2026-05-18T22:18:20.575Z
Learning: In dubinc/dub, `updateRewardAction` (apps/web/lib/actions/partners/update-reward.ts) only updates the `Reward` row and does NOT touch `programEnrollment` rows or queue any enrollment sync cron job. The enrollment sync cron at `/api/cron/rewards/sync-enrollments` only handles `create` and `delete` actions — there is no update-action cron path. Therefore, calling `notifyPartnersRewardChanged` directly inside `waitUntil(Promise.allSettled([...]))` within `updateRewardAction` is the correct pattern. Do NOT flag this as "premature notification before enrollment sync finalization" or as a duplication risk.

Learnt from: devkiran
Repo: dubinc/dub PR: 3449
File: apps/web/lib/partners/create-stablecoin-payout.ts:99-137
Timestamp: 2026-02-23T11:40:48.138Z
Learning: In apps/web/lib/partners/create-stablecoin-payout.ts, when handling Stripe recipient account issues: (1) closed account is treated as a permanent failure—clears stripeRecipientId, marks payouts as processed, and returns silently; (2) missing crypto wallet capability is treated as a temporary/retriable failure—clears only payoutsEnabledAt, keeps stripeRecipientId for retry, throws an error to signal the issue. The throw vs. return difference is intentional to enable retry after the partner fixes their payout method in case 2.
<!-- [/add_learning]

Learnt from: steven-tey
Repo: dubinc/dub PR: 3732
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx:262-305
Timestamp: 2026-04-10T03:43:25.907Z
Learning: In `apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-table.tsx`, the `disabledTooltip` for the "Confirm selected" button intentionally omits edge-case ineligibility reasons (already-paid/failed status, `mode == null`) for brevity. The tooltip only surfaces the three most common/actionable reasons: below minimum payout amount, partner has not connected payouts, and fraud hold. Do not flag this omission in future reviews.

Learnt from: steven-tey
Repo: dubinc/dub PR: 3584
File: apps/web/app/(ee)/api/admin/delete-partner-account/route.ts:120-134
Timestamp: 2026-03-14T20:01:18.394Z
Learning: In the dubinc/dub codebase, `prisma.partner.delete()` throws a Prisma error because `relationMode = "prisma"` enforces Restrict (default) on models without explicit `onDelete`: Payout, PartnerRewind, PartnerReferral, DiscountCode, and NotificationEmail all reference Partner without onDelete Cascade. The workaround is to explicitly `deleteMany` those non-cascade relations before calling `prisma.partner.delete()`.

Learnt from: devkiran
Repo: dubinc/dub PR: 3824
File: apps/web/lib/partners/create-partner-commission.ts:193-195
Timestamp: 2026-04-30T05:05:45.091Z
Learning: In `apps/web/lib/partners/create-partner-commission.ts`, there can only be one reward per partner + program pair. When iterating over multiple products (Stripe line items) to build the `rewards` array, `determinePartnerReward` will always resolve to the same reward object for all products. Therefore, using `rewards[0].reward` as the canonical reward is safe and not order-dependent. Eligibility checks such as `firstCommission.rewardId` and `maxDuration` are safe to run against `rewards[0].reward`.

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.

@steven-tey steven-tey merged commit cda421d into main Jun 6, 2026
11 checks passed
@steven-tey steven-tey deleted the fix-payouts-enabled-without-default-method branch June 6, 2026 02:27
@coderabbitai coderabbitai Bot mentioned this pull request Jun 9, 2026
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