Relevant source files
The following files were used as context for generating this wiki page:
- apps/web/lib/partners/create-stablecoin-payout.ts
- apps/web/app/ee/api/cron/payouts/process/process-payouts.ts
- apps/web/app/ee/api/cron/payouts/balance-available/route.ts
- apps/web/app/ee/api/cron/payouts/send-stripe-payout/route.ts
- apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts
- apps/web/lib/partners/create-stripe-transfer.ts
- apps/web/lib/payouts/get-partner-payout-methods.ts
- apps/web/app/ee/api/cron/payouts/process/route.ts
- apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/process/route.ts
- apps/web/lib/actions/partners/confirm-payouts.ts
- apps/web/lib/tremendous/send-tremendous-payouts.ts
- apps/web/lib/payouts/recompute-partner-payout-state.ts
- apps/web/app/ee/api/cron/payouts/send-tremendous-payout/route.ts
- apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts
- apps/web/app/ee/api/cron/trigger-withdrawal/route.ts
- apps/web/app/ee/api/cron/payouts/force-withdrawals/route.ts
- apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/route.ts
- apps/web/lib/actions/partners/force-withdrawal.ts
- apps/web/ui/partners/confirm-payouts-sheet.tsx
- apps/web/app/app.dub.co/dashboard/slug/ee/program/payouts/payout-table.tsx
- apps/web/scripts/customers/framer/split-bounty-payouts.ts
- apps/web/app/ee/partners.dub.co/onboarding/onboarding/payouts/page.tsx
- apps/web/app/ee/api/cron/payouts/process/split-payouts.ts
- apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts
- apps/web/ui/partners/payouts/use-payout-connect-flow.tsx
- apps/web/ui/partners/payouts/payout-method-config.ts
- apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts
- apps/web/app/ee/api/admin/payouts/stablecoin/route.ts
- apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts
- apps/web/lib/api/payouts/get-eligible-payouts.ts
Overview
Payout settlement orchestrates the end-to-end lifecycle of partner compensation, transitioning accumulated referral commissions from pending ledger entries into disbursed funds across multiple financial rails. The system automates commission aggregation against configurable holding periods, splits large payout periods when cutoffs are applied, validates program operator funding, and dispatches multi-rail transfers through Stripe Connect, stablecoins, PayPal, and Tremendous rewards. By handling asynchronous settlement timings, currency conversions, balance availability triggers, and automated force-withdrawals for inactive accounts, the payout engine maintains financial reconciliation across complex partner networks.
Sources: apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/route.ts:12-14, apps/web/app/ee/api/cron/payouts/process/split-payouts.ts:61-108, apps/web/lib/actions/partners/confirm-payouts.ts:189-214, apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts:67-119, apps/web/lib/partners/create-stripe-transfer.ts:203-222, apps/web/lib/partners/create-stablecoin-payout.ts:180-229, apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts:58-74, apps/web/lib/tremendous/send-tremendous-payouts.ts:158-180, apps/web/app/ee/api/cron/payouts/balance-available/route.ts:134-145, apps/web/app/ee/api/cron/payouts/force-withdrawals/route.ts:34-44
Commission Aggregation and Eligibility
Scheduled Cron Workflow
The commission aggregation pipeline runs as a scheduled cron job executing once every hour (0 * * * *). The root endpoint (GET /api/cron/payouts/aggregate-due-commissions) queries all programs containing pending commissions, chunks the program IDs into batches of 50, and enqueues jobs via QStash with a 10-second incremental delay between chunks. Each job is configured with a unique deduplication ID (aggregate-due-commissions-${programId}) and flow control parallelism set to 1.
// This cron job aggregates due commissions (pending commissions that are past the partner group's holding period) into payouts.
// Runs once every hour (0 * * * *) + calls itself recursively to look through all pending commissions available.
// GET /api/cron/payouts/aggregate-due-commissions
export const GET = withCron(async () => {
const programsWithPendingCommissions = await prisma.program.findMany({
where: {
commissions: {
some: {
status: CommissionStatus.pending,
},
},
},
select: {
id: true,
},
});
const programIds = programsWithPendingCommissions.map((p) => p.id);
if (programIds.length === 0) {
return logAndRespond("No programs with due commissions found. Skipping...");
}
console.log(`Found ${programIds.length} programs with pending commissions.`);
// process 50 programs at a time to avoid overwhelming the database
const programIdChunks = chunk(programIds, 50);
for (const [index, programIdChunk] of programIdChunks.entries()) {
const delaySeconds = index * 10; // delay by 10s between program chunks
const jobs: PublishBatchRequest<{ programId: string }>[] =
programIdChunk.map((programId) => ({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/aggregate-due-commissions/process`,
body: {
programId,
},
deduplicationId: `aggregate-due-commissions-${programId}`,
label: "aggregate-due-commissions",
flowControl: {
key: `aggregate-due-commissions-${programId}`,
parallelism: 1,
},
delay: delaySeconds,
}));Sources: apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/route.ts:12-56
Holding Periods and Processing
The process worker (POST /api/cron/payouts/aggregate-due-commissions/process) validates the incoming program ID and groups partner groups by their configured holdingPeriodDays in descending order of count. For each holding period tier, the worker fetches pending commissions in batches of 100 (BATCH_SIZE), filtering for those whose creation date satisfies the holding period threshold unless the commission type is manual or referral.
const BATCH_SIZE = 100;
const inputSchema = z.object({
programId: z.string(),
});
// POST /api/cron/payouts/aggregate-due-commissions/process
export const POST = withCron(async ({ rawBody }) => {
const { programId } = inputSchema.parse(JSON.parse(rawBody));
const program = await prisma.program.findUnique({
where: {
id: programId,
},
select: {
id: true,
name: true,
workspaceId: true,
},
});
if (!program) {
return logAndRespond(`Program ${programId} not found. Skipping...`);
}
const partnerGroupsByHoldingPeriod = await prisma.partnerGroup.groupBy({
by: ["holdingPeriodDays"],
where: {
programId,
},
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
});
for (const { holdingPeriodDays } of partnerGroupsByHoldingPeriod) {
const partnerGroups = await prisma.partnerGroup.findMany({
where: {
holdingPeriodDays,
programId,
},
select: {
id: true,
},
});
console.log(
`Found ${partnerGroups.length} partner groups with holding period days: ${holdingPeriodDays}`,
);
while (true) {
const dueCommissions = await prisma.commission.findMany({
where: {
programId,
status: CommissionStatus.pending,
programEnrollment: {
groupId: {
in: partnerGroups.map((p) => p.id),
},
},
// If holding period days is greater than 0:
// we only process commissions that were created before the holding period
// but custom commissions are always included
...(holdingPeriodDays > 0
? {
OR: [
{
type: {
// includes manual + referral commissions + clawbacks
in: ["custom", "referral"],
},
},
{
createdAt: {
lt: new Date(Sources: apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/process/route.ts:21-122
Note
Custom and referral commission types (custom, referral) bypass standard holding period age checks when holdingPeriodDays > 0, allowing manual adjustments and specific referral bonuses to aggregate immediately alongside mature entries.
Sources: apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/process/route.ts:89-106
Eligibility Filtering
Eligible payouts are retrieved and filtered by evaluating minimum payout thresholds and external reward network limits. When a cutoffPeriod is supplied, associated commission earnings are recalculated and filtered against program minimums and Tremendous disbursement bounds.
| Parameter / Constant | Value / Source | Purpose |
|---|---|---|
BATCH_SIZE | 100 | Number of due commissions processed per database query iteration |
maxDuration | 600 | Maximum execution timeout in seconds for cron worker routes |
TREMENDOUS_MIN_PAYOUT_AMOUNT_CENTS | External constant | Minimum payout limit when default payout method is Tremendous |
TREMENDOUS_MAX_PAYOUT_AMOUNT_CENTS | External constant | Maximum payout limit when default payout method is Tremendous |
export async function getEligiblePayouts({
program,
cutoffPeriod,
selectedPayoutIds,
excludedPayoutIds,
pageSize,
page = 1,
}: GetEligiblePayoutsProps) {
const cutoffPeriodValue = CUTOFF_PERIOD.find(
(c) => c.id === cutoffPeriod,
)?.value;
let payouts = await prisma.payout.findMany({
where: {
...payoutIdSelectionWhere({ selectedPayoutIds, excludedPayoutIds }),
...getPayoutEligibilityFilter({ program }),
},
include: {
partner: true,
programEnrollment: {
select: {
groupId: true,
tenantId: true,
},
},
...(cutoffPeriodValue && {
commissions: {
where: {
createdAt: {
lt: cutoffPeriodValue,
},
},
},
}),
},
orderBy: {
amount: "desc",
},
...(isFinite(pageSize) && {
skip: (page - 1) * pageSize,
take: pageSize,
}),
});
if (cutoffPeriodValue) {
payouts = payouts
.map((payout) => {
const newPayoutAmount = payout.commissions.reduce((acc, commission) => {
return acc + commission.earnings;
}, 0);
return {
...payout,
amount: newPayoutAmount,
};
})
.filter((payout) => {
if (payout.amount >= program.minPayoutAmount) {
if (payout.partner.defaultPayoutMethod === "tremendous") {
return (
payout.amount >= TREMENDOUS_MIN_PAYOUT_AMOUNT_CENTS &&
payout.amount <= TREMENDOUS_MAX_PAYOUT_AMOUNT_CENTS
);
}
return true;
}
return false;
});
}Sources: apps/web/lib/api/payouts/get-eligible-payouts.ts:22-90
Payout Processing and Splitting
Cron Lifecycle and Execution
The payout processing cron job executes via POST /api/cron/payouts/process, guarded by withCron with a maximum execution duration of 600 seconds. The incoming payload is validated against processPayoutsCronSchema to extract workspace identifier, user identifier, invoice identifier, payment method identifier, cutoff period, and optional selected or excluded payout IDs.
export const dynamic = "force-dynamic";
export const maxDuration = 600; // This function can run for a maximum of 10 minutes
const processPayoutsCronSchema = z.object({
workspaceId: z.string(),
userId: z.string(),
invoiceId: z.string(),
paymentMethodId: z.string(),
cutoffPeriod: CUTOFF_PERIOD_ENUM,
selectedPayoutIds: z.array(z.string()).optional(),
excludedPayoutIds: z.array(z.string()).optional(),
});
// POST /api/cron/payouts/process
// This route is used to process payouts for a given invoice
// we're intentionally offloading this to a cron job to avoid blocking the main thread
export const POST = withCron(async ({ rawBody }) => {
try {
const {
workspaceId,
userId,
invoiceId,
paymentMethodId,
cutoffPeriod,
selectedPayoutIds,
excludedPayoutIds,
} = processPayoutsCronSchema.parse(JSON.parse(rawBody));Sources: apps/web/app/ee/api/cron/payouts/process/route.ts:10-36
The handler fetches the unique workspace including programs and the target invoice. If the target invoice is already marked with a failed status, the job exits immediately to prevent race conditions with Stripe's charge.failed webhook handlers. When a cutoffPeriod is provided, splitPayouts executes prior to payout status mutations.
const workspace = await prisma.project.findUniqueOrThrow({
where: {
id: workspaceId,
},
include: {
programs: true,
invoices: {
where: {
id: invoiceId,
},
},
},
});
// should never happen, but just in case
if (workspace.programs.length === 0) {
return logAndRespond(
`Workspace ${workspaceId} has no programs. Skipping...`,
);
}
const program = workspace.programs[0];
// should never happen, but just in case
if (workspace.invoices.length === 0) {
return logAndRespond(
`Invoice ${invoiceId} not found for workspace ${workspaceId}. Skipping...`,
);
}
const invoice = workspace.invoices[0];
// avoid race condition where Stripe's charge.failed webhook is processed before this cron job
if (invoice.status === "failed") {
return logAndRespond(
`Invoice ${invoiceId} has already been marked as failed. Skipping...`,
);
}
if (cutoffPeriod) {
await splitPayouts({
program,
cutoffPeriod,
selectedPayoutIds,
excludedPayoutIds,
});
}Sources: apps/web/app/ee/api/cron/payouts/process/route.ts:38-84
Warning
If an invoice status is already failed when the process cron executes, processing halts instantly. Do not bypass this check, as it prevents double-processing charges whose webhooks have already triggered failures.
Sources: apps/web/app/ee/api/cron/payouts/process/route.ts:70-76
Aggregate Payout Splitting Algorithm
The splitPayouts function loads eligible payouts along with their associated commissions. For each payout, commissions are partitioned into previousCommissions (created prior to cutoffPeriodValue) and currentCommissions (created on or after cutoffPeriodValue), both sorted chronologically by creation timestamp.
Sources: apps/web/app/ee/api/cron/payouts/process/split-payouts.ts:23-57
When previousCommissionsCount > 0, the existing payout record is updated: its periodEnd is set to the end of the month of the last previous commission, and its amount is updated to sum the previous commission earnings. If currentCommissionsCount > 0, a new current-month payout record is created with a po_ prefix ID, assigning current commission earnings and linking them via payoutId.
Sources: apps/web/app/ee/api/cron/payouts/process/split-payouts.ts:58-107
Fee Calculation and Currency Conversion
The processPayouts function updates matching payout records to processing status, links them to invoice.id, sets initiatedAt, and determines their processing mode based on program configuration (external or internal). For hybrid programs, payouts belonging to partners with payoutsEnabledAt set to null are updated to external mode.
export async function processPayouts({
workspace,
program,
invoice,
userId,
paymentMethodId,
cutoffPeriod,
selectedPayoutIds,
excludedPayoutIds,
}: ProcessPayoutsProps) {
const cutoffPeriodValue = CUTOFF_PERIOD.find(
(c) => c.id === cutoffPeriod,
)?.value;
const res = await prisma.payout.updateMany({
where: {
...payoutIdSelectionWhere({ selectedPayoutIds, excludedPayoutIds }),
...getPayoutEligibilityFilter({ program }),
...(cutoffPeriodValue && {
periodEnd: {
lte: cutoffPeriodValue,
},
}),
},
data: {
invoiceId: invoice.id,
status: "processing",
userId,
initiatedAt: new Date(),
// if the program is in external mode, set the mode to external
// otherwise set it to internal (we'll update specific payouts to "external" later if it's hybrid mode)
mode: program.payoutMode === "external" ? "external" : "internal",
},
});
if (res.count === 0) {
console.log(
`No payouts updated/found for invoice ${invoice.id}. Skipping...`,
);
return;
}
console.log(
`Updated ${res.count} payouts to invoice ${invoice.id} and "processing" status`,
);
// if hybrid mode, we need to update payouts for partners with payoutsEnabledAt = null to external mode
// here we don't need to filter if they have tenantId cause getPayoutEligibilityFilter above already takes care of that
if (program.payoutMode === "hybrid") {
await prisma.payout.updateMany({
where: {
invoiceId: invoice.id,
partner: {
payoutsEnabledAt: null,
},
},
data: {
mode: "external",
},
});
}Sources: apps/web/app/ee/api/cron/payouts/process/process-payouts.ts:59-119
| Non-USD Payment Method Type | Target Currency | Stripe API Identifier |
|---|---|---|
sepa_debit | eur | SEPA Direct Debit |
acss_debit | cad | ACSS Debit (Canada) |
Sources: apps/web/app/ee/api/cron/payouts/process/process-payouts.ts:25-28
Total payout amounts are aggregated by mode, and Stripe payment methods are retrieved to calculate payout fees via calculatePayoutFeeForMethod and fee waivers via calculatePayoutFeeWithWaiver. Non-USD payment methods invoke createFxQuote to obtain real-time exchange rates from Stripe before finalizing charges.
const payoutsByMode = await prisma.payout.groupBy({
by: ["mode"],
where: {
invoiceId: invoice.id,
},
_sum: {
amount: true,
},
});
const totalInternalPayoutAmount =
payoutsByMode.find((p) => p.mode === "internal")?._sum.amount ?? 0;
const totalExternalPayoutAmount =
payoutsByMode.find((p) => p.mode === "external")?._sum.amount ?? 0;
const totalPayoutAmount =
totalInternalPayoutAmount + totalExternalPayoutAmount;
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
const payoutFee = calculatePayoutFeeForMethod({
paymentMethod: paymentMethod.type,
payoutFee: workspace.payoutFee,
});
if (!payoutFee) {
throw new Error("Failed to calculate payout fee.");
}
console.info(
`Using payout fee of ${payoutFee} for payment method ${paymentMethod.type}`,
);
const {
fee: invoiceFee,
feeFreeAmount,
feeChargedAmount,
feeWaiverRemaining,
} = calculatePayoutFeeWithWaiver({
payoutAmount: totalPayoutAmount,
payoutFee,
payoutFeeWaiverLimit: workspace.payoutFeeWaiverLimit,
payoutFeeWaiverUsage: workspace.payoutFeeWaiverUsage,
paymentMethod: invoice.paymentMethod!,
});
const invoiceTotal = totalPayoutAmount + invoiceFee;
console.log({
totalInternalPayoutAmount,
totalExternalPayoutAmount,
totalPayoutAmount,
invoiceFee,
invoiceTotal,
feeFreeAmount,
feeChargedAmount,
feeWaiverRemaining,
});
await prisma.invoice.update({
where: {
id: invoice.id,
},
data: {
amount: totalPayoutAmount,
externalAmount: totalExternalPayoutAmount,
fee: invoiceFee,
total: invoiceTotal,
},
});
let totalToCharge = invoiceTotal - totalExternalPayoutAmount;
const currency = nonUsdPaymentMethodTypes[paymentMethod.type] || "usd";
// convert the amount to EUR/CAD if the payment method is sepa_debit or acss_debit
if (Object.keys(nonUsdPaymentMethodTypes).includes(paymentMethod.type)) {
const fxQuote = await createFxQuote({
fromCurrency: currency,
toCurrency: "usd",
});
Sources: apps/web/app/ee/api/cron/payouts/process/process-payouts.ts:121-209
Program Operator Confirmation
Confirmation Schema and Validation Rules
The confirmPayoutsAction server action validates incoming batch payout requests using a Zod schema defined in confirm-payouts.ts. Program operators must specify the workspaceId, paymentMethodId, cutoffPeriod, and numerical billing totals (amount, fee, total). A strict validation refinement prevents combining selectedPayoutIds and excludedPayoutIds within the same request.
const confirmPayoutsSchema = z
.object({
workspaceId: z.string(),
paymentMethodId: z.string(),
cutoffPeriod: CUTOFF_PERIOD_ENUM,
selectedPayoutIds: z.array(z.string()).optional(),
excludedPayoutIds: z.array(z.string()).optional(),
fastSettlement: z.boolean().optional().default(false),
amount: z.number(),
fee: z.number(),
total: z.number(),
})
.superRefine((data, ctx) => {
if (data.selectedPayoutIds?.length && data.excludedPayoutIds?.length) {
ctx.addIssue({
code: "custom",
message:
"Cannot combine selectedPayoutIds with excludedPayoutIds in the same request.",
});
}
});Sources: apps/web/lib/actions/partners/confirm-payouts.ts:30-50
Prior to initiating invoice creation, the action enforces permission checks for the payouts.write role, verifies that the workspace has a valid stripeId, checks workspace usage quotas against payoutsLimit, and validates that the payout amount satisfies the minimum invoice requirement of $10 (INVOICE_MIN_PAYOUT_AMOUNT_CENTS).
throwIfNoPermission({
role: workspace.role,
requiredPermissions: ["payouts.write"],
});
if (!workspace.stripeId) {
throw new Error("Workspace does not have a valid Stripe ID.");
}
if (fastSettlement && !workspace.fastDirectDebitPayouts) {
throw new Error(
"Fast settlement is not enabled for this program. Contact sales to enable it.",
);
}
// if workspace's payouts usage + the current invoice amount
// is greater than the workspace's payouts limit, throw an error
if (workspace.payoutsUsage + amount > workspace.payoutsLimit) {
throw new Error(
exceededLimitError({
plan: workspace.plan,
planPeriod: workspace.planPeriod,
limit: workspace.payoutsLimit,
type: "payouts",
}),
);
}
if (amount < INVOICE_MIN_PAYOUT_AMOUNT_CENTS) {
throw new Error(
"Your payout total is less than the minimum invoice amount of $10.",
);
}Sources: apps/web/lib/actions/partners/confirm-payouts.ts:74-106
Warning
When a cutoffPeriod is specified, the action counts eligible payouts using prisma.payout.aggregate. If the total count exceeds CUTOFF_PERIOD_MAX_PAYOUTS, confirmation is aborted to protect against memory limits during batch splitting.
Sources: apps/web/lib/actions/partners/confirm-payouts.ts:109-123
Payment Method and External Payout Validation
The action retrieves the payment method via Stripe and asserts that its customer ID matches workspace.stripeId. It checks that the payment type is included in PAYMENT_METHOD_TYPES. If fastSettlement is requested, fastDirectDebitPayouts must be enabled on the workspace and the payment method type must be us_bank_account.
| Validation Check | Trigger Condition | Error or Action |
|---|---|---|
| Stripe Customer Mismatch | paymentMethod.customer !== workspace.stripeId | Throws "Invalid payout method." |
| Unsupported Payment Type | !PAYMENT_METHOD_TYPES.includes(paymentMethod.type) | Throws an error listing supported types |
| Fast Settlement Rail | fastSettlement && paymentMethod.type !== "us_bank_account" | Throws "Fast settlement is only supported for ACH payment." |
| Direct Debit Mandate | DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(paymentMethod.type) | Detaches payment method via stripe.paymentMethods.detach if mandate is missing |
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
if (paymentMethod.customer !== workspace.stripeId) {
throw new Error("Invalid payout method.");
}
if (!PAYMENT_METHOD_TYPES.includes(paymentMethod.type)) {
throw new Error(
`We only support ${PAYMENT_METHOD_TYPES.join(
", ",
)} for now. Please update your payout method to one of these.`,
);
}
if (fastSettlement && paymentMethod.type !== "us_bank_account") {
throw new Error("Fast settlement is only supported for ACH payment.");
}
// if it's a direct debit payment method, we need to check to make sure mandate is valid
if (DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(paymentMethod.type)) {
const mandate = await checkPaymentMethodMandate({
paymentMethodId,
});
if (!mandate) {
// if mandate is not valid, remove the payment method
await stripe.paymentMethods.detach(paymentMethodId);
throw new Error(
"No active mandate found for this bank account. Please set up a new bank account for payouts under your billing settings page.",
);
}
}Sources: apps/web/lib/actions/partners/confirm-payouts.ts:156-187
For non-internal program modes, the action retrieves eligible payouts and workspace webhooks subscribed to the payout.confirmed event. If the batch includes external payouts (payout.mode === "external") and no active webhook is registered, confirmation is blocked with an EXTERNAL_WEBHOOK_REQUIRED error.
if (program.payoutMode !== "internal") {
const [eligiblePayouts, payoutWebhooks] = await Promise.all([
getEligiblePayouts({
program,
cutoffPeriod,
selectedPayoutIds,
excludedPayoutIds,
page: 1,
pageSize: Infinity,
}),
getWebhooks({
workspaceId: workspace.id,
triggers: ["payout.confirmed"],
disabled: false,
installationId: null,
}),
]);
// Check if the invoice includes any external payouts
const hasExternalPayouts = eligiblePayouts.find(
(payout) => payout.mode === "external",
);
if (hasExternalPayouts && payoutWebhooks.length === 0) {
throw new Error(
`EXTERNAL_WEBHOOK_REQUIRED: This invoice includes at least one external payout, which requires an active webhook subscribed to the "payout.confirmed" event. Please set one up before proceeding.`,
);
}
}Sources: apps/web/lib/actions/partners/confirm-payouts.ts:125-154
Invoice Generation and Record Creation
Within a Prisma interactive transaction (prisma.$transaction), the action computes the next invoice sequence number by counting existing workspace invoices, formatting a padded string (padStart(4, "0")), and combining it with the workspace invoicePrefix. It then creates the Invoice record with inv_ prefixing.
const invoice = await prisma.$transaction(async (tx) => {
// Generate the next invoice number by counting the number of invoices for the workspace
const totalInvoices = await tx.invoice.count({
where: {
workspaceId: workspace.id,
},
});
const paddedNumber = String(totalInvoices + 1).padStart(4, "0");
const invoiceNumber = `${workspace.invoicePrefix}-${paddedNumber}`;
// Create the invoice and return it
return await tx.invoice.create({
data: {
id: createId({ prefix: "inv_" }),
number: invoiceNumber,
programId,
workspaceId: workspace.id,
// these numbers will be updated later in the payouts/process cron job
// but we're adding them now for the program/payouts/success screen
amount,
fee,
total,
paymentMethod: fastSettlement
? "ach_fast"
: STRIPE_PAYMENT_METHOD_NORMALIZATION[paymentMethod.type],Sources: apps/web/lib/actions/partners/confirm-payouts.ts:189-214
Disbursement Fanout on Settlement
Settlement Timing and Delayed Scheduling
When Stripe dispatches a charge.succeeded webhook event for an invoice, the system processes payouts via a dedicated cron endpoint (POST /api/cron/payouts/charge-succeeded). The handler validates the payload schema containing the invoiceId, retrieves the associated invoice along with a count of payouts currently marked with the processing status, and exits early if no matching records exist.
const payloadSchema = z.object({
invoiceId: z.string(),
});
// 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 so we can return a 200 to Stripe immediately.
export const POST = withCron(async ({ rawBody }) => {
try {
const { invoiceId } = payloadSchema.parse(JSON.parse(rawBody));
const invoice = await prisma.invoice.findUnique({
where: {
id: invoiceId,
},
include: {
_count: {
select: {
payouts: {
where: {
status: "processing",
},
},
},
},
},
});
if (!invoice) {
return logAndRespond(`Invoice ${invoiceId} not found.`);
}
if (invoice._count.payouts === 0) {
return logAndRespond(
`No payouts found with status 'processing' for invoice ${invoiceId}, skipping...`,
);
}Sources: apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts:16-52
Prior to queuing individual disbursement rails, the handler executes a raw SQL update against the database to align each payout's disbursement method with its corresponding partner's defaultPayoutMethod, provided the payout status is processing and a default method is set on the partner profile.
// Set the method for each payout in the invoice to the corresponding partner's default payout method
await prisma.$executeRaw`
UPDATE Payout p
INNER JOIN Partner pn ON p.partnerId = pn.id
SET p.method = pn.defaultPayoutMethod
WHERE p.invoiceId = ${invoice.id}
AND pn.defaultPayoutMethod IS NOT NULL
AND p.status = 'processing'
`;Sources: apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts:54-62
If the invoice payment method is a card, the system evaluates whether the funds have settled by inspecting the underlying Stripe balance transactions linked to the charge ID in stripeChargeMetadata. If the balance transaction's available_on timestamp is in the future, funds are deemed unavailable, restricting immediate disbursement rails.
| Settlement State | Condition | Action Taken |
|---|---|---|
| Available | available_on $\le$ now | fundsAvailable is set to true; all payout rails proceed immediately. |
| Pending / No Transaction | available_on $>$ now or zero balance transactions | fundsAvailable is set to false; post-settlement payout methods are scheduled for delayed execution via QStash. |
let fundsAvailable = true;
// if invoice payment method is card, we need to check if the funds have settled yet
if (invoice.paymentMethod === "card") {
const fundSettlementTiming = await getFundSettlementTiming(invoice);
if (!fundSettlementTiming.fundsAvailable) {
// set fundsAvailable to false so we don't queue any payouts that require funds to be available
fundsAvailable = false;
const postSettlementPayoutMethods = [
PartnerPayoutMethod.stablecoin,
PartnerPayoutMethod.paypal,
PartnerPayoutMethod.tremendous,
];
const postSettlementPayoutsCount = await prisma.payout.count({
where: {
invoiceId: invoice.id,
status: "processing",
method: {
in: postSettlementPayoutMethods,
},
},
});
if (postSettlementPayoutsCount > 0) {
await scheduleDelayedPayouts({
invoice,
executeAt: fundSettlementTiming.scheduledAt,
});
}
}
}Sources: apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts:64-96, apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts:68-117
Note
Payout methods requiring direct balance movement (PartnerPayoutMethod.stablecoin, PartnerPayoutMethod.paypal, and PartnerPayoutMethod.tremendous) do not support Stripe source_transaction references. When funds are unconfirmed, these methods are deferred until 15 minutes past the available_on timestamp to prevent fronting funds on reversible card charges.
Sources: apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts:73-77, apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts:64-67
When delayed scheduling triggers, scheduleDelayedPayouts publishes a delayed message back to /api/cron/payouts/charge-succeeded via QStash with a 5-minute buffer on top of the calculated delay, applying flow control parallelism keyed to the invoice identifier.
export async function scheduleDelayedPayouts({
invoice,
executeAt,
}: {
invoice: Pick<Invoice, "id">;
executeAt: Date;
}) {
const scheduleTimeMs = executeAt.getTime();
const delaySeconds = Math.max(
0,
Math.floor((scheduleTimeMs - Date.now()) / 1000),
);
const qstashResponse = await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/charge-succeeded`,
delay: delaySeconds + 5 * 60, // 5 minutes delay to give some buffer for the card charge to settle fully
deduplicationId: `retry-delayed-payouts-${invoice.id}`, // The deduplication window is 10 minutes
label: invoice.id,
flowControl: {
key: invoice.id,
parallelism: 1,
},
body: {
invoiceId: invoice.id,
},
});
if (qstashResponse.messageId) {
const scheduledAt = new Date(scheduleTimeMs);
console.log(
`Scheduled delayed payouts for invoice ${invoice.id} at ${scheduledAt.toISOString()}.`,
{
qstashResponse,
},
);
} else {
throw new Error(
`Failed to schedule delayed payouts for invoice ${invoice.id}`,
);
}
}Sources: apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts:20-62
Multi-Rail Queue Dispatch
Once settlement availability is established, the handler fans out disbursement requests across three provider queues using Promise.allSettled. Invoices with payoutMode === "external" bypass Stripe and Tremendous processing entirely.
await Promise.allSettled([
// Queue Stripe payouts (need to pass along fundsAvailable to handle stablecoin payouts)
queueStripePayouts({
invoice,
fundsAvailable,
}),
// only send PayPal and Tremendous payouts if funds are available
...(fundsAvailable
? [
sendPaypalPayouts({
invoice,
}),
queueTremendousPayouts({
invoice,
}),
]
: []),
// Queue external payouts (doesn't rely on fundsAvailable)
queueExternalPayouts(invoice),
]);
Sources: apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts:98-120
- Stripe Payout Queue (
queueStripePayouts): Aggregates stablecoin payouts meetingMIN_WITHDRAWAL_AMOUNT_CENTS, adds fixed fees (STABLECOIN_PAYOUT_FIXED_FEE_CENTS), funds Dub's financial account iffundsAvailableis true, and chunks eligible partners in batches of 100 to enqueue jobs into thesend-stripe-payoutQStash queue with deduplication keys formatted as${invoiceId}-${partnerId}.
export async function queueStripePayouts({
invoice,
fundsAvailable,
}: {
invoice: Pick<
Invoice,
"id" | "paymentMethod" | "stripeChargeMetadata" | "payoutMode"
>;
fundsAvailable: boolean;
}) {
if (invoice.payoutMode === "external") {
console.log(
`Invoice ${invoice.id} is paid externally, skipping Stripe payouts...`,
);
return;
}
const { id: invoiceId, paymentMethod, stripeChargeMetadata } = invoice;
if (fundsAvailable) {
// Fund the total Stablecoin payout amount for this invoice
const { _sum, _count } = await prisma.payout.aggregate({
_sum: {
amount: true,
},
_count: {
id: true,
},
where: {
invoiceId: invoice.id,
method: "stablecoin",
status: "processing",
// only transfer funds for stablecoin payouts >= minimum withdrawal amount
// for payouts below the minimum withdrawal amount, we will just mark them as processed
// and users can force withdraw them manually later (which triggers another fundFinancialAccount call)
amount: {
gte: MIN_WITHDRAWAL_AMOUNT_CENTS,
},
},
});
// We need to add the STABLECOIN_PAYOUT_FIXED_FEE_CENTS for each payout to the total funding amount
// to make sure that `createStablecoinPayout` later has enough funds to cover Stripe's fees
const stablecoinFundingAmount =
(_sum.amount ?? 0) + _count.id * STABLECOIN_PAYOUT_FIXED_FEE_CENTS;
// Send money to Financial Account to handle Stablecoin payouts
if (stablecoinFundingAmount > 0) {
try {
await fundFinancialAccount({
amount: stablecoinFundingAmount,
idempotencyKey: invoiceId,
});
} catch (error) {
await log({
message: `Failed to fund Dub's financial account for stablecoin payouts: ${error.message}`,
type: "errors",
});
throw error;
}
}
}
// Find the id of the charge that was used to fund the transfer
const parsedChargeMetadata =
stripeChargeMetadataSchema.safeParse(stripeChargeMetadata);
const chargeId = parsedChargeMetadata?.success
? parsedChargeMetadata?.data.id
: undefined;
// this should never happen since all completed invoices should have a charge id, but just in case
if (!chargeId) {
await log({
message:
"No charge id found in stripeChargeMetadata for invoice " +
invoiceId +
", continuing without source_transaction.",
type: "errors",
});Sources: apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts:16-154
- Tremendous Payout Queue (
queueTremendousPayouts): Filters internal processing payouts for partners with non-nulltremendousEmailandpayoutsEnabledAtwhose amounts fall betweenTREMENDOUS_MIN_PAYOUT_AMOUNT_CENTSandTREMENDOUS_MAX_PAYOUT_AMOUNT_CENTS. It verifies that the program defines atremendousCampaignIdbefore chunking partners into batches of 100 and enqueueing jobs into thesend-tremendous-payoutQStash queue.
export async function queueTremendousPayouts({
invoice,
}: {
invoice: Pick<Invoice, "id" | "payoutMode" | "programId">;
}) {
if (invoice.payoutMode === "external") {
console.log(
`Invoice ${invoice.id} is paid externally, skipping Tremendous payouts...`,
);
return;
}
// should never happen, but just in case
if (!invoice.programId) {
console.log(
`Invoice ${invoice.id} has no program ID, skipping Tremendous payouts...`,
);
return;
}
const partnersInCurrentInvoice = await prisma.payout.groupBy({
by: ["partnerId"],
where: {
invoiceId: invoice.id,
status: PayoutStatus.processing,
mode: PayoutMode.internal,
method: PartnerPayoutMethod.tremendous,
amount: {
gte: TREMENDOUS_MIN_PAYOUT_AMOUNT_CENTS,
lte: TREMENDOUS_MAX_PAYOUT_AMOUNT_CENTS,
},
partner: {
tremendousEmail: {
not: null,
},
payoutsEnabledAt: {
not: null,
},
},
},
});
if (partnersInCurrentInvoice.length === 0) {
console.log("No partners for sending via Tremendous, skipping...");
return;
}
const program = await prisma.program.findUniqueOrThrow({
where: {
id: invoice.programId,
},
select: {
tremendousCampaignId: true,
},
});
// Log and skip if the program has no Tremendous campaign.
if (!program.tremendousCampaignId) {
logger.error("missing_campaign", {
service: "tremendous",
operation: "queue_tremendous_payouts",
correlation: {
invoiceId: invoice.id,
programId: invoice.programId,
},
});
await logger.flush();
return;
}
const chunkedPartners = chunk(partnersInCurrentInvoice, 100);
for (let i = 0; i < chunkedPartners.length; i++) {
const partnersInChunk = chunkedPartners[i];
await Promise.all(
partnersInChunk.map(({ partnerId }) =>
queue.enqueueJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/send-tremendous-payout`,Sources: apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts:20-113
- PayPal and External Dispatch: PayPal payouts are dispatched directly when funds are available, while external payouts are queued independently of the
fundsAvailablestatus.
// only send PayPal and Tremendous payouts if funds are available
...(fundsAvailable
? [
sendPaypalPayouts({
invoice,
}),
queueTremendousPayouts({
invoice,
}),
]
: []),
// Queue external payouts (doesn't rely on fundsAvailable)
queueExternalPayouts(invoice),Sources: apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts:105-118
Stripe and Stablecoin Execution
Stripe Connect Transfers
The execution layer processes internal queue jobs targeting Stripe Connect transfers and stablecoin payouts via the /api/cron/payouts/send-stripe-payout cron route. When a job arrives, the payload schema validates the presence of a partnerId, an optional invoiceId, and an optional chargeId. The router queries for a processing payout matching the partner, invoice, internal mode, and method types (connect or stablecoin), delegating execution to the corresponding handler function.
const payloadSchema = z.object({
partnerId: z.string(),
invoiceId: z.string().optional(),
chargeId: z.string().optional(),
});Sources: apps/web/app/ee/api/cron/payouts/send-stripe-payout/route.ts:11-15
For connect payout methods, createStripeTransfer retrieves the partner's Stripe Connect identifier and verifies that payouts are enabled. It aggregates previously processed payouts with stripeTransferId set to null alongside active processing payouts for the current invoice.
const partner = await prisma.partner.findUniqueOrThrow({
where: {
id: partnerId,
},
select: {
id: true,
email: true,
stripeConnectId: true,
payoutsEnabledAt: true,
},
});
// should never happen, but just in case
if (!partner.stripeConnectId || !partner.payoutsEnabledAt) {
console.warn(
`Partner ${partner.email} does not have an active payout account.`,
);
return;
}
const commonInclude: Prisma.PayoutInclude = {
program: {
select: {
id: true,
name: true,
logo: true,
workspaceId: true,
},
},
};
const [previouslyProcessedPayouts, currentInvoicePayouts] = await Promise.all(
[
prisma.payout.findMany({
where: {
partnerId: partner.id,
status: "processed",
stripeTransferId: null,
method: "connect",
},
orderBy: {
id: "asc",
},
include: commonInclude,
}),
invoiceId
? prisma.payout.findMany({
where: {
partnerId: partner.id,
invoiceId,
status: "processing",
method: "connect",
},
orderBy: {
id: "asc",
},
include: commonInclude,
})
: Promise.resolve([]),
],
);Sources: apps/web/lib/partners/create-stripe-transfer.ts:37-98
Caution
If a partner's Stripe Express account returns payouts_enabled as false or has its transfers capability set to inactive, the system automatically nullifies payoutsEnabledAt and defaultPayoutMethod on the partner record, processes current invoice payouts, and halts transfer creation.
Sources: apps/web/lib/partners/create-stripe-transfer.ts:164-191
Transfers are executed via stripe.transfers.create using a calculated idempotency key. When no prior processed payouts exist and a chargeId is present, the transfer includes a source_transaction reference to associate the funds directly with the original payment.
const idempotencyKey = createPayoutsIdempotencyKey({
partnerId: partner.id,
invoiceId,
payoutIds: allPayouts.map((p) => p.id),
});
// Create a transfer for the partner combined payouts and update it as sent
const transfer = await stripe.transfers.create(
{
amount: finalTransferableAmount,
currency: "usd",
// here, `transfer_group` technically only used to associate the transfer with the invoice
// (even though the transfer could technically include payouts from multiple invoices)
transfer_group: finalPayoutInvoiceId!,
destination: partner.stripeConnectId,
description: `Dub Partners payout transfer (${allPayoutsProgramNames.join(", ")})`,
// Omit `source_transaction` if prior processed payouts exist to ensure this transfer
// never exceeds the original charge amount.
...(previouslyProcessedPayouts.length === 0 &&
chargeId && {
source_transaction: chargeId,
}),
},
{
idempotencyKey,
},
);Sources: apps/web/lib/partners/create-stripe-transfer.ts:196-222
Stablecoin Outbound Payments
Stablecoin disbursements managed by createStablecoinPayout fetch partner accounts using stripeRecipientId. The handler validates account status by checking whether the recipient account is closed or lacks active crypto_wallets capabilities.
const stripeRecipientAccount = await getStripeRecipientAccount(
partner.stripeRecipientId,
);
// Stripe recipient account is closed
if (stripeRecipientAccount.closed) {
await prisma.partner.update({
where: {
id: partner.id,
},
data: {
stripeRecipientId: null,
payoutsEnabledAt: null,
defaultPayoutMethod: null,
},
});
await markPayoutsAsProcessed(currentInvoicePayouts);
console.warn(
`Stripe recipient account for partner ${partner.email} is closed.`,
);
return;
}
// Stripe recipient account does not have crypto wallet capabilities
if (
stripeRecipientAccount.configuration?.recipient?.capabilities
?.crypto_wallets?.status !== "active"
) {
await prisma.partner.update({
where: {
id: partner.id,
},
data: {
payoutsEnabledAt: null,
},
});
await markPayoutsAsProcessed(currentInvoicePayouts);
const message = `Stripe recipient account for partner ${partner.email} does not have crypto wallet capabilities.`;
if (forceWithdrawal) {
throw new Error(message);
} else {
console.warn(message);
return;
}
}Sources: apps/web/lib/partners/create-stablecoin-payout.ts:180-229
Related
On This Page
OverviewCommission Aggregation and EligibilityScheduled Cron WorkflowHolding Periods and ProcessingEligibility FilteringPayout Processing and SplittingCron Lifecycle and ExecutionAggregate Payout Splitting AlgorithmFee Calculation and Currency ConversionProgram Operator ConfirmationConfirmation Schema and Validation RulesPayment Method and External Payout ValidationInvoice Generation and Record CreationDisbursement Fanout on SettlementSettlement Timing and Delayed SchedulingMulti-Rail Queue DispatchStripe and Stablecoin ExecutionStripe Connect TransfersStablecoin Outbound PaymentsRelated