Skip to content

Commit eb219ea

Browse files
authored
Merge pull request #4371 from dubinc/clawback-api
Add clawback support to the `POST /commissions` API
2 parents 3f3dfb8 + 5bc9004 commit eb219ea

13 files changed

Lines changed: 362 additions & 243 deletions

File tree

apps/web/app/(ee)/api/commissions/route.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,18 @@ export const POST = withWorkspace(
9090

9191
console.timeEnd("createManualCommissions");
9292

93-
return NextResponse.json(
94-
createCommissionResponseSchema.parse({
95-
success: true,
96-
message: "Your commissions are being created and will appear shortly.",
97-
}),
98-
{
99-
status: 202,
100-
},
101-
);
93+
const isClawback = body.type === "custom" && body.amount < 0;
94+
95+
const response = createCommissionResponseSchema.parse({
96+
success: true,
97+
message: isClawback
98+
? "A clawback has been queued for the partner!"
99+
: "Your commissions are being created and will appear shortly.",
100+
});
101+
102+
return NextResponse.json(response, {
103+
status: 202,
104+
});
102105
},
103106
{
104107
requiredPlan: ["business", "advanced", "enterprise"],

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx

Lines changed: 37 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
import { createClawbackAction } from "@/lib/actions/partners/create-clawback";
21
import { mutatePrefix } from "@/lib/swr/mutate";
2+
import { useApiMutation } from "@/lib/swr/use-api-mutation";
33
import useWorkspace from "@/lib/swr/use-workspace";
44
import {
55
CLAWBACK_REASONS,
6-
createClawbackSchema,
6+
createCommissionResponseSchema,
77
} from "@/lib/zod/schemas/commissions";
88
import { PartnerSelector } from "@/ui/partners/partner-selector";
99
import { X } from "@/ui/shared/icons";
1010
import { Button, Sheet } from "@dub/ui";
11-
import { useAction } from "next-safe-action/hooks";
1211
import { useParams } from "next/navigation";
1312
import { useState } from "react";
1413
import { Controller, useForm } from "react-hook-form";
@@ -21,7 +20,11 @@ interface CreateClawbackSheetProps {
2120
nested?: boolean;
2221
}
2322

24-
type FormData = z.infer<typeof createClawbackSchema>;
23+
type FormData = {
24+
partnerId?: string;
25+
amount?: number;
26+
reason?: (typeof CLAWBACK_REASONS)[number]["value"];
27+
};
2528

2629
function CreateClawbackSheetContent(
2730
props: Omit<CreateClawbackSheetProps, "nested">,
@@ -37,47 +40,42 @@ function CreateClawbackSheetContent(
3740
reset,
3841
watch,
3942
getValues,
40-
formState: { errors, isSubmitting, isSubmitSuccessful },
43+
formState: { errors, isSubmitting },
4144
} = useForm<FormData>({
4245
defaultValues: {
4346
partnerId: params.partnerId,
44-
description: "",
47+
reason: undefined,
4548
},
4649
});
4750

48-
const [partnerId, amount, description] = watch([
49-
"partnerId",
50-
"amount",
51-
"description",
52-
]);
51+
const [partnerId, amount, reason] = watch(["partnerId", "amount", "reason"]);
5352

54-
const { executeAsync, isPending } = useAction(createClawbackAction, {
55-
onSuccess: () => {
56-
toast.success("A clawback has been created for the partner!");
57-
setIsOpen(false);
58-
mutatePrefix(`/api/commissions?workspaceId=${workspaceId}`);
59-
const currentValues = getValues();
60-
reset(currentValues);
61-
},
62-
onError({ error }) {
63-
toast.error(error.serverError || "Failed to create clawback.");
64-
},
65-
});
53+
const { makeRequest, isSubmitting: isCreating } =
54+
useApiMutation<z.infer<typeof createCommissionResponseSchema>>();
6655

6756
const onSubmit = async (data: FormData) => {
6857
if (!workspaceId || !defaultProgramId) {
6958
return;
7059
}
7160

72-
await executeAsync({
73-
...data,
74-
amount: data.amount * 100,
75-
workspaceId,
61+
await makeRequest("/api/commissions", {
62+
method: "POST",
63+
body: {
64+
type: "custom",
65+
partnerId: data.partnerId,
66+
amount: data.amount ? -Math.round(data.amount * 100) : 0,
67+
description: data.reason,
68+
},
69+
onSuccess: async ({ message }) => {
70+
toast.success(message);
71+
setIsOpen(false);
72+
await mutatePrefix("/api/commissions");
73+
const currentValues = getValues();
74+
reset(currentValues);
75+
},
7676
});
7777
};
7878

79-
const disableSubmitButton = !partnerId || !amount || !description;
80-
8179
return (
8280
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
8381
<div className="sticky top-0 z-10 border-b border-neutral-200 bg-white">
@@ -110,7 +108,7 @@ function CreateClawbackSheetContent(
110108
rules={{ required: true }}
111109
render={({ field }) => (
112110
<PartnerSelector
113-
selectedPartnerId={field.value}
111+
selectedPartnerId={field.value ?? null}
114112
setSelectedPartnerId={field.onChange}
115113
/>
116114
)}
@@ -171,21 +169,21 @@ function CreateClawbackSheetContent(
171169

172170
<div>
173171
<label
174-
htmlFor="description"
172+
htmlFor="reason"
175173
className="text-sm font-medium text-neutral-900"
176174
>
177175
Reason
178176
</label>
179177
<div className="relative mt-2 rounded-md shadow-sm">
180178
<Controller
181-
name="description"
179+
name="reason"
182180
control={control}
183181
rules={{ required: true }}
184182
render={({ field }) => (
185183
<select
186-
id="description"
184+
id="reason"
187185
className="block w-full rounded-md border-neutral-300 pr-10 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
188-
value={field.value}
186+
value={field.value ?? ""}
189187
onChange={field.onChange}
190188
>
191189
<option value="" disabled>
@@ -199,9 +197,9 @@ function CreateClawbackSheetContent(
199197
</select>
200198
)}
201199
/>
202-
{errors.description && (
200+
{errors.reason && (
203201
<span className="text-xs text-red-600">
204-
{errors.description.message}
202+
{errors.reason.message}
205203
</span>
206204
)}
207205
</div>
@@ -216,15 +214,15 @@ function CreateClawbackSheetContent(
216214
onClick={() => setIsOpen(false)}
217215
text="Cancel"
218216
className="w-fit"
219-
disabled={isPending || isSubmitting || isSubmitSuccessful}
217+
disabled={isCreating || isSubmitting}
220218
/>
221219
<Button
222220
type="submit"
223221
variant="primary"
224222
text="Create clawback"
225223
className="w-fit"
226-
loading={isPending || isSubmitting || isSubmitSuccessful}
227-
disabled={disableSubmitButton}
224+
loading={isCreating || isSubmitting}
225+
disabled={!partnerId || !amount || !reason}
228226
/>
229227
</div>
230228
</div>

apps/web/lib/actions/partners/create-clawback.ts

Lines changed: 0 additions & 38 deletions
This file was deleted.

apps/web/lib/api/commissions/create-manual-commissions.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,6 @@ export async function createManualCommissions(args: CreateCommissionsArgs) {
121121
productId,
122122
} = args;
123123

124-
if (!importStripeInvoices && !saleAmount) {
125-
throw new DubApiError({
126-
code: "bad_request",
127-
message: "Either saleAmount or importStripeInvoices must be provided.",
128-
});
129-
}
130-
131124
const hasManualSaleFields =
132125
saleAmount || saleEventDate || invoiceId || productId;
133126

apps/web/lib/openapi/commissions/create-commission.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const createCommission: ZodOpenApiOperationObject = {
1010
"x-speakeasy-name-override": "create",
1111
summary: "Create commission",
1212
description:
13-
"Create one or more commissions (custom, lead or sale) for a partner. Commission creation is processed asynchronously. Use the List Commissions endpoint or webhooks to be notified when the commission is created.",
13+
"Create one or more commissions (custom, lead or sale) for a partner. Custom commissions accept a negative `amount` to create a clawback; in that case `description` is required and may be a known clawback reason or any other string. Commission creation is processed asynchronously. Use the List Commissions endpoint or webhooks to be notified when the commission is created.",
1414
requestBody: {
1515
content: {
1616
"application/json": {

apps/web/lib/zod/schemas/commissions.ts

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -318,15 +318,6 @@ export const CLAWBACK_REASONS_MAP = Object.fromEntries(
318318
CLAWBACK_REASONS.map((r) => [r.value, r]),
319319
);
320320

321-
export const createClawbackSchema = z.object({
322-
workspaceId: z.string(),
323-
partnerId: z.string(),
324-
amount: z.number().gt(0, "Amount must be greater than 0."),
325-
description: z.enum(
326-
CLAWBACK_REASONS.map((r) => r.value) as [string, ...string[]],
327-
),
328-
});
329-
330321
export const COMMISSION_EXPORT_COLUMNS = [
331322
{ id: "id", label: "ID", type: "string", default: true },
332323
{ id: "type", label: "Type", type: "string", default: true },
@@ -461,23 +452,31 @@ export const createPartnerCommissionSchema = z.object({
461452

462453
export const createManualCommissionBodySchema = z
463454
.discriminatedUnion("type", [
464-
// Custom commission
455+
// Custom commission (negative amount = clawback)
465456
z.object({
466457
type: z.literal("custom"),
467458
partnerId: z
468459
.string()
469460
.describe("The ID of the partner to create the commission for."),
470461
amount: centsSchema
471-
.pipe(z.number().min(1))
472-
.describe("The commission amount in cents."),
462+
.pipe(
463+
z.number().refine((n) => n !== 0, {
464+
message: "Amount cannot be 0.",
465+
}),
466+
)
467+
.describe(
468+
"The commission amount in cents. Use a negative amount to create a clawback.",
469+
),
473470
date: parseDateSchema
474471
.nullish()
475472
.describe("If not provided, the current date will be used."),
476473
description: z
477474
.string()
478475
.max(190)
479476
.nullish()
480-
.describe("The description of the commission."),
477+
.describe(
478+
"The description of the commission. Required for clawbacks (negative `amount`). May be a known clawback reason (`order_canceled`, `fraud`, `terms_violation`, `tracking_error`, `payment_failed`, `ineligible_partner`, `duplicate_commission`, `other`) or any other string.",
479+
),
481480
}),
482481

483482
// Lead commission
@@ -573,15 +572,40 @@ export const createManualCommissionBodySchema = z
573572
}),
574573
])
575574
.superRefine((data, ctx) => {
576-
if (data.type !== "sale") return;
575+
if (data.type === "custom") {
576+
if (data.amount < 0 && !data.description?.trim()) {
577+
ctx.addIssue({
578+
code: z.ZodIssueCode.custom,
579+
message:
580+
"`description` is required when creating a clawback (negative amount).",
581+
path: ["description"],
582+
});
583+
}
584+
return;
585+
}
586+
587+
if (data.type === "sale") {
588+
if (data.importStripeInvoices) {
589+
return;
590+
}
591+
592+
if (data.saleAmount == null) {
593+
ctx.addIssue({
594+
code: z.ZodIssueCode.custom,
595+
message:
596+
"`saleAmount` is required when `importStripeInvoices` is false.",
597+
path: ["saleAmount"],
598+
});
599+
return;
600+
}
577601

578-
if (!data.importStripeInvoices && data.saleAmount == null) {
579-
ctx.addIssue({
580-
code: z.ZodIssueCode.custom,
581-
message:
582-
"`saleAmount` is required when `importStripeInvoices` is false.",
583-
path: ["saleAmount"],
584-
});
602+
if (data.saleAmount === 0) {
603+
ctx.addIssue({
604+
code: z.ZodIssueCode.custom,
605+
message: "Sale amount cannot be 0.",
606+
path: ["saleAmount"],
607+
});
608+
}
585609
}
586610
});
587611

0 commit comments

Comments
 (0)