---
title: "Payout Settlement"
description: "Payout settlement orchestrates the end-to-end lifecycle of partner compensation, transitioning accumulated referral commissions from pending ledger entries into disbursed funds across multiple fina..."
last_updated: "2026-10-12T07:00:10.100727+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/billing-and-payouts/payout-settlement"
---

<details>
<summary>Relevant source files</summary>

The following files were used as context for generating this wiki page:

- [apps/web/lib/partners/create-stablecoin-payout.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/partners/create-stablecoin-payout.ts)
- [apps/web/app/ee/api/cron/payouts/process/process-payouts.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts)
- [apps/web/app/ee/api/cron/payouts/balance-available/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts)
- [apps/web/app/ee/api/cron/payouts/send-stripe-payout/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/send-stripe-payout/route.ts)
- [apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts)
- [apps/web/lib/partners/create-stripe-transfer.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/partners/create-stripe-transfer.ts)
- [apps/web/lib/payouts/get-partner-payout-methods.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/payouts/get-partner-payout-methods.ts)
- [apps/web/app/ee/api/cron/payouts/process/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/process/route.ts)
- [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/process/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/process/route.ts)
- [apps/web/lib/actions/partners/confirm-payouts.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/actions/partners/confirm-payouts.ts)
- [apps/web/lib/tremendous/send-tremendous-payouts.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/tremendous/send-tremendous-payouts.ts)
- [apps/web/lib/payouts/recompute-partner-payout-state.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/payouts/recompute-partner-payout-state.ts)
- [apps/web/app/ee/api/cron/payouts/send-tremendous-payout/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts)
- [apps/web/app/ee/api/cron/trigger-withdrawal/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/trigger-withdrawal/route.ts)
- [apps/web/app/ee/api/cron/payouts/force-withdrawals/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/force-withdrawals/route.ts)
- [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts)
- [apps/web/lib/actions/partners/force-withdrawal.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/actions/partners/force-withdrawal.ts)
- [apps/web/ui/partners/confirm-payouts-sheet.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/partners/confirm-payouts-sheet.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/payouts/payout-table.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/payouts/payout-table.tsx)
- [apps/web/scripts/customers/framer/split-bounty-payouts.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/scripts/customers/framer/split-bounty-payouts.ts)
- [apps/web/app/ee/partners.dub.co/onboarding/onboarding/payouts/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/payouts/page.tsx)
- [apps/web/app/ee/api/cron/payouts/process/split-payouts.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/process/split-payouts.ts)
- [apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts)
- [apps/web/ui/partners/payouts/use-payout-connect-flow.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/partners/payouts/use-payout-connect-flow.tsx)
- [apps/web/ui/partners/payouts/payout-method-config.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/partners/payouts/payout-method-config.ts)
- [apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts)
- [apps/web/app/ee/api/admin/payouts/stablecoin/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/admin/payouts/stablecoin/route.ts)
- [apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts)
- [apps/web/lib/api/payouts/get-eligible-payouts.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/payouts/get-eligible-payouts.ts)
</details>

## 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts#L12-L14), [apps/web/app/ee/api/cron/payouts/process/split-payouts.ts:61-108](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/process/split-payouts.ts#L61-L108), [apps/web/lib/actions/partners/confirm-payouts.ts:189-214](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/actions/partners/confirm-payouts.ts#L189-L214), [apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts:67-119](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts#L67-L119), [apps/web/lib/partners/create-stripe-transfer.ts:203-222](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/partners/create-stripe-transfer.ts#L203-L222), [apps/web/lib/partners/create-stablecoin-payout.ts:180-229](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/partners/create-stablecoin-payout.ts#L180-L229), [apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts:58-74](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts#L58-L74), [apps/web/lib/tremendous/send-tremendous-payouts.ts:158-180](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/tremendous/send-tremendous-payouts.ts#L158-L180), [apps/web/app/ee/api/cron/payouts/balance-available/route.ts:134-145](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts#L134-L145), [apps/web/app/ee/api/cron/payouts/force-withdrawals/route.ts:34-44](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/force-withdrawals/route.ts#L34-L44)

## 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.

```typescript
// 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts#L12-L56)

### 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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/process/route.ts#L21-L122)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/process/route.ts#L89-L106)

### 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 |

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/payouts/get-eligible-payouts.ts#L22-L90)

## 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. 

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/process/route.ts#L10-L36)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/process/route.ts#L38-L84)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/process/route.ts#L70-L76)

### 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/process/split-payouts.ts#L23-L57)

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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/process/split-payouts.ts#L58-L107)

### 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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts#L59-L119)

| 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts#L25-L28)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts#L121-L209)

## 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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/actions/partners/confirm-payouts.ts#L30-L50)

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`).

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/actions/partners/confirm-payouts.ts#L74-L106)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/actions/partners/confirm-payouts.ts#L109-L123)

### 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 |

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/actions/partners/confirm-payouts.ts#L156-L187)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/actions/partners/confirm-payouts.ts#L125-L154)

### 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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/actions/partners/confirm-payouts.ts#L189-L214)

## 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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts#L16-L52)

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.

```typescript
// 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts#L54-L62)

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. |

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts#L64-L96), [apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts:68-117](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts#L68-L117)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts#L73-L77), [apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts:64-67](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts#L64-L67)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts#L20-L62)

### 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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts#L98-L120)

- **Stripe Payout Queue (`queueStripePayouts`)**: Aggregates stablecoin payouts meeting `MIN_WITHDRAWAL_AMOUNT_CENTS`, adds fixed fees (`STABLECOIN_PAYOUT_FIXED_FEE_CENTS`), funds Dub's financial account if `fundsAvailable` is true, and chunks eligible partners in batches of 100 to enqueue jobs into the `send-stripe-payout` QStash queue with deduplication keys formatted as `${invoiceId}-${partnerId}`.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts#L16-L154)

- **Tremendous Payout Queue (`queueTremendousPayouts`)**: Filters internal processing payouts for partners with non-null `tremendousEmail` and `payoutsEnabledAt` whose amounts fall between `TREMENDOUS_MIN_PAYOUT_AMOUNT_CENTS` and `TREMENDOUS_MAX_PAYOUT_AMOUNT_CENTS`. It verifies that the program defines a `tremendousCampaignId` before chunking partners into batches of 100 and enqueueing jobs into the `send-tremendous-payout` QStash queue.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-tremendous-payouts.ts#L20-L113)

- **PayPal and External Dispatch**: PayPal payouts are dispatched directly when funds are available, while external payouts are queued independently of the `fundsAvailable` status.

```typescript
// 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts#L105-L118)

## 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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/send-stripe-payout/route.ts#L11-L15)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/partners/create-stripe-transfer.ts#L37-L98)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/partners/create-stripe-transfer.ts#L164-L191)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/partners/create-stripe-transfer.ts#L196-L222)

### 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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/partners/create-stablecoin-payout.ts#L180-L229)

## Related

- [Commission Engine](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/billing-and-payouts/commission-engine)
- [Fraud Prevention](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/billing-and-payouts/fraud-prevention)


## Sitemap

See the full [sitemap](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/llms.txt) for all pages in this wiki.
