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..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( @@ -149,6 +147,7 @@ export const POST = withCron(async ({ rawBody }) => { }, select: { id: true, + webhookEnabled: true, stripeConnectId: true, shopifyStoreId: true, }, 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/app/(ee)/api/discount-codes/[discountCodeId]/route.ts b/apps/web/app/(ee)/api/discount-codes/[idOrCode]/route.ts similarity index 77% 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 091e0984604..8a0d07b5b3a 100644 --- a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/[idOrCode]/route.ts @@ -7,42 +7,38 @@ import { prisma } from "@/lib/prisma"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; -// DELETE /api/discount-codes/[discountCodeId] - soft 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 prisma.discountCode.findUnique({ - where: { - id: discountCodeId, - }, + where: idOrCode.startsWith("dcode_") + ? { id: idOrCode } + : { programId_code: { programId, code: idOrCode } }, include: { - discount: { - select: { - provider: true, - }, - }, + discount: true, }, }); if (!discountCode || !discountCode.discount) { throw new DubApiError({ - message: `Discount code (${discountCodeId}) not found.`, - code: "bad_request", + code: "not_found", + message: `Discount code (${idOrCode}) not found.`, }); } if (discountCode.programId !== programId) { throw new DubApiError({ - message: `Discount code (${discountCodeId}) is not associated with the program.`, - code: "bad_request", + 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/app/(ee)/api/discount-codes/route.ts b/apps/web/app/(ee)/api/discount-codes/route.ts index c8504df0af1..fd7248181a3 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,47 @@ 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, - include: { - discountCodes: true, + 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 }), }, + 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"], @@ -46,17 +68,30 @@ 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, programId, include: { - links: true, discount: true, - discountCodes: true, + links: { + select: { + id: true, + }, + }, + discountCodes: { + select: { + code: true, + linkId: true, + }, + }, partner: { select: { id: true, @@ -85,6 +120,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({ @@ -107,18 +154,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/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", + }, + }); } 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/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/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/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/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/create-discount-code.ts b/apps/web/lib/discounts/create-discount-code.ts index d6d5b02c014..335a0d16747 100644 --- a/apps/web/lib/discounts/create-discount-code.ts +++ b/apps/web/lib/discounts/create-discount-code.ts @@ -1,12 +1,21 @@ 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 { sendWorkspaceWebhook } from "../webhook/publish"; +import { DiscountCodeWebhookSchema } from "../zod/schemas/discount"; import { constructDiscountCode } from "./construct-discount-code"; import { getDiscountProvider } from "./discount-provider"; +const MAX_ATTEMPTS = 3; + interface CreateDiscountCodeArgs { - workspace: Pick; + workspace: Pick< + Project, + "id" | "stripeConnectId" | "shopifyStoreId" | "webhookEnabled" + >; partner: Pick; link: Pick; discount: Discount; @@ -28,8 +37,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) { @@ -40,42 +57,153 @@ 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 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( + (async () => { + await sendWorkspaceWebhook({ + workspace, + trigger: "discount_code.created", + data: DiscountCodeWebhookSchema.parse(discountCode), + }); + })(), + ); + + 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 { - return 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, discountId: discount.id, }, + include: { + discount: true, + }, }); + + return { + discountCode, + }; } catch (error) { - try { - await discountProvider.disableDiscountCode({ + const isUniqueConflict = + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002"; + + if (isUniqueConflict && canRetry) { + await rollbackExternalDiscountCode({ + discountProvider, workspace, - code: externalDiscountCode.code, + code, }); - } catch (rollbackError) { - console.error("Failed to rollback external discount code", { - code: externalDiscountCode.code, - rollbackError, + + 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.`, + }); + } + + 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: @@ -86,3 +214,25 @@ export async function createDiscountCode({ throw error; } } + +async function rollbackExternalDiscountCode({ + discountProvider, + workspace, + code, +}: { + discountProvider: ReturnType; + workspace: CreateDiscountCodeArgs["workspace"]; + code: string; +}) { + try { + await discountProvider.disableDiscountCode({ + workspace, + 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 3594084d773..08f1d083725 100644 --- a/apps/web/lib/discounts/delete-discount-code.ts +++ b/apps/web/lib/discounts/delete-discount-code.ts @@ -1,13 +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 { sendWorkspaceWebhook } from "../webhook/publish"; + +type DiscountCodeWebhookDiscount = z.infer< + typeof DiscountCodeWebhookSchema +>["discount"]; type DeleteDiscountCodesParams = Pick< DiscountCode, - "id" | "code" | "programId" + "id" | "code" | "programId" | "partnerId" | "linkId" | "disabledAt" > & { - discount: Pick | null; + discount: DiscountCodeWebhookDiscount; }; type EnqueueDeleteDiscountCodeParams = Pick< @@ -38,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: { @@ -46,13 +56,22 @@ export async function deleteDiscountCodes( }, }, data: { - disabledAt: new Date(), + disabledAt, }, }); console.log( `[deleteDiscountCodes] Disabled ${disabledDiscountCodes.count} discount codes.`, ); + + waitUntil( + sendDiscountCodeDeletedWebhooks( + discountCodes.map((discountCode) => ({ + ...discountCode, + disabledAt, + })), + ), + ); } else { // Delete the discount codes from the database const deletedDiscountCodes = await prisma.discountCode.deleteMany({ @@ -66,6 +85,8 @@ export async function deleteDiscountCodes( console.log( `[deleteDiscountCodes] Deleted ${deletedDiscountCodes.count} discount codes.`, ); + + waitUntil(sendDiscountCodeDeletedWebhooks(discountCodes)); } await enqueueDeleteDiscountCode(discountCodes); @@ -74,12 +95,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) { @@ -104,3 +126,42 @@ export async function enqueueDeleteDiscountCode( ); } } + +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 sendWorkspaceWebhook({ + workspace, + trigger: "discount_code.deleted", + data: DiscountCodeWebhookSchema.parse(discountCode), + }); + }), + ); +} 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/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/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/lib/discounts/is-discount-equivalent.ts b/apps/web/lib/discounts/is-discount-equivalent.ts index fc8d6ec0939..55ffef88fd1 100644 --- a/apps/web/lib/discounts/is-discount-equivalent.ts +++ b/apps/web/lib/discounts/is-discount-equivalent.ts @@ -1,26 +1,36 @@ 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) { - return true; + if (firstDiscount.provider !== secondDiscount.provider) { + return false; } - // If both discounts are effectively equivalent + // If both groups use the same coupon if ( - firstDiscount.amount === secondDiscount.amount && - firstDiscount.type === secondDiscount.type && - firstDiscount.maxDuration === secondDiscount.maxDuration + firstDiscount.couponId && + secondDiscount.couponId && + firstDiscount.couponId === secondDiscount.couponId ) { return true; } - return false; + // If both discounts are effectively equivalent + return ( + firstDiscount.provider === secondDiscount.provider && + firstDiscount.amount === secondDiscount.amount && + firstDiscount.type === secondDiscount.type && + firstDiscount.maxDuration === secondDiscount.maxDuration + ); } diff --git a/apps/web/lib/integrations/slack/transform.ts b/apps/web/lib/integrations/slack/transform.ts index f3288b5255e..28bddd1720e 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,44 @@ const payoutConfirmedTemplate = ({ }; }; +const discountCodeTemplates = ({ + data, + event, +}: { + data: DiscountCodeEventWebhookPayload; + event: WebhookTrigger; +}) => { + const eventMessages = { + "discount_code.created": "*Discount code created* :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 +648,8 @@ const slackTemplates: Record = { "bounty.created": bountyTemplates, "bounty.updated": bountyTemplates, "payout.confirmed": payoutConfirmedTemplate, + "discount_code.created": discountCodeTemplates, + "discount_code.deleted": discountCodeTemplates, }; export const formatEventForSlack = ( @@ -625,9 +666,13 @@ export const formatEventForSlack = ( event, ); const isBountyEvent = ["bounty.created", "bounty.updated"].includes(event); + const isDiscountCodeEvent = [ + "discount_code.created", + "discount_code.deleted", + ].includes(event); return template({ data, - ...((isLinkEvent || isBountyEvent) && { event }), + ...((isLinkEvent || isBountyEvent || isDiscountCodeEvent) && { event }), }); }; 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..2de1f2ebf91 --- /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. 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": { + 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..06d8d21887a --- /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 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({ + 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 unique ID (e.g. `dcode_...`).", + 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..f5ec228406e --- /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/{idOrCode}": { + 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..d35965253f4 --- /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 paginated list of discount codes for a partner / a given discount / the whole 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/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/lib/webhook/constants.ts b/apps/web/lib/webhook/constants.ts index 4040bbf31aa..951492774d9 100644 --- a/apps/web/lib/webhook/constants.ts +++ b/apps/web/lib/webhook/constants.ts @@ -26,6 +26,8 @@ export const PROGRAM_LEVEL_WEBHOOK_TRIGGERS = [ "bounty.created", "bounty.updated", "payout.confirmed", + "discount_code.created", + "discount_code.deleted", ] as const; export const WEBHOOK_TRIGGERS = [ @@ -46,6 +48,8 @@ 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.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/payload.ts b/apps/web/lib/webhook/sample-events/payload.ts index 546af936567..88d783eef34 100644 --- a/apps/web/lib/webhook/sample-events/payload.ts +++ b/apps/web/lib/webhook/sample-events/payload.ts @@ -2,6 +2,8 @@ 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 leadCreated from "./lead-created.json"; import linkClicked from "./link-clicked.json"; import linkCreated from "./link-created.json"; @@ -25,4 +27,6 @@ export const samplePayload: Record = { "bounty.created": bountyCreated, "bounty.updated": bountyUpdated, "payout.confirmed": payoutConfirmed, + "discount_code.created": discountCodeCreated, + "discount_code.deleted": discountCodeDeleted, }; diff --git a/apps/web/lib/webhook/schemas.ts b/apps/web/lib/webhook/schemas.ts index c67e5c8efca..2aa2d8fceee 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,22 @@ 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.deleted"), + ]), + createdAt: z.string(), + data: DiscountCodeWebhookSchema, + }) + .meta({ + description: "Triggered when a discount code is created 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/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index e291a670358..a08462ac03a 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(), @@ -56,19 +56,41 @@ 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 alphanumeric 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. We don't delete the discount code to avoid another partner claiming a banned/deactivated code (abuse vector).", + ), + }) + .meta({ + title: "DiscountCode", + }); export const createDiscountCodeSchema = z.object({ code: z @@ -80,11 +102,43 @@ export const createDiscountCodeSchema = z.object({ "Code can only contain letters, numbers, dashes, and underscores.", ) .optional() - .or(z.literal("").transform(() => undefined)), - partnerId: z.string(), - linkId: z.string(), + .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() + .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(), +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..6def2c2381c --- /dev/null +++ b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts @@ -0,0 +1,539 @@ +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"; +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, +}; + +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, + overrides: Record = {}, +) { + if (!partnerGroupId) { + throw new Error("Custom discount group was not seeded."); + } + + return api.post("/api/partners", { + name: randomName(), + email: randomPartnerEmail(), + groupId: partnerGroupId, + ...overrides, + }); +} + +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(data).toEqual({ + id: expect.any(String), + code: body.code, + discountId: customDiscountId, + partnerId: partner.id, + linkId: body.linkId, + disabledAt: null, + }); + } 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 – 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, +}) => { + 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; + + 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("DELETE /discount-codes/{idOrCode} – by 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/{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", + }, + }); + }); +} 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/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 { 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); - }); -}); diff --git a/apps/web/tests/webhooks/index.test.ts b/apps/web/tests/webhooks/index.test.ts index 863522a25f9..96f0510bf30 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,8 @@ const eventSchemas: Record = { "bounty.created": bountyWebhookEventSchemaExtended, "bounty.updated": bountyWebhookEventSchemaExtended, "payout.confirmed": payoutWebhookEventSchemaExtended, + "discount_code.created": DiscountCodeWebhookSchema, + "discount_code.deleted": DiscountCodeWebhookSchema, }; describe("Webhooks", () => { diff --git a/apps/web/ui/modals/delete-discount-code-modal.tsx b/apps/web/ui/modals/delete-discount-code-modal.tsx index 0406cb06054..64a55d6efea 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, useEffect, 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,11 @@ export const DeleteDiscountCodeModal = ({ }: DeleteDiscountCodeModalProps) => { const { isMobile } = useMediaQuery(); const { makeRequest: deleteDiscountCode, isSubmitting } = useApiMutation(); + const [inputValue, setInputValue] = useState(""); + + useEffect(() => { + setInputValue(""); + }, [showModal, discountCode.id]); const onSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -62,7 +69,10 @@ export const DeleteDiscountCodeModal = ({

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

@@ -75,7 +85,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 +108,7 @@ export const DeleteDiscountCodeModal = ({ text="Delete discount code" variant="danger" loading={isSubmitting} + disabled={inputValue !== DELETE_DISCOUNT_CODE_CONFIRMATION} className="h-9 w-fit" /> 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 ? ( +
+ +
) : ( )} 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 && ( + + )}