Skip to content

Delay PayPal, Tremendous, and stablecoin payouts until charge funds settle - #4043

Merged
steven-tey merged 12 commits into
mainfrom
improve-payout-charge-succeed
Jun 17, 2026
Merged

Delay PayPal, Tremendous, and stablecoin payouts until charge funds settle#4043
steven-tey merged 12 commits into
mainfrom
improve-payout-charge-succeed

Conversation

@devkiran

@devkiran devkiran commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • Refactor
    • Updated the charge-succeeded cron payout flow to use settlement-availability timing with a unified delayed-scheduling helper.
    • Standardized payout queueing inputs around a shared fundsAvailable flag; conditional PayPal/Tremendous enqueuing and stablecoin funding/partner selection now depend on funds availability.
    • Added request signature verification for the cron handler.
  • Bug Fixes
    • Improved partner default payout assignment to only affect eligible processing payouts with unset methods.
  • Tests
    • Updated dispatch tests to cover the new fundsAvailable gating behavior across payout types.

@vercel

vercel Bot commented Jun 16, 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 17, 2026 12:30am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR replaces a stablecoin-specific payout scheduling flow with a generic settlement-timing mechanism. A new getFundSettlementTiming helper queries Stripe balance transactions to determine fund availability, and scheduleDelayedPayouts replaces the old stablecoin scheduler. All provider queue functions (queueStripePayouts, sendPaypalPayouts, queueTremendousPayouts) now accept and gate on a fundsAvailable boolean. The charge-succeeded route uses the settlement timing to conditionally schedule delayed payout execution when funds are not yet available.

Changes

Settlement-Timing Payout Overhaul

Layer / File(s) Summary
Settlement timing utilities
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts
Removes scheduleDelayedStablecoinPayouts; adds generic scheduleDelayedPayouts (QStash scheduler with explicit executeAt) and getFundSettlementTiming (queries Stripe balance transactions, returns fundsAvailable: true or scheduledAt offset of 15 min after available_on, or 1 hour when none found). Exports stripeChargeMetadataSchema.
Route orchestration
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts
Updates imports from stablecoin-specific funding to settlement-timing helpers. Tightens payout method SQL update to apply only where currently null. Calls getFundSettlementTiming; when funds not yet available, groups processing payout methods and schedules delayed execution. Passes { invoice, fundsAvailable } to all provider queue functions in Promise.allSettled.
queueStripePayouts: fundsAvailable + stablecoin funding
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts
Refactored to accept { invoice, fundsAvailable }. Imports stablecoin constants. Aggregates qualifying stablecoin payout amounts plus fixed fees; calls fundFinancialAccount with idempotency key when total > 0. Stablecoin method included in groupBy only when fundsAvailable is true.
PayPal and Tremendous fundsAvailable guard
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts, apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts
Both functions updated to accept { invoice, fundsAvailable } and return early when fundsAvailable is false, preventing any payout dispatch before funds settle.

Sequence Diagram

sequenceDiagram
  participant QStash
  participant Route as POST /charge-succeeded
  participant DB as Prisma DB
  participant Stripe as Stripe API
  participant Scheduler as scheduleDelayedPayouts
  participant Queues as queueStripe / sendPaypal / queueTremendous

  QStash->>Route: POST with Stripe invoice event
  Route->>DB: Update processing payout methods where method IS NULL
  Route->>Stripe: List balance transactions for charge
  Stripe-->>Route: balance transaction with available_on
  alt funds already available
    Route->>Queues: allSettled with fundsAvailable true
  else funds not yet settled
    Route->>DB: Group processing payout methods
    Route->>Scheduler: scheduleDelayedPayouts with scheduledAt
    Route->>Queues: allSettled with fundsAvailable false
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • dubinc/dub#3058: Directly modifies the queueStripePayouts implementation and charge-succeeded cron payout orchestration in the same files.
  • dubinc/dub#3952: Modifies the charge-succeeded cron flow to enqueue Tremendous payouts and queueTremendousPayouts function signature.

Suggested reviewers

  • steven-tey
  • pepeladeira

Poem

🐇 Hop, hop, the funds must wait,
No stablecoin rushing through the gate.
Settlement timing checks Stripe's clock,
Fifteen minutes past — unlock the block!
PayPal, Tremendous, all stand by,
fundsAvailable lets the payouts fly. 🪙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: delaying PayPal, Tremendous, and stablecoin payouts until Stripe charge funds settle, which aligns with the primary objective and modifications across multiple files.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch improve-payout-charge-succeed

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.

🧹 Nitpick comments (2)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts (1)

101-104: 💤 Low value

Misleading comment: "15 minutes from now" should be "15 minutes after available_on".

The comment says "15 minutes from now" but the code calculates 15 minutes after availableOnMs (the Stripe available_on timestamp), not 15 minutes from the current time.

📝 Suggested fix
   return {
     fundsAvailable: false,
-    scheduledAt: new Date(availableOnMs + 15 * 60 * 1000), // 15 minutes from now
+    scheduledAt: new Date(availableOnMs + 15 * 60 * 1000), // 15 minutes after available_on
   };
🤖 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/cron/payouts/charge-succeeded/utils.ts around lines
101 - 104, The comment on the scheduledAt line is inaccurate. It currently
states "15 minutes from now" but the code actually adds 15 minutes to
availableOnMs (the Stripe available_on timestamp), not the current time. Update
the comment to correctly describe that the scheduled time is 15 minutes after
the available_on timestamp, not 15 minutes from the current moment.
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts (1)

93-96: 💤 Low value

Redundant check: query already guarantees method is in postSettlementPayoutMethods.

The groupBy query on lines 78-91 filters by method: { in: postSettlementPayoutMethods }, so any result in payoutsByMethod will have a method in that list. The .some() check will always be true if payoutsByMethod.length > 0.

♻️ Suggested simplification
-      const shouldSchedulePayouts = payoutsByMethod.some(
-        (p) =>
-          p.method !== null && postSettlementPayoutMethods.includes(p.method),
-      );
-
-      if (shouldSchedulePayouts) {
+      if (payoutsByMethod.length > 0) {
         await scheduleDelayedPayouts({
           invoice,
           executeAt: fundSettlementTiming.scheduledAt,
         });
       }
🤖 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/cron/payouts/charge-succeeded/route.ts around lines 93
- 96, The .some() check in the shouldSchedulePayouts assignment is redundant
because the groupBy query on lines 78-91 already filters results by method: {
in: postSettlementPayoutMethods }, so every payout in payoutsByMethod is
guaranteed to have a valid method. Replace the .some() check that iterates
through payoutsByMethod and checks if p.method is in postSettlementPayoutMethods
with a simple check for whether payoutsByMethod has any items
(payoutsByMethod.length > 0), since the query has already guaranteed the
filtering condition.
🤖 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/app/`(ee)/api/cron/payouts/charge-succeeded/route.ts:
- Around line 93-96: The .some() check in the shouldSchedulePayouts assignment
is redundant because the groupBy query on lines 78-91 already filters results by
method: { in: postSettlementPayoutMethods }, so every payout in payoutsByMethod
is guaranteed to have a valid method. Replace the .some() check that iterates
through payoutsByMethod and checks if p.method is in postSettlementPayoutMethods
with a simple check for whether payoutsByMethod has any items
(payoutsByMethod.length > 0), since the query has already guaranteed the
filtering condition.

In `@apps/web/app/`(ee)/api/cron/payouts/charge-succeeded/utils.ts:
- Around line 101-104: The comment on the scheduledAt line is inaccurate. It
currently states "15 minutes from now" but the code actually adds 15 minutes to
availableOnMs (the Stripe available_on timestamp), not the current time. Update
the comment to correctly describe that the scheduled time is 15 minutes after
the available_on timestamp, not 15 minutes from the current moment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b953680e-f41d-4857-8fc6-07d39fea5d89

📥 Commits

Reviewing files that changed from the base of the PR and between 6c248d2 and 9721074.

📒 Files selected for processing (5)
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts

@devkiran

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review please

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== route.ts ==="
cat apps/web/app/\(ee\)/api/cron/payouts/charge-succeeded/route.ts

echo ""
echo "=== utils.ts ==="
cat apps/web/app/\(ee\)/api/cron/payouts/charge-succeeded/utils.ts

echo ""
echo "=== queue-stripe-payouts.ts ==="
cat apps/web/app/\(ee\)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts

echo ""
echo "=== queue-tremendous-payouts.ts ==="
cat apps/web/app/\(ee\)/api/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts

echo ""
echo "=== send-paypal-payouts.ts ==="
cat apps/web/app/\(ee\)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts

Length of output: 17632



@devkiran: Here is the full review of PR #4043.


PR Summary

This PR changes the payout dispatch strategy for PayPal, Tremendous, and stablecoin payouts to wait until the Stripe charge's funds have actually settled (available_on), preventing Dub from fronting money on card charges that can still be reversed. Connect payouts are unaffected since they use source_transaction. The approach is sound, but there are a few issues worth addressing.


Issues Found

1. 🔴 Potential infinite scheduling loop

File: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts

When the delayed cron job re-runs, if getFundSettlementTiming still returns fundsAvailable: false (e.g., Stripe's available_on is still in the future due to clock drift or a very conservative buffer), the code will:

  1. Find stablecoin/PayPal/Tremendous payouts still in "processing" status (because they were skipped, not advanced)
  2. Schedule yet another delayed job
  3. Run payout processors that skip again

There is no guard against re-scheduling if the invoice has already had a delayed job scheduled. This could loop indefinitely.

Suggestion: Track a re-schedule count (e.g., pass it through the QStash body and cap at 3–5 retries), or mark payouts as "delayed" so the payoutsByMethod query returns zero results on subsequent runs:

// In the body of the delayed QStash job
body: {
  invoiceId: invoice.id,
  attempt: (attempt ?? 0) + 1,
},
// At the top of POST handler, after fundSettlement check
const MAX_SETTLEMENT_RETRIES = 5;
if (!fundSettlement.fundsAvailable && attempt >= MAX_SETTLEMENT_RETRIES) {
  return logAndRespond(`Exceeded max settlement retries for invoice ${invoiceId}.`);
}

2. 🔴 Stablecoin aggregate missing status filter

File: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts, Lines 42–56

const { _sum, _count } = await prisma.payout.aggregate({
  _sum: { amount: true },
  _count: { id: true },
  where: {
    invoiceId: invoice.id,
    method: "stablecoin",
    amount: { gte: MIN_WITHDRAWAL_AMOUNT_CENTS },
    // ⚠️ No status filter here
  },
});

On a re-run (the delayed job), if any stablecoin payouts have already moved out of "processing" (e.g., manually cancelled, or a prior partial run), the aggregate will include their amounts. Because idempotencyKey: invoiceId is reused, Stripe will reject the call with a 400 if the amount differs from the first successful invocation.

Suggestion: Add status: "processing" to the aggregate filter:

  where: {
    invoiceId: invoice.id,
    method: "stablecoin",
+   status: "processing",
    amount: { gte: MIN_WITHDRAWAL_AMOUNT_CENTS },
  },

3. 🟡 Duplicate stripeChargeMetadataSchema definition

Files: utils.ts (Line 12) and queue-stripe-payouts.ts (Line 12)

Both files define an identical Zod schema:

const stripeChargeMetadataSchema = z.object({
  id: z.string(),
});

If the shape of stripeChargeMetadata ever changes, this needs to be updated in two places. Extract it to a shared constants/types file.


4. 🟡 Balance transaction selection doesn't filter by type

File: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts, Lines 52–57

const balanceTransaction = balanceTransactions.data[0];
const availableOnMs = balanceTransaction.available_on * 1000;

stripe.balanceTransactions.list({ source: chargeId }) can return multiple entries for a single charge (e.g., the original charge, a subsequent partial refund fee, dispute fee). The Stripe API returns them in reverse-chronological order by default, so data[0] may not be the original charge transaction.

Suggestion: Filter for type: "charge" explicitly:

const chargeTransaction = balanceTransactions.data.find(
  (bt) => bt.type === "charge",
);

if (!chargeTransaction) {
  console.log(`No charge balance transaction found for charge ${chargeId}`);
  return {
    fundsAvailable: false,
    scheduledAt: new Date(now + 60 * 60 * 1000),
  };
}

const availableOnMs = chargeTransaction.available_on * 1000;

5. 🟡 QStash deduplication window may not cover delayed re-runs

File: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts, Lines 103–117

Connect payouts are queued on the first run (even when fundsAvailable=false) and again on the delayed re-run (when fundsAvailable=true). The deduplication ID is ${invoiceId}-${partnerId}. If QStash's deduplication window (typically a few minutes) expires before the delayed job fires, Connect payouts could be enqueued twice.

Suggestion: Verify QStash's deduplication TTL covers your maximum scheduledAt offset. If not, consider adding an additional guard — e.g., check if the payout is still in "processing" status at the per-partner job level rather than relying solely on dedup IDs.


6. 🟢 Redundant shouldSchedulePayouts check

File: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts, Lines 80–86

const shouldSchedulePayouts = payoutsByMethod.some(
  (p) =>
    p.method !== null && postSettlementPayoutMethods.includes(p.method),
);

The where clause already constrains method: { in: postSettlementPayoutMethods }, so every returned row will satisfy both conditions. This simplifies to:

const shouldSchedulePayouts = payoutsByMethod.length > 0;

7. 🟢 Hardcoded 1-hour fallback undocumented

File: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts, Line 62

scheduledAt: new Date(now + 60 * 60 * 1000), // 1 hour from now

The 1-hour fallback for when no balance transaction is found is reasonable but arbitrary. Consider extracting this to a named constant:

const SETTLEMENT_FALLBACK_DELAY_MS = 60 * 60 * 1000; // 1 hour

Flow Diagram

sequenceDiagram
    participant W as Webhook (charge.succeeded)
    participant R as route.ts
    participant S as getFundSettlementTiming
    participant Q as QStash (delayed)
    participant P as Payout Processors

    W->>R: POST /charge-succeeded {invoiceId}
    R->>S: Check Stripe balance txn available_on
    alt fundsAvailable = true
        R->>P: queueStripe(connect+stablecoin) + sendPayPal + queueTremendous
    else fundsAvailable = false
        R->>Q: scheduleDelayedPayouts(executeAt=available_on+15min)
        R->>P: queueStripe(connect only) + skip PayPal + skip Tremendous
        Note over R,P: ⚠️ If delayed re-run also gets fundsAvailable=false,<br/>loop can repeat indefinitely
        Q-->>R: POST /charge-succeeded {invoiceId} (delayed)
    end
Loading

The most important items to address before merging are #1 (infinite loop) and #2 (status filter on the stablecoin aggregate). Items #3 and #4 are also worth fixing before this ships to production.

✅ Action performed

Full review finished.

@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

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/cron/payouts/charge-succeeded/utils.ts (1)

42-44: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

No retry counter protection against infinite scheduling loops. The QStash job body contains only invoiceId, so when jobs re-run after timing drift or API delays cause fundsAvailable: false again, the system reschedules indefinitely with no upper bound.

  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts#L42-L44: Add retryCount to the QStash body and enforce a maximum (e.g., 5) before scheduling; return early or log an error if exceeded.
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts#L68-L103: Extend payloadSchema to parse retryCount, then pass it through to scheduleDelayedPayouts.
🤖 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/cron/payouts/charge-succeeded/utils.ts around lines 42
- 44, Add retry counter protection to prevent infinite scheduling loops in the
QStash job system. In
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts lines 42-44,
include retryCount in the QStash job body and enforce a maximum retry limit
(e.g., 5) before scheduling; return early or log an error if the limit is
exceeded. In apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts lines
68-103, extend the payloadSchema to parse retryCount from the incoming payload
and pass it through to the scheduleDelayedPayouts function call so that the
retry counter is properly tracked across job re-executions.
🧹 Nitpick comments (6)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts (2)

103-103: 💤 Low value

Misleading comment: the offset is from available_on, not "from now".

The comment says "15 minutes from now" but the calculation is availableOnMs + 15 * 60 * 1000, which is 15 minutes after available_on.

📝 Proposed fix
   return {
     fundsAvailable: false,
-    scheduledAt: new Date(availableOnMs + 15 * 60 * 1000), // 15 minutes from now
+    scheduledAt: new Date(availableOnMs + 15 * 60 * 1000), // 15 minutes after available_on
   };
🤖 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/cron/payouts/charge-succeeded/utils.ts at line 103,
The comment on the line with `scheduledAt: new Date(availableOnMs + 15 * 60 *
1000)` incorrectly states "15 minutes from now" when the code actually adds 15
minutes to `availableOnMs` (the available_on timestamp), not to the current
time. Update the comment to accurately reflect that the scheduled time is 15
minutes after the available_on time, not 15 minutes from the current moment.

88-88: ⚡ Quick win

Extract hardcoded 1-hour fallback delay into a named constant.

The magic number 60 * 60 * 1000 reduces readability and maintainability.

♻️ Proposed fix
+const FALLBACK_DELAY_MS = 60 * 60 * 1000; // 1 hour
+
 // ...
 
 if (balanceTransactions.data.length === 0) {
   console.log(`No balance transaction found for charge ${chargeId}`);
 
   return {
     fundsAvailable: false,
-    scheduledAt: new Date(now + 60 * 60 * 1000), // 1 hour from now
+    scheduledAt: new Date(now + FALLBACK_DELAY_MS),
   };
 }
🤖 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/cron/payouts/charge-succeeded/utils.ts at line 88,
Extract the hardcoded magic number 60 * 60 * 1000 (representing 1 hour in
milliseconds) into a named constant at the top of the file or module scope.
Define this constant with a descriptive name like ONE_HOUR_IN_MS or
CHARGE_RETRY_DELAY_MS, then replace the hardcoded value in the scheduledAt line
to use this constant instead. This improves code readability and makes it easier
to maintain or adjust the delay value in the future.
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts (2)

92-95: 💤 Low value

Redundant check — simplifies to payoutsByMethod.length > 0.

The query at lines 77-90 already filters by method: { in: postSettlementPayoutMethods }, so payoutsByMethod will only contain those methods. The .some() check that re-validates against the same list is unnecessary.

♻️ Proposed fix
-    const shouldSchedulePayouts = payoutsByMethod.some(
-      (p) =>
-        p.method !== null && postSettlementPayoutMethods.includes(p.method),
-    );
+    const shouldSchedulePayouts = payoutsByMethod.length > 0;
🤖 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/cron/payouts/charge-succeeded/route.ts around lines 92
- 95, The `shouldSchedulePayouts` variable uses a redundant `.some()` check
because the query that populates `payoutsByMethod` already filters results by
`method: { in: postSettlementPayoutMethods }`, meaning all items in
`payoutsByMethod` are guaranteed to have a method within the allowed list.
Remove the `.some()` iteration and the redundant null check, and simplify
`shouldSchedulePayouts` to just check if `payoutsByMethod.length > 0`.

8-12: Extract stripeChargeMetadataSchema to a shared module.

The schema is defined identically in both queue-stripe-payouts.ts (lines 12–14) and utils.ts (lines 16–18). Changes to the schema require updates in both files, creating unnecessary maintenance overhead.

🤖 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/cron/payouts/charge-succeeded/route.ts around lines 8
- 12, The stripeChargeMetadataSchema is defined identically in both
queue-stripe-payouts.ts and utils.ts, creating duplicate code and maintenance
overhead. Extract this schema definition to a dedicated shared module, then
update both queue-stripe-payouts.ts and utils.ts to import
stripeChargeMetadataSchema from the new shared location instead of defining it
locally. This eliminates duplication and ensures any future schema changes only
need to be made in one place.
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts (2)

70-76: 💤 Low value

Narrow error type before accessing .message.

In strict TypeScript, error in a catch block is unknown. Accessing .message directly may cause type errors or runtime issues if a non-Error is thrown.

♻️ Suggested type-safe error handling
       } catch (error) {
+        const errorMessage = error instanceof Error ? error.message : String(error);
         await log({
-          message: `Failed to fund Dub's financial account for stablecoin payouts: ${error.message}`,
+          message: `Failed to fund Dub's financial account for stablecoin payouts: ${errorMessage}`,
           type: "errors",
         });

         throw error;
       }
🤖 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/cron/payouts/charge-succeeded/queue-stripe-payouts.ts
around lines 70 - 76, The catch block in the error handling code is accessing
error.message on an unknown type without narrowing it first. Add a type guard to
check if the error is an instance of Error before accessing the .message
property. If it is an Error, use its message; otherwise, convert the error to a
string representation (using String(error) or JSON.stringify). Apply this same
type-safe pattern to the log call where error.message is accessed.

12-14: Consider consolidating the duplicate schema definition.

stripeChargeMetadataSchema is defined identically in both utils.ts (line 16) and queue-stripe-payouts.ts (line 12). The schema should be exported from utils.ts and imported in queue-stripe-payouts.ts to avoid drift risk if the schema shape changes.

🤖 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/cron/payouts/charge-succeeded/queue-stripe-payouts.ts
around lines 12 - 14, The schema definition for stripeChargeMetadataSchema is
duplicated in two files. Remove the stripeChargeMetadataSchema definition from
queue-stripe-payouts.ts (the z.object block defining id as z.string()), then
export this same schema from utils.ts where it is already defined, and finally
import stripeChargeMetadataSchema from utils.ts into queue-stripe-payouts.ts to
use the single source of truth.
🤖 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/cron/payouts/charge-succeeded/queue-stripe-payouts.ts:
- Around line 46-55: The where clause in the stablecoin payout query is missing
a status filter, which causes the aggregate to include payouts with different
statuses on job re-runs, leading to mismatched amounts and Stripe idempotency
failures. Add a status filter with value "processing" to the where object
(alongside the existing invoiceId, method, and amount filters) to ensure only
payouts in the processing state are aggregated, maintaining consistent
calculations across re-runs with the same idempotencyKey.

In `@apps/web/app/`(ee)/api/cron/payouts/charge-succeeded/route.ts:
- Around line 68-103: Extend the payload schema in the charge-succeeded route
handler to parse the retryCount parameter from the incoming QStash request body.
Then update the call to scheduleDelayedPayouts (where the invoice and
fundSettlement.scheduledAt are passed) to include the parsed retryCount value as
an additional property in the parameters object, ensuring the value is
propagated through to support the infinite-loop protection logic in the delayed
payout scheduling.

---

Outside diff comments:
In `@apps/web/app/`(ee)/api/cron/payouts/charge-succeeded/utils.ts:
- Around line 42-44: Add retry counter protection to prevent infinite scheduling
loops in the QStash job system. In
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts lines 42-44,
include retryCount in the QStash job body and enforce a maximum retry limit
(e.g., 5) before scheduling; return early or log an error if the limit is
exceeded. In apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts lines
68-103, extend the payloadSchema to parse retryCount from the incoming payload
and pass it through to the scheduleDelayedPayouts function call so that the
retry counter is properly tracked across job re-executions.

---

Nitpick comments:
In `@apps/web/app/`(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts:
- Around line 70-76: The catch block in the error handling code is accessing
error.message on an unknown type without narrowing it first. Add a type guard to
check if the error is an instance of Error before accessing the .message
property. If it is an Error, use its message; otherwise, convert the error to a
string representation (using String(error) or JSON.stringify). Apply this same
type-safe pattern to the log call where error.message is accessed.
- Around line 12-14: The schema definition for stripeChargeMetadataSchema is
duplicated in two files. Remove the stripeChargeMetadataSchema definition from
queue-stripe-payouts.ts (the z.object block defining id as z.string()), then
export this same schema from utils.ts where it is already defined, and finally
import stripeChargeMetadataSchema from utils.ts into queue-stripe-payouts.ts to
use the single source of truth.

In `@apps/web/app/`(ee)/api/cron/payouts/charge-succeeded/route.ts:
- Around line 92-95: The `shouldSchedulePayouts` variable uses a redundant
`.some()` check because the query that populates `payoutsByMethod` already
filters results by `method: { in: postSettlementPayoutMethods }`, meaning all
items in `payoutsByMethod` are guaranteed to have a method within the allowed
list. Remove the `.some()` iteration and the redundant null check, and simplify
`shouldSchedulePayouts` to just check if `payoutsByMethod.length > 0`.
- Around line 8-12: The stripeChargeMetadataSchema is defined identically in
both queue-stripe-payouts.ts and utils.ts, creating duplicate code and
maintenance overhead. Extract this schema definition to a dedicated shared
module, then update both queue-stripe-payouts.ts and utils.ts to import
stripeChargeMetadataSchema from the new shared location instead of defining it
locally. This eliminates duplication and ensures any future schema changes only
need to be made in one place.

In `@apps/web/app/`(ee)/api/cron/payouts/charge-succeeded/utils.ts:
- Line 103: The comment on the line with `scheduledAt: new Date(availableOnMs +
15 * 60 * 1000)` incorrectly states "15 minutes from now" when the code actually
adds 15 minutes to `availableOnMs` (the available_on timestamp), not to the
current time. Update the comment to accurately reflect that the scheduled time
is 15 minutes after the available_on time, not 15 minutes from the current
moment.
- Line 88: Extract the hardcoded magic number 60 * 60 * 1000 (representing 1
hour in milliseconds) into a named constant at the top of the file or module
scope. Define this constant with a descriptive name like ONE_HOUR_IN_MS or
CHARGE_RETRY_DELAY_MS, then replace the hardcoded value in the scheduledAt line
to use this constant instead. This improves code readability and makes it easier
to maintain or adjust the delay value in the future.
🪄 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: a2f6ac28-d090-421d-a995-7fc32a4032f6

📥 Commits

Reviewing files that changed from the base of the PR and between 6c248d2 and 6ad9b5c.

📒 Files selected for processing (5)
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts

Comment thread apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.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.

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/cron/payouts/charge-succeeded/queue-stripe-payouts.ts (1)

66-73: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Type-unsafe access to error.message.

In TypeScript, error in catch blocks has type unknown. Accessing .message directly will cause a type error in strict mode and could log undefined if the thrown value isn't an Error instance.

🛡️ Proposed fix
       } catch (error) {
+        const errorMessage = error instanceof Error ? error.message : String(error);
         await log({
-          message: `Failed to fund Dub's financial account for stablecoin payouts: ${error.message}`,
+          message: `Failed to fund Dub's financial account for stablecoin payouts: ${errorMessage}`,
           type: "errors",
         });

         throw error;
       }
🤖 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/cron/payouts/charge-succeeded/queue-stripe-payouts.ts
around lines 66 - 73, The catch block accesses error.message directly, but the
error variable has type unknown in TypeScript, making this type-unsafe and
potentially logging undefined. Replace the direct error.message access with a
type-safe approach: check if error is an instance of Error using instanceof, and
if so access message; otherwise convert the error to a string using
String(error) or similar fallback. Update the log call in the catch block to use
this safe message extraction, ensuring the logged message always contains
meaningful error information regardless of what type of value was thrown.
🤖 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.

Outside diff comments:
In `@apps/web/app/`(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts:
- Around line 66-73: The catch block accesses error.message directly, but the
error variable has type unknown in TypeScript, making this type-unsafe and
potentially logging undefined. Replace the direct error.message access with a
type-safe approach: check if error is an instance of Error using instanceof, and
if so access message; otherwise convert the error to a string using
String(error) or similar fallback. Update the log call in the catch block to use
this safe message extraction, ensuring the logged message always contains
meaningful error information regardless of what type of value was thrown.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 43aba95b-fc46-4abd-9e38-6eb29da34b5e

📥 Commits

Reviewing files that changed from the base of the PR and between 6ad9b5c and 3e2364a.

📒 Files selected for processing (3)
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts

@steven-tey

Copy link
Copy Markdown
Collaborator

@coderabbitai final review please, any breaking changes, potential bugs, security issues?

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== route.ts ==="
cat apps/web/app/\(ee\)/api/cron/payouts/charge-succeeded/route.ts

echo ""
echo "=== utils.ts ==="
cat apps/web/app/\(ee\)/api/cron/payouts/charge-succeeded/utils.ts

echo ""
echo "=== queue-stripe-payouts.ts ==="
cat apps/web/app/\(ee\)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts

echo ""
echo "=== queue-tremendous-payouts.ts ==="
cat apps/web/app/\(ee\)/api/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts

echo ""
echo "=== send-paypal-payouts.ts ==="
cat apps/web/app/\(ee\)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts

Length of output: 18601


🏁 Script executed:

#!/bin/bash
echo "=== Other callers of queueStripePayouts ==="
rg "queueStripePayouts" --type=ts -n -B2 -A2

echo ""
echo "=== Other callers of queueTremendousPayouts ==="
rg "queueTremendousPayouts" --type=ts -n -B2 -A2

echo ""
echo "=== Other callers of sendPaypalPayouts ==="
rg "sendPaypalPayouts" --type=ts -n -B2 -A2

echo ""
echo "=== Other callers of scheduleDelayedPayouts ==="
rg "scheduleDelayedPayouts" --type=ts -n -B2 -A2

echo ""
echo "=== payloadSchema / invoiceId parsing in route.ts (check if attempt field exists) ==="
rg "attempt" apps/web/app/\(ee\)/api/cron/payouts/charge-succeeded/ -n

Length of output: 7306



@steven-tey: Here is the focused final review for this PR.


Final Review: Breaking Changes, Bugs & Security

✅ What's been fixed since the initial full review

Issue Status
Stablecoin aggregate missing status: "processing" filter ✅ Fixed
Duplicate stripeChargeMetadataSchema definition ✅ Fixed — now imported from utils.ts
Redundant shouldSchedulePayouts check ✅ Fixed — replaced with postSettlementPayoutsCount > 0

🚨 Breaking Changes

No external breaking changes. All three functions whose signatures changed (queueStripePayouts, queueTremendousPayouts, sendPaypalPayouts) have exactly one call site each — in route.ts. The updated call sites match the new signatures. No other files reference these functions.


🔴 Critical Bug — Infinite Scheduling Loop (still not fixed)

File: route.ts (payload schema) and utils.ts (scheduleDelayedPayouts)

There is still no retry counter anywhere in the charge-succeeded/ directory. If a delayed QStash job re-runs and getFundSettlementTiming still returns fundsAvailable: false, it will reschedule indefinitely.

// route.ts — payloadSchema has no attempt field
const payloadSchema = z.object({
  invoiceId: z.string(),
  // ⚠️ No attempt/retryCount — loop has no exit condition
});
// utils.ts — body has no attempt counter
body: {
  invoiceId: invoice.id,
  // ⚠️ No attempt counter passed through
},

Suggested fix:

// payloadSchema in route.ts
const payloadSchema = z.object({
  invoiceId: z.string(),
  attempt: z.number().int().min(0).default(0),
});

// After fundSettlement check in route.ts
const MAX_SETTLEMENT_RETRIES = 5;
if (!fundSettlement.fundsAvailable && attempt >= MAX_SETTLEMENT_RETRIES) {
  return logAndRespond(
    `Exceeded max settlement retries (${MAX_SETTLEMENT_RETRIES}) for invoice ${invoiceId}.`,
  );
}

// In scheduleDelayedPayouts call
await scheduleDelayedPayouts({
  invoice,
  executeAt: fundSettlementTiming.scheduledAt,
  attempt: attempt + 1,
});

// In utils.ts scheduleDelayedPayouts body
body: {
  invoiceId: invoice.id,
  attempt,
},

🟡 Bug — QStash Dedup Window Shorter Than Scheduled Delay

File: utils.ts

The comment in scheduleDelayedPayouts states the deduplication window is 10 minutes, but scheduledAt is set to available_on + 15 minutes. If the route is somehow triggered twice within 15 minutes (e.g., QStash retries the original message), the second invocation would see the dedup already expired and schedule a second delayed job.

deduplicationId: `retry-delayed-payouts-${invoice.id}`, // The deduplication window is 10 minutes
// ⚠️ But delay can be 15+ minutes — dedup expires before the job fires

This is partially mitigated by the flowControl rate limiter (rate: 1), which serializes jobs for the same invoice.id. But flowControl doesn't deduplicate — it only throttles. Verify with QStash docs whether the dedup window can be extended, or use flowControl as the primary guard and drop the deduplication reliance.


🟡 Bug — Balance Transaction Selection Without Type Filter

File: utils.ts, Line 56

stripe.balanceTransactions.list({ source: chargeId }) can return multiple entries (e.g., the original charge, partial refund fees, dispute fees). The API returns them in reverse-chronological order by default, so data[0] may be a refund or fee entry rather than the original charge, giving a wrong available_on timestamp.

// ⚠️ data[0] might be a refund fee or dispute fee, not the original charge
const balanceTransaction = balanceTransactions.data[0];

Suggested fix:

const balanceTransaction = balanceTransactions.data.find(
  (bt) => bt.type === "charge",
);

if (!balanceTransaction) {
  return {
    fundsAvailable: false,
    scheduledAt: new Date(Date.now() + 60 * 60 * 1000),
  };
}

🟢 Minor — Unused paymentMethod in queueTremendousPayouts Pick type

File: queue-tremendous-payouts.ts

paymentMethod appears in the Pick type but is never referenced in the function body.

// paymentMethod is picked but never used
invoice: Pick<Invoice, "id" | "paymentMethod" | "payoutMode" | "programId">;
- invoice: Pick<Invoice, "id" | "paymentMethod" | "payoutMode" | "programId">;
+ invoice: Pick<Invoice, "id" | "payoutMode" | "programId">;

🟢 Minor — Hardcoded 1-Hour Fallback

File: utils.ts, Line ~62

scheduledAt: new Date(now + 60 * 60 * 1000), // 1 hour from now

Should be a named constant for clarity: const SETTLEMENT_FALLBACK_DELAY_MS = 60 * 60 * 1000;


✅ Security Assessment

Check Status
verifyQstashSignature guards the endpoint ✅ Present — only QStash can invoke this route
Raw SQL parameterization (${invoice.id}) ✅ Safe — Prisma's tagged template escapes values
External data validated before use (stripeChargeMetadataSchema.parse) ✅ Present
Stablecoin aggregate scoped to status: "processing" ✅ Fixed — prevents cross-status amount mismatch on re-runs
Idempotency key reuse risk on fundFinancialAccount ✅ Safe — same invoiceId key with same status: "processing" filter guarantees same amount

The one operationally significant security risk is the infinite scheduling loop: runaway QStash jobs consume quota and incur cost without a bound. This is the single most important fix needed before merging.

✅ Action performed

Full review finished.

@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 (2)
apps/web/lib/partners/create-stripe-transfer.ts (1)

302-309: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Undefined payout access when currentInvoicePayouts is empty.

When invoiceId is omitted (optional parameter), currentInvoicePayouts is set to Promise.resolve([]) (line 81–94). If previouslyProcessedPayouts has items, the function proceeds to line 303 where const payout = currentInvoicePayouts[0] evaluates to undefined. In the non-forceWithdrawal branch (line 309), accessing payout.amount and payout.program.name causes a runtime crash.

Proposed fix: Use the first available payout (from currentInvoicePayouts if present, otherwise previouslyProcessedPayouts) to ensure the email subject and body reference a valid payout.

💥 Proposed fix to safely select payout for email
  if (partner.email) {
-   const payout = currentInvoicePayouts[0];
+   const payout = currentInvoicePayouts[0] || previouslyProcessedPayouts[0];
+   if (!payout) {
+     // Should never happen as allPayouts is non-empty at line 101, but guard against unforeseen edge cases
+     console.warn(`[createStripeTransfer] No payout available for email, skipping email send`);
+   } else {
      const emailRes = await sendEmail({
        variant: "notifications",
        to: partner.email,
        subject: forceWithdrawal
          ? `A withdrawal of ${currencyFormatter(totalTransferableAmount)} has been initiated from your Dub account`
          : `You've received a ${currencyFormatter(payout.amount)} payout from ${payout.program.name}`,
        react: forceWithdrawal
          ? PartnerPayoutForceWithdrawal({
              email: partner.email,
              payout: {
                amount: totalTransferableAmount,
                method: "connect",
              },
            })
          : PartnerPayoutProcessed({
              email: partner.email,
              program: payout.program,
              payout,
            }),
      });

      console.log(`Resend email sent: ${JSON.stringify(emailRes, null, 2)}`);
+   }
  }
🤖 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/partners/create-stripe-transfer.ts` around lines 302 - 309, The
code attempts to access the first element of currentInvoicePayouts without
checking if the array is empty, which can result in undefined when invoiceId is
omitted. In the sendEmail call around line 302-309, replace the direct
assignment of payout with logic that safely selects the first available payout
from either currentInvoicePayouts (if it contains items) or
previouslyProcessedPayouts as a fallback. This ensures that the payout object
used in the email subject line (specifically when accessing payout.amount and
payout.program.name in the non-forceWithdrawal branch) is always defined and
prevents the runtime crash.
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts (1)

21-45: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Cap delayed payout reschedules.

The delayed job body only carries invoiceId, so every re-run starts fresh. If Stripe still returns no usable settlement transaction, this path can keep scheduling itself indefinitely. Carry an attempt counter through QStash and stop/alert after a small cap.

🛠️ Proposed fix
-import { APP_DOMAIN_WITH_NGROK } from "`@dub/utils`";
+import { APP_DOMAIN_WITH_NGROK, log } from "`@dub/utils`";
 import * as z from "zod/v4";
 
+const MAX_DELAYED_PAYOUT_ATTEMPTS = 5;
+
 // Schedule the QStash job
 export async function scheduleDelayedPayouts({
   invoice,
   executeAt,
+  retryCount = 0,
 }: {
   invoice: Pick<Invoice, "id">;
   executeAt: Date;
+  retryCount?: number;
 }) {
+  if (retryCount >= MAX_DELAYED_PAYOUT_ATTEMPTS) {
+    await log({
+      message: `Exceeded delayed payout retry limit for invoice ${invoice.id}; not scheduling another delayed payout job.`,
+      type: "cron",
+    });
+    return;
+  }
+
   const scheduleTimeMs = executeAt.getTime();
@@
     body: {
       invoiceId: invoice.id,
+      retryCount: retryCount + 1,
     },
   });

Also parse and pass retryCount in the route payload before calling this helper.

Also applies to: 84-90, 104-113

🤖 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/cron/payouts/charge-succeeded/utils.ts around lines 21
- 45, The scheduleDelayedPayouts function needs to track retry attempts to
prevent indefinite rescheduling loops. Modify the function signature to accept a
retryCount parameter, increment it, and include it in the QStash message body
alongside invoiceId. Add validation logic to check if retryCount has exceeded a
reasonable cap (such as 3-5 attempts) and either stop scheduling or log an alert
when the limit is reached. Apply these changes throughout the file where
scheduleDelayedPayouts is called, ensuring the retryCount is passed as part of
the initial call and incremented appropriately in subsequent retry paths.
🧹 Nitpick comments (2)
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts (1)

17-21: ⚡ Quick win

Keep the funds-available guard at the Tremendous boundary.

The route currently skips this call when funds are unavailable, but this exported function can still enqueue Tremendous payouts if reused directly. Accept fundsAvailable and return early here too.

🛠️ Proposed fix
 export async function queueTremendousPayouts({
   invoice,
+  fundsAvailable,
 }: {
   invoice: Pick<Invoice, "id" | "paymentMethod" | "payoutMode" | "programId">;
+  fundsAvailable: boolean;
 }) {
+  if (!fundsAvailable) {
+    console.log(
+      `Funds are not available for invoice ${invoice.id}, skipping Tremendous payouts...`,
+    );
+    return;
+  }
+
   if (invoice.payoutMode === "external") {

Update the route call to pass fundsAvailable as well.

🤖 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/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts
around lines 17 - 21, The exported function queueTremendousPayouts lacks a guard
to prevent enqueueing Tremendous payouts when funds are unavailable, even though
its caller (the route) currently checks this condition. Add a fundsAvailable
parameter to the function's destructured object parameter alongside invoice,
then add an early return guard at the start of the function body to return
immediately if fundsAvailable is false. Update the route's call to
queueTremendousPayouts to pass the fundsAvailable value along with the invoice
parameter.
apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts (1)

10-14: ⚡ Quick win

Keep the funds-available guard at the PayPal boundary.

The route currently skips this call when funds are unavailable, but this exported function can still dispatch PayPal payouts if reused directly. Accept fundsAvailable and return early here too.

🛠️ Proposed fix
 export async function sendPaypalPayouts({
   invoice,
+  fundsAvailable,
 }: {
   invoice: Pick<Invoice, "id" | "payoutMode">;
+  fundsAvailable: boolean;
 }) {
+  if (!fundsAvailable) {
+    console.log(
+      `Funds are not available for invoice ${invoice.id}, skipping PayPal payouts...`,
+    );
+    return;
+  }
+
   if (invoice.payoutMode === "external") {

Update the route call to pass fundsAvailable as well.

🤖 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/cron/payouts/charge-succeeded/send-paypal-payouts.ts
around lines 10 - 14, The sendPaypalPayouts function lacks a funds-availability
guard at the function level, so it can still dispatch PayPal payouts if called
directly even when funds are unavailable. Add fundsAvailable as a parameter to
the function signature (alongside the existing invoice parameter in the
destructured object), then add an early return guard at the beginning of the
function body that exits if funds are not available. Finally, update the route
caller to pass the fundsAvailable value when invoking sendPaypalPayouts to
ensure the guard is properly checked.
🤖 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/cron/payouts/charge-succeeded/utils.ts:
- Around line 78-94: The current code uses balanceTransactions.list() and
selects data[0], which depends on unpredictable list response order. Instead,
replace the stripe.balanceTransactions.list() call with
stripe.charges.retrieve(chargeId, { expand: ["balance_transaction"] }) to
directly retrieve the specific balance transaction associated with the charge.
Access the balance transaction from the returned charge object's
balance_transaction property instead of selecting from a list, and update the
condition that checks if balanceTransactions.data.length === 0 to check if the
balance_transaction property exists or is null.

---

Outside diff comments:
In `@apps/web/app/`(ee)/api/cron/payouts/charge-succeeded/utils.ts:
- Around line 21-45: The scheduleDelayedPayouts function needs to track retry
attempts to prevent indefinite rescheduling loops. Modify the function signature
to accept a retryCount parameter, increment it, and include it in the QStash
message body alongside invoiceId. Add validation logic to check if retryCount
has exceeded a reasonable cap (such as 3-5 attempts) and either stop scheduling
or log an alert when the limit is reached. Apply these changes throughout the
file where scheduleDelayedPayouts is called, ensuring the retryCount is passed
as part of the initial call and incremented appropriately in subsequent retry
paths.

In `@apps/web/lib/partners/create-stripe-transfer.ts`:
- Around line 302-309: The code attempts to access the first element of
currentInvoicePayouts without checking if the array is empty, which can result
in undefined when invoiceId is omitted. In the sendEmail call around line
302-309, replace the direct assignment of payout with logic that safely selects
the first available payout from either currentInvoicePayouts (if it contains
items) or previouslyProcessedPayouts as a fallback. This ensures that the payout
object used in the email subject line (specifically when accessing payout.amount
and payout.program.name in the non-forceWithdrawal branch) is always defined and
prevents the runtime crash.

---

Nitpick comments:
In
`@apps/web/app/`(ee)/api/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts:
- Around line 17-21: The exported function queueTremendousPayouts lacks a guard
to prevent enqueueing Tremendous payouts when funds are unavailable, even though
its caller (the route) currently checks this condition. Add a fundsAvailable
parameter to the function's destructured object parameter alongside invoice,
then add an early return guard at the start of the function body to return
immediately if fundsAvailable is false. Update the route's call to
queueTremendousPayouts to pass the fundsAvailable value along with the invoice
parameter.

In `@apps/web/app/`(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts:
- Around line 10-14: The sendPaypalPayouts function lacks a funds-availability
guard at the function level, so it can still dispatch PayPal payouts if called
directly even when funds are unavailable. Add fundsAvailable as a parameter to
the function signature (alongside the existing invoice parameter in the
destructured object), then add an early return guard at the beginning of the
function body that exits if funds are not available. Finally, update the route
caller to pass the fundsAvailable value when invoking sendPaypalPayouts to
ensure the guard is properly checked.
🪄 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: d92d2442-8cc7-42d6-909c-bda65dacdc62

📥 Commits

Reviewing files that changed from the base of the PR and between 37874a0 and 08846c8.

📒 Files selected for processing (6)
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts
  • apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts
  • apps/web/lib/partners/create-stripe-transfer.ts

Comment thread apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts
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