diff --git a/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts b/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts index f4ad47a8ec..bd57dd09e4 100644 --- a/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts +++ b/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts @@ -166,29 +166,27 @@ export const PATCH = withWorkspace( } waitUntil( - (async () => { - await Promise.allSettled([ - syncTotalCommissions({ - partnerId: commission.partnerId, - programId: commission.programId, - }), - - recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "commission.updated", - description: `Commission ${commissionId} updated`, - actor: session.user, - targets: [ - { - type: "commission", - id: commission.id, - metadata: updatedCommission, - }, - ], - }), - ]); - })(), + Promise.allSettled([ + syncTotalCommissions({ + partnerId: commission.partnerId, + programId: commission.programId, + }), + + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "commission.updated", + description: `Commission ${commissionId} updated`, + actor: session.user, + targets: [ + { + type: "commission", + id: commission.id, + metadata: updatedCommission, + }, + ], + }), + ]), ); return NextResponse.json(CommissionEnrichedSchema.parse(updatedCommission)); diff --git a/apps/web/app/(ee)/api/cron/discount-codes/[discountCodeId]/delete/route.ts b/apps/web/app/(ee)/api/cron/discount-codes/[discountCodeId]/delete/route.ts new file mode 100644 index 0000000000..fd7af33681 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/discount-codes/[discountCodeId]/delete/route.ts @@ -0,0 +1,62 @@ +import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { disableStripeDiscountCode } from "@/lib/stripe/disable-stripe-discount-code"; +import { prisma } from "@dub/prisma"; +import { logAndRespond } from "../../../utils"; + +export const dynamic = "force-dynamic"; + +// POST /api/cron/discount-codes/[discountCodeId]/delete +export async function POST( + req: Request, + { params }: { params: Promise<{ discountCodeId: string }> }, +) { + try { + const { discountCodeId } = await params; + + const rawBody = await req.text(); + + await verifyQstashSignature({ + req, + rawBody, + }); + + const discountCode = await prisma.discountCode.findUnique({ + where: { + id: discountCodeId, + }, + }); + + if (!discountCode) { + return logAndRespond(`Discount code ${discountCodeId} not found.`); + } + + const workspace = await prisma.project.findUniqueOrThrow({ + where: { + defaultProgramId: discountCode.programId, + }, + select: { + stripeConnectId: true, + }, + }); + + const disabledDiscountCode = await disableStripeDiscountCode({ + code: discountCode.code, + stripeConnectId: workspace.stripeConnectId, + }); + + if (disabledDiscountCode) { + await prisma.discountCode.delete({ + where: { + id: discountCodeId, + }, + }); + } + + return logAndRespond( + `Discount code ${discountCode.code} disabled from Stripe for ${workspace.stripeConnectId}.`, + ); + } catch (error) { + return handleAndReturnErrorResponse(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 new file mode 100644 index 0000000000..33dda378cb --- /dev/null +++ b/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts @@ -0,0 +1,113 @@ +import { isDiscountEquivalent } from "@/lib/api/discounts/is-discount-equivalent"; +import { queueDiscountCodeDeletion } from "@/lib/api/discounts/queue-discount-code-deletion"; +import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { prisma } from "@dub/prisma"; +import { z } from "zod"; +import { logAndRespond } from "../../utils"; + +export const dynamic = "force-dynamic"; + +const schema = z.object({ + programId: z.string(), + partnerIds: z.array(z.string()), + groupId: z.string(), +}); + +// POST /api/cron/groups/remap-discount-codes +export async function POST(req: Request) { + try { + const rawBody = await req.text(); + + await verifyQstashSignature({ + req, + rawBody, + }); + + const { programId, partnerIds, groupId } = schema.parse( + JSON.parse(rawBody), + ); + + if (partnerIds.length === 0) { + return logAndRespond("No partner IDs provided."); + } + + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + partnerId: { + in: partnerIds, + }, + programId, + }, + include: { + discountCodes: { + include: { + discount: true, + }, + }, + }, + }); + + if (programEnrollments.length === 0) { + return logAndRespond("No program enrollments found."); + } + + const group = await prisma.partnerGroup.findUnique({ + where: { + id: groupId, + }, + include: { + discount: true, + }, + }); + + if (!group) { + return logAndRespond("Group not found."); + } + + const discountCodes = programEnrollments.flatMap( + ({ discountCodes }) => discountCodes, + ); + + const discountCodesToUpdate: string[] = []; + const discountCodesToRemove: string[] = []; + + for (const discountCode of discountCodes) { + const keepDiscountCode = isDiscountEquivalent( + group.discount, + discountCode.discount, + ); + + if (keepDiscountCode) { + discountCodesToUpdate.push(discountCode.id); + } else { + discountCodesToRemove.push(discountCode.id); + } + } + + // Update the discount codes to use the new discount + if (discountCodesToUpdate.length > 0) { + await prisma.discountCode.updateMany({ + where: { + id: { + in: discountCodesToUpdate, + }, + }, + data: { + discountId: group.discount?.id, + }, + }); + } + + // Remove the discount codes from the group + if (discountCodesToRemove.length > 0) { + await queueDiscountCodeDeletion(discountCodesToRemove); + } + + return logAndRespond( + `Updated ${discountCodesToUpdate.length} discount codes and removed ${discountCodesToRemove.length} discount codes.`, + ); + } catch (error) { + return handleAndReturnErrorResponse(error); + } +} diff --git a/apps/web/app/(ee)/api/cron/links/invalidate-for-discounts/route.ts b/apps/web/app/(ee)/api/cron/links/invalidate-for-discounts/route.ts index ae5db84003..668e525cbb 100644 --- a/apps/web/app/(ee)/api/cron/links/invalidate-for-discounts/route.ts +++ b/apps/web/app/(ee)/api/cron/links/invalidate-for-discounts/route.ts @@ -4,6 +4,7 @@ import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; import { chunk } from "@dub/utils"; import { z } from "zod"; +import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; @@ -34,8 +35,9 @@ export async function POST(req: Request) { }); if (!group) { - console.error(`Group ${groupId} not found.`); - return new Response("OK"); + return logAndRespond(`Group ${groupId} not found.`, { + logLevel: "error", + }); } // Find all the links of the partners in the group @@ -59,30 +61,28 @@ export async function POST(req: Request) { }); if (programEnrollments.length === 0) { - console.log(`No program enrollments found for group ${groupId}.`); - return new Response("OK"); + return logAndRespond( + `No program enrollments found for group ${groupId}.`, + ); } const links = programEnrollments.flatMap((enrollment) => enrollment.links); if (links.length === 0) { - console.log(`No links found for partners in the group ${groupId}.`); - return new Response("OK"); + return logAndRespond( + `No links found for partners in the group ${groupId}.`, + ); } - console.log(`Found ${links.length} links to invalidate the cache for.`); - const linkChunks = chunk(links, 100); // Expire the cache for the links for (const linkChunk of linkChunks) { const toExpire = linkChunk.map(({ domain, key }) => ({ domain, key })); await linkCache.expireMany(toExpire); - console.log(toExpire); - console.log(`Expired cache for ${toExpire.length} links.`); } - return new Response("OK"); + return logAndRespond(`Expired cache for ${links.length} links.`); } catch (error) { return handleAndReturnErrorResponse(error); } diff --git a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts new file mode 100644 index 0000000000..e559c6857a --- /dev/null +++ b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts @@ -0,0 +1,78 @@ +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { queueDiscountCodeDeletion } from "@/lib/api/discounts/queue-discount-code-deletion"; +import { DubApiError } from "@/lib/api/errors"; +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { withWorkspace } from "@/lib/auth"; +import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; +import { NextResponse } from "next/server"; + +// 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 prisma.discountCode.findUnique({ + where: { + id: discountCodeId, + }, + }); + + if (!discountCode || !discountCode.discountId) { + throw new DubApiError({ + message: `Discount code (${discountCodeId}) not found.`, + code: "bad_request", + }); + } + + if (discountCode.programId !== programId) { + throw new DubApiError({ + message: `Discount code (${discountCodeId}) is not associated with the program.`, + code: "bad_request", + }); + } + + await prisma.discountCode.update({ + where: { + id: discountCodeId, + }, + data: { + discountId: null, + }, + }); + + waitUntil( + Promise.allSettled([ + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "discount_code.deleted", + description: `Discount code (${discountCode.code}) deleted`, + actor: session.user, + targets: [ + { + type: "discount_code", + id: discountCode.id, + metadata: discountCode, + }, + ], + }), + + queueDiscountCodeDeletion(discountCode.id), + ]), + ); + + return NextResponse.json({ id: discountCode.id }); + }, + { + requiredPlan: [ + "business", + "business plus", + "business extra", + "business max", + "advanced", + "enterprise", + ], + }, +); diff --git a/apps/web/app/(ee)/api/discount-codes/route.ts b/apps/web/app/(ee)/api/discount-codes/route.ts new file mode 100644 index 0000000000..87fb066625 --- /dev/null +++ b/apps/web/app/(ee)/api/discount-codes/route.ts @@ -0,0 +1,190 @@ +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { createId } from "@/lib/api/create-id"; +import { DubApiError } from "@/lib/api/errors"; +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; +import { parseRequestBody } from "@/lib/api/utils"; +import { withWorkspace } from "@/lib/auth"; +import { createStripeDiscountCode } from "@/lib/stripe/create-stripe-discount-code"; +import { + createDiscountCodeSchema, + DiscountCodeSchema, + getDiscountCodesQuerySchema, +} from "@/lib/zod/schemas/discount"; +import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; +import { NextResponse } from "next/server"; + +// GET /api/discount-codes - get all discount codes for a partner +export const GET = withWorkspace( + async ({ workspace, searchParams }) => { + const programId = getDefaultProgramIdOrThrow(workspace); + + const { partnerId } = getDiscountCodesQuerySchema.parse(searchParams); + + const programEnrollment = await getProgramEnrollmentOrThrow({ + partnerId, + programId, + includeDiscountCodes: true, + }); + + const response = DiscountCodeSchema.array().parse( + programEnrollment.discountCodes, + ); + + return NextResponse.json(response); + }, + { + requiredPlan: [ + "business", + "business plus", + "business extra", + "business max", + "advanced", + "enterprise", + ], + }, +); + +// POST /api/discount-codes - create a discount code +export const POST = withWorkspace( + async ({ workspace, req, session }) => { + const programId = getDefaultProgramIdOrThrow(workspace); + + const { partnerId, linkId, code } = createDiscountCodeSchema.parse( + await parseRequestBody(req), + ); + + if (!workspace.stripeConnectId) { + throw new DubApiError({ + code: "bad_request", + message: + "Your workspace isn't connected to Stripe yet. Please install the Dub Stripe app in settings to create discount codes.", + }); + } + + const programEnrollment = await getProgramEnrollmentOrThrow({ + partnerId, + programId, + includeDiscount: true, + includeDiscountCodes: true, + }); + + const link = programEnrollment.links.find((link) => link.id === linkId); + + if (!link) { + throw new DubApiError({ + code: "bad_request", + message: "Partner link not found.", + }); + } + + const { discount } = programEnrollment; + + if (!discount) { + throw new DubApiError({ + code: "bad_request", + message: + "No discount is assigned to this partner group. Please add a discount before proceeding.", + }); + } + + // Check for duplicate by code + if (code) { + const duplicateByCode = await prisma.discountCode.findUnique({ + where: { + programId_code: { + programId, + code, + }, + }, + }); + + if (duplicateByCode) { + throw new DubApiError({ + code: "bad_request", + message: `A discount with the code ${code} already exists in the program. Please choose a different code.`, + }); + } + } + + // 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.`, + }); + } + + // Use the link.key as the code if no code is provided + const finalCode = code || link.key; + + try { + const stripeDiscountCode = await createStripeDiscountCode({ + stripeConnectId: workspace.stripeConnectId, + discount, + code: finalCode, + shouldRetry: !code, + }); + + if (!stripeDiscountCode?.code) { + throw new DubApiError({ + code: "bad_request", + message: "Failed to create Stripe discount code. Please try again.", + }); + } + + const discountCode = await prisma.discountCode.create({ + data: { + id: createId({ prefix: "dcode_" }), + code: stripeDiscountCode.code, + programId, + partnerId, + linkId, + discountId: discount.id, + }, + }); + + waitUntil( + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "discount_code.created", + description: `Discount code (${discountCode.code}) created`, + actor: session.user, + targets: [ + { + type: "discount_code", + id: discountCode.id, + metadata: discountCode, + }, + ], + }), + ); + + return NextResponse.json(DiscountCodeSchema.parse(discountCode)); + } catch (error) { + throw new DubApiError({ + code: "bad_request", + message: + error.code === "more_permissions_required_for_application" + ? "STRIPE_APP_UPGRADE_REQUIRED: Your connected Stripe account doesn't have the permissions needed to create discount codes. Please upgrade your Stripe integration in settings or reach out to our support team for help." + : error.message, + }); + } + }, + { + requiredPlan: [ + "business", + "business plus", + "business extra", + "business max", + "advanced", + "enterprise", + ], + }, +); diff --git a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/partners/route.ts b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/partners/route.ts index b638dbdd23..cc84e5f9cd 100644 --- a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/partners/route.ts +++ b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/partners/route.ts @@ -68,6 +68,15 @@ export const POST = withWorkspace( }, }), + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-discount-codes`, + body: { + programId, + partnerIds, + groupId: group.id, + }, + }), + triggerDraftBountySubmissionCreation({ programId, partnerIds, diff --git a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts index c92d7aa9db..d5d9be53b4 100644 --- a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts +++ b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts @@ -1,4 +1,6 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { isDiscountEquivalent } from "@/lib/api/discounts/is-discount-equivalent"; +import { queueDiscountCodeDeletion } from "@/lib/api/discounts/queue-discount-code-deletion"; import { DubApiError } from "@/lib/api/errors"; import { getGroupOrThrow } from "@/lib/api/groups/get-group-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; @@ -14,7 +16,7 @@ import { } from "@/lib/zod/schemas/groups"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, constructURLFromUTMParams } from "@dub/utils"; -import { Prisma } from "@prisma/client"; +import { DiscountCode, Prisma } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -232,8 +234,10 @@ export const DELETE = withWorkspace( }, include: { partners: true, + discount: true, }, }), + prisma.partnerGroup.findUniqueOrThrow({ where: { programId_slug: { @@ -241,6 +245,9 @@ export const DELETE = withWorkspace( slug: DEFAULT_PARTNER_GROUP.slug, }, }, + include: { + discount: true, + }, }), ]); @@ -250,7 +257,23 @@ export const DELETE = withWorkspace( message: "You cannot delete the default group of your program.", }); } - await prisma.$transaction(async (tx) => { + + const keepDiscountCodes = isDiscountEquivalent( + group.discount, + defaultGroup.discount, + ); + + // Cache discount codes to delete them later + let discountCodesToDelete: DiscountCode[] = []; + if (group.discountId && !keepDiscountCodes) { + discountCodesToDelete = await prisma.discountCode.findMany({ + where: { + discountId: group.discountId, + }, + }); + } + + const deletedGroup = await prisma.$transaction(async (tx) => { // 1. Update all partners in the group to the default group await tx.programEnrollment.updateMany({ where: { @@ -280,8 +303,18 @@ export const DELETE = withWorkspace( }); } - // 3. Delete the group's discount if (group.discountId) { + // 3. Update the discount codes + await tx.discountCode.updateMany({ + where: { + discountId: group.discountId, + }, + data: { + discountId: keepDiscountCodes ? defaultGroup.discountId : null, + }, + }); + + // 4. Delete the group's discount await tx.discount.delete({ where: { id: group.discountId, @@ -289,46 +322,54 @@ export const DELETE = withWorkspace( }); } - // 4. Delete the group + // 5. Delete the group await tx.partnerGroup.delete({ where: { id: group.id, }, }); + + return true; }); const partnerIds = group.partners.map(({ partnerId }) => partnerId); - waitUntil( - Promise.allSettled([ - partnerIds.length > 0 && - qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-default-links`, - body: { - programId, - groupId: defaultGroup.id, - partnerIds, - userId: session.user.id, - isGroupDeleted: true, - }, - }), + if (deletedGroup) { + waitUntil( + Promise.allSettled([ + partnerIds.length > 0 && + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-default-links`, + body: { + programId, + groupId: defaultGroup.id, + partnerIds, + userId: session.user.id, + isGroupDeleted: true, + }, + }), - recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "group.deleted", - description: `Group ${group.name} (${group.id}) deleted`, - actor: session.user, - targets: [ - { - type: "group", - id: group.id, - metadata: group, - }, - ], - }), - ]), - ); + ...discountCodesToDelete.map((discountCode) => + queueDiscountCodeDeletion(discountCode.id), + ), + + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "group.deleted", + description: `Group ${group.name} (${group.id}) deleted`, + actor: session.user, + targets: [ + { + type: "group", + id: group.id, + metadata: group, + }, + ], + }), + ]), + ); + } return NextResponse.json({ id: group.id }); }, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts index d3290ec074..0e6993b720 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts @@ -6,6 +6,7 @@ import { parseRequestBody } from "@/lib/api/utils"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withPartnerProfile } from "@/lib/auth/partner"; import { NewLinkProps } from "@/lib/types"; +import { PartnerProfileLinkSchema } from "@/lib/zod/schemas/partner-profile"; import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { getPrettyUrl } from "@dub/utils"; @@ -142,6 +143,6 @@ export const PATCH = withPartnerProfile( updatedLink: processedLink, }); - return NextResponse.json(partnerLink); + return NextResponse.json(PartnerProfileLinkSchema.parse(partnerLink)); }, ); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts index efe71a0015..c5a813b8ed 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts @@ -9,17 +9,31 @@ import { PartnerProfileLinkSchema } from "@/lib/zod/schemas/partner-profile"; import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; +import { z } from "zod"; // GET /api/partner-profile/programs/[programId]/links - get a partner's links in a program export const GET = withPartnerProfile(async ({ partner, params }) => { - const { links } = await getProgramEnrollmentOrThrow({ + const { links, discountCodes } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, + includeDiscountCodes: true, }); - return NextResponse.json( - links.map((link) => PartnerProfileLinkSchema.parse(link)), + // Add discount code to the links + const linksByDiscountCode = new Map( + discountCodes?.map((discountCode) => [discountCode.linkId, discountCode]), ); + + const result = links.map((link) => { + const discountCode = linksByDiscountCode.get(link.id); + + return { + ...link, + discountCode: discountCode?.code, + }; + }); + + return NextResponse.json(z.array(PartnerProfileLinkSchema).parse(result)); }); // POST /api/partner-profile/[programId]/links - create a link for a partner diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts index a13358901e..988f986d77 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts @@ -3,6 +3,7 @@ import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; import { createId } from "@/lib/api/create-id"; import { includeTags } from "@/lib/api/links/include-tags"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; +import { generateRandomName } from "@/lib/names"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; import { getClickEvent, @@ -10,6 +11,7 @@ import { recordLead, recordSale, } from "@/lib/tinybird"; +import { recordFakeClick } from "@/lib/tinybird/record-fake-click"; import { ClickEventTB, LeadEventTB, WebhookPartner } from "@/lib/types"; import { redis } from "@/lib/upstash"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; @@ -19,31 +21,35 @@ import { } from "@/lib/webhook/transform"; import { prisma } from "@dub/prisma"; import { Customer, WorkflowTrigger } from "@dub/prisma/client"; -import { nanoid } from "@dub/utils"; +import { COUNTRIES_TO_CONTINENTS, nanoid } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; -import { - getConnectedCustomer, - getSubscriptionProductId, - updateCustomerWithStripeCustomerId, -} from "./utils"; +import { getConnectedCustomer } from "./utils/get-connected-customer"; +import { getPromotionCode } from "./utils/get-promotion-code"; +import { getSubscriptionProductId } from "./utils/get-subscription-product-id"; +import { updateCustomerWithStripeCustomerId } from "./utils/update-customer-with-stripe-customer-id"; // Handle event "checkout.session.completed" export async function checkoutSessionCompleted(event: Stripe.Event) { let charge = event.data.object as Stripe.Checkout.Session; - let dubCustomerId = charge.metadata?.dubCustomerId; + let dubCustomerExternalId = charge.metadata?.dubCustomerId; // TODO: need to update to dubCustomerExternalId in the future for consistency const clientReferenceId = charge.client_reference_id; const stripeAccountId = event.account as string; const stripeCustomerId = charge.customer as string; const stripeCustomerName = charge.customer_details?.name; const stripeCustomerEmail = charge.customer_details?.email; const invoiceId = charge.invoice as string; + const promotionCodeId = charge.discounts?.[0]?.promotion_code as + | string + | null + | undefined; let customer: Customer | null = null; let existingCustomer: Customer | null = null; let clickEvent: ClickEventTB | null = null; - let leadEvent: LeadEventTB; - let linkId: string; + let leadEvent: LeadEventTB | undefined; + let linkId: string | undefined; + let shouldSendLeadWebhook = true; /* for stripe checkout links: @@ -139,7 +145,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { } else if (stripeCustomerId) { /* for regular stripe checkout setup (provided stripeCustomerId is present): - - if dubCustomerId is provided: + - if dubCustomerExternalId is provided: - we update the customer with the stripe customerId (for future events) - else: - we first try to see if the customer with the Stripe ID already exists in Dub @@ -149,17 +155,25 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { - we update the customer with the stripe customerId - we then find the lead event using the customer's unique ID on Dub - the lead event will then be passed to the remaining logic to record a sale - - if not present, we skip the event + - if not present: + - we check if a promotion code was used in the checkout + - if a promotion code is present, we try to attribute via the promotion code: + - confirm the promotion code exists in Stripe + - find the associated discount code and link in Dub + - record a fake click event for attribution + - create a new customer and lead event + - proceed with sale recording + - if no promotion code or attribution fails, we skip the event */ - if (dubCustomerId) { + if (dubCustomerExternalId) { customer = await updateCustomerWithStripeCustomerId({ stripeAccountId, - dubCustomerId, + dubCustomerExternalId, stripeCustomerId, }); if (!customer) { - return `dubCustomerId was provided but customer with dubCustomerId ${dubCustomerId} not found on Dub, skipping...`; + return `dubCustomerExternalId was provided but customer with dubCustomerExternalId ${dubCustomerExternalId} not found on Dub, skipping...`; } } else { existingCustomer = await prisma.customer.findUnique({ @@ -169,7 +183,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { }); if (existingCustomer) { - dubCustomerId = existingCustomer.externalId ?? stripeCustomerId; + dubCustomerExternalId = existingCustomer.externalId ?? stripeCustomerId; customer = existingCustomer; } else { const connectedCustomer = await getConnectedCustomer({ @@ -178,30 +192,48 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { livemode: event.livemode, }); - if (!connectedCustomer || !connectedCustomer.metadata.dubCustomerId) { - return `dubCustomerId not found in Stripe checkout session metadata (nor is it available in Dub, or on the connected customer ${stripeCustomerId}) and client_reference_id is not a dub_id, skipping...`; - } - - dubCustomerId = connectedCustomer.metadata.dubCustomerId; - customer = await updateCustomerWithStripeCustomerId({ - stripeAccountId, - dubCustomerId, - stripeCustomerId, - }); - if (!customer) { - return `dubCustomerId was found on the connected customer ${stripeCustomerId} but customer with dubCustomerId ${dubCustomerId} not found on Dub, skipping...`; + if (connectedCustomer?.metadata.dubCustomerId) { + dubCustomerExternalId = connectedCustomer.metadata.dubCustomerId; // TODO: need to update to dubCustomerExternalId in the future for consistency + customer = await updateCustomerWithStripeCustomerId({ + stripeAccountId, + dubCustomerExternalId, + stripeCustomerId, + }); + if (!customer) { + return `dubCustomerExternalId was found on the connected customer ${stripeCustomerId} but customer with dubCustomerExternalId ${dubCustomerExternalId} not found on Dub, skipping...`; + } + } else if (promotionCodeId) { + const promoCodeResponse = await attributeViaPromoCode({ + promotionCodeId, + stripeAccountId, + livemode: event.livemode, + charge, + }); + if (promoCodeResponse) { + ({ linkId, customer, clickEvent, leadEvent } = promoCodeResponse); + shouldSendLeadWebhook = false; + } else { + return `Failed to attribute via promotion code ${promotionCodeId}, skipping...`; + } + } else { + return `dubCustomerExternalId not found in Stripe checkout session metadata (nor is it available on the connected customer ${stripeCustomerId}), client_reference_id is not a dub_id, and promotion code is not provided, skipping...`; } } } - // Find lead - leadEvent = await getLeadEvent({ customerId: customer.id }).then( - (res) => res.data[0], - ); + // if leadEvent is not defined yet, we need to pull it from Tinybird + if (!leadEvent) { + leadEvent = await getLeadEvent({ customerId: customer.id }).then( + (res) => res.data[0], + ); + if (!leadEvent) { + return `No lead event found for customer ${customer.id}, skipping...`; + } - linkId = leadEvent.link_id; + linkId = leadEvent.link_id as string; + } } else { - return "No dubCustomerId or stripeCustomerId found in Stripe checkout session metadata, skipping..."; + return "No stripeCustomerId or dubCustomerExternalId found in Stripe checkout session metadata, skipping..."; } if (charge.amount_total === 0) { @@ -218,7 +250,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { `trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers { timestamp: new Date().toISOString(), - dubCustomerId, + dubCustomerExternalId, stripeCustomerId, stripeAccountId, invoiceId, @@ -262,11 +294,9 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { } } - const eventId = nanoid(16); - const saleData = { ...leadEvent, - event_id: eventId, + event_id: nanoid(16), // if the charge is a one-time payment, we set the event name to "Purchase" event_name: charge.mode === "payment" ? "Purchase" : "Subscription creation", @@ -365,7 +395,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { programId: link.programId, partnerId: link.partnerId, linkId: link.id, - eventId, + eventId: saleData.event_id, customerId: customer.id, amount: saleData.amount, quantity: 1, @@ -419,7 +449,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { (async () => { // if the clickEvent variable exists and there was no existing customer before, // we send a lead.created webhook - if (clickEvent && !existingCustomer) { + if (clickEvent && !existingCustomer && shouldSendLeadWebhook) { await sendWorkspaceWebhook({ trigger: "lead.created", workspace, @@ -450,5 +480,131 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { })(), ); - return `Checkout session completed for customer with external ID ${dubCustomerId} and invoice ID ${invoiceId}`; + return `Checkout session completed for customer with external ID ${dubCustomerExternalId} and invoice ID ${invoiceId}`; +} + +async function attributeViaPromoCode({ + promotionCodeId, + stripeAccountId, + livemode, + charge, +}: { + promotionCodeId: string; + stripeAccountId: string; + livemode: boolean; + charge: Stripe.Checkout.Session; +}) { + // Find the promotion code for the promotion code id + const promotionCode = await getPromotionCode({ + promotionCodeId, + stripeAccountId, + livemode, + }); + + if (!promotionCode) { + console.log( + `Promotion code ${promotionCodeId} not found in connected account ${stripeAccountId}, skipping...`, + ); + return null; + } + + // Find the workspace + const workspace = await prisma.project.findUnique({ + where: { + stripeConnectId: stripeAccountId, + }, + select: { + id: true, + stripeConnectId: true, + defaultProgramId: true, + }, + }); + + if (!workspace) { + console.log( + `Workspace with stripeConnectId ${stripeAccountId} not found, skipping...`, + ); + return null; + } + + if (!workspace.defaultProgramId) { + console.log( + `Workspace with stripeConnectId ${stripeAccountId} has no default program, skipping...`, + ); + return null; + } + + const discountCode = await prisma.discountCode.findUnique({ + where: { + programId_code: { + programId: workspace.defaultProgramId, + code: promotionCode.code, + }, + }, + select: { + link: true, + }, + }); + + if (!discountCode) { + console.log( + `Couldn't find link associated with promotion code ${promotionCode.code}, skipping...`, + ); + return null; + } + + const link = discountCode.link; + const linkId = link.id; + + // Record a fake click for this event + const customerDetails = charge.customer_details; + const customerAddress = customerDetails?.address; + + const clickEvent = await recordFakeClick({ + link, + customer: { + continent: customerAddress?.country + ? COUNTRIES_TO_CONTINENTS[customerAddress.country] + : "Unknown", + country: customerAddress?.country ?? "Unknown", + region: customerAddress?.state ?? "Unknown", + }, + }); + + const customer = await prisma.customer.create({ + data: { + id: createId({ prefix: "cus_" }), + name: + customerDetails?.name || customerDetails?.email || generateRandomName(), + email: customerDetails?.email, + externalId: clickEvent.click_id, + stripeCustomerId: charge.customer as string, + linkId: clickEvent.link_id, + clickId: clickEvent.click_id, + clickedAt: new Date(clickEvent.timestamp + "Z"), + country: customerAddress?.country, + projectId: workspace.id, + projectConnectId: workspace.stripeConnectId, + }, + }); + + // Prepare the payload for the lead event + const { timestamp, ...rest } = clickEvent; + + const leadEvent = { + ...rest, + event_id: nanoid(16), + event_name: "Sign up", + customer_id: customer.id, + metadata: "", + }; + + await recordLead(leadEvent); + + return { + linkId, + customer, + clickEvent, + leadEvent, + }; } diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts new file mode 100644 index 0000000000..7c999c0085 --- /dev/null +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts @@ -0,0 +1,133 @@ +import { getWorkspaceUsers } from "@/lib/api/get-workspace-users"; +import { qstash } from "@/lib/cron"; +import { sendBatchEmail } from "@dub/email"; +import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; +import DiscountDeleted from "@dub/email/templates/discount-deleted"; +import { prisma } from "@dub/prisma"; +import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; +import { waitUntil } from "@vercel/functions"; +import type Stripe from "stripe"; + +// Handle event "coupon.deleted" +export async function couponDeleted(event: Stripe.Event) { + const coupon = event.data.object as Stripe.Coupon; + const stripeAccountId = event.account as string; + + const workspace = await prisma.project.findUnique({ + where: { + stripeConnectId: stripeAccountId, + }, + select: { + id: true, + slug: true, + defaultProgramId: true, + stripeConnectId: true, + }, + }); + + if (!workspace) { + return `Workspace not found for Stripe account ${stripeAccountId}.`; + } + + if (!workspace.defaultProgramId) { + return `Workspace ${workspace.id} for stripe account ${stripeAccountId} has no programs.`; + } + + const discounts = await prisma.discount.findMany({ + where: { + programId: workspace.defaultProgramId, + OR: [{ couponId: coupon.id }, { couponTestId: coupon.id }], + }, + include: { + partnerGroup: true, + }, + }); + + if (!discounts.length) { + return `Discount not found for Stripe coupon ${coupon.id}.`; + } + + const discountIds = discounts.map((d) => d.id); + + await prisma.$transaction(async (tx) => { + if (discountIds.length > 0) { + await tx.partnerGroup.updateMany({ + where: { + discountId: { + in: discountIds, + }, + }, + data: { + discountId: null, + }, + }); + + await tx.programEnrollment.updateMany({ + where: { + discountId: { + in: discountIds, + }, + }, + data: { + discountId: null, + }, + }); + + await tx.discountCode.deleteMany({ + where: { + discountId: { + in: discountIds, + }, + }, + }); + + await tx.discount.deleteMany({ + where: { + id: { + in: discountIds, + }, + }, + }); + } + }); + + waitUntil( + (async () => { + const { users } = await getWorkspaceUsers({ + workspaceId: workspace.id, + role: "owner", + }); + + const groupIds = discounts + .map((d) => d.partnerGroup?.id) + .filter(Boolean) as string[]; + + await Promise.allSettled([ + ...groupIds.map((groupId) => + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, + body: { + groupId, + }, + }), + ), + + sendBatchEmail( + users.map((user) => ({ + from: VARIANT_TO_FROM_MAP.notifications, + to: user.email, + subject: `${process.env.NEXT_PUBLIC_APP_NAME}: Discount has been deleted`, + react: DiscountDeleted({ + email: user.email, + coupon: { + id: coupon.id, + }, + }), + })), + ), + ]); + })(), + ); + + return `Stripe coupon ${coupon.id} deleted.`; +} diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/customer-created.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/customer-created.ts index 7439c9c4f0..4a330a7120 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/customer-created.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/customer-created.ts @@ -1,12 +1,12 @@ import { prisma } from "@dub/prisma"; import type Stripe from "stripe"; -import { createNewCustomer } from "./utils"; +import { createNewCustomer } from "./utils/create-new-customer"; // Handle event "customer.created" export async function customerCreated(event: Stripe.Event) { const stripeCustomer = event.data.object as Stripe.Customer; const stripeAccountId = event.account as string; - const dubCustomerExternalId = stripeCustomer.metadata?.dubCustomerId; + const dubCustomerExternalId = stripeCustomer.metadata?.dubCustomerId; // TODO: need to update to dubCustomerExternalId in the future for consistency if (!dubCustomerExternalId) { return "External ID not found in Stripe customer metadata, skipping..."; diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/customer-updated.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/customer-updated.ts index 9335dcc73a..36a97b0afd 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/customer-updated.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/customer-updated.ts @@ -1,12 +1,12 @@ import { prisma } from "@dub/prisma"; import type Stripe from "stripe"; -import { createNewCustomer } from "./utils"; +import { createNewCustomer } from "./utils/create-new-customer"; // Handle event "customer.updated" export async function customerUpdated(event: Stripe.Event) { const stripeCustomer = event.data.object as Stripe.Customer; const stripeAccountId = event.account as string; - const dubCustomerExternalId = stripeCustomer.metadata?.dubCustomerId; + const dubCustomerExternalId = stripeCustomer.metadata?.dubCustomerId; // TODO: need to update to dubCustomerExternalId in the future for consistency if (!dubCustomerExternalId) { return "External ID not found in Stripe customer metadata, skipping..."; diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts index 1f2e41a055..30dfab1362 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts @@ -13,7 +13,7 @@ import { WorkflowTrigger } from "@dub/prisma/client"; import { nanoid } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; -import { getConnectedCustomer } from "./utils"; +import { getConnectedCustomer } from "./utils/get-connected-customer"; // Handle event "invoice.paid" export async function invoicePaid(event: Stripe.Event) { @@ -29,7 +29,7 @@ export async function invoicePaid(event: Stripe.Event) { }, }); - // if customer is not found, we check if the connected customer has a dubCustomerId + // if customer is not found, we check if the connected customer has a dubCustomerExternalId if (!customer) { const connectedCustomer = await getConnectedCustomer({ stripeCustomerId, @@ -37,16 +37,16 @@ export async function invoicePaid(event: Stripe.Event) { livemode: event.livemode, }); - const dubCustomerId = connectedCustomer?.metadata.dubCustomerId; + const dubCustomerExternalId = connectedCustomer?.metadata.dubCustomerId; // TODO: need to update to dubCustomerExternalId in the future for consistency - if (dubCustomerId) { + if (dubCustomerExternalId) { try { // Update customer with stripeCustomerId if exists – for future events customer = await prisma.customer.update({ where: { projectConnectId_externalId: { projectConnectId: stripeAccountId, - externalId: dubCustomerId, + externalId: dubCustomerExternalId, }, }, data: { @@ -55,14 +55,14 @@ export async function invoicePaid(event: Stripe.Event) { }); } catch (error) { console.log(error); - return `Customer with dubCustomerId ${dubCustomerId} not found, skipping...`; + return `Customer with dubCustomerExternalId ${dubCustomerExternalId} not found, skipping...`; } } } // if customer is still not found, we skip the event if (!customer) { - return `Customer with stripeCustomerId ${stripeCustomerId} not found on Dub (nor does the connected customer ${stripeCustomerId} have a valid dubCustomerId), skipping...`; + return `Customer with stripeCustomerId ${stripeCustomerId} not found on Dub (nor does the connected customer ${stripeCustomerId} have a valid dubCustomerExternalId), skipping...`; } // Skip if invoice id is already processed @@ -70,7 +70,7 @@ export async function invoicePaid(event: Stripe.Event) { `trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers { timestamp: new Date().toISOString(), - dubCustomerId: customer.externalId, + dubCustomerExternalId: customer.externalId, stripeCustomerId, stripeAccountId, invoiceId, diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/promotion-code-updated.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/promotion-code-updated.ts new file mode 100644 index 0000000000..6184792b1a --- /dev/null +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/promotion-code-updated.ts @@ -0,0 +1,54 @@ +import { prisma } from "@dub/prisma"; +import type Stripe from "stripe"; + +// Handle event "promotion_code.updated" +export async function promotionCodeUpdated(event: Stripe.Event) { + const promotionCode = event.data.object as Stripe.PromotionCode; + const stripeAccountId = event.account as string; + + const workspace = await prisma.project.findUnique({ + where: { + stripeConnectId: stripeAccountId, + }, + select: { + id: true, + slug: true, + defaultProgramId: true, + stripeConnectId: true, + }, + }); + + if (!workspace) { + return `Workspace not found for Stripe account ${stripeAccountId}.`; + } + + if (!workspace.defaultProgramId) { + return `Workspace ${workspace.id} for stripe account ${stripeAccountId} has no programs.`; + } + + if (promotionCode.active) { + return `Promotion code ${promotionCode.id} is active.`; + } + + // If the promotion code is not active, we need to remove them from Dub + const discountCode = await prisma.discountCode.findUnique({ + where: { + programId_code: { + programId: workspace.defaultProgramId, + code: promotionCode.code, + }, + }, + }); + + if (!discountCode) { + return `Discount code not found for Stripe promotion code ${promotionCode.id}.`; + } + + await prisma.discountCode.delete({ + where: { + id: discountCode.id, + }, + }); + + return `Discount code ${discountCode.id} deleted from the program ${workspace.defaultProgramId}.`; +} diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts index 58399c4a9c..8f2d5f41be 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts @@ -5,9 +5,11 @@ import Stripe from "stripe"; import { accountApplicationDeauthorized } from "./account-application-deauthorized"; import { chargeRefunded } from "./charge-refunded"; import { checkoutSessionCompleted } from "./checkout-session-completed"; +import { couponDeleted } from "./coupon-deleted"; import { customerCreated } from "./customer-created"; import { customerUpdated } from "./customer-updated"; import { invoicePaid } from "./invoice-paid"; +import { promotionCodeUpdated } from "./promotion-code-updated"; const relevantEvents = new Set([ "customer.created", @@ -16,6 +18,8 @@ const relevantEvents = new Set([ "invoice.paid", "charge.refunded", "account.application.deauthorized", + "coupon.deleted", + "promotion_code.updated", ]); // POST /api/stripe/integration/webhook – listen to Stripe webhooks (for Stripe Integration) @@ -75,7 +79,13 @@ export const POST = withAxiom(async (req: Request) => { case "account.application.deauthorized": response = await accountApplicationDeauthorized(event); break; + case "coupon.deleted": + response = await couponDeleted(event); + break; + case "promotion_code.updated": + response = await promotionCodeUpdated(event); + break; } - return logAndRespond(response); + return logAndRespond(`[${event.type}]: ${response}`); }); diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/utils.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts similarity index 65% rename from apps/web/app/(ee)/api/stripe/integration/webhook/utils.ts rename to apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts index 7b3b502b72..06ce243137 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/utils.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts @@ -3,7 +3,6 @@ import { includeTags } from "@/lib/api/links/include-tags"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { generateRandomName } from "@/lib/names"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; -import { stripeAppClient } from "@/lib/stripe"; import { getClickEvent, recordLead } from "@/lib/tinybird"; import { WebhookPartner } from "@/lib/types"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; @@ -153,90 +152,3 @@ export async function createNewCustomer(event: Stripe.Event) { return `New Dub customer created: ${customer.id}. Lead event recorded: ${leadData.event_id}`; } - -export async function getConnectedCustomer({ - stripeCustomerId, - stripeAccountId, - livemode = true, -}: { - stripeCustomerId?: string | null; - stripeAccountId?: string | null; - livemode?: boolean; -}) { - // if stripeCustomerId or stripeAccountId is not provided, return null - if (!stripeCustomerId || !stripeAccountId) { - return null; - } - - const connectedCustomer = await stripeAppClient({ - livemode, - }).customers.retrieve(stripeCustomerId, { - stripeAccount: stripeAccountId, - }); - - if (connectedCustomer.deleted) { - return null; - } - - return connectedCustomer; -} - -export async function updateCustomerWithStripeCustomerId({ - stripeAccountId, - dubCustomerId, - stripeCustomerId, -}: { - stripeAccountId?: string | null; - dubCustomerId: string; - stripeCustomerId?: string | null; -}) { - // if stripeCustomerId or stripeAccountId is not provided, return null - // (same logic as in getConnectedCustomer) - if (!stripeCustomerId || !stripeAccountId) { - return null; - } - - try { - // Update customer with stripeCustomerId if exists – for future events - return await prisma.customer.update({ - where: { - projectConnectId_externalId: { - projectConnectId: stripeAccountId, - externalId: dubCustomerId, - }, - }, - data: { - stripeCustomerId, - }, - }); - } catch (error) { - // Skip if customer not found (not an error, just a case where the customer doesn't exist on Dub yet) - console.log("Failed to update customer with StripeCustomerId:", error); - return null; - } -} - -export async function getSubscriptionProductId({ - stripeSubscriptionId, - stripeAccountId, - livemode = true, -}: { - stripeSubscriptionId?: string | null; - stripeAccountId?: string | null; - livemode?: boolean; -}) { - if (!stripeAccountId || !stripeSubscriptionId) { - return null; - } - try { - const subscription = await stripeAppClient({ - livemode, - }).subscriptions.retrieve(stripeSubscriptionId, { - stripeAccount: stripeAccountId, - }); - return subscription.items.data[0].price.product as string; - } catch (error) { - console.log("Failed to get subscription price ID:", error); - return null; - } -} diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-connected-customer.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-connected-customer.ts new file mode 100644 index 0000000000..45e4dded12 --- /dev/null +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-connected-customer.ts @@ -0,0 +1,28 @@ +import { stripeAppClient } from "@/lib/stripe"; + +export async function getConnectedCustomer({ + stripeCustomerId, + stripeAccountId, + livemode = true, +}: { + stripeCustomerId?: string | null; + stripeAccountId?: string | null; + livemode?: boolean; +}) { + // if stripeCustomerId or stripeAccountId is not provided, return null + if (!stripeCustomerId || !stripeAccountId) { + return null; + } + + const connectedCustomer = await stripeAppClient({ + livemode, + }).customers.retrieve(stripeCustomerId, { + stripeAccount: stripeAccountId, + }); + + if (connectedCustomer.deleted) { + return null; + } + + return connectedCustomer; +} diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-promotion-code.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-promotion-code.ts new file mode 100644 index 0000000000..4bd492b1f7 --- /dev/null +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-promotion-code.ts @@ -0,0 +1,26 @@ +import { stripeAppClient } from "@/lib/stripe"; + +export async function getPromotionCode({ + promotionCodeId, + stripeAccountId, + livemode = true, +}: { + promotionCodeId?: string | null; + stripeAccountId?: string | null; + livemode?: boolean; +}) { + if (!stripeAccountId || !promotionCodeId) { + return null; + } + + try { + return await stripeAppClient({ + livemode, + }).promotionCodes.retrieve(promotionCodeId, { + stripeAccount: stripeAccountId, + }); + } catch (error) { + console.log("Failed to get promotion code:", error); + return null; + } +} diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-subscription-product-id.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-subscription-product-id.ts new file mode 100644 index 0000000000..90bc607792 --- /dev/null +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-subscription-product-id.ts @@ -0,0 +1,27 @@ +import { stripeAppClient } from "@/lib/stripe"; + +export async function getSubscriptionProductId({ + stripeSubscriptionId, + stripeAccountId, + livemode = true, +}: { + stripeSubscriptionId?: string | null; + stripeAccountId?: string | null; + livemode?: boolean; +}) { + if (!stripeAccountId || !stripeSubscriptionId) { + return null; + } + + try { + const subscription = await stripeAppClient({ + livemode, + }).subscriptions.retrieve(stripeSubscriptionId, { + stripeAccount: stripeAccountId, + }); + return subscription.items.data[0].price.product as string; + } catch (error) { + console.log("Failed to get subscription price ID:", error); + return null; + } +} diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/utils/update-customer-with-stripe-customer-id.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/update-customer-with-stripe-customer-id.ts new file mode 100644 index 0000000000..52bfd38ac5 --- /dev/null +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/update-customer-with-stripe-customer-id.ts @@ -0,0 +1,36 @@ +import { prisma } from "@dub/prisma"; + +export async function updateCustomerWithStripeCustomerId({ + stripeAccountId, + dubCustomerExternalId, + stripeCustomerId, +}: { + stripeAccountId?: string | null; + dubCustomerExternalId: string; + stripeCustomerId?: string | null; +}) { + // if stripeCustomerId or stripeAccountId is not provided, return null + // (same logic as in getConnectedCustomer) + if (!stripeCustomerId || !stripeAccountId) { + return null; + } + + try { + // Update customer with stripeCustomerId if exists – for future events + return await prisma.customer.update({ + where: { + projectConnectId_externalId: { + projectConnectId: stripeAccountId, + externalId: dubCustomerExternalId, + }, + }, + data: { + stripeCustomerId, + }, + }); + } catch (error) { + // Skip if customer not found (not an error, just a case where the customer doesn't exist on Dub yet) + console.log("Failed to update customer with StripeCustomerId:", error); + return null; + } +} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx index c593f7ee35..6a451aca6d 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx @@ -4,6 +4,7 @@ import usePartnerAnalytics from "@/lib/swr/use-partner-analytics"; import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; import { PartnerProfileLinkProps } from "@/lib/types"; import { CommentsBadge } from "@/ui/links/comments-badge"; +import { DiscountCodeBadge } from "@/ui/partners/discounts/discount-code-badge"; import { ArrowTurnRight2, Button, @@ -13,6 +14,9 @@ import { InvoiceDollar, LinkLogo, LoadingSpinner, + SimpleTooltipContent, + Tooltip, + useCopyToClipboard, useInViewport, UserCheck, useRouterStuff, @@ -112,6 +116,8 @@ export function PartnerLinkCard({ link }: { link: PartnerProfileLinkProps }) { link, }); + const [copied, copyToClipboard] = useCopyToClipboard(); + return (
@@ -131,24 +137,28 @@ export function PartnerLinkCard({ link }: { link: PartnerProfileLinkProps }) {
-
- - {getPrettyUrl(partnerLink)} - - - - +
+ + {link.comments && }
+ {/* The max width implementation here is a bit hacky, we should improve in the future */}
@@ -172,7 +182,27 @@ export function PartnerLinkCard({ link }: { link: PartnerProfileLinkProps }) {
- +
+ {link.discountCode && ( + + } + > +
+ + Discount code + + +
+
+ )} + +
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/discounts/group-discounts.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/discounts/group-discounts.tsx index 2a60219581..d7c7667d0b 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/discounts/group-discounts.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/discounts/group-discounts.tsx @@ -3,7 +3,7 @@ import useGroup from "@/lib/swr/use-group"; import type { DiscountProps, GroupProps } from "@/lib/types"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; -import { useDiscountSheet } from "@/ui/partners/add-edit-discount-sheet"; +import { useDiscountSheet } from "@/ui/partners/discounts/add-edit-discount-sheet"; import { ProgramRewardDescription } from "@/ui/partners/program-reward-description"; import { X } from "@/ui/shared/icons"; import { diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/layout.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/layout.tsx index be2b64e3f5..7a87bf36f7 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/layout.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/layout.tsx @@ -27,7 +27,7 @@ import { } from "@dub/ui/icons"; import { LockOpen } from "lucide-react"; import Link from "next/link"; -import { useParams } from "next/navigation"; +import { redirect, useParams } from "next/navigation"; import { ReactNode, useState } from "react"; import { useCreateCommissionSheet } from "../../commissions/create-commission-sheet"; import { PartnerNav } from "./partner-nav"; @@ -41,6 +41,7 @@ export default function ProgramPartnerLayout({ const { slug: workspaceSlug } = useWorkspace(); const { partnerId } = useParams() as { partnerId: string }; + const { partner, loading: isPartnerLoading, @@ -49,6 +50,10 @@ export default function ProgramPartnerLayout({ partnerId, }); + if (partnerError && partnerError.status === 404) { + redirect(`/${workspaceSlug}/program/partners`); + } + return ( +
+ + +
) : (
{error ? ( @@ -128,9 +145,10 @@ const PartnerLinks = ({ partner }: { partner: EnrolledPartnerProps }) => { return ( <> -
-

Links

+

+ Referral links +

-
- +
+ + + ); +}; + +const PartnerDiscountCodes = ({ + partner, +}: { + partner: EnrolledPartnerProps; +}) => { + const { slug, stripeConnectId } = useWorkspace(); + + const [selectedDiscountCode, setSelectedDiscountCode] = + useState(null); + + const [showDeleteDiscountCodeModal, setShowDeleteDiscountCodeModal] = + useState(false); + + const { discountCodes, loading, error } = useDiscountCodes({ + partnerId: partner.id || null, + }); + + const { AddDiscountCodeModal, setShowAddDiscountCodeModal } = + useAddDiscountCodeModal({ + partner, + }); + + const table = useTable({ + data: discountCodes || [], + columns: [ + { + id: "code", + header: "Code", + cell: ({ row }) => , + }, + { + id: "shortLink", + header: "Link", + cell: ({ row }) => { + const link = partner.links?.find((l) => l.id === row.original.linkId); + return link ? ( + + {getPrettyUrl(link.shortLink)} + + ) : ( + Link not found + ); + }, + }, + { + id: "menu", + enableHiding: false, + minSize: 18, + size: 18, + maxSize: 18, + cell: ({ row }) => ( +
+ )} + + + + {selectedDiscountCode && ( + + )} ); }; diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page-client.tsx index 484b7c48bb..11c2e7dcdb 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page-client.tsx @@ -8,8 +8,8 @@ import usePartner from "@/lib/swr/use-partner"; import usePartnersCount from "@/lib/swr/use-partners-count"; import useWorkspace from "@/lib/swr/use-workspace"; import { EnrolledPartnerProps } from "@/lib/types"; +import { useBulkApprovePartnersModal } from "@/ui/modals/bulk-approve-partners-modal"; import { useConfirmModal } from "@/ui/modals/confirm-modal"; -import { useBulkApprovePartnersModal } from "@/ui/partners/bulk-approve-partners-modal"; import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; import { PartnerApplicationSheet } from "@/ui/partners/partner-application-sheet"; import { PartnerRowItem } from "@/ui/partners/partner-row-item"; diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx index 237ab705e1..6ff820f1dd 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx @@ -4,16 +4,15 @@ import { deleteProgramInviteAction } from "@/lib/actions/partners/delete-program import { resendProgramInviteAction } from "@/lib/actions/partners/resend-program-invite"; import { mutatePrefix } from "@/lib/swr/mutate"; import useGroups from "@/lib/swr/use-groups"; -import usePartner from "@/lib/swr/use-partner"; import usePartnersCount from "@/lib/swr/use-partners-count"; import useWorkspace from "@/lib/swr/use-workspace"; import { EnrolledPartnerProps } from "@/lib/types"; import { useArchivePartnerModal } from "@/ui/modals/archive-partner-modal"; import { useBanPartnerModal } from "@/ui/modals/ban-partner-modal"; +import { useChangeGroupModal } from "@/ui/modals/change-group-modal"; import { useDeactivatePartnerModal } from "@/ui/modals/deactivate-partner-modal"; import { useReactivatePartnerModal } from "@/ui/modals/reactivate-partner-modal"; import { useUnbanPartnerModal } from "@/ui/modals/unban-partner-modal"; -import { useChangeGroupModal } from "@/ui/partners/change-group-modal"; import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; import { PartnerRowItem } from "@/ui/partners/partner-row-item"; import { PartnerStatusBadges } from "@/ui/partners/partner-status-badges"; @@ -704,31 +703,3 @@ function MenuItem({ ); } - -/** Gets the current partner from the loaded partners array if available, or a separate fetch if not */ -function useCurrentPartner({ - partners, - partnerId, -}: { - partners?: EnrolledPartnerProps[]; - partnerId: string | null; -}) { - let currentPartner = partnerId - ? partners?.find(({ id }) => id === partnerId) - : null; - - const { partner: fetchedPartner, loading: isLoading } = usePartner( - { - partnerId: partners && partnerId && !currentPartner ? partnerId : null, - }, - { - keepPreviousData: true, - }, - ); - - if (!currentPartner && fetchedPartner?.id === partnerId) { - currentPartner = fetchedPartner; - } - - return { currentPartner, isLoading }; -} diff --git a/apps/web/lib/actions/partners/ban-partner.ts b/apps/web/lib/actions/partners/ban-partner.ts index b33fe2839c..7336b01369 100644 --- a/apps/web/lib/actions/partners/ban-partner.ts +++ b/apps/web/lib/actions/partners/ban-partner.ts @@ -1,6 +1,7 @@ "use server"; import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { queueDiscountCodeDeletion } from "@/lib/api/discounts/queue-discount-code-deletion"; import { linkCache } from "@/lib/api/links/cache"; import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; @@ -28,7 +29,9 @@ export const banPartnerAction = authActionClient const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId, programId, + includeProgram: true, includePartner: true, + includeDiscountCodes: true, }); if (programEnrollment.status === "banned") { @@ -92,51 +95,55 @@ export const banPartnerAction = authActionClient status: "rejected", }, }), + + prisma.discountCode.updateMany({ + where, + data: { + discountId: null, + }, + }), ]); waitUntil( (async () => { - // sync total commissions + // Sync total commissions await syncTotalCommissions({ partnerId, programId }); - const { program, partner } = programEnrollment; - - if (!partner.email) { - console.error("Partner has no email address."); - return; - } - - const supportEmail = program.supportEmail || "support@dub.co"; - // Expire links from cache const links = await prisma.link.findMany({ where, select: { domain: true, key: true, + discountCode: true, }, }); - await linkCache.expireMany(links); + const { program, partner, discountCodes } = programEnrollment; await Promise.allSettled([ - sendEmail({ - subject: `You've been banned from the ${program.name} Partner Program`, - to: partner.email, - replyTo: supportEmail, - react: PartnerBanned({ - partner: { - name: partner.name, - email: partner.email, - }, - program: { - name: program.name, - slug: program.slug, - }, - bannedReason: BAN_PARTNER_REASONS[parsedInput.reason], + linkCache.expireMany(links), + + queueDiscountCodeDeletion(discountCodes.map(({ id }) => id)), + + partner.email && + sendEmail({ + subject: `You've been banned from the ${program.name} Partner Program`, + to: partner.email, + replyTo: program.supportEmail || "support@dub.co", + react: PartnerBanned({ + partner: { + name: partner.name, + email: partner.email, + }, + program: { + name: program.name, + slug: program.slug, + }, + bannedReason: BAN_PARTNER_REASONS[parsedInput.reason], + }), + variant: "notifications", }), - variant: "notifications", - }), recordAuditLog({ workspaceId: workspace.id, diff --git a/apps/web/lib/actions/partners/bulk-ban-partners.ts b/apps/web/lib/actions/partners/bulk-ban-partners.ts index 956f771e37..e0f5814e02 100644 --- a/apps/web/lib/actions/partners/bulk-ban-partners.ts +++ b/apps/web/lib/actions/partners/bulk-ban-partners.ts @@ -1,6 +1,7 @@ "use server"; import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { queueDiscountCodeDeletion } from "@/lib/api/discounts/queue-discount-code-deletion"; import { linkCache } from "@/lib/api/links/cache"; import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; @@ -9,7 +10,6 @@ import { bulkBanPartnersSchema, } from "@/lib/zod/schemas/partners"; import { sendBatchEmail } from "@dub/email"; -import { resend } from "@dub/email/resend"; import PartnerBanned from "@dub/email/templates/partner-banned"; import { prisma } from "@dub/prisma"; import { ProgramEnrollmentStatus } from "@prisma/client"; @@ -47,6 +47,7 @@ export const bulkBanPartnersAction = authActionClient select: { domain: true, key: true, + discountCode: true, }, }, }, @@ -108,6 +109,15 @@ export const bulkBanPartnersAction = authActionClient status: "canceled", }, }), + + prisma.discountCode.updateMany({ + where: { + ...commonWhere, + }, + data: { + discountId: null, + }, + }), ]); waitUntil( @@ -122,9 +132,16 @@ export const bulkBanPartnersAction = authActionClient ), ); + const links = programEnrollments.flatMap(({ links }) => links); + // Expire links from cache - await linkCache.expireMany( - programEnrollments.flatMap(({ links }) => links), + await linkCache.expireMany(links); + + // Queue discount code deletions + await queueDiscountCodeDeletion( + links + .map((link) => link.discountCode?.id) + .filter((id): id is string => id !== undefined), ); // Record audit log for each partner @@ -156,11 +173,6 @@ export const bulkBanPartnersAction = authActionClient }, }); - if (!resend) { - console.error("Resend is not configured, skipping email sending."); - return; - } - await sendBatchEmail( programEnrollments .filter(({ partner }) => partner.email) diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 70734d4b75..d32e65bd28 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -5,9 +5,10 @@ import { createId } from "@/lib/api/create-id"; import { getGroupOrThrow } from "@/lib/api/groups/get-group-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { qstash } from "@/lib/cron"; +import { createStripeCoupon } from "@/lib/stripe/create-stripe-coupon"; import { createDiscountSchema } from "@/lib/zod/schemas/discount"; import { prisma } from "@dub/prisma"; -import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; +import { APP_DOMAIN_WITH_NGROK, truncate } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { authActionClient } from "../safe-action"; @@ -15,7 +16,7 @@ export const createDiscountAction = authActionClient .schema(createDiscountSchema) .action(async ({ parsedInput, ctx }) => { const { workspace, user } = ctx; - const { amount, type, maxDuration, couponId, couponTestId, groupId } = + let { amount, type, maxDuration, couponId, couponTestId, groupId } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -31,6 +32,43 @@ export const createDiscountAction = authActionClient ); } + // If no couponId or couponTestId is provided, create a new coupon on Stripe + const shouldCreateCouponOnStripe = !couponId && !couponTestId; + + if (shouldCreateCouponOnStripe) { + if (!workspace.stripeConnectId) { + throw new Error( + "STRIPE_CONNECTION_REQUIRED: Your workspace isn't connected to Stripe yet. Please install the Dub Stripe app in settings to create discount.", + ); + } + + try { + const stripeCoupon = await createStripeCoupon({ + workspace: { + id: workspace.id, + stripeConnectId: workspace.stripeConnectId, + }, + discount: { + name: `Dub Discount (${truncate(group.name, 25)})`, + amount, + type, + maxDuration: maxDuration ?? null, + }, + }); + + if (stripeCoupon) { + couponId = stripeCoupon.id; + } + } catch (error) { + throw new Error( + error.code === "more_permissions_required_for_application" + ? "STRIPE_APP_UPGRADE_REQUIRED: Your connected Stripe account doesn't have the permissions needed to create discount codes. Please upgrade your Stripe integration in settings or reach out to our support team for help." + : error.message, + ); + } + } + + // Create the discount and update the group and program enrollment const discount = await prisma.$transaction(async (tx) => { const discount = await tx.discount.create({ data: { @@ -40,7 +78,7 @@ export const createDiscountAction = authActionClient type, maxDuration, couponId, - couponTestId, + ...(couponTestId && { couponTestId }), }, }); diff --git a/apps/web/lib/actions/partners/deactivate-partner.ts b/apps/web/lib/actions/partners/deactivate-partner.ts index d351f2f5a0..93e6377b3b 100644 --- a/apps/web/lib/actions/partners/deactivate-partner.ts +++ b/apps/web/lib/actions/partners/deactivate-partner.ts @@ -1,6 +1,7 @@ "use server"; import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { queueDiscountCodeDeletion } from "@/lib/api/discounts/queue-discount-code-deletion"; import { linkCache } from "@/lib/api/links/cache"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; @@ -23,6 +24,7 @@ export const deactivatePartnerAction = authActionClient partnerId, programId, includePartner: true, + includeDiscountCodes: true, }); if (programEnrollment.status === "deactivated") { @@ -66,6 +68,11 @@ export const deactivatePartnerAction = authActionClient await Promise.allSettled([ // TODO send email to partner linkCache.expireMany(links), + + queueDiscountCodeDeletion( + programEnrollment.discountCodes.map(({ id }) => id), + ), + recordAuditLog({ workspaceId: workspace.id, programId, diff --git a/apps/web/lib/actions/partners/delete-discount.ts b/apps/web/lib/actions/partners/delete-discount.ts index e4397c41a7..df2ba71ecf 100644 --- a/apps/web/lib/actions/partners/delete-discount.ts +++ b/apps/web/lib/actions/partners/delete-discount.ts @@ -1,6 +1,7 @@ "use server"; import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { queueDiscountCodeDeletion } from "@/lib/api/discounts/queue-discount-code-deletion"; import { getDiscountOrThrow } from "@/lib/api/partners/get-discount-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { qstash } from "@/lib/cron"; @@ -28,6 +29,13 @@ export const deleteDiscountAction = authActionClient discountId, }); + // Cache discount codes to delete them later + const discountCodes = await prisma.discountCode.findMany({ + where: { + discountId: discount.id, + }, + }); + const group = await prisma.$transaction(async (tx) => { const group = await tx.partnerGroup.update({ where: { @@ -47,6 +55,15 @@ export const deleteDiscountAction = authActionClient }, }); + await tx.discountCode.updateMany({ + where: { + discountId: discount.id, + }, + data: { + discountId: null, + }, + }); + await tx.discount.delete({ where: { id: discount.id, @@ -65,6 +82,8 @@ export const deleteDiscountAction = authActionClient }, }), + queueDiscountCodeDeletion(discountCodes.map(({ id }) => id)), + recordAuditLog({ workspaceId: workspace.id, programId, diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index 1ebd31967f..6dbef26135 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -7,7 +7,7 @@ import { qstash } from "@/lib/cron"; import { updateDiscountSchema } from "@/lib/zod/schemas/discount"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { prisma } from "@dub/prisma"; -import { APP_DOMAIN_WITH_NGROK, deepEqual } from "@dub/utils"; +import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { revalidatePath } from "next/cache"; import { authActionClient } from "../safe-action"; @@ -16,8 +16,7 @@ export const updateDiscountAction = authActionClient .schema(updateDiscountSchema) .action(async ({ parsedInput, ctx }) => { const { workspace, user } = ctx; - const { discountId, amount, type, maxDuration, couponId, couponTestId } = - parsedInput; + const { discountId, couponTestId } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -32,11 +31,7 @@ export const updateDiscountAction = authActionClient id: discountId, }, data: { - amount, - type, - maxDuration, - couponId, - couponTestId, + couponTestId: couponTestId || null, }, include: { program: true, @@ -46,18 +41,8 @@ export const updateDiscountAction = authActionClient waitUntil( (async () => { - const shouldExpireCache = !deepEqual( - { - amount: discount.amount, - type: discount.type, - maxDuration: discount.maxDuration, - }, - { - amount: updatedDiscount.amount, - type: updatedDiscount.type, - maxDuration: updatedDiscount.maxDuration, - }, - ); + const shouldExpireCache = + discount.couponTestId !== updatedDiscount.couponTestId; await Promise.allSettled([ ...(shouldExpireCache diff --git a/apps/web/lib/analytics/is-first-conversion.ts b/apps/web/lib/analytics/is-first-conversion.ts index 9c1db221fd..60fc4f32f4 100644 --- a/apps/web/lib/analytics/is-first-conversion.ts +++ b/apps/web/lib/analytics/is-first-conversion.ts @@ -5,7 +5,7 @@ export const isFirstConversion = ({ linkId, }: { customer: Pick; - linkId: string; + linkId?: string; }) => { // if this is the first sale for the customer, it's a first conversion if (customer.sales === 0) { diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index cda75da6a3..18cc25758a 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -3,7 +3,7 @@ import { BountySubmissionSchema, } from "@/lib/zod/schemas/bounties"; import { CommissionSchema } from "@/lib/zod/schemas/commissions"; -import { DiscountSchema } from "@/lib/zod/schemas/discount"; +import { DiscountCodeSchema, DiscountSchema } from "@/lib/zod/schemas/discount"; import { GroupSchema } from "@/lib/zod/schemas/groups"; import { PartnerSchema } from "@/lib/zod/schemas/partners"; import { PayoutSchema } from "@/lib/zod/schemas/payouts"; @@ -42,6 +42,8 @@ const actionSchema = z.enum([ "discount.created", "discount.updated", "discount.deleted", + "discount_code.created", + "discount_code.deleted", // Partner applications "partner_application.approved", @@ -130,6 +132,12 @@ export const auditLogTarget = z.union([ }), }), + z.object({ + type: z.literal("discount_code"), + id: z.string(), + metadata: DiscountCodeSchema, + }), + z.object({ type: z.literal("partner"), id: z.string(), diff --git a/apps/web/lib/api/bounties/trigger-draft-bounty-submissions.ts b/apps/web/lib/api/bounties/trigger-draft-bounty-submissions.ts index d3e420b7f4..97c6db94dd 100644 --- a/apps/web/lib/api/bounties/trigger-draft-bounty-submissions.ts +++ b/apps/web/lib/api/bounties/trigger-draft-bounty-submissions.ts @@ -4,9 +4,6 @@ import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { Bounty } from "@prisma/client"; import { getBountiesByGroups } from "./get-bounties-by-groups"; -// TODO: -// Need a better method name - // Trigger the creation of draft submissions for performance bounties that uses lifetime stats for the given partners export async function triggerDraftBountySubmissionCreation({ programId, diff --git a/apps/web/lib/api/create-id.ts b/apps/web/lib/api/create-id.ts index ff7308f91f..0f93e6047c 100644 --- a/apps/web/lib/api/create-id.ts +++ b/apps/web/lib/api/create-id.ts @@ -26,6 +26,7 @@ const prefixes = [ "cm_", // commission "rw_", // reward "disc_", // discount + "dcode_", // discount code "dub_embed_", // dub embed "audit_", // audit log "import_", // import log diff --git a/apps/web/lib/api/discounts/is-discount-equivalent.ts b/apps/web/lib/api/discounts/is-discount-equivalent.ts new file mode 100644 index 0000000000..797725c326 --- /dev/null +++ b/apps/web/lib/api/discounts/is-discount-equivalent.ts @@ -0,0 +1,26 @@ +import { Discount } from "@dub/prisma/client"; + +export function isDiscountEquivalent( + firstDiscount: Discount | null | undefined, + secondDiscount: Discount | null | undefined, +): boolean { + if (!firstDiscount || !secondDiscount) { + return false; + } + + // If both groups use the same Stripe coupon + if (firstDiscount.couponId === secondDiscount.couponId) { + return true; + } + + // If both discounts are effectively equivalent + if ( + firstDiscount.amount === secondDiscount.amount && + firstDiscount.type === secondDiscount.type && + firstDiscount.maxDuration === secondDiscount.maxDuration + ) { + return true; + } + + return false; +} diff --git a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts new file mode 100644 index 0000000000..9e2e4bc1ef --- /dev/null +++ b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts @@ -0,0 +1,41 @@ +import { qstash } from "@/lib/cron"; +import { APP_DOMAIN_WITH_NGROK, chunk } from "@dub/utils"; + +const queue = qstash.queue({ + queueName: "discount-code-deletion", +}); + +// Triggered in the following cases: +// 1. When a discount is deleted +// 2. When a link is deleted that has a discount code associated with it +// 3. When partners are banned / deactivated +// 4. When a partner is moved to a different group +export async function queueDiscountCodeDeletion( + input: string | string[] | undefined, +) { + const discountCodeIds = Array.isArray(input) ? input : [input]; + + if (discountCodeIds.length === 0) { + return; + } + + await queue.upsert({ + parallelism: 10, + }); + + // TODO: + // Check if we can use the batchJSON (I tried it but didn't work) + + const chunks = chunk(discountCodeIds, 100); + + for (const chunkOfIds of chunks) { + await Promise.allSettled( + chunkOfIds.map((discountCodeId) => + queue.enqueueJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discount-codes/${discountCodeId}/delete`, + method: "POST", + }), + ), + ); + } +} diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index c498a8b580..6c255b50a1 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -3,6 +3,7 @@ import { recordLinkTB, transformLinkTB } from "@/lib/tinybird"; import { prisma } from "@dub/prisma"; import { R2_URL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; +import { queueDiscountCodeDeletion } from "../discounts/queue-discount-code-deletion"; import { linkCache } from "./cache"; import { includeTags } from "./include-tags"; import { transformLink } from "./utils"; @@ -14,6 +15,7 @@ export async function deleteLink(linkId: string) { }, include: { ...includeTags, + discountCode: true, }, }); @@ -42,6 +44,8 @@ export async function deleteLink(linkId: string) { totalLinks: { decrement: 1 }, }, }), + + link.discountCode && queueDiscountCodeDeletion(link.discountCode.id), ]), ); diff --git a/apps/web/lib/api/partners/delete-partner.ts b/apps/web/lib/api/partners/delete-partner.ts deleted file mode 100644 index 19f584bf7b..0000000000 --- a/apps/web/lib/api/partners/delete-partner.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { storage } from "@/lib/storage"; -import { stripe } from "@/lib/stripe"; -import { prisma } from "@dub/prisma"; -import { R2_URL } from "@dub/utils"; -import { bulkDeleteLinks } from "../links/bulk-delete-links"; - -// delete partner and all associated links, customers, payouts, and commissions -// currently only used for the cron/cleanup/e2e-tests job -export async function deletePartner({ partnerId }: { partnerId: string }) { - const partner = await prisma.partner.findUnique({ - where: { - id: partnerId, - }, - include: { - programs: { - select: { - links: true, - }, - }, - }, - }); - - if (!partner) { - console.error(`Partner with id ${partnerId} not found.`); - return; - } - - const links = partner.programs.length > 0 ? partner.programs[0].links : []; - - if (links.length > 0) { - await prisma.customer.deleteMany({ - where: { - linkId: { - in: links.map((link) => link.id), - }, - }, - }); - - await bulkDeleteLinks(links); - - await prisma.link.deleteMany({ - where: { - id: { - in: links.map((link) => link.id), - }, - }, - }); - } - - await prisma.commission.deleteMany({ - where: { - partnerId: partner.id, - }, - }); - - await prisma.payout.deleteMany({ - where: { - partnerId: partner.id, - }, - }); - - await prisma.partner.delete({ - where: { - id: partner.id, - }, - }); - - if (partner.stripeConnectId) { - await stripe.accounts.del(partner.stripeConnectId); - } - - if (partner.image && partner.image.startsWith(R2_URL)) { - await storage.delete(partner.image.replace(`${R2_URL}/`, "")); - } -} diff --git a/apps/web/lib/api/partners/get-discount-or-throw.ts b/apps/web/lib/api/partners/get-discount-or-throw.ts index 4b82013b0c..1d15b7e6e4 100644 --- a/apps/web/lib/api/partners/get-discount-or-throw.ts +++ b/apps/web/lib/api/partners/get-discount-or-throw.ts @@ -1,29 +1,19 @@ import { DiscountSchema } from "@/lib/zod/schemas/discount"; import { prisma } from "@dub/prisma"; -import { Discount } from "@prisma/client"; import { DubApiError } from "../errors"; export async function getDiscountOrThrow({ discountId, programId, - includePartnersCount = false, }: { discountId: string; programId: string; - includePartnersCount?: boolean; }) { - const discount = (await prisma.discount.findUnique({ + const discount = await prisma.discount.findUnique({ where: { id: discountId, }, - ...(includePartnersCount && { - include: { - _count: { - select: { programEnrollments: true }, - }, - }, - }), - })) as Discount & { _count?: { programEnrollments: number } }; + }); if (!discount) { throw new DubApiError({ @@ -39,10 +29,5 @@ export async function getDiscountOrThrow({ }); } - return DiscountSchema.parse({ - ...discount, - ...(includePartnersCount && { - partnersCount: discount._count?.programEnrollments, - }), - }); + return DiscountSchema.parse(discount); } diff --git a/apps/web/lib/api/partners/get-partner-for-program.ts b/apps/web/lib/api/partners/get-partner-for-program.ts index 2023c286b3..d71f15b0a5 100644 --- a/apps/web/lib/api/partners/get-partner-for-program.ts +++ b/apps/web/lib/api/partners/get-partner-for-program.ts @@ -15,6 +15,7 @@ export async function getPartnerForProgram({ pe.programId, pe.partnerId, pe.groupId, + pe.discountId, pe.tenantId, pe.applicationId, pe.createdAt as enrollmentCreatedAt, diff --git a/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts b/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts index ff340a3865..c449fd59f9 100644 --- a/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts +++ b/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts @@ -12,6 +12,7 @@ export async function getProgramEnrollmentOrThrow({ includeLeadReward = false, includeSaleReward = false, includeDiscount = false, + includeDiscountCodes = false, includeGroup = false, includeWorkspace = false, }: { @@ -23,6 +24,7 @@ export async function getProgramEnrollmentOrThrow({ includeLeadReward?: boolean; includeSaleReward?: boolean; includeDiscount?: boolean; + includeDiscountCodes?: boolean; includeGroup?: boolean; includeWorkspace?: boolean; }) { @@ -57,6 +59,16 @@ export async function getProgramEnrollmentOrThrow({ ...(includeDiscount && { discount: true, }), + ...(includeDiscountCodes && { + discountCodes: { + where: { + // Omit soft deleted discount codes + discountId: { + not: null, + }, + }, + }, + }), ...(includeGroup && { partnerGroup: true, }), diff --git a/apps/web/lib/planetscale/get-partner-discount.ts b/apps/web/lib/planetscale/get-partner-discount.ts index abf3ec0d8a..6de5e8be0a 100644 --- a/apps/web/lib/planetscale/get-partner-discount.ts +++ b/apps/web/lib/planetscale/get-partner-discount.ts @@ -33,11 +33,11 @@ export const getPartnerAndDiscount = async ({ Partner.name, Partner.image, Discount.id as discountId, - Discount.amount as amount, - Discount.type as type, - Discount.maxDuration as maxDuration, - Discount.couponId as couponId, - Discount.couponTestId as couponTestId + Discount.amount, + Discount.type, + Discount.maxDuration, + Discount.couponId, + Discount.couponTestId FROM ProgramEnrollment LEFT JOIN Partner ON Partner.id = ProgramEnrollment.partnerId LEFT JOIN Discount ON Discount.id = ProgramEnrollment.discountId diff --git a/apps/web/lib/stripe/create-stripe-coupon.ts b/apps/web/lib/stripe/create-stripe-coupon.ts new file mode 100644 index 0000000000..7f31edabe9 --- /dev/null +++ b/apps/web/lib/stripe/create-stripe-coupon.ts @@ -0,0 +1,75 @@ +import { Discount } from "@dub/prisma/client"; +import { stripeAppClient } from "."; +import { WorkspaceProps } from "../types"; + +const stripe = stripeAppClient({ + ...(process.env.VERCEL_ENV && { livemode: true }), +}); + +// Create a coupon on Stripe for connected accounts +export async function createStripeCoupon({ + workspace, + discount, +}: { + workspace: Pick; + discount: Pick & { + name: string; + }; +}) { + if (!workspace.stripeConnectId) { + console.error( + `stripeConnectId not found for the workspace ${workspace.id}. Skipping Stripe coupon creation.`, + ); + return; + } + + let duration: "once" | "repeating" | "forever" = "once"; + let durationInMonths: number | undefined = undefined; + + if (discount.maxDuration === null) { + duration = "forever"; + } else if (discount.maxDuration === 0) { + duration = "once"; + } else { + duration = "repeating"; + } + + if (duration === "repeating" && discount.maxDuration) { + durationInMonths = discount.maxDuration; + } + + try { + const stripeCoupon = await stripe.coupons.create( + { + currency: "usd", + duration, + ...(duration === "repeating" && { + duration_in_months: durationInMonths, + }), + ...(discount.type === "percentage" + ? { percent_off: discount.amount } + : { amount_off: discount.amount }), + ...(discount.name && { name: discount.name }), + }, + { + stripeAccount: workspace.stripeConnectId, + }, + ); + + console.info( + `Stripe coupon ${stripeCoupon.id} created for workspace ${workspace.id}.`, + ); + + return stripeCoupon; + } catch (error) { + console.error( + `Failed create Stripe coupon for workspace ${workspace.id}.`, + { + error, + discount, + }, + ); + + throw error; + } +} diff --git a/apps/web/lib/stripe/create-stripe-discount-code.ts b/apps/web/lib/stripe/create-stripe-discount-code.ts new file mode 100644 index 0000000000..da705cd26b --- /dev/null +++ b/apps/web/lib/stripe/create-stripe-discount-code.ts @@ -0,0 +1,93 @@ +import { nanoid } from "@dub/utils"; +import { stripeAppClient } from "."; +import { DiscountProps } from "../types"; + +const stripe = stripeAppClient({ + ...(process.env.VERCEL_ENV && { livemode: true }), +}); + +const MAX_ATTEMPTS = 3; + +export async function createStripeDiscountCode({ + stripeConnectId, + discount, + code, + shouldRetry = true, +}: { + stripeConnectId: string; + discount: Pick; + code: string; + shouldRetry?: boolean; // we don't retry if the code is provided by the user +}) { + if (!stripeConnectId) { + throw new Error( + `stripeConnectId is required to create a Stripe discount code.`, + ); + } + + if (!discount.couponId) { + throw new Error(`couponId not found for discount ${discount.id}.`); + } + + let attempt = 0; + let currentCode = code; + + while (attempt < MAX_ATTEMPTS) { + try { + return await stripe.promotionCodes.create( + { + coupon: discount.couponId, + code: currentCode.toUpperCase(), + }, + { + stripeAccount: stripeConnectId, + }, + ); + } catch (error: any) { + const errorMessage = error.raw?.message || error.message; + const isDuplicateError = errorMessage?.includes("already exists"); + + if (!isDuplicateError) { + throw error; + } + + if (!shouldRetry) { + throw error; + } + + attempt++; + + if (attempt >= MAX_ATTEMPTS) { + throw error; + } + + const newCode = constructDiscountCode({ + code: currentCode, + discount, + }); + + console.warn( + `Discount code "${currentCode}" already exists. Retrying with "${newCode}" (attempt ${attempt}/${MAX_ATTEMPTS}).`, + ); + + currentCode = newCode; + } + } +} + +export function constructDiscountCode({ + code, + discount, +}: { + code: string; + discount: Pick; +}) { + const amount = + discount.type === "percentage" ? discount.amount : discount.amount / 100; + + if (!code.endsWith(amount.toString())) { + return `${code}${amount}`; + } + + return `${code}${nanoid(4)}`; +} diff --git a/apps/web/lib/stripe/disable-stripe-discount-code.ts b/apps/web/lib/stripe/disable-stripe-discount-code.ts new file mode 100644 index 0000000000..77cb27f56c --- /dev/null +++ b/apps/web/lib/stripe/disable-stripe-discount-code.ts @@ -0,0 +1,54 @@ +import { stripeAppClient } from "."; + +const stripe = stripeAppClient({ + ...(process.env.VERCEL_ENV && { livemode: true }), +}); + +export async function disableStripeDiscountCode({ + stripeConnectId, + code, +}: { + stripeConnectId: string | null; + code: string; +}) { + if (!stripeConnectId) { + throw new Error( + `stripeConnectId is required to disable a Stripe discount code.`, + ); + } + + const promotionCodes = await stripe.promotionCodes.list( + { + code, + limit: 1, + }, + { + stripeAccount: stripeConnectId, + }, + ); + + if (promotionCodes.data.length === 0) { + console.error( + `Stripe promotion code ${code} not found (stripeConnectId=${stripeConnectId}).`, + ); + return; + } + + let promotionCode = promotionCodes.data[0]; + + promotionCode = await stripe.promotionCodes.update( + promotionCode.id, + { + active: false, + }, + { + stripeAccount: stripeConnectId, + }, + ); + + console.info( + `Disabled Stripe promotion code ${promotionCode.code} (id=${promotionCode.id}, stripeConnectId=${stripeConnectId}).`, + ); + + return promotionCode; +} diff --git a/apps/web/lib/swr/use-api-mutation.ts b/apps/web/lib/swr/use-api-mutation.ts index 70d94e65f5..eec91bccca 100644 --- a/apps/web/lib/swr/use-api-mutation.ts +++ b/apps/web/lib/swr/use-api-mutation.ts @@ -2,22 +2,19 @@ import useWorkspace from "@/lib/swr/use-workspace"; import { useCallback, useState } from "react"; import { toast } from "sonner"; -interface ApiRequestOptions { +interface ApiRequestOptions { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; body?: TBody; headers?: Record; - showToast?: boolean; - onSuccess?: () => void; - onError?: () => void; + onSuccess?: (data: TResponse) => void; + onError?: (error: string) => void; } interface ApiResponse { - data: T | null; - error: string | null; isSubmitting: boolean; makeRequest: ( endpoint: string, - options?: ApiRequestOptions, + options?: ApiRequestOptions, ) => Promise; } @@ -38,24 +35,16 @@ export function useApiMutation< TBody = any, >(): ApiResponse { const { id: workspaceId } = useWorkspace(); - const [data, setData] = useState(null); - const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const makeRequest = useCallback( - async (endpoint: string, options: ApiRequestOptions = {}) => { - const { - method = "GET", - body, - headers, - showToast = true, - onSuccess, - onError, - } = options; + async ( + endpoint: string, + options: ApiRequestOptions = {}, + ) => { + const { method = "GET", body, headers, onSuccess, onError } = options; setIsSubmitting(true); - setError(null); - setData(null); try { debug("Starting request", { @@ -88,8 +77,7 @@ export function useApiMutation< // Handle success const data = (await response.json()) as TResponse; - setData(data); - onSuccess?.(); + onSuccess?.(data); debug("Response received", data); } catch (error) { @@ -98,10 +86,9 @@ export function useApiMutation< ? error.message : "Something went wrong. Please try again."; - setError(errorMessage); - onError?.(); - - if (showToast) { + if (onError) { + onError?.(errorMessage); + } else { toast.error(errorMessage); } @@ -115,8 +102,6 @@ export function useApiMutation< ); return { - data, - error, isSubmitting, makeRequest, }; diff --git a/apps/web/lib/swr/use-discount-codes.ts b/apps/web/lib/swr/use-discount-codes.ts new file mode 100644 index 0000000000..cb82fbc66e --- /dev/null +++ b/apps/web/lib/swr/use-discount-codes.ts @@ -0,0 +1,31 @@ +import { fetcher } from "@dub/utils"; +import useSWR from "swr"; +import { DiscountCodeProps } from "../types"; +import useWorkspace from "./use-workspace"; + +export default function useDiscountCodes({ + partnerId, + enabled = true, +}: { + partnerId: string | null; + enabled?: boolean; +}) { + const { id: workspaceId } = useWorkspace(); + + const { data: discountCodes, error } = useSWR( + enabled && workspaceId && partnerId + ? `/api/discount-codes?partnerId=${partnerId}&workspaceId=${workspaceId}` + : null, + fetcher, + { + dedupingInterval: 60000, + keepPreviousData: true, + }, + ); + + return { + discountCodes, + loading: !discountCodes && !error, + error, + }; +} diff --git a/apps/web/lib/tinybird/record-fake-click.ts b/apps/web/lib/tinybird/record-fake-click.ts new file mode 100644 index 0000000000..8268e0ffdf --- /dev/null +++ b/apps/web/lib/tinybird/record-fake-click.ts @@ -0,0 +1,53 @@ +import { Link } from "@dub/prisma/client"; +import { nanoid } from "@dub/utils"; +import { clickEventSchemaTB } from "../zod/schemas/clicks"; +import { recordClick } from "./record-click"; + +// TODO: +// Use this in other places where we need to record a fake click event (Eg: import-customers) +export async function recordFakeClick({ + link, + customer, + timestamp, +}: { + link: Pick; + customer?: { + country?: string | null; + region?: string | null; + continent?: string | null; + }; + timestamp?: string | number; +}) { + const dummyRequest = new Request(link.url, { + headers: new Headers({ + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + "x-forwarded-for": "127.0.0.1", + "x-vercel-ip-country": customer?.country || "US", + "x-vercel-ip-country-region": customer?.region || "CA", + "x-vercel-ip-continent": customer?.continent || "NA", + }), + }); + + const clickData = await recordClick({ + req: dummyRequest, + clickId: nanoid(16), + linkId: link.id, + url: link.url, + domain: link.domain, + key: link.key, + workspaceId: link.projectId!, + skipRatelimit: true, + ...(timestamp && { timestamp: new Date(timestamp).toISOString() }), + }); + + if (!clickData) { + throw new Error("Failed to record fake click."); + } + + return clickEventSchemaTB.parse({ + ...clickData, + timestamp: clickData.timestamp.replace("T", " ").replace("Z", ""), + bot: 0, + qr: 0, + }); +} diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 950b92c896..9d5a9a7f5e 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -44,7 +44,7 @@ import { CustomerSchema, } from "./zod/schemas/customers"; import { dashboardSchema } from "./zod/schemas/dashboard"; -import { DiscountSchema } from "./zod/schemas/discount"; +import { DiscountCodeSchema, DiscountSchema } from "./zod/schemas/discount"; import { FolderSchema } from "./zod/schemas/folders"; import { GroupWithProgramSchema } from "./zod/schemas/group-with-program"; import { @@ -302,6 +302,7 @@ export interface SAMLProviderProps { export type NewLinkProps = z.infer; type ProcessedLinkOverrides = "domain" | "key" | "url" | "projectId"; + export type ProcessedLinkProps = Omit & Pick & { userId?: LinkProps["userId"] } & { createdAt?: Date; @@ -451,6 +452,8 @@ export type EnrolledPartnerExtendedProps = z.infer< export type DiscountProps = z.infer; +export type DiscountCodeProps = z.infer; + export type ProgramProps = z.infer; export type ProgramLanderData = z.infer; diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index 873a182eb4..06d025f102 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -7,9 +7,9 @@ export const DiscountSchema = z.object({ amount: z.number(), type: z.nativeEnum(RewardStructure), maxDuration: z.number().nullable(), - description: z.string().nullish(), couponId: z.string().nullable(), couponTestId: z.string().nullable(), + description: z.string().nullish(), partnersCount: z.number().nullish(), }); @@ -32,8 +32,9 @@ export const createDiscountSchema = z.object({ }); export const updateDiscountSchema = createDiscountSchema - .omit({ - groupId: true, + .pick({ + workspaceId: true, + couponTestId: true, }) .extend({ discountId: z.string(), @@ -48,3 +49,24 @@ export const discountPartnersQuerySchema = z pageSize: 25, }), ); + +export const DiscountCodeSchema = z.object({ + id: z.string(), + code: z.string(), + discountId: z.string().nullable(), + partnerId: z.string(), + linkId: z.string(), +}); + +export const createDiscountCodeSchema = z.object({ + code: z + .string() + .max(100, "Code must be less than 100 characters.") + .optional(), + partnerId: z.string(), + linkId: z.string(), +}); + +export const getDiscountCodesQuerySchema = z.object({ + partnerId: z.string(), +}); diff --git a/apps/web/lib/zod/schemas/groups.ts b/apps/web/lib/zod/schemas/groups.ts index 3ec5a96d79..46348fb853 100644 --- a/apps/web/lib/zod/schemas/groups.ts +++ b/apps/web/lib/zod/schemas/groups.ts @@ -29,7 +29,8 @@ export const additionalPartnerLinkSchema = z.object({ .min(1, "domain is required") .refine((v) => isValidDomainFormat(v), { message: "Please enter a valid domain (eg: acme.com).", - }), + }) + .transform((v) => v.toLowerCase()), validationMode: z.enum([ "domain", // domain match (e.g. if URL is example.com/path, example.com and example.com/another-path are allowed) "exact", // exact match (e.g. if URL is example.com/path, only example.com/path is allowed) diff --git a/apps/web/lib/zod/schemas/links.ts b/apps/web/lib/zod/schemas/links.ts index dd9f15a035..12049128ff 100644 --- a/apps/web/lib/zod/schemas/links.ts +++ b/apps/web/lib/zod/schemas/links.ts @@ -493,7 +493,6 @@ export const bulkUpdateLinksBodySchema = z.object({ .default([]), data: createLinkBodySchema .omit({ - id: true, domain: true, key: true, externalId: true, @@ -712,7 +711,6 @@ export const LinkSchema = z .describe( "The total dollar value of sales (in cents) generated by the short link.", ), - lastClicked: z .string() .nullable() diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index b1f0e41d47..de1e69e4b5 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -87,6 +87,7 @@ export const PartnerProfileLinkSchema = LinkSchema.pick({ }).extend({ createdAt: z.string().or(z.date()), partnerGroupDefaultLinkId: z.string().nullish(), + discountCode: z.string().nullable().default(null), }); export const PartnerProfileCustomerSchema = CustomerEnrichedSchema.pick({ @@ -101,7 +102,6 @@ export const PartnerProfileCustomerSchema = CustomerEnrichedSchema.pick({ }); export const partnerProfileAnalyticsQuerySchema = analyticsQuerySchema.omit({ - workspaceId: true, externalId: true, tenantId: true, programId: true, @@ -112,7 +112,6 @@ export const partnerProfileAnalyticsQuerySchema = analyticsQuerySchema.omit({ }); export const partnerProfileEventsQuerySchema = eventsQuerySchema.omit({ - workspaceId: true, externalId: true, tenantId: true, programId: true, diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index b092c7a31a..5890357144 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -521,7 +521,6 @@ export const createPartnerSchema = z.object({ publicStats: true, tagId: true, geo: true, - projectId: true, programId: true, partnerId: true, webhookIds: true, diff --git a/apps/web/lib/zod/schemas/payouts.ts b/apps/web/lib/zod/schemas/payouts.ts index 7c80386c47..1a58cd6874 100644 --- a/apps/web/lib/zod/schemas/payouts.ts +++ b/apps/web/lib/zod/schemas/payouts.ts @@ -42,7 +42,6 @@ export const payoutsCountQuerySchema = payoutsQuerySchema partnerId: true, eligibility: true, invoiceId: true, - excludeCurrentMonth: true, }) .merge( z.object({ @@ -79,7 +78,6 @@ export const PayoutResponseSchema = PayoutSchema.merge( export const PartnerPayoutResponseSchema = PayoutResponseSchema.omit({ partner: true, - _count: true, }).merge( z.object({ program: ProgramSchema.pick({ diff --git a/apps/web/lib/zod/schemas/programs.ts b/apps/web/lib/zod/schemas/programs.ts index dc9be08139..82dbbbbd85 100644 --- a/apps/web/lib/zod/schemas/programs.ts +++ b/apps/web/lib/zod/schemas/programs.ts @@ -7,16 +7,16 @@ import { PartnerBannedReason, ProgramEnrollmentStatus, } from "@dub/prisma/client"; +import { COUNTRY_CODES } from "@dub/utils"; import { z } from "zod"; import { DiscountSchema } from "./discount"; import { GroupSchema } from "./groups"; import { LinkSchema } from "./links"; +import { programApplicationFormDataWithValuesSchema } from "./program-application-form"; import { programLanderSchema } from "./program-lander"; import { RewardSchema } from "./rewards"; import { UserSchema } from "./users"; import { parseDateSchema } from "./utils"; -import { COUNTRY_CODES } from "@dub/utils"; -import { programApplicationFormDataWithValuesSchema } from "./program-application-form"; export const HOLDING_PERIOD_DAYS = [0, 7, 14, 30, 60, 90]; diff --git a/apps/web/scripts/partners/delete-partner-profile.ts b/apps/web/scripts/partners/delete-partner-profile.ts index 2997fb0bc4..eb2a168970 100644 --- a/apps/web/scripts/partners/delete-partner-profile.ts +++ b/apps/web/scripts/partners/delete-partner-profile.ts @@ -29,45 +29,45 @@ async function main() { const deleteLinkCaches = await bulkDeleteLinks(links); console.log("Deleted link caches", deleteLinkCaches); - const deleteCustomers = await prisma.customer.deleteMany({ + const deletedCustomers = await prisma.customer.deleteMany({ where: { linkId: { in: links.map((link) => link.id), }, }, }); - console.log("Deleted customers", deleteCustomers); + console.log("Deleted customers", deletedCustomers); - const deleteSales = await prisma.commission.deleteMany({ + const deletedSales = await prisma.commission.deleteMany({ where: { partnerId: partner.id, }, }); - console.log("Deleted sales", deleteSales); + console.log("Deleted sales", deletedSales); - const deletePayouts = await prisma.payout.deleteMany({ + const deletedPayouts = await prisma.payout.deleteMany({ where: { partnerId: partner.id, }, }); - console.log("Deleted payouts", deletePayouts); + console.log("Deleted payouts", deletedPayouts); - const deleteLinks = await prisma.link.deleteMany({ + const deletedLinks = await prisma.link.deleteMany({ where: { id: { in: links.map((link) => link.id), }, }, }); - console.log("Deleted links", deleteLinks); + console.log("Deleted links", deletedLinks); } - const deletePartner = await prisma.partner.delete({ + const deletedPartner = await prisma.partner.delete({ where: { id: partner.id, }, }); - console.log("Deleted partner", deletePartner); + console.log("Deleted partner", deletedPartner); if (partner.stripeConnectId) { const res = await stripeConnectClient.accounts.del(partner.stripeConnectId); diff --git a/apps/web/ui/modals/add-discount-code-modal.tsx b/apps/web/ui/modals/add-discount-code-modal.tsx new file mode 100644 index 0000000000..5974d5b585 --- /dev/null +++ b/apps/web/ui/modals/add-discount-code-modal.tsx @@ -0,0 +1,260 @@ +import { mutatePrefix } from "@/lib/swr/mutate"; +import { useApiMutation } from "@/lib/swr/use-api-mutation"; +import { DiscountCodeProps, EnrolledPartnerProps } from "@/lib/types"; +import { createDiscountCodeSchema } from "@/lib/zod/schemas/discount"; +import { + ArrowTurnLeft, + Button, + Combobox, + ComboboxOption, + Modal, + useCopyToClipboard, +} from "@dub/ui"; +import { cn, getPrettyUrl } from "@dub/utils"; +import { Tag } from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { useDebounce } from "use-debounce"; +import { z } from "zod"; +import { STRIPE_ERROR_MAP } from "../partners/constants"; +import { X } from "../shared/icons"; +import { UpgradeRequiredToast } from "../shared/upgrade-required-toast"; + +type FormData = z.infer; + +interface AddDiscountCodeModalProps { + showModal: boolean; + setShowModal: (showModal: boolean) => void; + partner: EnrolledPartnerProps; +} + +const AddDiscountCodeModal = ({ + showModal, + setShowModal, + partner, +}: AddDiscountCodeModalProps) => { + const [search, setSearch] = useState(""); + const [isOpen, setIsOpen] = useState(false); + + const formRef = useRef(null); + const [debouncedSearch] = useDebounce(search, 500); + const [, copyToClipboard] = useCopyToClipboard(); + const { makeRequest: createDiscountCode, isSubmitting } = + useApiMutation(); + + const { register, handleSubmit, setValue, watch } = useForm({ + defaultValues: { + code: "", + linkId: "", + }, + }); + + const [linkId] = watch(["linkId"]); + + // Get partner links for the dropdown + const partnerLinks = partner.links || []; + const selectedLink = partnerLinks.find((link) => link.id === linkId); + + const linkOptions = useMemo(() => { + if (!debouncedSearch) { + return partnerLinks.map((link) => ({ + value: link.id, + label: getPrettyUrl(link.shortLink), + })); + } + + return partnerLinks + .filter((link) => + link.shortLink.toLowerCase().includes(debouncedSearch.toLowerCase()), + ) + .map((link) => ({ + value: link.id, + label: getPrettyUrl(link.shortLink), + })); + }, [partnerLinks, debouncedSearch]); + + const onSubmit = async (formData: FormData) => { + await createDiscountCode("/api/discount-codes", { + method: "POST", + body: { + ...formData, + partnerId: partner.id, + }, + onSuccess: async (data) => { + setShowModal(false); + await mutatePrefix("/api/discount-codes"); + copyToClipboard(data.code); + toast.success("Discount code created and copied to clipboard!"); + }, + onError: (error) => { + if (error) { + const code = Object.keys(STRIPE_ERROR_MAP).find((key) => + error.startsWith(key), + ); + + if (code) { + const { title, ctaLabel, ctaUrl } = STRIPE_ERROR_MAP[code]; + const message = error.replace(`${code}: `, ""); + + toast.custom(() => ( + + )); + return; + } + } + + toast.error(error); + }, + }); + }; + + return ( + +
+
+
+

New discount code

+ +
+ +
+
+
+ +
+ + { + if (!option) { + return; + } + + setValue("linkId", option.value); + }} + options={linkOptions} + caret={true} + placeholder="Select referral link" + searchPlaceholder="Search" + buttonProps={{ + className: cn( + "w-full h-10 justify-start px-3", + "data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500", + "focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none", + ), + }} + optionClassName="sm:max-w-[400px]" + shouldFilter={false} + open={isOpen} + onOpenChange={setIsOpen} + onSearchChange={setSearch} + /> +

+ Choose a referral link to associate the discount code with +

+
+ +
+
+ +
+ +
+
+ +
+ +
+

+ Discount codes cannot be edited after creation +

+
+
+
+ +
+
+ +
+ ); +}; + +export function useAddDiscountCodeModal({ + partner, +}: { + partner: EnrolledPartnerProps; +}) { + const [showAddDiscountCodeModal, setShowAddDiscountCodeModal] = + useState(false); + + const AddDiscountCodeModalCallback = useCallback(() => { + return ( + + ); + }, [showAddDiscountCodeModal, setShowAddDiscountCodeModal, partner]); + + return useMemo( + () => ({ + setShowAddDiscountCodeModal, + AddDiscountCodeModal: AddDiscountCodeModalCallback, + }), + [setShowAddDiscountCodeModal, AddDiscountCodeModalCallback], + ); +} diff --git a/apps/web/ui/partners/bulk-approve-partners-modal.tsx b/apps/web/ui/modals/bulk-approve-partners-modal.tsx similarity index 100% rename from apps/web/ui/partners/bulk-approve-partners-modal.tsx rename to apps/web/ui/modals/bulk-approve-partners-modal.tsx diff --git a/apps/web/ui/partners/change-group-modal.tsx b/apps/web/ui/modals/change-group-modal.tsx similarity index 98% rename from apps/web/ui/partners/change-group-modal.tsx rename to apps/web/ui/modals/change-group-modal.tsx index 4f1d4399bc..4052115b53 100644 --- a/apps/web/ui/partners/change-group-modal.tsx +++ b/apps/web/ui/modals/change-group-modal.tsx @@ -13,7 +13,7 @@ import { useState, } from "react"; import { toast } from "sonner"; -import { GroupSelector } from "./groups/group-selector"; +import { GroupSelector } from "../partners/groups/group-selector"; type ChangeGroupModalProps = { showChangeGroupModal: boolean; diff --git a/apps/web/ui/modals/delete-discount-code-modal.tsx b/apps/web/ui/modals/delete-discount-code-modal.tsx new file mode 100644 index 0000000000..0406cb0605 --- /dev/null +++ b/apps/web/ui/modals/delete-discount-code-modal.tsx @@ -0,0 +1,106 @@ +import { mutatePrefix } from "@/lib/swr/mutate"; +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 { toast } from "sonner"; + +interface DeleteDiscountCodeModalProps { + discountCode: DiscountCodeProps; + showModal: boolean; + setShowModal: (showModal: boolean) => void; +} + +export const DeleteDiscountCodeModal = ({ + discountCode, + showModal, + setShowModal, +}: DeleteDiscountCodeModalProps) => { + const { isMobile } = useMediaQuery(); + const { makeRequest: deleteDiscountCode, isSubmitting } = useApiMutation(); + + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + + await deleteDiscountCode(`/api/discount-codes/${discountCode.id}`, { + method: "DELETE", + onSuccess: async () => { + setShowModal(false); + await mutatePrefix("/api/discount-codes"); + toast.success(`Discount code deleted successfully!`); + }, + }); + }; + + return ( + +
+

Delete discount code

+
+ +
+
+
+
+

Are you sure you want to delete this discount code?

+
+ +
+ +
+ {discountCode.code} +
+
+ +

+ Deleting this code will remove it for the partner and they’ll no + longer be able to use it – proceed with caution. +

+ +
+
+

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

+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ ); +}; diff --git a/apps/web/ui/partners/add-edit-discount-sheet.tsx b/apps/web/ui/partners/add-edit-discount-sheet.tsx deleted file mode 100644 index c4cbd336ea..0000000000 --- a/apps/web/ui/partners/add-edit-discount-sheet.tsx +++ /dev/null @@ -1,493 +0,0 @@ -"use client"; - -import { createDiscountAction } from "@/lib/actions/partners/create-discount"; -import { deleteDiscountAction } from "@/lib/actions/partners/delete-discount"; -import { updateDiscountAction } from "@/lib/actions/partners/update-discount"; -import { handleMoneyInputChange, handleMoneyKeyDown } from "@/lib/form-utils"; -import useGroup from "@/lib/swr/use-group"; -import useProgram from "@/lib/swr/use-program"; -import useWorkspace from "@/lib/swr/use-workspace"; -import { DiscountProps } from "@/lib/types"; -import { createDiscountSchema } from "@/lib/zod/schemas/discount"; -import { RECURRING_MAX_DURATIONS } from "@/lib/zod/schemas/misc"; -import { X } from "@/ui/shared/icons"; -import { AnimatedSizeContainer, Button, CircleCheckFill, Sheet } from "@dub/ui"; -import { cn, pluralize } from "@dub/utils"; -import { useAction } from "next-safe-action/hooks"; -import { Dispatch, SetStateAction, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { mutate } from "swr"; -import { z } from "zod"; -import { - ProgramSheetAccordion, - ProgramSheetAccordionContent, - ProgramSheetAccordionItem, - ProgramSheetAccordionTrigger, -} from "./program-sheet-accordion"; - -interface DiscountSheetProps { - setIsOpen: Dispatch>; - discount?: DiscountProps; - defaultDiscountValues?: DiscountProps; -} - -type FormData = z.infer; - -const discountTypes = [ - { - label: "One-off", - description: "Offer a one-time discount", - recurring: false, - }, - { - label: "Recurring", - description: "Offer an ongoing discount", - recurring: true, - }, -] as const; - -function DiscountSheetContent({ - setIsOpen, - discount, - defaultDiscountValues, -}: DiscountSheetProps) { - const formRef = useRef(null); - const { id: workspaceId, defaultProgramId } = useWorkspace(); - const { mutate: mutateProgram } = useProgram(); - const { group, mutateGroup } = useGroup(); - - const defaultValuesSource = discount || defaultDiscountValues; - - const [isRecurring, setIsRecurring] = useState( - defaultValuesSource ? defaultValuesSource.maxDuration !== 0 : false, - ); - - const [accordionValues, setAccordionValues] = useState([ - "discount-type", - "discount-details", - ]); - - const { - register, - handleSubmit, - watch, - setValue, - formState: { errors }, - } = useForm({ - defaultValues: { - amount: - defaultValuesSource?.type === "flat" - ? defaultValuesSource.amount / 100 - : defaultValuesSource?.amount, - type: defaultValuesSource?.type || "percentage", - maxDuration: - defaultValuesSource?.maxDuration === null - ? Infinity - : defaultValuesSource?.maxDuration || 0, - couponId: defaultValuesSource?.couponId || "", - couponTestId: defaultValuesSource?.couponTestId || "", - }, - }); - - const [type, amount] = watch(["type", "amount"]); - - const { executeAsync: createDiscount, isPending: isCreating } = useAction( - createDiscountAction, - { - onSuccess: async () => { - setIsOpen(false); - toast.success("Discount created!"); - await mutateProgram(); - await mutateGroup(); - }, - onError({ error }) { - toast.error(error.serverError); - }, - }, - ); - - const { executeAsync: updateDiscount, isPending: isUpdating } = useAction( - updateDiscountAction, - { - onSuccess: async () => { - setIsOpen(false); - toast.success("Discount updated!"); - await mutateProgram(); - await mutateGroup(); - }, - onError({ error }) { - toast.error(error.serverError); - }, - }, - ); - - const { executeAsync: deleteDiscount, isPending: isDeleting } = useAction( - deleteDiscountAction, - { - onSuccess: async () => { - setIsOpen(false); - toast.success("Discount deleted!"); - await mutate(`/api/programs/${defaultProgramId}`); - await mutateGroup(); - }, - onError({ error }) { - toast.error(error.serverError); - }, - }, - ); - - const onSubmit = async (data: FormData) => { - if (!workspaceId || !defaultProgramId || !group) { - return; - } - - const payload = { - ...data, - workspaceId, - amount: data.type === "flat" ? data.amount * 100 : data.amount || 0, - maxDuration: - Number(data.maxDuration) === Infinity ? null : data.maxDuration, - }; - - if (!discount) { - await createDiscount({ - ...payload, - groupId: group.id, - }); - } else { - await updateDiscount({ - ...payload, - discountId: discount.id, - }); - } - }; - - const onDelete = async () => { - if (!workspaceId || !defaultProgramId || !discount) { - return; - } - - if (!confirm("Are you sure you want to delete this discount?")) { - return; - } - - await deleteDiscount({ - workspaceId, - discountId: discount.id, - }); - }; - - return ( - <> -
-
- - {discount ? "Edit" : "Create"} discount - - -
- -
- - - - Discount Type - - -
-

- Set how the discount will be applied -

-
- -
-
- {discountTypes.map( - ({ label, description, recurring }) => { - const isSelected = isRecurring === recurring; - - return ( - - ); - }, - )} -
- - {isRecurring && ( -
-
- -
- -
-
-
- )} -
-
-
-
-
-
- - - - Discount Details - - -
-

- Set the discount amount and configuration -

- -
- -
- -
-
- -
- -
- {type === "flat" && ( - - $ - - )} - - - {type === "flat" ? "USD" : "%"} - -
-
- -
- -
- -
- -

- Learn more about{" "} - - Stripe coupon codes here - -

-
- -
- -
- -
-
-
-
-
-
-
- -
-
- {discount && ( -
- -
-
-
- - - ); -} - -export function DiscountSheet({ - isOpen, - nested, - ...rest -}: DiscountSheetProps & { - isOpen: boolean; - nested?: boolean; -}) { - return ( - - - - ); -} - -export function useDiscountSheet( - props: { nested?: boolean } & Omit, -) { - const [isOpen, setIsOpen] = useState(false); - - return { - DiscountSheet: ( - - ), - setIsOpen, - }; -} diff --git a/apps/web/ui/partners/constants.ts b/apps/web/ui/partners/constants.ts index 5dbfc33644..495f4d916c 100644 --- a/apps/web/ui/partners/constants.ts +++ b/apps/web/ui/partners/constants.ts @@ -23,3 +23,19 @@ export const REWARD_EVENTS = { eventName: "sale", }, } as const; + +export const STRIPE_ERROR_MAP: Record< + string, + { title: string; ctaLabel: string; ctaUrl: string } +> = { + STRIPE_CONNECTION_REQUIRED: { + title: "Stripe connection required", + ctaLabel: "Install Stripe app", + ctaUrl: "https://marketplace.stripe.com/apps/dub-conversions", + }, + STRIPE_APP_UPGRADE_REQUIRED: { + title: "Stripe app upgrade required", + ctaLabel: "Review permissions", + ctaUrl: "https://marketplace.stripe.com/apps/dub-conversions", + }, +}; diff --git a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx new file mode 100644 index 0000000000..26196e79f9 --- /dev/null +++ b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx @@ -0,0 +1,602 @@ +"use client"; + +import { createDiscountAction } from "@/lib/actions/partners/create-discount"; +import { deleteDiscountAction } from "@/lib/actions/partners/delete-discount"; +import { updateDiscountAction } from "@/lib/actions/partners/update-discount"; +import { constructRewardAmount } from "@/lib/api/sales/construct-reward-amount"; +import { handleMoneyInputChange, handleMoneyKeyDown } from "@/lib/form-utils"; +import useGroup from "@/lib/swr/use-group"; +import useProgram from "@/lib/swr/use-program"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { DiscountProps } from "@/lib/types"; +import { createDiscountSchema } from "@/lib/zod/schemas/discount"; +import { RECURRING_MAX_DURATIONS } from "@/lib/zod/schemas/misc"; +import { Stripe } from "@/ui/guides/icons/stripe"; +import { X } from "@/ui/shared/icons"; +import { + InlineBadgePopover, + InlineBadgePopoverMenu, +} from "@/ui/shared/inline-badge-popover"; +import { UpgradeRequiredToast } from "@/ui/shared/upgrade-required-toast"; +import { + Button, + InfoTooltip, + Sheet, + SimpleTooltipContent, + Switch, +} from "@dub/ui"; +import { CircleCheckFill, Tag } from "@dub/ui/icons"; +import { capitalize, cn, pluralize } from "@dub/utils"; +import { useAction } from "next-safe-action/hooks"; +import { + Dispatch, + PropsWithChildren, + ReactNode, + SetStateAction, + useRef, + useState, +} from "react"; +import { FormProvider, useForm, useFormContext } from "react-hook-form"; +import { toast } from "sonner"; +import { mutate } from "swr"; +import { z } from "zod"; +import { STRIPE_ERROR_MAP } from "../constants"; +import { RewardDiscountPartnersCard } from "../groups/reward-discount-partners-card"; + +interface DiscountSheetProps { + setIsOpen: Dispatch>; + discount?: DiscountProps; + defaultDiscountValues?: DiscountProps; +} + +type FormData = z.infer; + +export const useAddEditDiscountForm = () => useFormContext(); + +const COUPON_CREATION_OPTIONS = [ + { + label: "New Stripe coupon", + description: "Create a new coupon", + useExisting: false, + }, + { + label: "Use Stripe coupon ID", + description: "Use an existing coupon", + useExisting: true, + }, +] as const; + +function DiscountSheetContent({ + setIsOpen, + discount, + defaultDiscountValues, +}: DiscountSheetProps) { + const formRef = useRef(null); + + const { group, mutateGroup } = useGroup(); + const { mutate: mutateProgram } = useProgram(); + const { id: workspaceId, defaultProgramId, stripeConnectId } = useWorkspace(); + + const [useExistingCoupon, setUseExistingCoupon] = useState(false); + + const [useStripeTestCouponId, setUseStripeTestCouponId] = useState( + Boolean(discount?.couponTestId), + ); + + const defaultValuesSource = discount || + defaultDiscountValues || { + amount: 10, + type: "percentage", + maxDuration: 6, + couponId: "", + couponTestId: "", + }; // default is 10% for 6 months + + const form = useForm({ + defaultValues: { + amount: + defaultValuesSource.type === "flat" + ? defaultValuesSource.amount / 100 + : defaultValuesSource.amount, + type: defaultValuesSource.type, + maxDuration: + defaultValuesSource.maxDuration === null + ? Infinity + : defaultValuesSource.maxDuration, + couponId: defaultValuesSource.couponId || "", + couponTestId: defaultValuesSource.couponTestId, + }, + }); + + const { handleSubmit, watch, setValue, register } = form; + const [type, amount, maxDuration] = watch(["type", "amount", "maxDuration"]); + + const { executeAsync: createDiscount, isPending: isCreating } = useAction( + createDiscountAction, + { + onSuccess: async () => { + setIsOpen(false); + toast.success("Discount created!"); + await mutateProgram(); + await mutateGroup(); + }, + onError({ error }) { + if (error.serverError) { + const code = Object.keys(STRIPE_ERROR_MAP).find((key) => + error.serverError!.startsWith(key), + ); + + if (code) { + const { title, ctaLabel, ctaUrl } = STRIPE_ERROR_MAP[code]; + const message = error.serverError!.replace(`${code}: `, ""); + + toast.custom(() => ( + + )); + return; + } + } + + toast.error(error.serverError); + }, + }, + ); + + const { executeAsync: updateDiscount, isPending: isUpdating } = useAction( + updateDiscountAction, + { + onSuccess: async () => { + setIsOpen(false); + toast.success("Discount updated!"); + await mutateProgram(); + await mutateGroup(); + }, + onError({ error }) { + toast.error(error.serverError); + }, + }, + ); + + const { executeAsync: deleteDiscount, isPending: isDeleting } = useAction( + deleteDiscountAction, + { + onSuccess: async () => { + setIsOpen(false); + toast.success("Discount deleted!"); + await mutate(`/api/programs/${defaultProgramId}`); + await mutateGroup(); + }, + onError({ error }) { + toast.error(error.serverError); + }, + }, + ); + + const onSubmit = async (data: FormData) => { + if (!workspaceId || !defaultProgramId || !group) { + return; + } + + if (discount) { + await updateDiscount({ + workspaceId, + discountId: discount.id, + couponTestId: data.couponTestId, + }); + return; + } + + await createDiscount({ + ...data, + workspaceId, + groupId: group.id, + amount: data.type === "flat" ? data.amount * 100 : data.amount || 0, + maxDuration: + Number(data.maxDuration) === Infinity ? null : data.maxDuration, + }); + }; + + const onDelete = async () => { + if (!workspaceId || !defaultProgramId || !discount) { + return; + } + + if (!confirm("Are you sure you want to delete this discount?")) { + return; + } + + await deleteDiscount({ + workspaceId, + discountId: discount.id, + }); + }; + + return ( + +
+
+ + {discount ? "Edit" : "Create"} discount + + +
+ +
+ +
+ +
+ Coupon connection + + } + content={ +
+
+
+ +
+ +
+
+ + {!discount && ( +
+ {COUPON_CREATION_OPTIONS.map( + ({ label, description, useExisting }) => { + const isSelected = useExistingCoupon === useExisting; + + return ( + + ); + }, + )} +
+ )} + + {(useExistingCoupon || discount) && ( + <> +
+ +
+ +
+
+ +
+ { + setUseStripeTestCouponId(!useStripeTestCouponId); + setValue("couponTestId", ""); + }} + checked={useStripeTestCouponId} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> +
+

+ Use Stripe test coupon ID +

+ + + } + /> +
+
+ + {useStripeTestCouponId && ( +
+ +
+ +
+
+ )} + + )} +
+
+ } + /> + + + + + + + Discount a{" "} + + + setValue("type", value as "flat" | "percentage", { + shouldDirty: true, + }) + } + items={[ + { + text: "Flat", + value: "flat", + }, + { + text: "Percentage", + value: "percentage", + }, + ]} + /> + {" "} + {type === "percentage" && "of "} + + + {" "} + + + setValue("maxDuration", Number(value), { + shouldDirty: true, + }) + } + items={[ + { + text: "one time", + value: "0", + }, + ...RECURRING_MAX_DURATIONS.filter((v) => v !== 0).map( + (v) => ({ + text: `for ${v} ${pluralize("month", Number(v))}`, + value: v.toString(), + }), + ), + { + text: "for the customer's lifetime", + value: "Infinity", + }, + ]} + /> + + + + } + content={<>} + /> + + + + {group && } +
+ +
+
+ {discount && ( +
+ +
+
+
+ +
+ ); +} + +function DiscountSheetCard({ + title, + content, + className, +}: PropsWithChildren<{ + title: ReactNode; + content: ReactNode; + className?: string; +}>) { + return ( +
+
+ {title} +
+ {content && <>{content}} +
+ ); +} + +const VerticalLine = () => ( +
+); + +function AmountInput({ disabled }: { disabled?: boolean }) { + const { watch, register } = useAddEditDiscountForm(); + const type = watch("type"); + + return ( +
+ {type === "flat" && ( + + $ + + )} + (value === "" ? undefined : +value), + min: 0, + max: type === "percentage" ? 100 : undefined, + onChange: handleMoneyInputChange, + })} + onKeyDown={handleMoneyKeyDown} + /> + + {type === "flat" ? "USD" : "%"} + +
+ ); +} + +export function DiscountSheet({ + isOpen, + nested, + ...rest +}: DiscountSheetProps & { + isOpen: boolean; + nested?: boolean; +}) { + return ( + + + + ); +} + +export function useDiscountSheet( + props: { nested?: boolean } & Omit, +) { + const [isOpen, setIsOpen] = useState(false); + + return { + DiscountSheet: ( + + ), + setIsOpen, + }; +} diff --git a/apps/web/ui/partners/discounts/discount-code-badge.tsx b/apps/web/ui/partners/discounts/discount-code-badge.tsx new file mode 100644 index 0000000000..a51d3a0108 --- /dev/null +++ b/apps/web/ui/partners/discounts/discount-code-badge.tsx @@ -0,0 +1,29 @@ +import { Tag, useCopyToClipboard } from "@dub/ui"; +import { cn } from "@dub/utils"; +import { toast } from "sonner"; + +export function DiscountCodeBadge({ code }: { code: string }) { + const [copied, copyToClipboard] = useCopyToClipboard(); + return ( + + ); +} diff --git a/apps/web/ui/partners/rewards/reward-partners-card.tsx b/apps/web/ui/partners/groups/reward-discount-partners-card.tsx similarity index 96% rename from apps/web/ui/partners/rewards/reward-partners-card.tsx rename to apps/web/ui/partners/groups/reward-discount-partners-card.tsx index e7c30f43e6..94655a2a2f 100644 --- a/apps/web/ui/partners/rewards/reward-partners-card.tsx +++ b/apps/web/ui/partners/groups/reward-discount-partners-card.tsx @@ -8,15 +8,19 @@ import { motion } from "motion/react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { useState } from "react"; -import { RewardIconSquare } from "./reward-icon-square"; +import { RewardIconSquare } from "../rewards/reward-icon-square"; + +export function RewardDiscountPartnersCard({ groupId }: { groupId: string }) { + const [isExpanded, setIsExpanded] = useState(false); -export function RewardPartnersCard({ groupId }: { groupId: string }) { - const { partners } = usePartners({ - query: { groupId, pageSize: 10 }, - }); const { partnersCount } = usePartnersCount({ groupId }); - const [isExpanded, setIsExpanded] = useState(false); + const { partners } = usePartners({ + query: { + groupId, + pageSize: 10, + }, + }); return (
diff --git a/apps/web/ui/partners/partner-info-group.tsx b/apps/web/ui/partners/partner-info-group.tsx index a694d2407a..ebe168e9bc 100644 --- a/apps/web/ui/partners/partner-info-group.tsx +++ b/apps/web/ui/partners/partner-info-group.tsx @@ -5,7 +5,7 @@ import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { Button } from "@dub/ui"; import { cn } from "@dub/utils"; import Link from "next/link"; -import { useChangeGroupModal } from "./change-group-modal"; +import { useChangeGroupModal } from "../modals/change-group-modal"; import { GroupColorCircle } from "./groups/group-color-circle"; export function PartnerInfoGroup({ diff --git a/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx b/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx index d868aa370b..da320c4162 100644 --- a/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx +++ b/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx @@ -53,9 +53,9 @@ import { InlineBadgePopoverInput, InlineBadgePopoverMenu, } from "../../shared/inline-badge-popover"; +import { RewardDiscountPartnersCard } from "../groups/reward-discount-partners-card"; import { usePartnersUpgradeModal } from "../partners-upgrade-modal"; import { RewardIconSquare } from "./reward-icon-square"; -import { RewardPartnersCard } from "./reward-partners-card"; import { REWARD_TYPES, RewardsLogic } from "./rewards-logic"; interface RewardSheetProps { @@ -476,7 +476,7 @@ function RewardSheetContent({ - {group && } + {group && }
diff --git a/apps/web/ui/shared/inline-badge-popover.tsx b/apps/web/ui/shared/inline-badge-popover.tsx index 8c40404c61..9aa88305ec 100644 --- a/apps/web/ui/shared/inline-badge-popover.tsx +++ b/apps/web/ui/shared/inline-badge-popover.tsx @@ -33,11 +33,13 @@ export const InlineBadgePopoverContext = createContext<{ export function InlineBadgePopover({ text, invalid, + disabled, children, buttonClassName, }: PropsWithChildren<{ text: ReactNode; invalid?: boolean; + disabled?: boolean; buttonClassName?: string; }>) { const [isOpen, setIsOpen] = useState(false); @@ -61,6 +63,7 @@ export function InlineBadgePopover({ >