From c00021d02e38d864b8919b9f11f6273cd56246a8 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 24 Oct 2025 23:50:40 -0700 Subject: [PATCH 1/6] Handle payouts in batches of 100 --- .../cron/payouts/charge-succeeded/route.ts | 31 +++++++++++++++++-- .../charge-succeeded/send-paypal-payouts.ts | 1 + .../charge-succeeded/send-stripe-payouts.ts | 1 + 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts index fe0847f1238..2b05480c0ba 100644 --- a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts @@ -1,8 +1,10 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; -import { log } from "@dub/utils"; +import { APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; import { z } from "zod"; +import { logAndRespond } from "../../utils"; import { sendPaypalPayouts } from "./send-paypal-payouts"; import { sendStripePayouts } from "./send-stripe-payouts"; @@ -88,7 +90,32 @@ export async function POST(req: Request) { }), ]); - return new Response(`Invoice ${invoiceId} processed.`); + if (invoice._count.payouts > 100) { + console.log( + "More than 100 payouts found for invoice, scheduling next batch...", + ); + const qstashResponse = await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/charge-succeeded`, + body: { + invoiceId: invoiceId, + }, + }); + if (qstashResponse.messageId) { + console.log( + `Message sent to Qstash with id ${qstashResponse.messageId}`, + ); + } else { + console.error("Error sending message to Qstash", qstashResponse); + } + + return logAndRespond( + `Completed processing current batch of payouts for invoice ${invoiceId}. Next batch scheduled.`, + ); + } + + return logAndRespond( + `Completed processing all payouts for invoice ${invoiceId}.`, + ); } catch (error) { await log({ message: `Error sending payouts for invoice: ${error.message}`, diff --git a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts index 670fc2f0178..948ee230652 100644 --- a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts +++ b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts @@ -33,6 +33,7 @@ export async function sendPaypalPayouts({ invoiceId }: { invoiceId: string }) { }, }, }, + take: 100, }); if (payouts.length === 0) { diff --git a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts index d1cbfd24e67..64340011db1 100644 --- a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts +++ b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts @@ -42,6 +42,7 @@ export async function sendStripePayouts({ }, }, include: commonInclude, + take: 100, }); if (currentInvoicePayouts.length === 0) { From ea6619ebc16ae4b429853e274481ded823f72e9d Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 24 Oct 2025 23:57:15 -0700 Subject: [PATCH 2/6] final changes --- .../(ee)/api/cron/payouts/charge-succeeded/route.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts index 2b05480c0ba..f96b3c65ba9 100644 --- a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts @@ -19,9 +19,9 @@ const stripeChargeMetadataSchema = z.object({ }); // POST /api/cron/payouts/charge-succeeded -// This route is used to process the charge-succeeded event from Stripe -// we're intentionally offloading this to a cron job to avoid blocking the main thread -// so that we can return a 200 to Stripe immediately +// This route is used to process the charge-succeeded event from Stripe. +// We're intentionally offloading this to a cron job so we can return a 200 to Stripe immediately. +// We'll also be calling this route recursively to process payouts in batches of 100. export async function POST(req: Request) { try { const rawBody = await req.text(); @@ -38,9 +38,7 @@ export async function POST(req: Request) { select: { payouts: { where: { - status: { - not: "completed", - }, + status: "processing", }, }, }, From ed7cfc71b1dab2c88be9c91c240fdd79199c2ca6 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sat, 25 Oct 2025 10:48:05 -0700 Subject: [PATCH 3/6] update sendPaypalPayouts --- .../charge-succeeded/send-paypal-payouts.ts | 16 +++++++++++++--- .../api/paypal/webhook/payouts-item-failed.ts | 4 ++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts index 948ee230652..72ce2b3b706 100644 --- a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts +++ b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts @@ -7,9 +7,7 @@ export async function sendPaypalPayouts({ invoiceId }: { invoiceId: string }) { const payouts = await prisma.payout.findMany({ where: { invoiceId, - status: { - not: "completed", - }, + status: "processing", partner: { payoutsEnabledAt: { not: null, @@ -48,6 +46,18 @@ export async function sendPaypalPayouts({ invoiceId }: { invoiceId: string }) { console.log("PayPal batch payout created", batchPayout); + // update the payouts to "sent" status + const updatedPayouts = await prisma.payout.updateMany({ + where: { + id: { in: payouts.map((p) => p.id) }, + }, + data: { + status: "sent", + paidAt: new Date(), + }, + }); + console.log(`Updated ${updatedPayouts.count} payouts to "sent" status`); + const batchEmails = await sendBatchEmail( payouts .filter((payout) => payout.partner.email) diff --git a/apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts b/apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts index 245e0e956ba..83490f45b2d 100644 --- a/apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts +++ b/apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts @@ -9,10 +9,10 @@ const PAYPAL_TO_DUB_STATUS = { "PAYMENT.PAYOUTS-ITEM.CANCELED": "canceled", "PAYMENT.PAYOUTS-ITEM.DENIED": "failed", "PAYMENT.PAYOUTS-ITEM.FAILED": "failed", - "PAYMENT.PAYOUTS-ITEM.HELD": "processing", + "PAYMENT.PAYOUTS-ITEM.HELD": "processed", "PAYMENT.PAYOUTS-ITEM.REFUNDED": "failed", "PAYMENT.PAYOUTS-ITEM.RETURNED": "failed", - "PAYMENT.PAYOUTS-ITEM.UNCLAIMED": "processing", + "PAYMENT.PAYOUTS-ITEM.UNCLAIMED": "processed", }; export async function payoutsItemFailed(event: any) { From f4c5b4fcb210c60e3b8b53c9bfe5a8d90b4a00b8 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sat, 25 Oct 2025 10:49:55 -0700 Subject: [PATCH 4/6] missed a spot: --- apps/web/app/(ee)/api/paypal/webhook/payouts-item-succeeded.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/paypal/webhook/payouts-item-succeeded.ts b/apps/web/app/(ee)/api/paypal/webhook/payouts-item-succeeded.ts index 1267244f11e..a00e0ddbb6c 100644 --- a/apps/web/app/(ee)/api/paypal/webhook/payouts-item-succeeded.ts +++ b/apps/web/app/(ee)/api/paypal/webhook/payouts-item-succeeded.ts @@ -45,7 +45,7 @@ export async function payoutsItemSucceeded(event: any) { data: { paypalTransferId: payoutItemId, status: "completed", - paidAt: new Date(), + paidAt: payout.paidAt ?? new Date(), // preserve the paidAt if it already exists }, }), From dae80008734938dd199bb080fc3bdc770297fd8c Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sat, 25 Oct 2025 12:18:51 -0700 Subject: [PATCH 5/6] fix updated payoutStatus --- apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts b/apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts index 83490f45b2d..dd6a39b186c 100644 --- a/apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts +++ b/apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts @@ -58,9 +58,9 @@ export async function payoutsItemFailed(event: any) { }, }); - if (payoutStatus === "processing") { + if (payoutStatus === "processed") { await log({ - message: `Paypal payout is stuck in processing for invoice ${invoiceId} and partner ${paypalEmail}. PayPal webhook status: ${body.event_type}.${ + message: `Paypal payout is stuck in processed for invoice ${invoiceId} and partner ${paypalEmail}. PayPal webhook status: ${body.event_type}.${ failureReason ? ` Failure reason: ${failureReason}` : "" }`, type: "errors", From 8612d728ef8a9b84b380b8b57dc54d8ada99dc04 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Sat, 25 Oct 2025 12:24:56 -0700 Subject: [PATCH 6/6] improve logs --- .../api/cron/payouts/charge-succeeded/route.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts index f96b3c65ba9..fdfaa4c8eee 100644 --- a/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts @@ -47,14 +47,12 @@ export async function POST(req: Request) { }); if (!invoice) { - console.log(`Invoice with id ${invoiceId} not found.`); - return new Response(`Invoice with id ${invoiceId} not found.`); + return logAndRespond(`Invoice ${invoiceId} not found.`); } if (invoice._count.payouts === 0) { - console.log("No payouts found with status not completed, skipping..."); - return new Response( - `No payouts found with status not completed for invoice ${invoiceId}`, + return logAndRespond( + `No payouts found with status 'processing' for invoice ${invoiceId}, skipping...`, ); } @@ -103,7 +101,11 @@ export async function POST(req: Request) { `Message sent to Qstash with id ${qstashResponse.messageId}`, ); } else { - console.error("Error sending message to Qstash", qstashResponse); + // should never happen but just in case + await log({ + message: `Error sending message to Qstash to schedule next batch of payouts for invoice ${invoiceId}: ${JSON.stringify(qstashResponse)}`, + type: "errors", + }); } return logAndRespond(