Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { createClawbackAction } from "@/lib/actions/partners/create-clawback";
import { mutatePrefix } from "@/lib/swr/mutate";
import { useApiMutation } from "@/lib/swr/use-api-mutation";
import useWorkspace from "@/lib/swr/use-workspace";
import {
CLAWBACK_REASONS,
createClawbackSchema,
createCommissionResponseSchema,
} from "@/lib/zod/schemas/commissions";
import { PartnerSelector } from "@/ui/partners/partner-selector";
import { X } from "@/ui/shared/icons";
import { Button, Sheet } from "@dub/ui";
import { useAction } from "next-safe-action/hooks";
import { useParams } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
Expand All @@ -21,7 +20,11 @@ interface CreateClawbackSheetProps {
nested?: boolean;
}

type FormData = z.infer<typeof createClawbackSchema>;
type FormData = {
partnerId?: string;
amount?: number;
reason?: (typeof CLAWBACK_REASONS)[number]["value"];
};

function CreateClawbackSheetContent(
props: Omit<CreateClawbackSheetProps, "nested">,
Expand All @@ -37,47 +40,42 @@ function CreateClawbackSheetContent(
reset,
watch,
getValues,
formState: { errors, isSubmitting, isSubmitSuccessful },
formState: { errors, isSubmitting },
} = useForm<FormData>({
defaultValues: {
partnerId: params.partnerId,
description: "",
reason: undefined,
},
});

const [partnerId, amount, description] = watch([
"partnerId",
"amount",
"description",
]);
const [partnerId, amount, reason] = watch(["partnerId", "amount", "reason"]);

const { executeAsync, isPending } = useAction(createClawbackAction, {
onSuccess: () => {
toast.success("A clawback has been created for the partner!");
setIsOpen(false);
mutatePrefix(`/api/commissions?workspaceId=${workspaceId}`);
const currentValues = getValues();
reset(currentValues);
},
onError({ error }) {
toast.error(error.serverError || "Failed to create clawback.");
},
});
const { makeRequest, isSubmitting: isCreating } =
useApiMutation<z.infer<typeof createCommissionResponseSchema>>();

const onSubmit = async (data: FormData) => {
if (!workspaceId || !defaultProgramId) {
return;
}

await executeAsync({
...data,
amount: data.amount * 100,
workspaceId,
await makeRequest("/api/commissions", {
method: "POST",
body: {
type: "custom",
partnerId: data.partnerId,
amount: data.amount ? -Math.round(data.amount * 100) : 0,
description: data.reason,
},
onSuccess: async () => {
toast.success("A clawback has been created for the partner!");
setIsOpen(false);
await mutatePrefix("/api/commissions");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const currentValues = getValues();
reset(currentValues);
},
});
};

const disableSubmitButton = !partnerId || !amount || !description;

return (
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
<div className="sticky top-0 z-10 border-b border-neutral-200 bg-white">
Expand Down Expand Up @@ -110,7 +108,7 @@ function CreateClawbackSheetContent(
rules={{ required: true }}
render={({ field }) => (
<PartnerSelector
selectedPartnerId={field.value}
selectedPartnerId={field.value ?? null}
setSelectedPartnerId={field.onChange}
/>
)}
Expand Down Expand Up @@ -171,21 +169,21 @@ function CreateClawbackSheetContent(

<div>
<label
htmlFor="description"
htmlFor="reason"
className="text-sm font-medium text-neutral-900"
>
Reason
</label>
<div className="relative mt-2 rounded-md shadow-sm">
<Controller
name="description"
name="reason"
control={control}
rules={{ required: true }}
render={({ field }) => (
<select
id="description"
id="reason"
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"
value={field.value}
value={field.value ?? ""}
onChange={field.onChange}
>
<option value="" disabled>
Expand All @@ -199,9 +197,9 @@ function CreateClawbackSheetContent(
</select>
)}
/>
{errors.description && (
{errors.reason && (
<span className="text-xs text-red-600">
{errors.description.message}
{errors.reason.message}
</span>
)}
</div>
Expand All @@ -216,15 +214,15 @@ function CreateClawbackSheetContent(
onClick={() => setIsOpen(false)}
text="Cancel"
className="w-fit"
disabled={isPending || isSubmitting || isSubmitSuccessful}
disabled={isCreating || isSubmitting}
/>
<Button
type="submit"
variant="primary"
text="Create clawback"
className="w-fit"
loading={isPending || isSubmitting || isSubmitSuccessful}
disabled={disableSubmitButton}
loading={isCreating || isSubmitting}
disabled={!partnerId || !amount || !reason}
/>
</div>
</div>
Expand Down
38 changes: 0 additions & 38 deletions apps/web/lib/actions/partners/create-clawback.ts

This file was deleted.

7 changes: 0 additions & 7 deletions apps/web/lib/api/commissions/create-manual-commissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,6 @@ export async function createManualCommissions(args: CreateCommissionsArgs) {
productId,
} = args;

if (!importStripeInvoices && !saleAmount) {
throw new DubApiError({
code: "bad_request",
message: "Either saleAmount or importStripeInvoices must be provided.",
});
}

const hasManualSaleFields =
saleAmount || saleEventDate || invoiceId || productId;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/openapi/commissions/create-commission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const createCommission: ZodOpenApiOperationObject = {
"x-speakeasy-name-override": "create",
summary: "Create commission",
description:
"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.",
"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.",
requestBody: {
content: {
"application/json": {
Expand Down
46 changes: 32 additions & 14 deletions apps/web/lib/zod/schemas/commissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,15 +318,6 @@ export const CLAWBACK_REASONS_MAP = Object.fromEntries(
CLAWBACK_REASONS.map((r) => [r.value, r]),
);

export const createClawbackSchema = z.object({
workspaceId: z.string(),
partnerId: z.string(),
amount: z.number().gt(0, "Amount must be greater than 0."),
description: z.enum(
CLAWBACK_REASONS.map((r) => r.value) as [string, ...string[]],
),
});

export const COMMISSION_EXPORT_COLUMNS = [
{ id: "id", label: "ID", type: "string", default: true },
{ id: "type", label: "Type", type: "string", default: true },
Expand Down Expand Up @@ -461,23 +452,31 @@ export const createPartnerCommissionSchema = z.object({

export const createManualCommissionBodySchema = z
.discriminatedUnion("type", [
// Custom commission
// Custom commission (negative amount = clawback)
z.object({
type: z.literal("custom"),
partnerId: z
.string()
.describe("The ID of the partner to create the commission for."),
amount: centsSchema
.pipe(z.number().min(1))
.describe("The commission amount in cents."),
.pipe(
z.number().refine((n) => n !== 0, {
message: "Amount cannot be 0.",
}),
)
.describe(
"The commission amount in cents. Use a negative amount to create a clawback.",
),
date: parseDateSchema
.nullish()
.describe("If not provided, the current date will be used."),
description: z
.string()
.max(190)
.nullish()
.describe("The description of the commission."),
.describe(
"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.",
),
}),

// Lead commission
Expand Down Expand Up @@ -548,7 +547,14 @@ export const createManualCommissionBodySchema = z
"When `true`, import all unimported paid Stripe invoices for the customer and create a commission for each. When `false`, create a single manual sale event using `saleAmount`.",
),
saleAmount: centsSchema
.pipe(z.number().min(0))
.pipe(
z
.number()
.min(0)
.refine((n) => n !== 0, {
message: "Sale amount cannot be 0.",
}),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
.nullish()
.describe(
"Required when `importStripeInvoices` is `false`. The sale amount in cents for the manual sale event. Ignored when importing from Stripe.",
Expand All @@ -573,6 +579,18 @@ export const createManualCommissionBodySchema = z
}),
])
.superRefine((data, ctx) => {
if (data.type === "custom" && data.amount < 0) {
if (!data.description?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"`description` is required when creating a clawback (negative amount).",
path: ["description"],
});
}
return;
}

if (data.type !== "sale") return;

if (!data.importStripeInvoices && data.saleAmount == null) {
Expand Down
Loading
Loading