diff --git a/apps/web/app/(ee)/api/commissions/count/route.ts b/apps/web/app/(ee)/api/commissions/count/route.ts index 25f29fa8f96..47715e40aa3 100644 --- a/apps/web/app/(ee)/api/commissions/count/route.ts +++ b/apps/web/app/(ee)/api/commissions/count/route.ts @@ -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 isHoldStatus = searchParams.status === "hold"; + const { status: _status, ...restSearchParams } = searchParams; + + const parsedParams = getCommissionsCountQuerySchema.parse( + isHoldStatus ? restSearchParams : searchParams, + ); const counts = await getCommissionsCount({ ...parsedParams, programId, + isHoldStatus, }); return NextResponse.json(counts); diff --git a/apps/web/app/(ee)/api/commissions/route.ts b/apps/web/app/(ee)/api/commissions/route.ts index c5711a89f0e..a33785932cc 100644 --- a/apps/web/app/(ee)/api/commissions/route.ts +++ b/apps/web/app/(ee)/api/commissions/route.ts @@ -15,8 +15,12 @@ import * as z from "zod/v4"; export const GET = withWorkspace(async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); - let { partnerId, tenantId, ...filters } = - getCommissionsQuerySchema.parse(searchParams); + const isHoldStatus = searchParams.status === "hold"; + const { status: _status, ...restSearchParams } = searchParams; + + let { partnerId, tenantId, ...filters } = getCommissionsQuerySchema.parse( + isHoldStatus ? restSearchParams : searchParams, + ); if (tenantId && !partnerId) { const partner = await prisma.programEnrollment.findUnique({ @@ -45,6 +49,7 @@ export const GET = withWorkspace(async ({ workspace, searchParams }) => { ...filters, partnerId, programId, + isHoldStatus, }); return NextResponse.json( diff --git a/apps/web/app/(ee)/api/programs/[programId]/payouts/count/route.ts b/apps/web/app/(ee)/api/programs/[programId]/payouts/count/route.ts index 4f7356f326a..0d4c50ffac6 100644 --- a/apps/web/app/(ee)/api/programs/[programId]/payouts/count/route.ts +++ b/apps/web/app/(ee)/api/programs/[programId]/payouts/count/route.ts @@ -4,15 +4,23 @@ import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { withWorkspace } from "@/lib/auth"; import { payoutsCountQuerySchema } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; -import { PayoutStatus, Prisma } from "@dub/prisma/client"; +import { FraudEventStatus, PayoutStatus, Prisma } from "@dub/prisma/client"; import { NextResponse } from "next/server"; // GET /api/programs/[programId]/payouts/count export const GET = withWorkspace(async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); - const { partnerId, groupBy, eligibility, status, invoiceId } = - payoutsCountQuerySchema.parse(searchParams); + const isHoldStatus = searchParams.status === "hold"; + const { status: _status, ...restSearchParams } = searchParams; + let { status, partnerId, groupBy, eligibility, invoiceId } = + payoutsCountQuerySchema.parse( + isHoldStatus ? restSearchParams : searchParams, + ); + + if (isHoldStatus) { + status = PayoutStatus.pending; + } const program = await getProgramOrThrow({ workspaceId: workspace.id, @@ -26,6 +34,15 @@ export const GET = withWorkspace(async ({ workspace, searchParams }) => { ...getPayoutEligibilityFilter({ program, workspace }), }), ...(invoiceId && { invoiceId }), + ...(isHoldStatus && { + programEnrollment: { + fraudEventGroups: { + some: { + status: FraudEventStatus.pending, + }, + }, + }, + }), }; // Get payout count by status diff --git a/apps/web/app/(ee)/api/programs/[programId]/payouts/route.ts b/apps/web/app/(ee)/api/programs/[programId]/payouts/route.ts index 39c9cc7e1f5..0e1129f6d04 100644 --- a/apps/web/app/(ee)/api/programs/[programId]/payouts/route.ts +++ b/apps/web/app/(ee)/api/programs/[programId]/payouts/route.ts @@ -7,6 +7,7 @@ import { payoutsQuerySchema, } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; +import { FraudEventStatus, PayoutStatus } from "@dub/prisma/client"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; @@ -14,8 +15,15 @@ import * as z from "zod/v4"; export const GET = withWorkspace(async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); - const { status, partnerId, invoiceId, sortBy, sortOrder, page, pageSize } = - payoutsQuerySchema.parse(searchParams); + const isHoldStatus = searchParams.status === "hold"; + const { status: _status, ...restSearchParams } = searchParams; + + let { status, partnerId, invoiceId, sortBy, sortOrder, page, pageSize } = + payoutsQuerySchema.parse(isHoldStatus ? restSearchParams : searchParams); + + if (isHoldStatus) { + status = PayoutStatus.pending; + } const program = await getProgramOrThrow({ workspaceId: workspace.id, @@ -28,21 +36,19 @@ export const GET = withWorkspace(async ({ workspace, searchParams }) => { ...(status && { status }), ...(partnerId && { partnerId }), ...(invoiceId && { invoiceId }), - }, - include: { - partner: { - include: { - programs: { - where: { - programId, - }, - select: { - tenantId: true, + ...(isHoldStatus && { + programEnrollment: { + fraudEventGroups: { + some: { + status: FraudEventStatus.pending, }, }, }, - }, - user: true, + }), + }, + include: { + programEnrollment: true, + partner: true, }, skip: (page - 1) * pageSize, take: pageSize, @@ -51,23 +57,25 @@ export const GET = withWorkspace(async ({ workspace, searchParams }) => { }, }); - const transformedPayouts = payouts.map(({ partner, ...payout }) => { - const mode = - payout.mode ?? - getEffectivePayoutMode({ - payoutMode: program.payoutMode, - payoutsEnabledAt: partner.payoutsEnabledAt, - }); + const transformedPayouts = payouts.map( + ({ partner, programEnrollment, ...payout }) => { + const mode = + payout.mode ?? + getEffectivePayoutMode({ + payoutMode: program.payoutMode, + payoutsEnabledAt: partner.payoutsEnabledAt, + }); - return { - ...payout, - mode, - partner: { - ...partner, - ...partner.programs[0], - }, - }; - }); + return { + ...payout, + mode, + partner: { + ...partner, + tenantId: programEnrollment.tenantId, + }, + }; + }, + ); return NextResponse.json( z.array(PayoutResponseSchema).parse(transformedPayouts), diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/use-commission-filters.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/use-commission-filters.tsx index 1654d6eda5f..2e272d6f8ef 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/use-commission-filters.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/use-commission-filters.tsx @@ -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, diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/use-payout-filters.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/use-payout-filters.tsx index 01370292873..cd5776ccaac 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/use-payout-filters.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/use-payout-filters.tsx @@ -49,9 +49,8 @@ export function usePayoutFilters() { key: "status", icon: CircleDotted, label: "Status", - options: Object.entries(PayoutStatusBadges) - .filter(([key]) => key !== "hold") - .map(([value, { label }]) => { + options: Object.entries(PayoutStatusBadges).map( + ([value, { label }]) => { const Icon = PayoutStatusBadges[value].icon; const count = payoutsCount?.find((p) => p.status === value)?.count; @@ -66,9 +65,12 @@ export function usePayoutFilters() { )} /> ), - right: nFormatter(count || 0, { full: true }), + ...(value !== "hold" && { + right: nFormatter(count || 0, { full: true }), + }), }; - }), + }, + ), }, { key: "invoiceId", diff --git a/apps/web/lib/api/commissions/get-commissions-count.ts b/apps/web/lib/api/commissions/get-commissions-count.ts index 285e54153ae..cae042181c2 100644 --- a/apps/web/lib/api/commissions/get-commissions-count.ts +++ b/apps/web/lib/api/commissions/get-commissions-count.ts @@ -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; + isHoldStatus?: boolean; }; export async function getCommissionsCount(filters: CommissionsCountFilters) { @@ -23,6 +24,7 @@ export async function getCommissionsCount(filters: CommissionsCountFilters) { interval, timezone, programId, + isHoldStatus, } = filters; const { startDate, endDate } = getStartEndDates({ @@ -32,6 +34,27 @@ export async function getCommissionsCount(filters: CommissionsCountFilters) { timezone, }); + const statusFilter = isHoldStatus + ? { in: [CommissionStatus.pending, CommissionStatus.processed] } + : status ?? { + notIn: [ + CommissionStatus.duplicate, + CommissionStatus.fraud, + CommissionStatus.canceled, + ], + }; + + const programEnrollmentFilter = { + ...(groupId && { groupId }), + ...(isHoldStatus && { + fraudEventGroups: { + some: { + status: FraudEventStatus.pending, + }, + }, + }), + }; + const commissionsCount = await prisma.commission.groupBy({ by: ["status"], where: { @@ -40,13 +63,7 @@ export async function getCommissionsCount(filters: CommissionsCountFilters) { }, programId, partnerId, - status: status ?? { - notIn: [ - CommissionStatus.duplicate, - CommissionStatus.fraud, - CommissionStatus.canceled, - ], - }, + status: statusFilter, type, payoutId, customerId, @@ -54,10 +71,8 @@ export async function getCommissionsCount(filters: CommissionsCountFilters) { gte: startDate, lte: endDate, }, - ...(groupId && { - programEnrollment: { - groupId, - }, + ...(Object.keys(programEnrollmentFilter).length > 0 && { + programEnrollment: programEnrollmentFilter, }), }, _count: true, @@ -77,7 +92,7 @@ export async function getCommissionsCount(filters: CommissionsCountFilters) { return acc; }, {} as Record< - CommissionStatus | "all", + CommissionStatus | "all" | "hold", { count: number; amount: number; diff --git a/apps/web/lib/api/commissions/get-commissions.ts b/apps/web/lib/api/commissions/get-commissions.ts index 20527405493..31933834082 100644 --- a/apps/web/lib/api/commissions/get-commissions.ts +++ b/apps/web/lib/api/commissions/get-commissions.ts @@ -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 & { programId: string; + isHoldStatus?: boolean; }; export async function getCommissions(filters: CommissionsFilters) { @@ -26,6 +27,7 @@ export async function getCommissions(filters: CommissionsFilters) { pageSize, sortBy, sortOrder, + isHoldStatus, } = filters; const { startDate, endDate } = getStartEndDates({ @@ -35,6 +37,27 @@ export async function getCommissions(filters: CommissionsFilters) { timezone, }); + const statusFilter = isHoldStatus + ? { in: [CommissionStatus.pending, CommissionStatus.processed] } + : status ?? { + notIn: [ + CommissionStatus.duplicate, + CommissionStatus.fraud, + CommissionStatus.canceled, + ], + }; + + const programEnrollmentFilter = { + ...(groupId && { groupId }), + ...(isHoldStatus && { + fraudEventGroups: { + some: { + status: FraudEventStatus.pending, + }, + }, + }), + }; + return await prisma.commission.findMany({ where: invoiceId ? { @@ -47,13 +70,7 @@ export async function getCommissions(filters: CommissionsFilters) { }, programId, partnerId, - status: status ?? { - notIn: [ - CommissionStatus.duplicate, - CommissionStatus.fraud, - CommissionStatus.canceled, - ], - }, + status: statusFilter, type, customerId, payoutId, @@ -61,10 +78,8 @@ export async function getCommissions(filters: CommissionsFilters) { gte: startDate, lte: endDate, }, - ...(groupId && { - programEnrollment: { - groupId, - }, + ...(Object.keys(programEnrollmentFilter).length > 0 && { + programEnrollment: programEnrollmentFilter, }), }, include: { diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index b443db32122..4523906b4aa 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -446,7 +446,7 @@ export type UsageResponse = z.infer; export type PartnersCount = Record; export type CommissionsCount = Record< - CommissionStatus | "all", + CommissionStatus | "all" | "hold", { count: number; amount: number; diff --git a/apps/web/ui/modals/export-commissions-modal.tsx b/apps/web/ui/modals/export-commissions-modal.tsx index bf8873f89ab..ece566c0cad 100644 --- a/apps/web/ui/modals/export-commissions-modal.tsx +++ b/apps/web/ui/modals/export-commissions-modal.tsx @@ -71,8 +71,15 @@ function ExportCommissionsModal({ }; const searchParams = data.useFilters - ? getQueryString(params) - : "?" + new URLSearchParams(params); + ? getQueryString({ + ...params, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }) + : "?" + + new URLSearchParams({ + ...params, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }); const response = await fetch(`/api/commissions/export${searchParams}`, { method: "GET", diff --git a/apps/web/ui/partners/commission-status-badges.tsx b/apps/web/ui/partners/commission-status-badges.tsx index 84e83fe9f75..b9fb3a5eafc 100644 --- a/apps/web/ui/partners/commission-status-badges.tsx +++ b/apps/web/ui/partners/commission-status-badges.tsx @@ -128,11 +128,12 @@ export const CommissionStatusBadges = { return title; }, }, + // extra status for hold (not in OpenAPI spec) hold: { label: "On Hold", variant: "error", className: "text-red-600 bg-red-100", - icon: CircleXmark, + icon: ShieldAlert, tooltip: (data: CommissionTooltipDataProps) => { if (data.variant === "partner") { const title = diff --git a/apps/web/ui/partners/payout-status-badges.tsx b/apps/web/ui/partners/payout-status-badges.tsx index e15f502d0aa..ea02ff44e64 100644 --- a/apps/web/ui/partners/payout-status-badges.tsx +++ b/apps/web/ui/partners/payout-status-badges.tsx @@ -50,6 +50,7 @@ export const PayoutStatusBadges = { icon: CircleXmark, className: "text-gray-600 bg-gray-100", }, + // extra status for hold (not in OpenAPI spec) hold: { label: "On Hold", variant: "error", diff --git a/packages/prisma/schema/payout.prisma b/packages/prisma/schema/payout.prisma index 0e1d18b68c2..6464c867b50 100644 --- a/packages/prisma/schema/payout.prisma +++ b/packages/prisma/schema/payout.prisma @@ -46,6 +46,7 @@ model Payout { commissions Commission[] @@index([programId, status]) + @@index([programId, partnerId]) // for programEnrollment relation @@index(partnerId) @@index(invoiceId) @@index(status)