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
33 commits
Select commit Hold shift + click to select a range
d85ad68
remove minPayoutAmount
devkiran Jul 7, 2025
cfb70f4
add minWithdrawalAmount
devkiran Jul 7, 2025
d4d3701
Add small withdrawal fee constant, implement manual payout schedule i…
devkiran Jul 7, 2025
41f8540
run format
devkiran Jul 7, 2025
83d9484
send amount
devkiran Jul 7, 2025
a7c39ba
Implement balance available webhook and refactor payout logic in Stri…
devkiran Jul 7, 2025
1caae2f
source_transaction
devkiran Jul 7, 2025
6624d61
Update programs.ts
devkiran Jul 7, 2025
5cf8c83
Update constants.ts
devkiran Jul 7, 2025
be4c11d
Update route.ts
devkiran Jul 7, 2025
99d9c35
Update route.ts
devkiran Jul 7, 2025
5419088
Update route.ts
devkiran Jul 7, 2025
64f9140
Update split-payouts.ts
devkiran Jul 7, 2025
79e88b8
Update confirm-payouts.ts
devkiran Jul 7, 2025
c7f23a4
revert minPayoutAmount
devkiran Jul 7, 2025
9263a1e
Update earnings-table.tsx
devkiran Jul 7, 2025
99770ef
Update payout-table.tsx
devkiran Jul 7, 2025
2b66cda
revert some of the changes
devkiran Jul 7, 2025
68428eb
Update payouts.ts
devkiran Jul 7, 2025
0077c97
Update check-pending-paypal-payouts.ts
devkiran Jul 7, 2025
31b3457
Update commission-status-badges.tsx
devkiran Jul 7, 2025
f4a409e
Merge branch 'main' into minimum-withdrawal-amount
devkiran Jul 7, 2025
0959d63
Update constants.ts
devkiran Jul 7, 2025
2ed9a97
Update programs.ts
devkiran Jul 7, 2025
72ecb53
Update convert-currency.ts
devkiran Jul 7, 2025
04d4a62
Update balance-available.ts
devkiran Jul 7, 2025
8f28378
remove DUB_MIN_PAYOUT_AMOUNT_CENTS
steven-tey Jul 7, 2025
8b0f6ba
move payout settings
steven-tey Jul 7, 2025
1b6b5f2
small changes
steven-tey Jul 7, 2025
f7293f6
Update balance-available.ts
steven-tey Jul 7, 2025
c8fc5fa
Update balance-available.ts
steven-tey Jul 7, 2025
51db0dc
final changes
steven-tey Jul 7, 2025
3329b98
improve logs
steven-tey Jul 8, 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
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { limiter } from "@/lib/cron/limiter";
import { verifyVercelSignature } from "@/lib/cron/verify-vercel";
import { DUB_MIN_PAYOUT_AMOUNT_CENTS } from "@/lib/partners/constants";
import { sendEmail } from "@dub/email";
import ProgramPayoutReminder from "@dub/email/templates/program-payout-reminder";
import { prisma } from "@dub/prisma";
Expand Down Expand Up @@ -31,7 +30,7 @@ export async function GET(req: Request) {
const programsWithCustomMinPayouts = await prisma.program.findMany({
where: {
minPayoutAmount: {
gt: DUB_MIN_PAYOUT_AMOUNT_CENTS,
gt: 0,
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Verify the behavioral impact of broadened filtering criteria.

The change from DUB_MIN_PAYOUT_AMOUNT_CENTS to 0 broadens the selection criteria to include any positive amounts rather than those above the previous $100 threshold. This will likely increase the number of reminder emails sent to program owners.

Please confirm this behavioral change is intentional and aligns with the business requirements:

Also applies to: 43-43


🏁 Script executed:

#!/bin/bash
# Check if there are any tests or documentation that need updating
rg -A 5 -B 5 "payout.*reminder|reminder.*payout" --type md --type ts

# Look for any configuration that might control reminder frequency
rg -A 3 -B 3 "13.*25-31.*1-5|cron.*reminder" --type ts

Length of output: 9464


Document & test the broadened payout reminder threshold

The filter in both cron reminder endpoints now uses gt: 0 instead of gt: DUB_MIN_PAYOUT_AMOUNT_CENTS, but there are no tests or docs updated to reflect the removal of the previous ~$100 minimum. Please confirm this change aligns with business requirements and add/update:

  • Automated tests to cover the new “any positive amount” behavior
  • Documentation (code comments or markdown) noting that reminders are sent for all positive payouts

Locations to update:

  • apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts (lines 33 & 43: gt: DUB_MIN_PAYOUT_AMOUNT_CENTSgt: 0)
  • apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts (corresponding groupBy filter)
🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts at line
33, the payout filter was changed from gt: DUB_MIN_PAYOUT_AMOUNT_CENTS to gt: 0,
broadening the threshold to any positive amount. Confirm this change matches
business requirements, then add automated tests verifying reminders trigger for
all positive payouts, including amounts below the previous ~$100 minimum. Also
update or add documentation in this file and related partner reminders file to
clearly state that reminders now apply to all positive payouts.

},
},
});
Expand All @@ -41,7 +40,7 @@ export async function GET(req: Request) {
where: {
status: "pending",
amount: {
gte: DUB_MIN_PAYOUT_AMOUNT_CENTS,
gt: 0,
},
programId: {
notIn: programsWithCustomMinPayouts.map((p) => p.id),
Expand Down
156 changes: 156 additions & 0 deletions apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { ZERO_DECIMAL_CURRENCIES } from "@/lib/analytics/convert-currency";
import {
BELOW_MIN_WITHDRAWAL_FEE_CENTS,
MIN_WITHDRAWAL_AMOUNT_CENTS,
} from "@/lib/partners/constants";
import { stripe } from "@/lib/stripe";
import { redis } from "@/lib/upstash";
import { prisma } from "@dub/prisma";
import { currencyFormatter, log } from "@dub/utils";
import Stripe from "stripe";

export async function balanceAvailable(event: Stripe.Event) {
const stripeAccount = event.account;

if (!stripeAccount) {
console.error("Stripe account not found. Skipping...");
return;
}

const partner = await prisma.partner.findUnique({
where: {
stripeConnectId: stripeAccount,
},
});

if (!partner) {
console.error(
`Partner not found with stripeConnectId ${stripeAccount}. Skipping...`,
);
return;
}

// Get the partner's current balance
const balance = await stripe.balance.retrieve({
stripeAccount,
});

// Check if there's any available balance
if (balance.available.length === 0 || balance.available[0].amount === 0) {
console.log(
`No available balance found for partner ${partner.email} (${stripeAccount}). Skipping...`,
);
return;
}

const { amount, currency } = balance.available[0];

let availableBalance = amount;
let convertedUsdAmount = amount;

if (currency !== "usd") {
const fxRates = await redis.hget("fxRates:usd", currency.toUpperCase());

if (!fxRates) {
console.error(
`Failed to get exchange rate from Redis for ${currency}. Skipping...`,
);
return;
}

convertedUsdAmount = availableBalance / Number(fxRates);

const isZeroDecimalCurrency = ZERO_DECIMAL_CURRENCIES.includes(
currency.toUpperCase(),
);

if (isZeroDecimalCurrency) {
convertedUsdAmount = convertedUsdAmount * 100;
}
}

// Check minimum withdrawal amount
if (convertedUsdAmount < partner.minWithdrawalAmount) {
console.log(
`The available balance (${currencyFormatter(convertedUsdAmount / 100, { maximumFractionDigits: 2 })}) for partner ${partner.email} (${stripeAccount}) is less than their minimum withdrawal amount (${currencyFormatter(partner.minWithdrawalAmount / 100, { maximumFractionDigits: 2 })})`,
);
return;
}

let withdrawalFee = 0;

// 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;

const transfers = await stripe.transfers.list({
destination: stripeAccount,
});

if (transfers.data.length === 0) {
console.error(
`No transfers found for partner ${partner.email} (${stripeAccount}). Skipping...`,
);
return;
}

// Find the latest transfer that's large enough to cover the withdrawal fee
const suitableTransfer = transfers.data
.filter((transfer) => transfer.amount >= withdrawalFee)
.sort((a, b) => b.created - a.created)[0];

// This should never happen, but just in case
if (!suitableTransfer) {
const errorMessage = `Error processing withdrawal for partner ${partner.email} (${stripeAccount}): No transfer found with amount >= withdrawal fee (${currencyFormatter(withdrawalFee / 100)}). Available transfers: ${transfers.data
.map((t) => currencyFormatter(t.amount / 100))
.join(", ")}. Skipping...`;
console.error(errorMessage);
await log({
message: errorMessage,
type: "errors",
mention: true,
});
return;
}

// Charge the withdrawal fee to the partner's account
await stripe.transfers.createReversal(suitableTransfer.id, {
amount: withdrawalFee,
description: "Dub Partners withdrawal fee",
});

// If the withdrawal fee was charged, we need to fetch the partner's updated balance
const updatedBalance = await stripe.balance.retrieve({
stripeAccount,
});

if (
updatedBalance.available.length === 0 ||
updatedBalance.available[0].amount === 0
) {
// this should never happen, but just in case
console.log(
`No available balance found after withdrawal fee for partner ${partner.email} (${stripeAccount}). Skipping...`,
);
return;
}

availableBalance = updatedBalance.available[0].amount;
}

const payout = await stripe.payouts.create(
{
amount: availableBalance,
currency,
description: "Dub Partners payout",
method: "standard",
},
{
stripeAccount,
},
);

console.log(
`Stripe payout created for partner ${partner.email} (${stripeAccount}): ${payout.id} (${currencyFormatter(payout.amount / 100, { maximumFractionDigits: 2 })})`,
);
}
11 changes: 9 additions & 2 deletions apps/web/app/(ee)/api/stripe/connect/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import { log } from "@dub/utils";
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { accountUpdated } from "./account-updated";
import { balanceAvailable } from "./balance-available";

const relevantEvents = new Set(["account.updated"]);
const relevantEvents = new Set(["account.updated", "balance.available"]);

// POST /api/stripe/connect/webhook – listen to Stripe Connect webhooks (for connected accounts)
export const POST = async (req: Request) => {
const buf = await req.text();
const sig = req.headers.get("Stripe-Signature") as string;
const sig = req.headers.get("Stripe-Signature");
const webhookSecret = process.env.STRIPE_CONNECT_WEBHOOK_SECRET;
let event: Stripe.Event;

try {
if (!sig || !webhookSecret) return;
event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);
Expand All @@ -28,17 +30,22 @@ export const POST = async (req: Request) => {
status: 200,
});
}

try {
switch (event.type) {
case "account.updated":
await accountUpdated(event);
break;
case "balance.available":
await balanceAvailable(event);
break;
}
} catch (error) {
await log({
message: `Stripe webhook failed. Error: ${error.message}`,
type: "errors",
});

return new Response('Webhook error: "Webhook handler failed. View logs."', {
status: 400,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ function PayoutDetailsSheetContent({ payout }: PayoutDetailsSheetProps) {

return (
<div>
<div className="flex items-center justify-between border-b border-neutral-200 px-6 py-4 h-16">
<div className="flex h-16 items-center justify-between border-b border-neutral-200 px-6 py-4">
<Sheet.Title className="text-lg font-semibold">
Payout details
</Sheet.Title>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export function EarningsTablePartner({ limit }: { limit?: number }) {
holdingPeriodDays:
programEnrollment?.program.holdingPeriodDays ?? 0,
minPayoutAmount:
programEnrollment?.program.minPayoutAmount ?? 10000,
programEnrollment?.program.minPayoutAmount ?? 0,
supportEmail:
programEnrollment?.program.supportEmail ?? "[email protected]",
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function CreateClawbackSheetContent(
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
<div className="sticky top-0 z-10 border-b border-neutral-200 bg-white">
<div className="flex items-center justify-between px-6 py-4 h-16">
<div className="flex h-16 items-center justify-between px-6 py-4">
<Sheet.Title className="text-lg font-semibold">
Create clawback
</Sheet.Title>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ function CreateCommissionSheetContent(props: CreateCommissionSheetProps) {
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
<div className="sticky top-0 z-10 border-b border-neutral-200 bg-white">
<div className="flex items-center justify-between px-6 py-4 h-16">
<div className="flex h-16 items-center justify-between px-6 py-4">
<Sheet.Title className="text-lg font-semibold">
Create commission
</Sheet.Title>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
<div className="sticky top-0 z-10 border-b border-neutral-200 bg-white">
<div className="flex items-center justify-between px-6 py-4 h-16">
<div className="flex h-16 items-center justify-between px-6 py-4">
<Sheet.Title className="text-lg font-semibold">
Invite partner
</Sheet.Title>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { PageContent } from "@/ui/layout/page-content";
import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper";
import { ProgramPayoutsPageClient } from "./page-client";
import { ProgramPayoutSettingsButton } from "./program-payout-settings-button";

export default function ProgramPayoutsPage() {
return (
Expand All @@ -11,6 +12,7 @@ export default function ProgramPayoutsPage() {
"Learn more about how you can send payouts to your affiliate partners globally with Dub.",
href: "https://dub.co/help/article/partner-payouts",
}}
controls={<ProgramPayoutSettingsButton />}
>
<PageWidthWrapper>
<ProgramPayoutsPageClient />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ function PayoutDetailsSheetContent({ payout }: PayoutDetailsSheetProps) {

return (
<div className="flex h-full flex-col">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-neutral-200 bg-white px-6 py-4 h-16">
<div className="sticky top-0 z-10 flex h-16 items-center justify-between border-b border-neutral-200 bg-white px-6 py-4">
<Sheet.Title className="text-lg font-semibold">
{capitalize(payout.status)} payout
</Sheet.Title>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use client";

import { DUB_MIN_PAYOUT_AMOUNT_CENTS } from "@/lib/partners/constants";
import usePayoutsCount from "@/lib/swr/use-payouts-count";
import useProgram from "@/lib/swr/use-program";
import useWorkspace from "@/lib/swr/use-workspace";
Expand Down Expand Up @@ -315,8 +314,7 @@ function AmountRowItem({
maximumFractionDigits: 2,
});

const minPayoutAmount =
program?.minPayoutAmount || DUB_MIN_PAYOUT_AMOUNT_CENTS;
const minPayoutAmount = program?.minPayoutAmount || 0;

if (status === PayoutStatus.pending) {
if (amount < minPayoutAmount) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import { Button } from "@dub/ui";
import { useProgramPayoutSettingsModal } from "./program-payout-settings-modal";

export function ProgramPayoutSettingsButton() {
const { ProgramPayoutSettingsModal, setShowProgramPayoutSettingsModal } =
useProgramPayoutSettingsModal();

return (
<>
<ProgramPayoutSettingsModal />
<Button
type="button"
text="Payout settings"
variant="secondary"
onClick={() => setShowProgramPayoutSettingsModal(true)}
/>
</>
);
}
Loading