Skip to content
Merged
83 changes: 83 additions & 0 deletions apps/web/app/(ee)/api/clawbacks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { DubApiError } from "@/lib/api/errors";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation";
import { throwIfNoPartnerIdOrTenantId } from "@/lib/partners/throw-if-no-partnerid-tenantid";
import { prisma } from "@/lib/prisma";
import {
createClawbackSchema,
createCommissionResponseSchema,
} from "@/lib/zod/schemas/commissions";
import { NextResponse } from "next/server";

// POST /api/clawbacks – create a clawback for a partner
export const POST = withWorkspace(
async ({ workspace, session, req }) => {
const programId = getDefaultProgramIdOrThrow(workspace);

let { partnerId, tenantId, amount, reason } = createClawbackSchema.parse(
await parseRequestBody(req),
);

throwIfNoPartnerIdOrTenantId({
partnerId,
tenantId,
});

if (tenantId && !partnerId) {
const partner = await prisma.programEnrollment.findUnique({
where: {
tenantId_programId: {
tenantId,
programId,
},
},
select: {
partnerId: true,
},
});

if (!partner) {
throw new DubApiError({
code: "not_found",
message: `Partner with specified tenantId ${tenantId} not found.`,
});
}

partnerId = partner.partnerId;
}

await getProgramEnrollmentOrThrow({
programId,
partnerId: partnerId!,
include: {},
});

await queuePartnerCommissionCreation({
event: "custom",
partnerId: partnerId!,
programId,
description: reason,
amount: -amount,
quantity: 1,
userId: session.user.id,
triggerAggregateDueCommissions: true,
});

return NextResponse.json(
createCommissionResponseSchema.parse({
success: true,
message: "Your clawback is being created and will appear shortly.",
}),
{
status: 202,
},
);
},
{
requiredPlan: ["business", "advanced", "enterprise"],
requiredRoles: ["owner", "member"],
},
);
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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 Down Expand Up @@ -41,42 +41,38 @@ function CreateClawbackSheetContent(
} = 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/clawbacks", {
method: "POST",
body: {
partnerId: data.partnerId,
amount: data.amount ? Math.round(data.amount * 100) : 0,
reason: data.reason,
},
onSuccess: async ({ message }) => {
toast.success(message);
setIsOpen(false);
await mutatePrefix("/api/commissions");
const currentValues = getValues();
reset(currentValues);
},
});
};

const disableSubmitButton = !partnerId || !amount || !description;
const disableSubmitButton = !partnerId || !amount || !reason;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return (
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
Expand Down Expand Up @@ -110,7 +106,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 +167,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 +195,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,14 +212,14 @@ function CreateClawbackSheetContent(
onClick={() => setIsOpen(false)}
text="Cancel"
className="w-fit"
disabled={isPending || isSubmitting || isSubmitSuccessful}
disabled={isCreating || isSubmitting || isSubmitSuccessful}
/>
<Button
type="submit"
variant="primary"
text="Create clawback"
className="w-fit"
loading={isPending || isSubmitting || isSubmitSuccessful}
loading={isCreating || isSubmitting || isSubmitSuccessful}
disabled={disableSubmitButton}
/>
</div>
Expand Down
38 changes: 0 additions & 38 deletions apps/web/lib/actions/partners/create-clawback.ts

This file was deleted.

1 change: 1 addition & 0 deletions apps/web/lib/auth/product-access-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const PROGRAM_API_PATHS = [
"/api/programs",
"/api/partners",
"/api/commissions",
"/api/clawbacks",
"/api/payouts",
"/api/rewards",
"/api/bounties",
Expand Down
34 changes: 34 additions & 0 deletions apps/web/lib/openapi/clawbacks/create-clawback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {
createClawbackSchema,
createCommissionResponseSchema,
} from "@/lib/zod/schemas/commissions";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { openApiErrorResponses } from "../responses";

export const createClawback: ZodOpenApiOperationObject = {
operationId: "createClawback",
"x-speakeasy-name-override": "create",
summary: "Create clawback",
description:
"Create a clawback for a partner. Clawback creation is processed asynchronously. Use the List Commissions endpoint or webhooks to be notified when the clawback is created.",
requestBody: {
content: {
"application/json": {
schema: createClawbackSchema,
},
},
},
Comment thread
devkiran marked this conversation as resolved.
Outdated
responses: {
"202": {
description: "The request was accepted and clawback creation was queued.",
content: {
"application/json": {
schema: createCommissionResponseSchema,
},
},
},
...openApiErrorResponses,
},
tags: ["Commissions"],
security: [{ token: [] }],
};
8 changes: 8 additions & 0 deletions apps/web/lib/openapi/clawbacks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ZodOpenApiPathsObject } from "zod-openapi";
import { createClawback } from "./create-clawback";

export const clawbacksPaths: ZodOpenApiPathsObject = {
"/clawbacks": {
post: createClawback,
},
};
2 changes: 2 additions & 0 deletions apps/web/lib/openapi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { LinkErrorSchema, LinkSchema } from "../zod/schemas/links";
import { LinkTagSchema } from "../zod/schemas/tags";
import { analyticsPath } from "./analytics";
import { bountiesPaths } from "./bounties";
import { clawbacksPaths } from "./clawbacks";
import { commissionsPaths } from "./commissions";
import { customersPaths } from "./customers";
import { discountCodesPaths } from "./discount-codes";
Expand Down Expand Up @@ -57,6 +58,7 @@ export const document = createDocument({
...partnersPaths,
...discountCodesPaths,
...commissionsPaths,
...clawbacksPaths,
...payoutsPaths,
...embedTokensPaths,
...qrCodePaths,
Expand Down
20 changes: 12 additions & 8 deletions apps/web/lib/zod/schemas/commissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import {
getCursorPaginationQuerySchema,
getPaginationQuerySchema,
} from "./misc";
import { EnrolledPartnerSchema, WebhookPartnerSchema } from "./partners";
import {
EnrolledPartnerSchema,
partnerIdTenantIdSchema,
WebhookPartnerSchema,
} from "./partners";
import { PayoutSchema } from "./payouts";
import { rewardContextSchema, RewardSchema } from "./rewards";
import { UserSchema } from "./users";
Expand Down Expand Up @@ -318,13 +322,13 @@ 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 createClawbackSchema = partnerIdTenantIdSchema.extend({
amount: centsSchema
.pipe(z.number().gt(0, "Amount must be greater than 0."))
.describe("The clawback amount in cents (positive)."),
reason: z
.enum(CLAWBACK_REASONS.map((r) => r.value) as [string, ...string[]])
.describe("The reason for the clawback."),
});

export const COMMISSION_EXPORT_COLUMNS = [
Expand Down
Loading
Loading