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

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
e0fc230
move the invoice to advanced settings
devkiran Jul 10, 2025
11ab726
add sent to PayoutStatus
devkiran Jul 10, 2025
3b9b5d5
Update partner-payout-settings-modal.tsx
devkiran Jul 10, 2025
6f475de
Update partner-payout-settings-modal.tsx
devkiran Jul 10, 2025
0c88486
Update payout-methods-dropdown.tsx
devkiran Jul 10, 2025
bafafb1
Update payout-methods-dropdown.tsx
devkiran Jul 10, 2025
776d1d0
update the new stats UI
devkiran Jul 10, 2025
8c708d7
Enhance PayoutStats component with dynamic tooltips and update Payout…
devkiran Jul 10, 2025
2a77654
Update payout-stats.tsx
devkiran Jul 10, 2025
51ea5a6
Update payout-stats.tsx
devkiran Jul 10, 2025
cbe7b89
Update payout-stats.tsx
devkiran Jul 10, 2025
bf5320e
Update payout-stats.tsx
devkiran Jul 10, 2025
9702afa
Update send-stripe-payouts.ts
devkiran Jul 10, 2025
124daf5
Update use-partner-payouts.ts
devkiran Jul 10, 2025
0c214a1
wip "payout.paid"
devkiran Jul 10, 2025
7f6e6a3
Update payout-stats.tsx
devkiran Jul 10, 2025
8befba2
update URLs
devkiran Jul 10, 2025
38140ee
your funds are on their way to your bank"
devkiran Jul 10, 2025
518a173
Update payout-stats.tsx
devkiran Jul 10, 2025
b4b2430
Update payout-stats.tsx
devkiran Jul 10, 2025
bde5b6b
Enhance Stripe webhook handling: update balanceAvailable to associate…
devkiran Jul 10, 2025
dc610b5
Merge branch 'main' into payout-updates
steven-tey Jul 10, 2025
5fa8017
update emails
steven-tey Jul 10, 2025
7e58a47
Merge branch 'main' into payout-updates
steven-tey Jul 10, 2025
4a76a05
Refactor payout processing
devkiran Jul 11, 2025
8c35d66
Refactor payout email notifications and update payout model schema
devkiran Jul 11, 2025
3f2f18b
Enhance payout processing logic and update UI tooltips for clarity
devkiran Jul 11, 2025
63e5038
Improve error logging in Stripe webhook handlers for better clarity a…
devkiran Jul 11, 2025
e0b9ae6
Update payout-stats.tsx
devkiran Jul 11, 2025
6017666
adjust column
devkiran Jul 11, 2025
3e597a4
Merge branch 'main' into payout-updates
devkiran Jul 11, 2025
51c52fa
Update utils.ts
devkiran Jul 11, 2025
db2aa0a
Update send-paypal-payouts.ts
devkiran Jul 11, 2025
158182b
Update payout-paid.ts
devkiran Jul 11, 2025
c49f3b9
Update send-stripe-payouts.ts
devkiran Jul 11, 2025
96c7b51
Update payouts-item-succeeded.ts
devkiran Jul 11, 2025
97b7d13
Update payout-stats.tsx
devkiran Jul 11, 2025
6b03cf2
Update partner-payout-confirmed.tsx
devkiran Jul 11, 2025
1dc4d14
updated payouts logic
steven-tey Jul 11, 2025
433b894
update icon
steven-tey Jul 11, 2025
dc4fd46
Merge branch 'main' into payout-updates
steven-tey Jul 11, 2025
e111702
improve PartnerPayoutProcessed
steven-tey Jul 11, 2025
f2f4f10
Update payout-stats.tsx
steven-tey Jul 11, 2025
ca03b06
convert to sheet
steven-tey Jul 11, 2025
5d4977e
improve mobile view
steven-tey Jul 12, 2025
18aa725
finalize email templates
steven-tey Jul 12, 2025
8dff05c
final changes
steven-tey Jul 12, 2025
f0b4beb
Update route.ts
steven-tey Jul 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 2 additions & 32 deletions apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { prisma } from "@dub/prisma";
import { log } from "@dub/utils";
import { sendPaypalPayouts } from "./send-paypal-payouts";
import { sendStripePayouts } from "./send-stripe-payouts";
import { payloadSchema, Payouts } from "./utils";
import { payloadSchema } from "./utils";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -51,44 +51,14 @@ export async function POST(req: Request) {
);
}

const payouts = await prisma.payout.findMany({
where: {
invoiceId,
status: {
not: "completed",
},
partner: {
payoutsEnabledAt: {
not: null,
},
},
},
include: {
partner: true,
program: true,
},
});

let stripePayouts: Payouts[] = [];
let paypalPayouts: Payouts[] = [];

payouts.forEach((payout) => {
if (payout.partner.stripeConnectId) {
stripePayouts.push(payout);
} else if (payout.partner.paypalEmail) {
paypalPayouts.push(payout);
}
});

await Promise.allSettled([
sendStripePayouts({
payload: body,
payouts: stripePayouts,
invoice,
}),

sendPaypalPayouts({
payload: body,
payouts: paypalPayouts,
}),
]);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,71 @@
import { createPayPalBatchPayout } from "@/lib/paypal/create-batch-payout";
import { Payload, Payouts } from "./utils";
import { resend } from "@dub/email/resend";
import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants";
import PartnerPayoutProcessed from "@dub/email/templates/partner-payout-processed";
import { prisma } from "@dub/prisma";
import { Payload } from "./utils";

export async function sendPaypalPayouts({ payload }: { payload: Payload }) {
const { invoiceId } = payload;

const payouts = await prisma.payout.findMany({
where: {
invoiceId,
status: {
not: "completed",
},
partner: {
payoutsEnabledAt: {
not: null,
},
paypalEmail: {
not: null,
},
},
},
include: {
partner: {
select: {
email: true,
paypalEmail: true,
},
},
program: {
select: {
name: true,
logo: true,
},
},
},
});

export async function sendPaypalPayouts({
payload,
payouts,
}: {
payload: Payload;
payouts: Payouts[];
}) {
if (payouts.length === 0) {
console.log("No payouts for sending via PayPal, skipping...");
return;
}

const { invoiceId } = payload;

await createPayPalBatchPayout({
const batchPayout = await createPayPalBatchPayout({
payouts,
invoiceId,
});

console.log("PayPal batch payout created", batchPayout);

const batchEmails = await resend?.batch.send(
payouts
.filter((payout) => payout.partner.email)
.map((payout) => ({
from: VARIANT_TO_FROM_MAP.notifications,
to: payout.partner.email!,
subject: "You've been paid!",
react: PartnerPayoutProcessed({
email: payout.partner.email!,
program: payout.program,
payout,
variant: "paypal",
}),
})),
);

console.log("Resend batch emails sent", batchEmails);
}
Original file line number Diff line number Diff line change
@@ -1,79 +1,177 @@
import {
BELOW_MIN_WITHDRAWAL_FEE_CENTS,
MIN_WITHDRAWAL_AMOUNT_CENTS,
} from "@/lib/partners/constants";
import { stripe } from "@/lib/stripe";
import { sendEmail } from "@dub/email";
import PartnerPayoutSent from "@dub/email/templates/partner-payout-sent";
import PartnerPayoutProcessed from "@dub/email/templates/partner-payout-processed";
import { prisma } from "@dub/prisma";
import { Payload, Payouts } from "./utils";
import { currencyFormatter, pluralize } from "@dub/utils";
import { Invoice } from "@prisma/client";
import { Payload } from "./utils";

export async function sendStripePayouts({
payload,
payouts,
invoice,
}: {
payload: Payload;
payouts: Payouts[];
invoice: Invoice;
}) {
const { invoiceId } = payload;

const payouts = await prisma.payout.findMany({
where: {
status: {
in: ["processing", "processed"],
},
stripeTransferId: null,
partner: {
payoutsEnabledAt: {
not: null,
},
stripeConnectId: {
not: null,
},
},
},
include: {
partner: {
select: {
id: true,
email: true,
stripeConnectId: true,
minWithdrawalAmount: true,
},
},
program: {
select: {
id: true,
name: true,
logo: true,
},
},
},
});

if (payouts.length === 0) {
console.log("No payouts for sending via Stripe, skipping...");
return;
}

const { invoiceId, chargeId, achCreditTransfer } = payload;
const latestInvoicePayout = payouts.find((p) => p.invoiceId === invoiceId)!;
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Potential issue: Unsafe non-null assertion.

The non-null assertion ! assumes an invoice payout exists, but this could fail if the invoice contains no payouts.

Add proper validation:

-const latestInvoicePayout = payouts.find((p) => p.invoiceId === invoiceId)!;
+const latestInvoicePayout = payouts.find((p) => p.invoiceId === invoiceId);
+if (!latestInvoicePayout) {
+  console.log(`No payout found for invoice ${invoiceId}, skipping...`);
+  return;
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const latestInvoicePayout = payouts.find((p) => p.invoiceId === invoiceId)!;
const latestInvoicePayout = payouts.find((p) => p.invoiceId === invoiceId);
if (!latestInvoicePayout) {
console.log(`No payout found for invoice ${invoiceId}, skipping...`);
return;
}
🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-stripe-payouts.ts at
line 61, the code uses a non-null assertion operator (!) on the result of
payouts.find, which can cause runtime errors if no matching payout is found.
Replace the non-null assertion with a check to verify that latestInvoicePayout
is not undefined before using it, and handle the case where no payout is found
appropriately, such as returning early or throwing a descriptive error.


// Group payouts by partnerId
const payoutsByPartner = payouts.reduce((map, payout) => {
const { partner } = payout;

if (!map.has(partner.id)) {
map.set(partner.id, []);
}

map.get(partner.id)!.push(payout);

return map;
}, new Map<string, typeof payouts>());

for (const payout of payouts) {
// Process payouts for each partner
for (const [_, payouts] of payoutsByPartner) {
let withdrawalFee = 0;
const partner = payouts[0].partner;
const payoutIds = payouts.map((p) => p.id);
const totalAmount = payouts.reduce((acc, payout) => acc + payout.amount, 0);

// Total payout amount is less than the minimum withdrawal amount
// we only update status to "processed" – no need to create a transfer for now
if (totalAmount < partner.minWithdrawalAmount) {
await prisma.payout.updateMany({
where: {
id: {
in: payoutIds,
},
},
data: {
status: "processed",
},
});

console.log(
`Total processed payouts (${currencyFormatter(totalAmount / 100)}) for partner ${partner.id} are below the minWithdrawalAmount (${currencyFormatter(partner.minWithdrawalAmount / 100)}), skipping...`,
);

continue;
}

// Decide if we need to charge a withdrawal fee for the partner
if (partner.minWithdrawalAmount < MIN_WITHDRAWAL_AMOUNT_CENTS) {
withdrawalFee = BELOW_MIN_WITHDRAWAL_FEE_CENTS;
}

// Minus the withdrawal fee from the total amount
const updatedBalance = totalAmount - withdrawalFee;

if (updatedBalance <= 0) {
continue;
}

// Create a transfer for the partner combined payouts and update it as sent
const transfer = await stripe.transfers.create(
{
amount: payout.amount,
amount: updatedBalance,
currency: "usd",
transfer_group: invoiceId,
destination: payout.partner.stripeConnectId!,
description: `Dub Partners payout (${payout.program.name})`,
...(!achCreditTransfer
? {
source_transaction: chargeId,
}
: {}),
destination: partner.stripeConnectId!,
description: `Dub Partners payout for ${payouts.map((p) => p.id).join(", ")}`,
},
{
idempotencyKey: `${invoiceId}-${partner.id}`,
},
{ idempotencyKey: payout.id }, // add idempotency key to avoid duplicate transfers
);

console.log(`Transfer created for payout ${payout.id}`, transfer);
console.log(
`Transfer of ${currencyFormatter(totalAmount / 100)} (${transfer.id}) created for partner ${partner.id} for ${pluralize(
"payout",
payouts.length,
)} ${payouts.map((p) => p.id).join(", ")}`,
);

await Promise.allSettled([
prisma.payout.update({
prisma.payout.updateMany({
where: {
id: payout.id,
id: {
in: payoutIds,
},
},
data: {
stripeTransferId: transfer.id,
status: "completed",
status: "sent",
paidAt: new Date(),
},
}),

prisma.commission.updateMany({
where: {
payoutId: payout.id,
payoutId: {
in: payoutIds,
},
},
data: {
status: "paid",
},
}),

payout.partner.email &&
sendEmail({
subject: "You've been paid!",
email: payout.partner.email,
react: PartnerPayoutSent({
email: payout.partner.email,
program: payout.program,
payout: {
id: payout.id,
amount: payout.amount,
startDate: payout.periodStart,
endDate: payout.periodEnd,
},
}),
variant: "notifications",
}),
partner.email
? sendEmail({
variant: "notifications",
subject: "You've been paid!",
email: partner.email,
react: PartnerPayoutProcessed({
email: partner.email,
program: latestInvoicePayout.program,
payout: latestInvoicePayout,
variant: "stripe",
}),
})
: Promise.resolve(),
]);

// sleep for 250ms
Expand Down
6 changes: 0 additions & 6 deletions apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Partner, Payout, Program } from "@dub/prisma/client";
import { z } from "zod";

export const payloadSchema = z.object({
Expand All @@ -7,9 +6,4 @@ export const payloadSchema = z.object({
achCreditTransfer: z.boolean(),
});

export type Payouts = Payout & {
partner: Partner;
program: Program;
};

export type Payload = z.infer<typeof payloadSchema>;
Loading