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