Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 7 additions & 1 deletion apps/web/app/(ee)/api/commissions/count/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@ import { NextResponse } from "next/server";
export const GET = withWorkspace(async ({ workspace, searchParams }) => {
const programId = getDefaultProgramIdOrThrow(workspace);

const parsedParams = getCommissionsCountQuerySchema.parse(searchParams);
const isHold = searchParams.status === "hold";
const { status: _status, ...restSearchParams } = searchParams;

const parsedParams = getCommissionsCountQuerySchema.parse(
isHold ? restSearchParams : searchParams,
);

const counts = await getCommissionsCount({
...parsedParams,
programId,
isHold,
});

return NextResponse.json(counts);
Expand Down
6 changes: 5 additions & 1 deletion apps/web/app/(ee)/api/commissions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ import * as z from "zod/v4";
export const GET = withWorkspace(async ({ workspace, searchParams }) => {
const programId = getDefaultProgramIdOrThrow(workspace);

const isHold = searchParams.status === "hold";
const { status: _status, ...restSearchParams } = searchParams;

let { partnerId, tenantId, ...filters } =
getCommissionsQuerySchema.parse(searchParams);
getCommissionsQuerySchema.parse(isHold ? restSearchParams : searchParams);

if (tenantId && !partnerId) {
const partner = await prisma.programEnrollment.findUnique({
Expand Down Expand Up @@ -45,6 +48,7 @@ export const GET = withWorkspace(async ({ workspace, searchParams }) => {
...filters,
partnerId,
programId,
isHold,
});

return NextResponse.json(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,8 @@ export function useCommissionFilters() {
key: "status",
icon: CircleDotted,
label: "Status",
options: Object.entries(CommissionStatusBadges)
.filter(([key]) => key !== "hold")
.map(([value, { label }]) => {
options: Object.entries(CommissionStatusBadges).map(
([value, { label }]) => {
const Icon = CommissionStatusBadges[value].icon;
return {
value,
Expand Down
81 changes: 68 additions & 13 deletions apps/web/lib/api/commissions/get-commissions-count.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates";
import { getCommissionsCountQuerySchema } from "@/lib/zod/schemas/commissions";
import { prisma } from "@dub/prisma";
import { CommissionStatus } from "@dub/prisma/client";
import { CommissionStatus, FraudEventStatus } from "@dub/prisma/client";
import * as z from "zod/v4";

type CommissionsCountFilters = z.infer<
typeof getCommissionsCountQuerySchema
> & {
programId: string;
isHold?: boolean;
};

export async function getCommissionsCount(filters: CommissionsCountFilters) {
Expand All @@ -23,6 +24,7 @@ export async function getCommissionsCount(filters: CommissionsCountFilters) {
interval,
timezone,
programId,
isHold,
} = filters;

const { startDate, endDate } = getStartEndDates({
Expand All @@ -32,6 +34,27 @@ export async function getCommissionsCount(filters: CommissionsCountFilters) {
timezone,
});

const statusFilter = isHold
? { in: [CommissionStatus.pending, CommissionStatus.processed] }
: status ?? {
notIn: [
CommissionStatus.duplicate,
CommissionStatus.fraud,
CommissionStatus.canceled,
],
};

const programEnrollmentFilter = {
...(groupId && { groupId }),
...(isHold && {
fraudEventGroups: {
some: {
status: FraudEventStatus.pending,
},
},
}),
};

const commissionsCount = await prisma.commission.groupBy({
by: ["status"],
where: {
Expand All @@ -40,24 +63,16 @@ export async function getCommissionsCount(filters: CommissionsCountFilters) {
},
programId,
partnerId,
status: status ?? {
notIn: [
CommissionStatus.duplicate,
CommissionStatus.fraud,
CommissionStatus.canceled,
],
},
status: statusFilter,
type,
payoutId,
customerId,
createdAt: {
gte: startDate,
lte: endDate,
},
...(groupId && {
programEnrollment: {
groupId,
},
...(Object.keys(programEnrollmentFilter).length > 0 && {
programEnrollment: programEnrollmentFilter,
}),
},
_count: true,
Expand All @@ -77,7 +92,7 @@ export async function getCommissionsCount(filters: CommissionsCountFilters) {
return acc;
},
{} as Record<
CommissionStatus | "all",
CommissionStatus | "all" | "hold",
{
count: number;
amount: number;
Expand Down Expand Up @@ -106,5 +121,45 @@ export async function getCommissionsCount(filters: CommissionsCountFilters) {
{ count: 0, amount: 0, earnings: 0 },
);

// Calculate hold count (pending/processed commissions for partners with pending fraud events)
if (isHold) {
counts.hold = counts.all;
} else {
const holdCount = await prisma.commission.aggregate({
where: {
earnings: { not: 0 },
programId,
partnerId,
status: { in: [CommissionStatus.pending, CommissionStatus.processed] },
type,
payoutId,
customerId,
createdAt: {
gte: startDate,
lte: endDate,
},
programEnrollment: {
...(groupId && { groupId }),
fraudEventGroups: {
some: {
status: FraudEventStatus.pending,
},
},
},
},
_count: { _all: true },
_sum: {
amount: true,
earnings: true,
},
});

counts.hold = {
count: holdCount._count._all,
amount: holdCount._sum?.amount ?? 0,
earnings: holdCount._sum?.earnings ?? 0,
};
}

return counts;
}
39 changes: 27 additions & 12 deletions apps/web/lib/api/commissions/get-commissions.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates";
import { getCommissionsQuerySchema } from "@/lib/zod/schemas/commissions";
import { prisma } from "@dub/prisma";
import { CommissionStatus } from "@dub/prisma/client";
import { CommissionStatus, FraudEventStatus } from "@dub/prisma/client";
import * as z from "zod/v4";

type CommissionsFilters = z.infer<typeof getCommissionsQuerySchema> & {
programId: string;
isHold?: boolean;
};

export async function getCommissions(filters: CommissionsFilters) {
Expand All @@ -26,6 +27,7 @@ export async function getCommissions(filters: CommissionsFilters) {
pageSize,
sortBy,
sortOrder,
isHold,
} = filters;

const { startDate, endDate } = getStartEndDates({
Expand All @@ -35,6 +37,27 @@ export async function getCommissions(filters: CommissionsFilters) {
timezone,
});

const statusFilter = isHold
? { in: [CommissionStatus.pending, CommissionStatus.processed] }
: status ?? {
notIn: [
CommissionStatus.duplicate,
CommissionStatus.fraud,
CommissionStatus.canceled,
],
};

const programEnrollmentFilter = {
...(groupId && { groupId }),
...(isHold && {
fraudEventGroups: {
some: {
status: FraudEventStatus.pending,
},
},
}),
};

return await prisma.commission.findMany({
where: invoiceId
? {
Expand All @@ -47,24 +70,16 @@ export async function getCommissions(filters: CommissionsFilters) {
},
programId,
partnerId,
status: status ?? {
notIn: [
CommissionStatus.duplicate,
CommissionStatus.fraud,
CommissionStatus.canceled,
],
},
status: statusFilter,
type,
customerId,
payoutId,
createdAt: {
gte: startDate,
lte: endDate,
},
...(groupId && {
programEnrollment: {
groupId,
},
...(Object.keys(programEnrollmentFilter).length > 0 && {
programEnrollment: programEnrollmentFilter,
}),
},
include: {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ export type UsageResponse = z.infer<typeof usageResponse>;
export type PartnersCount = Record<ProgramEnrollmentStatus | "all", number>;

export type CommissionsCount = Record<
CommissionStatus | "all",
CommissionStatus | "all" | "hold",
{
count: number;
amount: number;
Expand Down
48 changes: 24 additions & 24 deletions apps/web/ui/partners/commission-status-badges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,30 @@ export const CommissionStatusBadges = {
icon: CircleCheck,
tooltip: (_: CommissionTooltipDataProps) => null,
},
hold: {
label: "On Hold",
variant: "error",
className: "text-red-600 bg-red-100",
icon: ShieldAlert,
tooltip: (data: CommissionTooltipDataProps) => {
if (data.variant === "partner") {
const title =
"This commission is on hold due to pending fraud events and cannot be paid out until they are resolved.";

if (data.program?.name && data.program?.slug) {
return `${title} If you believe this is incorrect, [reach out to the ${data.program.name} team](${PARTNERS_DOMAIN}/messages/${data.program.slug}).`;
}

return title;
}

const linkToFraudEvents = data.partner?.id
? `/${data.workspace?.slug}/program/fraud?partnerId=${data.partner.id}`
: `/${data.workspace?.slug}/program/fraud`;

return `This partner's commissions are on hold due to [unresolved fraud events](${linkToFraudEvents}). They cannot be paid out until resolved.`;
},
},
fraud: {
label: "Fraud",
variant: "error",
Expand Down Expand Up @@ -128,28 +152,4 @@ export const CommissionStatusBadges = {
return title;
},
},
hold: {
label: "On Hold",
variant: "error",
className: "text-red-600 bg-red-100",
icon: CircleXmark,
tooltip: (data: CommissionTooltipDataProps) => {
if (data.variant === "partner") {
const title =
"This commission is on hold due to pending fraud events and cannot be paid out until they are resolved.";

if (data.program?.name && data.program?.slug) {
return `${title} If you believe this is incorrect, [reach out to the ${data.program.name} team](${PARTNERS_DOMAIN}/messages/${data.program.slug}).`;
}

return title;
}

const linkToFraudEvents = data.partner?.id
? `/${data.workspace?.slug}/program/fraud?partnerId=${data.partner.id}`
: `/${data.workspace?.slug}/program/fraud`;

return `This partner's commissions are on hold due to [unresolved fraud events](${linkToFraudEvents}). They cannot be paid out until resolved.`;
},
},
};
Loading