-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Refactor Stripe balance webhook to use QStash queue #2758
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
WalkthroughMoves 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
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 unit tests
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this 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_moreuntil 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.
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis 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 forqstash.queue,enqueueJSON, and ourverifyQstashSignaturehelper:• 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
.upsertcalls 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 Stripeevent.idfor end-to-end idempotencyInclude
event.idin 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
idempotencyKeyhere 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 requirementStripe’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.
|
/bug0 run |
ea17ddf to
f7d87c3
Compare
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
|
@coderabbitai addressed some feedback - review again, please. |
|
@devkiran I'll review the changes again to see how you've addressed the feedback. ✅ Actions performedReview triggered.
|
There was a problem hiding this 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.
📒 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 forenqueueJSON. 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 usageOnce 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, });
There was a problem hiding this 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
eventIdin 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
eventIdto 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
eventIdfrom 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) plusstripeAccountandevent.idfor 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.
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis 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.accountavoids 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: stripeAccountensures transfer ids belong to the same connected account, and updating “sent” payouts lacking a payout id aligns with the removed uniqueness constraint onstripeTransferId.Also applies to: 119-130
|
@coderabbitai, are there any breaking changes? Final review please |
|
@devkiran I'll analyze the breaking changes and provide a final review. Breaking Changes AnalysisThe 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 Impact:
Non-Breaking Changes:
Recommendation: This change is functionally breaking in terms of timing and processing model, even though the API contract remains the same. Consider:
✅ Actions performedFull review triggered. |
There was a problem hiding this 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 idempotencyInclude 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 onceCalling 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 rulesOnly 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 unknownNormalize 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 0Index 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 currencyYou 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 descriptionPrevent 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 itNeeded 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 observabilityConsider 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 updatesFetching 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.
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis 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 & lockfileI’ve inventoried all
@upstash/qstashimports and queue‐related calls:• Imports of QStash SDK
– apps/web/lib/cron/verify-qstash.ts (importsReceiver)
– apps/web/lib/cron/index.ts (importsClient)• 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 inlib/tolt,lib/partnerstack, etc., are custom wrappers aroundpublishJSONand 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 forupsert()andenqueueJSON()).- 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 pragmaticSimple, 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_NGROKThe
APP_DOMAIN_WITH_NGROKconstant 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_ENVvariable is set appropriately in each environment. There isn’t anabsoluteBaseUrlutility 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).
Summary by CodeRabbit