From 88212ea2d861380ddbcc665ac9f39a6d343cb9c1 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 1 Apr 2026 02:39:54 +0530 Subject: [PATCH 1/6] Improve update commissions API & UIs (#3666) Co-authored-by: Steven Tey --- .../api/commissions/[commissionId]/route.ts | 201 ++--------- .../app/(ee)/api/commissions/bulk/route.ts | 35 ++ .../cron/partners/ban/cancel-commissions.ts | 13 + .../app/(ee)/api/cron/partners/ban/route.ts | 8 +- .../aggregate-due-commissions/route.ts | 17 + .../paypal/webhook/payouts-item-succeeded.ts | 20 ++ .../integration/webhook/charge-refunded.ts | 11 +- .../[commissionId]/page-client.tsx | 217 ++---------- .../program/commissions/commissions-table.tsx | 41 ++- .../partners/mark-commission-duplicate.ts | 107 ------ .../mark-commission-fraud-or-canceled.ts | 143 -------- .../web/lib/actions/partners/unban-partner.ts | 23 ++ .../bulk-update-partner-commissions.ts | 171 +++++++++ .../commissions/reconcile-payout-amounts.ts | 82 +++++ .../track-commission-update-activity-log.ts | 142 ++++++++ .../commissions/update-partner-commission.ts | 325 +++++++++++++++++ apps/web/lib/constants/payouts.ts | 3 + .../commissions/bulk-update-commissions.ts | 39 ++ apps/web/lib/openapi/commissions/index.ts | 4 + .../lib/partners/create-stablecoin-payout.ts | 33 +- .../lib/partners/create-stripe-transfer.ts | 33 +- apps/web/lib/types.ts | 6 + apps/web/lib/zod/schemas/activity-log.ts | 3 + apps/web/lib/zod/schemas/commissions.ts | 63 +++- .../backfill-commission-activity-log.ts | 198 +++++++++++ .../tests/commissions/bulk-updates.test.ts | 107 ++++++ apps/web/tests/commissions/index.test.ts | 10 +- apps/web/ui/activity-logs/activity-feed.tsx | 2 +- .../ui/activity-logs/commission-activity.tsx | 264 ++++++++++++++ .../partners/bulk-edit-commissions-modal.tsx | 200 +++++++++++ apps/web/ui/partners/commission-row-menu.tsx | 62 +--- .../web/ui/partners/edit-commission-modal.tsx | 333 ++++++++++++++++++ .../mark-commission-duplicate-modal.tsx | 183 ---------- ...ark-commission-fraud-or-canceled-modal.tsx | 197 ----------- apps/web/ui/partners/partner-comments.tsx | 2 +- 35 files changed, 2218 insertions(+), 1080 deletions(-) create mode 100644 apps/web/app/(ee)/api/commissions/bulk/route.ts delete mode 100644 apps/web/lib/actions/partners/mark-commission-duplicate.ts delete mode 100644 apps/web/lib/actions/partners/mark-commission-fraud-or-canceled.ts create mode 100644 apps/web/lib/api/commissions/bulk-update-partner-commissions.ts create mode 100644 apps/web/lib/api/commissions/reconcile-payout-amounts.ts create mode 100644 apps/web/lib/api/commissions/track-commission-update-activity-log.ts create mode 100644 apps/web/lib/api/commissions/update-partner-commission.ts create mode 100644 apps/web/lib/openapi/commissions/bulk-update-commissions.ts create mode 100644 apps/web/scripts/migrations/backfill-commission-activity-log.ts create mode 100644 apps/web/tests/commissions/bulk-updates.test.ts create mode 100644 apps/web/ui/activity-logs/commission-activity.tsx create mode 100644 apps/web/ui/partners/bulk-edit-commissions-modal.tsx create mode 100644 apps/web/ui/partners/edit-commission-modal.tsx delete mode 100644 apps/web/ui/partners/mark-commission-duplicate-modal.tsx delete mode 100644 apps/web/ui/partners/mark-commission-fraud-or-canceled-modal.tsx diff --git a/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts b/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts index 15c69f48757..4d6b21f4ea6 100644 --- a/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts +++ b/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts @@ -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 @@ -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, diff --git a/apps/web/app/(ee)/api/commissions/bulk/route.ts b/apps/web/app/(ee)/api/commissions/bulk/route.ts new file mode 100644 index 00000000000..f8c68e3c1ef --- /dev/null +++ b/apps/web/app/(ee)/api/commissions/bulk/route.ts @@ -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"], + }, +); diff --git a/apps/web/app/(ee)/api/cron/partners/ban/cancel-commissions.ts b/apps/web/app/(ee)/api/cron/partners/ban/cancel-commissions.ts index 485d9bc74e3..e44df5ac363 100644 --- a/apps/web/app/(ee)/api/cron/partners/ban/cancel-commissions.ts +++ b/apps/web/app/(ee)/api/cron/partners/ban/cancel-commissions.ts @@ -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; }) { @@ -34,6 +37,9 @@ export async function cancelCommissions({ }, select: { id: true, + amount: true, + earnings: true, + status: true, }, orderBy: { id: "asc", @@ -56,6 +62,13 @@ export async function cancelCommissions({ }, }); + await trackCommissionStatusUpdate({ + workspaceId, + programId, + commissions, + newStatus: "canceled", + }); + canceledCommissions += count; } catch (error) { failedBatches++; diff --git a/apps/web/app/(ee)/api/cron/partners/ban/route.ts b/apps/web/app/(ee)/api/cron/partners/ban/route.ts index 09f58b2122e..5f6b1ed4cd3 100644 --- a/apps/web/app/(ee)/api/cron/partners/ban/route.ts +++ b/apps/web/app/(ee)/api/cron/partners/ban/route.ts @@ -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, @@ -37,6 +37,11 @@ export const POST = withCron(async ({ rawBody }) => { discountCode: true, }, }, + program: { + select: { + workspaceId: true, + }, + }, }, }); @@ -109,6 +114,7 @@ export const POST = withCron(async ({ rawBody }) => { // Mark the commissions as canceled await cancelCommissions({ + workspaceId: program.workspaceId, programId, partnerId, }); diff --git a/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts b/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts index 00cf613bd6d..7386733071f 100644 --- a/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts +++ b/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts @@ -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"; @@ -62,6 +63,7 @@ async function handler(req: Request) { select: { id: true, name: true, + workspaceId: true, }, }, }, @@ -103,7 +105,9 @@ async function handler(req: Request) { select: { id: true, createdAt: true, + amount: true, earnings: true, + status: true, partnerId: true, programId: true, }, @@ -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({ diff --git a/apps/web/app/(ee)/api/paypal/webhook/payouts-item-succeeded.ts b/apps/web/app/(ee)/api/paypal/webhook/payouts-item-succeeded.ts index a00e0ddbb6c..059c794a82f 100644 --- a/apps/web/app/(ee)/api/paypal/webhook/payouts-item-succeeded.ts +++ b/apps/web/app/(ee)/api/paypal/webhook/payouts-item-succeeded.ts @@ -1,3 +1,4 @@ +import { trackCommissionStatusUpdate } from "@/lib/api/commissions/track-commission-update-activity-log"; import { prisma } from "@dub/prisma"; import { payoutsItemSchema } from "./utils"; @@ -37,6 +38,18 @@ export async function payoutsItemSucceeded(event: any) { return; } + const commissions = await prisma.commission.findMany({ + where: { + payoutId: payout.id, + }, + select: { + id: true, + amount: true, + earnings: true, + status: true, + }, + }); + await Promise.all([ prisma.payout.update({ where: { @@ -58,4 +71,11 @@ export async function payoutsItemSucceeded(event: any) { }, }), ]); + + await trackCommissionStatusUpdate({ + workspaceId: payout.program.workspaceId, + programId: payout.program.id, + commissions, + newStatus: "paid", + }); } diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts index 44c6ed67b0b..fa1a0dcbb91 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts @@ -1,3 +1,4 @@ +import { trackCommissionStatusUpdate } from "@/lib/api/commissions/track-commission-update-activity-log"; import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { stripeAppClient } from "@/lib/stripe"; import { StripeMode } from "@/lib/types"; @@ -60,9 +61,10 @@ export async function chargeRefunded(event: Stripe.Event, mode: StripeMode) { }, select: { id: true, + amount: true, + earnings: true, status: true, payoutId: true, - earnings: true, partnerId: true, programId: true, }, @@ -113,5 +115,12 @@ export async function chargeRefunded(event: Stripe.Event, mode: StripeMode) { programId: commission.programId, }); + await trackCommissionStatusUpdate({ + workspaceId: workspace.id, + programId: commission.programId, + commissions: [commission], + newStatus: "refunded", + }); + return `Commission ${commission.id} updated to status "refunded"`; } diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/[commissionId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/[commissionId]/page-client.tsx index 6a75972bb8c..79284e5b6f6 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/[commissionId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/[commissionId]/page-client.tsx @@ -4,26 +4,27 @@ import { useCommission } from "@/lib/swr/use-commission"; import useGroups from "@/lib/swr/use-groups"; import useWorkspace from "@/lib/swr/use-workspace"; import { CommissionDetail, CommissionResponse } from "@/lib/types"; +import { CommissionActivity } from "@/ui/activity-logs/commission-activity"; import { CustomerAvatar } from "@/ui/customers/customer-avatar"; import { PageContent } from "@/ui/layout/page-content"; import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper"; -import { ActivityEvent } from "@/ui/partners/activity-event"; import { CommissionTypeIcon } from "@/ui/partners/comission-type-icon"; import { CommissionRowMenu } from "@/ui/partners/commission-row-menu"; import { CommissionStatusBadges } from "@/ui/partners/commission-status-badges"; import { CommissionTypeBadge } from "@/ui/partners/commission-type-badge"; +import { useEditCommissionModal } from "@/ui/partners/edit-commission-modal"; import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; import { PartnerAvatar } from "@/ui/partners/partner-avatar"; -import { CommentCardDisplay } from "@/ui/partners/partner-comments"; import { ConditionalLink } from "@/ui/shared/conditional-link"; -import { UserAvatar } from "@/ui/users/user-avatar"; import { + Button, ChevronRight, InvoiceDollar, StatusBadge, Table, useTable, } from "@dub/ui"; +import { Pen2 } from "@dub/ui/icons"; import { cn, currencyFormatter, @@ -32,7 +33,6 @@ import { pluralize, } from "@dub/utils"; import { Row } from "@tanstack/react-table"; -import { addDays } from "date-fns"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -105,6 +105,9 @@ function CommissionDetailsContent({ const { groups } = useGroups(); const group = groups?.find((g) => g.id === commission.partner.groupId); + const { openEditCommissionModal, EditCommissionModal } = + useEditCommissionModal(); + const statusBadge = CommissionStatusBadges[commission.status]; const itemsTable = useTable({ @@ -269,6 +272,7 @@ function CommissionDetailsContent({ return (
+
@@ -277,9 +281,20 @@ function CommissionDetailsContent({
-

- Commission details -

+
+

+ Commission details +

+ {commission.status !== "paid" && ( +
{Object.entries(detailRows).map(([key, value]) => (
-

Activity

-
- {[ - // Paid event - ...(commission.status === "paid" && commission.payout?.id - ? [ - { - icon: CommissionStatusBadges["paid"].icon, - timestamp: commission.payout?.paidAt ?? null, - children: ( - <> - - Commission - - - {CommissionStatusBadges["paid"].label} - - {commission.payout.user && ( - <> - by -
- - - {commission.payout.user.name} - -
- - )} - - - - {commission.payout.id} - - - - ), - }, - ] - : []), - - // Processed event - ...(["paid", "processed"].includes(commission.status) - ? [ - { - icon: CommissionStatusBadges["processed"].icon, - timestamp: addDays( - commission.createdAt, - commission.holdingPeriodDays ?? 0, - ), - children: ( - <> - - Commission - - - {CommissionStatusBadges["processed"].label} - - {commission.holdingPeriodDays ? ( - - after {commission.holdingPeriodDays}-day{" "} - - holding period - - - ) : null} - - ), - }, - ] - : []), - - ...(["canceled", "duplicate", "fraud", "refunded"].includes( - commission.status, - ) - ? [ - { - icon: CommissionStatusBadges[commission.status].icon, - timestamp: commission.updatedAt, - children: ( - <> - - Commission marked as - - - {CommissionStatusBadges[commission.status].label} - - - ), - }, - ] - : []), - - // Pending / created event - { - icon: CommissionStatusBadges["pending"].icon, - timestamp: commission.createdAt, - note: (() => { - const text = commission.reward - ? `Earn ${ - commission.reward.type === "percentage" - ? `${commission.reward.amountInPercentage ?? 0}%` - : currencyFormatter( - commission.reward.amountInCents ?? 0, - { trailingZeroDisplay: "stripIfInteger" }, - ) - } per ${commission.reward.event}` - : commission.description ?? null; - - if (!text) return undefined; - - return ( - - ); - })(), - children: ( - <> - Commission - - {CommissionStatusBadges["pending"].label} - - {commission.user ? ( - <> - by -
- - - {commission.user.name} - -
- - ) : null} - - ), - }, - ].map((event, index, arr) => ( - - {event.children} - - ))} -
-
- ); -} - function CommissionDetailSkeleton() { return (
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commissions-table.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commissions-table.tsx index 7f6015e1ff8..90b2a91b6f1 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commissions-table.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commissions-table.tsx @@ -9,6 +9,7 @@ import useWorkspace from "@/lib/swr/use-workspace"; import { CommissionResponse, FraudGroupCountByPartner } from "@/lib/types"; import { CLAWBACK_REASONS_MAP } from "@/lib/zod/schemas/commissions"; import { CustomerRowItem } from "@/ui/customers/customer-row-item"; +import { useBulkEditCommissionsModal } from "@/ui/partners/bulk-edit-commissions-modal"; import { CommissionRowMenu } from "@/ui/partners/commission-row-menu"; import { CommissionStatusBadges } from "@/ui/partners/commission-status-badges"; import { CommissionTypeBadge } from "@/ui/partners/commission-type-badge"; @@ -19,6 +20,7 @@ import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row"; import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; import { AnimatedSizeContainer, + Button, EditColumnsButton, Filter, StatusBadge, @@ -30,7 +32,7 @@ import { useRouterStuff, useTable, } from "@dub/ui"; -import { MoneyBill2 } from "@dub/ui/icons"; +import { MoneyBill2, Pen2 } from "@dub/ui/icons"; import { cn, currencyFormatter, @@ -133,6 +135,9 @@ export function CommissionsTable() { const { canManageFraudEvents } = getPlanCapabilities(workspace?.plan ?? ""); + const { openBulkEditCommissionsModal, BulkEditCommissionsModal } = + useBulkEditCommissionsModal(); + const columns = useMemo( () => [ @@ -315,7 +320,7 @@ export function CommissionsTable() { [slug, groups, program, workspace, fraudGroupCount], ); - const table = useTable({ + const { table, ...tableProps } = useTable({ data: commissions || [], columns, columnPinning: { right: ["menu"] }, @@ -359,6 +364,35 @@ export function CommissionsTable() { ) ); }, + getRowId: (row) => row.id, + selectionControls: (table) => { + const selectedCommissions = table + .getSelectedRowModel() + .rows.map((row) => row.original); + + const hasPaidCommission = selectedCommissions.some( + (c) => c.status === "paid", + ); + const exceedsLimit = selectedCommissions.length > 100; + + return ( +
+
) : ( { - const { workspace, user } = ctx; - const { commissionId } = parsedInput; - - throwIfNoPermission({ - role: workspace.role, - requiredRoles: ["owner", "member"], - }); - - const programId = getDefaultProgramIdOrThrow(workspace); - - const commission = await prisma.commission.findUniqueOrThrow({ - where: { - id: commissionId, - }, - include: { - payout: true, - }, - }); - - if (commission.programId !== programId) { - throw new DubApiError({ - code: "not_found", - message: "Commission not found.", - }); - } - - if (commission.status === "paid") { - throw new Error("You cannot mark a paid commission as duplicate."); - } - - await prisma.$transaction(async (tx) => { - await tx.commission.update({ - where: { - id: commission.id, - }, - data: { - status: "duplicate", - payoutId: null, - }, - }); - - // if there is a payout associated with this commission - // we need to update the payout amount if the commission is being marked as duplicate - if (commission.payout) { - const earnings = commission.earnings; - const revisedAmount = commission.payout.amount - earnings; - - if (revisedAmount === 0) { - return tx.payout.delete({ where: { id: commission.payout.id } }); - } - return tx.payout.update({ - where: { id: commission.payout.id }, - data: { amount: revisedAmount }, - }); - } - }); - - waitUntil( - (async () => { - await Promise.allSettled([ - syncTotalCommissions({ - partnerId: commission.partnerId, - programId, - }), - - recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "commission.marked_duplicate", - description: `Commission ${commissionId} marked as duplicate`, - actor: user, - targets: [ - { - type: "commission", - id: commissionId, - metadata: commission, - }, - ], - }), - ]); - })(), - ); - - // TODO: We might want to store the history of the sale status changes - // TODO: Send email to the partner informing them about the sale status change - }); diff --git a/apps/web/lib/actions/partners/mark-commission-fraud-or-canceled.ts b/apps/web/lib/actions/partners/mark-commission-fraud-or-canceled.ts deleted file mode 100644 index eacc1872eea..00000000000 --- a/apps/web/lib/actions/partners/mark-commission-fraud-or-canceled.ts +++ /dev/null @@ -1,143 +0,0 @@ -"use server"; - -import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; -import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; -import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; -import { prisma } from "@dub/prisma"; -import { waitUntil } from "@vercel/functions"; -import * as z from "zod/v4"; -import { authActionClient } from "../safe-action"; -import { throwIfNoPermission } from "../throw-if-no-permission"; - -const markCommissionFraudOrCanceledSchema = z.object({ - workspaceId: z.string(), - commissionId: z.string(), - status: z.enum(["fraud", "canceled"]), -}); - -// Mark a commission as fraud or canceled for a partner + customer for all historical commissions -export const markCommissionFraudOrCanceledAction = authActionClient - .inputSchema(markCommissionFraudOrCanceledSchema) - .action(async ({ parsedInput, ctx }) => { - const { workspace, user } = ctx; - const { commissionId, status } = parsedInput; - - throwIfNoPermission({ - role: workspace.role, - requiredRoles: ["owner", "member"], - }); - - const programId = getDefaultProgramIdOrThrow(workspace); - - const commission = await prisma.commission.findUniqueOrThrow({ - where: { - id: commissionId, - }, - }); - - if (commission.programId !== programId) { - throw new Error("Commission not found."); - } - - const { partnerId, customerId } = commission; - - // for custom and click commissions, only update this commission - // for all other commission types, update all historical commissions for the customer and partner combination - const commissions = await prisma.commission.findMany({ - where: { - ...(commission.type === "custom" || commission.type === "click" - ? { id: commissionId } - : {}), - partnerId, - customerId, - status: { - in: ["pending", "processed"], - }, - }, - include: { - payout: true, - }, - }); - - const commissionsWithPayout = commissions.filter( - (commission) => commission.payout, - ); - - // Group commissions by payout ID to batch updates - const payoutUpdates = commissionsWithPayout.reduce( - (acc, commission) => { - const payoutId = commission.payout!.id; - - if (!acc[payoutId]) { - acc[payoutId] = { - payoutId, - currentAmount: commission.payout!.amount, - earningsToDeduct: 0, - }; - } - - acc[payoutId].earningsToDeduct += commission.earnings; - - return acc; - }, - {} as Record< - string, - { - payoutId: string; - currentAmount: number; - earningsToDeduct: number; - } - >, - ); - - await prisma.$transaction([ - ...Object.values(payoutUpdates).map( - ({ payoutId, currentAmount, earningsToDeduct }) => { - if (currentAmount - earningsToDeduct === 0) { - return prisma.payout.delete({ where: { id: payoutId } }); - } - return prisma.payout.update({ - where: { id: payoutId }, - data: { amount: currentAmount - earningsToDeduct }, - }); - }, - ), - prisma.commission.updateMany({ - where: { - id: { - in: commissions.map((commission) => commission.id), - }, - }, - data: { status, payoutId: null }, - }), - ]); - - waitUntil( - (async () => { - await Promise.allSettled([ - syncTotalCommissions({ - partnerId: commission.partnerId, - programId, - }), - - recordAuditLog({ - workspaceId: workspace.id, - programId, - action: - status === "fraud" - ? "commission.marked_fraud" - : "commission.canceled", - description: `Commission ${commissionId} marked as ${status}`, - actor: user, - targets: [ - { - type: "commission", - id: commissionId, - metadata: commission, - }, - ], - }), - ]); - })(), - ); - }); diff --git a/apps/web/lib/actions/partners/unban-partner.ts b/apps/web/lib/actions/partners/unban-partner.ts index 04bd6c6568f..2d9c32bd200 100644 --- a/apps/web/lib/actions/partners/unban-partner.ts +++ b/apps/web/lib/actions/partners/unban-partner.ts @@ -1,6 +1,7 @@ "use server"; import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { trackCommissionStatusUpdate } from "@/lib/api/commissions/track-commission-update-activity-log"; import { getGroupOrThrow } from "@/lib/api/groups/get-group-or-throw"; import { linkCache } from "@/lib/api/links/cache"; import { includeProgramEnrollment } from "@/lib/api/links/include-program-enrollment"; @@ -57,6 +58,20 @@ export const unbanPartnerAction = authActionClient programEnrollment.groupId || programEnrollment.program.defaultGroupId, }); + // Fetch canceled commissions before the transaction for activity logging + const canceledCommissions = await prisma.commission.findMany({ + where: { + ...where, + status: "canceled", + }, + select: { + id: true, + amount: true, + earnings: true, + status: true, + }, + }); + await prisma.$transaction([ prisma.link.updateMany({ where, @@ -129,6 +144,14 @@ export const unbanPartnerAction = authActionClient // Update Tinybird links metadata recordLink(links), + // Track commission activity logs for the unban + trackCommissionStatusUpdate({ + workspaceId: workspace.id, + programId, + commissions: canceledCommissions, + newStatus: "pending", + }), + recordAuditLog({ workspaceId: workspace.id, programId, diff --git a/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts b/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts new file mode 100644 index 00000000000..5c6282e7396 --- /dev/null +++ b/apps/web/lib/api/commissions/bulk-update-partner-commissions.ts @@ -0,0 +1,171 @@ +import { MUTABLE_PAYOUT_STATUSES } from "@/lib/constants/payouts"; +import { bulkUpdateCommissionsSchema } from "@/lib/zod/schemas/commissions"; +import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; +import * as z from "zod/v4"; +import { DubApiError } from "../errors"; +import { syncTotalCommissions } from "../partners/sync-total-commissions"; +import { reconcilePayoutAmounts } from "./reconcile-payout-amounts"; +import { trackCommissionActivityLog } from "./track-commission-update-activity-log"; + +type BulkUpdatePartnerCommissionsProps = z.infer< + typeof bulkUpdateCommissionsSchema +> & { + workspaceId: string; + programId: string; + userId: string; +}; + +// TODO: +// Send email to partners about the commission update + +export async function bulkUpdatePartnerCommissions({ + workspaceId, + programId, + commissionIds, + status, + userId, +}: BulkUpdatePartnerCommissionsProps) { + const commissions = await prisma.commission.findMany({ + where: { + programId, + id: { + in: commissionIds, + }, + }, + include: { + program: { + select: { + workspaceId: true, + }, + }, + payout: { + select: { + id: true, + status: true, + }, + }, + }, + orderBy: { + id: "asc", + }, + }); + + if (commissions.length !== commissionIds.length) { + throw new DubApiError({ + code: "not_found", + message: + "One or more commissions were not found in this program or the IDs are invalid.", + }); + } + + const paidIds = commissions + .filter((c) => c.status === "paid") + .map((c) => c.id); + + if (paidIds.length > 0) { + throw new DubApiError({ + code: "bad_request", + message: `Cannot update commissions: The following commission(s) have already been paid: ${paidIds.join(", ")}`, + }); + } + + const blockedByPayout = commissions.filter( + (c) => c.payout && !MUTABLE_PAYOUT_STATUSES.includes(c.payout.status), + ); + + if (blockedByPayout.length > 0) { + throw new DubApiError({ + code: "bad_request", + message: `Cannot update commissions: ${blockedByPayout.map((c) => `${c.id} (its payout is already ${c.payout!.status})`).join(", ")}`, + }); + } + + const alreadyTargetIds = commissions + .filter((c) => c.status === status) + .map((c) => c.id); + + if (alreadyTargetIds.length > 0) { + throw new DubApiError({ + code: "bad_request", + message: `The following commission(s) are already in the ${status} status: ${alreadyTargetIds.join(", ")}`, + }); + } + + // Update the commissions to the new status + await prisma.commission.updateMany({ + where: { + id: { + in: commissions.map((c) => c.id), + }, + status: { + not: "paid", + }, + OR: [ + { + payoutId: null, + }, + { + payout: { + status: { + in: MUTABLE_PAYOUT_STATUSES, + }, + }, + }, + ], + }, + data: { + status, + payoutId: null, + }, + }); + + // Reconcile the payout amounts + const payoutIds = commissions + .filter((c) => c.payoutId) + .map((c) => c.payoutId!); + + await reconcilePayoutAmounts(payoutIds); + + // Retrieve the updated commissions + const updatedCommissions = await prisma.commission.findMany({ + where: { + programId, + id: { + in: commissionIds, + }, + }, + include: { + customer: true, + partner: true, + programEnrollment: true, + }, + orderBy: { + id: "asc", + }, + }); + + // Find unique partner Ids + const partnerIds = [...new Set(updatedCommissions.map((c) => c.partnerId))]; + + waitUntil( + Promise.allSettled([ + ...partnerIds.map((partnerId) => + syncTotalCommissions({ + partnerId, + programId, + }), + ), + + trackCommissionActivityLog({ + workspaceId, + programId, + userId, + old: commissions, + new: updatedCommissions, + }), + ]), + ); + + return updatedCommissions; +} diff --git a/apps/web/lib/api/commissions/reconcile-payout-amounts.ts b/apps/web/lib/api/commissions/reconcile-payout-amounts.ts new file mode 100644 index 00000000000..d058bf6ef02 --- /dev/null +++ b/apps/web/lib/api/commissions/reconcile-payout-amounts.ts @@ -0,0 +1,82 @@ +import { MUTABLE_PAYOUT_STATUSES } from "@/lib/constants/payouts"; +import { prisma } from "@dub/prisma"; + +export async function reconcilePayoutAmounts(payoutIds: string[]) { + const uniquePayoutIds = [...new Set(payoutIds)]; + + if (uniquePayoutIds.length === 0) { + return; + } + + await prisma.$transaction(async (tx) => { + const aggregates = await tx.commission.groupBy({ + by: ["payoutId"], + where: { + payoutId: { + in: uniquePayoutIds, + }, + }, + _sum: { + earnings: true, + }, + }); + + const sumByPayoutId = new Map( + aggregates.map((a) => [a.payoutId!, a._sum.earnings ?? 0]), + ); + + const toDelete: string[] = []; + const toUpdate: { id: string; amount: number }[] = []; + + for (const id of uniquePayoutIds) { + const newPayoutAmount = sumByPayoutId.get(id) ?? 0; + + if (newPayoutAmount === 0) { + toDelete.push(id); + } else { + toUpdate.push({ id, amount: newPayoutAmount }); + } + } + + if (toDelete.length > 0) { + await tx.payout.deleteMany({ + where: { + id: { + in: toDelete, + }, + status: { + in: MUTABLE_PAYOUT_STATUSES, + }, + }, + }); + } + + await Promise.all( + toUpdate.map(({ id, amount }) => + tx.payout.update({ + where: { + id, + status: { + in: MUTABLE_PAYOUT_STATUSES, + }, + }, + data: { + amount, + }, + }), + ), + ); + + for (const id of toDelete) { + console.log( + `[reconcilePayoutAmount] Deleted payout ${id} because it has no commissions.`, + ); + } + + for (const { id, amount } of toUpdate) { + console.log( + `[reconcilePayoutAmount] Updated payout amount for payout ${id} to ${amount}`, + ); + } + }); +} diff --git a/apps/web/lib/api/commissions/track-commission-update-activity-log.ts b/apps/web/lib/api/commissions/track-commission-update-activity-log.ts new file mode 100644 index 00000000000..2960820e3d5 --- /dev/null +++ b/apps/web/lib/api/commissions/track-commission-update-activity-log.ts @@ -0,0 +1,142 @@ +import { + trackActivityLog, + type TrackActivityLogInput, +} from "@/lib/api/activity-log/track-activity-log"; +import { CommissionActivitySnapshot } from "@/lib/types"; +import type { Commission, CommissionStatus } from "@dub/prisma/client"; +import { groupBy } from "@dub/utils"; +import { getResourceDiff } from "../activity-log/get-resource-diff"; + +interface TrackActivityLogParams + extends Omit< + TrackActivityLogInput, + "action" | "changeSet" | "resourceType" | "batchId" | "resourceId" + > { + old: Pick[] | null; + new: Pick[] | null; +} + +const COMMISSION_ACTIVITY_FIELDS = ["amount", "earnings", "status"]; + +export function toCommissionActivitySnapshot( + commission: Pick, +): CommissionActivitySnapshot { + return { + amount: commission.amount, + earnings: commission.earnings, + status: commission.status, + }; +} + +export async function trackCommissionActivityLog({ + old: oldCommissions, + new: newCommissions, + ...baseInput +}: TrackActivityLogParams) { + const activityLogs: TrackActivityLogInput[] = []; + + const oldById = new Map((oldCommissions ?? []).map((c) => [c.id, c])); + const newById = new Map((newCommissions ?? []).map((c) => [c.id, c])); + + const commissionIds = [ + ...new Set([...oldById.keys(), ...newById.keys()]), + ].sort(); + + for (const id of commissionIds) { + const oldCommission = oldById.get(id); + const newCommission = newById.get(id); + + if (oldCommission && newCommission) { + const oldSnapshot = toCommissionActivitySnapshot(oldCommission); + const newSnapshot = toCommissionActivitySnapshot(newCommission); + + const diff = getResourceDiff(oldSnapshot, newSnapshot, { + fields: COMMISSION_ACTIVITY_FIELDS, + }); + + if (diff) { + activityLogs.push({ + ...baseInput, + resourceId: newCommission.id, + resourceType: "commission", + action: "commission.updated", + changeSet: { + commission: { + old: oldSnapshot, + new: newSnapshot, + }, + }, + }); + } + } + } + + return await trackActivityLog(activityLogs); +} + +// Track activity logs for commissions that are all being updated to the same new status. +// Useful for bulk operations like aggregate-due-commissions, ban/unban, payouts, etc. +export async function trackCommissionStatusUpdate({ + commissions, + newStatus, + ...baseInput +}: { + commissions: Pick[]; + newStatus: CommissionStatus; +} & Pick) { + if (commissions.length === 0) { + return; + } + + return trackCommissionActivityLog({ + ...baseInput, + old: commissions, + new: commissions.map((commission) => ({ + ...commission, + status: newStatus, + })), + }); +} + +// For payouts that may span multiple programs: resolve workspace from payouts, then +// delegate to trackCommissionStatusUpdate once per program. +export async function trackCommissionStatusUpdatesByProgram({ + commissions, + payouts, + newStatus = "paid", +}: { + commissions: Pick< + Commission, + "id" | "amount" | "earnings" | "status" | "programId" + >[]; + payouts: Array<{ program: { id: string; workspaceId: string } }>; + newStatus?: CommissionStatus; +}) { + if (commissions.length === 0) { + return; + } + + const workspaceByProgram = new Map( + payouts.map((p) => [p.program.id, p.program.workspaceId]), + ); + + const commissionsByProgram = groupBy(commissions, (c) => c.programId); + + for (const [programId, commissions] of Object.entries(commissionsByProgram)) { + const workspaceId = workspaceByProgram.get(programId); + + if (!workspaceId) { + console.error( + `Workspace not found for program ${programId}. Skipping...`, + ); + continue; + } + + await trackCommissionStatusUpdate({ + workspaceId, + programId, + commissions, + newStatus, + }); + } +} diff --git a/apps/web/lib/api/commissions/update-partner-commission.ts b/apps/web/lib/api/commissions/update-partner-commission.ts new file mode 100644 index 00000000000..c9d5d36b779 --- /dev/null +++ b/apps/web/lib/api/commissions/update-partner-commission.ts @@ -0,0 +1,325 @@ +import { convertCurrency } from "@/lib/analytics/convert-currency"; +import { MUTABLE_PAYOUT_STATUSES } from "@/lib/constants/payouts"; +import { determinePartnerReward } from "@/lib/partners/determine-partner-reward"; +import { updateCommissionSchemaExtended } from "@/lib/zod/schemas/commissions"; +import { prisma } from "@dub/prisma"; +import { Commission } from "@dub/prisma/client"; +import { waitUntil } from "@vercel/functions"; +import * as z from "zod/v4"; +import { DubApiError } from "../errors"; +import { syncTotalCommissions } from "../partners/sync-total-commissions"; +import { getProgramEnrollmentOrThrow } from "../programs/get-program-enrollment-or-throw"; +import { calculateSaleEarnings } from "../sales/calculate-sale-earnings"; +import { reconcilePayoutAmounts } from "./reconcile-payout-amounts"; +import { + trackCommissionActivityLog, + trackCommissionStatusUpdate, +} from "./track-commission-update-activity-log"; + +type UpdatePartnerCommissionProps = z.infer< + typeof updateCommissionSchemaExtended +> & { + workspaceId: string; + programId: string; + commissionId: string; + userId?: string; +}; + +// TODO: +// Send email to partners about the commission update + +export async function updatePartnerCommission({ + workspaceId, + programId, + commissionId, + status, + userId, + // Sale commission fields + saleAmount, + modifySaleAmount, + currency, + // Custom commission fields + earnings, + updateHistoricalCommissions = false, +}: UpdatePartnerCommissionProps) { + const commission = await prisma.commission.findUnique({ + where: { + id: commissionId, + }, + include: { + payout: { + select: { + id: true, + amount: true, + status: true, + }, + }, + partner: { + select: { + id: true, + }, + }, + }, + }); + + if (!commission || commission.programId !== programId) { + 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.`, + }); + } + + if ( + commission.payout && + !MUTABLE_PAYOUT_STATUSES.includes(commission.payout.status) + ) { + throw new DubApiError({ + code: "bad_request", + message: `Cannot update commission: Its payout is already ${commission.payout.status}.`, + }); + } + + if (status && status === commission.status) { + throw new DubApiError({ + code: "bad_request", + message: `This commission is already in the ${status} status.`, + }); + } + + const { partner, amount: originalSaleAmount } = commission; + + let finalSaleAmount: number | undefined; + let finalEarnings: number | undefined; + + // Commission for sale events + if (saleAmount !== undefined || modifySaleAmount !== undefined) { + if (commission.type !== "sale") { + throw new DubApiError({ + code: "bad_request", + message: `Cannot update sale 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 = modifySaleAmount ?? saleAmount; + + if (valueToConvert !== undefined) { + const { currency: convertedCurrency, amount: convertedAmount } = + await convertCurrency({ + currency, + amount: valueToConvert, + }); + + if (modifySaleAmount !== undefined) { + modifySaleAmount = convertedAmount; + } else { + saleAmount = convertedAmount; + } + + currency = convertedCurrency; + } + } + + finalSaleAmount = Math.max( + modifySaleAmount !== undefined + ? originalSaleAmount + modifySaleAmount + : saleAmount ?? originalSaleAmount, + 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: finalSaleAmount, + quantity: commission.quantity, + }, + }); + } + + // Commission for custom events + if (earnings !== undefined) { + if (commission.type !== "custom") { + throw new DubApiError({ + code: "bad_request", + message: `Cannot update earnings: Commission ${commissionId} is not a custom commission.`, + }); + } + + finalEarnings = earnings; + } + + const isRefunded = finalSaleAmount === 0 || finalEarnings === 0; + const finalStatus = status ?? (isRefunded ? "refunded" : undefined); + + const updatedCommission = await prisma.commission.update({ + where: { + id: commission.id, + status: { + not: "paid", + }, + OR: [ + { + payoutId: null, + }, + { + payout: { + status: { + in: MUTABLE_PAYOUT_STATUSES, + }, + }, + }, + ], + }, + 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 : finalSaleAmount, + earnings: isRefunded ? undefined : finalEarnings, + status: finalStatus, + ...(finalStatus ? { payoutId: null } : {}), + }, + include: { + customer: true, + partner: true, + }, + }); + + // For fraud/canceled on sale/lead commissions, also update all related + // historical commissions for the same customer + partner combination + let relatedCommissions: Pick< + Commission, + "id" | "amount" | "earnings" | "status" | "payoutId" + >[] = []; + + if ( + updateHistoricalCommissions && + (finalStatus === "fraud" || finalStatus === "canceled") && + (commission.type === "sale" || commission.type === "lead") + ) { + relatedCommissions = await prisma.commission.findMany({ + where: { + programId: commission.programId, + partnerId: commission.partnerId, + customerId: commission.customerId, + status: { + in: ["pending", "processed"], + }, + id: { + not: commission.id, + }, + }, + select: { + id: true, + amount: true, + earnings: true, + status: true, + payoutId: true, + }, + }); + + if (relatedCommissions.length > 0) { + await prisma.commission.updateMany({ + where: { + id: { + in: relatedCommissions.map(({ id }) => id), + }, + status: { + not: "paid", + }, + OR: [ + { + payoutId: null, + }, + { + payout: { + status: { + in: MUTABLE_PAYOUT_STATUSES, + }, + }, + }, + ], + }, + data: { + status: finalStatus, + payoutId: null, + }, + }); + } + } + + // Reconcile payout amounts for all affected payouts + const affectedPayoutIds = [ + ...(commission.status === "processed" && commission.payoutId + ? [commission.payoutId] + : []), + ...relatedCommissions + .filter(({ payoutId }) => payoutId) + .map(({ payoutId }) => payoutId!), + ]; + + if (affectedPayoutIds.length > 0) { + await reconcilePayoutAmounts(affectedPayoutIds); + } + + waitUntil( + Promise.allSettled([ + syncTotalCommissions({ + partnerId: commission.partnerId, + programId: commission.programId, + }), + + trackCommissionActivityLog({ + workspaceId, + programId, + userId, + old: [commission], + new: [updatedCommission], + }), + + relatedCommissions.length > 0 + ? trackCommissionStatusUpdate({ + workspaceId, + programId, + userId, + commissions: relatedCommissions, + newStatus: finalStatus!, + }) + : Promise.resolve(), + ]), + ); + + return updatedCommission; +} diff --git a/apps/web/lib/constants/payouts.ts b/apps/web/lib/constants/payouts.ts index bf2a50fafe0..e28bb1bfd76 100644 --- a/apps/web/lib/constants/payouts.ts +++ b/apps/web/lib/constants/payouts.ts @@ -1,3 +1,4 @@ +import { PayoutStatus } from "@dub/prisma/client"; import Stripe from "stripe"; import { PaymentMethodOption } from "../types"; @@ -18,6 +19,8 @@ export const MIN_WITHDRAWAL_AMOUNT_CENTS = 1000; // $10 export const BELOW_MIN_WITHDRAWAL_FEE_CENTS = 50; // $0.50 export const MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS = 100; // $1 (doesn't make sense to force a withdrawal for less than $1) +export const MUTABLE_PAYOUT_STATUSES: PayoutStatus[] = ["pending", "canceled"]; + // Direct debit payment types for Partner payout export const DIRECT_DEBIT_PAYMENT_TYPES_INFO: { type: Stripe.PaymentMethod.Type; diff --git a/apps/web/lib/openapi/commissions/bulk-update-commissions.ts b/apps/web/lib/openapi/commissions/bulk-update-commissions.ts new file mode 100644 index 00000000000..0de38705e84 --- /dev/null +++ b/apps/web/lib/openapi/commissions/bulk-update-commissions.ts @@ -0,0 +1,39 @@ +import { + bulkUpdateCommissionsSchema, + CommissionSchema, +} from "@/lib/zod/schemas/commissions"; +import { ZodOpenApiOperationObject } from "zod-openapi"; +import * as z from "zod/v4"; +import { openApiErrorResponses } from "../responses"; + +export const bulkUpdateCommissions: ZodOpenApiOperationObject = { + operationId: "bulkUpdateCommissions", + "x-speakeasy-name-override": "updateMany", + summary: "Bulk update commissions", + description: "Bulk update up to 100 commissions with the same status.", + requestBody: { + content: { + "application/json": { + schema: bulkUpdateCommissionsSchema, + }, + }, + }, + responses: { + "200": { + description: "The updated commissions.", + content: { + "application/json": { + schema: z.array( + CommissionSchema.pick({ + id: true, + status: true, + }), + ), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Commissions"], + security: [{ token: [] }], +}; diff --git a/apps/web/lib/openapi/commissions/index.ts b/apps/web/lib/openapi/commissions/index.ts index 34e06441647..ac453856313 100644 --- a/apps/web/lib/openapi/commissions/index.ts +++ b/apps/web/lib/openapi/commissions/index.ts @@ -1,4 +1,5 @@ import { ZodOpenApiPathsObject } from "zod-openapi"; +import { bulkUpdateCommissions } from "./bulk-update-commissions"; import { listCommissions } from "./list-commissions"; import { updateCommission } from "./update-commission"; @@ -9,4 +10,7 @@ export const commissionsPaths: ZodOpenApiPathsObject = { "/commissions/{id}": { patch: updateCommission, }, + "/commissions/bulk": { + patch: bulkUpdateCommissions, + }, }; diff --git a/apps/web/lib/partners/create-stablecoin-payout.ts b/apps/web/lib/partners/create-stablecoin-payout.ts index ee15de46e2e..96e575d91ba 100644 --- a/apps/web/lib/partners/create-stablecoin-payout.ts +++ b/apps/web/lib/partners/create-stablecoin-payout.ts @@ -1,9 +1,11 @@ +import { trackCommissionStatusUpdatesByProgram } from "@/lib/api/commissions/track-commission-update-activity-log"; import { sendEmail } from "@dub/email"; import PartnerPayoutForceWithdrawal from "@dub/email/templates/partner-payout-force-withdrawal"; import PartnerPayoutProcessed from "@dub/email/templates/partner-payout-processed"; import { prisma } from "@dub/prisma"; import { PartnerPayoutMethod, Prisma } from "@dub/prisma/client"; import { currencyFormatter, prettyPrint } from "@dub/utils"; +import { waitUntil } from "@vercel/functions"; import { BELOW_MIN_WITHDRAWAL_FEE_CENTS, MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS, @@ -54,8 +56,10 @@ export const createStablecoinPayout = async ({ const commonInclude: Prisma.PayoutInclude = { program: { select: { + id: true, name: true, logo: true, + workspaceId: true, }, }, }; @@ -227,11 +231,28 @@ export const createStablecoinPayout = async ({ return; } + const payoutIds = allPayouts.map((p) => p.id); + + const commissions = await prisma.commission.findMany({ + where: { + payoutId: { + in: payoutIds, + }, + }, + select: { + id: true, + amount: true, + earnings: true, + status: true, + programId: true, + }, + }); + await prisma.$transaction([ prisma.payout.updateMany({ where: { id: { - in: allPayouts.map((p) => p.id), + in: payoutIds, }, }, data: { @@ -245,7 +266,7 @@ export const createStablecoinPayout = async ({ prisma.commission.updateMany({ where: { payoutId: { - in: allPayouts.map((p) => p.id), + in: payoutIds, }, }, data: { @@ -254,6 +275,14 @@ export const createStablecoinPayout = async ({ }), ]); + waitUntil( + trackCommissionStatusUpdatesByProgram({ + commissions, + payouts: allPayouts, + newStatus: "paid", + }), + ); + if (!partner.email) { console.warn( `Partner ${partner.email} does not have an email address to send the payout email to.`, diff --git a/apps/web/lib/partners/create-stripe-transfer.ts b/apps/web/lib/partners/create-stripe-transfer.ts index 1d8d7ecc5c8..52e9452b249 100644 --- a/apps/web/lib/partners/create-stripe-transfer.ts +++ b/apps/web/lib/partners/create-stripe-transfer.ts @@ -1,3 +1,4 @@ +import { trackCommissionStatusUpdatesByProgram } from "@/lib/api/commissions/track-commission-update-activity-log"; import { BELOW_MIN_WITHDRAWAL_FEE_CENTS, MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS, @@ -10,6 +11,7 @@ import PartnerPayoutProcessed from "@dub/email/templates/partner-payout-processe import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { currencyFormatter, pluralize } from "@dub/utils"; +import { waitUntil } from "@vercel/functions"; import { createPayoutsIdempotencyKey } from "../payouts/create-payouts-idempotency-key"; import { markPayoutsAsProcessed } from "../payouts/mark-payouts-as-processed"; @@ -46,8 +48,10 @@ export const createStripeTransfer = async ({ const commonInclude: Prisma.PayoutInclude = { program: { select: { + id: true, name: true, logo: true, + workspaceId: true, }, }, }; @@ -194,11 +198,28 @@ export const createStripeTransfer = async ({ )} ${allPayouts.map((p) => p.id).join(", ")}`, ); + const payoutIds = allPayouts.map((p) => p.id); + + const commissions = await prisma.commission.findMany({ + where: { + payoutId: { + in: payoutIds, + }, + }, + select: { + id: true, + amount: true, + earnings: true, + status: true, + programId: true, + }, + }); + await Promise.allSettled([ prisma.payout.updateMany({ where: { id: { - in: allPayouts.map((p) => p.id), + in: payoutIds, }, }, data: { @@ -212,7 +233,7 @@ export const createStripeTransfer = async ({ prisma.commission.updateMany({ where: { payoutId: { - in: allPayouts.map((p) => p.id), + in: payoutIds, }, }, data: { @@ -221,6 +242,14 @@ export const createStripeTransfer = async ({ }), ]); + waitUntil( + trackCommissionStatusUpdatesByProgram({ + commissions, + payouts: allPayouts, + newStatus: "paid", + }), + ); + if (partner.email) { const payout = currentInvoicePayouts[0]; const emailRes = await sendEmail({ diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 4009dbd8795..8ae6ede6605 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -10,6 +10,7 @@ import { } from "@/lib/zod/schemas/partner-profile"; import { DirectorySyncProviders } from "@boxyhq/saml-jackson"; import { + Commission, CommissionStatus, FolderUserRole, FraudEvent, @@ -892,3 +893,8 @@ export type NullableOptional = { export type PartnerBountySubmission = z.infer< typeof partnerBountySubmissionSchema >; + +export type CommissionActivitySnapshot = Pick< + Commission, + "amount" | "earnings" | "status" +>; diff --git a/apps/web/lib/zod/schemas/activity-log.ts b/apps/web/lib/zod/schemas/activity-log.ts index aa775d7682b..b9fae24532a 100644 --- a/apps/web/lib/zod/schemas/activity-log.ts +++ b/apps/web/lib/zod/schemas/activity-log.ts @@ -4,6 +4,7 @@ import { UserSchema } from "./users"; export const activityLogResourceTypeSchema = z.enum([ "referral", "partner", + "commission", "clickReward", "saleReward", "leadReward", @@ -21,6 +22,8 @@ export const activityLogActionSchema = z.enum([ "partner.groupChanged", + "commission.updated", + "reward.created", "reward.updated", "reward.deleted", diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index 63543f3d182..d44cdbd63eb 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -193,33 +193,76 @@ export const createCommissionSchema = z.object({ productId: z.string().nullish(), }); +export const commissionPatchStatusSchema = z.enum([ + "pending", + "refunded", + "duplicate", + "canceled", + "fraud", +]); + export const updateCommissionSchema = z.object({ - amount: z + saleAmount: z .number() .min(0) + .optional() .describe( "The new absolute amount for the sale. Paid commissions cannot be updated.", - ) - .optional(), - modifyAmount: z + ), + modifySaleAmount: z .number() + .optional() .describe( - "Modify the current sale amount: use positive values to increase the amount, negative values to decrease it. Takes precedence over `amount`. Paid commissions cannot be updated.", - ) - .optional(), + "Modify the current sale amount: use positive values to increase the amount, negative values to decrease it. Takes precedence over `saleAmount`. Paid commissions cannot be updated.", + ), + earnings: z + .number() + .min(0) + .optional() + .describe( + "The new absolute earnings for the custom commission. Paid commissions cannot be updated.", + ), currency: z .string() + .optional() .default("usd") .transform((val) => val.toLowerCase()) .describe( "The currency of the sale amount to update. Accepts ISO 4217 currency codes.", ), - status: z - .enum(["refunded", "duplicate", "canceled", "fraud"]) + status: commissionPatchStatusSchema .optional() .describe( - "Useful for marking a commission as refunded, duplicate, canceled, or fraudulent. Takes precedence over `amount` and `modifyAmount`. When a commission is marked as refunded, duplicate, canceled, or fraudulent, it will be omitted from the payout, and the payout amount will be recalculated accordingly. Paid commissions cannot be updated.", + "Useful for marking a commission as pending, refunded, duplicate, canceled, or fraudulent. Takes precedence over `saleAmount` and `modifySaleAmount`. When a commission is marked as pending, refunded, duplicate, canceled, or fraudulent, it will be omitted from the payout, and the payout amount will be recalculated accordingly. Paid commissions cannot be updated.", ), + amount: z + .number() + .min(0) + .optional() + .describe("Deprecated. Use `saleAmount` instead.") + .meta({ deprecated: true }), + modifyAmount: z + .number() + .optional() + .describe("Deprecated. Use `modifySaleAmount` instead.") + .meta({ deprecated: true }), +}); + +export const updateCommissionSchemaExtended = updateCommissionSchema.extend({ + updateHistoricalCommissions: z.boolean().optional(), +}); + +export const bulkUpdateCommissionsSchema = z.object({ + commissionIds: z + .array(z.string()) + .min(1, "At least one commission ID is required.") + .max(100, "You can only update up to 100 commissions at a time.") + .refine((ids) => new Set(ids).size === ids.length, { + message: "commissionIds must be unique.", + }), + status: commissionPatchStatusSchema.describe( + "The status to apply to every commission in the batch.", + ), }); export const CLAWBACK_REASONS = [ diff --git a/apps/web/scripts/migrations/backfill-commission-activity-log.ts b/apps/web/scripts/migrations/backfill-commission-activity-log.ts new file mode 100644 index 00000000000..4fed3d1020a --- /dev/null +++ b/apps/web/scripts/migrations/backfill-commission-activity-log.ts @@ -0,0 +1,198 @@ +import "dotenv-flow/config"; + +import { prisma } from "@dub/prisma"; +import { Prisma } from "@dub/prisma/client"; + +const BATCH_SIZE = 500; +const TERMINAL_STATUSES = ["refunded", "duplicate", "fraud", "canceled"]; + +// REMOVE before running: +// - "server-only" from any imported files if needed + +// Note: Activity log createdAt is not accurate, but it's the best we can do + +async function main() { + const programId = process.env.PROGRAM_ID; + + let cursor: string | undefined = undefined; + let totalProcessed = 0; + let totalLogsCreated = 0; + + console.log( + `Starting commission activity log backfill${programId ? ` for program ${programId}` : ""}...`, + ); + + while (true) { + const commissions = await prisma.commission.findMany({ + where: { + ...(programId ? { programId } : {}), + }, + include: { + program: { + select: { + workspaceId: true, + }, + }, + payout: { + select: { + paidAt: true, + }, + }, + }, + orderBy: { + id: "asc", + }, + take: BATCH_SIZE, + ...(cursor + ? { + skip: 1, + cursor: { + id: cursor, + }, + } + : {}), + }); + + if (commissions.length === 0) { + break; + } + + cursor = commissions[commissions.length - 1].id; + + // Reset every batch (avoid accidental accumulation / re-inserts) + let commissionsToBackfill = [] as typeof commissions; + commissionsToBackfill = commissions.filter((c) => c.id); + + if (commissionsToBackfill.length === 0) { + totalProcessed += commissions.length; + console.log( + `Batch skipped (all have logs). Total processed: ${totalProcessed}`, + ); + continue; + } + + const activityLogs: Prisma.ActivityLogCreateManyInput[] = []; + + for (const commission of commissionsToBackfill) { + const { amount, earnings, status, program } = commission; + + const base = { + workspaceId: program.workspaceId, + programId: commission.programId, + resourceType: "commission", + resourceId: commission.id, + }; + + if (status === "processed") { + activityLogs.push({ + ...base, + action: "commission.updated", + createdAt: commission.updatedAt, + changeSet: { + commission: { + old: { + amount, + earnings, + status: "pending", + }, + new: { + amount, + earnings, + status: "processed", + }, + }, + }, + }); + + continue; + } + + if (status === "paid") { + // pending → processed + activityLogs.push({ + ...base, + action: "commission.updated", + createdAt: commission.updatedAt, + changeSet: { + commission: { + old: { + amount, + earnings, + status: "pending", + }, + new: { + amount, + earnings, + status: "processed", + }, + }, + }, + }); + + // this should be there but just in case + if (commission.payout?.paidAt) { + // processed → paid + activityLogs.push({ + ...base, + action: "commission.updated", + createdAt: commission.payout?.paidAt, + changeSet: { + commission: { + old: { + amount, + earnings, + status: "processed", + }, + new: { + amount, + earnings, + status: "paid", + }, + }, + }, + }); + } else { + console.warn( + `No paidAt for commission ${commission.id} payout ${commission.payoutId}`, + ); + } + + continue; + } + + if (TERMINAL_STATUSES.includes(status)) { + activityLogs.push({ + ...base, + action: "commission.updated", + createdAt: commission.updatedAt, + changeSet: { + commission: { + old: { amount, earnings, status: "pending" }, + new: { amount, earnings, status }, + }, + }, + }); + } + } + + if (activityLogs.length > 0) { + await prisma.activityLog.createMany({ + data: activityLogs, + skipDuplicates: true, + }); + } + + totalProcessed += commissions.length; + totalLogsCreated += activityLogs.length; + + console.log( + `Batch done: ${commissionsToBackfill.length} commissions, ${activityLogs.length} logs created. Total processed: ${totalProcessed}, total logs: ${totalLogsCreated}`, + ); + } + + console.log( + `\nBackfill complete. Processed ${totalProcessed} commissions, created ${totalLogsCreated} activity logs.`, + ); +} + +main(); diff --git a/apps/web/tests/commissions/bulk-updates.test.ts b/apps/web/tests/commissions/bulk-updates.test.ts new file mode 100644 index 00000000000..521ec8c316a --- /dev/null +++ b/apps/web/tests/commissions/bulk-updates.test.ts @@ -0,0 +1,107 @@ +import { CommissionResponse } from "@/lib/types"; +import { describe, expect, test } from "vitest"; +import { IntegrationHarness } from "../utils/integration"; + +describe.sequential("/commissions/bulk - bulk updates", async () => { + const h = new IntegrationHarness(); + const { http } = await h.init(); + + const getCommissionsByStatus = async (status: string) => { + const { status: responseStatus, data } = await http.get({ + path: "/commissions", + query: { + status, + pageSize: "100", + sortBy: "createdAt", + sortOrder: "desc", + }, + }); + + expect(responseStatus).toEqual(200); + return data; + }; + + test("PATCH /commissions/bulk - validation error for duplicate commission IDs", async () => { + const pendingCommissions = await getCommissionsByStatus("pending"); + expect(pendingCommissions.length).toBeGreaterThan(0); + + const duplicateId = pendingCommissions[0].id; + + const { status, data } = await http.patch({ + path: "/commissions/bulk", + body: { + commissionIds: [duplicateId, duplicateId], + status: "canceled", + }, + }); + + expect(status).toEqual(422); + expect(data.error.message).toContain("commissionIds must be unique"); + }); + + test("PATCH /commissions/bulk - returns not_found for invalid commission IDs", async () => { + const { status, data } = await http.patch({ + path: "/commissions/bulk", + body: { + commissionIds: ["cm_invalid_commission_id_12345"], + status: "canceled", + }, + }); + + expect(status).toEqual(404); + expect(data.error.message).toContain("One or more commissions were not found"); + }); + + test("PATCH /commissions/bulk - returns bad_request for paid commissions", async () => { + const paidCommissions = await getCommissionsByStatus("paid"); + expect(paidCommissions.length).toBeGreaterThan(0); + + const { status, data } = await http.patch({ + path: "/commissions/bulk", + body: { + commissionIds: [paidCommissions[0].id], + status: "canceled", + }, + }); + + expect(status).toEqual(400); + expect(data.error.message).toContain("have already been paid"); + }); + + test("PATCH /commissions/bulk - returns bad_request when already in target status", async () => { + const pendingCommissions = await getCommissionsByStatus("pending"); + expect(pendingCommissions.length).toBeGreaterThan(0); + + const { status, data } = await http.patch({ + path: "/commissions/bulk", + body: { + commissionIds: [pendingCommissions[0].id], + status: "pending", + }, + }); + + expect(status).toEqual(400); + expect(data.error.message).toContain("already in the pending status"); + }); + + test("PATCH /commissions/bulk - updates multiple commissions", async () => { + const pendingCommissions = await getCommissionsByStatus("pending"); + expect(pendingCommissions.length).toBeGreaterThanOrEqual(2); + + const commissionIds = pendingCommissions.slice(0, 2).map((c) => c.id); + + const { status, data } = await http.patch>({ + path: "/commissions/bulk", + body: { + commissionIds, + status: "duplicate", + }, + }); + + expect(status).toEqual(200); + expect(Array.isArray(data)).toBe(true); + expect(data).toHaveLength(2); + expect(data.map((c) => c.id).sort()).toEqual([...commissionIds].sort()); + expect(data.every((c) => c.status === "duplicate")).toBe(true); + }); +}); diff --git a/apps/web/tests/commissions/index.test.ts b/apps/web/tests/commissions/index.test.ts index 69680ee3468..8ac750e6da4 100644 --- a/apps/web/tests/commissions/index.test.ts +++ b/apps/web/tests/commissions/index.test.ts @@ -81,9 +81,9 @@ describe.sequential("/commissions/**", async () => { }); }); - test("PATCH /commissions/{id} - modify amount", async () => { + test("PATCH /commissions/{id} - modify saleAmount", async () => { const toUpdate = { - modifyAmount: 1000, // Add $10.00 to existing amount + modifySaleAmount: 1000, // Add $10.00 to existing amount currency: "usd", }; @@ -98,7 +98,7 @@ describe.sequential("/commissions/**", async () => { test("PATCH /commissions/{id} - foreign currency conversion", async () => { const toUpdate = { - amount: 1580, // approximately 1000 USD cents + saleAmount: 1580, // approximately 1000 USD cents currency: "jpy", }; @@ -115,7 +115,7 @@ describe.sequential("/commissions/**", async () => { test("PATCH /commissions/{id} - error on lead commission", async () => { const toUpdate = { - amount: 5000, + saleAmount: 5000, }; const response = await http.patch({ @@ -129,7 +129,7 @@ describe.sequential("/commissions/**", async () => { test("PATCH /commissions/{id} - error on paid commission", async () => { const toUpdate = { - amount: 5000, + saleAmount: 5000, }; const response = await http.patch({ diff --git a/apps/web/ui/activity-logs/activity-feed.tsx b/apps/web/ui/activity-logs/activity-feed.tsx index 56412f846e3..e74185d4701 100644 --- a/apps/web/ui/activity-logs/activity-feed.tsx +++ b/apps/web/ui/activity-logs/activity-feed.tsx @@ -5,7 +5,7 @@ import { RewardActivityItem } from "@/ui/activity-logs/reward-activity-item"; import { ComponentType } from "react"; const ACTIVITY_ITEM_MAP: Record< - ActivityLogResourceType, + Exclude, ComponentType<{ log: ActivityLog; isLast?: boolean }> > = { partner: PartnerGroupActivityItem, diff --git a/apps/web/ui/activity-logs/commission-activity.tsx b/apps/web/ui/activity-logs/commission-activity.tsx new file mode 100644 index 00000000000..490405f1c8d --- /dev/null +++ b/apps/web/ui/activity-logs/commission-activity.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useActivityLogs } from "@/lib/swr/use-activity-logs"; +import { + ActivityLog, + CommissionActivitySnapshot, + CommissionDetail, +} from "@/lib/types"; +import { ActivityEvent } from "@/ui/partners/activity-event"; +import { CommissionStatusBadges } from "@/ui/partners/commission-status-badges"; +import { CommentCardDisplay } from "@/ui/partners/partner-comments"; +import { UserAvatar } from "@/ui/users/user-avatar"; +import { InvoiceDollar, StatusBadge } from "@dub/ui"; +import { currencyFormatter } from "@dub/utils"; +import Link from "next/link"; + +type CommissionChangeSet = Record< + string, + { + old: CommissionActivitySnapshot | null; + new: CommissionActivitySnapshot; + } +>; + +function parseChangeSet(log: ActivityLog) { + const changeSet = log.changeSet as CommissionChangeSet | null; + const old = changeSet?.commission?.old ?? null; + const cur = changeSet?.commission?.new ?? null; + + if (!cur) return null; + + const statusChanged = + old?.status !== cur.status && + typeof cur.status === "string" && + cur.status in CommissionStatusBadges; + + const amountChanged = + !statusChanged && + (old?.amount !== cur.amount || old?.earnings !== cur.earnings); + + return { + old, + cur, + statusChanged, + amountChanged, + newStatus: statusChanged + ? (cur.status as keyof typeof CommissionStatusBadges) + : null, + }; +} + +export function CommissionActivity({ + commission, + slug, +}: { + commission: CommissionDetail; + slug: string; +}) { + const { activityLogs, loading } = useActivityLogs({ + enabled: !!commission.id, + query: { + resourceType: "commission", + resourceId: commission.id, + }, + }); + + const createdEvent = { + key: "created", + icon: CommissionStatusBadges["pending"].icon, + timestamp: commission.createdAt, + note: (() => { + const text = commission.reward + ? `Earn ${ + commission.reward.type === "percentage" + ? `${commission.reward.amountInPercentage ?? 0}%` + : currencyFormatter(commission.reward.amountInCents ?? 0, { + trailingZeroDisplay: "stripIfInteger", + }) + } per ${commission.reward.event}` + : commission.description ?? null; + + if (!text) return undefined; + + return ( + + ); + })(), + + children: ( + <> + Commission + + {CommissionStatusBadges["pending"].label} + + + ), + }; + + if (loading) { + return ( +
+

+ Activity +

+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ); + } + + const fmt = (v: number) => + currencyFormatter(v, { trailingZeroDisplay: "stripIfInteger" }); + + const logEvents = (activityLogs ?? []) + .map((log) => { + const parsed = parseChangeSet(log); + if (!parsed) return null; + + const { old, cur, statusChanged, amountChanged, newStatus } = parsed; + + const note = log.description ? ( + + ) : undefined; + + const userByline = log.user ? ( + <> + by +
+ + + {log.user.name} + +
+ + ) : null; + + if (statusChanged && newStatus) { + const badge = CommissionStatusBadges[newStatus]; + + return { + key: log.id, + icon: badge.icon, + timestamp: log.createdAt, + note, + children: ( + <> + + Status updated to + + + {badge.label} + + {newStatus === "processed" && commission.holdingPeriodDays ? ( + + after {commission.holdingPeriodDays}-day{" "} + + holding period + + + ) : null} + {userByline} + {newStatus === "paid" && commission.payout?.id ? ( + + + + {commission.payout.id} + + + ) : null} + + ), + }; + } + + if (amountChanged) { + const parts: React.ReactNode[] = []; + + if (old?.amount !== cur.amount) { + parts.push( + + Amount changed to + , + + {fmt(old?.amount ?? 0)} → {fmt(cur.amount)} + , + ); + } + + if (old?.earnings !== cur.earnings) { + parts.push( + + Earnings updated + , + + {fmt(old?.earnings ?? 0)} → {fmt(cur.earnings)} + , + ); + } + + return { + key: log.id, + icon: InvoiceDollar, + timestamp: log.createdAt, + note, + children: ( + <> + {parts} + {userByline} + + ), + }; + } + + return null; + }) + .filter((event): event is NonNullable => event !== null); + + const allEvents = [...logEvents, createdEvent]; + + return ( +
+

Activity

+
+ {allEvents.map((event, index) => ( + + {event.children} + + ))} +
+
+ ); +} diff --git a/apps/web/ui/partners/bulk-edit-commissions-modal.tsx b/apps/web/ui/partners/bulk-edit-commissions-modal.tsx new file mode 100644 index 00000000000..132329609f4 --- /dev/null +++ b/apps/web/ui/partners/bulk-edit-commissions-modal.tsx @@ -0,0 +1,200 @@ +import { mutatePrefix } from "@/lib/swr/mutate"; +import { useApiMutation } from "@/lib/swr/use-api-mutation"; +import { CommissionResponse } from "@/lib/types"; +import { commissionPatchStatusSchema } from "@/lib/zod/schemas/commissions"; +import { Button, Modal } from "@dub/ui"; +import { InvoiceDollar } from "@dub/ui/icons"; +import { pluralize } from "@dub/utils"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { CommissionStatusBadges } from "./commission-status-badges"; +import { PartnerAvatar } from "./partner-avatar"; + +type FormData = { + status: string; +}; + +interface BulkEditCommissionsModalProps { + showModal: boolean; + setShowModal: (show: boolean) => void; + commissions: CommissionResponse[]; +} + +function BulkEditCommissionsModal({ + showModal, + setShowModal, + commissions, +}: BulkEditCommissionsModalProps) { + const { makeRequest, isSubmitting } = useApiMutation(); + + // Check if all commissions are from the same partner + const singlePartner = useMemo(() => { + if (commissions.length === 0) return null; + + const firstPartnerId = commissions[0].partner.id; + const allSamePartner = commissions.every( + (c) => c.partner.id === firstPartnerId, + ); + + return allSamePartner ? commissions[0].partner : null; + }, [commissions]); + + const { + control, + handleSubmit, + reset, + formState: { isDirty }, + } = useForm({ + defaultValues: { + status: commissions[0]?.status ?? "pending", + }, + }); + + useEffect(() => { + if (showModal) { + reset({ + status: commissions[0]?.status ?? "pending", + }); + } + }, [showModal, commissions, reset]); + + const onSubmit = async (data: FormData) => { + await makeRequest("/api/commissions/bulk", { + method: "PATCH", + body: { + commissionIds: commissions.map((c) => c.id), + status: data.status, + }, + onSuccess: async () => { + setShowModal(false); + await mutatePrefix(["/api/commissions", "/api/payouts"]); + toast.success( + `${commissions.length} ${pluralize("commission", commissions.length)} updated successfully!`, + ); + }, + }); + }; + + const statusOptions = commissionPatchStatusSchema.options; + + return ( + +
+

Edit commissions

+
+ +
+
{ + e.stopPropagation(); + return handleSubmit(onSubmit)(e); + }} + > +
+
+
+ {singlePartner && ( +
+ +

+ {singlePartner.name} +

+
+ )} +
+
+ +
+ + {commissions.length}{" "} + {pluralize("commission", commissions.length)} selected + +
+
+
+ +
+ + ( + + )} + /> +
+
+ +
+
+
+
+ +
+
+ ); +} + +export function useBulkEditCommissionsModal() { + const [commissions, setCommissions] = useState([]); + const [showModal, setShowModal] = useState(false); + + const openBulkEditCommissionsModal = useCallback( + (commissions: CommissionResponse[]) => { + setCommissions(commissions); + setShowModal(true); + }, + [], + ); + + const BulkEditCommissionsModalCallback = useCallback(() => { + if (!showModal || commissions.length === 0) return null; + + return ( + + ); + }, [showModal, commissions]); + + return useMemo( + () => ({ + openBulkEditCommissionsModal, + BulkEditCommissionsModal: BulkEditCommissionsModalCallback, + }), + [openBulkEditCommissionsModal, BulkEditCommissionsModalCallback], + ); +} diff --git a/apps/web/ui/partners/commission-row-menu.tsx b/apps/web/ui/partners/commission-row-menu.tsx index 5fa16c0923e..ad877f56a32 100644 --- a/apps/web/ui/partners/commission-row-menu.tsx +++ b/apps/web/ui/partners/commission-row-menu.tsx @@ -1,45 +1,22 @@ import { CommissionResponse } from "@/lib/types"; import { Button, Icon, Popover, useCopyToClipboard } from "@dub/ui"; -import { - CircleCheck, - CircleXmark, - Dots, - Duplicate, - InvoiceDollar, - ShieldAlert, -} from "@dub/ui/icons"; +import { CircleCheck, Dots, InvoiceDollar, Pen2 } from "@dub/ui/icons"; import { cn } from "@dub/utils"; import { Row } from "@tanstack/react-table"; import { Command } from "cmdk"; import { useState } from "react"; import { toast } from "sonner"; -import { useMarkCommissionDuplicateModal } from "./mark-commission-duplicate-modal"; -import { useMarkCommissionFraudOrCanceledModal } from "./mark-commission-fraud-or-canceled-modal"; +import { useEditCommissionModal } from "./edit-commission-modal"; export function CommissionRowMenu({ row }: { row: Row }) { const [isOpen, setIsOpen] = useState(false); - const { - setShowModal: setShowMarkCommissionDuplicateModal, - MarkCommissionDuplicateModal, - } = useMarkCommissionDuplicateModal({ - commission: row.original, - }); - - const [commissionStatus, setCommissionStatus] = useState< - "fraud" | "canceled" - >("fraud"); - - const { setShowModal, MarkCommissionFraudOrCanceledModal } = - useMarkCommissionFraudOrCanceledModal({ - commission: row.original, - status: commissionStatus, - }); + const { openEditCommissionModal, EditCommissionModal } = + useEditCommissionModal(); const [copiedInvoiceId, copyInvoiceIdToClipboard] = useCopyToClipboard(); - const showUpdateActions = - row.original.status === "pending" || row.original.status === "processed"; + const showUpdateActions = row.original.status !== "paid"; if (!showUpdateActions && !row.original.invoiceId) { return null; @@ -47,8 +24,7 @@ export function CommissionRowMenu({ row }: { row: Row }) { return ( <> - - + }) { {showUpdateActions && ( { - setShowMarkCommissionDuplicateModal(true); - setIsOpen(false); - }} - /> - - { - setCommissionStatus("fraud"); - setShowModal(true); - setIsOpen(false); - }} - /> - - { - setCommissionStatus("canceled"); - setShowModal(true); + openEditCommissionModal(row.original); setIsOpen(false); }} /> diff --git a/apps/web/ui/partners/edit-commission-modal.tsx b/apps/web/ui/partners/edit-commission-modal.tsx new file mode 100644 index 00000000000..e3b898dfd90 --- /dev/null +++ b/apps/web/ui/partners/edit-commission-modal.tsx @@ -0,0 +1,333 @@ +import { handleMoneyInputChange, handleMoneyKeyDown } from "@/lib/form-utils"; +import { mutatePrefix } from "@/lib/swr/mutate"; +import { useApiMutation } from "@/lib/swr/use-api-mutation"; +import { CommissionResponse } from "@/lib/types"; +import { commissionPatchStatusSchema } from "@/lib/zod/schemas/commissions"; +import { Button, Combobox, Modal, StatusBadge, Switch } from "@dub/ui"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import * as z from "zod/v4"; +import { CommissionStatusBadges } from "./commission-status-badges"; +import { PartnerAvatar } from "./partner-avatar"; + +type FormData = { + earnings: number | null; + status: z.infer; + updateHistoricalCommissions: boolean; +}; + +interface EditCommissionModalProps { + showModal: boolean; + setShowModal: (show: boolean) => void; + commission: CommissionResponse; +} + +function EditCommissionModal({ + showModal, + setShowModal, + commission, +}: EditCommissionModalProps) { + const { makeRequest, isSubmitting } = useApiMutation(); + + const isCustom = commission.type === "custom"; + + const { + control, + handleSubmit, + reset, + watch, + formState: { isDirty }, + } = useForm({ + defaultValues: { + earnings: isCustom ? commission.earnings / 100 : null, + status: commission.status as FormData["status"], + updateHistoricalCommissions: false, + }, + }); + + const selectedStatus = watch("status"); + const showUpdateHistoricalCommissionsCheckbox = + (commission.type === "sale" || commission.type === "lead") && + (selectedStatus === "fraud" || selectedStatus === "canceled") && + selectedStatus !== commission.status; + + useEffect(() => { + if (showModal) { + reset({ + earnings: isCustom ? commission.earnings / 100 : null, + status: commission.status as FormData["status"], + updateHistoricalCommissions: false, + }); + } + }, [showModal, commission, reset, isCustom]); + + const onSubmit = async (data: FormData) => { + const body: Record = {}; + + if (data.status !== commission.status) { + body.status = data.status; + } + + if ( + showUpdateHistoricalCommissionsCheckbox && + data.updateHistoricalCommissions + ) { + body.updateHistoricalCommissions = true; + } + + if (isCustom && data.earnings !== null) { + const earningsInCents = Math.round(data.earnings * 100); + if (earningsInCents !== commission.earnings) { + body.earnings = earningsInCents; + } + } + + if (Object.keys(body).length === 0) { + setShowModal(false); + return; + } + + await makeRequest(`/api/commissions/${commission.id}`, { + method: "PATCH", + body, + onSuccess: async () => { + setShowModal(false); + await mutatePrefix(["/api/commissions", "/api/payouts"]); + toast.success("Commission updated successfully!"); + }, + }); + }; + + const statusOptions = commissionPatchStatusSchema.options; + const statusComboboxOptions = useMemo( + () => + statusOptions.map((status) => { + const badge = CommissionStatusBadges[status]; + const StatusIcon = badge?.icon; + const statusTextClass = badge?.className + ?.split(" ") + .find((className) => className.startsWith("text-")); + + return { + value: status, + label: badge?.label ?? status, + variant: badge?.variant ?? "neutral", + icon: StatusIcon ? ( + + ) : undefined, + }; + }), + [statusOptions, commission.status], + ); + + const selectedStatusOption = useMemo( + () => + statusComboboxOptions.find((option) => option.value === selectedStatus), + [statusComboboxOptions, selectedStatus], + ); + + return ( + +
+

Edit commission

+
+ +
+
{ + e.stopPropagation(); + return handleSubmit(onSubmit)(e); + }} + > +
+
+
+ +

+ {commission.partner.name} +

+
+
+ + {isCustom && ( +
+ +
+ + $ + + ( + { + handleMoneyInputChange(e); + const val = e.target.value; + field.onChange(val === "" ? null : parseFloat(val)); + }} + onKeyDown={handleMoneyKeyDown} + placeholder="0.00" + /> + )} + /> +
+
+ )} + +
+ + ( +
+ { + if (!option) return; + field.onChange(option.value); + }} + placeholder="Select status" + searchPlaceholder="Search status..." + matchTriggerWidth + buttonProps={{ + className: + "w-full justify-start border-neutral-300 px-3 data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500 focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none", + }} + > + {selectedStatusOption ? ( + + {selectedStatusOption.icon} + {selectedStatusOption.label} + + ) : ( + "Select status" + )} + +
+ )} + /> +
+ + {showUpdateHistoricalCommissionsCheckbox && ( + ( +
+
+
+ field.onChange(checked)} + checked={field.value} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> +
+
+

+ Mark all previous commissions for this + customer-partner pair as{" "} + + {selectedStatusOption?.label} + +

+

+ This will also prevent this partner from receiving + commissions from this customer in the future. +

+
+
+
+ )} + /> + )} +
+ +
+
+
+
+ +
+
+ ); +} + +export function useEditCommissionModal() { + const [commission, setCommission] = useState(null); + + const openEditCommissionModal = useCallback( + (commission: CommissionResponse) => { + setCommission(commission); + }, + [], + ); + + const closeEditCommissionModal = useCallback(() => { + setCommission(null); + }, []); + + const EditCommissionModalCallback = useCallback(() => { + if (!commission) return null; + + return ( + { + if (!show) closeEditCommissionModal(); + }} + /> + ); + }, [commission, closeEditCommissionModal]); + + return useMemo( + () => ({ + openEditCommissionModal, + closeEditCommissionModal, + EditCommissionModal: EditCommissionModalCallback, + }), + [ + openEditCommissionModal, + closeEditCommissionModal, + EditCommissionModalCallback, + ], + ); +} diff --git a/apps/web/ui/partners/mark-commission-duplicate-modal.tsx b/apps/web/ui/partners/mark-commission-duplicate-modal.tsx deleted file mode 100644 index 9346cdd809b..00000000000 --- a/apps/web/ui/partners/mark-commission-duplicate-modal.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { markCommissionDuplicateAction } from "@/lib/actions/partners/mark-commission-duplicate"; -import { mutatePrefix } from "@/lib/swr/mutate"; -import useWorkspace from "@/lib/swr/use-workspace"; -import { CommissionResponse } from "@/lib/types"; -import { Button, Modal, StatusBadge } from "@dub/ui"; -import { currencyFormatter, nFormatter } from "@dub/utils"; -import { useAction } from "next-safe-action/hooks"; -import React, { useCallback, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { CustomerRowItem } from "../customers/customer-row-item"; -import { CommissionStatusBadges } from "./commission-status-badges"; -import { CommissionTypeBadge } from "./commission-type-badge"; -import { PartnerRowItem } from "./partner-row-item"; - -interface ModalProps { - showModal: boolean; - setShowModal: (show: boolean) => void; - commission: CommissionResponse; -} - -function MarkCommissionDuplicateModal({ - showModal, - setShowModal, - commission, -}: ModalProps) { - return ( - - - - ); -} - -function ModalInner({ - setShowModal, - commission, -}: Omit) { - const { id: workspaceId } = useWorkspace(); - - const { executeAsync, isExecuting, hasSucceeded } = useAction( - markCommissionDuplicateAction, - { - onSuccess: async () => { - toast.success("Successfully marked commission as duplicate."); - await mutatePrefix(["/api/commissions", "/api/payouts"]); - setShowModal(false); - }, - onError: () => { - toast.error("Failed to update commission status."); - }, - }, - ); - - const commissionItem = useMemo(() => { - const badge = CommissionStatusBadges[commission.status]; - - return { - Date: new Date(commission.createdAt).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }), - - ...(commission.customer - ? { - Customer: ( - - ), - } - : {}), - - Partner: ( - - ), - - Type: , - - Amount: - commission.type === "sale" - ? currencyFormatter(commission.amount) - : nFormatter(commission.quantity), - - Commission: currencyFormatter(commission.earnings), - - Status: ( - - {badge.label} - - ), - }; - }, [commission]); - - return ( - <> -
-

- Mark commission as duplicate -

-
- -
-
-
- Warning: This will mark the - commission as duplicate and remove it from any upcoming payouts. - This action cannot be undone. -
-
- -
-
- {Object.entries(commissionItem).map(([key, value]) => ( - -
- {key} -
-
- {value} -
-
- ))} -
-
-
- -
-
- - ); -} - -export function useMarkCommissionDuplicateModal({ - commission, -}: { - commission: CommissionResponse; -}) { - const [showModal, setShowModal] = useState(false); - - const ModalCallback = useCallback(() => { - return ( - - ); - }, [showModal, setShowModal, commission]); - - return useMemo( - () => ({ - setShowModal, - MarkCommissionDuplicateModal: ModalCallback, - }), - [setShowModal, ModalCallback], - ); -} diff --git a/apps/web/ui/partners/mark-commission-fraud-or-canceled-modal.tsx b/apps/web/ui/partners/mark-commission-fraud-or-canceled-modal.tsx deleted file mode 100644 index beb2cdfd73f..00000000000 --- a/apps/web/ui/partners/mark-commission-fraud-or-canceled-modal.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { markCommissionFraudOrCanceledAction } from "@/lib/actions/partners/mark-commission-fraud-or-canceled"; -import { mutatePrefix } from "@/lib/swr/mutate"; -import useWorkspace from "@/lib/swr/use-workspace"; -import { CommissionResponse } from "@/lib/types"; -import { Button, Modal, StatusBadge } from "@dub/ui"; -import { currencyFormatter, nFormatter } from "@dub/utils"; -import { useAction } from "next-safe-action/hooks"; -import React, { useCallback, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { CustomerRowItem } from "../customers/customer-row-item"; -import { CommissionStatusBadges } from "./commission-status-badges"; -import { CommissionTypeBadge } from "./commission-type-badge"; -import { PartnerRowItem } from "./partner-row-item"; - -interface ModalProps { - showModal: boolean; - setShowModal: (show: boolean) => void; - commission: CommissionResponse; - status: "fraud" | "canceled"; -} - -function MarkCommissionFraudOrCanceledModal({ - showModal, - setShowModal, - commission, - status, -}: ModalProps) { - return ( - - - - ); -} - -function ModalInner({ - setShowModal, - commission, - status, -}: Omit) { - const { id: workspaceId } = useWorkspace(); - - const { executeAsync, isExecuting, hasSucceeded } = useAction( - markCommissionFraudOrCanceledAction, - { - onSuccess: async () => { - toast.success(`Commission marked as ${status} successfully!`); - await mutatePrefix(["/api/commissions", "/api/payouts"]); - setShowModal(false); - }, - onError: () => { - toast.error("Failed to update commission status."); - }, - }, - ); - - const commissionItem = useMemo(() => { - const badge = CommissionStatusBadges[commission.status]; - - return { - Date: new Date(commission.createdAt).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }), - - ...(commission.customer - ? { - Customer: ( - - ), - } - : {}), - - Partner: ( - - ), - - Type: , - - Amount: - commission.type === "sale" - ? currencyFormatter(commission.amount) - : nFormatter(commission.quantity), - - Commission: currencyFormatter(commission.earnings), - - Status: ( - - {badge.label} - - ), - }; - }, [commission]); - - return ( - <> -
-

- Mark commission as {status} -

-
- -
-
-
- Warning: This will mark{" "} - {commission.type === "custom" || commission.type === "click" - ? "this commission" - : "all future and past commissions for this customer and partner combination"}{" "} - as {status}. This action cannot be undone – please proceed with - caution. -
-
- -
-
- {Object.entries(commissionItem).map(([key, value]) => ( - -
- {key} -
-
- {value} -
-
- ))} -
-
-
- -
-
- - ); -} - -export function useMarkCommissionFraudOrCanceledModal({ - commission, - status, -}: { - commission: CommissionResponse; - status: "fraud" | "canceled"; -}) { - const [showModal, setShowModal] = useState(false); - - const ModalCallback = useCallback(() => { - return ( - - ); - }, [showModal, setShowModal, commission, status]); - - return useMemo( - () => ({ - setShowModal, - MarkCommissionFraudOrCanceledModal: ModalCallback, - }), - [setShowModal, ModalCallback], - ); -} diff --git a/apps/web/ui/partners/partner-comments.tsx b/apps/web/ui/partners/partner-comments.tsx index 34d89a408f3..1c3e9c82a0b 100644 --- a/apps/web/ui/partners/partner-comments.tsx +++ b/apps/web/ui/partners/partner-comments.tsx @@ -410,7 +410,7 @@ export function CommentCardDisplay({ return (
Date: Tue, 31 Mar 2026 14:10:23 -0700 Subject: [PATCH 2/6] update BOUNTY_MAX_SUBMISSIONS to 50 --- apps/web/lib/bounty/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/bounty/constants.ts b/apps/web/lib/bounty/constants.ts index 2bc3609ec39..12515304da1 100644 --- a/apps/web/lib/bounty/constants.ts +++ b/apps/web/lib/bounty/constants.ts @@ -2,7 +2,7 @@ import { BountySubmissionFrequency } from "@dub/prisma/client"; export const BOUNTY_DESCRIPTION_MAX_LENGTH = 5000; -export const BOUNTY_MAX_SUBMISSIONS = 10; +export const BOUNTY_MAX_SUBMISSIONS = 50; export const BOUNTY_MAX_SUBMISSION_FILES = 4; From 339081201f0e52b88bf89ed9914afa0c946e30b1 Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Tue, 31 Mar 2026 14:17:48 -0700 Subject: [PATCH 3/6] Responsive improvements (#3670) Co-authored-by: Steven Tey --- .../(ee)/app.dub.co/(new-program)/header.tsx | 10 +++++----- .../(ee)/app.dub.co/(new-program)/layout.tsx | 4 ++-- .../(ee)/app.dub.co/(new-program)/steps.tsx | 14 +++++++------- .../[programSlug]/(enrolled)/page-client.tsx | 2 +- apps/web/ui/layout/main-nav.tsx | 18 +++++++++--------- apps/web/ui/layout/page-content/index.tsx | 2 +- apps/web/ui/layout/page-content/nav-button.tsx | 2 +- .../layout/page-content/page-content-old.tsx | 14 +++++++------- .../page-content-with-side-panel.tsx | 2 +- 9 files changed, 34 insertions(+), 34 deletions(-) diff --git a/apps/web/app/(ee)/app.dub.co/(new-program)/header.tsx b/apps/web/app/(ee)/app.dub.co/(new-program)/header.tsx index bb1ab19e6d9..167fcec2a3f 100644 --- a/apps/web/app/(ee)/app.dub.co/(new-program)/header.tsx +++ b/apps/web/app/(ee)/app.dub.co/(new-program)/header.tsx @@ -15,14 +15,14 @@ import { useSidebar } from "./sidebar-context"; export function ProgramOnboardingHeader() { const pathname = usePathname(); - const { isMobile } = useMediaQuery(); + const { isDesktop } = useMediaQuery(); const { getValues } = useFormContext(); const { isOpen, setIsOpen } = useSidebar(); const { id: workspaceId, slug: workspaceSlug } = useWorkspace(); useEffect(() => { - document.body.style.overflow = isOpen && isMobile ? "hidden" : "auto"; - }, [isOpen, isMobile]); + document.body.style.overflow = isOpen && !isDesktop ? "hidden" : "auto"; + }, [isOpen, isDesktop]); const { executeAsync, isPending } = useAction(onboardProgramAction, { onError: ({ error }) => { @@ -62,14 +62,14 @@ export function ProgramOnboardingHeader() {
-

+

Create partner program

diff --git a/apps/web/app/(ee)/app.dub.co/(new-program)/layout.tsx b/apps/web/app/(ee)/app.dub.co/(new-program)/layout.tsx index 290c46ef555..bbdb5267fdd 100644 --- a/apps/web/app/(ee)/app.dub.co/(new-program)/layout.tsx +++ b/apps/web/app/(ee)/app.dub.co/(new-program)/layout.tsx @@ -11,9 +11,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
-
+
-
{children}
+
{children}
diff --git a/apps/web/app/(ee)/app.dub.co/(new-program)/steps.tsx b/apps/web/app/(ee)/app.dub.co/(new-program)/steps.tsx index 131c648f98d..1e610e11407 100644 --- a/apps/web/app/(ee)/app.dub.co/(new-program)/steps.tsx +++ b/apps/web/app/(ee)/app.dub.co/(new-program)/steps.tsx @@ -13,15 +13,15 @@ import { useSidebar } from "./sidebar-context"; export function ProgramOnboardingSteps() { const pathname = usePathname(); - const { isMobile } = useMediaQuery(); + const { isDesktop } = useMediaQuery(); const { isOpen, setIsOpen } = useSidebar(); const { slug } = useParams<{ slug: string }>(); const [programOnboarding] = useWorkspaceStore("programOnboarding"); useEffect(() => { - document.body.style.overflow = isOpen && isMobile ? "hidden" : "auto"; - }, [isOpen, isMobile]); + document.body.style.overflow = isOpen && !isDesktop ? "hidden" : "auto"; + }, [isOpen, isDesktop]); const currentPath = pathname.replace(`/${slug}`, ""); @@ -40,10 +40,10 @@ export function ProgramOnboardingSteps() { <>
{ if (e.target === e.currentTarget) { @@ -54,12 +54,12 @@ export function ProgramOnboardingSteps() { >
-
+

Program Setup