Skip to content

Commit

Permalink
Merge pull request #2040 from dubinc/update-sale
Browse files Browse the repository at this point in the history
Add `dub.partners.updateSale` method
  • Loading branch information
steven-tey authored Mar 3, 2025
2 parents 2fe0fac + 99d2198 commit b498478
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 2 deletions.
113 changes: 113 additions & 0 deletions apps/web/app/api/partners/sales/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { DubApiError } from "@/lib/api/errors";
import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw";
import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth/workspace";
import { determinePartnerReward } from "@/lib/partners/determine-partner-reward";
import { updatePartnerSaleSchema } from "@/lib/zod/schemas/partners";
import { ProgramSaleSchema } from "@/lib/zod/schemas/program-sales";
import { prisma } from "@dub/prisma";
import { NextResponse } from "next/server";

// PATCH /api/partners/sales - update a sale
export const PATCH = withWorkspace(
async ({ req, workspace }) => {
const { programId, invoiceId, amount } = updatePartnerSaleSchema.parse(
await parseRequestBody(req),
);

const program = await getProgramOrThrow({
workspaceId: workspace.id,
programId,
});

const sale = await prisma.commission.findUnique({
where: {
programId_invoiceId: {
programId: program.id,
invoiceId,
},
},
include: {
partner: true,
},
});

if (!sale) {
throw new DubApiError({
code: "not_found",
message: `Sale with invoice ID ${invoiceId} not found for program ${programId}.`,
});
}

if (sale.status === "paid") {
throw new DubApiError({
code: "bad_request",
message: `Cannot update amount: Sale with invoice ID ${invoiceId} has already been paid.`,
});
}

const { partner } = sale;

const reward = await determinePartnerReward({
event: "sale",
partnerId: partner.id,
programId: program.id,
});

if (!reward) {
throw new DubApiError({
code: "not_found",
message: `No reward found for partner ${partner.id} in program ${program.id}.`,
});
}

// Recalculate the earnings based on the new amount
const earnings = calculateSaleEarnings({
reward,
sale: {
amount,
quantity: sale.quantity,
},
});

const updatedSale = await prisma.commission.update({
where: {
id: sale.id,
},
data: {
amount,
earnings,
},
});

// If the sale has already been paid, we need to update the payout
if (sale.status === "processed" && sale.payoutId) {
const earningsDifference = earnings - sale.earnings;

await prisma.payout.update({
where: {
id: sale.payoutId,
},
data: {
amount: {
...(earningsDifference < 0
? { decrement: Math.abs(earningsDifference) }
: { increment: earningsDifference }),
},
},
});
}

return NextResponse.json(ProgramSaleSchema.parse(updatedSale));
},
{
requiredPlan: [
"business",
"business extra",
"business max",
"business plus",
"enterprise",
],
},
);
4 changes: 4 additions & 0 deletions apps/web/lib/openapi/partners/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ZodOpenApiPathsObject } from "zod-openapi";
import { createPartner } from "./create-partner";
import { createPartnerLink } from "./create-partner-link";
import { retrievePartnerAnalytics } from "./retrieve-analytics";
import { updatePartnerSale } from "./update-partner-sale";
import { upsertPartnerLink } from "./upsert-partner-link";

export const partnersPaths: ZodOpenApiPathsObject = {
Expand All @@ -17,4 +18,7 @@ export const partnersPaths: ZodOpenApiPathsObject = {
"/partners/analytics": {
get: retrievePartnerAnalytics,
},
"/partners/sales": {
patch: updatePartnerSale,
},
};
32 changes: 32 additions & 0 deletions apps/web/lib/openapi/partners/update-partner-sale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ZodOpenApiOperationObject } from "zod-openapi";
import { updatePartnerSaleSchema } from "../../zod/schemas/partners";
import { ProgramSaleSchema } from "../../zod/schemas/program-sales";
import { openApiErrorResponses } from "../responses";

export const updatePartnerSale: ZodOpenApiOperationObject = {
operationId: "updatePartnerSale",
"x-speakeasy-name-override": "updateSale",
summary: "Update a sale for a partner.",
description:
"Update an existing sale amount. This is useful for handling refunds (partial or full) or fraudulent sales.",
requestBody: {
content: {
"application/json": {
schema: updatePartnerSaleSchema,
},
},
},
responses: {
"200": {
description: "The updated sale.",
content: {
"application/json": {
schema: ProgramSaleSchema,
},
},
},
...openApiErrorResponses,
},
tags: ["Partners"],
security: [{ token: [] }],
};
4 changes: 2 additions & 2 deletions apps/web/lib/webhook/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { linkEventSchema } from "../zod/schemas/links";
import { EnrolledPartnerSchema } from "../zod/schemas/partners";
import { WEBHOOK_TRIGGERS } from "./constants";

const saleSchema = z.object({
const webhookSaleSchema = z.object({
amount: z.number(),
currency: z.string(),
paymentProcessor: z.string(),
Expand All @@ -29,7 +29,7 @@ export const saleWebhookEventSchema = z.object({
customer: CustomerSchema,
click: clickEventSchema,
link: linkEventSchema,
sale: saleSchema,
sale: webhookSaleSchema,
});

// Schema of the payload sent to the webhook endpoint by Dub
Expand Down
9 changes: 9 additions & 0 deletions apps/web/lib/zod/schemas/partners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,12 @@ export const partnerAnalyticsResponseSchema = {
title: "PartnerAnalyticsTopLinks",
}),
} as const;

export const updatePartnerSaleSchema = z.object({
programId: z.string(),
invoiceId: z.string(),
amount: z
.number({ required_error: "Amount is required." })
.min(0)
.describe("The new amount for the sale."),
});

0 comments on commit b498478

Please sign in to comment.