Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
3bbfb45
Add workflows/create-partner-commission
steven-tey May 29, 2026
73c2c89
Merge branch 'main' into create-partner-commission-workflow
devkiran May 29, 2026
b43272a
Improve QStash workflow triggering with retries, labels, and Axiom lo…
devkiran May 29, 2026
ce77e40
Move partner commission creation into create-partner-commission workf…
devkiran May 29, 2026
9af6848
Queue partner commissions via QStash from createPartnerCommission.
devkiran May 29, 2026
2e9eef2
Rename createPartnerCommission to queuePartnerCommissionCreation
devkiran May 29, 2026
9d30c46
Improve partner commission workflow types, flow control, and schemas
devkiran May 29, 2026
f0f08c3
Link bounty submissions to commissions in partner commission workflow
devkiran May 29, 2026
4baa357
Update route.ts
devkiran May 29, 2026
8b5576e
Move fraud detection to partner commission workflow side effects
devkiran May 29, 2026
d7b7f1d
Pass isFirstConversion through partner commission workflow
devkiran May 29, 2026
7974bfd
Update qstash-workflow.ts
devkiran May 29, 2026
e49d717
Run fraud detection before commission side effects and queue bounty c…
devkiran May 29, 2026
4f33974
Update route.ts
devkiran May 29, 2026
201962b
Merge branch 'main' into create-partner-commission-workflow
steven-tey May 29, 2026
90e7ced
rename queuePartnerCommissionCreation, fix tests
steven-tey May 29, 2026
0b26c64
Merge branch 'main' into create-partner-commission-workflow
steven-tey May 31, 2026
5f844ba
increase timeout
steven-tey May 31, 2026
3ac23e6
Merge branch 'main' into create-partner-commission-workflow
steven-tey May 31, 2026
56c582c
Update verify-fraud-event.ts
devkiran May 31, 2026
0f6236f
finalize workflow
steven-tey May 31, 2026
ad443a1
fix isFirstConversion flag
steven-tey May 31, 2026
ceca508
address coderabbit feedback
steven-tey May 31, 2026
9183e71
Add POST /api/commissions for manual commission creation
devkiran Jun 1, 2026
262adcf
Trigger partner metrics workflow on manual commission creation
devkiran Jun 1, 2026
4f33879
Update create-manual-commissions.ts
devkiran Jun 1, 2026
564dde4
Merge branch 'main' into create-partner-commission-workflow
steven-tey Jun 1, 2026
40d98b7
Update attribute-via-promotion-code-id.ts
steven-tey Jun 1, 2026
df30650
Merge branch 'main' into create-partner-commission-workflow
steven-tey Jun 1, 2026
1cab5fa
increase test timeout
steven-tey Jun 1, 2026
f732b16
Merge branch 'create-partner-commission-workflow' into create-commiss…
devkiran Jun 2, 2026
02d3002
Update create-commission.test.ts
devkiran Jun 2, 2026
41b0dd9
Update create-commission.test.ts
devkiran Jun 2, 2026
3d97408
Update create-manual-commissions.ts
devkiran Jun 2, 2026
3ace87c
Merge branch 'main' into create-commissions-api-v1
devkiran Jun 2, 2026
ed47a2f
Fix the conflicts
devkiran Jun 2, 2026
8f07935
add createCommission
devkiran Jun 2, 2026
5791924
Trigger due commission aggregation for manual commissions
devkiran Jun 2, 2026
e0cd8f4
use args
devkiran Jun 2, 2026
79fc0f5
Trigger due commission aggregation once per batch
devkiran Jun 2, 2026
e146b6b
Merge branch 'fix/invoice-paid-promo-error-key' into create-commissio…
devkiran Jun 2, 2026
232c0e4
Fix manual commission schema sale refinement
devkiran Jun 2, 2026
4fba0a3
Merge branch 'main' into create-commissions-api-v1
devkiran Jun 2, 2026
5c5f4a5
Update create-commission.test.ts
devkiran Jun 2, 2026
adc82d8
Merge branch 'create-commissions-api-v1' of https://github.com/dubinc…
devkiran Jun 2, 2026
e187e73
Update create-commission.test.ts
devkiran Jun 2, 2026
66f19c2
Update create-commission.test.ts
devkiran Jun 2, 2026
95ae609
Merge branch 'main' into create-commissions-api-v1
steven-tey Jun 3, 2026
53e78f4
increase limit to 60
steven-tey Jun 3, 2026
e4e6184
Add timing for manual commission creation and enhance commission schema
devkiran Jun 3, 2026
8695a7d
Merge branch 'main' into create-commissions-api-v1
pepeladeira Jun 3, 2026
a996010
Merge branch 'main' into create-commissions-api-v1
steven-tey Jun 4, 2026
43581e9
VITEST_POLL_INTERVAL_MS, VITEST_TEST_TIMEOUT_MS
steven-tey Jun 4, 2026
1f70f15
improve index
steven-tey Jun 4, 2026
879f9c6
Merge branch 'main' into create-commissions-api-v1
steven-tey Jun 4, 2026
e37585f
Merge branch 'main' into create-commissions-api-v1
steven-tey Jun 4, 2026
c2c59b5
fix tests
steven-tey Jun 4, 2026
0711b46
Merge branch 'main' into create-commissions-api-v1
steven-tey Jun 4, 2026
bd805c5
Merge branch 'main' into create-commissions-api-v1
steven-tey Jun 4, 2026
80d9e43
Merge branch 'main' into create-commissions-api-v1
steven-tey Jun 5, 2026
2fb4d3c
Merge branch 'main' into create-commissions-api-v1
steven-tey Jun 5, 2026
de26f78
improve OpenAPI spec
steven-tey Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/web/app/(ee)/api/commissions/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { createManualCommissions } from "@/lib/api/commissions/create-manual-commissions";
import { getCommissions } from "@/lib/api/commissions/get-commissions";
import { transformCustomerForCommission } from "@/lib/api/customers/transform-customer";
import { DubApiError } from "@/lib/api/errors";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import {
CommissionEnrichedSchema,
createCommissionResponseSchema,
createManualCommissionBodySchema,
getCommissionsQuerySchema,
} from "@/lib/zod/schemas/commissions";
import { prisma } from "@dub/prisma";
Expand Down Expand Up @@ -75,3 +79,46 @@ export const GET = withWorkspace(async ({ workspace, searchParams }) => {
),
);
});

// POST /api/commissions - create manual commission
export const POST = withWorkspace(
async ({ workspace, session, req }) => {
const programId = getDefaultProgramIdOrThrow(workspace);

const body = createManualCommissionBodySchema.parse(
await parseRequestBody(req),
);

console.time("createManualCommissions");

await createManualCommissions({
...body,
workspace,
programId,
user: session.user,
});

console.timeEnd("createManualCommissions");

return NextResponse.json(
createCommissionResponseSchema.parse({
success: true,
message: "Your commissions are being created and will appear shortly.",
}),
{
status: 202,
},
);
},
{
requiredPlan: [
"business",
"business plus",
"business extra",
"business max",
"advanced",
"enterprise",
],
requiredRoles: ["owner", "member"],
},
);
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { triggerAggregateDueCommissionsCronJob } from "@/lib/actions/partners/trigger-aggregate-due-commissions";
import { createId } from "@/lib/api/create-id";
import { detectAndRecordFraudEvent } from "@/lib/api/fraud/detect-record-fraud-event";
import { notifyPartnerCommission } from "@/lib/api/partners/notify-partner-commission";
Expand Down Expand Up @@ -432,6 +433,8 @@ async function stepRunSideEffects(
skipWorkflow,
clickEvent,
isFirstConversion,
userId,
triggerAggregateDueCommissions,
} = input;

if (!_commission) {
Expand Down Expand Up @@ -563,6 +566,10 @@ async function stepRunSideEffects(
click: pick(clickEvent, ["url", "referer"]),
event: { id: eventId },
}),

// Aggregate due commissions immediately for manual commission
triggerAggregateDueCommissions &&
triggerAggregateDueCommissionsCronJob(programId),
]);

return [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { createManualCommissionAction } from "@/lib/actions/partners/create-manual-commission";
import { handleMoneyKeyDown } from "@/lib/form-utils";
import { mutatePrefix } from "@/lib/swr/mutate";
import { useApiMutation } from "@/lib/swr/use-api-mutation";
import useRewards from "@/lib/swr/use-rewards";
import useWorkspace from "@/lib/swr/use-workspace";
import { createCommissionSchema } from "@/lib/zod/schemas/commissions";
import {
createCommissionResponseSchema,
createManualCommissionBodySchema,
} from "@/lib/zod/schemas/commissions";
import { StripeCustomerInvoiceSchema } from "@/lib/zod/schemas/customers";
import { CustomerSelector } from "@/ui/customers/customer-selector";
import { PartnerLinkSelector } from "@/ui/partners/partner-link-selector";
Expand All @@ -27,7 +30,6 @@ import {
ToggleGroup,
} from "@dub/ui";
import { cn, currencyFormatter, formatDate } from "@dub/utils";
import { useAction } from "next-safe-action/hooks";
import { useParams } from "next/navigation";
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
Expand All @@ -39,7 +41,27 @@ interface CreateCommissionSheetProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
}

type FormData = z.infer<typeof createCommissionSchema>;
// Flatten the discriminated union for react-hook-form compatibility.
// The form renders all possible fields; onSubmit builds the correct variant.
type CreateCommissionBody = z.infer<typeof createManualCommissionBodySchema>;
type CustomBody = Extract<CreateCommissionBody, { type: "custom" }>;
type LeadBody = Extract<CreateCommissionBody, { type: "lead" }>;
type SaleBody = Extract<CreateCommissionBody, { type: "sale" }>;

type FormData = {
partnerId: CustomBody["partnerId"];
amount: CustomBody["amount"] | null;
date: CustomBody["date"];
description: CustomBody["description"];
customerId: LeadBody["customerId"];
linkId: LeadBody["linkId"];
leadEventDate: LeadBody["leadEventDate"];
leadEventName: LeadBody["leadEventName"];
saleAmount: SaleBody["saleAmount"];
saleEventDate: SaleBody["saleEventDate"];
invoiceId: SaleBody["invoiceId"];
productId: SaleBody["productId"];
};

type StripeInvoiceFromApi = z.infer<typeof StripeCustomerInvoiceSchema>;

Expand Down Expand Up @@ -74,7 +96,7 @@ function CreateCommissionSheetContent({

const [hasCustomLeadEventDate, setHasCustomLeadEventDate] = useState(false);
const [hasCustomLeadEventName, setHasCustomLeadEventName] = useState(false);
const [useExistingEvents, setUseExistingEvents] = useState(false);
const [importStripeInvoices, setImportStripeInvoices] = useState(false);

const [commissionType, setCommissionType] =
useState<CommissionType>("custom");
Expand Down Expand Up @@ -111,7 +133,6 @@ function CreateCommissionSheetContent({
saleEventDate,
saleAmount,
leadEventDate,
description,
] = watch([
"partnerId",
"date",
Expand All @@ -121,7 +142,6 @@ function CreateCommissionSheetContent({
"saleEventDate",
"saleAmount",
"leadEventDate",
"description",
]);

const { rewards } = useRewards();
Expand Down Expand Up @@ -172,7 +192,10 @@ function CreateCommissionSheetContent({
isLoading: isStripeInvoicesLoading,
error: stripeInvoicesError,
} = useSWR(
customerId && useExistingEvents && commissionType === "sale" && workspaceId
customerId &&
importStripeInvoices &&
commissionType === "sale" &&
workspaceId
? `/api/customers/${customerId}/stripe-invoices?workspaceId=${workspaceId}`
: null,
fetcherStripeInvoices,
Expand Down Expand Up @@ -217,44 +240,71 @@ function CreateCommissionSheetContent({
}
}, [commissionType]);

const { executeAsync, isPending } = useAction(createManualCommissionAction, {
onSuccess: async () => {
toast.success("A commission has been created for the partner!");
setIsOpen(false);
await mutatePrefix(`/api/commissions?workspaceId=${workspaceId}`);
},
onError({ error }) {
toast.error(error.serverError);
},
});
const { makeRequest, isSubmitting } =
useApiMutation<z.infer<typeof createCommissionResponseSchema>>();

const onSubmit = async (data: FormData) => {
if (!workspaceId || !defaultProgramId) {
toast.error("Please fill all required fields.");
return;
}

const date = data.date ? new Date(data.date).toISOString() : null;

const saleEventDate = data.saleEventDate
? new Date(data.saleEventDate).toISOString()
: null;
let body: Record<string, unknown>;

const leadEventDate = data.leadEventDate
? new Date(data.leadEventDate).toISOString()
: null;
if (commissionType === "custom") {
body = {
type: "custom",
partnerId,
amount: data.amount ? Math.round(data.amount * 100) : 0,
date: data.date ? data.date.toISOString() : null,
description: data.description || null,
};
} else if (commissionType === "lead") {
body = {
type: "lead",
partnerId,
customerId: data.customerId,
linkId: data.linkId,
leadEventDate: data.leadEventDate
? data.leadEventDate.toISOString()
: null,
leadEventName: data.leadEventName || null,
};
} else {
body = {
type: "sale",
partnerId,
customerId: data.customerId,
linkId: data.linkId,
importStripeInvoices,
...(importStripeInvoices
? {
saleAmount: null,
saleEventDate: null,
invoiceId: null,
productId: null,
}
: {
saleAmount: data.saleAmount
? Math.round(data.saleAmount * 100)
: null,
saleEventDate: data.saleEventDate
? data.saleEventDate.toISOString()
: null,
invoiceId: data.invoiceId || null,
productId: data.productId || null,
}),
};
}

await executeAsync({
...data,
commissionType,
partnerId,
workspaceId,
date,
amount: data.amount ? data.amount * 100 : null,
saleAmount: data.saleAmount ? data.saleAmount * 100 : null,
saleEventDate,
leadEventDate,
useExistingEvents,
await makeRequest("/api/commissions", {
method: "POST",
body,
onSuccess: async ({ message }) => {
toast.success(message);
setIsOpen(false);
await mutatePrefix("/api/commissions");
},
});
};

Expand All @@ -274,7 +324,7 @@ function CreateCommissionSheetContent({
}

if (commissionType === "sale") {
if (useExistingEvents) {
if (importStripeInvoices) {
if (isStripeInvoicesLoading) {
return "Loading Stripe invoices...";
}
Expand All @@ -301,7 +351,7 @@ function CreateCommissionSheetContent({
customerId,
amount, // custom commission amount
saleAmount, // sale commission amount
useExistingEvents,
importStripeInvoices,
noStripeCustomerId,
unimportedStripeInvoices.length,
isStripeInvoicesLoading,
Expand Down Expand Up @@ -591,17 +641,17 @@ function CreateCommissionSheetContent({
optionClassName="h-8 flex items-center justify-center rounded-md flex-1 text-sm normal-case"
indicatorClassName="bg-white"
options={eventSourceOptions}
selected={useExistingEvents ? "existing" : "new"}
selected={importStripeInvoices ? "existing" : "new"}
selectAction={(value: string) =>
setUseExistingEvents(value === "existing")
setImportStripeInvoices(value === "existing")
}
/>
<p className="mt-2 text-xs text-neutral-500">
{
eventSourceOptions.find(
(option) =>
option.value ===
(useExistingEvents ? "existing" : "new"),
(importStripeInvoices ? "existing" : "new"),
)?.description
}
</p>
Expand All @@ -610,7 +660,7 @@ function CreateCommissionSheetContent({

{/* Stripe invoices for sale + use existing */}
{customerId &&
useExistingEvents &&
importStripeInvoices &&
commissionType === "sale" && (
<div className="space-y-3">
{isStripeInvoicesLoading ? (
Expand Down Expand Up @@ -714,7 +764,7 @@ function CreateCommissionSheetContent({
)}

{customerId &&
!useExistingEvents &&
!importStripeInvoices &&
(commissionType === "lead" ? (
<>
<AnimatedSizeContainer
Expand Down Expand Up @@ -1007,15 +1057,15 @@ function CreateCommissionSheetContent({
onClick={() => setIsOpen(false)}
text="Cancel"
className="w-fit"
disabled={isPending}
disabled={isSubmitting}
/>

<Button
type="submit"
variant="primary"
text={`Create commission${unimportedStripeInvoices.length > 0 ? "s" : ""}`}
className="w-fit"
loading={isPending}
loading={isSubmitting}
disabledTooltip={submitDisabledMessage}
/>
</div>
Expand Down
Loading
Loading