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
201 changes: 25 additions & 176 deletions apps/web/app/(ee)/api/commissions/[commissionId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,15 @@
import { convertCurrency } from "@/lib/analytics/convert-currency";
import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log";
import { updatePartnerCommission } from "@/lib/api/commissions/update-partner-commission";
import { transformCustomerForCommission } from "@/lib/api/customers/transform-customer";
import { DubApiError } from "@/lib/api/errors";
import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import { determinePartnerReward } from "@/lib/partners/determine-partner-reward";
import {
CommissionDetailSchema,
CommissionEnrichedSchema,
updateCommissionSchema,
updateCommissionSchemaExtended,
} from "@/lib/zod/schemas/commissions";
import { prisma } from "@dub/prisma";
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";

// GET /api/commissions/:commissionId - get a single commission by ID
Expand Down Expand Up @@ -103,176 +97,31 @@ export const PATCH = withWorkspace(

const { commissionId } = params;

const commission = await prisma.commission.findUnique({
where: {
id: commissionId,
programId,
},
include: {
partner: true,
},
});

if (!commission) {
throw new DubApiError({
code: "not_found",
message: `Commission ${commissionId} not found.`,
});
}

if (commission.status === "paid") {
throw new DubApiError({
code: "bad_request",
message: `Cannot update commission: Commission ${commissionId} has already been paid.`,
});
}

const { partner, amount: originalAmount } = commission;

let { amount, modifyAmount, currency, status } =
updateCommissionSchema.parse(await parseRequestBody(req));

let finalAmount: number | undefined;
let finalEarnings: number | undefined;

if (amount || modifyAmount) {
if (commission.type !== "sale") {
throw new DubApiError({
code: "bad_request",
message: `Cannot update amount: Commission ${commissionId} is not a sale commission.`,
});
}

// if currency is not USD, convert it to USD based on the current FX rate
// TODO: allow custom "defaultCurrency" on workspace table in the future
if (currency !== "usd") {
const valueToConvert = modifyAmount || amount;
if (valueToConvert) {
const { currency: convertedCurrency, amount: convertedAmount } =
await convertCurrency({ currency, amount: valueToConvert });

if (modifyAmount) {
modifyAmount = convertedAmount;
} else {
amount = convertedAmount;
}
currency = convertedCurrency;
}
}

finalAmount = Math.max(
modifyAmount ? originalAmount + modifyAmount : amount ?? originalAmount,
0, // Ensure the amount is not negative
);

const programEnrollment = await getProgramEnrollmentOrThrow({
partnerId: partner.id,
programId,
include: {
partner: true,
links: true,
saleReward: true,
},
});

const reward = determinePartnerReward({
event: "sale",
programEnrollment,
});

if (!reward) {
throw new DubApiError({
code: "not_found",
message: `No reward found for partner ${partner.id} in program ${programId}.`,
});
}

// Recalculate the earnings based on the new amount
finalEarnings = calculateSaleEarnings({
reward,
sale: {
amount: finalAmount,
quantity: commission.quantity,
},
});
}

const isRefunded = finalAmount === 0 || finalEarnings === 0;

const updatedCommission = await prisma.commission.update({
where: {
id: commission.id,
},
data: {
// if the sale/commission is fully refunded, we don't need to update the amount or earnings
// we just update status to refunded and exclude it from the payout
// same goes for updating status to refunded, duplicate, canceled, or fraudulent
amount: isRefunded ? undefined : finalAmount,
earnings: isRefunded ? undefined : finalEarnings,
status: status ?? (isRefunded ? "refunded" : undefined),
...(status || isRefunded ? { payoutId: null } : {}),
},
include: {
customer: true,
partner: true,
},
const {
saleAmount,
modifySaleAmount,
earnings,
currency,
status,
updateHistoricalCommissions,
// Deprecated fields
amount,
modifyAmount,
} = updateCommissionSchemaExtended.parse(await parseRequestBody(req));

const updatedCommission = await updatePartnerCommission({
workspaceId: workspace.id,
programId,
commissionId,
userId: session.user.id,
saleAmount: saleAmount ?? amount,
modifySaleAmount: modifySaleAmount ?? modifyAmount,
currency,
status,
earnings,
updateHistoricalCommissions,
});

// If the commission has already been added to a payout, we need to update the payout amount
if (commission.status === "processed" && commission.payoutId) {
waitUntil(
prisma.$transaction(async (tx) => {
const commissionAggregate = await tx.commission.aggregate({
where: {
payoutId: commission.payoutId,
},
_sum: {
earnings: true,
},
});

const newPayoutAmount = commissionAggregate._sum.earnings ?? 0;

if (newPayoutAmount === 0) {
console.log(`Deleting payout ${commission.payoutId}`);
await tx.payout.delete({ where: { id: commission.payoutId! } });
} else {
console.log(
`Updating payout ${commission.payoutId} to ${newPayoutAmount}`,
);
await tx.payout.update({
where: { id: commission.payoutId! },
data: { amount: newPayoutAmount },
});
}
}),
);
}

waitUntil(
Promise.allSettled([
syncTotalCommissions({
partnerId: commission.partnerId,
programId: commission.programId,
}),

recordAuditLog({
workspaceId: workspace.id,
programId,
action: "commission.updated",
description: `Commission ${commissionId} updated`,
actor: session.user,
targets: [
{
type: "commission",
id: commission.id,
metadata: updatedCommission,
},
],
}),
]),
);

return NextResponse.json(
CommissionEnrichedSchema.parse({
...updatedCommission,
Expand Down
35 changes: 35 additions & 0 deletions apps/web/app/(ee)/api/commissions/bulk/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { bulkUpdatePartnerCommissions } from "@/lib/api/commissions/bulk-update-partner-commissions";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import { bulkUpdateCommissionsSchema } from "@/lib/zod/schemas/commissions";
import { NextResponse } from "next/server";

// PATCH /api/commissions/bulk — bulk update commission status
export const PATCH = withWorkspace(
async ({ workspace, req, session }) => {
const programId = getDefaultProgramIdOrThrow(workspace);

const { commissionIds, status } = bulkUpdateCommissionsSchema.parse(
await parseRequestBody(req),
);

const updatedCommissions = await bulkUpdatePartnerCommissions({
workspaceId: workspace.id,
programId,
commissionIds,
status,
userId: session.user.id,
});

return NextResponse.json(
updatedCommissions.map((c) => ({
id: c.id,
status: c.status,
})),
);
},
{
requiredRoles: ["owner", "member"],
},
);
13 changes: 13 additions & 0 deletions apps/web/app/(ee)/api/cron/partners/ban/cancel-commissions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { trackCommissionStatusUpdate } from "@/lib/api/commissions/track-commission-update-activity-log";
import { prisma } from "@dub/prisma";

// Mark the commissions as canceled
export async function cancelCommissions({
workspaceId,
programId,
partnerId,
}: {
workspaceId: string;
programId: string;
partnerId: string;
}) {
Expand Down Expand Up @@ -34,6 +37,9 @@ export async function cancelCommissions({
},
select: {
id: true,
amount: true,
earnings: true,
status: true,
},
orderBy: {
id: "asc",
Expand All @@ -56,6 +62,13 @@ export async function cancelCommissions({
},
});

await trackCommissionStatusUpdate({
workspaceId,
programId,
commissions,
newStatus: "canceled",
});

canceledCommissions += count;
} catch (error) {
failedBatches++;
Expand Down
8 changes: 7 additions & 1 deletion apps/web/app/(ee)/api/cron/partners/ban/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const POST = withCron(async ({ rawBody }) => {

console.info(`Banning partner ${partnerId} from program ${programId}...`);

const { partner, links, ...programEnrollment } =
const { partner, links, program, ...programEnrollment } =
await getProgramEnrollmentOrThrow({
partnerId,
programId,
Expand All @@ -37,6 +37,11 @@ export const POST = withCron(async ({ rawBody }) => {
discountCode: true,
},
},
program: {
select: {
workspaceId: true,
},
},
},
});

Expand Down Expand Up @@ -109,6 +114,7 @@ export const POST = withCron(async ({ rawBody }) => {

// Mark the commissions as canceled
await cancelCommissions({
workspaceId: program.workspaceId,
programId,
partnerId,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { trackCommissionStatusUpdate } from "@/lib/api/commissions/track-commission-update-activity-log";
import { createId } from "@/lib/api/create-id";
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { qstash } from "@/lib/cron";
Expand Down Expand Up @@ -62,6 +63,7 @@ async function handler(req: Request) {
select: {
id: true,
name: true,
workspaceId: true,
},
},
},
Expand Down Expand Up @@ -103,7 +105,9 @@ async function handler(req: Request) {
select: {
id: true,
createdAt: true,
amount: true,
earnings: true,
status: true,
partnerId: true,
programId: true,
},
Expand Down Expand Up @@ -219,6 +223,19 @@ async function handler(req: Request) {
},
});

const workspaceId = partnerGroups.find(
(p) => p.program.id === programId,
)?.program.workspaceId;

if (workspaceId) {
await trackCommissionStatusUpdate({
workspaceId,
programId,
commissions,
newStatus: "processed",
});
}

// if we're reusing a pending payout, we need to update the amount and periodEnd
if (existingPendingPayouts.find((p) => p.id === payoutToUse.id)) {
await prisma.payout.update({
Expand Down
Loading
Loading