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

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
322 changes: 93 additions & 229 deletions apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion apps/web/app/(ee)/api/cron/payouts/process/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CUTOFF_PERIOD_ENUM } from "@/lib/partners/cutoff-period";
import { prisma } from "@dub/prisma";
import { log } from "@dub/utils";
import { z } from "zod";
import { logAndRespond } from "../../utils";
import { processPayouts } from "./process-payouts";
import { splitPayouts } from "./split-payouts";

Expand Down Expand Up @@ -72,11 +73,12 @@ export async function POST(req: Request) {
excludedPayoutIds,
});

return new Response(`Payouts confirmed for program ${program.name}.`);
return logAndRespond(`Processed payouts for program ${program.name}.`);
} catch (error) {
await log({
message: `Error confirming payouts for program: ${error.message}`,
type: "errors",
mention: true,
});

return handleAndReturnErrorResponse(error);
Expand Down
153 changes: 153 additions & 0 deletions apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log";
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { qstash } from "@/lib/cron";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { sendBatchEmail } from "@dub/email";
import PartnerPayoutConfirmed from "@dub/email/templates/partner-payout-confirmed";
import { prisma } from "@dub/prisma";
import { APP_DOMAIN_WITH_NGROK, currencyFormatter, log } from "@dub/utils";
import { z } from "zod";
import { logAndRespond } from "../../../utils";

export const dynamic = "force-dynamic";

const payloadSchema = z.object({
invoiceId: z.string(),
startingAfter: z.string().optional(),
});

const BATCH_SIZE = 100;

// POST /api/cron/payouts/process/updates
// Recursive cron job to handle side effects of the `cron/payouts/process` job (recordAuditLog, sendBatchEmails)
export async function POST(req: Request) {
try {
const rawBody = await req.text();

await verifyQstashSignature({
req,
rawBody,
});

const { invoiceId, startingAfter } = payloadSchema.parse(
JSON.parse(rawBody),
);

const payouts = await prisma.payout.findMany({
where: {
invoiceId,
},
include: {
program: true,
partner: true,
invoice: true,
},
take: BATCH_SIZE,
skip: startingAfter ? 1 : 0,
...(startingAfter && {
cursor: {
id: startingAfter,
},
}),
orderBy: {
id: "asc",
},
});

if (payouts.length === 0) {
return logAndRespond(
`No more payouts to process for invoice ${invoiceId}. Skipping...`,
);
}

const auditLogResponse = await recordAuditLog(
payouts.map((p) => {
const { program, partner, invoice, ...payout } = p;
return {
workspaceId: program.workspaceId,
programId: program.id,
action: "payout.confirmed",
description: `Payout ${payout.id} confirmed`,
actor: {
id: payout.userId ?? "system",
},
targets: [
{
type: "payout",
id: payout.id,
metadata: payout,
},
],
};
}),
);
console.log(JSON.stringify({ auditLogResponse }, null, 2));

const invoice = payouts[0].invoice;
const internalPayouts = payouts.filter(
(payout) => payout.mode === "internal",
);
if (
invoice &&
invoice.paymentMethod !== "card" &&
internalPayouts.length > 0
) {
const batchEmailResponse = await sendBatchEmail(
internalPayouts.map((payout) => ({
to: payout.partner.email!,
subject: `Your ${currencyFormatter(payout.amount)} payout for ${payout.program.name} is on the way`,
variant: "notifications",
replyTo: payout.program.supportEmail || "noreply",
react: PartnerPayoutConfirmed({
email: payout.partner.email!,
program: {
id: payout.program.id,
name: payout.program.name,
logo: payout.program.logo,
},
payout: {
id: payout.id,
amount: payout.amount,
startDate: payout.periodStart,
endDate: payout.periodEnd,
mode: payout.mode,
paymentMethod: invoice.paymentMethod ?? "ach",
},
}),
})),
);
console.log(JSON.stringify({ batchEmailResponse }, null, 2));
}

if (payouts.length === BATCH_SIZE) {
const nextStartingAfter = payouts[payouts.length - 1].id;

await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/process/updates`,
method: "POST",
body: {
invoiceId,
startingAfter: nextStartingAfter,
},
});

return logAndRespond(
`Enqueued next batch for invoice ${invoiceId} (startingAfter: ${nextStartingAfter}).`,
);
}

return logAndRespond(
`Finished processing updates for ${payouts.length} payouts for invoice ${invoiceId}`,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);

await log({
message: `Error sending Stripe payout: ${errorMessage}`,
type: "errors",
mention: true,
});

return handleAndReturnErrorResponse(error);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { getEligiblePayouts } from "@/lib/api/payouts/get-eligible-payouts";
import { getPayoutEligibilityFilter } from "@/lib/api/payouts/payout-eligibility-filter";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw";
import { withWorkspace } from "@/lib/auth";
import { CUTOFF_PERIOD } from "@/lib/partners/cutoff-period";
import { eligiblePayoutsCountQuerySchema } from "@/lib/zod/schemas/payouts";
import { prisma } from "@dub/prisma";
import { NextResponse } from "next/server";

/*
* GET /api/programs/[programId]/payouts/eligible/count - get count of eligible payouts
*/
export const GET = withWorkspace(async ({ workspace, searchParams }) => {
const programId = getDefaultProgramIdOrThrow(workspace);

const { cutoffPeriod, selectedPayoutId, excludedPayoutIds } =
eligiblePayoutsCountQuerySchema.parse(searchParams);

const program = await getProgramOrThrow({
workspaceId: workspace.id,
programId,
});

const cutoffPeriodValue = CUTOFF_PERIOD.find(
(c) => c.id === cutoffPeriod,
)?.value;

// Requires special re-computing and filtering of payouts, so we just have to fetch all of them
if (cutoffPeriodValue) {
const eligiblePayouts = await getEligiblePayouts({
program,
cutoffPeriod,
selectedPayoutId,
excludedPayoutIds,
pageSize: Infinity,
page: 1,
});
return NextResponse.json({
count: eligiblePayouts.length ?? 0,
amount: eligiblePayouts.reduce((acc, payout) => acc + payout.amount, 0),
});
}

const data = await prisma.payout.aggregate({
where: {
...(selectedPayoutId
? { id: selectedPayoutId }
: excludedPayoutIds && excludedPayoutIds.length > 0
? { id: { notIn: excludedPayoutIds } }
: {}),
...getPayoutEligibilityFilter(program),
},
_count: true,
_sum: {
amount: true,
},
});

return NextResponse.json({
count: data._count ?? 0,
amount: data._sum?.amount ?? 0,
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ import { NextResponse } from "next/server";
export const GET = withWorkspace(async ({ workspace, searchParams }) => {
const programId = getDefaultProgramIdOrThrow(workspace);

const { cutoffPeriod, selectedPayoutId } =
eligiblePayoutsQuerySchema.parse(searchParams);
const query = eligiblePayoutsQuerySchema.parse(searchParams);

const program = await getProgramOrThrow({
workspaceId: workspace.id,
Expand All @@ -29,8 +28,7 @@ export const GET = withWorkspace(async ({ workspace, searchParams }) => {

const eligiblePayouts = await getEligiblePayouts({
program,
cutoffPeriod,
selectedPayoutId,
...query,
});

return NextResponse.json(eligiblePayouts);
Expand Down
27 changes: 26 additions & 1 deletion apps/web/lib/actions/partners/confirm-payouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

import { createId } from "@/lib/api/create-id";
import { getEligiblePayouts } from "@/lib/api/payouts/get-eligible-payouts";
import { getPayoutEligibilityFilter } from "@/lib/api/payouts/payout-eligibility-filter";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw";
import {
CUTOFF_PERIOD_MAX_PAYOUTS,
INVOICE_MIN_PAYOUT_AMOUNT_CENTS,
PAYMENT_METHOD_TYPES,
STRIPE_PAYMENT_METHOD_NORMALIZATION,
} from "@/lib/constants/payouts";
Expand Down Expand Up @@ -74,7 +77,7 @@ export const confirmPayoutsAction = authActionClient
);
}

if (amount < 1000) {
if (amount < INVOICE_MIN_PAYOUT_AMOUNT_CENTS) {
throw new Error(
"Your payout total is less than the minimum invoice amount of $10.",
);
Expand All @@ -85,13 +88,35 @@ export const confirmPayoutsAction = authActionClient
programId,
});

// TODO: Remove this once we can support cutoff periods for invoices with > 1,000 payouts
if (cutoffPeriod) {
const totalEligiblePayouts = await prisma.payout.aggregate({
where: {
...(selectedPayoutId
? { id: selectedPayoutId }
: excludedPayoutIds && excludedPayoutIds.length > 0
? { id: { notIn: excludedPayoutIds } }
: {}),
...getPayoutEligibilityFilter(program),
},
_count: true,
});
if (totalEligiblePayouts._count > CUTOFF_PERIOD_MAX_PAYOUTS) {
throw new Error(
`You cannot specify a cutoff period when the number of eligible payouts is greater than ${CUTOFF_PERIOD_MAX_PAYOUTS}.`,
);
}
}

if (program.payoutMode !== "internal") {
const [eligiblePayouts, payoutWebhooks] = await Promise.all([
getEligiblePayouts({
program,
cutoffPeriod,
selectedPayoutId,
excludedPayoutIds,
page: 1,
pageSize: Infinity,
}),

getWebhooks({
Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/api/audit-logs/record-audit-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const recordAuditLog = async (data: AuditLogInput | AuditLogInput[]) => {

try {
waitUntil(recordAuditLogTBOld(auditLogs));
await recordAuditLogTB(auditLogs);
return await recordAuditLogTB(auditLogs);
} catch (error) {
console.error(
"Failed to record audit log",
Expand Down
11 changes: 10 additions & 1 deletion apps/web/lib/api/payouts/get-eligible-payouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import { getEffectivePayoutMode } from "./get-effective-payout-mode";
import { getPayoutEligibilityFilter } from "./payout-eligibility-filter";

interface GetEligiblePayoutsProps
extends z.infer<typeof eligiblePayoutsQuerySchema> {
extends Omit<
z.infer<typeof eligiblePayoutsQuerySchema>,
"excludedPayoutIds"
> {
excludedPayoutIds?: string[];
program: Pick<Program, "id" | "name" | "minPayoutAmount" | "payoutMode">;
}
Expand All @@ -20,6 +23,8 @@ export async function getEligiblePayouts({
cutoffPeriod,
selectedPayoutId,
excludedPayoutIds,
pageSize,
page,
}: GetEligiblePayoutsProps) {
const cutoffPeriodValue = CUTOFF_PERIOD.find(
(c) => c.id === cutoffPeriod,
Expand Down Expand Up @@ -73,6 +78,10 @@ export async function getEligiblePayouts({
orderBy: {
amount: "desc",
},
...(isFinite(pageSize) && {
skip: (page - 1) * pageSize,
take: pageSize,
}),
});

if (cutoffPeriodValue) {
Expand Down
4 changes: 4 additions & 0 deletions apps/web/lib/constants/payouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ export const FOREX_MARKUP_RATE = 0.005; // 0.5%

export const PAYOUT_HOLDING_PERIOD_DAYS = [0, 7, 14, 30, 60, 90];
export const ALLOWED_MIN_PAYOUT_AMOUNTS = [0, 1000, 2000, 5000, 10000];
export const INVOICE_MIN_PAYOUT_AMOUNT_CENTS = 1000; // $10
export const MIN_WITHDRAWAL_AMOUNT_CENTS = 1000; // $10
export const BELOW_MIN_WITHDRAWAL_FEE_CENTS = 50; // $0.50

export const ELIGIBLE_PAYOUTS_MAX_PAGE_SIZE = 500;
export const CUTOFF_PERIOD_MAX_PAYOUTS = 1000;

// Direct debit payment types for Partner payout
export const DIRECT_DEBIT_PAYMENT_TYPES_INFO: {
type: Stripe.PaymentMethod.Type;
Expand Down
Loading