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/(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