From 4e38b76516395654d11c07e76d79d2f6d751baea Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 12:30:34 +0530 Subject: [PATCH 01/20] Add discount codes to the OpenAPI spec --- .../discount-codes/[discountCodeId]/route.ts | 51 +++++++----- .../get-discount-code-or-throw.ts | 44 ++++++++++ .../discount-codes/create-discount-code.ts | 34 ++++++++ .../discount-codes/delete-discount-code.ts | 33 ++++++++ apps/web/lib/openapi/discount-codes/index.ts | 14 ++++ .../discount-codes/list-discount-codes.ts | 31 +++++++ apps/web/lib/openapi/index.ts | 4 + apps/web/lib/zod/schemas/discount.ts | 83 +++++++++++++------ apps/web/prisma/schema/discount.prisma | 1 + 9 files changed, 249 insertions(+), 46 deletions(-) create mode 100644 apps/web/lib/discount-codes/get-discount-code-or-throw.ts create mode 100644 apps/web/lib/openapi/discount-codes/create-discount-code.ts create mode 100644 apps/web/lib/openapi/discount-codes/delete-discount-code.ts create mode 100644 apps/web/lib/openapi/discount-codes/index.ts create mode 100644 apps/web/lib/openapi/discount-codes/list-discount-codes.ts diff --git a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts index 091e0984604..887bc67e808 100644 --- a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts @@ -2,43 +2,52 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; +import { getDiscountCodeOrThrow } from "@/lib/discount-codes/get-discount-code-or-throw"; import { deleteDiscountCodes } from "@/lib/discounts/delete-discount-code"; import { prisma } from "@/lib/prisma"; +import { DiscountProvider } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; -// DELETE /api/discount-codes/[discountCodeId] - soft delete a discount code -export const DELETE = withWorkspace( +// PATCH /api/discount-codes/[discountCodeId] - update a discount code +export const PATCH = withWorkspace( async ({ workspace, params, session }) => { const { discountCodeId } = params; const programId = getDefaultProgramIdOrThrow(workspace); - const discountCode = await prisma.discountCode.findUnique({ - where: { - id: discountCodeId, - }, - include: { - discount: { - select: { - provider: true, - }, - }, - }, + const discountCode = await getDiscountCodeOrThrow({ + discountCodeId, + programId, }); - if (!discountCode || !discountCode.discount) { + if (discountCode.discount.provider !== DiscountProvider.custom) { throw new DubApiError({ - message: `Discount code (${discountCodeId}) not found.`, code: "bad_request", + message: `This operation is only available for "custom" discount provider.`, }); } - if (discountCode.programId !== programId) { - throw new DubApiError({ - message: `Discount code (${discountCodeId}) is not associated with the program.`, - code: "bad_request", - }); - } + // TODO: + // - Update the discount code + + return NextResponse.json({ id: discountCode.id }); + }, + { + requiredPlan: ["business", "advanced", "enterprise"], + requiredRoles: ["owner", "member"], + }, +); + +// DELETE /api/discount-codes/[discountCodeId] - soft delete a discount code +export const DELETE = withWorkspace( + async ({ workspace, params, session }) => { + const { discountCodeId } = params; + const programId = getDefaultProgramIdOrThrow(workspace); + + const discountCode = await getDiscountCodeOrThrow({ + discountCodeId, + programId, + }); await prisma.discountCode.update({ where: { diff --git a/apps/web/lib/discount-codes/get-discount-code-or-throw.ts b/apps/web/lib/discount-codes/get-discount-code-or-throw.ts new file mode 100644 index 00000000000..7c21643864b --- /dev/null +++ b/apps/web/lib/discount-codes/get-discount-code-or-throw.ts @@ -0,0 +1,44 @@ +import { prisma } from "@/lib/prisma"; +import { DubApiError } from "../api/errors"; + +export async function getDiscountCodeOrThrow({ + discountCodeId, + programId, +}: { + discountCodeId: string; + programId: string; +}) { + const discountCode = await prisma.discountCode.findUnique({ + where: { + id: discountCodeId, + }, + include: { + discount: { + select: { + provider: true, + }, + }, + }, + }); + + if (!discountCode || !discountCode.discount) { + throw new DubApiError({ + code: "not_found", + message: `Discount code (${discountCodeId}) not found.`, + }); + } + + if (discountCode.programId !== programId) { + throw new DubApiError({ + code: "not_found", + message: `Discount code (${discountCodeId}) not found.`, + }); + } + + const { discount, ...rest } = discountCode; + + return { + ...rest, + discount, + }; +} diff --git a/apps/web/lib/openapi/discount-codes/create-discount-code.ts b/apps/web/lib/openapi/discount-codes/create-discount-code.ts new file mode 100644 index 00000000000..60f73eab844 --- /dev/null +++ b/apps/web/lib/openapi/discount-codes/create-discount-code.ts @@ -0,0 +1,34 @@ +import { openApiErrorResponses } from "@/lib/openapi/responses"; +import { + createDiscountCodeSchema, + DiscountCodeSchema, +} from "@/lib/zod/schemas/discount"; +import { ZodOpenApiOperationObject } from "zod-openapi"; + +export const createDiscountCode: ZodOpenApiOperationObject = { + operationId: "createDiscountCode", + "x-speakeasy-name-override": "create", + summary: "Create a discount code", + description: + "Create a discount code for a partner. A discount must already be assigned to the partner's group, and the specified link cannot already have a discount code.", + requestBody: { + content: { + "application/json": { + schema: createDiscountCodeSchema, + }, + }, + }, + responses: { + "200": { + description: "The created discount code.", + content: { + "application/json": { + schema: DiscountCodeSchema, + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Discount Codes"], + security: [{ token: [] }], +}; diff --git a/apps/web/lib/openapi/discount-codes/delete-discount-code.ts b/apps/web/lib/openapi/discount-codes/delete-discount-code.ts new file mode 100644 index 00000000000..5262afdee9a --- /dev/null +++ b/apps/web/lib/openapi/discount-codes/delete-discount-code.ts @@ -0,0 +1,33 @@ +import { openApiErrorResponses } from "@/lib/openapi/responses"; +import { DiscountCodeSchema } from "@/lib/zod/schemas/discount"; +import { ZodOpenApiOperationObject } from "zod-openapi"; +import * as z from "zod/v4"; + +export const deleteDiscountCode: ZodOpenApiOperationObject = { + operationId: "deleteDiscountCode", + "x-speakeasy-name-override": "delete", + "x-speakeasy-max-method-params": 1, + summary: "Delete a discount code", + description: + "Delete a discount code for a partner. This will also disable the code in your connected Stripe or Shopify account.", + requestParams: { + path: z.object({ + id: DiscountCodeSchema.shape.id.describe( + "The ID of the discount code to delete.", + ), + }), + }, + responses: { + "200": { + description: "The deleted discount code ID.", + content: { + "application/json": { + schema: DiscountCodeSchema.pick({ id: true }), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Discount Codes"], + security: [{ token: [] }], +}; diff --git a/apps/web/lib/openapi/discount-codes/index.ts b/apps/web/lib/openapi/discount-codes/index.ts new file mode 100644 index 00000000000..01eafc4392b --- /dev/null +++ b/apps/web/lib/openapi/discount-codes/index.ts @@ -0,0 +1,14 @@ +import { ZodOpenApiPathsObject } from "zod-openapi"; +import { createDiscountCode } from "./create-discount-code"; +import { deleteDiscountCode } from "./delete-discount-code"; +import { listDiscountCodes } from "./list-discount-codes"; + +export const discountCodesPaths: ZodOpenApiPathsObject = { + "/discount-codes": { + get: listDiscountCodes, + post: createDiscountCode, + }, + "/discount-codes/{id}": { + delete: deleteDiscountCode, + }, +}; diff --git a/apps/web/lib/openapi/discount-codes/list-discount-codes.ts b/apps/web/lib/openapi/discount-codes/list-discount-codes.ts new file mode 100644 index 00000000000..23833d4d350 --- /dev/null +++ b/apps/web/lib/openapi/discount-codes/list-discount-codes.ts @@ -0,0 +1,31 @@ +import { openApiErrorResponses } from "@/lib/openapi/responses"; +import { + DiscountCodeSchema, + getDiscountCodesQuerySchema, +} from "@/lib/zod/schemas/discount"; +import { ZodOpenApiOperationObject } from "zod-openapi"; +import * as z from "zod/v4"; + +export const listDiscountCodes: ZodOpenApiOperationObject = { + operationId: "listDiscountCodes", + "x-speakeasy-name-override": "list", + summary: "List discount codes", + description: + "Retrieve a list of discount codes for a partner in your program.", + requestParams: { + query: getDiscountCodesQuerySchema, + }, + responses: { + "200": { + description: "The list of discount codes.", + content: { + "application/json": { + schema: z.array(DiscountCodeSchema), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Discount Codes"], + security: [{ token: [] }], +}; diff --git a/apps/web/lib/openapi/index.ts b/apps/web/lib/openapi/index.ts index 9669bda97cb..0044fc30b02 100644 --- a/apps/web/lib/openapi/index.ts +++ b/apps/web/lib/openapi/index.ts @@ -1,5 +1,6 @@ import { createDocument } from "zod-openapi"; import { webhookEventSchema } from "../webhook/schemas"; +import { DiscountCodeSchema } from "../zod/schemas/discount"; import { DomainSchema } from "../zod/schemas/domains"; import { FolderSchema } from "../zod/schemas/folders"; import { LinkErrorSchema, LinkSchema } from "../zod/schemas/links"; @@ -8,6 +9,7 @@ import { analyticsPath } from "./analytics"; import { bountiesPaths } from "./bounties"; import { commissionsPaths } from "./commissions"; import { customersPaths } from "./customers"; +import { discountCodesPaths } from "./discount-codes"; import { domainsPaths } from "./domains"; import { embedTokensPaths } from "./embed-tokens"; import { eventsPath } from "./events"; @@ -53,6 +55,7 @@ export const document = createDocument({ ...trackPaths, ...customersPaths, ...partnersPaths, + ...discountCodesPaths, ...commissionsPaths, ...payoutsPaths, ...embedTokensPaths, @@ -65,6 +68,7 @@ export const document = createDocument({ LinkTagSchema, FolderSchema, DomainSchema, + DiscountCodeSchema, webhookEventSchema, LinkErrorSchema, }, diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index e291a670358..0894da4c82d 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -56,35 +56,68 @@ export const discountPartnersQuerySchema = z }) .extend(getPaginationQuerySchema({ pageSize: 25 })); -export const DiscountCodeSchema = z.object({ - id: z.string(), - code: z.string(), - discountId: z.string().nullable(), - partnerId: z.string(), - linkId: z.string(), - disabledAt: z.coerce - .date() - .nullish() - .describe( - "When this discount code was disabled, which happens when a partner is banned or deactivated.", - ), -}); +export const DiscountCodeSchema = z + .object({ + id: z.string().describe("The unique ID of the discount code.").meta({ + example: "dcode_1JVR7XRCSR0EDBAF39FZ4PMYE", + }), + code: z + .string() + .describe("The discount code that customers can apply at checkout.") + .meta({ + example: "PARTNER10OFF", + }), + discountId: z + .string() + .nullable() + .describe("The ID of the discount this code belongs to."), + partnerId: z + .string() + .describe("The ID of the partner this discount code is assigned to."), + linkId: z + .string() + .describe( + "The ID of the partner's referral link this discount code is associated with.", + ), + disabledAt: z.coerce + .date() + .nullish() + .describe( + "When this discount code was disabled, which happens when a partner is banned or deactivated.", + ), + }) + .meta({ + title: "DiscountCode", + }); export const createDiscountCodeSchema = z.object({ - code: z + code: z.preprocess( + (val) => (typeof val === "string" && val.trim() === "" ? undefined : val), + z + .string() + .trim() + .max(100, "Code must be 100 characters or fewer.") + .regex( + /^[a-zA-Z0-9\-_]+$/, + "Code can only contain letters, numbers, dashes, and underscores.", + ) + .optional() + .describe( + "The discount code to create. If omitted, a unique code will be generated automatically from the partner's name.", + ), + ), + partnerId: z + .string() + .describe("The ID of the partner to create a discount code for."), + linkId: z .string() - .trim() - .max(100, "Code must be 100 characters or fewer.") - .regex( - /^[a-zA-Z0-9\-_]+$/, - "Code can only contain letters, numbers, dashes, and underscores.", - ) - .optional() - .or(z.literal("").transform(() => undefined)), - partnerId: z.string(), - linkId: z.string(), + .describe( + "The ID of the partner's referral link to associate this discount code with. Each link can only have one discount code.", + ), }); export const getDiscountCodesQuerySchema = z.object({ - partnerId: z.string(), + partnerId: z + .string() + .describe("The ID of the partner to retrieve discount codes for."), }); diff --git a/apps/web/prisma/schema/discount.prisma b/apps/web/prisma/schema/discount.prisma index 8d301001629..f5251a051c6 100644 --- a/apps/web/prisma/schema/discount.prisma +++ b/apps/web/prisma/schema/discount.prisma @@ -1,6 +1,7 @@ enum DiscountProvider { stripe shopify + custom } model Discount { From cf72878f9094d02b1a90cec7eb6d12e1e1959d94 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 12:35:16 +0530 Subject: [PATCH 02/20] Update route.ts --- .../discount-codes/[discountCodeId]/route.ts | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts index 887bc67e808..2a1016d6fee 100644 --- a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts @@ -9,35 +9,6 @@ import { DiscountProvider } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; -// PATCH /api/discount-codes/[discountCodeId] - update a discount code -export const PATCH = withWorkspace( - async ({ workspace, params, session }) => { - const { discountCodeId } = params; - const programId = getDefaultProgramIdOrThrow(workspace); - - const discountCode = await getDiscountCodeOrThrow({ - discountCodeId, - programId, - }); - - if (discountCode.discount.provider !== DiscountProvider.custom) { - throw new DubApiError({ - code: "bad_request", - message: `This operation is only available for "custom" discount provider.`, - }); - } - - // TODO: - // - Update the discount code - - return NextResponse.json({ id: discountCode.id }); - }, - { - requiredPlan: ["business", "advanced", "enterprise"], - requiredRoles: ["owner", "member"], - }, -); - // DELETE /api/discount-codes/[discountCodeId] - soft delete a discount code export const DELETE = withWorkspace( async ({ workspace, params, session }) => { @@ -86,3 +57,32 @@ export const DELETE = withWorkspace( requiredRoles: ["owner", "member"], }, ); + +// PATCH /api/discount-codes/[discountCodeId] - update a discount code +export const PATCH = withWorkspace( + async ({ workspace, params, session }) => { + const { discountCodeId } = params; + const programId = getDefaultProgramIdOrThrow(workspace); + + const discountCode = await getDiscountCodeOrThrow({ + discountCodeId, + programId, + }); + + if (discountCode.discount.provider !== DiscountProvider.custom) { + throw new DubApiError({ + code: "bad_request", + message: `This operation is only available for "custom" discount provider.`, + }); + } + + // TODO: + // - Update the discount code + + return NextResponse.json({ id: discountCode.id }); + }, + { + requiredPlan: ["business", "advanced", "enterprise"], + requiredRoles: ["owner", "member"], + }, +); From cb79fc4401a61eedaf10d343cb3693033fca508d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 13:44:17 +0530 Subject: [PATCH 03/20] Add discount code update API, webhooks, and custom provider --- .../discount-codes/[discountCodeId]/route.ts | 116 +++++++++++++++++- apps/web/app/(ee)/api/discount-codes/route.ts | 60 +++++++-- .../lib/actions/partners/delete-discount.ts | 11 +- apps/web/lib/api/audit-logs/schemas.ts | 1 + .../get-discount-code-or-throw.ts | 6 +- apps/web/lib/discounts/discount-provider.ts | 2 + apps/web/lib/openapi/discount-codes/index.ts | 2 + .../discount-codes/list-discount-codes.ts | 2 +- .../discount-codes/update-discount-code.ts | 44 +++++++ apps/web/lib/webhook/constants.ts | 6 + .../sample-events/discount-code-created.json | 14 +++ .../sample-events/discount-code-deleted.json | 14 +++ .../sample-events/discount-code-updated.json | 14 +++ apps/web/lib/webhook/sample-events/payload.ts | 6 + apps/web/lib/webhook/schemas.ts | 19 +++ apps/web/lib/webhook/types.ts | 8 +- apps/web/tests/webhooks/index.test.ts | 4 + .../discounts/add-edit-discount-sheet.tsx | 10 +- 18 files changed, 303 insertions(+), 36 deletions(-) create mode 100644 apps/web/lib/openapi/discount-codes/update-discount-code.ts create mode 100644 apps/web/lib/webhook/sample-events/discount-code-created.json create mode 100644 apps/web/lib/webhook/sample-events/discount-code-deleted.json create mode 100644 apps/web/lib/webhook/sample-events/discount-code-updated.json diff --git a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts index 2a1016d6fee..b8dc4f672a3 100644 --- a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts @@ -1,15 +1,22 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; 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 { getDiscountCodeOrThrow } from "@/lib/discount-codes/get-discount-code-or-throw"; import { deleteDiscountCodes } from "@/lib/discounts/delete-discount-code"; +import { sendDiscountCodeWebhook } from "@/lib/discounts/discount-code-webhook"; import { prisma } from "@/lib/prisma"; -import { DiscountProvider } from "@prisma/client"; +import { + DiscountCodeSchema, + updateDiscountCodeSchema, +} from "@/lib/zod/schemas/discount"; +import { APP_DOMAIN } from "@dub/utils"; +import { DiscountProvider, Prisma } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; -// DELETE /api/discount-codes/[discountCodeId] - soft delete a discount code +// DELETE /api/discount-codes/[discountCodeId] - delete a discount code export const DELETE = withWorkspace( async ({ workspace, params, session }) => { const { discountCodeId } = params; @@ -60,7 +67,7 @@ export const DELETE = withWorkspace( // PATCH /api/discount-codes/[discountCodeId] - update a discount code export const PATCH = withWorkspace( - async ({ workspace, params, session }) => { + async ({ workspace, params, req, session }) => { const { discountCodeId } = params; const programId = getDefaultProgramIdOrThrow(workspace); @@ -76,10 +83,107 @@ export const PATCH = withWorkspace( }); } - // TODO: - // - Update the discount code + const { code: newCode } = updateDiscountCodeSchema.parse( + await parseRequestBody(req), + ); - return NextResponse.json({ id: discountCode.id }); + if (newCode !== discountCode.code) { + const duplicateByCode = await prisma.discountCode.findUnique({ + where: { + programId_code: { + programId, + code: newCode, + }, + }, + include: { + partner: true, + }, + }); + + if (duplicateByCode) { + throw new DubApiError({ + code: "conflict", + message: `This discount code "${newCode}" is already in use by [${duplicateByCode.partner.email}](${APP_DOMAIN}/${workspace.slug}/program/partners/${duplicateByCode.partner.id}). Please choose a different code.`, + }); + } + } + + let updatedDiscountCode: Prisma.DiscountCodeGetPayload<{ + include: { discount: true }; + }>; + + try { + updatedDiscountCode = await prisma.discountCode.update({ + where: { + id: discountCodeId, + }, + data: { + code: newCode, + }, + include: { + discount: true, + }, + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + const duplicateByCode = await prisma.discountCode.findUnique({ + where: { + programId_code: { + programId, + code: newCode, + }, + }, + include: { + partner: { + select: { + email: true, + id: true, + }, + }, + }, + }); + + throw new DubApiError({ + code: "conflict", + message: duplicateByCode + ? `This discount code "${newCode}" is already in use by [${duplicateByCode.partner.email}](${APP_DOMAIN}/${workspace.slug}/program/partners/${duplicateByCode.partner.id}). Please choose a different code.` + : `This discount code "${newCode}" is already in use. Please choose a different code.`, + }); + } + + throw error; + } + + waitUntil( + Promise.allSettled([ + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "discount_code.updated", + description: `Discount code (${updatedDiscountCode.code}) updated`, + actor: session.user, + targets: [ + { + type: "discount_code", + id: updatedDiscountCode.id, + metadata: updatedDiscountCode, + }, + ], + }), + + sendDiscountCodeWebhook({ + trigger: "discount_code.updated", + workspaceId: workspace.id, + programId, + data: updatedDiscountCode, + }), + ]), + ); + + return NextResponse.json(DiscountCodeSchema.parse(updatedDiscountCode)); }, { requiredPlan: ["business", "advanced", "enterprise"], diff --git a/apps/web/app/(ee)/api/discount-codes/route.ts b/apps/web/app/(ee)/api/discount-codes/route.ts index c8504df0af1..69e561a1b57 100644 --- a/apps/web/app/(ee)/api/discount-codes/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/route.ts @@ -1,5 +1,6 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { DubApiError } from "@/lib/api/errors"; +import { getDiscountOrThrow } from "@/lib/api/partners/get-discount-or-throw"; 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"; @@ -15,26 +16,50 @@ import { APP_DOMAIN } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; -// GET /api/discount-codes - get all discount codes for a partner +// GET /api/discount-codes - list discount codes export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); - const { partnerId } = getDiscountCodesQuerySchema.parse(searchParams); - - const programEnrollment = await getProgramEnrollmentOrThrow({ + const { partnerId, - programId, + discountId, + page = 1, + pageSize, + } = getDiscountCodesQuerySchema.parse(searchParams); + + if (discountId) { + await getDiscountOrThrow({ + discountId, + programId, + }); + } + + if (partnerId) { + await getProgramEnrollmentOrThrow({ + partnerId, + programId, + include: {}, + }); + } + + const discountCodes = await prisma.discountCode.findMany({ + where: { + programId, + ...(partnerId && { partnerId }), + ...(discountId && { discountId }), + }, include: { - discountCodes: true, + discount: true, + }, + orderBy: { + createdAt: "desc", }, + take: pageSize, + skip: (page - 1) * pageSize, }); - const response = DiscountCodeSchema.array().parse( - programEnrollment.discountCodes, - ); - - return NextResponse.json(response); + return NextResponse.json(DiscountCodeSchema.array().parse(discountCodes)); }, { requiredPlan: ["business", "advanced", "enterprise"], @@ -54,9 +79,18 @@ export const POST = withWorkspace( partnerId, programId, include: { - links: true, discount: true, - discountCodes: true, + links: { + select: { + id: true, + }, + }, + discountCodes: { + select: { + code: true, + linkId: true, + }, + }, partner: { select: { id: true, diff --git a/apps/web/lib/actions/partners/delete-discount.ts b/apps/web/lib/actions/partners/delete-discount.ts index 4a4c48843ca..f141234bb94 100644 --- a/apps/web/lib/actions/partners/delete-discount.ts +++ b/apps/web/lib/actions/partners/delete-discount.ts @@ -40,15 +40,8 @@ export const deleteDiscountAction = authActionClient where: { discountId: discount.id, }, - select: { - id: true, - code: true, - programId: true, - discount: { - select: { - provider: true, - }, - }, + include: { + discount: true, }, }); diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index 700bb14bbc6..cb89a6e30ea 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -44,6 +44,7 @@ const actionSchema = z.enum([ "discount.updated", "discount.deleted", "discount_code.created", + "discount_code.updated", "discount_code.deleted", // Partner applications diff --git a/apps/web/lib/discount-codes/get-discount-code-or-throw.ts b/apps/web/lib/discount-codes/get-discount-code-or-throw.ts index 7c21643864b..8af87ac13ba 100644 --- a/apps/web/lib/discount-codes/get-discount-code-or-throw.ts +++ b/apps/web/lib/discount-codes/get-discount-code-or-throw.ts @@ -13,11 +13,7 @@ export async function getDiscountCodeOrThrow({ id: discountCodeId, }, include: { - discount: { - select: { - provider: true, - }, - }, + discount: true, }, }); diff --git a/apps/web/lib/discounts/discount-provider.ts b/apps/web/lib/discounts/discount-provider.ts index f39f609ec7f..e827d6e087d 100644 --- a/apps/web/lib/discounts/discount-provider.ts +++ b/apps/web/lib/discounts/discount-provider.ts @@ -1,10 +1,12 @@ import { DiscountProvider } from "@prisma/client"; +import { customDiscountProvider } from "./discount-provider-custom"; import { shopifyDiscountProvider } from "./discount-provider-shopify"; import { stripeDiscountProvider } from "./discount-provider-stripe"; const discountProviders = { stripe: stripeDiscountProvider, shopify: shopifyDiscountProvider, + custom: customDiscountProvider, } as const; export function getDiscountProvider(name: DiscountProvider) { diff --git a/apps/web/lib/openapi/discount-codes/index.ts b/apps/web/lib/openapi/discount-codes/index.ts index 01eafc4392b..11e440b15e2 100644 --- a/apps/web/lib/openapi/discount-codes/index.ts +++ b/apps/web/lib/openapi/discount-codes/index.ts @@ -2,6 +2,7 @@ import { ZodOpenApiPathsObject } from "zod-openapi"; import { createDiscountCode } from "./create-discount-code"; import { deleteDiscountCode } from "./delete-discount-code"; import { listDiscountCodes } from "./list-discount-codes"; +import { updateDiscountCode } from "./update-discount-code"; export const discountCodesPaths: ZodOpenApiPathsObject = { "/discount-codes": { @@ -9,6 +10,7 @@ export const discountCodesPaths: ZodOpenApiPathsObject = { post: createDiscountCode, }, "/discount-codes/{id}": { + patch: updateDiscountCode, delete: deleteDiscountCode, }, }; diff --git a/apps/web/lib/openapi/discount-codes/list-discount-codes.ts b/apps/web/lib/openapi/discount-codes/list-discount-codes.ts index 23833d4d350..fd93c8ffb0e 100644 --- a/apps/web/lib/openapi/discount-codes/list-discount-codes.ts +++ b/apps/web/lib/openapi/discount-codes/list-discount-codes.ts @@ -11,7 +11,7 @@ export const listDiscountCodes: ZodOpenApiOperationObject = { "x-speakeasy-name-override": "list", summary: "List discount codes", description: - "Retrieve a list of discount codes for a partner in your program.", + "Retrieve a paginated list of discount codes for the partner program.", requestParams: { query: getDiscountCodesQuerySchema, }, diff --git a/apps/web/lib/openapi/discount-codes/update-discount-code.ts b/apps/web/lib/openapi/discount-codes/update-discount-code.ts new file mode 100644 index 00000000000..35793cf5928 --- /dev/null +++ b/apps/web/lib/openapi/discount-codes/update-discount-code.ts @@ -0,0 +1,44 @@ +import { openApiErrorResponses } from "@/lib/openapi/responses"; +import { + DiscountCodeSchema, + DiscountCodeWebhookSchema, + updateDiscountCodeSchema, +} from "@/lib/zod/schemas/discount"; +import { ZodOpenApiOperationObject } from "zod-openapi"; +import * as z from "zod/v4"; + +export const updateDiscountCode: ZodOpenApiOperationObject = { + operationId: "updateDiscountCode", + "x-speakeasy-name-override": "update", + "x-speakeasy-max-method-params": 2, + summary: "Update a discount code", + description: + "Update a custom discount code. This is only available when the discount provider is `custom`.", + requestParams: { + path: z.object({ + id: DiscountCodeSchema.shape.id.describe( + "The ID of the discount code to update.", + ), + }), + }, + requestBody: { + content: { + "application/json": { + schema: updateDiscountCodeSchema, + }, + }, + }, + responses: { + "200": { + description: "The updated discount code.", + content: { + "application/json": { + schema: DiscountCodeWebhookSchema, + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Discount Codes"], + security: [{ token: [] }], +}; diff --git a/apps/web/lib/webhook/constants.ts b/apps/web/lib/webhook/constants.ts index 4040bbf31aa..c8fffaf1263 100644 --- a/apps/web/lib/webhook/constants.ts +++ b/apps/web/lib/webhook/constants.ts @@ -26,6 +26,9 @@ export const PROGRAM_LEVEL_WEBHOOK_TRIGGERS = [ "bounty.created", "bounty.updated", "payout.confirmed", + "discount_code.created", + "discount_code.updated", + "discount_code.deleted", ] as const; export const WEBHOOK_TRIGGERS = [ @@ -46,6 +49,9 @@ export const WEBHOOK_TRIGGER_DESCRIPTIONS: Record = { "bounty.created": "Bounty created", "bounty.updated": "Bounty updated", "payout.confirmed": "Payout confirmed", + "discount_code.created": "Discount code created", + "discount_code.updated": "Discount code updated", + "discount_code.deleted": "Discount code deleted", } as const; export const WEBHOOK_FAILURE_NOTIFY_THRESHOLDS = [5, 10, 15] as const; diff --git a/apps/web/lib/webhook/sample-events/discount-code-created.json b/apps/web/lib/webhook/sample-events/discount-code-created.json new file mode 100644 index 00000000000..41bcd5d9037 --- /dev/null +++ b/apps/web/lib/webhook/sample-events/discount-code-created.json @@ -0,0 +1,14 @@ +{ + "id": "dcode_1K39DGZG3MHY9RP4PD0AS2C5P", + "code": "STEVEN10OFF", + "partnerId": "pn_1K9BZE1K285BSTX4W6MPKXJFZ", + "linkId": "link_5myDHLqhIQvUmUPjchVygF9R", + "disabledAt": null, + "discount": { + "id": "disc_1KEC01MXC5H50XQMSN83VCW65", + "amount": 10, + "type": "percentage", + "maxDuration": 6, + "provider": "custom" + } +} diff --git a/apps/web/lib/webhook/sample-events/discount-code-deleted.json b/apps/web/lib/webhook/sample-events/discount-code-deleted.json new file mode 100644 index 00000000000..41bcd5d9037 --- /dev/null +++ b/apps/web/lib/webhook/sample-events/discount-code-deleted.json @@ -0,0 +1,14 @@ +{ + "id": "dcode_1K39DGZG3MHY9RP4PD0AS2C5P", + "code": "STEVEN10OFF", + "partnerId": "pn_1K9BZE1K285BSTX4W6MPKXJFZ", + "linkId": "link_5myDHLqhIQvUmUPjchVygF9R", + "disabledAt": null, + "discount": { + "id": "disc_1KEC01MXC5H50XQMSN83VCW65", + "amount": 10, + "type": "percentage", + "maxDuration": 6, + "provider": "custom" + } +} diff --git a/apps/web/lib/webhook/sample-events/discount-code-updated.json b/apps/web/lib/webhook/sample-events/discount-code-updated.json new file mode 100644 index 00000000000..ee92bdf7f04 --- /dev/null +++ b/apps/web/lib/webhook/sample-events/discount-code-updated.json @@ -0,0 +1,14 @@ +{ + "id": "dcode_1K39DGZG3MHY9RP4PD0AS2C5P", + "code": "STEVEN10OFF", + "partnerId": "pn_1K9BZE1K285BSTX4W6MPKXJFZ", + "linkId": "link_5myDHLqhIQvUmUPjchVygF9R", + "disabledAt": "2025-09-01T17:34:00.000Z", + "discount": { + "id": "disc_1KEC01MXC5H50XQMSN83VCW65", + "amount": 10, + "type": "percentage", + "maxDuration": 6, + "provider": "custom" + } +} diff --git a/apps/web/lib/webhook/sample-events/payload.ts b/apps/web/lib/webhook/sample-events/payload.ts index 546af936567..6ed6fe77bac 100644 --- a/apps/web/lib/webhook/sample-events/payload.ts +++ b/apps/web/lib/webhook/sample-events/payload.ts @@ -2,6 +2,9 @@ import type { WebhookTrigger } from "@/lib/webhook/types"; import bountyCreated from "./bounty-created.json"; import bountyUpdated from "./bounty-updated.json"; import commissionCreated from "./commission-created.json"; +import discountCodeCreated from "./discount-code-created.json"; +import discountCodeDeleted from "./discount-code-deleted.json"; +import discountCodeUpdated from "./discount-code-updated.json"; import leadCreated from "./lead-created.json"; import linkClicked from "./link-clicked.json"; import linkCreated from "./link-created.json"; @@ -25,4 +28,7 @@ export const samplePayload: Record = { "bounty.created": bountyCreated, "bounty.updated": bountyUpdated, "payout.confirmed": payoutConfirmed, + "discount_code.created": discountCodeCreated, + "discount_code.updated": discountCodeUpdated, + "discount_code.deleted": discountCodeDeleted, }; diff --git a/apps/web/lib/webhook/schemas.ts b/apps/web/lib/webhook/schemas.ts index c67e5c8efca..cb19a895ab2 100644 --- a/apps/web/lib/webhook/schemas.ts +++ b/apps/web/lib/webhook/schemas.ts @@ -2,6 +2,7 @@ import * as z from "zod/v4"; import { clickEventSchema } from "../zod/schemas/clicks"; import { CommissionWebhookSchema } from "../zod/schemas/commissions"; import { CustomerSchema } from "../zod/schemas/customers"; +import { DiscountCodeWebhookSchema } from "../zod/schemas/discount"; import { linkEventSchema } from "../zod/schemas/links"; import { EnrolledPartnerSchema, @@ -168,6 +169,24 @@ export const webhookEventSchema = z id: "CommissionCreatedEvent", outputId: "CommissionCreatedEvent", }), + + z + .object({ + id: z.string(), + event: z.union([ + z.literal("discount_code.created"), + z.literal("discount_code.updated"), + z.literal("discount_code.deleted"), + ]), + createdAt: z.string(), + data: DiscountCodeWebhookSchema, + }) + .meta({ + description: + "Triggered when a discount code is created, updated, or deleted.", + id: "DiscountCodeWebhookEvent", + outputId: "DiscountCodeWebhookEvent", + }), ]) .meta({ description: "Webhook event schema", diff --git a/apps/web/lib/webhook/types.ts b/apps/web/lib/webhook/types.ts index b25707c8a03..1ff442a78de 100644 --- a/apps/web/lib/webhook/types.ts +++ b/apps/web/lib/webhook/types.ts @@ -1,5 +1,6 @@ import * as z from "zod/v4"; import { BountySchema } from "../zod/schemas/bounties"; +import { DiscountCodeWebhookSchema } from "../zod/schemas/discount"; import { CommissionWebhookSchema } from "../zod/schemas/commissions"; import { linkEventSchema } from "../zod/schemas/links"; import { EnrolledPartnerSchema } from "../zod/schemas/partners"; @@ -36,6 +37,10 @@ export type PayoutEventWebhookPayload = z.infer< typeof payoutWebhookEventSchema >; +export type DiscountCodeEventWebhookPayload = z.infer< + typeof DiscountCodeWebhookSchema +>; + export type WebhookEventPayload = | z.infer | ClickEventWebhookPayload @@ -45,4 +50,5 @@ export type WebhookEventPayload = | PartnerApplicationWebhookPayload | CommissionEventWebhookPayload | BountyEventWebhookPayload - | PayoutEventWebhookPayload; + | PayoutEventWebhookPayload + | DiscountCodeEventWebhookPayload; diff --git a/apps/web/tests/webhooks/index.test.ts b/apps/web/tests/webhooks/index.test.ts index 863522a25f9..68b94826748 100644 --- a/apps/web/tests/webhooks/index.test.ts +++ b/apps/web/tests/webhooks/index.test.ts @@ -11,6 +11,7 @@ import type { WebhookTrigger } from "@/lib/webhook/types"; import { BountySchema } from "@/lib/zod/schemas/bounties"; import { CommissionWebhookSchema } from "@/lib/zod/schemas/commissions"; import { CustomerSchema } from "@/lib/zod/schemas/customers"; +import { DiscountCodeWebhookSchema } from "@/lib/zod/schemas/discount"; import { linkEventSchema } from "@/lib/zod/schemas/links"; import { EnrolledPartnerSchema } from "@/lib/zod/schemas/partners"; import { payoutWebhookEventSchema } from "@/lib/zod/schemas/payouts"; @@ -99,6 +100,9 @@ const eventSchemas: Record = { "bounty.created": bountyWebhookEventSchemaExtended, "bounty.updated": bountyWebhookEventSchemaExtended, "payout.confirmed": payoutWebhookEventSchemaExtended, + "discount_code.created": DiscountCodeWebhookSchema, + "discount_code.updated": DiscountCodeWebhookSchema, + "discount_code.deleted": DiscountCodeWebhookSchema, }; describe("Webhooks", () => { diff --git a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx index 38725bfd01f..18ff1d54af5 100644 --- a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx +++ b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx @@ -302,7 +302,10 @@ function DiscountSheetContent({ onProviderChange(e); setUseExistingCoupon(false); setUseStripeTestCouponId(false); - if (e.target.value === DiscountProvider.shopify) { + if ( + e.target.value === DiscountProvider.shopify || + e.target.value === DiscountProvider.custom + ) { setValue("couponId", ""); setValue("couponTestId", ""); } @@ -315,6 +318,7 @@ function DiscountSheetContent({ + @@ -456,6 +460,10 @@ function DiscountSheetContent({ <> {effectiveProvider === DiscountProvider.shopify ? ( + ) : effectiveProvider === DiscountProvider.custom ? ( +
+ +
) : ( )} From ac7833fae42f3bf7fd7e20b4d1f983b428b20da8 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 14:05:42 +0530 Subject: [PATCH 04/20] Add custom discount code webhooks, update helper, and API tests --- .../discount-codes/[discountCodeId]/route.ts | 123 +---- .../web/lib/discounts/create-discount-code.ts | 26 +- .../web/lib/discounts/delete-discount-code.ts | 2 + .../lib/discounts/discount-code-webhook.ts | 28 + .../lib/discounts/discount-provider-custom.ts | 31 ++ .../web/lib/discounts/update-discount-code.ts | 110 ++++ apps/web/lib/integrations/slack/transform.ts | 50 +- apps/web/lib/zod/schemas/discount.ts | 56 +- .../api/discount-codes/discount-codes.spec.ts | 499 ++++++++++++++++++ 9 files changed, 806 insertions(+), 119 deletions(-) create mode 100644 apps/web/lib/discounts/discount-code-webhook.ts create mode 100644 apps/web/lib/discounts/discount-provider-custom.ts create mode 100644 apps/web/lib/discounts/update-discount-code.ts create mode 100644 apps/web/playwright/api/discount-codes/discount-codes.spec.ts diff --git a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts index b8dc4f672a3..bd58cb109fb 100644 --- a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts @@ -1,18 +1,15 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; -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 { getDiscountCodeOrThrow } from "@/lib/discount-codes/get-discount-code-or-throw"; import { deleteDiscountCodes } from "@/lib/discounts/delete-discount-code"; -import { sendDiscountCodeWebhook } from "@/lib/discounts/discount-code-webhook"; +import { updateDiscountCode } from "@/lib/discounts/update-discount-code"; import { prisma } from "@/lib/prisma"; import { DiscountCodeSchema, updateDiscountCodeSchema, } from "@/lib/zod/schemas/discount"; -import { APP_DOMAIN } from "@dub/utils"; -import { DiscountProvider, Prisma } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -76,111 +73,31 @@ export const PATCH = withWorkspace( programId, }); - if (discountCode.discount.provider !== DiscountProvider.custom) { - throw new DubApiError({ - code: "bad_request", - message: `This operation is only available for "custom" discount provider.`, - }); - } - const { code: newCode } = updateDiscountCodeSchema.parse( await parseRequestBody(req), ); - if (newCode !== discountCode.code) { - const duplicateByCode = await prisma.discountCode.findUnique({ - where: { - programId_code: { - programId, - code: newCode, - }, - }, - include: { - partner: true, - }, - }); - - if (duplicateByCode) { - throw new DubApiError({ - code: "conflict", - message: `This discount code "${newCode}" is already in use by [${duplicateByCode.partner.email}](${APP_DOMAIN}/${workspace.slug}/program/partners/${duplicateByCode.partner.id}). Please choose a different code.`, - }); - } - } - - let updatedDiscountCode: Prisma.DiscountCodeGetPayload<{ - include: { discount: true }; - }>; - - try { - updatedDiscountCode = await prisma.discountCode.update({ - where: { - id: discountCodeId, - }, - data: { - code: newCode, - }, - include: { - discount: true, - }, - }); - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2002" - ) { - const duplicateByCode = await prisma.discountCode.findUnique({ - where: { - programId_code: { - programId, - code: newCode, - }, - }, - include: { - partner: { - select: { - email: true, - id: true, - }, - }, - }, - }); - - throw new DubApiError({ - code: "conflict", - message: duplicateByCode - ? `This discount code "${newCode}" is already in use by [${duplicateByCode.partner.email}](${APP_DOMAIN}/${workspace.slug}/program/partners/${duplicateByCode.partner.id}). Please choose a different code.` - : `This discount code "${newCode}" is already in use. Please choose a different code.`, - }); - } - - throw error; - } + const updatedDiscountCode = await updateDiscountCode({ + workspace, + discountCode, + newCode, + }); waitUntil( - Promise.allSettled([ - recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "discount_code.updated", - description: `Discount code (${updatedDiscountCode.code}) updated`, - actor: session.user, - targets: [ - { - type: "discount_code", - id: updatedDiscountCode.id, - metadata: updatedDiscountCode, - }, - ], - }), - - sendDiscountCodeWebhook({ - trigger: "discount_code.updated", - workspaceId: workspace.id, - programId, - data: updatedDiscountCode, - }), - ]), + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "discount_code.updated", + description: `Discount code (${updatedDiscountCode.code}) updated`, + actor: session.user, + targets: [ + { + type: "discount_code", + id: updatedDiscountCode.id, + metadata: updatedDiscountCode, + }, + ], + }), ); return NextResponse.json(DiscountCodeSchema.parse(updatedDiscountCode)); diff --git a/apps/web/lib/discounts/create-discount-code.ts b/apps/web/lib/discounts/create-discount-code.ts index d6d5b02c014..6a3e2b007e1 100644 --- a/apps/web/lib/discounts/create-discount-code.ts +++ b/apps/web/lib/discounts/create-discount-code.ts @@ -2,11 +2,16 @@ import { createId } from "@/lib/api/create-id"; import { DubApiError } from "@/lib/api/errors"; import { prisma } from "@/lib/prisma"; import { Discount, Link, Partner, Prisma, Project } from "@prisma/client"; +import { waitUntil } from "@vercel/functions"; import { constructDiscountCode } from "./construct-discount-code"; +import { sendDiscountCodeWebhook } from "./discount-code-webhook"; import { getDiscountProvider } from "./discount-provider"; interface CreateDiscountCodeArgs { - workspace: Pick; + workspace: Pick< + Project, + "id" | "stripeConnectId" | "shopifyStoreId" | "webhookEnabled" + >; partner: Pick; link: Pick; discount: Discount; @@ -48,8 +53,12 @@ export async function createDiscountCode({ shouldRetry: code ? false : true, }); + let discountCode: Prisma.DiscountCodeGetPayload<{ + include: { discount: true }; + }>; + try { - return await prisma.discountCode.create({ + discountCode = await prisma.discountCode.create({ data: { id: createId({ prefix: "dcode_" }), code: externalDiscountCode.code, @@ -58,6 +67,9 @@ export async function createDiscountCode({ linkId: link.id, discountId: discount.id, }, + include: { + discount: true, + }, }); } catch (error) { try { @@ -85,4 +97,14 @@ export async function createDiscountCode({ throw error; } + + waitUntil( + sendDiscountCodeWebhook({ + trigger: "discount_code.created", + data: discountCode, + workspace, + }), + ); + + return discountCode; } diff --git a/apps/web/lib/discounts/delete-discount-code.ts b/apps/web/lib/discounts/delete-discount-code.ts index 3594084d773..72d8c5d374f 100644 --- a/apps/web/lib/discounts/delete-discount-code.ts +++ b/apps/web/lib/discounts/delete-discount-code.ts @@ -104,3 +104,5 @@ export async function enqueueDeleteDiscountCode( ); } } + +// TODO: Send webhook when a discount code is deleted diff --git a/apps/web/lib/discounts/discount-code-webhook.ts b/apps/web/lib/discounts/discount-code-webhook.ts new file mode 100644 index 00000000000..1b34e0291b6 --- /dev/null +++ b/apps/web/lib/discounts/discount-code-webhook.ts @@ -0,0 +1,28 @@ +import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; +import { DiscountCodeWebhookSchema } from "@/lib/zod/schemas/discount"; +import { DiscountProvider, Project } from "@prisma/client"; +import * as z from "zod/v4"; + +export async function sendDiscountCodeWebhook({ + trigger, + data, + workspace, +}: { + trigger: + | "discount_code.created" + | "discount_code.updated" + | "discount_code.deleted"; + data: z.infer; + workspace: Pick; +}) { + // Only send webhook for custom discount provider for now + if (data.discount?.provider !== DiscountProvider.custom) { + return; + } + + await sendWorkspaceWebhook({ + trigger, + workspace, + data: DiscountCodeWebhookSchema.parse(data), + }); +} diff --git a/apps/web/lib/discounts/discount-provider-custom.ts b/apps/web/lib/discounts/discount-provider-custom.ts new file mode 100644 index 00000000000..10a73e40c64 --- /dev/null +++ b/apps/web/lib/discounts/discount-provider-custom.ts @@ -0,0 +1,31 @@ +function createCustomDiscountProvider() { + const getCoupon = async () => { + throw new Error("Custom discount provider does not support this method."); + }; + + const createCoupon = async () => { + throw new Error("Custom discount provider does not support this method."); + }; + + const createDiscountCode = async ({ code }: { code: string }) => { + return { code }; + }; + + const disableDiscountCode = async () => { + // Dub is the source of truth; external apps disable coupons via webhook. + }; + + const assertDiscountIntegration = async () => { + // Custom discounts do not require Stripe or Shopify. + }; + + return { + getCoupon, + createCoupon, + createDiscountCode, + disableDiscountCode, + assertDiscountIntegration, + }; +} + +export const customDiscountProvider = createCustomDiscountProvider(); diff --git a/apps/web/lib/discounts/update-discount-code.ts b/apps/web/lib/discounts/update-discount-code.ts new file mode 100644 index 00000000000..b1192733b72 --- /dev/null +++ b/apps/web/lib/discounts/update-discount-code.ts @@ -0,0 +1,110 @@ +import { DubApiError } from "@/lib/api/errors"; +import { getDiscountCodeOrThrow } from "@/lib/discount-codes/get-discount-code-or-throw"; +import { prisma } from "@/lib/prisma"; +import { DiscountProvider, Prisma, Project } from "@prisma/client"; +import { waitUntil } from "@vercel/functions"; +import { sendDiscountCodeWebhook } from "./discount-code-webhook"; + +interface UpdateDiscountCodeArgs { + workspace: Pick; + discountCode: Awaited>; + newCode: string; +} + +export async function updateDiscountCode({ + workspace, + discountCode, + newCode, +}: UpdateDiscountCodeArgs) { + if (discountCode.discount.provider !== DiscountProvider.custom) { + throw new DubApiError({ + code: "bad_request", + message: `This operation is only available for "custom" discount provider.`, + }); + } + + if (newCode !== discountCode.code) { + await assertDiscountCodeAvailable({ + programId: discountCode.programId, + code: newCode, + }); + } + + let updatedDiscountCode: Prisma.DiscountCodeGetPayload<{ + include: { discount: true }; + }>; + + try { + updatedDiscountCode = await prisma.discountCode.update({ + where: { + id: discountCode.id, + }, + data: { + code: newCode, + }, + include: { + discount: true, + }, + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + await assertDiscountCodeAvailable({ + programId: discountCode.programId, + code: newCode, + }); + + throw new DubApiError({ + code: "conflict", + message: `This discount code "${newCode}" is already in use. Please choose a different code.`, + }); + } + + throw error; + } + + waitUntil( + sendDiscountCodeWebhook({ + trigger: "discount_code.updated", + data: updatedDiscountCode, + workspace, + }), + ); + + return updatedDiscountCode; +} + +async function assertDiscountCodeAvailable({ + programId, + code, +}: { + programId: string; + code: string; +}) { + const duplicateByCode = await prisma.discountCode.findUnique({ + where: { + programId_code: { + programId, + code, + }, + }, + select: { + partner: { + select: { + email: true, + }, + }, + }, + }); + + if (!duplicateByCode) { + return; + } + + throw new DubApiError({ + code: "conflict", + message: `This discount code "${code}" is already in use by partner "${duplicateByCode.partner.email}". Please choose a different code.`, + }); +} diff --git a/apps/web/lib/integrations/slack/transform.ts b/apps/web/lib/integrations/slack/transform.ts index f3288b5255e..921901ac843 100644 --- a/apps/web/lib/integrations/slack/transform.ts +++ b/apps/web/lib/integrations/slack/transform.ts @@ -10,6 +10,7 @@ import { BountyEventWebhookPayload, ClickEventWebhookPayload, CommissionEventWebhookPayload, + DiscountCodeEventWebhookPayload, LeadEventWebhookPayload, PartnerApplicationWebhookPayload, PartnerEventWebhookPayload, @@ -596,6 +597,45 @@ const payoutConfirmedTemplate = ({ }; }; +const discountCodeTemplates = ({ + data, + event, +}: { + data: DiscountCodeEventWebhookPayload; + event: WebhookTrigger; +}) => { + const eventMessages = { + "discount_code.created": "*Discount code created* :ticket:", + "discount_code.updated": "*Discount code updated* :ticket:", + "discount_code.deleted": "*Discount code deleted* :ticket:", + }; + + return { + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: eventMessages[event as keyof typeof eventMessages], + }, + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Code*\n${data.code}`, + }, + { + type: "mrkdwn", + text: `*Partner ID*\n${data.partnerId}`, + }, + ], + }, + ], + }; +}; + const slackTemplates: Record = { "link.created": linkTemplates, "link.updated": linkTemplates, @@ -609,6 +649,9 @@ const slackTemplates: Record = { "bounty.created": bountyTemplates, "bounty.updated": bountyTemplates, "payout.confirmed": payoutConfirmedTemplate, + "discount_code.created": discountCodeTemplates, + "discount_code.updated": discountCodeTemplates, + "discount_code.deleted": discountCodeTemplates, }; export const formatEventForSlack = ( @@ -625,9 +668,14 @@ export const formatEventForSlack = ( event, ); const isBountyEvent = ["bounty.created", "bounty.updated"].includes(event); + const isDiscountCodeEvent = [ + "discount_code.created", + "discount_code.updated", + "discount_code.deleted", + ].includes(event); return template({ data, - ...((isLinkEvent || isBountyEvent) && { event }), + ...((isLinkEvent || isBountyEvent || isDiscountCodeEvent) && { event }), }); }; diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index 0894da4c82d..d9541bb38f8 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -33,7 +33,7 @@ export const createDiscountSchema = z.object({ amount: z.number().min(0), type: z.enum(RewardStructure).default("flat"), maxDuration: maxDurationSchema, - couponId: z.string(), + couponId: z.string().optional(), couponTestId: z.string().nullish(), groupId: z.string(), autoProvision: z.boolean().optional(), @@ -90,17 +90,19 @@ export const DiscountCodeSchema = z title: "DiscountCode", }); +const discountCodeValueSchema = z + .string() + .trim() + .max(100, "Code must be 100 characters or fewer.") + .regex( + /^[a-zA-Z0-9\-_]+$/, + "Code can only contain letters, numbers, dashes, and underscores.", + ); + export const createDiscountCodeSchema = z.object({ code: z.preprocess( (val) => (typeof val === "string" && val.trim() === "" ? undefined : val), - z - .string() - .trim() - .max(100, "Code must be 100 characters or fewer.") - .regex( - /^[a-zA-Z0-9\-_]+$/, - "Code can only contain letters, numbers, dashes, and underscores.", - ) + discountCodeValueSchema .optional() .describe( "The discount code to create. If omitted, a unique code will be generated automatically from the partner's name.", @@ -116,8 +118,36 @@ export const createDiscountCodeSchema = z.object({ ), }); -export const getDiscountCodesQuerySchema = z.object({ - partnerId: z - .string() - .describe("The ID of the partner to retrieve discount codes for."), +export const updateDiscountCodeSchema = z.object({ + code: discountCodeValueSchema.describe( + "The updated discount code. Only available for custom discount providers.", + ), +}); + +export const getDiscountCodesQuerySchema = z + .object({ + partnerId: z + .string() + .optional() + .describe( + "The ID of the partner to retrieve discount codes for. If omitted, returns discount codes for the whole program.", + ), + discountId: z + .string() + .optional() + .describe("Filter discount codes by discount ID."), + }) + .extend(getPaginationQuerySchema({ pageSize: 100 })); + +// Schema for the discount code webhook +export const DiscountCodeWebhookSchema = DiscountCodeSchema.omit({ + discountId: true, +}).extend({ + discount: DiscountSchema.pick({ + id: true, + amount: true, + type: true, + maxDuration: true, + provider: true, + }).nullable(), }); diff --git a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts new file mode 100644 index 00000000000..050f9dbb5d0 --- /dev/null +++ b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts @@ -0,0 +1,499 @@ +import { createId } from "@/lib/api/create-id"; +import { conn } from "@/lib/planetscale"; +import { prisma } from "@/lib/prisma"; +import type { EnrolledPartnerProps } from "@/lib/types"; +import { DiscountCodeSchema } from "@/lib/zod/schemas/discount"; +import { DEFAULT_ADDITIONAL_PARTNER_LINKS } from "@/lib/zod/schemas/groups"; +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { DiscountProvider, RewardStructure } from "@prisma/client"; +import * as z from "zod/v4"; +import { randomName, randomPartnerEmail } from "../../utils"; +import { test, type ApiClient } from "../fixtures"; +import { TEST_WORKSPACE } from "../setup-test-workspace"; + +type DiscountCode = z.infer; + +test.describe.configure({ + mode: "parallel", +}); + +const customDiscount = { + amount: 10, + type: RewardStructure.percentage, + maxDuration: 6, + provider: DiscountProvider.custom, +}; + +const expectedDiscountShape = { + id: expect.any(String), + ...customDiscount, +}; + +let customDiscountId: string | undefined; +let partnerGroupId: string | undefined; + +test.beforeAll(async ({ program }) => { + const discount = await prisma.discount.create({ + data: { + id: createId({ prefix: "disc_" }), + programId: program.id, + ...customDiscount, + }, + }); + + const group = await prisma.partnerGroup.create({ + data: { + id: createId({ prefix: "grp_" }), + programId: program.id, + slug: `pw-dcode-${nanoid(8).toLowerCase()}`, + name: "Playwright Discount Codes", + maxPartnerLinks: DEFAULT_ADDITIONAL_PARTNER_LINKS, + discountId: discount.id, + }, + }); + + await prisma.partnerGroupDefaultLink.create({ + data: { + id: createId({ prefix: "pgdl_" }), + programId: program.id, + groupId: group.id, + domain: TEST_WORKSPACE.program.domain, + url: TEST_WORKSPACE.program.url, + }, + }); + + partnerGroupId = group.id; + customDiscountId = discount.id; +}); + +test.afterAll(async () => { + if (partnerGroupId) { + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + groupId: partnerGroupId, + }, + select: { + partnerId: true, + }, + }); + + for (const enrollment of programEnrollments) { + await deletePartner(enrollment.partnerId); + } + + await prisma.partnerGroupDefaultLink.deleteMany({ + where: { + groupId: partnerGroupId, + }, + }); + + await prisma.partnerGroup.delete({ + where: { + id: partnerGroupId, + }, + }); + } + + if (customDiscountId) { + await prisma.discountCode.deleteMany({ + where: { + discountId: customDiscountId, + }, + }); + + await prisma.programEnrollment.updateMany({ + where: { + discountId: customDiscountId, + }, + data: { + discountId: null, + }, + }); + + await prisma.discount.delete({ + where: { + id: customDiscountId, + }, + }); + } +}); + +async function createPartner(api: ApiClient) { + if (!partnerGroupId) { + throw new Error("Custom discount group was not seeded."); + } + + return api.post("/api/partners", { + name: randomName(), + email: randomPartnerEmail(), + groupId: partnerGroupId, + }); +} + +async function deletePartner(partnerId: string | undefined) { + if (!partnerId) return; + + await prisma.discountCode.deleteMany({ + where: { + partnerId, + }, + }); + + await prisma.link.deleteMany({ + where: { + partnerId, + }, + }); + + await prisma.programEnrollment.deleteMany({ + where: { + partnerId, + }, + }); + + await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partnerId]); +} + +async function createDiscountCode( + api: ApiClient, + overrides: Record = {}, +) { + const { data: partner } = await createPartner(api); + const linkId = partner.links?.[0]?.id; + + if (!linkId) { + throw new Error("Partner was created without a default link."); + } + + const body = { + partnerId: partner.id, + linkId, + code: `PW${nanoid(8)}`, + ...overrides, + }; + + const response = await api.post("/api/discount-codes", body); + + return { partner, linkId, body, ...response }; +} + +test("POST /discount-codes", async ({ api }) => { + let partnerId: string | undefined; + + try { + const { status, data, partner, body } = await createDiscountCode(api); + partnerId = partner.id; + + expect(status).toEqual(200); + expect(DiscountCodeSchema.parse(data)).toMatchObject({ + id: expect.any(String), + code: body.code, + partnerId: partner.id, + linkId: body.linkId, + disabledAt: null, + discount: expectedDiscountShape, + }); + } finally { + await deletePartner(partnerId); + } +}); + +test("POST /discount-codes – omits code and auto-generates", async ({ + api, +}) => { + let partnerId: string | undefined; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + const linkId = partner.links?.[0]?.id; + + const { status, data } = await api.post( + "/api/discount-codes", + { + partnerId: partner.id, + linkId, + }, + ); + + expect(status).toEqual(200); + expect(data.code).toEqual(expect.any(String)); + expect(data.code.length).toBeGreaterThan(0); + expect(data.partnerId).toEqual(partner.id); + } finally { + await deletePartner(partnerId); + } +}); + +test("POST /discount-codes – same link", async ({ api }) => { + let partnerId: string | undefined; + + try { + const created = await createDiscountCode(api); + partnerId = created.partner.id; + + const { status, data } = await api.post("/api/discount-codes", { + partnerId: created.partner.id, + linkId: created.linkId, + code: `PW${nanoid(8)}`, + }); + + expect(status).toEqual(400); + expect(data).toEqual({ + error: { + code: "bad_request", + message: `This link already has a discount code (${created.data.code}) assigned.`, + doc_url: "https://dub.co/docs/api-reference/errors#bad-request", + }, + }); + } finally { + await deletePartner(partnerId); + } +}); + +test("POST /discount-codes – duplicate code", async ({ api }) => { + let partnerIdA: string | undefined; + let partnerIdB: string | undefined; + + try { + const first = await createDiscountCode(api); + partnerIdA = first.partner.id; + + const { data: partnerB } = await createPartner(api); + partnerIdB = partnerB.id; + + const { status, data } = await api.post("/api/discount-codes", { + partnerId: partnerB.id, + linkId: partnerB.links?.[0]?.id, + code: first.body.code, + }); + + expect(status).toEqual(409); + expect(data).toMatchObject({ + error: { + code: "conflict", + message: expect.stringContaining( + `This discount code "${first.body.code}" is already in use`, + ), + doc_url: "https://dub.co/docs/api-reference/errors#conflict", + }, + }); + } finally { + await deletePartner(partnerIdA); + await deletePartner(partnerIdB); + } +}); + +const invalidCodeCases = [ + { + name: "POST /discount-codes – invalid characters", + code: "NOT VALID!", + message: + "invalid_format: code: Code can only contain letters, numbers, dashes, and underscores.", + }, + { + name: "POST /discount-codes – too long", + code: "A".repeat(101), + message: "too_big: code: Code must be 100 characters or fewer.", + }, +]; + +for (const { name, code, message } of invalidCodeCases) { + test(name, async ({ api }) => { + expect( + await api.post("/api/discount-codes", { + partnerId: "pn_x", + linkId: "link_x", + code, + }), + ).toEqual({ + status: 422, + data: { + error: { + code: "unprocessable_entity", + message, + doc_url: + "https://dub.co/docs/api-reference/errors#unprocessable-entity", + }, + }, + }); + }); +} + +test("POST /discount-codes – missing partnerId", async ({ api }) => { + expect( + await api.post("/api/discount-codes", { + linkId: "link_missing", + code: `PW${nanoid(8)}`, + }), + ).toEqual({ + status: 422, + data: { + error: { + code: "unprocessable_entity", + message: + "invalid_type: partnerId: Invalid input: expected string, received undefined", + doc_url: + "https://dub.co/docs/api-reference/errors#unprocessable-entity", + }, + }, + }); +}); + +test("GET /discount-codes – by partnerId", async ({ api }) => { + let partnerId: string | undefined; + + try { + const created = await createDiscountCode(api); + partnerId = created.partner.id; + + const { status, data } = await api.get( + `/api/discount-codes?partnerId=${partnerId}`, + ); + + expect(status).toEqual(200); + expect(data).toEqual([created.data]); + } finally { + await deletePartner(partnerId); + } +}); + +test("GET /discount-codes – by discountId", async ({ api }) => { + let partnerId: string | undefined; + + try { + const created = await createDiscountCode(api); + partnerId = created.partner.id; + + const { status, data } = await api.get( + `/api/discount-codes?discountId=${created.data.discountId}&partnerId=${partnerId}`, + ); + + expect(status).toEqual(200); + expect(data.map((code) => code.id)).toContain(created.data.id); + } finally { + await deletePartner(partnerId); + } +}); + +test("GET /discount-codes – pagination", async ({ api }) => { + let partnerIdA: string | undefined; + let partnerIdB: string | undefined; + + try { + const first = await createDiscountCode(api); + const second = await createDiscountCode(api); + partnerIdA = first.partner.id; + partnerIdB = second.partner.id; + + const { status, data } = await api.get( + "/api/discount-codes?pageSize=1&page=1", + ); + + expect(status).toEqual(200); + expect(data).toHaveLength(1); + } finally { + await deletePartner(partnerIdA); + await deletePartner(partnerIdB); + } +}); + +test("GET /discount-codes – unknown partner", async ({ api, program }) => { + expect( + await api.get("/api/discount-codes?partnerId=pn_does_not_exist"), + ).toEqual({ + status: 404, + data: { + error: { + code: "not_found", + message: `Partner pn_does_not_exist is not enrolled in program ${program.id}.`, + doc_url: "https://dub.co/docs/api-reference/errors#not-found", + }, + }, + }); +}); + +test("PATCH /discount-codes/{id}", async ({ api }) => { + let partnerId: string | undefined; + + try { + const created = await createDiscountCode(api); + partnerId = created.partner.id; + const nextCode = `PW${nanoid(8)}`; + + const { status, data } = await api.patch( + `/api/discount-codes/${created.data.id}`, + { code: nextCode }, + ); + + expect(status).toEqual(200); + expect(data).toEqual({ + ...created.data, + code: nextCode, + }); + } finally { + await deletePartner(partnerId); + } +}); + +test("PATCH /discount-codes/{id} – invalid code", async ({ api }) => { + let partnerId: string | undefined; + + try { + const created = await createDiscountCode(api); + partnerId = created.partner.id; + + expect( + await api.patch(`/api/discount-codes/${created.data.id}`, { + code: "not valid!", + }), + ).toEqual({ + status: 422, + data: { + error: { + code: "unprocessable_entity", + message: + "invalid_format: code: Code can only contain letters, numbers, dashes, and underscores.", + doc_url: + "https://dub.co/docs/api-reference/errors#unprocessable-entity", + }, + }, + }); + } finally { + await deletePartner(partnerId); + } +}); + +test("DELETE /discount-codes/{id}", async ({ api }) => { + let partnerId: string | undefined; + + try { + const created = await createDiscountCode(api); + partnerId = created.partner.id; + + const { status, data } = await api.delete<{ id: string }>( + `/api/discount-codes/${created.data.id}`, + ); + + expect(status).toEqual(200); + expect(data).toEqual({ id: created.data.id }); + } finally { + await deletePartner(partnerId); + } +}); + +test("DELETE /discount-codes/{id} – not found", async ({ api }) => { + const { status, data } = await api.delete( + "/api/discount-codes/dcode_does_not_exist", + ); + + expect(status).toEqual(404); + expect(data).toEqual({ + error: { + code: "not_found", + message: "Discount code (dcode_does_not_exist) not found.", + doc_url: "https://dub.co/docs/api-reference/errors#not-found", + }, + }); +}); From f86ca8a6e269d003294f47c550af69bcb6eaab2a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 15:21:48 +0530 Subject: [PATCH 05/20] Pass workspace into partner discount generation and add custom discount tests --- apps/web/app/(ee)/api/discount-codes/route.ts | 27 +- .../api/workflows/partner-approved/route.ts | 18 +- .../actions/partners/accept-program-invite.ts | 4 +- .../web/lib/discounts/create-discount-code.ts | 12 +- .../generate-discount-code-for-partner.ts | 19 +- .../api/discount-codes/discount-codes.spec.ts | 9 +- .../api/discounts/discounts.spec.ts | 380 ++++++++++++++++++ .../ui/modals/delete-discount-code-modal.tsx | 15 +- 8 files changed, 442 insertions(+), 42 deletions(-) create mode 100644 apps/web/playwright/api/discounts/discounts.spec.ts diff --git a/apps/web/app/(ee)/api/discount-codes/route.ts b/apps/web/app/(ee)/api/discount-codes/route.ts index 69e561a1b57..23c1ad95f9f 100644 --- a/apps/web/app/(ee)/api/discount-codes/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/route.ts @@ -49,9 +49,6 @@ export const GET = withWorkspace( ...(partnerId && { partnerId }), ...(discountId && { discountId }), }, - include: { - discount: true, - }, orderBy: { createdAt: "desc", }, @@ -119,6 +116,18 @@ export const POST = withWorkspace( }); } + // A link can have only one discount code + const duplicateByLink = programEnrollment.discountCodes.find( + (discountCode) => discountCode.linkId === linkId, + ); + + if (duplicateByLink) { + throw new DubApiError({ + code: "bad_request", + message: `This link already has a discount code (${duplicateByLink.code}) assigned.`, + }); + } + // Check for duplicate by code if (code) { const duplicateByCode = await prisma.discountCode.findUnique({ @@ -141,18 +150,6 @@ export const POST = withWorkspace( } } - // A link can have only one discount code - const duplicateByLink = programEnrollment.discountCodes.find( - (discountCode) => discountCode.linkId === linkId, - ); - - if (duplicateByLink) { - throw new DubApiError({ - code: "bad_request", - message: `This link already has a discount code (${duplicateByLink.code}) assigned.`, - }); - } - const discountCode = await createDiscountCode({ workspace, partner: programEnrollment.partner, diff --git a/apps/web/app/(ee)/api/workflows/partner-approved/route.ts b/apps/web/app/(ee)/api/workflows/partner-approved/route.ts index 599f02aed11..746ad264471 100644 --- a/apps/web/app/(ee)/api/workflows/partner-approved/route.ts +++ b/apps/web/app/(ee)/api/workflows/partner-approved/route.ts @@ -126,6 +126,9 @@ export const { POST } = serve( select: { id: true, plan: true, + webhookEnabled: true, + stripeConnectId: true, + shopifyStoreId: true, }, }); @@ -172,8 +175,21 @@ export const { POST } = serve( // Step 2: Auto-provision discount code if enabled await context.run("create-discount-codes", async () => { + const workspace = await prisma.project.findUniqueOrThrow({ + where: { + id: program.workspaceId, + }, + select: { + id: true, + plan: true, + webhookEnabled: true, + stripeConnectId: true, + shopifyStoreId: true, + }, + }); + await generateDiscountCodeForPartner({ - workspaceId: program.workspaceId, + workspace, partner: { id: partner.id, name: partner.name, diff --git a/apps/web/lib/actions/partners/accept-program-invite.ts b/apps/web/lib/actions/partners/accept-program-invite.ts index 48d54bf0731..4d38f177d6b 100644 --- a/apps/web/lib/actions/partners/accept-program-invite.ts +++ b/apps/web/lib/actions/partners/accept-program-invite.ts @@ -54,6 +54,8 @@ export const acceptProgramInviteAction = authPartnerActionClient select: { id: true, webhookEnabled: true, + stripeConnectId: true, + shopifyStoreId: true, }, }); @@ -72,7 +74,7 @@ export const acceptProgramInviteAction = authPartnerActionClient await Promise.allSettled([ // 1. Generate discount code for partner (if enabled) generateDiscountCodeForPartner({ - workspaceId: workspace.id, + workspace, partner: enrolledPartner, }), // 2. Send "partner.enrolled" webhook to workspace diff --git a/apps/web/lib/discounts/create-discount-code.ts b/apps/web/lib/discounts/create-discount-code.ts index 6a3e2b007e1..e30fda7a331 100644 --- a/apps/web/lib/discounts/create-discount-code.ts +++ b/apps/web/lib/discounts/create-discount-code.ts @@ -33,8 +33,16 @@ export async function createDiscountCode({ }); const linkWithCode = await prisma.link.findUnique({ - where: { id: link.id }, - select: { discountCode: { select: { code: true } } }, + where: { + id: link.id, + }, + select: { + discountCode: { + select: { + code: true, + }, + }, + }, }); if (linkWithCode?.discountCode) { diff --git a/apps/web/lib/discounts/generate-discount-code-for-partner.ts b/apps/web/lib/discounts/generate-discount-code-for-partner.ts index 8df75a78592..711e79f06ce 100644 --- a/apps/web/lib/discounts/generate-discount-code-for-partner.ts +++ b/apps/web/lib/discounts/generate-discount-code-for-partner.ts @@ -1,12 +1,16 @@ import { prisma } from "@/lib/prisma"; import { EnrolledPartnerProps } from "@/lib/types"; +import { Project } from "@prisma/client"; import { createDiscountCode } from "./create-discount-code"; export async function generateDiscountCodeForPartner({ - workspaceId, + workspace, partner, }: { - workspaceId: string; + workspace: Pick< + Project, + "id" | "webhookEnabled" | "stripeConnectId" | "shopifyStoreId" + >; partner: Pick; }) { if (!partner.groupId) { @@ -32,17 +36,6 @@ export async function generateDiscountCodeForPartner({ return; } - const workspace = await prisma.project.findUniqueOrThrow({ - where: { - id: workspaceId, - }, - select: { - id: true, - stripeConnectId: true, - shopifyStoreId: true, - }, - }); - const partnerDefaultLink = await prisma.link.findFirst({ where: { programId: group.programId, diff --git a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts index 050f9dbb5d0..4aa17ec72f7 100644 --- a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts +++ b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts @@ -25,11 +25,6 @@ const customDiscount = { provider: DiscountProvider.custom, }; -const expectedDiscountShape = { - id: expect.any(String), - ...customDiscount, -}; - let customDiscountId: string | undefined; let partnerGroupId: string | undefined; @@ -186,13 +181,13 @@ test("POST /discount-codes", async ({ api }) => { partnerId = partner.id; expect(status).toEqual(200); - expect(DiscountCodeSchema.parse(data)).toMatchObject({ + expect(data).toEqual({ id: expect.any(String), code: body.code, + discountId: customDiscountId, partnerId: partner.id, linkId: body.linkId, disabledAt: null, - discount: expectedDiscountShape, }); } finally { await deletePartner(partnerId); diff --git a/apps/web/playwright/api/discounts/discounts.spec.ts b/apps/web/playwright/api/discounts/discounts.spec.ts new file mode 100644 index 00000000000..502caf8ef08 --- /dev/null +++ b/apps/web/playwright/api/discounts/discounts.spec.ts @@ -0,0 +1,380 @@ +import { createId } from "@/lib/api/create-id"; +import { conn } from "@/lib/planetscale"; +import { prisma } from "@/lib/prisma"; +import type { + Customer, + CustomerEnriched, + DiscountProps, + EnrolledPartnerProps, + GroupProps, +} from "@/lib/types"; +import { DEFAULT_ADDITIONAL_PARTNER_LINKS } from "@/lib/zod/schemas/groups"; +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { DiscountProvider, RewardStructure } from "@prisma/client"; +import { randomCustomer, randomName, randomPartnerEmail } from "../../utils"; +import { test, type ApiClient } from "../fixtures"; +import { TEST_WORKSPACE } from "../setup-test-workspace"; + +test.describe.configure({ + mode: "parallel", +}); + +const customDiscount = { + amount: 10, + type: RewardStructure.percentage, + maxDuration: 6, + provider: DiscountProvider.custom, +}; + +const expectedCustomDiscount = { + ...customDiscount, + couponId: null, + couponTestId: null, + description: null, + autoProvisionEnabledAt: null, +}; + +const expectedCustomerDiscount = { + id: expect.any(String), + amount: customDiscount.amount, + type: customDiscount.type, + maxDuration: customDiscount.maxDuration, + couponId: null, + couponTestId: null, + description: null, +}; + +let customDiscountId: string | undefined; +let partnerGroupId: string | undefined; + +test.beforeAll(async ({ program }) => { + const discount = await prisma.discount.create({ + data: { + id: createId({ prefix: "disc_" }), + programId: program.id, + ...customDiscount, + }, + }); + + const group = await prisma.partnerGroup.create({ + data: { + id: createId({ prefix: "grp_" }), + programId: program.id, + slug: `pw-disc-${nanoid(8).toLowerCase()}`, + name: "Playwright Custom Discounts", + maxPartnerLinks: DEFAULT_ADDITIONAL_PARTNER_LINKS, + discountId: discount.id, + }, + }); + + await prisma.partnerGroupDefaultLink.create({ + data: { + id: createId({ prefix: "pgdl_" }), + programId: program.id, + groupId: group.id, + domain: TEST_WORKSPACE.program.domain, + url: TEST_WORKSPACE.program.url, + }, + }); + + partnerGroupId = group.id; + customDiscountId = discount.id; +}); + +test.afterAll(async () => { + if (partnerGroupId) { + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + groupId: partnerGroupId, + }, + select: { + partnerId: true, + }, + }); + + for (const enrollment of programEnrollments) { + await deletePartner(enrollment.partnerId); + } + + await prisma.partnerGroupDefaultLink.deleteMany({ + where: { + groupId: partnerGroupId, + }, + }); + + await prisma.partnerGroup.delete({ + where: { + id: partnerGroupId, + }, + }); + } + + if (customDiscountId) { + await prisma.discountCode.deleteMany({ + where: { + discountId: customDiscountId, + }, + }); + + await prisma.programEnrollment.updateMany({ + where: { + discountId: customDiscountId, + }, + data: { + discountId: null, + }, + }); + + await prisma.discount.delete({ + where: { + id: customDiscountId, + }, + }); + } +}); + +async function createPartner(api: ApiClient) { + if (!partnerGroupId) { + throw new Error("Custom discount group was not seeded."); + } + + return api.post("/api/partners", { + name: randomName(), + email: randomPartnerEmail(), + groupId: partnerGroupId, + }); +} + +async function deletePartner(partnerId: string | undefined) { + if (!partnerId) return; + + await prisma.discountCode.deleteMany({ + where: { + partnerId, + }, + }); + + await prisma.link.deleteMany({ + where: { + partnerId, + }, + }); + + await prisma.programEnrollment.deleteMany({ + where: { + partnerId, + }, + }); + + await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partnerId]); +} + +async function createCustomerWithCustomDiscount({ + api, + program, +}: { + api: ApiClient; + program: { id: string }; +}) { + const { data: partner } = await createPartner(api); + const linkId = partner.links?.[0]?.id; + + if (!linkId) { + throw new Error("Partner was created without a default link."); + } + + const { data: customer } = await api.post( + "/api/customers", + randomCustomer(), + ); + + await prisma.customer.update({ + where: { + id: customer.id, + }, + data: { + linkId, + partnerId: partner.id, + programId: program.id, + }, + }); + + return { partner, customer }; +} + +async function deleteCustomer(api: ApiClient, id: string | undefined) { + if (!id) return; + await api.delete(`/api/customers/${id}`); +} + +test("GET /programs/{programId}/discounts – custom provider", async ({ + api, + program, +}) => { + const { status, data } = await api.get( + `/api/programs/${program.id}/discounts`, + ); + + expect(status).toEqual(200); + + const discount = data.find((item) => item.id === customDiscountId); + + expect(discount).toEqual({ + id: customDiscountId, + ...expectedCustomDiscount, + partnersCount: expect.any(Number), + }); +}); + +test("GET /groups/{id} – nested custom discount", async ({ api }) => { + const { status, data } = await api.get( + `/api/groups/${partnerGroupId}`, + ); + + expect(status).toEqual(200); + expect(data.discount).toEqual({ + id: customDiscountId, + ...expectedCustomDiscount, + }); +}); + +test("GET /groups/{id} – group without discount", async ({ api, program }) => { + let groupId: string | undefined; + + try { + const group = await prisma.partnerGroup.create({ + data: { + id: createId({ prefix: "grp_" }), + programId: program.id, + slug: `pw-nodisc-${nanoid(8).toLowerCase()}`, + name: randomName("group"), + maxPartnerLinks: DEFAULT_ADDITIONAL_PARTNER_LINKS, + }, + }); + groupId = group.id; + + const { status, data } = await api.get( + `/api/groups/${groupId}`, + ); + + expect(status).toEqual(200); + expect(data.discount).toBeNull(); + } finally { + if (groupId) { + await prisma.partnerGroup.delete({ + where: { + id: groupId, + }, + }); + } + } +}); + +test("GET /partners/{id} – custom discount", async ({ api }) => { + let partnerId: string | undefined; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + + const { status, data } = await api.get< + EnrolledPartnerProps & { + discount: Pick | null; + } + >(`/api/partners/${partnerId}`); + + expect(status).toEqual(200); + expect(data.discount).toEqual({ + id: customDiscountId, + provider: DiscountProvider.custom, + }); + } finally { + await deletePartner(partnerId); + } +}); + +test("GET /customers/{id} – custom discount", async ({ api, program }) => { + let partnerId: string | undefined; + let customerId: string | undefined; + + try { + const { partner, customer } = await createCustomerWithCustomDiscount({ + api, + program, + }); + partnerId = partner.id; + customerId = customer.id; + + const { status, data } = await api.get( + `/api/customers/${customerId}?includeExpandedFields=true`, + ); + + expect(status).toEqual(200); + expect(data.discount).toMatchObject({ + ...expectedCustomerDiscount, + id: customDiscountId, + }); + expect(data.discount).not.toHaveProperty("provider"); + } finally { + await deleteCustomer(api, customerId); + await deletePartner(partnerId); + } +}); + +test("GET /customers?email= – custom discount", async ({ api, program }) => { + let partnerId: string | undefined; + let customerId: string | undefined; + + try { + const { partner, customer } = await createCustomerWithCustomDiscount({ + api, + program, + }); + partnerId = partner.id; + customerId = customer.id; + + const { status, data: customers } = await api.get( + `/api/customers?email=${encodeURIComponent(customer.email!)}&includeExpandedFields=true`, + ); + + expect(status).toEqual(200); + expect(customers[0].discount).toMatchObject({ + ...expectedCustomerDiscount, + id: customDiscountId, + }); + } finally { + await deleteCustomer(api, customerId); + await deletePartner(partnerId); + } +}); + +test("GET /customers?externalId= – custom discount", async ({ + api, + program, +}) => { + let partnerId: string | undefined; + let customerId: string | undefined; + + try { + const { partner, customer } = await createCustomerWithCustomDiscount({ + api, + program, + }); + partnerId = partner.id; + customerId = customer.id; + + const { status, data: customers } = await api.get( + `/api/customers?externalId=${encodeURIComponent(customer.externalId!)}&includeExpandedFields=true`, + ); + + expect(status).toEqual(200); + expect(customers[0].discount).toMatchObject({ + ...expectedCustomerDiscount, + id: customDiscountId, + }); + } finally { + await deleteCustomer(api, customerId); + await deletePartner(partnerId); + } +}); diff --git a/apps/web/ui/modals/delete-discount-code-modal.tsx b/apps/web/ui/modals/delete-discount-code-modal.tsx index 0406cb06054..2b452cac974 100644 --- a/apps/web/ui/modals/delete-discount-code-modal.tsx +++ b/apps/web/ui/modals/delete-discount-code-modal.tsx @@ -3,7 +3,7 @@ import { useApiMutation } from "@/lib/swr/use-api-mutation"; import { DiscountCodeProps } from "@/lib/types"; import { Button, Modal, useMediaQuery } from "@dub/ui"; import { Tag } from "@dub/ui/icons"; -import { FormEvent } from "react"; +import { FormEvent, useState } from "react"; import { toast } from "sonner"; interface DeleteDiscountCodeModalProps { @@ -12,6 +12,8 @@ interface DeleteDiscountCodeModalProps { setShowModal: (showModal: boolean) => void; } +const DELETE_DISCOUNT_CODE_CONFIRMATION = "delete discount code"; + export const DeleteDiscountCodeModal = ({ discountCode, showModal, @@ -19,6 +21,7 @@ export const DeleteDiscountCodeModal = ({ }: DeleteDiscountCodeModalProps) => { const { isMobile } = useMediaQuery(); const { makeRequest: deleteDiscountCode, isSubmitting } = useApiMutation(); + const [inputValue, setInputValue] = useState(""); const onSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -62,7 +65,10 @@ export const DeleteDiscountCodeModal = ({

To verify, type{" "} - delete code below + + {DELETE_DISCOUNT_CODE_CONFIRMATION} + {" "} + below

@@ -75,7 +81,9 @@ export const DeleteDiscountCodeModal = ({ className="block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" aria-invalid="true" autoFocus={!isMobile} - pattern="delete code" + pattern={DELETE_DISCOUNT_CODE_CONFIRMATION} + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} /> @@ -96,6 +104,7 @@ export const DeleteDiscountCodeModal = ({ text="Delete discount code" variant="danger" loading={isSubmitting} + disabled={inputValue !== DELETE_DISCOUNT_CODE_CONFIRMATION} className="h-9 w-fit" /> From a0a1f75dfd81691259b89732e07f451970c945a0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 15:52:54 +0530 Subject: [PATCH 06/20] Delete index.test.ts --- apps/web/tests/discounts/index.test.ts | 42 -------------------------- 1 file changed, 42 deletions(-) delete mode 100644 apps/web/tests/discounts/index.test.ts diff --git a/apps/web/tests/discounts/index.test.ts b/apps/web/tests/discounts/index.test.ts deleted file mode 100644 index b197a93a6dd..00000000000 --- a/apps/web/tests/discounts/index.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CustomerEnriched } from "@/lib/types"; -import { E2E_CUSTOMER_WITH_DISCOUNT, E2E_DISCOUNT } from "tests/utils/resource"; -import { describe, expect, test } from "vitest"; -import { IntegrationHarness } from "../utils/integration"; - -describe("Discounts", () => { - test("/customers?email=", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - const { status, data: customers } = await http.get({ - path: `/customers?email=${E2E_CUSTOMER_WITH_DISCOUNT.email}&includeExpandedFields=true`, - }); - - expect(status).toEqual(200); - expect(customers[0].discount).toStrictEqual(E2E_DISCOUNT); - }); - - test("/customers?externalId=", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - const { status, data: customers } = await http.get({ - path: `/customers?externalId=${E2E_CUSTOMER_WITH_DISCOUNT.externalId}&includeExpandedFields=true`, - }); - - expect(status).toEqual(200); - expect(customers[0].discount).toStrictEqual(E2E_DISCOUNT); - }); - - test("/customers/:id", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - const { status, data: customer } = await http.get({ - path: `/customers/${E2E_CUSTOMER_WITH_DISCOUNT.id}?includeExpandedFields=true`, - }); - - expect(status).toEqual(200); - expect(customer.discount).toStrictEqual(E2E_DISCOUNT); - }); -}); From 9588e86aae4f1513fe2fa145fe4a2e52441724bd Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 16:29:48 +0530 Subject: [PATCH 07/20] Retry generated discount codes on unique conflicts and emit deleted webhooks. --- .../api/cron/discount-codes/create/route.ts | 9 + .../cron/groups/remap-discount-codes/route.ts | 1 + apps/web/lib/api/links/bulk-delete-links.ts | 6 +- .../web/lib/discounts/create-discount-code.ts | 167 +++++++++++++++--- .../web/lib/discounts/delete-discount-code.ts | 48 ++++- 5 files changed, 199 insertions(+), 32 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/discount-codes/create/route.ts b/apps/web/app/(ee)/api/cron/discount-codes/create/route.ts index 4888d4eafe4..444e7e29b89 100644 --- a/apps/web/app/(ee)/api/cron/discount-codes/create/route.ts +++ b/apps/web/app/(ee)/api/cron/discount-codes/create/route.ts @@ -1,3 +1,4 @@ +import { DubApiError } from "@/lib/api/errors"; import { withCron } from "@/lib/cron/with-cron"; import { createDiscountCode } from "@/lib/discounts/create-discount-code"; import { isNonRecoverableDiscountError } from "@/lib/discounts/discount-error"; @@ -44,6 +45,7 @@ export const POST = withCron(async ({ rawBody }) => { project: { select: { id: true, + webhookEnabled: true, stripeConnectId: true, shopifyStoreId: true, }, @@ -94,6 +96,13 @@ export const POST = withCron(async ({ rawBody }) => { return logAndRespond(error.message, { logLevel: "warn" }); } + if ( + error instanceof DubApiError && + (error.code === "conflict" || error.code === "bad_request") + ) { + return logAndRespond(error.message, { logLevel: "warn" }); + } + throw error; } diff --git a/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts b/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts index a49f8dd9f68..208b4097671 100644 --- a/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts @@ -149,6 +149,7 @@ export const POST = withCron(async ({ rawBody }) => { }, select: { id: true, + webhookEnabled: true, stripeConnectId: true, shopifyStoreId: true, }, diff --git a/apps/web/lib/api/links/bulk-delete-links.ts b/apps/web/lib/api/links/bulk-delete-links.ts index 1a16a84e5cf..bec5e954d15 100644 --- a/apps/web/lib/api/links/bulk-delete-links.ts +++ b/apps/web/lib/api/links/bulk-delete-links.ts @@ -79,11 +79,7 @@ async function deleteLinksBatch(links: ExpandedLink[]): Promise { }, }, include: { - discount: { - select: { - provider: true, - }, - }, + discount: true, }, }); diff --git a/apps/web/lib/discounts/create-discount-code.ts b/apps/web/lib/discounts/create-discount-code.ts index e30fda7a331..0c2801ffc0d 100644 --- a/apps/web/lib/discounts/create-discount-code.ts +++ b/apps/web/lib/discounts/create-discount-code.ts @@ -1,12 +1,15 @@ import { createId } from "@/lib/api/create-id"; import { DubApiError } from "@/lib/api/errors"; import { prisma } from "@/lib/prisma"; +import { nanoid } from "@dub/utils"; import { Discount, Link, Partner, Prisma, Project } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import { constructDiscountCode } from "./construct-discount-code"; import { sendDiscountCodeWebhook } from "./discount-code-webhook"; import { getDiscountProvider } from "./discount-provider"; +const MAX_ATTEMPTS = 3; + interface CreateDiscountCodeArgs { workspace: Pick< Project, @@ -53,23 +56,86 @@ export async function createDiscountCode({ } const discountProvider = getDiscountProvider(discount.provider); + const shouldRetry = !code; const externalDiscountCode = await discountProvider.createDiscountCode({ workspace, discount, code: finalCode, - shouldRetry: code ? false : true, + shouldRetry, }); - let discountCode: Prisma.DiscountCodeGetPayload<{ - include: { discount: true }; - }>; + let currentCode = externalDiscountCode.code; + let discountCode: DiscountCodeWithDiscount | undefined; + for (let attempt = 0; attempt < (shouldRetry ? MAX_ATTEMPTS : 1); attempt++) { + const result = await createDiscountCodeRecord({ + workspace, + partner, + link, + discount, + code: currentCode, + canRetry: shouldRetry && attempt < MAX_ATTEMPTS - 1, + discountProvider, + }); + + if (result.discountCode) { + discountCode = result.discountCode; + break; + } + + currentCode = result.nextCode; + } + + if (!discountCode) { + throw new DubApiError({ + code: "conflict", + message: + "This discount code is already in use, or this link already has a code. Please refresh and try again.", + }); + } + + waitUntil( + sendDiscountCodeWebhook({ + trigger: "discount_code.created", + data: discountCode, + workspace, + }), + ); + + return discountCode; +} + +type DiscountCodeWithDiscount = Prisma.DiscountCodeGetPayload<{ + include: { discount: true }; +}>; + +type CreateDiscountCodeRecordResult = + | { discountCode: DiscountCodeWithDiscount; nextCode?: never } + | { nextCode: string; discountCode?: never }; + +async function createDiscountCodeRecord({ + workspace, + partner, + link, + discount, + code, + canRetry, + discountProvider, +}: { + workspace: CreateDiscountCodeArgs["workspace"]; + partner: CreateDiscountCodeArgs["partner"]; + link: CreateDiscountCodeArgs["link"]; + discount: Discount; + code: string; + canRetry: boolean; + discountProvider: ReturnType; +}): Promise { try { - discountCode = await prisma.discountCode.create({ + const discountCode = await prisma.discountCode.create({ data: { id: createId({ prefix: "dcode_" }), - code: externalDiscountCode.code, + code, programId: discount.programId, partnerId: partner.id, linkId: link.id, @@ -79,23 +145,62 @@ export async function createDiscountCode({ discount: true, }, }); + + return { + discountCode, + }; } catch (error) { - try { - await discountProvider.disableDiscountCode({ + const isUniqueConflict = + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002"; + + if (isUniqueConflict && canRetry) { + const existingForLink = await prisma.discountCode.findUnique({ + where: { + linkId: link.id, + }, + select: { + code: true, + }, + }); + + if (existingForLink) { + throw new DubApiError({ + code: "bad_request", + message: `This link already has a discount code (${existingForLink.code}) assigned.`, + }); + } + + await rollbackExternalDiscountCode({ + discountProvider, workspace, - code: externalDiscountCode.code, + code, }); - } catch (rollbackError) { - console.error("Failed to rollback external discount code", { - code: externalDiscountCode.code, - rollbackError, + + const nextCode = `${code}${nanoid(2)}`; + console.warn( + `Discount code "${code}" already exists. Retrying with "${nextCode}".`, + ); + + const retried = await discountProvider.createDiscountCode({ + workspace, + discount, + code: nextCode, + shouldRetry: false, }); + + return { + nextCode: retried.code, + }; } - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2002" - ) { + await rollbackExternalDiscountCode({ + discountProvider, + workspace, + code, + }); + + if (isUniqueConflict) { throw new DubApiError({ code: "conflict", message: @@ -105,14 +210,26 @@ export async function createDiscountCode({ throw error; } +} - waitUntil( - sendDiscountCodeWebhook({ - trigger: "discount_code.created", - data: discountCode, +async function rollbackExternalDiscountCode({ + discountProvider, + workspace, + code, +}: { + discountProvider: ReturnType; + workspace: CreateDiscountCodeArgs["workspace"]; + code: string; +}) { + try { + await discountProvider.disableDiscountCode({ workspace, - }), - ); - - return discountCode; + code, + }); + } catch (rollbackError) { + console.error("Failed to rollback external discount code", { + code, + rollbackError, + }); + } } diff --git a/apps/web/lib/discounts/delete-discount-code.ts b/apps/web/lib/discounts/delete-discount-code.ts index 72d8c5d374f..a300444c35c 100644 --- a/apps/web/lib/discounts/delete-discount-code.ts +++ b/apps/web/lib/discounts/delete-discount-code.ts @@ -1,11 +1,14 @@ import { prisma } from "@/lib/prisma"; +import { DiscountCodeWebhookSchema } from "@/lib/zod/schemas/discount"; import { APP_DOMAIN_WITH_NGROK, chunk } from "@dub/utils"; import { Discount, DiscountCode } from "@prisma/client"; +import { waitUntil } from "@vercel/functions"; import { enqueueBatchJobs } from "../cron/enqueue-batch-jobs"; +import { sendDiscountCodeWebhook } from "./discount-code-webhook"; type DeleteDiscountCodesParams = Pick< DiscountCode, - "id" | "code" | "programId" + "id" | "code" | "programId" | "partnerId" | "linkId" | "disabledAt" > & { discount: Pick | null; }; @@ -68,6 +71,10 @@ export async function deleteDiscountCodes( ); } + if (!isSoftDelete) { + waitUntil(sendDiscountCodeDeletedWebhooks(discountCodes)); + } + await enqueueDeleteDiscountCode(discountCodes); } @@ -105,4 +112,41 @@ export async function enqueueDeleteDiscountCode( } } -// TODO: Send webhook when a discount code is deleted +async function sendDiscountCodeDeletedWebhooks( + discountCodes: DeleteDiscountCodesParams[], +) { + const programIds = [...new Set(discountCodes.map((dc) => dc.programId))]; + + const workspaces = await prisma.project.findMany({ + where: { + defaultProgramId: { + in: programIds, + }, + }, + select: { + id: true, + webhookEnabled: true, + defaultProgramId: true, + }, + }); + + const workspaceByProgramId = new Map( + workspaces.map((workspace) => [workspace.defaultProgramId, workspace]), + ); + + await Promise.all( + discountCodes.map((discountCode) => { + const workspace = workspaceByProgramId.get(discountCode.programId); + + if (!workspace) { + return; + } + + return sendDiscountCodeWebhook({ + trigger: "discount_code.deleted", + workspace, + data: DiscountCodeWebhookSchema.parse(discountCode), + }); + }), + ); +} From a5bf954de5f5eb16a8ce8b0bef75ae4b94d628a9 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 16:44:49 +0530 Subject: [PATCH 08/20] Send disable/delete discount-code webhooks for all providers and skip custom cleanup. --- .../cron/groups/remap-discount-codes/route.ts | 6 +-- .../app/(ee)/api/cron/partners/ban/route.ts | 6 +-- .../api/cron/partners/deactivate/route.ts | 6 +-- apps/web/lib/api/links/delete-link.ts | 35 ++++--------- .../web/lib/discounts/delete-discount-code.ts | 47 +++++++++++++---- .../lib/discounts/discount-code-webhook.ts | 7 +-- .../discount-codes/delete-discount-code.ts | 2 +- .../discount-codes/update-discount-code.ts | 3 +- .../api/discount-codes/discount-codes.spec.ts | 50 ++++++++++++++++++- 9 files changed, 102 insertions(+), 60 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts b/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts index 208b4097671..e1028fa51bf 100644 --- a/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts @@ -4,7 +4,7 @@ import { deleteDiscountCodes } from "@/lib/discounts/delete-discount-code"; import { isDiscountProviderError } from "@/lib/discounts/discount-error"; import { isDiscountEquivalent } from "@/lib/discounts/is-discount-equivalent"; import { prisma } from "@/lib/prisma"; -import { Discount, DiscountCode } from "@prisma/client"; +import { DiscountCode } from "@prisma/client"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; @@ -68,9 +68,7 @@ export const POST = withCron(async ({ rawBody }) => { // Find the discount codes to update and remove const discountCodesToUpdate: DiscountCode[] = []; - const discountCodesToRemove: (DiscountCode & { - discount: Pick | null; - })[] = []; + const discountCodesToRemove: typeof discountCodes = []; for (const discountCode of discountCodes) { const keepDiscountCode = isDiscountEquivalent( diff --git a/apps/web/app/(ee)/api/cron/partners/ban/route.ts b/apps/web/app/(ee)/api/cron/partners/ban/route.ts index 89ac41c8851..d566eaec7b7 100644 --- a/apps/web/app/(ee)/api/cron/partners/ban/route.ts +++ b/apps/web/app/(ee)/api/cron/partners/ban/route.ts @@ -40,11 +40,7 @@ export const POST = withCron(async ({ rawBody }) => { }, discountCodes: { include: { - discount: { - select: { - provider: true, - }, - }, + discount: true, }, }, }, diff --git a/apps/web/app/(ee)/api/cron/partners/deactivate/route.ts b/apps/web/app/(ee)/api/cron/partners/deactivate/route.ts index 2658f91a608..be34948bac2 100644 --- a/apps/web/app/(ee)/api/cron/partners/deactivate/route.ts +++ b/apps/web/app/(ee)/api/cron/partners/deactivate/route.ts @@ -39,11 +39,7 @@ export const POST = withCron(async ({ rawBody }) => { links: true, discountCodes: { include: { - discount: { - select: { - provider: true, - }, - }, + discount: true, }, }, }, diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index 7976a0737c8..a6509c292e6 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -1,4 +1,4 @@ -import { enqueueDeleteDiscountCode } from "@/lib/discounts/delete-discount-code"; +import { deleteDiscountCodes } from "@/lib/discounts/delete-discount-code"; import { prisma } from "@/lib/prisma"; import { storage } from "@/lib/storage"; import { recordLink } from "@/lib/tinybird"; @@ -19,34 +19,21 @@ export async function deleteLink(linkId: string) { ...includeProgramEnrollment, discountCode: { include: { - discount: { - select: { - provider: true, - }, - }, + discount: true, }, }, }, }); - // Delete the discount code and link in a transaction - await prisma.$transaction([ - ...(link.discountCode - ? [ - prisma.discountCode.delete({ - where: { - id: link.discountCode.id, - }, - }), - ] - : []), + if (link.discountCode) { + await deleteDiscountCodes([link.discountCode]); + } - prisma.link.delete({ - where: { - id: linkId, - }, - }), - ]); + await prisma.link.delete({ + where: { + id: linkId, + }, + }); waitUntil( Promise.allSettled([ @@ -72,8 +59,6 @@ export async function deleteLink(linkId: string) { }, }, }), - - link.discountCode && enqueueDeleteDiscountCode([link.discountCode]), ]), ); diff --git a/apps/web/lib/discounts/delete-discount-code.ts b/apps/web/lib/discounts/delete-discount-code.ts index a300444c35c..e65c80f113f 100644 --- a/apps/web/lib/discounts/delete-discount-code.ts +++ b/apps/web/lib/discounts/delete-discount-code.ts @@ -1,16 +1,21 @@ import { prisma } from "@/lib/prisma"; import { DiscountCodeWebhookSchema } from "@/lib/zod/schemas/discount"; import { APP_DOMAIN_WITH_NGROK, chunk } from "@dub/utils"; -import { Discount, DiscountCode } from "@prisma/client"; +import { Discount, DiscountCode, DiscountProvider } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; +import * as z from "zod/v4"; import { enqueueBatchJobs } from "../cron/enqueue-batch-jobs"; import { sendDiscountCodeWebhook } from "./discount-code-webhook"; +type DiscountCodeWebhookDiscount = z.infer< + typeof DiscountCodeWebhookSchema +>["discount"]; + type DeleteDiscountCodesParams = Pick< DiscountCode, "id" | "code" | "programId" | "partnerId" | "linkId" | "disabledAt" > & { - discount: Pick | null; + discount: DiscountCodeWebhookDiscount; }; type EnqueueDeleteDiscountCodeParams = Pick< @@ -41,6 +46,8 @@ export async function deleteDiscountCodes( } if (isSoftDelete) { + const disabledAt = new Date(); + // Soft delete the discount codes from the database (mark them as disabled) const disabledDiscountCodes = await prisma.discountCode.updateMany({ where: { @@ -49,13 +56,23 @@ export async function deleteDiscountCodes( }, }, data: { - disabledAt: new Date(), + disabledAt, }, }); console.log( `[deleteDiscountCodes] Disabled ${disabledDiscountCodes.count} discount codes.`, ); + + waitUntil( + sendDiscountCodeWebhooks({ + trigger: "discount_code.updated", + discountCodes: discountCodes.map((discountCode) => ({ + ...discountCode, + disabledAt, + })), + }), + ); } else { // Delete the discount codes from the database const deletedDiscountCodes = await prisma.discountCode.deleteMany({ @@ -69,10 +86,13 @@ export async function deleteDiscountCodes( console.log( `[deleteDiscountCodes] Deleted ${deletedDiscountCodes.count} discount codes.`, ); - } - if (!isSoftDelete) { - waitUntil(sendDiscountCodeDeletedWebhooks(discountCodes)); + waitUntil( + sendDiscountCodeWebhooks({ + trigger: "discount_code.deleted", + discountCodes, + }), + ); } await enqueueDeleteDiscountCode(discountCodes); @@ -81,12 +101,13 @@ export async function deleteDiscountCodes( // Only enqueue external-provider cleanup for codes whose provider is known. // Orphaned codes (discount relation is null) still get deleted locally above // but we can't tell which external provider to clean up, so we skip them. +// Custom providers disable via webhook, so they are not queued. export async function enqueueDeleteDiscountCode( discountCodes: EnqueueDeleteDiscountCodeParams[], ) { const codesWithProvider = discountCodes.filter( (dc): dc is typeof dc & { discount: Pick } => - dc.discount != null, + dc.discount != null && dc.discount.provider !== DiscountProvider.custom, ); if (codesWithProvider.length === 0) { @@ -112,9 +133,13 @@ export async function enqueueDeleteDiscountCode( } } -async function sendDiscountCodeDeletedWebhooks( - discountCodes: DeleteDiscountCodesParams[], -) { +async function sendDiscountCodeWebhooks({ + trigger, + discountCodes, +}: { + trigger: "discount_code.updated" | "discount_code.deleted"; + discountCodes: DeleteDiscountCodesParams[]; +}) { const programIds = [...new Set(discountCodes.map((dc) => dc.programId))]; const workspaces = await prisma.project.findMany({ @@ -143,7 +168,7 @@ async function sendDiscountCodeDeletedWebhooks( } return sendDiscountCodeWebhook({ - trigger: "discount_code.deleted", + trigger, workspace, data: DiscountCodeWebhookSchema.parse(discountCode), }); diff --git a/apps/web/lib/discounts/discount-code-webhook.ts b/apps/web/lib/discounts/discount-code-webhook.ts index 1b34e0291b6..6cfa8928775 100644 --- a/apps/web/lib/discounts/discount-code-webhook.ts +++ b/apps/web/lib/discounts/discount-code-webhook.ts @@ -1,6 +1,6 @@ import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { DiscountCodeWebhookSchema } from "@/lib/zod/schemas/discount"; -import { DiscountProvider, Project } from "@prisma/client"; +import { Project } from "@prisma/client"; import * as z from "zod/v4"; export async function sendDiscountCodeWebhook({ @@ -15,11 +15,6 @@ export async function sendDiscountCodeWebhook({ data: z.infer; workspace: Pick; }) { - // Only send webhook for custom discount provider for now - if (data.discount?.provider !== DiscountProvider.custom) { - return; - } - await sendWorkspaceWebhook({ trigger, workspace, diff --git a/apps/web/lib/openapi/discount-codes/delete-discount-code.ts b/apps/web/lib/openapi/discount-codes/delete-discount-code.ts index 5262afdee9a..1f7edac6e66 100644 --- a/apps/web/lib/openapi/discount-codes/delete-discount-code.ts +++ b/apps/web/lib/openapi/discount-codes/delete-discount-code.ts @@ -9,7 +9,7 @@ export const deleteDiscountCode: ZodOpenApiOperationObject = { "x-speakeasy-max-method-params": 1, summary: "Delete a discount code", description: - "Delete a discount code for a partner. This will also disable the code in your connected Stripe or Shopify account.", + "Delete a discount code for a partner. This will also disable the code in your connected discount provider (Stripe, Shopify, or custom via webhook).", requestParams: { path: z.object({ id: DiscountCodeSchema.shape.id.describe( diff --git a/apps/web/lib/openapi/discount-codes/update-discount-code.ts b/apps/web/lib/openapi/discount-codes/update-discount-code.ts index 35793cf5928..2c0cb6fb79a 100644 --- a/apps/web/lib/openapi/discount-codes/update-discount-code.ts +++ b/apps/web/lib/openapi/discount-codes/update-discount-code.ts @@ -1,7 +1,6 @@ import { openApiErrorResponses } from "@/lib/openapi/responses"; import { DiscountCodeSchema, - DiscountCodeWebhookSchema, updateDiscountCodeSchema, } from "@/lib/zod/schemas/discount"; import { ZodOpenApiOperationObject } from "zod-openapi"; @@ -33,7 +32,7 @@ export const updateDiscountCode: ZodOpenApiOperationObject = { description: "The updated discount code.", content: { "application/json": { - schema: DiscountCodeWebhookSchema, + schema: DiscountCodeSchema, }, }, }, diff --git a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts index 4aa17ec72f7..4fcb67b3792 100644 --- a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts +++ b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts @@ -1,4 +1,5 @@ import { createId } from "@/lib/api/create-id"; +import { constructDiscountCode } from "@/lib/discounts/construct-discount-code"; import { conn } from "@/lib/planetscale"; import { prisma } from "@/lib/prisma"; import type { EnrolledPartnerProps } from "@/lib/types"; @@ -114,7 +115,10 @@ test.afterAll(async () => { } }); -async function createPartner(api: ApiClient) { +async function createPartner( + api: ApiClient, + overrides: Record = {}, +) { if (!partnerGroupId) { throw new Error("Custom discount group was not seeded."); } @@ -123,6 +127,7 @@ async function createPartner(api: ApiClient) { name: randomName(), email: randomPartnerEmail(), groupId: partnerGroupId, + ...overrides, }); } @@ -221,6 +226,49 @@ test("POST /discount-codes – omits code and auto-generates", async ({ } }); +test("POST /discount-codes – auto-generated first-name collision retries", async ({ + api, +}) => { + let partnerIdA: string | undefined; + let partnerIdB: string | undefined; + + try { + const firstName = `Sarah${nanoid(6)}`; + const { data: partnerA } = await createPartner(api, { + name: `${firstName} One`, + }); + const { data: partnerB } = await createPartner(api, { + name: `${firstName} Two`, + }); + partnerIdA = partnerA.id; + partnerIdB = partnerB.id; + + const expectedBase = constructDiscountCode({ + partner: partnerA, + discount: customDiscount, + }); + + const first = await api.post("/api/discount-codes", { + partnerId: partnerA.id, + linkId: partnerA.links?.[0]?.id, + }); + const second = await api.post("/api/discount-codes", { + partnerId: partnerB.id, + linkId: partnerB.links?.[0]?.id, + }); + + expect(first.status).toEqual(200); + expect(second.status).toEqual(200); + expect(first.data.code).toEqual(expectedBase); + expect(second.data.code).not.toEqual(first.data.code); + expect(second.data.code.startsWith(expectedBase)).toBe(true); + expect(second.data.code.length).toEqual(expectedBase.length + 2); + } finally { + await deletePartner(partnerIdA); + await deletePartner(partnerIdB); + } +}); + test("POST /discount-codes – same link", async ({ api }) => { let partnerId: string | undefined; From 42c9e8d2b1909c1e43b2ee1614e0d7772221e546 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 17:31:17 +0530 Subject: [PATCH 09/20] Remove discount code PATCH API and discount_code.updated webhook. --- .../discount-codes/[discountCodeId]/route.ts | 52 --------- apps/web/lib/api/audit-logs/schemas.ts | 1 - .../web/lib/discounts/delete-discount-code.ts | 26 ++--- .../lib/discounts/discount-code-webhook.ts | 5 +- .../web/lib/discounts/update-discount-code.ts | 110 ------------------ apps/web/lib/integrations/slack/transform.ts | 3 - apps/web/lib/openapi/discount-codes/index.ts | 2 - .../discount-codes/update-discount-code.ts | 43 ------- apps/web/lib/webhook/constants.ts | 2 - .../sample-events/discount-code-updated.json | 14 --- apps/web/lib/webhook/sample-events/payload.ts | 2 - apps/web/lib/webhook/schemas.ts | 4 +- apps/web/lib/zod/schemas/discount.ts | 6 - .../api/discount-codes/discount-codes.spec.ts | 51 -------- apps/web/tests/webhooks/index.test.ts | 1 - 15 files changed, 10 insertions(+), 312 deletions(-) delete mode 100644 apps/web/lib/discounts/update-discount-code.ts delete mode 100644 apps/web/lib/openapi/discount-codes/update-discount-code.ts delete mode 100644 apps/web/lib/webhook/sample-events/discount-code-updated.json diff --git a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts index bd58cb109fb..26ed37d5461 100644 --- a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts @@ -1,15 +1,9 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; -import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { getDiscountCodeOrThrow } from "@/lib/discount-codes/get-discount-code-or-throw"; import { deleteDiscountCodes } from "@/lib/discounts/delete-discount-code"; -import { updateDiscountCode } from "@/lib/discounts/update-discount-code"; import { prisma } from "@/lib/prisma"; -import { - DiscountCodeSchema, - updateDiscountCodeSchema, -} from "@/lib/zod/schemas/discount"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -61,49 +55,3 @@ export const DELETE = withWorkspace( requiredRoles: ["owner", "member"], }, ); - -// PATCH /api/discount-codes/[discountCodeId] - update a discount code -export const PATCH = withWorkspace( - async ({ workspace, params, req, session }) => { - const { discountCodeId } = params; - const programId = getDefaultProgramIdOrThrow(workspace); - - const discountCode = await getDiscountCodeOrThrow({ - discountCodeId, - programId, - }); - - const { code: newCode } = updateDiscountCodeSchema.parse( - await parseRequestBody(req), - ); - - const updatedDiscountCode = await updateDiscountCode({ - workspace, - discountCode, - newCode, - }); - - waitUntil( - recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "discount_code.updated", - description: `Discount code (${updatedDiscountCode.code}) updated`, - actor: session.user, - targets: [ - { - type: "discount_code", - id: updatedDiscountCode.id, - metadata: updatedDiscountCode, - }, - ], - }), - ); - - return NextResponse.json(DiscountCodeSchema.parse(updatedDiscountCode)); - }, - { - requiredPlan: ["business", "advanced", "enterprise"], - requiredRoles: ["owner", "member"], - }, -); diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index cb89a6e30ea..700bb14bbc6 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -44,7 +44,6 @@ const actionSchema = z.enum([ "discount.updated", "discount.deleted", "discount_code.created", - "discount_code.updated", "discount_code.deleted", // Partner applications diff --git a/apps/web/lib/discounts/delete-discount-code.ts b/apps/web/lib/discounts/delete-discount-code.ts index e65c80f113f..c1414b54bfc 100644 --- a/apps/web/lib/discounts/delete-discount-code.ts +++ b/apps/web/lib/discounts/delete-discount-code.ts @@ -65,13 +65,12 @@ export async function deleteDiscountCodes( ); waitUntil( - sendDiscountCodeWebhooks({ - trigger: "discount_code.updated", - discountCodes: discountCodes.map((discountCode) => ({ + sendDiscountCodeDeletedWebhooks( + discountCodes.map((discountCode) => ({ ...discountCode, disabledAt, })), - }), + ), ); } else { // Delete the discount codes from the database @@ -87,12 +86,7 @@ export async function deleteDiscountCodes( `[deleteDiscountCodes] Deleted ${deletedDiscountCodes.count} discount codes.`, ); - waitUntil( - sendDiscountCodeWebhooks({ - trigger: "discount_code.deleted", - discountCodes, - }), - ); + waitUntil(sendDiscountCodeDeletedWebhooks(discountCodes)); } await enqueueDeleteDiscountCode(discountCodes); @@ -133,13 +127,9 @@ export async function enqueueDeleteDiscountCode( } } -async function sendDiscountCodeWebhooks({ - trigger, - discountCodes, -}: { - trigger: "discount_code.updated" | "discount_code.deleted"; - discountCodes: DeleteDiscountCodesParams[]; -}) { +async function sendDiscountCodeDeletedWebhooks( + discountCodes: DeleteDiscountCodesParams[], +) { const programIds = [...new Set(discountCodes.map((dc) => dc.programId))]; const workspaces = await prisma.project.findMany({ @@ -168,7 +158,7 @@ async function sendDiscountCodeWebhooks({ } return sendDiscountCodeWebhook({ - trigger, + trigger: "discount_code.deleted", workspace, data: DiscountCodeWebhookSchema.parse(discountCode), }); diff --git a/apps/web/lib/discounts/discount-code-webhook.ts b/apps/web/lib/discounts/discount-code-webhook.ts index 6cfa8928775..9c0424bc2f1 100644 --- a/apps/web/lib/discounts/discount-code-webhook.ts +++ b/apps/web/lib/discounts/discount-code-webhook.ts @@ -8,10 +8,7 @@ export async function sendDiscountCodeWebhook({ data, workspace, }: { - trigger: - | "discount_code.created" - | "discount_code.updated" - | "discount_code.deleted"; + trigger: "discount_code.created" | "discount_code.deleted"; data: z.infer; workspace: Pick; }) { diff --git a/apps/web/lib/discounts/update-discount-code.ts b/apps/web/lib/discounts/update-discount-code.ts deleted file mode 100644 index b1192733b72..00000000000 --- a/apps/web/lib/discounts/update-discount-code.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { DubApiError } from "@/lib/api/errors"; -import { getDiscountCodeOrThrow } from "@/lib/discount-codes/get-discount-code-or-throw"; -import { prisma } from "@/lib/prisma"; -import { DiscountProvider, Prisma, Project } from "@prisma/client"; -import { waitUntil } from "@vercel/functions"; -import { sendDiscountCodeWebhook } from "./discount-code-webhook"; - -interface UpdateDiscountCodeArgs { - workspace: Pick; - discountCode: Awaited>; - newCode: string; -} - -export async function updateDiscountCode({ - workspace, - discountCode, - newCode, -}: UpdateDiscountCodeArgs) { - if (discountCode.discount.provider !== DiscountProvider.custom) { - throw new DubApiError({ - code: "bad_request", - message: `This operation is only available for "custom" discount provider.`, - }); - } - - if (newCode !== discountCode.code) { - await assertDiscountCodeAvailable({ - programId: discountCode.programId, - code: newCode, - }); - } - - let updatedDiscountCode: Prisma.DiscountCodeGetPayload<{ - include: { discount: true }; - }>; - - try { - updatedDiscountCode = await prisma.discountCode.update({ - where: { - id: discountCode.id, - }, - data: { - code: newCode, - }, - include: { - discount: true, - }, - }); - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2002" - ) { - await assertDiscountCodeAvailable({ - programId: discountCode.programId, - code: newCode, - }); - - throw new DubApiError({ - code: "conflict", - message: `This discount code "${newCode}" is already in use. Please choose a different code.`, - }); - } - - throw error; - } - - waitUntil( - sendDiscountCodeWebhook({ - trigger: "discount_code.updated", - data: updatedDiscountCode, - workspace, - }), - ); - - return updatedDiscountCode; -} - -async function assertDiscountCodeAvailable({ - programId, - code, -}: { - programId: string; - code: string; -}) { - const duplicateByCode = await prisma.discountCode.findUnique({ - where: { - programId_code: { - programId, - code, - }, - }, - select: { - partner: { - select: { - email: true, - }, - }, - }, - }); - - if (!duplicateByCode) { - return; - } - - throw new DubApiError({ - code: "conflict", - message: `This discount code "${code}" is already in use by partner "${duplicateByCode.partner.email}". Please choose a different code.`, - }); -} diff --git a/apps/web/lib/integrations/slack/transform.ts b/apps/web/lib/integrations/slack/transform.ts index 921901ac843..28bddd1720e 100644 --- a/apps/web/lib/integrations/slack/transform.ts +++ b/apps/web/lib/integrations/slack/transform.ts @@ -606,7 +606,6 @@ const discountCodeTemplates = ({ }) => { const eventMessages = { "discount_code.created": "*Discount code created* :ticket:", - "discount_code.updated": "*Discount code updated* :ticket:", "discount_code.deleted": "*Discount code deleted* :ticket:", }; @@ -650,7 +649,6 @@ const slackTemplates: Record = { "bounty.updated": bountyTemplates, "payout.confirmed": payoutConfirmedTemplate, "discount_code.created": discountCodeTemplates, - "discount_code.updated": discountCodeTemplates, "discount_code.deleted": discountCodeTemplates, }; @@ -670,7 +668,6 @@ export const formatEventForSlack = ( const isBountyEvent = ["bounty.created", "bounty.updated"].includes(event); const isDiscountCodeEvent = [ "discount_code.created", - "discount_code.updated", "discount_code.deleted", ].includes(event); diff --git a/apps/web/lib/openapi/discount-codes/index.ts b/apps/web/lib/openapi/discount-codes/index.ts index 11e440b15e2..01eafc4392b 100644 --- a/apps/web/lib/openapi/discount-codes/index.ts +++ b/apps/web/lib/openapi/discount-codes/index.ts @@ -2,7 +2,6 @@ import { ZodOpenApiPathsObject } from "zod-openapi"; import { createDiscountCode } from "./create-discount-code"; import { deleteDiscountCode } from "./delete-discount-code"; import { listDiscountCodes } from "./list-discount-codes"; -import { updateDiscountCode } from "./update-discount-code"; export const discountCodesPaths: ZodOpenApiPathsObject = { "/discount-codes": { @@ -10,7 +9,6 @@ export const discountCodesPaths: ZodOpenApiPathsObject = { post: createDiscountCode, }, "/discount-codes/{id}": { - patch: updateDiscountCode, delete: deleteDiscountCode, }, }; diff --git a/apps/web/lib/openapi/discount-codes/update-discount-code.ts b/apps/web/lib/openapi/discount-codes/update-discount-code.ts deleted file mode 100644 index 2c0cb6fb79a..00000000000 --- a/apps/web/lib/openapi/discount-codes/update-discount-code.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { openApiErrorResponses } from "@/lib/openapi/responses"; -import { - DiscountCodeSchema, - updateDiscountCodeSchema, -} from "@/lib/zod/schemas/discount"; -import { ZodOpenApiOperationObject } from "zod-openapi"; -import * as z from "zod/v4"; - -export const updateDiscountCode: ZodOpenApiOperationObject = { - operationId: "updateDiscountCode", - "x-speakeasy-name-override": "update", - "x-speakeasy-max-method-params": 2, - summary: "Update a discount code", - description: - "Update a custom discount code. This is only available when the discount provider is `custom`.", - requestParams: { - path: z.object({ - id: DiscountCodeSchema.shape.id.describe( - "The ID of the discount code to update.", - ), - }), - }, - requestBody: { - content: { - "application/json": { - schema: updateDiscountCodeSchema, - }, - }, - }, - responses: { - "200": { - description: "The updated discount code.", - content: { - "application/json": { - schema: DiscountCodeSchema, - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Discount Codes"], - security: [{ token: [] }], -}; diff --git a/apps/web/lib/webhook/constants.ts b/apps/web/lib/webhook/constants.ts index c8fffaf1263..951492774d9 100644 --- a/apps/web/lib/webhook/constants.ts +++ b/apps/web/lib/webhook/constants.ts @@ -27,7 +27,6 @@ export const PROGRAM_LEVEL_WEBHOOK_TRIGGERS = [ "bounty.updated", "payout.confirmed", "discount_code.created", - "discount_code.updated", "discount_code.deleted", ] as const; @@ -50,7 +49,6 @@ export const WEBHOOK_TRIGGER_DESCRIPTIONS: Record = { "bounty.updated": "Bounty updated", "payout.confirmed": "Payout confirmed", "discount_code.created": "Discount code created", - "discount_code.updated": "Discount code updated", "discount_code.deleted": "Discount code deleted", } as const; diff --git a/apps/web/lib/webhook/sample-events/discount-code-updated.json b/apps/web/lib/webhook/sample-events/discount-code-updated.json deleted file mode 100644 index ee92bdf7f04..00000000000 --- a/apps/web/lib/webhook/sample-events/discount-code-updated.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "id": "dcode_1K39DGZG3MHY9RP4PD0AS2C5P", - "code": "STEVEN10OFF", - "partnerId": "pn_1K9BZE1K285BSTX4W6MPKXJFZ", - "linkId": "link_5myDHLqhIQvUmUPjchVygF9R", - "disabledAt": "2025-09-01T17:34:00.000Z", - "discount": { - "id": "disc_1KEC01MXC5H50XQMSN83VCW65", - "amount": 10, - "type": "percentage", - "maxDuration": 6, - "provider": "custom" - } -} diff --git a/apps/web/lib/webhook/sample-events/payload.ts b/apps/web/lib/webhook/sample-events/payload.ts index 6ed6fe77bac..88d783eef34 100644 --- a/apps/web/lib/webhook/sample-events/payload.ts +++ b/apps/web/lib/webhook/sample-events/payload.ts @@ -4,7 +4,6 @@ import bountyUpdated from "./bounty-updated.json"; import commissionCreated from "./commission-created.json"; import discountCodeCreated from "./discount-code-created.json"; import discountCodeDeleted from "./discount-code-deleted.json"; -import discountCodeUpdated from "./discount-code-updated.json"; import leadCreated from "./lead-created.json"; import linkClicked from "./link-clicked.json"; import linkCreated from "./link-created.json"; @@ -29,6 +28,5 @@ export const samplePayload: Record = { "bounty.updated": bountyUpdated, "payout.confirmed": payoutConfirmed, "discount_code.created": discountCodeCreated, - "discount_code.updated": discountCodeUpdated, "discount_code.deleted": discountCodeDeleted, }; diff --git a/apps/web/lib/webhook/schemas.ts b/apps/web/lib/webhook/schemas.ts index cb19a895ab2..2aa2d8fceee 100644 --- a/apps/web/lib/webhook/schemas.ts +++ b/apps/web/lib/webhook/schemas.ts @@ -175,15 +175,13 @@ export const webhookEventSchema = z id: z.string(), event: z.union([ z.literal("discount_code.created"), - z.literal("discount_code.updated"), z.literal("discount_code.deleted"), ]), createdAt: z.string(), data: DiscountCodeWebhookSchema, }) .meta({ - description: - "Triggered when a discount code is created, updated, or deleted.", + description: "Triggered when a discount code is created or deleted.", id: "DiscountCodeWebhookEvent", outputId: "DiscountCodeWebhookEvent", }), diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index d9541bb38f8..f0d70ed15b5 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -118,12 +118,6 @@ export const createDiscountCodeSchema = z.object({ ), }); -export const updateDiscountCodeSchema = z.object({ - code: discountCodeValueSchema.describe( - "The updated discount code. Only available for custom discount providers.", - ), -}); - export const getDiscountCodesQuerySchema = z .object({ partnerId: z diff --git a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts index 4fcb67b3792..fc305b1711f 100644 --- a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts +++ b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts @@ -457,57 +457,6 @@ test("GET /discount-codes – unknown partner", async ({ api, program }) => { }); }); -test("PATCH /discount-codes/{id}", async ({ api }) => { - let partnerId: string | undefined; - - try { - const created = await createDiscountCode(api); - partnerId = created.partner.id; - const nextCode = `PW${nanoid(8)}`; - - const { status, data } = await api.patch( - `/api/discount-codes/${created.data.id}`, - { code: nextCode }, - ); - - expect(status).toEqual(200); - expect(data).toEqual({ - ...created.data, - code: nextCode, - }); - } finally { - await deletePartner(partnerId); - } -}); - -test("PATCH /discount-codes/{id} – invalid code", async ({ api }) => { - let partnerId: string | undefined; - - try { - const created = await createDiscountCode(api); - partnerId = created.partner.id; - - expect( - await api.patch(`/api/discount-codes/${created.data.id}`, { - code: "not valid!", - }), - ).toEqual({ - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: - "invalid_format: code: Code can only contain letters, numbers, dashes, and underscores.", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }); - } finally { - await deletePartner(partnerId); - } -}); - test("DELETE /discount-codes/{id}", async ({ api }) => { let partnerId: string | undefined; diff --git a/apps/web/tests/webhooks/index.test.ts b/apps/web/tests/webhooks/index.test.ts index 68b94826748..96f0510bf30 100644 --- a/apps/web/tests/webhooks/index.test.ts +++ b/apps/web/tests/webhooks/index.test.ts @@ -101,7 +101,6 @@ const eventSchemas: Record = { "bounty.updated": bountyWebhookEventSchemaExtended, "payout.confirmed": payoutWebhookEventSchemaExtended, "discount_code.created": DiscountCodeWebhookSchema, - "discount_code.updated": DiscountCodeWebhookSchema, "discount_code.deleted": DiscountCodeWebhookSchema, }; From b7b34d1c227cf3322f2d431b57184b48eca41f30 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 17:38:38 +0530 Subject: [PATCH 10/20] Remove sendDiscountCodeWebhook --- .../web/lib/discounts/create-discount-code.ts | 9 +++++---- .../web/lib/discounts/delete-discount-code.ts | 6 +++--- .../lib/discounts/discount-code-webhook.ts | 20 ------------------- 3 files changed, 8 insertions(+), 27 deletions(-) delete mode 100644 apps/web/lib/discounts/discount-code-webhook.ts diff --git a/apps/web/lib/discounts/create-discount-code.ts b/apps/web/lib/discounts/create-discount-code.ts index 0c2801ffc0d..e2a11dd7cbf 100644 --- a/apps/web/lib/discounts/create-discount-code.ts +++ b/apps/web/lib/discounts/create-discount-code.ts @@ -4,8 +4,9 @@ import { prisma } from "@/lib/prisma"; import { nanoid } from "@dub/utils"; import { Discount, Link, Partner, Prisma, Project } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; +import { sendWorkspaceWebhook } from "../webhook/publish"; +import { DiscountCodeWebhookSchema } from "../zod/schemas/discount"; import { constructDiscountCode } from "./construct-discount-code"; -import { sendDiscountCodeWebhook } from "./discount-code-webhook"; import { getDiscountProvider } from "./discount-provider"; const MAX_ATTEMPTS = 3; @@ -96,10 +97,10 @@ export async function createDiscountCode({ } waitUntil( - sendDiscountCodeWebhook({ - trigger: "discount_code.created", - data: discountCode, + sendWorkspaceWebhook({ workspace, + trigger: "discount_code.created", + data: DiscountCodeWebhookSchema.parse(discountCode), }), ); diff --git a/apps/web/lib/discounts/delete-discount-code.ts b/apps/web/lib/discounts/delete-discount-code.ts index c1414b54bfc..08f1d083725 100644 --- a/apps/web/lib/discounts/delete-discount-code.ts +++ b/apps/web/lib/discounts/delete-discount-code.ts @@ -5,7 +5,7 @@ import { Discount, DiscountCode, DiscountProvider } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import * as z from "zod/v4"; import { enqueueBatchJobs } from "../cron/enqueue-batch-jobs"; -import { sendDiscountCodeWebhook } from "./discount-code-webhook"; +import { sendWorkspaceWebhook } from "../webhook/publish"; type DiscountCodeWebhookDiscount = z.infer< typeof DiscountCodeWebhookSchema @@ -157,9 +157,9 @@ async function sendDiscountCodeDeletedWebhooks( return; } - return sendDiscountCodeWebhook({ - trigger: "discount_code.deleted", + return sendWorkspaceWebhook({ workspace, + trigger: "discount_code.deleted", data: DiscountCodeWebhookSchema.parse(discountCode), }); }), diff --git a/apps/web/lib/discounts/discount-code-webhook.ts b/apps/web/lib/discounts/discount-code-webhook.ts deleted file mode 100644 index 9c0424bc2f1..00000000000 --- a/apps/web/lib/discounts/discount-code-webhook.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; -import { DiscountCodeWebhookSchema } from "@/lib/zod/schemas/discount"; -import { Project } from "@prisma/client"; -import * as z from "zod/v4"; - -export async function sendDiscountCodeWebhook({ - trigger, - data, - workspace, -}: { - trigger: "discount_code.created" | "discount_code.deleted"; - data: z.infer; - workspace: Pick; -}) { - await sendWorkspaceWebhook({ - trigger, - workspace, - data: DiscountCodeWebhookSchema.parse(data), - }); -} From eaaecd28697d28c91321d9d4d57f80e9e6420624 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 17:43:45 +0530 Subject: [PATCH 11/20] Reset delete discount code confirmation when the modal closes. --- apps/web/ui/modals/delete-discount-code-modal.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/ui/modals/delete-discount-code-modal.tsx b/apps/web/ui/modals/delete-discount-code-modal.tsx index 2b452cac974..0c363cdf8a2 100644 --- a/apps/web/ui/modals/delete-discount-code-modal.tsx +++ b/apps/web/ui/modals/delete-discount-code-modal.tsx @@ -3,7 +3,7 @@ import { useApiMutation } from "@/lib/swr/use-api-mutation"; import { DiscountCodeProps } from "@/lib/types"; import { Button, Modal, useMediaQuery } from "@dub/ui"; import { Tag } from "@dub/ui/icons"; -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useState } from "react"; import { toast } from "sonner"; interface DeleteDiscountCodeModalProps { @@ -23,6 +23,12 @@ export const DeleteDiscountCodeModal = ({ const { makeRequest: deleteDiscountCode, isSubmitting } = useApiMutation(); const [inputValue, setInputValue] = useState(""); + useEffect(() => { + if (!showModal) { + setInputValue(""); + } + }, [showModal, discountCode.id]); + const onSubmit = async (e: FormEvent) => { e.preventDefault(); From 559db95a3270b7f0a65b8faeeeeb5c6c989ed31c Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 17:59:14 +0530 Subject: [PATCH 12/20] Fix remap treating null coupon IDs as equivalent and roll back provider codes on linkId conflicts. --- .../web/lib/discounts/create-discount-code.ts | 12 ++++----- .../lib/discounts/is-discount-equivalent.ts | 26 ++++++++++++------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/web/lib/discounts/create-discount-code.ts b/apps/web/lib/discounts/create-discount-code.ts index e2a11dd7cbf..43b10d38761 100644 --- a/apps/web/lib/discounts/create-discount-code.ts +++ b/apps/web/lib/discounts/create-discount-code.ts @@ -156,6 +156,12 @@ async function createDiscountCodeRecord({ error.code === "P2002"; if (isUniqueConflict && canRetry) { + await rollbackExternalDiscountCode({ + discountProvider, + workspace, + code, + }); + const existingForLink = await prisma.discountCode.findUnique({ where: { linkId: link.id, @@ -172,12 +178,6 @@ async function createDiscountCodeRecord({ }); } - await rollbackExternalDiscountCode({ - discountProvider, - workspace, - code, - }); - const nextCode = `${code}${nanoid(2)}`; console.warn( `Discount code "${code}" already exists. Retrying with "${nextCode}".`, diff --git a/apps/web/lib/discounts/is-discount-equivalent.ts b/apps/web/lib/discounts/is-discount-equivalent.ts index fc8d6ec0939..b6dc8129fa7 100644 --- a/apps/web/lib/discounts/is-discount-equivalent.ts +++ b/apps/web/lib/discounts/is-discount-equivalent.ts @@ -1,26 +1,32 @@ import { Discount } from "@prisma/client"; +type DiscountEquivalenceFields = Pick< + Discount, + "couponId" | "provider" | "amount" | "type" | "maxDuration" +>; + export function isDiscountEquivalent( - firstDiscount: Discount | null | undefined, - secondDiscount: Discount | null | undefined, + firstDiscount: DiscountEquivalenceFields | null | undefined, + secondDiscount: DiscountEquivalenceFields | null | undefined, ): boolean { if (!firstDiscount || !secondDiscount) { return false; } - // If both groups use the same Stripe coupon - if (firstDiscount.couponId === secondDiscount.couponId) { + // If both groups use the same coupon + if ( + firstDiscount.couponId && + secondDiscount.couponId && + firstDiscount.couponId === secondDiscount.couponId + ) { return true; } // If both discounts are effectively equivalent - if ( + return ( + firstDiscount.provider === secondDiscount.provider && firstDiscount.amount === secondDiscount.amount && firstDiscount.type === secondDiscount.type && firstDiscount.maxDuration === secondDiscount.maxDuration - ) { - return true; - } - - return false; + ); } From 8cea895c37e2405d63d50b15578cf3a78078444e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 18:11:44 +0530 Subject: [PATCH 13/20] Update is-discount-equivalent.ts --- apps/web/lib/discounts/is-discount-equivalent.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/lib/discounts/is-discount-equivalent.ts b/apps/web/lib/discounts/is-discount-equivalent.ts index b6dc8129fa7..55ffef88fd1 100644 --- a/apps/web/lib/discounts/is-discount-equivalent.ts +++ b/apps/web/lib/discounts/is-discount-equivalent.ts @@ -13,6 +13,10 @@ export function isDiscountEquivalent( return false; } + if (firstDiscount.provider !== secondDiscount.provider) { + return false; + } + // If both groups use the same coupon if ( firstDiscount.couponId && From 65da0d9876e558b106daa9dcc54e1679824a5322 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 18 Aug 2026 21:41:05 +0530 Subject: [PATCH 14/20] CR feedback --- apps/web/lib/actions/partners/create-discount.ts | 8 ++++++-- apps/web/lib/discounts/create-discount-code.ts | 12 +++++++----- apps/web/ui/modals/delete-discount-code-modal.tsx | 4 +--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 2dbcc188d62..75dbb69e7f7 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -82,8 +82,12 @@ export const createDiscountAction = authActionClient type, maxDuration, provider, - couponId: coupon?.id || couponId || null, - ...(couponTestId && { couponTestId }), + couponId: + provider === DiscountProvider.stripe + ? coupon?.id || couponId || null + : null, + ...(provider === DiscountProvider.stripe && + couponTestId && { couponTestId }), ...(autoProvision && { autoProvisionEnabledAt: new Date() }), }, }); diff --git a/apps/web/lib/discounts/create-discount-code.ts b/apps/web/lib/discounts/create-discount-code.ts index 43b10d38761..335a0d16747 100644 --- a/apps/web/lib/discounts/create-discount-code.ts +++ b/apps/web/lib/discounts/create-discount-code.ts @@ -97,11 +97,13 @@ export async function createDiscountCode({ } waitUntil( - sendWorkspaceWebhook({ - workspace, - trigger: "discount_code.created", - data: DiscountCodeWebhookSchema.parse(discountCode), - }), + (async () => { + await sendWorkspaceWebhook({ + workspace, + trigger: "discount_code.created", + data: DiscountCodeWebhookSchema.parse(discountCode), + }); + })(), ); return discountCode; diff --git a/apps/web/ui/modals/delete-discount-code-modal.tsx b/apps/web/ui/modals/delete-discount-code-modal.tsx index 0c363cdf8a2..64a55d6efea 100644 --- a/apps/web/ui/modals/delete-discount-code-modal.tsx +++ b/apps/web/ui/modals/delete-discount-code-modal.tsx @@ -24,9 +24,7 @@ export const DeleteDiscountCodeModal = ({ const [inputValue, setInputValue] = useState(""); useEffect(() => { - if (!showModal) { - setInputValue(""); - } + setInputValue(""); }, [showModal, discountCode.id]); const onSubmit = async (e: FormEvent) => { From 0ff5c3244d73034b95b6e28037afdff06e3b2ea8 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 18 Aug 2026 21:56:44 -0700 Subject: [PATCH 15/20] improve OpenAPI descriptions --- apps/web/lib/openapi/discount-codes/create-discount-code.ts | 2 +- apps/web/lib/openapi/discount-codes/delete-discount-code.ts | 2 +- apps/web/lib/openapi/discount-codes/list-discount-codes.ts | 2 +- apps/web/lib/zod/schemas/discount.ts | 6 ++++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/lib/openapi/discount-codes/create-discount-code.ts b/apps/web/lib/openapi/discount-codes/create-discount-code.ts index 60f73eab844..2de1f2ebf91 100644 --- a/apps/web/lib/openapi/discount-codes/create-discount-code.ts +++ b/apps/web/lib/openapi/discount-codes/create-discount-code.ts @@ -10,7 +10,7 @@ export const createDiscountCode: ZodOpenApiOperationObject = { "x-speakeasy-name-override": "create", summary: "Create a discount code", description: - "Create a discount code for a partner. A discount must already be assigned to the partner's group, and the specified link cannot already have a discount code.", + "Create a discount code for a partner. The partner's group must already have a discount assigned to it, and the discount code must be associated with a link that is not already linked with another discount code.", requestBody: { content: { "application/json": { diff --git a/apps/web/lib/openapi/discount-codes/delete-discount-code.ts b/apps/web/lib/openapi/discount-codes/delete-discount-code.ts index 1f7edac6e66..9f4d41a7d38 100644 --- a/apps/web/lib/openapi/discount-codes/delete-discount-code.ts +++ b/apps/web/lib/openapi/discount-codes/delete-discount-code.ts @@ -9,7 +9,7 @@ export const deleteDiscountCode: ZodOpenApiOperationObject = { "x-speakeasy-max-method-params": 1, summary: "Delete a discount code", description: - "Delete a discount code for a partner. This will also disable the code in your connected discount provider (Stripe, Shopify, or custom via webhook).", + "Delete a discount code for a partner. This will also disable the code in your connected discount provider (Stripe, Shopify, or custom via `disccount.deleted` webhook).", requestParams: { path: z.object({ id: DiscountCodeSchema.shape.id.describe( diff --git a/apps/web/lib/openapi/discount-codes/list-discount-codes.ts b/apps/web/lib/openapi/discount-codes/list-discount-codes.ts index fd93c8ffb0e..d35965253f4 100644 --- a/apps/web/lib/openapi/discount-codes/list-discount-codes.ts +++ b/apps/web/lib/openapi/discount-codes/list-discount-codes.ts @@ -11,7 +11,7 @@ export const listDiscountCodes: ZodOpenApiOperationObject = { "x-speakeasy-name-override": "list", summary: "List discount codes", description: - "Retrieve a paginated list of discount codes for the partner program.", + "Retrieve a paginated list of discount codes for a partner / a given discount / the whole program.", requestParams: { query: getDiscountCodesQuerySchema, }, diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index f0d70ed15b5..eddb99b8f35 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -63,7 +63,9 @@ export const DiscountCodeSchema = z }), code: z .string() - .describe("The discount code that customers can apply at checkout.") + .describe( + "The alphanumeric discount code that customers can apply at checkout.", + ) .meta({ example: "PARTNER10OFF", }), @@ -83,7 +85,7 @@ export const DiscountCodeSchema = z .date() .nullish() .describe( - "When this discount code was disabled, which happens when a partner is banned or deactivated.", + "When this discount code was disabled, which happens when a partner is banned or deactivated. We don't delete the discount code to avoid another partner claiming a banned/deactivated code (abuse vector).", ), }) .meta({ From ab8dc977db1f352419f529793721d1b7b4411c8d Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 18 Aug 2026 22:14:31 -0700 Subject: [PATCH 16/20] =?UTF-8?q?discountId=20=E2=86=92=20idOrCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{[discountCodeId] => [idOrCode]}/route.ts | 32 +++++++++++---- .../get-discount-code-or-throw.ts | 40 ------------------- .../discount-codes/delete-discount-code.ts | 8 ++-- apps/web/lib/openapi/discount-codes/index.ts | 2 +- 4 files changed, 30 insertions(+), 52 deletions(-) rename apps/web/app/(ee)/api/discount-codes/{[discountCodeId] => [idOrCode]}/route.ts (63%) delete mode 100644 apps/web/lib/discount-codes/get-discount-code-or-throw.ts diff --git a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts b/apps/web/app/(ee)/api/discount-codes/[idOrCode]/route.ts similarity index 63% rename from apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts rename to apps/web/app/(ee)/api/discount-codes/[idOrCode]/route.ts index 26ed37d5461..8a0d07b5b3a 100644 --- a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/[idOrCode]/route.ts @@ -1,26 +1,44 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; -import { getDiscountCodeOrThrow } from "@/lib/discount-codes/get-discount-code-or-throw"; import { deleteDiscountCodes } from "@/lib/discounts/delete-discount-code"; import { prisma } from "@/lib/prisma"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; -// DELETE /api/discount-codes/[discountCodeId] - delete a discount code +// DELETE /api/discount-codes/[idOrCode] - delete a discount code export const DELETE = withWorkspace( async ({ workspace, params, session }) => { - const { discountCodeId } = params; + const { idOrCode } = params; const programId = getDefaultProgramIdOrThrow(workspace); - const discountCode = await getDiscountCodeOrThrow({ - discountCodeId, - programId, + const discountCode = await prisma.discountCode.findUnique({ + where: idOrCode.startsWith("dcode_") + ? { id: idOrCode } + : { programId_code: { programId, code: idOrCode } }, + include: { + discount: true, + }, }); + if (!discountCode || !discountCode.discount) { + throw new DubApiError({ + code: "not_found", + message: `Discount code (${idOrCode}) not found.`, + }); + } + + if (discountCode.programId !== programId) { + throw new DubApiError({ + code: "not_found", + message: `Discount code (${idOrCode}) not found.`, + }); + } + await prisma.discountCode.update({ where: { - id: discountCodeId, + id: discountCode.id, }, data: { discountId: null, diff --git a/apps/web/lib/discount-codes/get-discount-code-or-throw.ts b/apps/web/lib/discount-codes/get-discount-code-or-throw.ts deleted file mode 100644 index 8af87ac13ba..00000000000 --- a/apps/web/lib/discount-codes/get-discount-code-or-throw.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { prisma } from "@/lib/prisma"; -import { DubApiError } from "../api/errors"; - -export async function getDiscountCodeOrThrow({ - discountCodeId, - programId, -}: { - discountCodeId: string; - programId: string; -}) { - const discountCode = await prisma.discountCode.findUnique({ - where: { - id: discountCodeId, - }, - include: { - discount: true, - }, - }); - - if (!discountCode || !discountCode.discount) { - throw new DubApiError({ - code: "not_found", - message: `Discount code (${discountCodeId}) not found.`, - }); - } - - if (discountCode.programId !== programId) { - throw new DubApiError({ - code: "not_found", - message: `Discount code (${discountCodeId}) not found.`, - }); - } - - const { discount, ...rest } = discountCode; - - return { - ...rest, - discount, - }; -} diff --git a/apps/web/lib/openapi/discount-codes/delete-discount-code.ts b/apps/web/lib/openapi/discount-codes/delete-discount-code.ts index 9f4d41a7d38..06d8d21887a 100644 --- a/apps/web/lib/openapi/discount-codes/delete-discount-code.ts +++ b/apps/web/lib/openapi/discount-codes/delete-discount-code.ts @@ -9,17 +9,17 @@ export const deleteDiscountCode: ZodOpenApiOperationObject = { "x-speakeasy-max-method-params": 1, summary: "Delete a discount code", description: - "Delete a discount code for a partner. This will also disable the code in your connected discount provider (Stripe, Shopify, or custom via `disccount.deleted` webhook).", + "Delete a discount code for a partner by its unique ID or alphanumeric code. This will also disable the code in your connected discount provider (Stripe, Shopify, or custom via `disccount.deleted` webhook).", requestParams: { path: z.object({ - id: DiscountCodeSchema.shape.id.describe( - "The ID of the discount code to delete.", + idOrCode: DiscountCodeSchema.shape.id.describe( + "The unique ID (e.g. `dcode_...`) or alphanumeric code (e.g. `ABC123`) of the discount code to delete.", ), }), }, responses: { "200": { - description: "The deleted discount code ID.", + description: "The deleted discount code unique ID (e.g. `dcode_...`).", content: { "application/json": { schema: DiscountCodeSchema.pick({ id: true }), diff --git a/apps/web/lib/openapi/discount-codes/index.ts b/apps/web/lib/openapi/discount-codes/index.ts index 01eafc4392b..f5ec228406e 100644 --- a/apps/web/lib/openapi/discount-codes/index.ts +++ b/apps/web/lib/openapi/discount-codes/index.ts @@ -8,7 +8,7 @@ export const discountCodesPaths: ZodOpenApiPathsObject = { get: listDiscountCodes, post: createDiscountCode, }, - "/discount-codes/{id}": { + "/discount-codes/{idOrCode}": { delete: deleteDiscountCode, }, }; From a41865314febcfc98cd3e4abc89b62049adc0f52 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 19 Aug 2026 11:07:59 +0530 Subject: [PATCH 17/20] Keep create discount code optional in OpenAPI and treat empty strings as omitted. --- apps/web/app/(ee)/api/discount-codes/route.ts | 10 +++++-- apps/web/lib/zod/schemas/discount.ts | 29 ++++++++----------- .../api/discount-codes/discount-codes.spec.ts | 26 +++++++++++++++++ 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/apps/web/app/(ee)/api/discount-codes/route.ts b/apps/web/app/(ee)/api/discount-codes/route.ts index 23c1ad95f9f..fd7248181a3 100644 --- a/apps/web/app/(ee)/api/discount-codes/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/route.ts @@ -68,9 +68,13 @@ export const POST = withWorkspace( async ({ workspace, req, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); - const { partnerId, linkId, code } = createDiscountCodeSchema.parse( - await parseRequestBody(req), - ); + const body = await parseRequestBody(req); + + if (typeof body.code === "string" && body.code.trim() === "") { + delete body.code; + } + + const { partnerId, linkId, code } = createDiscountCodeSchema.parse(body); const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId, diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index f0d70ed15b5..e872c8aa617 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -90,24 +90,19 @@ export const DiscountCodeSchema = z title: "DiscountCode", }); -const discountCodeValueSchema = z - .string() - .trim() - .max(100, "Code must be 100 characters or fewer.") - .regex( - /^[a-zA-Z0-9\-_]+$/, - "Code can only contain letters, numbers, dashes, and underscores.", - ); - export const createDiscountCodeSchema = z.object({ - code: z.preprocess( - (val) => (typeof val === "string" && val.trim() === "" ? undefined : val), - discountCodeValueSchema - .optional() - .describe( - "The discount code to create. If omitted, a unique code will be generated automatically from the partner's name.", - ), - ), + code: z + .string() + .trim() + .max(100, "Code must be 100 characters or fewer.") + .regex( + /^[a-zA-Z0-9\-_]+$/, + "Code can only contain letters, numbers, dashes, and underscores.", + ) + .optional() + .describe( + "The discount code to create. If omitted, a unique code will be generated automatically from the partner's name.", + ), partnerId: z .string() .describe("The ID of the partner to create a discount code for."), diff --git a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts index fc305b1711f..110b4bcd3ae 100644 --- a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts +++ b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts @@ -226,6 +226,32 @@ test("POST /discount-codes – omits code and auto-generates", async ({ } }); +test("POST /discount-codes – empty code auto-generates", async ({ api }) => { + let partnerId: string | undefined; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + const linkId = partner.links?.[0]?.id; + + const { status, data } = await api.post( + "/api/discount-codes", + { + partnerId: partner.id, + linkId, + code: "", + }, + ); + + expect(status).toEqual(200); + expect(data.code).toEqual(expect.any(String)); + expect(data.code.length).toBeGreaterThan(0); + expect(data.partnerId).toEqual(partner.id); + } finally { + await deletePartner(partnerId); + } +}); + test("POST /discount-codes – auto-generated first-name collision retries", async ({ api, }) => { From 5cd20afe50c521eb53dc479bd5b1cedf14c20152 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 19 Aug 2026 11:11:10 +0530 Subject: [PATCH 18/20] Cover deleting discount codes by id or alphanumeric code. --- .../api/discount-codes/discount-codes.spec.ts | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts index 110b4bcd3ae..6def2c2381c 100644 --- a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts +++ b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts @@ -483,7 +483,7 @@ test("GET /discount-codes – unknown partner", async ({ api, program }) => { }); }); -test("DELETE /discount-codes/{id}", async ({ api }) => { +test("DELETE /discount-codes/{idOrCode} – by id", async ({ api }) => { let partnerId: string | undefined; try { @@ -501,17 +501,39 @@ test("DELETE /discount-codes/{id}", async ({ api }) => { } }); -test("DELETE /discount-codes/{id} – not found", async ({ api }) => { - const { status, data } = await api.delete( - "/api/discount-codes/dcode_does_not_exist", - ); - - expect(status).toEqual(404); - expect(data).toEqual({ - error: { - code: "not_found", - message: "Discount code (dcode_does_not_exist) not found.", - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }); +test("DELETE /discount-codes/{idOrCode} – by code", async ({ api }) => { + let partnerId: string | undefined; + + try { + const created = await createDiscountCode(api); + partnerId = created.partner.id; + + const { status, data } = await api.delete<{ id: string }>( + `/api/discount-codes/${created.data.code}`, + ); + + expect(status).toEqual(200); + expect(data).toEqual({ id: created.data.id }); + } finally { + await deletePartner(partnerId); + } }); + +for (const idOrCode of ["dcode_does_not_exist", "CODE_DOES_NOT_EXIST"]) { + test(`DELETE /discount-codes/{idOrCode} – not found (${idOrCode})`, async ({ + api, + }) => { + const { status, data } = await api.delete( + `/api/discount-codes/${idOrCode}`, + ); + + expect(status).toEqual(404); + expect(data).toEqual({ + error: { + code: "not_found", + message: `Discount code (${idOrCode}) not found.`, + doc_url: "https://dub.co/docs/api-reference/errors#not-found", + }, + }); + }); +} From 2b1d973e267f85061b95fcfbde16df20e268b23c Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 19 Aug 2026 17:44:10 +0530 Subject: [PATCH 19/20] Always show program owner activity on partner applications --- .../swr/use-partner-cross-program-summary.ts | 3 +++ .../partner-application-risk-summary.tsx | 8 ------ .../partner-program-owner-activity.tsx | 27 +++++++++++++++++++ apps/web/ui/partners/partner-info-cards.tsx | 4 +++ 4 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 apps/web/ui/partners/fraud-risks/partner-program-owner-activity.tsx diff --git a/apps/web/lib/swr/use-partner-cross-program-summary.ts b/apps/web/lib/swr/use-partner-cross-program-summary.ts index d49ccd2ed08..80c473b23f1 100644 --- a/apps/web/lib/swr/use-partner-cross-program-summary.ts +++ b/apps/web/lib/swr/use-partner-cross-program-summary.ts @@ -20,6 +20,9 @@ export function usePartnerCrossProgramSummary({ ? `/api/partners/${partnerId}/cross-program-summary?workspaceId=${workspaceId}` : null, fetcher, + { + revalidateOnMount: true, + }, ); return { diff --git a/apps/web/ui/partners/fraud-risks/partner-application-risk-summary.tsx b/apps/web/ui/partners/fraud-risks/partner-application-risk-summary.tsx index f04d9720171..af38641da38 100644 --- a/apps/web/ui/partners/fraud-risks/partner-application-risk-summary.tsx +++ b/apps/web/ui/partners/fraud-risks/partner-application-risk-summary.tsx @@ -11,7 +11,6 @@ import Link from "next/link"; import { useAdvancedUpsellModal } from "../advanced-upsell-modal"; import { PartnerApplicationFraudSeverityIndicator } from "./partner-application-fraud-severity-indicator"; import { usePartnerApplicationRiskSummaryModal } from "./partner-application-risk-summary-modal"; -import { PartnerCrossProgramSummary } from "./partner-cross-program-summary"; import { RiskDisclaimerBanner } from "./risk-disclaimer-banner"; interface PartnerApplicationRiskSummaryProps { @@ -86,13 +85,6 @@ export function PartnerApplicationRiskSummary({ ); })} - - -
-

- Program owner activity -

- {severity === "high" && ( diff --git a/apps/web/ui/partners/fraud-risks/partner-program-owner-activity.tsx b/apps/web/ui/partners/fraud-risks/partner-program-owner-activity.tsx new file mode 100644 index 00000000000..cbc4258ca33 --- /dev/null +++ b/apps/web/ui/partners/fraud-risks/partner-program-owner-activity.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { getPlanCapabilities } from "@/lib/plan-capabilities"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { PartnerCrossProgramSummary } from "./partner-cross-program-summary"; + +export function PartnerProgramOwnerActivity({ + partnerId, +}: { + partnerId: string; +}) { + const { plan } = useWorkspace(); + const { canManageFraudEvents } = getPlanCapabilities(plan); + + if (!canManageFraudEvents) { + return null; + } + + return ( +
+

+ Program owner activity +

+ +
+ ); +} diff --git a/apps/web/ui/partners/partner-info-cards.tsx b/apps/web/ui/partners/partner-info-cards.tsx index cb1c5ae8c65..7682f7f5c4f 100644 --- a/apps/web/ui/partners/partner-info-cards.tsx +++ b/apps/web/ui/partners/partner-info-cards.tsx @@ -40,6 +40,7 @@ import Link from "next/link"; import { Fragment, ReactNode, createElement } from "react"; import useSWR from "swr"; import { PartnerApplicationRiskSummary } from "./fraud-risks/partner-application-risk-summary"; +import { PartnerProgramOwnerActivity } from "./fraud-risks/partner-program-owner-activity"; import { PartnerApplicationRiskBanner, PartnerRiskBanner, @@ -373,6 +374,9 @@ export function PartnerInfoCards({ {partner && isEnrolled && showApplicationRiskAnalysis && ( )} + {partner && isEnrolled && showApplicationRiskAnalysis && ( + + )}
From 26024b1a0b1bf596ad99cc15a4ff4bb59b8ad563 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 19 Aug 2026 10:50:13 -0700 Subject: [PATCH 20/20] cache api.dub.co till next deployment --- apps/web/app/api/route.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/app/api/route.ts b/apps/web/app/api/route.ts index 50e1ba7a48d..b3149410a18 100644 --- a/apps/web/app/api/route.ts +++ b/apps/web/app/api/route.ts @@ -1,8 +1,14 @@ import { document } from "@/lib/openapi"; import { NextResponse } from "next/server"; -export const runtime = "edge"; +export const dynamic = "force-static"; export function GET() { - return NextResponse.json(document); + return NextResponse.json(document, { + headers: { + // cache indefinitely till next deployment + "Vercel-CDN-Cache-Control": "s-maxage=31536000", + "Cache-Control": "public, max-age=31536000", + }, + }); }