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

Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Aug 18, 2025

Summary by CodeRabbit

  • New Features
    • Cron-based processing for partner payouts and partner notification emails when a payout is initiated.
  • Improvements
    • Webhook now enqueues payout work for better responsiveness and reliability.
    • Standardized logging and responses for cron endpoints; currency-specific rounding applied.
  • Bug Fixes
    • Prevents payouts when no available balance remains.
  • Chores
    • Updated queueing library dependency.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 18, 2025

Walkthrough

Moves Stripe payout processing out of the webhook into a Qstash-enqueued cron endpoint, adds a small log-and-respond utility, and updates the Qstash dependency. The webhook now validates input and enqueues work; the cron route performs balance checks, payouts, DB updates, and notification emails.

Changes

Cohort / File(s) Summary
Webhook queue integration
apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts
Replaces inlined Stripe/DB/email logic with Qstash upsert/enqueue to /api/cron/payouts/balance-available; adds guards, logs, and async queue usage with deduplication.
Cron payout processor
apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts
New Next.js dynamic route with POST handler: Qstash validation, partner lookup by stripeAccount, Stripe balance fetch, pending payout subtraction, currency rounding, payout creation, transfer association, DB updates, email notification, and structured error handling.
Utility addition
apps/web/app/(ee)/api/cron/utils.ts
Adds export function logAndRespond(message: string, { status = 200, logLevel = "info" } = {}) which logs via console[logLevel] and returns new Response(message, { status }).
Dependency update
apps/web/package.json
Bumps @upstash/qstash from ^2.3.0 to ^2.8.2.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Stripe as Stripe
  participant Webhook as Webhook: balance-available
  participant QStash as Qstash Queue
  participant Cron as /api/cron/payouts/balance-available
  participant DB as Database
  participant StripeAPI as Stripe API
  participant Email as Email Service

  Stripe->>Webhook: Event (balance.available)
  alt Missing stripeAccount
    Webhook-->>Stripe: 200 OK (log error, exit)
  else Valid event
    Webhook->>QStash: upsert/enqueueJSON {stripeAccount} (dedupeId=event.id)
    Webhook-->>Stripe: 200 OK (enqueued)
    QStash->>Cron: POST payload (Qstash-verified)
    Cron->>DB: Find partner by stripeConnectId
    alt Partner not found or no available balance
      Cron-->>QStash: 200 OK (log and exit)
    else Eligible
      Cron->>StripeAPI: Retrieve balance (connected account)
      Cron->>StripeAPI: List pending payouts/transfers
      Cron->>StripeAPI: Create payout (amount with currency rounding)
      Cron->>DB: Update payout records with stripePayoutId
      Cron->>Email: Send PartnerPayoutWithdrawalInitiated (if email)
      Cron-->>QStash: 200 OK (completed)
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • steven-tey

Poem

I hop from hook to cron tonight,
I queue the work and hold on tight.
Balances counted, payouts set free,
Carrots, logs, and Qstash glee.
A tiny rabbit dev, stacking tasks with glee. 🥕🐇

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch stripe-queue

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@vercel
Copy link
Contributor

vercel bot commented Aug 18, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Aug 25, 2025 8:27pm

@socket-security
Copy link

socket-security bot commented Aug 18, 2025

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​upstash/​qstash@​2.3.0 ⏵ 2.8.29910010098 +1100

View full report

@devkiran devkiran marked this pull request as ready for review August 18, 2025 18:13
@devkiran devkiran marked this pull request as draft August 18, 2025 18:13
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (5)
apps/web/app/(ee)/api/cron/utils.ts (1)

1-12: Consider standardizing response shape and log routing.

Today this returns a plain text Response and logs to console. For consistency with other API helpers (e.g., handleAndReturnErrorResponse) and easier observability:

  • Optionally return JSON { message } with an explicit Content-Type.
  • Optionally route error/warn logs through the shared log({ type: "cron" | "errors" }) helper in packages/utils for Slack visibility.

Example minimal tweak for JSON response:

-  console[logLevel](message);
-  return new Response(message, { status });
+  console[logLevel](message);
+  return new Response(JSON.stringify({ message }), {
+    status,
+    headers: { "Content-Type": "application/json" },
+  });
apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (1)

5-7: Queue upsert on the hot path could be avoided.

await queue.upsert({ parallelism: 10 }) is executed for every webhook. If upsert is idempotent but billed/latency-sensitive, consider moving this to an initialization path or a separate setup script. The webhook can assume the queue exists.

apps/web/app/(ee)/api/cron/partners/balance-available/route.ts (3)

30-35: Return a non-200 status for missing partner.

This is an error condition; returning 200 can mask failures in upstream monitoring. Suggest 404 to reflect “resource not found.”

-      return logAndRespond({
+      return logAndRespond({
         message: `Partner not found with Stripe connect account ${stripeAccount}. Skipping...`,
-        logLevel: "error",
+        logLevel: "error",
+        status: 404,
       });

42-48: Consider 204 (No Content) when there’s no available balance.

For “nothing to do” paths, 204 is a better semantic fit than 200, and it keeps logs/metrics cleaner.

-      return logAndRespond({
+      return logAndRespond({
         message: `No available balance found for partner ${partner.email} (${stripeAccount}). Skipping...`,
-      });
+        status: 204,
+      });

105-125: Transfers pagination may miss older transfers.

stripe.transfers.list({ limit: 100 }) might not cover all relevant transfers. If you intend to link all previously “sent” payouts without a payout id, consider:

  • Paging through has_more until done, or
  • Restrict by a reasonable time window (e.g., since last payout) to keep within limits.

If 100 is sufficient for your data volume, feel free to ignore.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 0080bac and f8b648e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (4)
  • apps/web/app/(ee)/api/cron/partners/balance-available/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/utils.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (2 hunks)
  • apps/web/package.json (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (2)
apps/web/app/(ee)/api/cron/partners/balance-available/route.ts (5)
apps/web/app/(ee)/api/cron/utils.ts (1)
  • logAndRespond (1-12)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (1-11)
packages/email/src/templates/partner-payout-withdrawal-initiated.tsx (1)
  • PartnerPayoutWithdrawalInitiated (18-98)
packages/utils/src/functions/log.ts (1)
  • log (9-45)
apps/web/lib/api/errors.ts (1)
  • handleAndReturnErrorResponse (175-181)
apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (1)
packages/utils/src/constants/main.ts (1)
  • APP_DOMAIN_WITH_NGROK (20-25)
🔇 Additional comments (4)
apps/web/package.json (1)

47-47: QStash SDK v2.8.2 usages verified
Ran a repository-wide search for qstash.queue, enqueueJSON, and our verifyQstashSignature helper:

• apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts –
• qstash.queue({ queueName: … })
• queue.enqueueJSON({ url, method, … })
• apps/web/lib/cron/verify-qstash.ts + all cron webhook routes –
• verifyQstashSignature({ req, rawBody })

No .upsert calls were found. All signatures and options match the v2.8.2 API, and there are no known breaking changes for Node or Edge runtimes.
Upgrade approved.

apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (1)

23-29: Propagate Stripe event.id for end-to-end idempotency

Include event.id in the worker payload so the downstream job can use it as a deterministic idempotency key and avoid duplicate payouts on retries.

Please update this call in
apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts:

 await queue.enqueueJSON({
   url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/balance-available`,
   method: "POST",
   body: {
-    stripeAccount,
+    stripeAccount,
+    eventId: event.id,
   },
+  // If your QStash client (v2.8.2+) supports it, you can also pass:
+  // idempotencyKey: event.id,
 });

Let me know if you’d like to add the QStash‐level idempotencyKey here as well.

apps/web/app/(ee)/api/cron/partners/balance-available/route.ts (2)

151-153: Consistent success response status.

Success defaults to 200 via logAndRespond, which is fine. If you adopt 204 on no-ops, keeping 200 on success makes behavior predictable for monitors.


82-86: Confirmed Stripe HUF/TWD 100-unit requirement

Stripe’s documentation specifies that zero-decimal currencies HUF and TWD must be sent as an integer amount that is a multiple of 100. The existing Math.floor(availableBalance / 100) * 100 logic correctly enforces this rule. No changes needed.

@panda-sandeep
Copy link

/bug0 run

@devkiran devkiran marked this pull request as ready for review August 25, 2025 08:33
@devkiran
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 25, 2025

✅ Actions performed

Full review triggered.

@devkiran
Copy link
Collaborator Author

@coderabbitai addressed some feedback - review again, please.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 25, 2025

@devkiran I'll review the changes again to see how you've addressed the feedback.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (6)
apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (2)

5-7: Queue creation is good; consider initializing queue settings once per cold start, not per event.

Creating the queue object here is fine. The queue’s configuration (parallelism, etc.) should be ensured once per process to avoid an extra Upstash API call on every webhook event.

Apply this diff to ensure the upsert happens once per cold start and is awaited before enqueue:

 const queue = qstash.queue({
   queueName: "stripe-balance-available",
 });
+
+// Ensure queue configuration once per cold start.
+const ensureQueuePromise = queue.upsert({
+  parallelism: 10,
+});

32-32: Trim log noise and avoid dumping full enqueue responses.

Log a concise message (queue name, deduplication id, target URL) instead of the full response blob.

Apply this diff:

-  console.log(response);
+  console.log(
+    `Enqueued "balance.available" -> stripe-balance-available (dedupId=${event.id}) for account ${stripeAccount}`
+  );
apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts (4)

97-99: Description formatting doesn’t match the comment example.

Add day and year so the description renders like “Aug 1, 2025”.

This is included in the idempotency diff above; no further action needed if you apply it.


110-114: Transfer association may miss items due to pagination; consider iterating or constraining by time.

limit: 100 can be insufficient for high-volume partners; you might miss older transfers that still lack a payout id. Either paginate or bound the window by created date since the last processed payout, then iterate until done.

I can sketch a paginator if desired.

Also applies to: 115-131


151-153: Be mindful of logging PII and payload size for email send results.

Logging the entire send result can leak PII and clutter logs. Prefer a small summary (status/id).

Apply this diff:

-      console.log(
-        `Sent email to partner ${partner.email} (${stripeAccount}): ${JSON.stringify(sentEmail, null, 2)}`,
-      );
+      console.log(
+        `Sent email to partner ${partner.email} (${stripeAccount}) [template=payout-initiated, ok=${Boolean(sentEmail)}]`
+      );

156-158: Consider Slack logging on success to the “payouts” channel.

A concise Slack log helps observability and on-call. You already have @dub/utils/log.

Apply this snippet near the success return:

await log({
  message: `Processed "balance.available" for ${partner.email} (${stripeAccount}) – payout ${currencyFormatter(availableBalance / 100, { maximumFractionDigits: 2, currency })}`,
  type: "payouts",
});
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f8b648e and 3f29713.

📒 Files selected for processing (3)
  • apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/utils.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/(ee)/api/cron/utils.ts
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts (5)
apps/web/app/(ee)/api/cron/utils.ts (1)
  • logAndRespond (1-13)
packages/utils/src/functions/currency-formatter.ts (1)
  • currencyFormatter (1-11)
packages/email/src/templates/partner-payout-withdrawal-initiated.tsx (1)
  • PartnerPayoutWithdrawalInitiated (18-98)
packages/utils/src/functions/log.ts (1)
  • log (9-45)
apps/web/lib/api/errors.ts (1)
  • handleAndReturnErrorResponse (175-181)
apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (1)
packages/utils/src/constants/main.ts (1)
  • APP_DOMAIN_WITH_NGROK (20-25)
🔇 Additional comments (1)
apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (1)

23-30: I wasn’t able to locate the QStash SDK source in the repo to confirm the grouping option name for enqueueJSON. Could you please:

• Provide a link or snippet from the Upstash QStash v2.8 documentation showing support for per-key grouping/ordered delivery
• Confirm the exact option name (e.g. group, groupId, or something else) and its usage

Once we have that, we can update the call to:

 const response = await queue.enqueueJSON({
   url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/balance-available`,
   deduplicationId: event.id,
   method: "POST",
   body: {
     stripeAccount,
     eventId: event.id,
   },
+  // ← add grouping option here once confirmed, for example:
+  // groupId: stripeAccount,
 });

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (8)
apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (2)

19-21: Avoid calling queue.upsert on every event; hoist to a one-time module init.

Repeated upserts add latency and extra API calls; initialize once and await the same promise per request.

Apply this diff:

 import Stripe from "stripe";

 const queue = qstash.queue({
   queueName: "stripe-balance-available",
 });
+
+// Initialize queue once at module load.
+const ensureQueuePromise = queue.upsert({ parallelism: 10 });
@@
-  await queue.upsert({
-    parallelism: 10,
-  });
+  await ensureQueuePromise;

23-30: Pass the Stripe event id through to the worker for end-to-end idempotency.

Include eventId in the payload so the worker can use it as Stripe Idempotency-Key when creating payouts.

Apply this diff:

   const response = await queue.enqueueJSON({
     url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/balance-available`,
     deduplicationId: event.id,
     method: "POST",
     body: {
-      stripeAccount,
+      stripeAccount,
+      eventId: event.id,
     },
   });
apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts (6)

87-91: Currency-specific rounding: extend beyond HUF/TWD and centralize rules.

Stripe imposes special rounding constraints for several currencies, not just HUF/TWD. Centralize payout rounding to avoid scattered hard-codes.

Example refactor:

-    if (["huf", "twd"].includes(currency)) {
-      // For HUF and TWD, Stripe requires payout amounts to be evenly divisible by 100
-      // We need to round down to the nearest 100 units
-      availableBalance = Math.floor(availableBalance / 100) * 100;
-    }
+    const round = getPayoutRounding(currency);
+    availableBalance = round(availableBalance);

New helper (add once in a shared utils module):

// utils/stripePayoutRounding.ts
type RoundingFn = (amountMinor: number) => number;

const divisibleBy100 = (a: number) => Math.floor(a / 100) * 100;
const divisibleBy10 = (a: number) => Math.floor(a / 10) * 10;
const noRounding = (a: number) => a;

const special100 = new Set(["huf", "twd", "isk", "ugx"]);
const special10 = new Set(["bhd", "jod", "kwd", "omr", "tnd"]);

export function getPayoutRounding(currency: string): RoundingFn {
  const c = currency.toLowerCase();
  if (special100.has(c)) return divisibleBy100;
  if (special10.has(c)) return divisibleBy10;
  return noRounding;
}
Confirm the current Stripe payout amount constraints per currency (zero-decimal and special divisibility rules) to validate the sets above.

12-14: Carry eventId from the webhook; validate it and make it available to downstream calls.

Add eventId to the payload schema and parse it. This unblocks idempotent payout creation later.

Apply this diff:

-const payloadSchema = z.object({
-  stripeAccount: z.string(),
-});
+const payloadSchema = z.object({
+  stripeAccount: z.string(),
+  eventId: z.string(),
+});
@@
-    const { stripeAccount } = payloadSchema.parse(JSON.parse(rawBody));
+    const { stripeAccount, eventId } = payloadSchema.parse(JSON.parse(rawBody));

Also applies to: 22-23


47-55: Fix multi-currency and zero-amount edge case: don’t assume index 0.

Pick the first positive-amount entry (or the desired payout currency, if you have one). Index 0 can be zero while another currency has funds.

Apply this diff:

-    // Check if there's any available balance
-    if (balance.available.length === 0 || balance.available[0].amount === 0) {
+    // Select a positive-amount entry (avoid index assumptions)
+    const availableEntry = balance.available.find((b) => b.amount > 0);
+    if (!availableEntry) {
       return logAndRespond(
-        `No available balance found for partner ${partner.email} (${stripeAccount}). Skipping...`,
+        `No available balance found for partner ${partner.email} (${stripeAccount}). Skipping...`,
       );
     }
@@
-    const { amount, currency } = balance.available[0];
+    const { amount, currency } = availableEntry;

56-79: Pending/in_transit calculation misses “in_transit” and may mix currencies. Fetch both statuses and filter by currency.

You request only "pending" then later try to subtract in-transit amounts; also ensure you only subtract payouts of the same currency.

Apply this diff:

-    const { data: stripePayouts } = await stripe.payouts.list(
-      {
-        status: "pending",
-      },
-      {
-        stripeAccount,
-      },
-    );
-
-    let availableBalance = amount;
-
-    // Subtract the pending/in-transit payouts from the available balance
-    if (stripePayouts.length > 0) {
-      const pendingOrInTransitPayouts = stripePayouts.filter(
-        ({ status }) => status === "pending" || status === "in_transit",
-      );
-
-      const alreadyPaidOutAmount = pendingOrInTransitPayouts.reduce(
-        (acc, payout) => acc + payout.amount,
-        0,
-      );
-
-      availableBalance = availableBalance - alreadyPaidOutAmount;
-    }
+    const [{ data: pending }, { data: inTransit }] = await Promise.all([
+      stripe.payouts.list({ status: "pending", limit: 100 }, { stripeAccount }),
+      stripe.payouts.list({ status: "in_transit", limit: 100 }, { stripeAccount }),
+    ]);
+
+    let availableBalance = amount;
+    const relevantPayouts = [...pending, ...inTransit].filter(
+      (p) => p.currency === currency,
+    );
+    if (relevantPayouts.length > 0) {
+      const alreadyPaidOutAmount = relevantPayouts.reduce(
+        (acc, p) => acc + p.amount,
+        0,
+      );
+      availableBalance -= alreadyPaidOutAmount;
+    }

93-104: Add Stripe idempotency key when creating the payout.

Protects against duplicate payouts on retries; use the eventId from payload.

Apply this diff:

     const stripePayout = await stripe.payouts.create(
       {
         amount: availableBalance,
         currency,
         // example: "Dub Partners auto-withdrawal (Aug 1, 2025)"
-        description: `Dub Partners auto-withdrawal (${formatDate(new Date(), { month: "short" })})`,
+        description: `Dub Partners auto-withdrawal (${formatDate(new Date(), { month: "short", day: "numeric", year: "numeric" })})`,
         method: "standard",
       },
       {
-        stripeAccount,
+        stripeAccount,
+        idempotencyKey: `payout:${stripeAccount}:${availableBalance}:${Date.now()}:${eventId}`,
       },
     );

159-168: Type-safe error logging; avoid accessing .message on unknown.

Normalize the error first to preserve details and satisfy TS.

Apply this diff:

-  } catch (error) {
-    await log({
-      message: `Error handling "balance.available" ${error.message}.`,
-      type: "errors",
-    });
-
-    console.error(error);
-
-    return handleAndReturnErrorResponse(error);
-  }
+  } catch (error) {
+    const err =
+      error instanceof Error
+        ? error
+        : new Error(typeof error === "string" ? error : JSON.stringify(error));
+    await log({
+      message: `Error handling "balance.available": ${err.message}`,
+      type: "errors",
+    });
+    console.error(error);
+    return handleAndReturnErrorResponse(err);
+  }
🧹 Nitpick comments (3)
apps/web/app/(ee)/api/cron/utils.ts (1)

1-13: Simple, reusable log+Response helper — consider setting a Content-Type header.

Current implementation is fine. Minor improvement: explicitly set Content-Type to text/plain for predictable clients.

Apply this minimal change:

   console[logLevel](message);
-  return new Response(message, { status });
+  return new Response(message, {
+    status,
+    headers: { "Content-Type": "text/plain; charset=utf-8" },
+  });
apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (1)

32-33: Enqueue logging: add minimal context and avoid dumping entire response objects.

Log messageId (or status) plus stripeAccount and event.id for traceability; avoid logging entire response payloads in case of internal metadata.

Example:

-  console.log(response);
+  console.log(
+    "Enqueued balance.available",
+    { stripeAccount, eventId: event.id, qstashMessageId: response.messageId }
+  );
apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts (1)

81-85: Formatting amounts: consider currency-aware division instead of “/ 100”.

Stripe amounts are in the smallest currency unit; dividing by 100 breaks zero-decimal and three-decimal currencies. Use a currency-decimals map or a helper that derives decimals from Stripe metadata.

You can introduce a small helper and reuse it across logs:

// utils/stripeAmount.ts
const DECIMALS: Record<string, number> = {
  // zero-decimal
  bIF: 0, clp: 0, djf: 0, gnf: 0, jpy: 0, kmf: 0, krw: 0, mga: 0, pyg: 0,
  rwf: 0, ugx: 0, vnd: 0, vuv: 0, xaf: 0, xof: 0, xpf: 0, huf: 0, twd: 0,
  // three-decimal examples
  bhd: 3, jod: 3, kwd: 3, omr: 3, tnd: 3,
};
export function toMajorUnit(amountMinor: number, currency: string) {
  const d = DECIMALS[currency.toLowerCase()] ?? 2;
  return amountMinor / 10 ** d;
}

Then:

-`... ${currencyFormatter(availableBalance / 100, { maximumFractionDigits: 2, currency })} ...`
+`... ${currencyFormatter(toMajorUnit(availableBalance, currency), { maximumFractionDigits: 2, currency })} ...`

And similarly where you log stripePayout.amount.

Also applies to: 106-109

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 509133c and 3f29713.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (4)
  • apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/utils.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (2 hunks)
  • apps/web/package.json (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-07-11T16:28:55.693Z
Learnt from: devkiran
PR: dubinc/dub#2635
File: packages/prisma/schema/payout.prisma:24-25
Timestamp: 2025-07-11T16:28:55.693Z
Learning: In the Dub codebase, multiple payout records can now share the same stripeTransferId because payouts are grouped by partner and processed as single Stripe transfers. This is why the unique constraint was removed from the stripeTransferId field in the Payout model - a single transfer can include multiple payouts for the same partner.

Applied to files:

  • apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts
🔇 Additional comments (3)
apps/web/package.json (1)

47-47: @upstash/qstash version consolidation confirmed

  • apps/web/package.json declares "@upstash/qstash": "^2.8.2" and no other workspace package references QStash.
  • pnpm-lock.yaml contains only two entries for @upstash/[email protected], confirming the lockfile was updated.
  • QStash API is used via qstash.queue() in
    apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts:5.

Everything is aligned on v2.8.2 across the monorepo and the new queue API is in use. Ready to merge.

apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (1)

12-17: Early guard for missing connect account is correct.

Short-circuiting on a missing event.account avoids unnecessary queue calls and downstream errors. No changes needed.

apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts (1)

110-114: Update-many linkage looks consistent with schema change; good use of transfers filter.

Using destination: stripeAccount ensures transfer ids belong to the same connected account, and updating “sent” payouts lacking a payout id aligns with the removed uniqueness constraint on stripeTransferId.

Also applies to: 119-130

@devkiran devkiran requested a review from steven-tey August 25, 2025 08:53
@devkiran
Copy link
Collaborator Author

@coderabbitai, are there any breaking changes? Final review please

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 25, 2025

@devkiran I'll analyze the breaking changes and provide a final review.

Breaking Changes Analysis

⚠️ Potential Breaking Change Identified:

The main behavioral change in this PR is the shift from synchronous to asynchronous processing in the Stripe balance webhook:

Before: The webhook processed payouts, database updates, and email notifications synchronously within the webhook request
After: The webhook immediately queues the work and returns, with actual processing happening later via cron

Impact:

  • Response Time: Webhooks now respond much faster (just queuing vs full processing)
  • Processing Timing: Payouts, database updates, and email notifications are delayed until the cron job runs
  • Error Handling: Errors now occur in the cron job rather than the webhook, potentially affecting error visibility
  • External Dependencies: If any external systems expect synchronous completion of payout processing, they may be affected

Non-Breaking Changes:

  • ✅ Function signatures remain unchanged (balanceAvailable export signature is preserved)
  • ✅ New utility functions and endpoints are additions, not modifications
  • ✅ Dependency version bump (@upstash/qstash ^2.3.0 → ^2.8.2) appears to be a minor/patch update

Recommendation: This change is functionally breaking in terms of timing and processing model, even though the API contract remains the same. Consider:

  1. Documenting this behavioral change
  2. Monitoring the new cron-based processing for reliability
  3. Ensuring proper error handling and alerting in the cron job
✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (8)
apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (2)

23-30: Carry Stripe event id through to the worker for idempotency

Include event.id in the enqueued body so the worker can set Stripe idempotency keys. This prevents duplicate payouts on retries.

-  const response = await queue.enqueueJSON({
+  const response = await queue.enqueueJSON({
     url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/balance-available`,
     deduplicationId: event.id,
     method: "POST",
     body: {
-      stripeAccount,
+      stripeAccount,
+      eventId: event.id,
     },
   });

19-21: Don’t upsert the queue on every event; initialize once

Calling queue.upsert per webhook adds latency and unnecessary API calls. There’s also an inconsistency: a previous review claims this was addressed, but this file still calls upsert in the hot path.

-  await queue.upsert({
-    parallelism: 10,
-  });
+// module-scope one-time init (top of file)
+const ensureQueuePromise = queue.upsert({ parallelism: 10 });
+
+// inside handler, just await the promise (fast if already resolved)
+await ensureQueuePromise;
apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts (6)

87-91: Currency-specific payout rounding is incomplete; centralize rules

Only handling HUF/TWD misses other Stripe-specific constraints. Extract rules to a helper and apply uniformly.

-    if (["huf", "twd"].includes(currency)) {
-      // For HUF and TWD, Stripe requires payout amounts to be evenly divisible by 100
-      // We need to round down to the nearest 100 units
-      availableBalance = Math.floor(availableBalance / 100) * 100;
-    }
+    // Apply currency-specific payout rounding (centralized util)
+    availableBalance = getPayoutRounding(currency)(availableBalance);

Add import at the top:

import { getPayoutRounding } from "@/lib/stripe/payoutRounding";

Proposed utility (new file), keeping the lists easy to update as Stripe changes:

// apps/web/lib/stripe/payoutRounding.ts
type RoundingFn = (amount: number) => number;

const divisibleBy100 = (a: number) => Math.floor(a / 100) * 100;
const divisibleBy10 = (a: number) => Math.floor(a / 10) * 10;
const noRounding = (a: number) => a;

// TODO: verify these lists against current Stripe docs at release time.
const special100 = new Set(["huf", "twd", "isk", "ugx"]);
const special10 = new Set(["bhd", "jod", "kwd", "omr", "tnd"]);

export function getPayoutRounding(currency: string): RoundingFn {
  const c = currency.toLowerCase();
  if (special100.has(c)) return divisibleBy100;
  if (special10.has(c)) return divisibleBy10;
  return noRounding;
}

159-168: Type-safe error logging; avoid .message on unknown

Normalize the error before accessing .message to keep type safety and preserve details.

-  } catch (error) {
-    await log({
-      message: `Error handling "balance.available" ${error.message}.`,
-      type: "errors",
-    });
-
-    console.error(error);
-
-    return handleAndReturnErrorResponse(error);
-  }
+  } catch (error) {
+    const msg =
+      error instanceof Error
+        ? error.message
+        : typeof error === "string"
+          ? error
+          : JSON.stringify(error);
+    await log({
+      message: `Error handling "balance.available": ${msg}`,
+      type: "errors",
+    });
+    console.error(error);
+    return handleAndReturnErrorResponse(error);
+  }

47-55: Handle multi-currency balances; don’t assume index 0

Index 0 can be zero while other currencies are positive. Pick a positive-amount entry (or your default payout currency).

-    // Check if there's any available balance
-    if (balance.available.length === 0 || balance.available[0].amount === 0) {
+    // Check if there's any available balance (prefer first positive entry)
+    const availableEntry = balance.available.find((b) => b.amount > 0);
+    if (!availableEntry) {
       return logAndRespond(
-        `No available balance found for partner ${partner.email} (${stripeAccount}). Skipping...`,
+        `No available balance found for partner ${partner.email} (${stripeAccount}). Skipping...`,
       );
     }
-
-    const { amount, currency } = balance.available[0];
+    const { amount, currency } = availableEntry;

56-79: Also subtract “in_transit” payouts and match currency

You currently request only status "pending", but later try to subtract "in_transit" as well; those never arrive. Fetch both and filter by currency.

-    const { data: stripePayouts } = await stripe.payouts.list(
-      {
-        status: "pending",
-      },
-      {
-        stripeAccount,
-      },
-    );
+    const [{ data: pending }, { data: inTransit }] = await Promise.all([
+      stripe.payouts.list({ status: "pending", limit: 100 }, { stripeAccount }),
+      stripe.payouts.list({ status: "in_transit", limit: 100 }, { stripeAccount }),
+    ]);
+    const stripePayouts = [...pending, ...inTransit].filter(
+      (p) => p.currency === currency,
+    );
 
     let availableBalance = amount;
 
     // Subtract the pending/in-transit payouts from the available balance
     if (stripePayouts.length > 0) {
-      const pendingOrInTransitPayouts = stripePayouts.filter(
-        ({ status }) => status === "pending" || status === "in_transit",
-      );
-
-      const alreadyPaidOutAmount = pendingOrInTransitPayouts.reduce(
+      const alreadyPaidOutAmount = stripePayouts.reduce(
         (acc, payout) => acc + payout.amount,
         0,
       );
 
       availableBalance = availableBalance - alreadyPaidOutAmount;
     }

93-104: Add Stripe idempotency key and include a full, dated description

Prevent duplicate payouts on retries and make descriptions auditable. Use the eventId (pass it from the webhook) for idempotency.

-    const stripePayout = await stripe.payouts.create(
+    const stripePayout = await stripe.payouts.create(
       {
         amount: availableBalance,
         currency,
-        // example: "Dub Partners auto-withdrawal (Aug 1, 2025)"
-        description: `Dub Partners auto-withdrawal (${formatDate(new Date(), { month: "short" })})`,
+        // example: "Dub Partners auto-withdrawal (Aug 25, 2025)"
+        description: `Dub Partners auto-withdrawal (${formatDate(new Date(), { month: "short", day: "numeric", year: "numeric" })})`,
         method: "standard",
       },
       {
         stripeAccount,
+        idempotencyKey: `payout:${stripeAccount}:${eventId}`,
       },
     );

Note: This requires adding eventId to the request payload and schema (see separate comment).


12-23: Include eventId in payload schema and parse it

Needed to build idempotency keys and for traceability.

 const payloadSchema = z.object({
-  stripeAccount: z.string(),
+  stripeAccount: z.string(),
+  eventId: z.string(),
 });
@@
-    const { stripeAccount } = payloadSchema.parse(JSON.parse(rawBody));
+    const { stripeAccount, eventId } = payloadSchema.parse(JSON.parse(rawBody));
🧹 Nitpick comments (3)
apps/web/app/(ee)/api/cron/utils.ts (1)

1-13: Optionally return JSON and include context for better observability

Consider extending to support JSON payloads and structured context (e.g., partner, stripeAccount, eventId). This improves log searchability and API clients.

 export function logAndRespond(
-  message: string,
+  message: string,
   {
     status = 200,
     logLevel = "info",
+    json,
+    context,
   }: {
     status?: number;
     logLevel?: "error" | "warn" | "info";
+    json?: unknown;
+    context?: Record<string, unknown>;
   } = {},
 ) {
-  console[logLevel](message);
-  return new Response(message, { status });
+  console[logLevel](context ? `${message} ${JSON.stringify(context)}` : message);
+  if (json !== undefined) {
+    return new Response(JSON.stringify(json), {
+      status,
+      headers: { "content-type": "application/json" },
+    });
+  }
+  return new Response(message, { status });
 }
apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts (2)

110-130: Optional: reduce Stripe API calls and scope DB updates

Fetching the last 100 transfers and updating all matching payouts might touch unrelated historical records. Consider:

  • Querying prisma.payout for candidate records (status "sent", stripePayoutId null), pull their stripeTransferId values, and only then fetch Stripe transfers for those IDs to confirm/account match.
  • Or rely on your existing invariant that transfer IDs are unique and belong to the destination account, and keep as-is.

16-18: Per-account concurrency guard (optional but recommended)

Even with deduplication by event.id, two distinct events for the same connected account can race and create overlapping payouts. Consider:

  • QStash queue-level or message grouping (if supported) to serialize on stripeAccount, or
  • A short-lived Redis lock keyed by stripeAccount in this handler.

If you want, I can draft a tiny redis-lock helper tailored to your @upstash/redis setup.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 509133c and 3f29713.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (4)
  • apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts (1 hunks)
  • apps/web/app/(ee)/api/cron/utils.ts (1 hunks)
  • apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (2 hunks)
  • apps/web/package.json (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-07-11T16:28:55.693Z
Learnt from: devkiran
PR: dubinc/dub#2635
File: packages/prisma/schema/payout.prisma:24-25
Timestamp: 2025-07-11T16:28:55.693Z
Learning: In the Dub codebase, multiple payout records can now share the same stripeTransferId because payouts are grouped by partner and processed as single Stripe transfers. This is why the unique constraint was removed from the stripeTransferId field in the Payout model - a single transfer can include multiple payouts for the same partner.

Applied to files:

  • apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts
🔇 Additional comments (3)
apps/web/package.json (1)

47-47: Upgrade @upstash/qstash to ^2.8.2: Usage audit & lockfile

I’ve inventoried all @upstash/qstash imports and queue‐related calls:

• Imports of QStash SDK
– apps/web/lib/cron/verify-qstash.ts (imports Receiver)
– apps/web/lib/cron/index.ts (imports Client)

• Calls to the new queue API
– apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts
const queue = qstash.queue({ queueName: "stripe-balance-available" })
await queue.upsert({...})
await queue.enqueueJSON({ url, deduplicationId: event.id, … })

(All other queue() methods in lib/tolt, lib/partnerstack, etc., are custom wrappers around publishJSON and unaffected.)

Please verify that in the Stripe webhook handler:

  • Client.queue() options still match the v2.8.2 signature (e.g. parameter names and types for upsert() and enqueueJSON()).
  • No other modules import or call queue methods in a breaking way.
  • The lockfile is committed after bumping to ensure CI/prod install the exact ^2.8.2 version.

Once confirmed, commit the updated lockfile so our CI and production environments resolve the same package version.

apps/web/app/(ee)/api/cron/utils.ts (1)

1-13: Utility is concise and pragmatic

Simple, typed, and does exactly what’s needed for cron routes. Approved.

apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts (1)

24-24: No changes needed for APP_DOMAIN_WITH_NGROK

The APP_DOMAIN_WITH_NGROK constant already resolves to the correct, publicly reachable URL in production and preview environments—falling back to ngrok (or localhost) only in local development:

  • NEXT_PUBLIC_VERCEL_ENV === "production": https://app.${NEXT_PUBLIC_APP_DOMAIN}
  • NEXT_PUBLIC_VERCEL_ENV === "preview": https://preview.${NEXT_PUBLIC_APP_DOMAIN}
  • otherwise: process.env.NEXT_PUBLIC_NGROK_URL || "http://localhost:8888"

Ensure that the NEXT_PUBLIC_VERCEL_ENV variable is set appropriately in each environment. There isn’t an absoluteBaseUrl utility in the codebase, so no replacement is necessary.

(Optional nit) If the “_WITH_NGROK” suffix is confusing—since it isn’t used in prod or preview—you might consider renaming the constant (e.g. APP_ABSOLUTE_DOMAIN).

@steven-tey steven-tey merged commit f92967e into main Aug 25, 2025
7 of 8 checks passed
@steven-tey steven-tey deleted the stripe-queue branch August 25, 2025 22:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants