From 171c3fb722bcbd846efcec34b9322e0e8fc4cf52 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 8 Jul 2025 14:33:15 +0530 Subject: [PATCH 001/221] Update stripe-app.json --- packages/stripe-app/stripe-app.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/stripe-app/stripe-app.json b/packages/stripe-app/stripe-app.json index 6db4433a689..88cb33baf17 100644 --- a/packages/stripe-app/stripe-app.json +++ b/packages/stripe-app/stripe-app.json @@ -39,6 +39,14 @@ { "permission": "secret_write", "purpose": "Allows storing Dub access tokens in Stripe for an account." + }, + { + "permission": "promotion_code_write", + "purpose": "Allows Dub to create promotion codes for an account." + }, + { + "permission": "promotion_code_read", + "purpose": "Allows Dub to read promotion codes for an account." } ], "ui_extension": { From 1815f7334ca04dde9fc11228a3c25aa2934d32dc Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 8 Jul 2025 15:54:34 +0530 Subject: [PATCH 002/221] wip handle coupon in checkoutSessionCompleted --- .../webhook/checkout-session-completed.ts | 74 ++++++++++++++++++- apps/web/lib/stripe/create-promotion-code.ts | 40 ++++++++++ apps/web/lib/zod/schemas/programs.ts | 1 + apps/web/scripts/stripe/coupon.ts | 58 +++++++++++++++ packages/prisma/schema/program.prisma | 2 + packages/prisma/schema/workspace.prisma | 1 + 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 apps/web/lib/stripe/create-promotion-code.ts create mode 100644 apps/web/scripts/stripe/coupon.ts 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 caae0481bfc..836e83e2a52 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 @@ -20,7 +20,7 @@ import { clickEventSchemaTB } from "@/lib/zod/schemas/clicks"; import { leadEventSchemaTB } from "@/lib/zod/schemas/leads"; import { prisma } from "@dub/prisma"; import { Customer } from "@dub/prisma/client"; -import { nanoid } from "@dub/utils"; +import { linkConstructorSimple, nanoid } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; import { @@ -200,6 +200,78 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { ); linkId = leadEvent.link_id; + } else if (charge.discounts && charge.discounts.length > 0) { + // Handle promotion code tracking for coupon-based attribution + // When a charge has discounts, we can attribute the sale to a partner + // based on the promotion code used during checkout + + const workspace = await prisma.project.findUnique({ + where: { + stripeConnectId: stripeAccountId, + }, + select: { + defaultProgram: { + select: { + domain: true, + couponCodeTrackingEnabledAt: true, + }, + }, + }, + }); + + if (!workspace) { + return `Workspace with stripeConnectId ${stripeAccountId} not found, skipping...`; + } + + if (!workspace.defaultProgram) { + return `Workspace with stripeConnectId ${stripeAccountId} has no program, skipping...`; + } + + const { defaultProgram: program } = workspace; + + if (!program.couponCodeTrackingEnabledAt) { + return `Workspace with stripeConnectId ${stripeAccountId} has no coupon code tracking enabled, skipping...`; + } + + if (!program.domain) { + return `Workspace with stripeConnectId ${stripeAccountId} has no domain for program, skipping...`; + } + + const promotionCodes = charge.discounts.map( + ({ promotion_code }) => promotion_code, + ); + + if (promotionCodes.length === 0) { + return "No promotion codes found in Stripe checkout session, skipping..."; + } + + const promotionCode = promotionCodes[0] as Stripe.PromotionCode; + + const link = await prisma.link.findUnique({ + where: { + domain_key: { + domain: program.domain, + key: promotionCode.code, + }, + }, + select: { + id: true, + }, + }); + + if (!link) { + return `Link ${linkConstructorSimple({ + domain: program.domain, + key: promotionCode.code, + })} not found, skipping...`; + } + + linkId = link.id; + + // let customer: Customer | null = null; + // let existingCustomer: Customer | null = null; + // let clickEvent: z.infer | null = null; + // let leadEvent: z.infer; } else { return "No dubCustomerId or stripeCustomerId found in Stripe checkout session metadata, skipping..."; } diff --git a/apps/web/lib/stripe/create-promotion-code.ts b/apps/web/lib/stripe/create-promotion-code.ts new file mode 100644 index 00000000000..30fa85e60aa --- /dev/null +++ b/apps/web/lib/stripe/create-promotion-code.ts @@ -0,0 +1,40 @@ +import { Link } from "@prisma/client"; +import { stripeAppClient } from "."; + +export async function createPromotionCode({ + couponId, + link, + stripeAccount, +}: { + couponId: string; + link: Pick; + stripeAccount: string; +}) { + const stripe = stripeAppClient({ + livemode: process.env.NODE_ENV === "production", + }); + + try { + const promotionCode = await stripe.promotionCodes.create( + { + coupon: couponId, + code: link.key, + metadata: { + partnerId: link.partnerId, + }, + }, + { + stripeAccount, + }, + ); + + console.log( + `Promotion code ${promotionCode.id} created for link ${link.key} for account ${stripeAccount}`, + ); + + return promotionCode; + } catch (error) { + console.error("Failed to create promotion code", error); + throw error; + } +} diff --git a/apps/web/lib/zod/schemas/programs.ts b/apps/web/lib/zod/schemas/programs.ts index 5785823c5dc..e2802df3bae 100644 --- a/apps/web/lib/zod/schemas/programs.ts +++ b/apps/web/lib/zod/schemas/programs.ts @@ -27,6 +27,7 @@ export const ProgramSchema = z.object({ linkParameter: z.string().nullish(), landerPublishedAt: z.date().nullish(), autoApprovePartnersEnabledAt: z.date().nullish(), + couponCodeTrackingEnabledAt: z.date().nullish(), rewards: z.array(RewardSchema).nullish(), discounts: z.array(DiscountSchema).nullish(), defaultFolderId: z.string().nullable(), diff --git a/apps/web/scripts/stripe/coupon.ts b/apps/web/scripts/stripe/coupon.ts new file mode 100644 index 00000000000..0f0eef6c984 --- /dev/null +++ b/apps/web/scripts/stripe/coupon.ts @@ -0,0 +1,58 @@ +import { stripeAppClient } from "@/lib/stripe"; +import "dotenv-flow/config"; + +// Just for testing purposes +async function main() { + const stripeApp = stripeAppClient({ + livemode: false, + }); + + const stripeAccount = "acct_1QVcu12ULJbggj84"; + + // Create a coupon + const coupon = await stripeApp.coupons.create( + { + name: "Coupon 1", + percent_off: 30, + duration: "repeating", + duration_in_months: 12, + }, + { + stripeAccount, + }, + ); + + console.log(coupon); + + // List all coupons + const coupons = await stripeApp.coupons.list({ + stripeAccount, + }); + + console.log(coupons); + + // Create a promotion code + const promotionCode = await stripeApp.promotionCodes.create( + { + coupon: "eL9nAqZy", + code: "TEST_CODE", + metadata: { + partnerId: "123", + }, + }, + { + stripeAccount, + }, + ); + + console.log(promotionCode); + + // List all promotion codes + const promotionCodes = await stripeApp.promotionCodes.list({ + stripeAccount, + }); + + console.log(promotionCodes); +} + +main(); diff --git a/packages/prisma/schema/program.prisma b/packages/prisma/schema/program.prisma index 89602ade061..6f13347cbca 100644 --- a/packages/prisma/schema/program.prisma +++ b/packages/prisma/schema/program.prisma @@ -48,11 +48,13 @@ model Program { linkStructure LinkStructure @default(short) linkParameter String? // null for SHORT, "via" for QUERY, "refer" for PATH autoApprovePartnersEnabledAt DateTime? + couponCodeTrackingEnabledAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt workspace Project @relation(fields: [workspaceId], references: [id]) primaryDomain Domain? @relation(fields: [domain], references: [slug], onUpdate: Cascade) + defaultFor Project? @relation("defaultProgram") partners ProgramEnrollment[] payouts Payout[] invoices Invoice[] diff --git a/packages/prisma/schema/workspace.prisma b/packages/prisma/schema/workspace.prisma index 78ae32bb7a7..7073e394dc9 100644 --- a/packages/prisma/schema/workspace.prisma +++ b/packages/prisma/schema/workspace.prisma @@ -57,6 +57,7 @@ model Project { domains Domain[] tags Tag[] programs Program[] + defaultProgram Program? @relation("defaultProgram", fields: [defaultProgramId], references: [id], onDelete: NoAction, onUpdate: NoAction) invoices Invoice[] customers Customer[] defaultDomains DefaultDomains[] From 4493c286be68db49d5b4d76b81e2ffaf9c6cf404 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 8 Jul 2025 16:53:49 +0530 Subject: [PATCH 003/221] Update coupon.ts --- apps/web/scripts/stripe/coupon.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/scripts/stripe/coupon.ts b/apps/web/scripts/stripe/coupon.ts index 0f0eef6c984..b0f88d73c28 100644 --- a/apps/web/scripts/stripe/coupon.ts +++ b/apps/web/scripts/stripe/coupon.ts @@ -7,9 +7,9 @@ async function main() { livemode: false, }); - const stripeAccount = "acct_1QVcu12ULJbggj84"; + const stripeAccount = "acct_1RiZ6DDixECvUM5P"; - // Create a coupon + // // Create a coupon const coupon = await stripeApp.coupons.create( { name: "Coupon 1", @@ -24,7 +24,7 @@ async function main() { console.log(coupon); - // List all coupons + // // List all coupons const coupons = await stripeApp.coupons.list({ stripeAccount, }); @@ -34,8 +34,8 @@ async function main() { // Create a promotion code const promotionCode = await stripeApp.promotionCodes.create( { - coupon: "eL9nAqZy", - code: "TEST_CODE", + coupon: "msvkvUlA", + code: "WELCOME", metadata: { partnerId: "123", }, From 094ce72275dcff1cff543670ec568ccacafe7e37 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 9 Jul 2025 12:19:46 +0530 Subject: [PATCH 004/221] Update slider.tsx --- packages/ui/src/slider.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/src/slider.tsx b/packages/ui/src/slider.tsx index 4cb176ae415..89eda4eb7fe 100644 --- a/packages/ui/src/slider.tsx +++ b/packages/ui/src/slider.tsx @@ -56,7 +56,6 @@ export function Slider({ {sliderMarks.map((mark) => { const left = ((mark - min) / (max - min)) * 100; - const isFilled = mark <= value; return ( Date: Wed, 9 Jul 2025 14:36:58 +0530 Subject: [PATCH 005/221] Add support for link-based coupon codes with Stripe integration - Updated discount schemas to include optional `provider` field for tracking coupon sources. - Implemented `createStripeCoupon` and `deleteStripeCoupon` functions for managing coupons on Stripe. - Modified discount creation, deletion, and update actions to handle Stripe coupons. - Adjusted API routes and audit log schemas to accommodate new fields and functionality. - Ensured backward compatibility by making certain fields optional in the discount schema. --- .../links/invalidate-for-discounts/route.ts | 10 +- .../lib/actions/partners/create-discount.ts | 34 +++++- .../lib/actions/partners/delete-discount.ts | 11 +- .../lib/actions/partners/update-discount.ts | 101 ++++++++++-------- apps/web/lib/api/audit-logs/schemas.ts | 2 +- apps/web/lib/stripe/create-coupon.ts | 50 +++++++++ apps/web/lib/stripe/delete-coupon.ts | 42 ++++++++ apps/web/lib/zod/schemas/discount.ts | 6 +- packages/prisma/schema/discount.prisma | 5 +- packages/prisma/schema/program.prisma | 1 + packages/stripe-app/stripe-app.json | 10 +- 11 files changed, 216 insertions(+), 56 deletions(-) create mode 100644 apps/web/lib/stripe/create-coupon.ts create mode 100644 apps/web/lib/stripe/delete-coupon.ts 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 b34f601adcd..0ee44952120 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 @@ -8,9 +8,15 @@ import { z } from "zod"; export const dynamic = "force-dynamic"; const schema = z.object({ - programId: z.string(), discountId: z.string(), - isDefault: z.boolean(), + programId: z + .string() + .optional() + .describe("Must be passed for discount-deleted action"), + isDefault: z + .boolean() + .optional() + .describe("Must be passed for discount-deleted action"), action: z.enum(["discount-created", "discount-updated", "discount-deleted"]), }); diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 44bbca3348f..f2596350443 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -5,10 +5,12 @@ import { createId } from "@/lib/api/create-id"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { qstash } from "@/lib/cron"; +import { createStripeCoupon } from "@/lib/stripe/create-coupon"; import { createDiscountSchema } from "@/lib/zod/schemas/discount"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; +import Stripe from "stripe"; import { authActionClient } from "../safe-action"; export const createDiscountAction = authActionClient @@ -24,6 +26,7 @@ export const createDiscountAction = authActionClient isDefault, includedPartnerIds, excludedPartnerIds, + provider, } = parsedInput; includedPartnerIds = includedPartnerIds || []; @@ -81,6 +84,32 @@ export const createDiscountAction = authActionClient } } + // Create Stripe coupon for link-based coupon codes + let stripeCoupon: Stripe.Coupon | null = null; + + if (provider === "stripe") { + if (!workspace.stripeConnectId) { + throw new Error( + "Make sure you have connected your Stripe account to your workspace to create a coupon.", + ); + } + + const response = await createStripeCoupon({ + coupon: { + amount, + type, + maxDuration: maxDuration ?? null, + }, + stripeConnectId: workspace.stripeConnectId, + }); + + if (!response) { + throw new Error("Failed to create a coupon on Stripe."); + } + + stripeCoupon = response; + } + const discount = await prisma.discount.create({ data: { id: createId({ prefix: "disc_" }), @@ -88,9 +117,10 @@ export const createDiscountAction = authActionClient amount, type, maxDuration, - couponId, + couponId: stripeCoupon?.id ?? couponId, couponTestId, default: isDefault, + provider, }, }); @@ -123,9 +153,7 @@ export const createDiscountAction = authActionClient qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, body: { - programId, discountId: discount.id, - isDefault, action: "discount-created", }, }), diff --git a/apps/web/lib/actions/partners/delete-discount.ts b/apps/web/lib/actions/partners/delete-discount.ts index 99099c846a6..4ded83f04cf 100644 --- a/apps/web/lib/actions/partners/delete-discount.ts +++ b/apps/web/lib/actions/partners/delete-discount.ts @@ -5,6 +5,7 @@ import { getDiscountOrThrow } from "@/lib/api/partners/get-discount-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { qstash } from "@/lib/cron"; +import { deleteStripeCoupon } from "@/lib/stripe/delete-coupon"; import { redis } from "@/lib/upstash"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; @@ -25,7 +26,7 @@ export const deleteDiscountAction = authActionClient const programId = getDefaultProgramIdOrThrow(workspace); - const program = await getProgramOrThrow({ + await getProgramOrThrow({ workspaceId: workspace.id, programId, }); @@ -127,6 +128,14 @@ export const deleteDiscountAction = authActionClient }, ], }), + + // Remove the Stripe coupon if it exists + discount.provider === "stripe" + ? deleteStripeCoupon({ + coupon: discount, + stripeConnectId: workspace.stripeConnectId, + }) + : Promise.resolve(), ]), ); } diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index 3c2f388cc69..31460f090f5 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -62,55 +62,64 @@ export const updateDiscountAction = authActionClient } } - const updatedDiscount = await prisma.discount.update({ - where: { - id: discountId, - }, - data: { - amount, - type, - maxDuration, - couponId, - couponTestId, - }, - }); + let updatedDiscount: Discount | undefined = undefined; + + // Stripe doesn't support updating the standard coupon fields + if (discount.provider !== "stripe") { + updatedDiscount = await prisma.discount.update({ + where: { + id: discountId, + }, + data: { + amount, + type, + maxDuration, + couponId, + couponTestId, + }, + }); + } // Update partners associated with the discount - if (updatedDiscount.default) { + if (discount.default) { await updateDefaultDiscountPartners({ - discount: updatedDiscount, + discountId, + programId, partnerIds: excludedPartnerIds, }); } else { await updateNonDefaultDiscountPartners({ - discount: updatedDiscount, + discountId, + programId, partnerIds: includedPartnerIds, }); } waitUntil( (async () => { - const shouldExpireCache = !deepEqual( - { - amount: discount.amount, - type: discount.type, - maxDuration: discount.maxDuration, - }, - { - amount: updatedDiscount.amount, - type: updatedDiscount.type, - maxDuration: updatedDiscount.maxDuration, - }, - ); + let shouldExpireCache = false; + + if (updatedDiscount) { + shouldExpireCache = !deepEqual( + { + amount: discount.amount, + type: discount.type, + maxDuration: discount.maxDuration, + }, + { + amount: updatedDiscount.amount, + type: updatedDiscount.type, + maxDuration: updatedDiscount.maxDuration, + }, + ); + } await Promise.allSettled([ shouldExpireCache ? qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, body: { - programId, discountId, - isDefault: updatedDiscount.default, action: "discount-updated", }, }) @@ -137,15 +146,17 @@ export const updateDiscountAction = authActionClient // Update default discount const updateDefaultDiscountPartners = async ({ - discount, + discountId, + programId, partnerIds, }: { - discount: Discount; + discountId: string; + programId: string; partnerIds: string[]; // Excluded partners }) => { const existingPartners = await prisma.programEnrollment.findMany({ where: { - programId: discount.programId, + programId, discountId: null, }, select: { @@ -167,7 +178,7 @@ const updateDefaultDiscountPartners = async ({ if (excludedPartnerIds.length > 0) { await prisma.programEnrollment.updateMany({ where: { - programId: discount.programId, + programId, partnerId: { in: excludedPartnerIds, }, @@ -182,14 +193,14 @@ const updateDefaultDiscountPartners = async ({ if (includedPartnerIds.length > 0) { await prisma.programEnrollment.updateMany({ where: { - programId: discount.programId, + programId, discountId: null, partnerId: { in: includedPartnerIds, }, }, data: { - discountId: discount.id, + discountId, }, }); } @@ -197,16 +208,18 @@ const updateDefaultDiscountPartners = async ({ // Update non-default discount const updateNonDefaultDiscountPartners = async ({ - discount, + discountId, + programId, partnerIds, }: { - discount: Discount; + discountId: string; + programId: string; partnerIds: string[]; // Included partners }) => { const existingPartners = await prisma.programEnrollment.findMany({ where: { - programId: discount.programId, - discountId: discount.id, + programId, + discountId, }, select: { partnerId: true, @@ -227,13 +240,13 @@ const updateNonDefaultDiscountPartners = async ({ if (includedPartnerIds.length > 0) { await prisma.programEnrollment.updateMany({ where: { - programId: discount.programId, + programId, partnerId: { in: includedPartnerIds, }, }, data: { - discountId: discount.id, + discountId, }, }); } @@ -242,15 +255,15 @@ const updateNonDefaultDiscountPartners = async ({ if (excludedPartnerIds.length > 0) { const defaultDiscount = await prisma.discount.findFirst({ where: { - programId: discount.programId, + programId, default: true, }, }); await prisma.programEnrollment.updateMany({ where: { - programId: discount.programId, - discountId: discount.id, + programId, + discountId, partnerId: { in: excludedPartnerIds, }, diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index 116324f82f3..460125cde01 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -105,7 +105,7 @@ export const auditLogTarget = z.union([ amount: true, maxDuration: true, couponId: true, - }), + }).optional(), }), z.object({ diff --git a/apps/web/lib/stripe/create-coupon.ts b/apps/web/lib/stripe/create-coupon.ts new file mode 100644 index 00000000000..97e5f3a6b5e --- /dev/null +++ b/apps/web/lib/stripe/create-coupon.ts @@ -0,0 +1,50 @@ +import { Discount } from "@prisma/client"; +import { stripeAppClient } from "."; + +// Create a coupon on Stripe for connected accounts +export async function createStripeCoupon({ + coupon, + stripeConnectId, +}: { + coupon: Pick; + stripeConnectId: string | null; +}) { + if (!stripeConnectId) { + console.error( + "stripeConnectId not found for the workspace. Stripe coupon creation skipped.", + ); + return; + } + + const stripe = stripeAppClient({ + livemode: process.env.NODE_ENV === "production", + }); + + const { type, amount, maxDuration } = coupon; + + const duration = + maxDuration === null ? "forever" : maxDuration === 1 ? "once" : "repeating"; + + try { + return await stripe.coupons.create( + { + currency: "usd", + duration, + ...(duration === "repeating" && { + duration_in_months: maxDuration!, + }), + ...(type === "percentage" + ? { percent_off: amount } + : { amount_off: amount }), + }, + { + stripeAccount: stripeConnectId, + }, + ); + } catch (error) { + console.error( + `Failed to create Stripe coupon for ${stripeConnectId}: ${error}`, + ); + return null; + } +} diff --git a/apps/web/lib/stripe/delete-coupon.ts b/apps/web/lib/stripe/delete-coupon.ts new file mode 100644 index 00000000000..413d566474b --- /dev/null +++ b/apps/web/lib/stripe/delete-coupon.ts @@ -0,0 +1,42 @@ +import { Discount } from "@prisma/client"; +import { stripeAppClient } from "."; + +// Delete a coupon on Stripe for connected accounts +export async function deleteStripeCoupon({ + coupon, + stripeConnectId, +}: { + coupon: Pick; + stripeConnectId: string | null; +}) { + if (!stripeConnectId) { + console.error( + "stripeConnectId not found for the workspace. Stripe coupon creation skipped.", + ); + return; + } + + const { couponId } = coupon; + + if (!couponId) { + console.error( + "couponId not found for the discount. Stripe coupon deletion skipped.", + ); + return; + } + + const stripe = stripeAppClient({ + livemode: process.env.NODE_ENV === "production", + }); + + try { + return await stripe.coupons.del(couponId, { + stripeAccount: stripeConnectId, + }); + } catch (error) { + console.error( + `Failed to delete Stripe coupon for ${stripeConnectId}: ${error}`, + ); + return null; + } +} diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index bb3fabc8d4e..b793240d3ce 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -7,11 +7,12 @@ 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(), - partnersCount: z.number().nullish(), default: z.boolean(), + provider: z.enum(["stripe"]).nullable(), + description: z.string().nullish(), + partnersCount: z.number().nullish(), }); export const DiscountSchemaWithDeprecatedFields = DiscountSchema.extend({ @@ -38,6 +39,7 @@ export const createDiscountSchema = z.object({ .array(z.string()) .nullish() .describe("Only applicable for default discounts"), + provider: z.enum(["stripe"]).nullish(), }); export const updateDiscountSchema = createDiscountSchema.extend({ diff --git a/packages/prisma/schema/discount.prisma b/packages/prisma/schema/discount.prisma index c0b38b896eb..25bacaddc8f 100644 --- a/packages/prisma/schema/discount.prisma +++ b/packages/prisma/schema/discount.prisma @@ -6,13 +6,14 @@ model Discount { maxDuration Int? // in months (0 -> not recurring, null -> lifetime) description String? couponId String? - couponTestId String? + couponTestId String? default Boolean @default(false) + provider String? // coupon provider for link-based coupon code tracking createdAt DateTime @default(now()) updatedAt DateTime @updatedAt programEnrollments ProgramEnrollment[] program Program @relation("ProgramDiscounts", fields: [programId], references: [id], onDelete: Cascade, onUpdate: Cascade) - + @@index(programId) } diff --git a/packages/prisma/schema/program.prisma b/packages/prisma/schema/program.prisma index 6f13347cbca..391f01c6b8a 100644 --- a/packages/prisma/schema/program.prisma +++ b/packages/prisma/schema/program.prisma @@ -77,6 +77,7 @@ model ProgramEnrollment { clickRewardId String? leadRewardId String? saleRewardId String? + couponId String? // Stripe coupon ID for link-based coupon code tracking applicationId String? @unique status ProgramEnrollmentStatus @default(pending) totalCommissions Int @default(0) // total commissions earned by the partner (in cents) diff --git a/packages/stripe-app/stripe-app.json b/packages/stripe-app/stripe-app.json index 88cb33baf17..5fc6843c93d 100644 --- a/packages/stripe-app/stripe-app.json +++ b/packages/stripe-app/stripe-app.json @@ -40,6 +40,14 @@ "permission": "secret_write", "purpose": "Allows storing Dub access tokens in Stripe for an account." }, + { + "permission": "coupon_write", + "purpose": "Allows Dub to create coupons for an account." + }, + { + "permission": "coupon_read", + "purpose": "Allows Dub to read coupons for an account." + }, { "permission": "promotion_code_write", "purpose": "Allows Dub to create promotion codes for an account." @@ -77,4 +85,4 @@ ], "stripe_api_access_type": "oauth", "distribution_type": "public" -} \ No newline at end of file +} From 18d46a8c7b8fac9f733a2d45968392e17505b90d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 9 Jul 2025 15:25:20 +0530 Subject: [PATCH 006/221] Add API route for creating promotion codes linked to discounts - Introduced a new route to handle the creation of promotion codes for link-based coupon codes. - Implemented validation and error handling for discount retrieval and workspace checks. - Integrated the promotion code creation process with Stripe for each link associated with the discount. - Updated the discount creation process to trigger the new promotion code route upon successful coupon creation. --- .../links/create-promotion-codes/route.ts | 121 ++++++++++++++++++ .../lib/actions/partners/create-discount.ts | 8 ++ apps/web/lib/stripe/create-coupon.ts | 3 +- apps/web/lib/stripe/create-promotion-code.ts | 39 +++--- 4 files changed, 151 insertions(+), 20 deletions(-) create mode 100644 apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts new file mode 100644 index 00000000000..2e8b19bb93f --- /dev/null +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -0,0 +1,121 @@ +import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { createStripePromotionCode } from "@/lib/stripe/create-promotion-code"; +import { prisma } from "@dub/prisma"; +import { chunk } from "@dub/utils"; +import { z } from "zod"; + +export const dynamic = "force-dynamic"; + +const schema = z.object({ + discountId: z.string(), +}); + +// This route is used to create promotion codes for each link for link-based coupon codes tracking. +// POST /api/cron/links/create-promotion-codes +export async function POST(req: Request) { + try { + const rawBody = await req.text(); + await verifyQstashSignature({ req, rawBody }); + + const body = schema.parse(JSON.parse(rawBody)); + const { discountId } = body; + + const discount = await prisma.discount.findUnique({ + where: { + id: discountId, + }, + }); + + if (!discount) { + return new Response("Discount not found."); + } + + const { provider, programId, couponId } = discount; + + if (provider !== "stripe") { + return new Response("Discount is not a link-based coupon code."); + } + + if (!couponId) { + return new Response("Discount coupon ID not found."); + } + + const workspace = await prisma.project.findUnique({ + where: { + defaultProgramId: programId, + }, + select: { + stripeConnectId: true, + }, + }); + + if (!workspace?.stripeConnectId) { + return new Response("Workspace not found."); + } + + const enrollments = await prisma.programEnrollment.findMany({ + where: { + programId, + couponId: discount.id, + }, + select: { + partnerId: true, + }, + }); + + if (enrollments.length === 0) { + return new Response("No enrollments found."); + } + + const links = await prisma.link.findMany({ + where: { + programId, + partnerId: { + in: enrollments.map(({ partnerId }) => partnerId), + }, + }, + select: { + key: true, + }, + }); + + if (links.length === 0) { + return new Response("No links found."); + } + + const { stripeConnectId } = workspace; + const linksChunks = chunk(links, 20); + const failedRequests: Error[] = []; + + for (const linksChunk of linksChunks) { + const results = await Promise.allSettled( + linksChunk.map(({ key }) => + createStripePromotionCode({ + couponId, + linkKey: key, + stripeConnectId, + }), + ), + ); + + results.forEach((result) => { + if (result.status === "rejected") { + failedRequests.push(result.reason); + } + }); + } + + if (failedRequests.length > 0) { + console.error(failedRequests); + } + + return new Response( + failedRequests.length > 0 + ? `Failed to create promotion codes for ${failedRequests.length} links. See logs for more details.` + : "OK", + ); + } catch (error) { + return handleAndReturnErrorResponse(error); + } +} diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index f2596350443..543b49b5c2c 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -172,6 +172,14 @@ export const createDiscountAction = authActionClient }, ], }), + + // If the coupon code is created on Stripe, create a promotion code for each link + stripeCoupon ? qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, + body: { + discountId: discount.id, + }, + }) : Promise.resolve(), ]); })(), ); diff --git a/apps/web/lib/stripe/create-coupon.ts b/apps/web/lib/stripe/create-coupon.ts index 97e5f3a6b5e..82e417dc6c1 100644 --- a/apps/web/lib/stripe/create-coupon.ts +++ b/apps/web/lib/stripe/create-coupon.ts @@ -45,6 +45,7 @@ export async function createStripeCoupon({ console.error( `Failed to create Stripe coupon for ${stripeConnectId}: ${error}`, ); - return null; + + throw new Error(error instanceof Error ? error.message : "Unknown error"); } } diff --git a/apps/web/lib/stripe/create-promotion-code.ts b/apps/web/lib/stripe/create-promotion-code.ts index 30fa85e60aa..032d2280daf 100644 --- a/apps/web/lib/stripe/create-promotion-code.ts +++ b/apps/web/lib/stripe/create-promotion-code.ts @@ -1,40 +1,41 @@ -import { Link } from "@prisma/client"; import { stripeAppClient } from "."; -export async function createPromotionCode({ +// Create a promotion code on Stripe for connected accounts +export async function createStripePromotionCode({ couponId, - link, - stripeAccount, + linkKey, + stripeConnectId, }: { couponId: string; - link: Pick; - stripeAccount: string; + linkKey: string; + stripeConnectId: string | null; }) { + if (!stripeConnectId) { + console.error( + "stripeConnectId not found for the workspace. Stripe promotion code creation skipped.", + ); + return; + } + const stripe = stripeAppClient({ livemode: process.env.NODE_ENV === "production", }); try { - const promotionCode = await stripe.promotionCodes.create( + return await stripe.promotionCodes.create( { coupon: couponId, - code: link.key, - metadata: { - partnerId: link.partnerId, - }, + code: linkKey.toUpperCase(), }, { - stripeAccount, + stripeAccount: stripeConnectId, }, ); - - console.log( - `Promotion code ${promotionCode.id} created for link ${link.key} for account ${stripeAccount}`, + } catch (error) { + console.error( + `Failed to create Stripe promotion code for ${stripeConnectId}: ${error}`, ); - return promotionCode; - } catch (error) { - console.error("Failed to create promotion code", error); - throw error; + throw new Error(error instanceof Error ? error.message : "Unknown error"); } } From 1cfa644a932e283e6a43b661b581ee39eea4ca8c Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 9 Jul 2025 15:27:42 +0530 Subject: [PATCH 007/221] Update create-discount.ts --- .../lib/actions/partners/create-discount.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 543b49b5c2c..09538853bc2 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -34,7 +34,7 @@ export const createDiscountAction = authActionClient const programId = getDefaultProgramIdOrThrow(workspace); - await getProgramOrThrow({ + const program = await getProgramOrThrow({ workspaceId: workspace.id, programId, }); @@ -174,12 +174,26 @@ export const createDiscountAction = authActionClient }), // If the coupon code is created on Stripe, create a promotion code for each link - stripeCoupon ? qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, - body: { - discountId: discount.id, - }, - }) : Promise.resolve(), + stripeCoupon + ? qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, + body: { + discountId: discount.id, + }, + }) + : Promise.resolve(), + + // Enable the coupon-code tracking if not already enabled + stripeCoupon && !program.couponCodeTrackingEnabledAt + ? prisma.program.update({ + where: { + id: programId, + }, + data: { + couponCodeTrackingEnabledAt: new Date(), + }, + }) + : Promise.resolve(), ]); })(), ); From 25a837a1c454b81caa0df90e7efb88b00b2f1762 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 11 Jul 2025 22:46:28 +0530 Subject: [PATCH 008/221] revert the db changes --- packages/prisma/schema/discount.prisma | 1 - packages/prisma/schema/program.prisma | 2 -- packages/prisma/schema/workspace.prisma | 1 - 3 files changed, 4 deletions(-) diff --git a/packages/prisma/schema/discount.prisma b/packages/prisma/schema/discount.prisma index 25bacaddc8f..b1e1cc35415 100644 --- a/packages/prisma/schema/discount.prisma +++ b/packages/prisma/schema/discount.prisma @@ -8,7 +8,6 @@ model Discount { couponId String? couponTestId String? default Boolean @default(false) - provider String? // coupon provider for link-based coupon code tracking createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/prisma/schema/program.prisma b/packages/prisma/schema/program.prisma index 391f01c6b8a..d5459c1653d 100644 --- a/packages/prisma/schema/program.prisma +++ b/packages/prisma/schema/program.prisma @@ -54,7 +54,6 @@ model Program { workspace Project @relation(fields: [workspaceId], references: [id]) primaryDomain Domain? @relation(fields: [domain], references: [slug], onUpdate: Cascade) - defaultFor Project? @relation("defaultProgram") partners ProgramEnrollment[] payouts Payout[] invoices Invoice[] @@ -77,7 +76,6 @@ model ProgramEnrollment { clickRewardId String? leadRewardId String? saleRewardId String? - couponId String? // Stripe coupon ID for link-based coupon code tracking applicationId String? @unique status ProgramEnrollmentStatus @default(pending) totalCommissions Int @default(0) // total commissions earned by the partner (in cents) diff --git a/packages/prisma/schema/workspace.prisma b/packages/prisma/schema/workspace.prisma index 7073e394dc9..78ae32bb7a7 100644 --- a/packages/prisma/schema/workspace.prisma +++ b/packages/prisma/schema/workspace.prisma @@ -57,7 +57,6 @@ model Project { domains Domain[] tags Tag[] programs Program[] - defaultProgram Program? @relation("defaultProgram", fields: [defaultProgramId], references: [id], onDelete: NoAction, onUpdate: NoAction) invoices Invoice[] customers Customer[] defaultDomains DefaultDomains[] From 0c8937eff10d183d57e484237998a4499cf9fafc Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 14 Jul 2025 14:36:43 +0530 Subject: [PATCH 009/221] Refactor discount actions to remove unused provider parameter and streamline coupon handling --- .../lib/actions/partners/create-discount.ts | 32 +++------------- .../lib/actions/partners/delete-discount.ts | 17 +++------ .../lib/actions/partners/update-discount.ts | 30 ++++++--------- apps/web/lib/stripe/create-coupon.ts | 38 ++++++++----------- apps/web/lib/stripe/delete-coupon.ts | 14 +------ apps/web/lib/zod/schemas/discount.ts | 2 - 6 files changed, 39 insertions(+), 94 deletions(-) diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 09538853bc2..bd1797f008d 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -3,7 +3,6 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { createId } from "@/lib/api/create-id"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; -import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { qstash } from "@/lib/cron"; import { createStripeCoupon } from "@/lib/stripe/create-coupon"; import { createDiscountSchema } from "@/lib/zod/schemas/discount"; @@ -26,7 +25,6 @@ export const createDiscountAction = authActionClient isDefault, includedPartnerIds, excludedPartnerIds, - provider, } = parsedInput; includedPartnerIds = includedPartnerIds || []; @@ -34,11 +32,6 @@ export const createDiscountAction = authActionClient const programId = getDefaultProgramIdOrThrow(workspace); - const program = await getProgramOrThrow({ - workspaceId: workspace.id, - programId, - }); - // A program can have only one default discount if (isDefault) { const defaultDiscount = await prisma.discount.findFirst({ @@ -84,10 +77,10 @@ export const createDiscountAction = authActionClient } } - // Create Stripe coupon for link-based coupon codes let stripeCoupon: Stripe.Coupon | null = null; + const shouldCreateCouponOnStripe = !couponId && !couponTestId; - if (provider === "stripe") { + if (shouldCreateCouponOnStripe) { if (!workspace.stripeConnectId) { throw new Error( "Make sure you have connected your Stripe account to your workspace to create a coupon.", @@ -95,19 +88,17 @@ export const createDiscountAction = authActionClient } const response = await createStripeCoupon({ + stripeConnectId: workspace.stripeConnectId, coupon: { amount, type, maxDuration: maxDuration ?? null, }, - stripeConnectId: workspace.stripeConnectId, }); - if (!response) { - throw new Error("Failed to create a coupon on Stripe."); + if (response) { + stripeCoupon = response; } - - stripeCoupon = response; } const discount = await prisma.discount.create({ @@ -120,7 +111,6 @@ export const createDiscountAction = authActionClient couponId: stripeCoupon?.id ?? couponId, couponTestId, default: isDefault, - provider, }, }); @@ -182,18 +172,6 @@ export const createDiscountAction = authActionClient }, }) : Promise.resolve(), - - // Enable the coupon-code tracking if not already enabled - stripeCoupon && !program.couponCodeTrackingEnabledAt - ? prisma.program.update({ - where: { - id: programId, - }, - data: { - couponCodeTrackingEnabledAt: new Date(), - }, - }) - : Promise.resolve(), ]); })(), ); diff --git a/apps/web/lib/actions/partners/delete-discount.ts b/apps/web/lib/actions/partners/delete-discount.ts index 4ded83f04cf..386939b6235 100644 --- a/apps/web/lib/actions/partners/delete-discount.ts +++ b/apps/web/lib/actions/partners/delete-discount.ts @@ -3,7 +3,6 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getDiscountOrThrow } from "@/lib/api/partners/get-discount-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; -import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { qstash } from "@/lib/cron"; import { deleteStripeCoupon } from "@/lib/stripe/delete-coupon"; import { redis } from "@/lib/upstash"; @@ -26,11 +25,6 @@ export const deleteDiscountAction = authActionClient const programId = getDefaultProgramIdOrThrow(workspace); - await getProgramOrThrow({ - workspaceId: workspace.id, - programId, - }); - const discount = await getDiscountOrThrow({ programId, discountId, @@ -130,12 +124,11 @@ export const deleteDiscountAction = authActionClient }), // Remove the Stripe coupon if it exists - discount.provider === "stripe" - ? deleteStripeCoupon({ - coupon: discount, - stripeConnectId: workspace.stripeConnectId, - }) - : Promise.resolve(), + discount.couponId && + deleteStripeCoupon({ + couponId: discount.couponId, + stripeConnectId: workspace.stripeConnectId, + }), ]), ); } diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index 31460f090f5..4ed86073256 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -6,7 +6,6 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-progr import { qstash } from "@/lib/cron"; import { updateDiscountSchema } from "@/lib/zod/schemas/discount"; import { prisma } from "@dub/prisma"; -import { Discount } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK, deepEqual } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { authActionClient } from "../safe-action"; @@ -62,23 +61,18 @@ export const updateDiscountAction = authActionClient } } - let updatedDiscount: Discount | undefined = undefined; - - // Stripe doesn't support updating the standard coupon fields - if (discount.provider !== "stripe") { - updatedDiscount = await prisma.discount.update({ - where: { - id: discountId, - }, - data: { - amount, - type, - maxDuration, - couponId, - couponTestId, - }, - }); - } + const updatedDiscount = await prisma.discount.update({ + where: { + id: discountId, + }, + data: { + amount, + type, + maxDuration, + couponId, + couponTestId, + }, + }); // Update partners associated with the discount if (discount.default) { diff --git a/apps/web/lib/stripe/create-coupon.ts b/apps/web/lib/stripe/create-coupon.ts index 82e417dc6c1..749b72a5ce9 100644 --- a/apps/web/lib/stripe/create-coupon.ts +++ b/apps/web/lib/stripe/create-coupon.ts @@ -25,27 +25,19 @@ export async function createStripeCoupon({ const duration = maxDuration === null ? "forever" : maxDuration === 1 ? "once" : "repeating"; - try { - return await stripe.coupons.create( - { - currency: "usd", - duration, - ...(duration === "repeating" && { - duration_in_months: maxDuration!, - }), - ...(type === "percentage" - ? { percent_off: amount } - : { amount_off: amount }), - }, - { - stripeAccount: stripeConnectId, - }, - ); - } catch (error) { - console.error( - `Failed to create Stripe coupon for ${stripeConnectId}: ${error}`, - ); - - throw new Error(error instanceof Error ? error.message : "Unknown error"); - } + return await stripe.coupons.create( + { + currency: "usd", + duration, + ...(duration === "repeating" && { + duration_in_months: maxDuration!, + }), + ...(type === "percentage" + ? { percent_off: amount } + : { amount_off: amount }), + }, + { + stripeAccount: stripeConnectId, + }, + ); } diff --git a/apps/web/lib/stripe/delete-coupon.ts b/apps/web/lib/stripe/delete-coupon.ts index 413d566474b..1c8b877065e 100644 --- a/apps/web/lib/stripe/delete-coupon.ts +++ b/apps/web/lib/stripe/delete-coupon.ts @@ -1,12 +1,11 @@ -import { Discount } from "@prisma/client"; import { stripeAppClient } from "."; // Delete a coupon on Stripe for connected accounts export async function deleteStripeCoupon({ - coupon, + couponId, stripeConnectId, }: { - coupon: Pick; + couponId: string; stripeConnectId: string | null; }) { if (!stripeConnectId) { @@ -16,15 +15,6 @@ export async function deleteStripeCoupon({ return; } - const { couponId } = coupon; - - if (!couponId) { - console.error( - "couponId not found for the discount. Stripe coupon deletion skipped.", - ); - return; - } - const stripe = stripeAppClient({ livemode: process.env.NODE_ENV === "production", }); diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index b793240d3ce..38e57bb9eb8 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -10,7 +10,6 @@ export const DiscountSchema = z.object({ couponId: z.string().nullable(), couponTestId: z.string().nullable(), default: z.boolean(), - provider: z.enum(["stripe"]).nullable(), description: z.string().nullish(), partnersCount: z.number().nullish(), }); @@ -39,7 +38,6 @@ export const createDiscountSchema = z.object({ .array(z.string()) .nullish() .describe("Only applicable for default discounts"), - provider: z.enum(["stripe"]).nullish(), }); export const updateDiscountSchema = createDiscountSchema.extend({ From 56daee40f2687422995afd3f36010c105b49afbf Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 14 Jul 2025 14:44:00 +0530 Subject: [PATCH 010/221] Refactor promotion code creation logic --- .../links/create-promotion-codes/route.ts | 34 +++++++------------ .../lib/actions/partners/create-discount.ts | 15 ++++---- apps/web/lib/stripe/create-promotion-code.ts | 6 ++-- 3 files changed, 23 insertions(+), 32 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 2e8b19bb93f..0c63f35ee0f 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -18,8 +18,7 @@ export async function POST(req: Request) { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); - const body = schema.parse(JSON.parse(rawBody)); - const { discountId } = body; + const { discountId } = schema.parse(JSON.parse(rawBody)); const discount = await prisma.discount.findUnique({ where: { @@ -31,33 +30,27 @@ export async function POST(req: Request) { return new Response("Discount not found."); } - const { provider, programId, couponId } = discount; - - if (provider !== "stripe") { - return new Response("Discount is not a link-based coupon code."); - } - - if (!couponId) { - return new Response("Discount coupon ID not found."); + if (!discount.couponId) { + return new Response("couponId doesn't set for the discount."); } - const workspace = await prisma.project.findUnique({ + const workspace = await prisma.project.findUniqueOrThrow({ where: { - defaultProgramId: programId, + defaultProgramId: discount.programId, }, select: { stripeConnectId: true, }, }); - if (!workspace?.stripeConnectId) { - return new Response("Workspace not found."); + if (!workspace.stripeConnectId) { + return new Response("stripeConnectId doesn't exist for the workspace."); } const enrollments = await prisma.programEnrollment.findMany({ where: { - programId, - couponId: discount.id, + programId: discount.programId, + discountId: discount.id, }, select: { partnerId: true, @@ -70,7 +63,7 @@ export async function POST(req: Request) { const links = await prisma.link.findMany({ where: { - programId, + programId: discount.programId, partnerId: { in: enrollments.map(({ partnerId }) => partnerId), }, @@ -84,7 +77,6 @@ export async function POST(req: Request) { return new Response("No links found."); } - const { stripeConnectId } = workspace; const linksChunks = chunk(links, 20); const failedRequests: Error[] = []; @@ -92,9 +84,9 @@ export async function POST(req: Request) { const results = await Promise.allSettled( linksChunk.map(({ key }) => createStripePromotionCode({ - couponId, - linkKey: key, - stripeConnectId, + code: key, + couponId: discount.couponId!, + stripeConnectId: workspace.stripeConnectId, }), ), ); diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index bd1797f008d..48e03250616 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -164,14 +164,13 @@ export const createDiscountAction = authActionClient }), // If the coupon code is created on Stripe, create a promotion code for each link - stripeCoupon - ? qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, - body: { - discountId: discount.id, - }, - }) - : Promise.resolve(), + stripeCoupon && + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, + body: { + discountId: discount.id, + }, + }), ]); })(), ); diff --git a/apps/web/lib/stripe/create-promotion-code.ts b/apps/web/lib/stripe/create-promotion-code.ts index 032d2280daf..2d58694f270 100644 --- a/apps/web/lib/stripe/create-promotion-code.ts +++ b/apps/web/lib/stripe/create-promotion-code.ts @@ -3,11 +3,11 @@ import { stripeAppClient } from "."; // Create a promotion code on Stripe for connected accounts export async function createStripePromotionCode({ couponId, - linkKey, + code, stripeConnectId, }: { couponId: string; - linkKey: string; + code: string; stripeConnectId: string | null; }) { if (!stripeConnectId) { @@ -25,7 +25,7 @@ export async function createStripePromotionCode({ return await stripe.promotionCodes.create( { coupon: couponId, - code: linkKey.toUpperCase(), + code: code.toUpperCase(), }, { stripeAccount: stripeConnectId, From 1a31aab38c2fc86e2c52b87f17cc42670943de2d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 14 Jul 2025 16:57:53 +0530 Subject: [PATCH 011/221] Delete coupon.ts --- apps/web/scripts/stripe/coupon.ts | 58 ------------------------------- 1 file changed, 58 deletions(-) delete mode 100644 apps/web/scripts/stripe/coupon.ts diff --git a/apps/web/scripts/stripe/coupon.ts b/apps/web/scripts/stripe/coupon.ts deleted file mode 100644 index b0f88d73c28..00000000000 --- a/apps/web/scripts/stripe/coupon.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { stripeAppClient } from "@/lib/stripe"; -import "dotenv-flow/config"; - -// Just for testing purposes -async function main() { - const stripeApp = stripeAppClient({ - livemode: false, - }); - - const stripeAccount = "acct_1RiZ6DDixECvUM5P"; - - // // Create a coupon - const coupon = await stripeApp.coupons.create( - { - name: "Coupon 1", - percent_off: 30, - duration: "repeating", - duration_in_months: 12, - }, - { - stripeAccount, - }, - ); - - console.log(coupon); - - // // List all coupons - const coupons = await stripeApp.coupons.list({ - stripeAccount, - }); - - console.log(coupons); - - // Create a promotion code - const promotionCode = await stripeApp.promotionCodes.create( - { - coupon: "msvkvUlA", - code: "WELCOME", - metadata: { - partnerId: "123", - }, - }, - { - stripeAccount, - }, - ); - - console.log(promotionCode); - - // List all promotion codes - const promotionCodes = await stripeApp.promotionCodes.list({ - stripeAccount, - }); - - console.log(promotionCodes); -} - -main(); From 6e782e56d5226c9c74fa3debea056959b284a4ed Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 21 Jul 2025 13:52:05 +0530 Subject: [PATCH 012/221] wip --- .../lib/actions/partners/update-discount.ts | 103 +--- apps/web/lib/zod/schemas/discount.ts | 19 +- .../ui/partners/add-edit-discount-sheet.tsx | 543 +++++++++++------- 3 files changed, 375 insertions(+), 290 deletions(-) diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index 4ed86073256..a00e60eba04 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -3,10 +3,8 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; 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"; import { updateDiscountSchema } from "@/lib/zod/schemas/discount"; import { prisma } from "@dub/prisma"; -import { APP_DOMAIN_WITH_NGROK, deepEqual } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { authActionClient } from "../safe-action"; @@ -14,19 +12,7 @@ export const updateDiscountAction = authActionClient .schema(updateDiscountSchema) .action(async ({ parsedInput, ctx }) => { const { workspace, user } = ctx; - let { - discountId, - amount, - type, - maxDuration, - couponId, - couponTestId, - includedPartnerIds, - excludedPartnerIds, - } = parsedInput; - - includedPartnerIds = includedPartnerIds || []; - excludedPartnerIds = excludedPartnerIds || []; + let { discountId, includedPartnerIds, excludedPartnerIds } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -35,6 +21,9 @@ export const updateDiscountAction = authActionClient discountId, }); + includedPartnerIds = includedPartnerIds || []; + excludedPartnerIds = excludedPartnerIds || []; + const finalPartnerIds = [...includedPartnerIds, ...excludedPartnerIds]; if (finalPartnerIds && finalPartnerIds.length > 0) { @@ -61,91 +50,47 @@ export const updateDiscountAction = authActionClient } } - const updatedDiscount = await prisma.discount.update({ - where: { - id: discountId, - }, - data: { - amount, - type, - maxDuration, - couponId, - couponTestId, - }, - }); - // Update partners associated with the discount if (discount.default) { await updateDefaultDiscountPartners({ - discountId, programId, + discountId, partnerIds: excludedPartnerIds, }); } else { await updateNonDefaultDiscountPartners({ - discountId, programId, + discountId, partnerIds: includedPartnerIds, }); } waitUntil( - (async () => { - let shouldExpireCache = false; - - if (updatedDiscount) { - shouldExpireCache = !deepEqual( - { - amount: discount.amount, - type: discount.type, - maxDuration: discount.maxDuration, - }, - { - amount: updatedDiscount.amount, - type: updatedDiscount.type, - maxDuration: updatedDiscount.maxDuration, - }, - ); - } - - await Promise.allSettled([ - shouldExpireCache - ? qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, - body: { - discountId, - action: "discount-updated", - }, - }) - : Promise.resolve(), - - recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "discount.updated", - description: `Discount ${discount.id} updated`, - actor: user, - targets: [ - { - type: "discount", - id: discount.id, - metadata: updatedDiscount, - }, - ], - }), - ]); - })(), + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "discount.updated", + description: `Discount ${discount.id} updated`, + actor: user, + targets: [ + { + type: "discount", + id: discount.id, + metadata: discount, + }, + ], + }), ); }); // Update default discount const updateDefaultDiscountPartners = async ({ - discountId, programId, + discountId, partnerIds, }: { - discountId: string; programId: string; + discountId: string; partnerIds: string[]; // Excluded partners }) => { const existingPartners = await prisma.programEnrollment.findMany({ @@ -202,12 +147,12 @@ const updateDefaultDiscountPartners = async ({ // Update non-default discount const updateNonDefaultDiscountPartners = async ({ - discountId, programId, + discountId, partnerIds, }: { - discountId: string; programId: string; + discountId: string; partnerIds: string[]; // Included partners }) => { const existingPartners = await prisma.programEnrollment.findMany({ diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index 38e57bb9eb8..084a1831455 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -27,8 +27,11 @@ export const createDiscountSchema = z.object({ amount: z.number().min(0), type: z.nativeEnum(RewardStructure).default("flat"), maxDuration: maxDurationSchema, - couponId: z.string(), - couponTestId: z.string().nullish(), + couponId: z.string().nullish().describe("Use existing Stripe coupon ID"), + couponTestId: z + .string() + .nullish() + .describe("Use existing Stripe test coupon ID"), isDefault: z.boolean(), includedPartnerIds: z .array(z.string()) @@ -40,9 +43,15 @@ export const createDiscountSchema = z.object({ .describe("Only applicable for default discounts"), }); -export const updateDiscountSchema = createDiscountSchema.extend({ - discountId: z.string(), -}); +export const updateDiscountSchema = createDiscountSchema + .pick({ + workspaceId: true, + includedPartnerIds: true, + excludedPartnerIds: true, + }) + .extend({ + discountId: z.string(), + }); export const discountPartnersQuerySchema = z .object({ diff --git a/apps/web/ui/partners/add-edit-discount-sheet.tsx b/apps/web/ui/partners/add-edit-discount-sheet.tsx index aa4a178343c..4e6d188b831 100644 --- a/apps/web/ui/partners/add-edit-discount-sheet.tsx +++ b/apps/web/ui/partners/add-edit-discount-sheet.tsx @@ -14,6 +14,7 @@ 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 { BadgePercent } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -21,6 +22,7 @@ import { toast } from "sonner"; import { mutate } from "swr"; import { z } from "zod"; import { DiscountPartnersTable } from "./discount-partners-table"; +import { ProgramRewardDescription } from "./program-reward-description"; import { ProgramSheetAccordion, ProgramSheetAccordionContent, @@ -49,22 +51,40 @@ const discountTypes = [ }, ] as const; +const couponTypes = [ + { + label: "New coupon", + description: "Create a new coupon", + useExisting: false, + }, + { + label: "Existing coupon", + description: "Use an existing coupon", + useExisting: true, + }, +] as const; + function DiscountSheetContent({ setIsOpen, discount, isDefault, }: DiscountSheetProps) { const formRef = useRef(null); - const { id: workspaceId, defaultProgramId } = useWorkspace(); const { mutate: mutateProgram } = useProgram(); + const { id: workspaceId, defaultProgramId } = useWorkspace(); const [isRecurring, setIsRecurring] = useState( discount ? discount.maxDuration !== 0 : false, ); + const [useExistingCoupon, setUseExistingCoupon] = useState( + Boolean(discount?.couponId), + ); + const [accordionValues, setAccordionValues] = useState([ "discount-type", "discount-details", + "stripe-coupon-details", ]); const { @@ -75,20 +95,6 @@ function DiscountSheetContent({ formState: { errors }, } = useForm({ defaultValues: { - ...(discount && { - amount: discount.amount - ? discount.type === "flat" - ? discount.amount / 100 - : discount.amount - : 0, - type: discount.type || "flat", - maxDuration: - discount?.maxDuration === null - ? Infinity - : discount?.maxDuration || 0, - couponId: discount.couponId || "", - couponTestId: discount.couponTestId || "", - }), includedPartnerIds: null, excludedPartnerIds: null, }, @@ -176,23 +182,23 @@ function DiscountSheetContent({ return; } - const payload = { - ...data, - workspaceId, - includedPartnerIds: isDefault ? null : includedPartnerIds, - excludedPartnerIds: isDefault ? excludedPartnerIds : null, - amount: data.type === "flat" ? data.amount * 100 : data.amount || 0, - maxDuration: - Number(data.maxDuration) === Infinity ? null : data.maxDuration, - isDefault: isDefault || false, - }; - if (!discount) { - await createDiscount(payload); + await createDiscount({ + ...data, + workspaceId, + includedPartnerIds: isDefault ? null : includedPartnerIds, + excludedPartnerIds: isDefault ? excludedPartnerIds : null, + amount: data.type === "flat" ? data.amount * 100 : data.amount || 0, + maxDuration: + Number(data.maxDuration) === Infinity ? null : data.maxDuration, + isDefault: isDefault || false, + }); } else { await updateDiscount({ - ...payload, + workspaceId, discountId: discount.id, + includedPartnerIds: isDefault ? null : includedPartnerIds, + excludedPartnerIds: isDefault ? excludedPartnerIds : null, }); } }; @@ -238,34 +244,194 @@ function DiscountSheetContent({ value={accordionValues} onValueChange={setAccordionValues} > - - - Discount Type - - -
-

- Set how the discount will be applied -

-
- -
-
+ + Discount Type + + +
+

+ Set how the discount will be applied +

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

+ Set the discount amount and configuration +

+ +
+ +
+ +
+
+ +
+ +
+ {type === "flat" && ( + + $ + + )} + + {...register("amount", { + valueAsNumber: true, + min: 0, + max: 100, + onChange: handleMoneyInputChange, + required: true, + })} + onKeyDown={handleMoneyKeyDown} + placeholder={"0"} + /> + + {type === "flat" ? "USD" : "%"} + +
+
+ + {/* Display the coupon switcher if the discount is being created */} + {!discount && ( +
+

+ Create a new discount code or connect an existing one +

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

- Set the discount amount and configuration -

- -
- -
- -
-
+ )} + + {useExistingCoupon && ( + <> +
+ +
+ +
-
- -
- {type === "flat" && ( - - $ - - )} - - - {type === "flat" ? "USD" : "%"} - -
+

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

+
+ +
+ +
+ +
+
+ + )}
+ + + )} -
- -
- + {discount && ( + + + Stripe coupon + + +
+
+ +
+ +
-

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

-
+ {discount.couponTestId && ( +
+ +
+ +
+
+ )} -
- -
- +
+
+
+ +
+ + + +
+

+ Discounts cannot be changed after creation, only their + partner eligibility. +

-
-
-
+ + + )} {!isDefault && defaultProgramId && ( From 062568248569f5d7b166fa87b5975af11151c391 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 14 Jul 2025 08:40:31 +0530 Subject: [PATCH 013/221] wip --- apps/web/lib/partnerstack/types.ts | 62 ++++++--- .../ui/modals/import-partnerstack-modal.tsx | 119 ++++++++++-------- 2 files changed, 109 insertions(+), 72 deletions(-) diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts index e2db94c8b33..aca341366de 100644 --- a/apps/web/lib/partnerstack/types.ts +++ b/apps/web/lib/partnerstack/types.ts @@ -1,31 +1,53 @@ import { z } from "zod"; -import { - partnerStackCommission, - partnerStackCustomer, - partnerStackImportPayloadSchema, - partnerStackLink, - partnerStackPartner, -} from "./schemas"; export interface PartnerStackConfig { - publicKey: string; - secretKey: string; + token: string; + userId: string; + partnerstackProgramId?: string; } export interface PartnerStackListResponse { - data: { - items: T[]; - }; + success: true; + total_count: number; + data: T[]; } -export type PartnerStackImportPayload = z.infer< - typeof partnerStackImportPayloadSchema ->; - -export type PartnerStackPartner = z.infer; +// Basic types - these will be expanded as we implement the API +export interface PartnerStackAffiliate { + id: string; + email: string; + first_name: string; + last_name: string; + company_name?: string; + country_code?: string; + profile_type?: string; + created_at: string; +} -export type PartnerStackLink = z.infer; +export interface PartnerStackLink { + id: string; + tracking_url: string; + token: string; + partner_id: string; + created_at: string; +} -export type PartnerStackCustomer = z.infer; +export interface PartnerStackReferral { + id: string; + email: string; + first_name: string; + last_name: string; + company_name?: string; + partner_id: string; + created_at: string; +} -export type PartnerStackCommission = z.infer; +export interface PartnerStackCommission { + id: string; + amount: number; + commission_amount: number; + status: string; + partner_id: string; + customer_id: string; + created_at: string; +} diff --git a/apps/web/ui/modals/import-partnerstack-modal.tsx b/apps/web/ui/modals/import-partnerstack-modal.tsx index 73ed42d1b49..7487be79a2b 100644 --- a/apps/web/ui/modals/import-partnerstack-modal.tsx +++ b/apps/web/ui/modals/import-partnerstack-modal.tsx @@ -1,3 +1,4 @@ +import { setPartnerStackTokenAction } from "@/lib/actions/partners/set-partnerstack-token"; import { startPartnerStackImportAction } from "@/lib/actions/partners/start-partnerstack-import"; import useWorkspace from "@/lib/swr/use-workspace"; import { Button, Logo, Modal, useMediaQuery, useRouterStuff } from "@dub/ui"; @@ -51,9 +52,7 @@ function ImportPartnerStackModal({
-

- Import Your PartnerStack Program -

+

Import Your PartnerStack Program

Import your existing PartnerStack program into{" "} {process.env.NEXT_PUBLIC_APP_NAME} with just a few clicks. @@ -74,93 +73,109 @@ function ImportPartnerStackModal({ ); } -function TokenForm({ onClose }: { onClose: () => void }) { - const router = useRouter(); +function TokenForm({ + onClose, +}: { + onClose: () => void; +}) { const { isMobile } = useMediaQuery(); - const [publicKey, setPublicKey] = useState(""); - const [secretKey, setSecretKey] = useState(""); + const router = useRouter(); const { id: workspaceId, slug } = useWorkspace(); - const { executeAsync, isPending } = useAction(startPartnerStackImportAction, { - onSuccess: () => { - onClose(); - toast.success( - "Successfully added program to import queue! We will send you an email when your program has been fully imported.", - ); - router.push(`/${slug}/program/partners`); - }, - onError: ({ error }) => { - toast.error(error.serverError); + const [token, setToken] = useState(""); + + const { executeAsync: setTokenAsync, isPending: isSettingToken } = useAction( + setPartnerStackTokenAction, + { + onError: ({ error }) => { + toast.error(error.serverError); + }, }, - }); + ); + + const { executeAsync: startImportAsync, isPending: isStartingImport } = + useAction(startPartnerStackImportAction, { + onSuccess: () => { + onClose(); + toast.success( + "Successfully added program to import queue! We will send you an email when your program has been fully imported.", + ); + router.push(`/${slug}/program/partners`); + }, + onError: ({ error }) => { + toast.error(error.serverError); + }, + }); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!workspaceId || !publicKey || !secretKey) { - toast.error("Please fill in all required fields."); + if (!workspaceId || !token) { return; } - await executeAsync({ - workspaceId, - publicKey, - secretKey, - }); + try { + // First set the token + await setTokenAsync({ + workspaceId, + token, + }); + + // Then start the import + await startImportAsync({ + workspaceId, + }); + } catch (error) { + // Error handling is done in the action callbacks + console.error("Import error:", error); + } }; + const isLoading = isSettingToken || isStartingImport; + return (

setPublicKey(e.target.value)} + onChange={(e) => setToken(e.target.value)} className="mt-1 block w-full rounded-md border border-neutral-200 px-3 py-2 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" required />

You can find your PartnerStack API key in your{" "} - Settings + API settings

-
- - setSecretKey(e.target.value)} - className="mt-1 block w-full rounded-md border border-neutral-200 px-3 py-2 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" - required - /> -
+
-

Import Your PartnerStack Program

+

+ Import Your PartnerStack Program +

Import your existing PartnerStack program into{" "} {process.env.NEXT_PUBLIC_APP_NAME} with just a few clicks. @@ -73,109 +74,93 @@ function ImportPartnerStackModal({ ); } -function TokenForm({ - onClose, -}: { - onClose: () => void; -}) { - const { isMobile } = useMediaQuery(); +function TokenForm({ onClose }: { onClose: () => void }) { const router = useRouter(); + const { isMobile } = useMediaQuery(); + const [publicKey, setPublicKey] = useState(""); + const [secretKey, setSecretKey] = useState(""); const { id: workspaceId, slug } = useWorkspace(); - const [token, setToken] = useState(""); - - const { executeAsync: setTokenAsync, isPending: isSettingToken } = useAction( - setPartnerStackTokenAction, - { - onError: ({ error }) => { - toast.error(error.serverError); - }, + const { executeAsync, isPending } = useAction(startPartnerStackImportAction, { + onSuccess: () => { + onClose(); + toast.success( + "Successfully added program to import queue! We will send you an email when your program has been fully imported.", + ); + router.push(`/${slug}/program/partners`); }, - ); - - const { executeAsync: startImportAsync, isPending: isStartingImport } = - useAction(startPartnerStackImportAction, { - onSuccess: () => { - onClose(); - toast.success( - "Successfully added program to import queue! We will send you an email when your program has been fully imported.", - ); - router.push(`/${slug}/program/partners`); - }, - onError: ({ error }) => { - toast.error(error.serverError); - }, - }); + onError: ({ error }) => { + toast.error(error.serverError); + }, + }); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!workspaceId || !token) { + if (!workspaceId || !publicKey || !secretKey) { + toast.error("Please fill in all required fields."); return; } - try { - // First set the token - await setTokenAsync({ - workspaceId, - token, - }); - - // Then start the import - await startImportAsync({ - workspaceId, - }); - } catch (error) { - // Error handling is done in the action callbacks - console.error("Import error:", error); - } + await executeAsync({ + workspaceId, + publicKey, + secretKey, + }); }; - const isLoading = isSettingToken || isStartingImport; - return (

setToken(e.target.value)} + onChange={(e) => setPublicKey(e.target.value)} className="mt-1 block w-full rounded-md border border-neutral-200 px-3 py-2 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" required />

You can find your PartnerStack API key in your{" "} - API settings + Settings

- +
+ + setSecretKey(e.target.value)} + className="mt-1 block w-full rounded-md border border-neutral-200 px-3 py-2 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" + required + /> +
From 705cc1df1c90baf16fd9acec91f7f73269ad9c2f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 21 Jul 2025 16:31:17 +0530 Subject: [PATCH 020/221] Update route.ts --- .../links/create-promotion-codes/route.ts | 179 ++++++++++++------ 1 file changed, 118 insertions(+), 61 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 0c63f35ee0f..f6c1860e898 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -1,113 +1,170 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { createStripePromotionCode } from "@/lib/stripe/create-promotion-code"; import { prisma } from "@dub/prisma"; -import { chunk } from "@dub/utils"; +import { APP_DOMAIN_WITH_NGROK, chunk, log } from "@dub/utils"; import { z } from "zod"; export const dynamic = "force-dynamic"; const schema = z.object({ discountId: z.string(), + page: z.number().optional().default(1), }); +const PAGE_LIMIT = 20; +const MAX_BATCHES = 5; + // This route is used to create promotion codes for each link for link-based coupon codes tracking. // POST /api/cron/links/create-promotion-codes export async function POST(req: Request) { + let discountId: string | undefined; + try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); - const { discountId } = schema.parse(JSON.parse(rawBody)); + const parsedBody = schema.parse(JSON.parse(rawBody)); - const discount = await prisma.discount.findUnique({ + const { page } = parsedBody; + discountId = parsedBody.discountId; + + const { + couponId, + programId, + program: { couponCodeTrackingEnabledAt }, + } = await prisma.discount.findUniqueOrThrow({ where: { id: discountId, }, + select: { + couponId: true, + programId: true, + program: { + select: { + couponCodeTrackingEnabledAt: true, + }, + }, + }, }); - if (!discount) { - return new Response("Discount not found."); + if (!couponCodeTrackingEnabledAt) { + return new Response( + "couponCodeTrackingEnabledAt is not set for the program. Skipping promotion code creation.", + ); } - if (!discount.couponId) { - return new Response("couponId doesn't set for the discount."); + if (!couponId) { + return new Response( + "couponId doesn't set for the discount. Skipping promotion code creation.", + ); } - const workspace = await prisma.project.findUniqueOrThrow({ + const { stripeConnectId } = await prisma.project.findUniqueOrThrow({ where: { - defaultProgramId: discount.programId, + defaultProgramId: programId, }, select: { stripeConnectId: true, }, }); - if (!workspace.stripeConnectId) { + if (!stripeConnectId) { return new Response("stripeConnectId doesn't exist for the workspace."); } - const enrollments = await prisma.programEnrollment.findMany({ - where: { - programId: discount.programId, - discountId: discount.id, - }, - select: { - partnerId: true, - }, - }); + let hasMore = true; + let currentPage = page; + let processedBatches = 0; - if (enrollments.length === 0) { - return new Response("No enrollments found."); - } + while (hasMore && processedBatches < MAX_BATCHES) { + const enrollments = await prisma.programEnrollment.findMany({ + where: { + programId, + discountId, + }, + select: { + partnerId: true, + }, + orderBy: { + id: "desc", + }, + take: PAGE_LIMIT, + skip: (currentPage - 1) * PAGE_LIMIT, + }); - const links = await prisma.link.findMany({ - where: { - programId: discount.programId, - partnerId: { - in: enrollments.map(({ partnerId }) => partnerId), + if (enrollments.length === 0) { + console.log("No more enrollments found."); + hasMore = false; + break; + } + + const links = await prisma.link.findMany({ + where: { + programId, + partnerId: { + in: enrollments.map(({ partnerId }) => partnerId), + }, }, - }, - select: { - key: true, - }, - }); + select: { + key: true, + }, + }); - if (links.length === 0) { - return new Response("No links found."); + if (links.length === 0) { + console.log("No more links found."); + continue; + } + + const linksChunks = chunk(links, 10); + const failedRequests: Error[] = []; + + for (const linksChunk of linksChunks) { + const results = await Promise.allSettled( + linksChunk.map(({ key }) => + createStripePromotionCode({ + code: key, + couponId, + stripeConnectId, + }), + ), + ); + + results.forEach((result) => { + if (result.status === "rejected") { + failedRequests.push(result.reason); + } + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + if (failedRequests.length > 0) { + console.error(failedRequests); + } + + currentPage++; + processedBatches++; } - const linksChunks = chunk(links, 20); - const failedRequests: Error[] = []; - - for (const linksChunk of linksChunks) { - const results = await Promise.allSettled( - linksChunk.map(({ key }) => - createStripePromotionCode({ - code: key, - couponId: discount.couponId!, - stripeConnectId: workspace.stripeConnectId, - }), - ), - ); - - results.forEach((result) => { - if (result.status === "rejected") { - failedRequests.push(result.reason); - } + if (hasMore) { + await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, + body: { + discountId, + page: currentPage, + }, }); } - if (failedRequests.length > 0) { - console.error(failedRequests); - } - - return new Response( - failedRequests.length > 0 - ? `Failed to create promotion codes for ${failedRequests.length} links. See logs for more details.` - : "OK", - ); + return new Response("OK"); } catch (error) { + await log({ + message: `Error creating Stripe promotion codes for discount ${discountId}: ${error.message}`, + type: "errors", + }); + return handleAndReturnErrorResponse(error); } } From 6fc3ec5be0b95c05ef2fd7d03067fbbbaf6059a6 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 21 Jul 2025 16:31:20 +0530 Subject: [PATCH 021/221] Update create-promotion-code.ts --- apps/web/lib/stripe/create-promotion-code.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/stripe/create-promotion-code.ts b/apps/web/lib/stripe/create-promotion-code.ts index 93c034316c6..c73ba766a8a 100644 --- a/apps/web/lib/stripe/create-promotion-code.ts +++ b/apps/web/lib/stripe/create-promotion-code.ts @@ -6,12 +6,12 @@ const stripe = stripeAppClient({ // Create a promotion code on Stripe for connected accounts export async function createStripePromotionCode({ - couponId, code, + couponId, stripeConnectId, }: { - couponId: string; code: string; + couponId: string; stripeConnectId: string | null; }) { if (!stripeConnectId) { From 63ae63dd9b2837b529c58521a3045ec99d594951 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 21 Jul 2025 16:32:10 +0530 Subject: [PATCH 022/221] Update checkout-session-completed.ts --- .../webhook/checkout-session-completed.ts | 74 +------------------ 1 file changed, 1 insertion(+), 73 deletions(-) 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 a7a75055653..c9f3aee54aa 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 @@ -18,7 +18,7 @@ import { } from "@/lib/webhook/transform"; import { prisma } from "@dub/prisma"; import { Customer } from "@dub/prisma/client"; -import { linkConstructorSimple, nanoid } from "@dub/utils"; +import { nanoid } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; import { @@ -199,78 +199,6 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { ); linkId = leadEvent.link_id; - } else if (charge.discounts && charge.discounts.length > 0) { - // Handle promotion code tracking for coupon-based attribution - // When a charge has discounts, we can attribute the sale to a partner - // based on the promotion code used during checkout - - const workspace = await prisma.project.findUnique({ - where: { - stripeConnectId: stripeAccountId, - }, - select: { - defaultProgram: { - select: { - domain: true, - couponCodeTrackingEnabledAt: true, - }, - }, - }, - }); - - if (!workspace) { - return `Workspace with stripeConnectId ${stripeAccountId} not found, skipping...`; - } - - if (!workspace.defaultProgram) { - return `Workspace with stripeConnectId ${stripeAccountId} has no program, skipping...`; - } - - const { defaultProgram: program } = workspace; - - if (!program.couponCodeTrackingEnabledAt) { - return `Workspace with stripeConnectId ${stripeAccountId} has no coupon code tracking enabled, skipping...`; - } - - if (!program.domain) { - return `Workspace with stripeConnectId ${stripeAccountId} has no domain for program, skipping...`; - } - - const promotionCodes = charge.discounts.map( - ({ promotion_code }) => promotion_code, - ); - - if (promotionCodes.length === 0) { - return "No promotion codes found in Stripe checkout session, skipping..."; - } - - const promotionCode = promotionCodes[0] as Stripe.PromotionCode; - - const link = await prisma.link.findUnique({ - where: { - domain_key: { - domain: program.domain, - key: promotionCode.code, - }, - }, - select: { - id: true, - }, - }); - - if (!link) { - return `Link ${linkConstructorSimple({ - domain: program.domain, - key: promotionCode.code, - })} not found, skipping...`; - } - - linkId = link.id; - - // let customer: Customer | null = null; - // let existingCustomer: Customer | null = null; - // let clickEvent: z.infer | null = null; - // let leadEvent: z.infer; } else { return "No dubCustomerId or stripeCustomerId found in Stripe checkout session metadata, skipping..."; } From 23c0be95970aaf76e0d2b08cd7c2e36ec9a1b60f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 21 Jul 2025 17:29:43 +0530 Subject: [PATCH 023/221] Add coupon deleted webhook handler and integrate with route --- .../integration/webhook/coupon-deleted.ts | 50 +++++++ .../api/stripe/integration/webhook/route.ts | 5 + .../lib/actions/partners/delete-discount.ts | 113 +-------------- apps/web/lib/api/partners/delete-discount.ts | 129 ++++++++++++++++++ 4 files changed, 189 insertions(+), 108 deletions(-) create mode 100644 apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts create mode 100644 apps/web/lib/api/partners/delete-discount.ts 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 00000000000..5524d387e74 --- /dev/null +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts @@ -0,0 +1,50 @@ +import { deleteDiscount } from "@/lib/api/partners/delete-discount"; +import { prisma } from "@dub/prisma"; +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, + 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 discount = await prisma.discount.findFirst({ + where: { + programId: workspace.defaultProgramId, + couponId: coupon.id, + }, + }); + + if (!discount) { + return `Discount not found for Stripe coupon ${coupon.id}.`; + } + + const deletedDiscountId = await deleteDiscount({ + workspace, + discount, + }); + + if (deletedDiscountId) { + return `Discount ${deletedDiscountId} deleted.`; + } + + return `Failed to delete discount ${discount.id}.`; +} 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 0d094e8aec4..0decbb98976 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts @@ -4,6 +4,7 @@ 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"; @@ -15,6 +16,7 @@ const relevantEvents = new Set([ "invoice.paid", "charge.refunded", "account.application.deauthorized", + "coupon.deleted", ]); // POST /api/stripe/integration/webhook – listen to Stripe webhooks (for Stripe Integration) @@ -74,6 +76,9 @@ export const POST = withAxiom(async (req: Request) => { case "account.application.deauthorized": response = await accountApplicationDeauthorized(event); break; + case "coupon.deleted": + response = await couponDeleted(event); + break; } return new Response(response, { diff --git a/apps/web/lib/actions/partners/delete-discount.ts b/apps/web/lib/actions/partners/delete-discount.ts index 386939b6235..118cc76b8ee 100644 --- a/apps/web/lib/actions/partners/delete-discount.ts +++ b/apps/web/lib/actions/partners/delete-discount.ts @@ -1,14 +1,8 @@ "use server"; -import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { deleteDiscount } from "@/lib/api/partners/delete-discount"; 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"; -import { deleteStripeCoupon } from "@/lib/stripe/delete-coupon"; -import { redis } from "@/lib/upstash"; -import { prisma } from "@dub/prisma"; -import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; -import { waitUntil } from "@vercel/functions"; import { z } from "zod"; import { authActionClient } from "../safe-action"; @@ -30,106 +24,9 @@ export const deleteDiscountAction = authActionClient discountId, }); - if (!discount.default) { - let offset = 0; - - while (true) { - const partners = await prisma.programEnrollment.findMany({ - where: { - programId, - discountId, - }, - select: { - partnerId: true, - }, - skip: offset, - take: 1000, - }); - - if (partners.length === 0) { - break; - } - - await redis.lpush( - `discount-partners:${discountId}`, - partners.map((partner) => partner.partnerId), - ); - - offset += 1000; - } - } - - const deletedDiscountId = await prisma.$transaction(async (tx) => { - // 1. Find the default discount (if it exists) - const defaultDiscount = await tx.discount.findFirst({ - where: { - programId, - default: true, - }, - }); - - // 2. Update current associations - await tx.programEnrollment.updateMany({ - where: { - programId, - discountId: discount.id, - }, - data: { - // Replace the current discount with the default discount if it exists - // and the discount we're deleting is not the default discount - discountId: discount.default - ? null - : defaultDiscount - ? defaultDiscount.id - : null, - }, - }); - - // 3. Finally, delete the current discount - await tx.discount.delete({ - where: { - id: discount.id, - }, - }); - - return discountId; + await deleteDiscount({ + workspace, + discount, + user, }); - - if (deletedDiscountId) { - waitUntil( - Promise.allSettled([ - qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, - body: { - programId, - discountId, - isDefault: discount.default, - action: "discount-deleted", - }, - }), - - recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "discount.deleted", - description: `Discount ${discountId} deleted`, - actor: user, - targets: [ - { - type: "discount", - id: discountId, - metadata: discount, - }, - ], - }), - - // Remove the Stripe coupon if it exists - discount.couponId && - deleteStripeCoupon({ - couponId: discount.couponId, - stripeConnectId: workspace.stripeConnectId, - }), - ]), - ); - } }); diff --git a/apps/web/lib/api/partners/delete-discount.ts b/apps/web/lib/api/partners/delete-discount.ts new file mode 100644 index 00000000000..74ddba713b3 --- /dev/null +++ b/apps/web/lib/api/partners/delete-discount.ts @@ -0,0 +1,129 @@ +import { qstash } from "@/lib/cron"; +import { deleteStripeCoupon } from "@/lib/stripe/delete-coupon"; +import { redis } from "@/lib/upstash"; +import { prisma } from "@dub/prisma"; +import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; +import { Discount, Project, User } from "@prisma/client"; +import { waitUntil } from "@vercel/functions"; +import { recordAuditLog } from "../audit-logs/record-audit-log"; + +export async function deleteDiscount({ + workspace, + discount, + user, +}: { + workspace: Pick; + discount: Omit< + Discount, + "createdAt" | "updatedAt" | "description" | "programId" + >; + user?: Pick; +}) { + const programId = workspace.defaultProgramId!; + + if (!discount.default) { + let offset = 0; + + while (true) { + const partners = await prisma.programEnrollment.findMany({ + where: { + programId, + discountId: discount.id, + }, + select: { + partnerId: true, + }, + skip: offset, + take: 1000, + }); + + if (partners.length === 0) { + break; + } + + await redis.lpush( + `discount-partners:${discount.id}`, + partners.map((partner) => partner.partnerId), + ); + + offset += 1000; + } + } + + const deletedDiscountId = await prisma.$transaction(async (tx) => { + // 1. Find the default discount (if it exists) + const defaultDiscount = await tx.discount.findFirst({ + where: { + programId, + default: true, + }, + }); + + // 2. Update current associations + await tx.programEnrollment.updateMany({ + where: { + programId, + discountId: discount.id, + }, + data: { + // Replace the current discount with the default discount if it exists + // and the discount we're deleting is not the default discount + discountId: discount.default + ? null + : defaultDiscount + ? defaultDiscount.id + : null, + }, + }); + + // 3. Finally, delete the current discount + await tx.discount.delete({ + where: { + id: discount.id, + }, + }); + + return discount.id; + }); + + if (deletedDiscountId) { + waitUntil( + Promise.allSettled([ + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, + body: { + programId, + discountId: discount.id, + isDefault: discount.default, + action: "discount-deleted", + }, + }), + + user + ? recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "discount.deleted", + description: `Discount ${discount.id} deleted`, + actor: user, + targets: [ + { + type: "discount", + id: discount.id, + metadata: discount, + }, + ], + }) + : Promise.resolve(), + + discount.couponId && + deleteStripeCoupon({ + couponId: discount.couponId, + stripeConnectId: workspace.stripeConnectId, + }), + ]), + ); + } + + return deletedDiscountId; +} From c5bc5df2598ecc55a0928d3093812a5be31a89b0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 21 Jul 2025 22:46:27 +0530 Subject: [PATCH 024/221] add getPromotionCode --- .../webhook/checkout-session-completed.ts | 136 ++++++++++++++++++ .../api/stripe/integration/webhook/utils.ts | 26 ++++ 2 files changed, 162 insertions(+) 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 c9f3aee54aa..f2624af159c 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 @@ -6,7 +6,9 @@ import { createPartnerCommission } from "@/lib/partners/create-partner-commissio import { getClickEvent, getLeadEvent, + recordClick, recordLead, + recordLeadWithTimestamp, recordSale, } from "@/lib/tinybird"; import { ClickEventTB, LeadEventTB } from "@/lib/types"; @@ -16,6 +18,8 @@ import { transformLeadEventData, transformSaleEventData, } from "@/lib/webhook/transform"; +import { clickEventSchemaTB } from "@/lib/zod/schemas/clicks"; +import { leadEventSchemaTB } from "@/lib/zod/schemas/leads"; import { prisma } from "@dub/prisma"; import { Customer } from "@dub/prisma/client"; import { nanoid } from "@dub/utils"; @@ -23,6 +27,7 @@ import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; import { getConnectedCustomer, + getPromotionCode, getSubscriptionProductId, updateCustomerWithStripeCustomerId, } from "./utils"; @@ -67,6 +72,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { }, select: { id: true, + defaultProgramId: true, }, }); @@ -150,6 +156,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { - the lead event will then be passed to the remaining logic to record a sale - if not present, we skip the event */ + if (dubCustomerId) { customer = await updateCustomerWithStripeCustomerId({ stripeAccountId, @@ -187,6 +194,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { dubCustomerId, stripeCustomerId, }); + if (!customer) { return `dubCustomerId was found on the connected customer ${stripeCustomerId} but customer with dubCustomerId ${dubCustomerId} not found on Dub, skipping...`; } @@ -198,6 +206,134 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { (res) => res.data[0], ); + if (!leadEvent) { + const promotionCodeId = charge.discounts?.[0]?.promotion_code as string; + + // TODO: + // Can we move this to top of the function? + const workspace = await prisma.project.findUnique({ + where: { + stripeConnectId: stripeAccountId, + }, + select: { + id: true, + stripeConnectId: true, + programs: { + select: { + id: true, + domain: true, + couponCodeTrackingEnabledAt: true, + }, + }, + }, + }); + + if (!workspace) { + return `Workspace with stripeConnectId ${stripeAccountId} not found, skipping...`; + } + + if (workspace.programs.length === 0) { + return `Workspace with stripeConnectId ${stripeAccountId} has no programs, skipping...`; + } + + const program = workspace.programs[0]; + + if (!program.couponCodeTrackingEnabledAt) { + return `Program ${program.id} not enabled coupon code tracking, skipping...`; + } + + if (!program.domain) { + return `Program ${program.id} has no domain, skipping...`; + } + + const promotionCode = await getPromotionCode({ + promotionCodeId, + stripeAccountId, + livemode: event.livemode, + }); + + if (promotionCode) { + const link = await prisma.link.findUnique({ + where: { + domain_key: { + domain: program.domain, + key: promotionCode.code, + }, + }, + select: { + id: true, + url: true, + domain: true, + key: true, + }, + }); + + if (!link) { + return `Couldn't find link associated with promotion code ${promotionCode.code} and program ${program.id}, skipping...`; + } + + const stripeCustomerAddress = charge.customer_details?.address; + + 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": stripeCustomerAddress?.country || "US", + "x-vercel-ip-country-region": stripeCustomerAddress?.state || "CA", + "x-vercel-ip-continent": "NA", + }), + }); + + const clickData = await recordClick({ + req: dummyRequest, + linkId: link.id, + clickId: nanoid(16), + url: link.url, + domain: link.domain, + key: link.key, + workspaceId: workspace.id, + skipRatelimit: true, + timestamp: new Date().toISOString(), + }); + + const clickEvent = clickEventSchemaTB.parse({ + ...clickData, + bot: 0, + qr: 0, + }); + + const customerId = createId({ prefix: "cus_" }); + + customer = await prisma.customer.create({ + data: { + id: customerId, + name: stripeCustomerName || stripeCustomerEmail, + email: stripeCustomerEmail, + projectId: workspace.id, + projectConnectId: workspace.stripeConnectId, + clickId: clickEvent.click_id, + linkId: link.id, + country: clickEvent.country, + externalId: stripeCustomerEmail, + clickedAt: new Date(), + createdAt: new Date(), + }, + }); + + const leadData = { + ...clickEvent, + event_id: nanoid(16), + event_name: "Sign up", + customer_id: customerId, + timestamp: new Date(customer.updatedAt).toISOString(), + }; + + await recordLeadWithTimestamp(leadData); + + leadEvent = leadEventSchemaTB.parse(leadData); + } + } + linkId = leadEvent.link_id; } else { return "No dubCustomerId or stripeCustomerId found in Stripe checkout session metadata, skipping..."; diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/utils.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/utils.ts index 6c7e9eb633a..45ee7ff349f 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/utils.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/utils.ts @@ -205,6 +205,7 @@ export async function getSubscriptionProductId({ if (!stripeAccountId || !stripeSubscriptionId) { return null; } + try { const subscription = await stripeAppClient({ livemode, @@ -217,3 +218,28 @@ export async function getSubscriptionProductId({ return null; } } + +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; + } +} From 98dc15d019fdecb2ee74978e5d74702341a102e6 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 21 Jul 2025 23:06:36 +0530 Subject: [PATCH 025/221] add recordFakeClick --- .../webhook/checkout-session-completed.ts | 36 ++++---------- .../lib/actions/partners/create-commission.ts | 35 +++----------- apps/web/lib/partnerstack/import-customers.ts | 36 +++----------- apps/web/lib/rewardful/import-referrals.ts | 32 ++----------- apps/web/lib/tinybird/record-fake-click.ts | 47 +++++++++++++++++++ apps/web/lib/tolt/import-referrals.ts | 36 +++----------- 6 files changed, 79 insertions(+), 143 deletions(-) create mode 100644 apps/web/lib/tinybird/record-fake-click.ts 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 f2624af159c..899335e6d0d 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 @@ -6,11 +6,11 @@ import { createPartnerCommission } from "@/lib/partners/create-partner-commissio import { getClickEvent, getLeadEvent, - recordClick, recordLead, recordLeadWithTimestamp, recordSale, } from "@/lib/tinybird"; +import { recordFakeClick } from "@/lib/tinybird/record-fake-click"; import { ClickEventTB, LeadEventTB } from "@/lib/types"; import { redis } from "@/lib/upstash"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; @@ -18,7 +18,6 @@ import { transformLeadEventData, transformSaleEventData, } from "@/lib/webhook/transform"; -import { clickEventSchemaTB } from "@/lib/zod/schemas/clicks"; import { leadEventSchemaTB } from "@/lib/zod/schemas/leads"; import { prisma } from "@dub/prisma"; import { Customer } from "@dub/prisma/client"; @@ -265,6 +264,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { url: true, domain: true, key: true, + projectId: true, }, }); @@ -274,32 +274,12 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { const stripeCustomerAddress = charge.customer_details?.address; - 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": stripeCustomerAddress?.country || "US", - "x-vercel-ip-country-region": stripeCustomerAddress?.state || "CA", - "x-vercel-ip-continent": "NA", - }), - }); - - const clickData = await recordClick({ - req: dummyRequest, - linkId: link.id, - clickId: nanoid(16), - url: link.url, - domain: link.domain, - key: link.key, - workspaceId: workspace.id, - skipRatelimit: true, - timestamp: new Date().toISOString(), - }); - - const clickEvent = clickEventSchemaTB.parse({ - ...clickData, - bot: 0, - qr: 0, + const clickEvent = await recordFakeClick({ + link, + customer: { + country: stripeCustomerAddress?.country, + region: stripeCustomerAddress?.state, + }, }); const customerId = createId({ prefix: "cus_" }); diff --git a/apps/web/lib/actions/partners/create-commission.ts b/apps/web/lib/actions/partners/create-commission.ts index 5517a6bad8f..743a04c7a2d 100644 --- a/apps/web/lib/actions/partners/create-commission.ts +++ b/apps/web/lib/actions/partners/create-commission.ts @@ -4,11 +4,10 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-progr import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; import { getLeadEvent } from "@/lib/tinybird"; -import { recordClick } from "@/lib/tinybird/record-click"; +import { recordFakeClick } from "@/lib/tinybird/record-fake-click"; import { recordLeadWithTimestamp } from "@/lib/tinybird/record-lead"; import { recordSaleWithTimestamp } from "@/lib/tinybird/record-sale"; import { ClickEventTB, LeadEventTB } from "@/lib/types"; -import { clickEventSchemaTB } from "@/lib/zod/schemas/clicks"; import { createCommissionSchema } from "@/lib/zod/schemas/commissions"; import { leadEventSchemaTB } from "@/lib/zod/schemas/leads"; import { prisma } from "@dub/prisma"; @@ -123,38 +122,16 @@ export const createCommissionAction = authActionClient } else { // else, if there's no existing lead event and there is also no custom leadEventName/Date // we need to create a dummy click + lead event - 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": "US", - "x-vercel-ip-country-region": "CA", - "x-vercel-ip-continent": "NA", - }), - }); const finalLeadEventDate = leadEventDate ?? saleEventDate ?? new Date(); - const clickData = await recordClick({ - req: dummyRequest, - linkId, - clickId: nanoid(16), - url: link.url, - domain: link.domain, - key: link.key, - workspaceId: workspace.id, - skipRatelimit: true, - timestamp: new Date( - new Date(finalLeadEventDate).getTime() - 5 * 60 * 1000, - ).toISOString(), + clickEvent = await recordFakeClick({ + link, + timestamp: new Date(finalLeadEventDate).getTime() - 5 * 60 * 1000, }); - clickEvent = clickEventSchemaTB.parse({ - ...clickData, - bot: 0, - qr: 0, - }); const leadEventId = nanoid(16); + leadEvent = leadEventSchemaTB.parse({ ...clickEvent, event_id: leadEventId, @@ -162,7 +139,7 @@ export const createCommissionAction = authActionClient customer_id: customerId, }); - shouldUpdateCustomer = !customer.linkId && clickData ? true : false; + shouldUpdateCustomer = !customer.linkId && clickEvent ? true : false; await Promise.allSettled([ recordLeadWithTimestamp({ diff --git a/apps/web/lib/partnerstack/import-customers.ts b/apps/web/lib/partnerstack/import-customers.ts index 257fd6ac58b..7a7dfd99c05 100644 --- a/apps/web/lib/partnerstack/import-customers.ts +++ b/apps/web/lib/partnerstack/import-customers.ts @@ -2,9 +2,9 @@ import { prisma } from "@dub/prisma"; import { nanoid } from "@dub/utils"; import { Link, Project } from "@prisma/client"; import { createId } from "../api/create-id"; -import { recordClick, recordLeadWithTimestamp } from "../tinybird"; +import { recordLeadWithTimestamp } from "../tinybird"; +import { recordFakeClick } from "../tinybird/record-fake-click"; import { redis } from "../upstash"; -import { clickEventSchemaTB } from "../zod/schemas/clicks"; import { PartnerStackApi } from "./api"; import { MAX_BATCHES, @@ -90,6 +90,7 @@ export async function importCustomers(payload: PartnerStackImportPayload) { key: true, domain: true, url: true, + projectId: true, }, }, }, @@ -144,7 +145,7 @@ async function createCustomer({ customer, }: { workspace: Pick; - links: Pick[]; + links: Pick[]; customer: PartnerStackCustomer; }) { if (links.length === 0) { @@ -174,32 +175,9 @@ async function createCustomer({ const link = links[0]; - 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": "US", - "x-vercel-ip-country-region": "CA", - "x-vercel-ip-continent": "NA", - }), - }); - - const clickData = await recordClick({ - req: dummyRequest, - linkId: link.id, - clickId: nanoid(16), - url: link.url, - domain: link.domain, - key: link.key, - workspaceId: workspace.id, - skipRatelimit: true, - timestamp: new Date(customer.created_at).toISOString(), - }); - - const clickEvent = clickEventSchemaTB.parse({ - ...clickData, - bot: 0, - qr: 0, + const clickEvent = await recordFakeClick({ + link, + timestamp: customer.created_at, }); const customerId = createId({ prefix: "cus_" }); diff --git a/apps/web/lib/rewardful/import-referrals.ts b/apps/web/lib/rewardful/import-referrals.ts index 06f750bcb4e..61faf3cbbe4 100644 --- a/apps/web/lib/rewardful/import-referrals.ts +++ b/apps/web/lib/rewardful/import-referrals.ts @@ -2,9 +2,8 @@ import { prisma } from "@dub/prisma"; import { nanoid } from "@dub/utils"; import { Program, Project } from "@prisma/client"; import { createId } from "../api/create-id"; -import { recordClick } from "../tinybird/record-click"; +import { recordFakeClick } from "../tinybird/record-fake-click"; import { recordLeadWithTimestamp } from "../tinybird/record-lead"; -import { clickEventSchemaTB } from "../zod/schemas/clicks"; import { RewardfulApi } from "./api"; import { MAX_BATCHES, rewardfulImporter } from "./importer"; import { RewardfulReferral } from "./types"; @@ -152,32 +151,9 @@ async function createReferral({ return; } - 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": "US", - "x-vercel-ip-country-region": "CA", - "x-vercel-ip-continent": "NA", - }), - }); - - const clickData = await recordClick({ - req: dummyRequest, - linkId: link.id, - clickId: nanoid(16), - url: link.url, - domain: link.domain, - key: link.key, - workspaceId: workspace.id, - skipRatelimit: true, - timestamp: new Date(referral.created_at).toISOString(), - }); - - const clickEvent = clickEventSchemaTB.parse({ - ...clickData, - bot: 0, - qr: 0, + const clickEvent = await recordFakeClick({ + link, + timestamp: referral.created_at, }); const customerId = createId({ prefix: "cus_" }); 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 00000000000..67a6b2fe8cd --- /dev/null +++ b/apps/web/lib/tinybird/record-fake-click.ts @@ -0,0 +1,47 @@ +import { Link } from "@dub/prisma/client"; +import { nanoid } from "@dub/utils"; +import { clickEventSchemaTB } from "../zod/schemas/clicks"; +import { recordClick } from "./record-click"; + +export async function recordFakeClick({ + link, + customer, + timestamp, +}: { + link: Pick; + customer?: { + country?: string | null; + region?: 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": "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() + : new Date().toISOString(), + }); + + return clickEventSchemaTB.parse({ + ...clickData, + bot: 0, + qr: 0, + }); +} diff --git a/apps/web/lib/tolt/import-referrals.ts b/apps/web/lib/tolt/import-referrals.ts index 90461779f24..05245e5f175 100644 --- a/apps/web/lib/tolt/import-referrals.ts +++ b/apps/web/lib/tolt/import-referrals.ts @@ -2,8 +2,8 @@ import { prisma } from "@dub/prisma"; import { nanoid } from "@dub/utils"; import { Link, Project } from "@prisma/client"; import { createId } from "../api/create-id"; -import { recordClick, recordLeadWithTimestamp } from "../tinybird"; -import { clickEventSchemaTB } from "../zod/schemas/clicks"; +import { recordLeadWithTimestamp } from "../tinybird"; +import { recordFakeClick } from "../tinybird/record-fake-click"; import { ToltApi } from "./api"; import { MAX_BATCHES, toltImporter } from "./importer"; import { ToltAffiliate, ToltCustomer } from "./types"; @@ -75,6 +75,7 @@ export async function importReferrals({ key: true, domain: true, url: true, + projectId: true, }, }, }, @@ -127,7 +128,7 @@ async function createReferral({ customer: Omit; partner: ToltAffiliate; workspace: Pick; - links: Pick[]; + links: Pick[]; }) { if (links.length === 0) { console.log("Link not found for referral, skipping...", { @@ -155,32 +156,9 @@ async function createReferral({ const link = links[0]; - 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": "US", - "x-vercel-ip-country-region": "CA", - "x-vercel-ip-continent": "NA", - }), - }); - - const clickData = await recordClick({ - req: dummyRequest, - linkId: link.id, - clickId: nanoid(16), - url: link.url, - domain: link.domain, - key: link.key, - workspaceId: workspace.id, - skipRatelimit: true, - timestamp: new Date(customer.created_at).toISOString(), - }); - - const clickEvent = clickEventSchemaTB.parse({ - ...clickData, - bot: 0, - qr: 0, + const clickEvent = await recordFakeClick({ + link, + timestamp: customer.created_at, }); const customerId = createId({ prefix: "cus_" }); From c55274332a9f74b240434fad9e6299bface4b8b4 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 21 Jul 2025 23:27:38 +0530 Subject: [PATCH 026/221] Update checkout-session-completed.ts --- .../webhook/checkout-session-completed.ts | 114 +++++++++--------- 1 file changed, 57 insertions(+), 57 deletions(-) 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 899335e6d0d..6aac9ecfd21 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 @@ -156,6 +156,8 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { - if not present, we skip the event */ + const promotionCodeId = charge.discounts?.[0]?.promotion_code as string; + if (dubCustomerId) { customer = await updateCustomerWithStripeCustomerId({ stripeAccountId, @@ -206,10 +208,6 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { ); if (!leadEvent) { - const promotionCodeId = charge.discounts?.[0]?.promotion_code as string; - - // TODO: - // Can we move this to top of the function? const workspace = await prisma.project.findUnique({ where: { stripeConnectId: stripeAccountId, @@ -251,67 +249,69 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { livemode: event.livemode, }); - if (promotionCode) { - const link = await prisma.link.findUnique({ - where: { - domain_key: { - domain: program.domain, - key: promotionCode.code, - }, - }, - select: { - id: true, - url: true, - domain: true, - key: true, - projectId: true, + if (!promotionCode) { + return `Promotion code ${promotionCodeId} not found, skipping...`; + } + + const link = await prisma.link.findUnique({ + where: { + domain_key: { + domain: program.domain, + key: promotionCode.code, }, - }); + }, + select: { + id: true, + url: true, + domain: true, + key: true, + projectId: true, + }, + }); - if (!link) { - return `Couldn't find link associated with promotion code ${promotionCode.code} and program ${program.id}, skipping...`; - } + if (!link) { + return `Couldn't find link associated with promotion code ${promotionCode.code} and program ${program.id}, skipping...`; + } - const stripeCustomerAddress = charge.customer_details?.address; + const stripeCustomerAddress = charge.customer_details?.address; - const clickEvent = await recordFakeClick({ - link, - customer: { - country: stripeCustomerAddress?.country, - region: stripeCustomerAddress?.state, - }, - }); + const clickEvent = await recordFakeClick({ + link, + customer: { + country: stripeCustomerAddress?.country, + region: stripeCustomerAddress?.state, + }, + }); - const customerId = createId({ prefix: "cus_" }); - - customer = await prisma.customer.create({ - data: { - id: customerId, - name: stripeCustomerName || stripeCustomerEmail, - email: stripeCustomerEmail, - projectId: workspace.id, - projectConnectId: workspace.stripeConnectId, - clickId: clickEvent.click_id, - linkId: link.id, - country: clickEvent.country, - externalId: stripeCustomerEmail, - clickedAt: new Date(), - createdAt: new Date(), - }, - }); + const customerId = createId({ prefix: "cus_" }); - const leadData = { - ...clickEvent, - event_id: nanoid(16), - event_name: "Sign up", - customer_id: customerId, - timestamp: new Date(customer.updatedAt).toISOString(), - }; + customer = await prisma.customer.create({ + data: { + id: customerId, + name: stripeCustomerName || stripeCustomerEmail, + email: stripeCustomerEmail, + projectId: workspace.id, + projectConnectId: workspace.stripeConnectId, + clickId: clickEvent.click_id, + linkId: link.id, + country: clickEvent.country, + externalId: stripeCustomerEmail, + clickedAt: new Date(), + createdAt: new Date(), + }, + }); - await recordLeadWithTimestamp(leadData); + const leadData = { + ...clickEvent, + event_id: nanoid(16), + event_name: "Sign up", + customer_id: customerId, + timestamp: new Date(customer.updatedAt).toISOString(), + }; - leadEvent = leadEventSchemaTB.parse(leadData); - } + await recordLeadWithTimestamp(leadData); + + leadEvent = leadEventSchemaTB.parse(leadData); } linkId = leadEvent.link_id; From d7923bb2909ff981755809aed9deee99368676a1 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 21 Jul 2025 23:35:07 +0530 Subject: [PATCH 027/221] Update checkout-session-completed.ts --- .../webhook/checkout-session-completed.ts | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) 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 6aac9ecfd21..c1b4657496d 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 @@ -20,7 +20,7 @@ import { } from "@/lib/webhook/transform"; import { leadEventSchemaTB } from "@/lib/zod/schemas/leads"; import { prisma } from "@dub/prisma"; -import { Customer } from "@dub/prisma/client"; +import { Customer, Link } from "@dub/prisma/client"; import { nanoid } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; @@ -47,6 +47,7 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { let clickEvent: ClickEventTB | null = null; let leadEvent: LeadEventTB; let linkId: string; + let link: Link | null = null; /* for stripe checkout links: @@ -253,20 +254,13 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { return `Promotion code ${promotionCodeId} not found, skipping...`; } - const link = await prisma.link.findUnique({ + link = await prisma.link.findUnique({ where: { domain_key: { domain: program.domain, key: promotionCode.code, }, }, - select: { - id: true, - url: true, - domain: true, - key: true, - projectId: true, - }, }); if (!link) { @@ -380,11 +374,13 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { }), }; - const link = await prisma.link.findUnique({ - where: { - id: linkId, - }, - }); + if (!link) { + link = await prisma.link.findUnique({ + where: { + id: linkId, + }, + }); + } const [_sale, linkUpdated, workspace] = await Promise.all([ recordSale(saleData), From dfb32cce9e5864ccc65e030fff2324ada47807fb Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 23 Jul 2025 17:58:54 +0530 Subject: [PATCH 028/221] send discount has been deleted email --- .../integration/webhook/coupon-deleted.ts | 45 +++++++++++ .../email/src/templates/discount-deleted.tsx | 74 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 packages/email/src/templates/discount-deleted.tsx 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 index 5524d387e74..cdc649be271 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts @@ -1,5 +1,8 @@ import { deleteDiscount } from "@/lib/api/partners/delete-discount"; +import { sendEmail } from "@dub/email"; +import DiscountDeleted from "@dub/email/templates/discount-deleted"; import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; // Handle event "coupon.deleted" @@ -13,6 +16,7 @@ export async function couponDeleted(event: Stripe.Event) { }, select: { id: true, + slug: true, defaultProgramId: true, stripeConnectId: true, }, @@ -43,6 +47,47 @@ export async function couponDeleted(event: Stripe.Event) { }); if (deletedDiscountId) { + waitUntil( + (async () => { + const workspaceUsers = await prisma.projectUsers.findFirst({ + where: { + projectId: workspace.id, + role: "owner", + user: { + email: { + not: null, + }, + }, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + if (workspaceUsers) { + const { user } = workspaceUsers; + + sendEmail({ + subject: `${process.env.NEXT_PUBLIC_APP_NAME}: Discount has been deleted`, + email: user.email!, + react: DiscountDeleted({ + email: user.email!, + workspace: { + slug: workspace.slug, + }, + coupon: { + id: coupon.id, + }, + }), + }); + } + })(), + ); + return `Discount ${deletedDiscountId} deleted.`; } diff --git a/packages/email/src/templates/discount-deleted.tsx b/packages/email/src/templates/discount-deleted.tsx new file mode 100644 index 00000000000..6128f1916bb --- /dev/null +++ b/packages/email/src/templates/discount-deleted.tsx @@ -0,0 +1,74 @@ +import { DUB_WORDMARK } from "@dub/utils"; +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { Footer } from "../components/footer"; + +export default function DiscountDeleted({ + email = "panic@thedis.co", + workspace = { + slug: "acme", + }, + coupon = { + id: "jMT0WJUD", + }, +}: { + email: string; + workspace: { + slug: string; + }; + coupon: { + id: string; + }; +}) { + return ( + + + Discount has been deleted + + + +
+ Dub +
+ + + Discount has been deleted + + + + Your discount with Stripe coupon {coupon.id} has + been deleted. + + + + This action also removes the discount association from any + partners who were using this discount. + + +
+ + Manage discounts + +
+ +
+ + + + + ); +} From 714be7af7a60d49579cdfd330554df6cafe508a9 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 23 Jul 2025 18:00:03 +0530 Subject: [PATCH 029/221] Update delete-discount.ts --- apps/web/lib/api/partners/delete-discount.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/api/partners/delete-discount.ts b/apps/web/lib/api/partners/delete-discount.ts index 74ddba713b3..b1dfc22ce5f 100644 --- a/apps/web/lib/api/partners/delete-discount.ts +++ b/apps/web/lib/api/partners/delete-discount.ts @@ -17,7 +17,7 @@ export async function deleteDiscount({ Discount, "createdAt" | "updatedAt" | "description" | "programId" >; - user?: Pick; + user?: Pick; // the user who deleted the discount }) { const programId = workspace.defaultProgramId!; From 0a93a55f38ca7a29b1ee9e4f20e5fef908c93a18 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 23 Jul 2025 18:00:32 +0530 Subject: [PATCH 030/221] Update delete-discount.ts --- apps/web/lib/api/partners/delete-discount.ts | 31 ++++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/web/lib/api/partners/delete-discount.ts b/apps/web/lib/api/partners/delete-discount.ts index b1dfc22ce5f..d99c620f25c 100644 --- a/apps/web/lib/api/partners/delete-discount.ts +++ b/apps/web/lib/api/partners/delete-discount.ts @@ -99,22 +99,21 @@ export async function deleteDiscount({ }, }), - user - ? recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "discount.deleted", - description: `Discount ${discount.id} deleted`, - actor: user, - targets: [ - { - type: "discount", - id: discount.id, - metadata: discount, - }, - ], - }) - : Promise.resolve(), + user && + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "discount.deleted", + description: `Discount ${discount.id} deleted`, + actor: user, + targets: [ + { + type: "discount", + id: discount.id, + metadata: discount, + }, + ], + }), discount.couponId && deleteStripeCoupon({ From 86238214276eacc4d623bbd20baf1089d390e0e8 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 6 Aug 2025 18:03:48 +0530 Subject: [PATCH 031/221] Update record-fake-click.ts --- apps/web/lib/tinybird/record-fake-click.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/tinybird/record-fake-click.ts b/apps/web/lib/tinybird/record-fake-click.ts index 67a6b2fe8cd..8e2613c1929 100644 --- a/apps/web/lib/tinybird/record-fake-click.ts +++ b/apps/web/lib/tinybird/record-fake-click.ts @@ -12,6 +12,7 @@ export async function recordFakeClick({ customer?: { country?: string | null; region?: string | null; + continent?: string | null; }; timestamp?: string | number; }) { @@ -21,7 +22,7 @@ export async function recordFakeClick({ "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": "NA", + "x-vercel-ip-continent": customer?.continent || "NA", }), }); From ef3e1e5079cc19696d0a1bb30aeb2cbde0a4df51 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 6 Aug 2025 18:51:51 +0530 Subject: [PATCH 032/221] create promotion code when adding a new link --- .../(ee)/api/embed/referrals/links/route.ts | 38 +++++++++++++++++++ .../programs/[programId]/links/route.ts | 33 +++++++++++++++- apps/web/app/(ee)/api/partners/links/route.ts | 30 ++++++++++++--- .../(ee)/api/partners/links/upsert/route.ts | 24 +++++++++--- 4 files changed, 114 insertions(+), 11 deletions(-) diff --git a/apps/web/app/(ee)/api/embed/referrals/links/route.ts b/apps/web/app/(ee)/api/embed/referrals/links/route.ts index 9054afc0770..64d3387ca21 100644 --- a/apps/web/app/(ee)/api/embed/referrals/links/route.ts +++ b/apps/web/app/(ee)/api/embed/referrals/links/route.ts @@ -3,9 +3,11 @@ import { createLink, processLink } from "@/lib/api/links"; import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-url"; import { parseRequestBody } from "@/lib/api/utils"; import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth"; +import { createStripePromotionCode } from "@/lib/stripe/create-promotion-code"; import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners"; import { ReferralsEmbedLinkSchema } from "@/lib/zod/schemas/referrals-embed"; import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // GET /api/embed/referrals/links – get links for a partner @@ -86,6 +88,42 @@ export const POST = withReferralsEmbedToken( const partnerLink = await createLink(link); + waitUntil( + (async () => { + if (!programEnrollment.discountId) { + return; + } + + const discount = await prisma.discount.findUniqueOrThrow({ + where: { + id: programEnrollment.discountId, + }, + select: { + couponId: true, + program: { + select: { + workspace: { + select: { + stripeConnectId: true, + }, + }, + }, + }, + }, + }); + + if (!program.couponCodeTrackingEnabledAt || !discount.couponId) { + return; + } + + await createStripePromotionCode({ + code: partnerLink.key, + couponId: discount.couponId, + stripeConnectId: discount.program.workspace.stripeConnectId, + }); + })(), + ); + return NextResponse.json(ReferralsEmbedLinkSchema.parse(partnerLink), { status: 201, }); 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 7c36b6dee36..f4bb6560e8e 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 @@ -4,8 +4,11 @@ import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-ur import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { createStripePromotionCode } from "@/lib/stripe/create-promotion-code"; import { PartnerProfileLinkSchema } from "@/lib/zod/schemas/partner-profile"; import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners"; +import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/links - get a partner's links in a program @@ -27,10 +30,11 @@ export const POST = withPartnerProfile( .pick({ url: true, key: true, comments: true }) .parse(await parseRequestBody(req)); - const { program, links, tenantId, status } = + const { program, links, tenantId, status, discount } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, + includeDiscount: true, }); if (status === "banned") { @@ -88,6 +92,33 @@ export const POST = withPartnerProfile( const partnerLink = await createLink(link); + waitUntil( + (async () => { + if ( + !discount || + !discount.couponId || + !program.couponCodeTrackingEnabledAt + ) { + return; + } + + const workspace = await prisma.project.findUniqueOrThrow({ + where: { + id: program.workspaceId, + }, + select: { + stripeConnectId: true, + }, + }); + + await createStripePromotionCode({ + code: partnerLink.key, + couponId: discount.couponId, + stripeConnectId: workspace.stripeConnectId, + }); + })(), + ); + return NextResponse.json(PartnerProfileLinkSchema.parse(partnerLink), { status: 201, }); diff --git a/apps/web/app/(ee)/api/partners/links/route.ts b/apps/web/app/(ee)/api/partners/links/route.ts index c53fa046b69..90c9fdf45fa 100644 --- a/apps/web/app/(ee)/api/partners/links/route.ts +++ b/apps/web/app/(ee)/api/partners/links/route.ts @@ -5,6 +5,7 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-progr import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; +import { createStripePromotionCode } from "@/lib/stripe/create-promotion-code"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { linkEventSchema } from "@/lib/zod/schemas/links"; import { @@ -94,6 +95,15 @@ export const POST = withWorkspace( where: partnerId ? { partnerId_programId: { partnerId, programId } } : { tenantId_programId: { tenantId: tenantId!, programId } }, + select: { + partnerId: true, + tenantId: true, + discount: { + select: { + couponId: true, + }, + }, + }, }); if (!partner) { @@ -130,11 +140,21 @@ export const POST = withWorkspace( const partnerLink = await createLink(link); waitUntil( - sendWorkspaceWebhook({ - trigger: "link.created", - workspace, - data: linkEventSchema.parse(partnerLink), - }), + Promise.allSettled([ + sendWorkspaceWebhook({ + trigger: "link.created", + workspace, + data: linkEventSchema.parse(partnerLink), + }), + + program.couponCodeTrackingEnabledAt && partner.discount?.couponId + ? createStripePromotionCode({ + code: partnerLink.key, + couponId: partner.discount?.couponId!, + stripeConnectId: workspace.stripeConnectId, + }) + : Promise.resolve(), + ]), ); return NextResponse.json(partnerLink, { status: 201 }); diff --git a/apps/web/app/(ee)/api/partners/links/upsert/route.ts b/apps/web/app/(ee)/api/partners/links/upsert/route.ts index d9444d8a9f6..702b02b8c46 100644 --- a/apps/web/app/(ee)/api/partners/links/upsert/route.ts +++ b/apps/web/app/(ee)/api/partners/links/upsert/route.ts @@ -11,6 +11,7 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-progr import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; +import { createStripePromotionCode } from "@/lib/stripe/create-promotion-code"; import { NewLinkProps } from "@/lib/types"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { linkEventSchema } from "@/lib/zod/schemas/links"; @@ -54,6 +55,9 @@ export const PUT = withWorkspace( where: partnerId ? { partnerId_programId: { partnerId, programId } } : { tenantId_programId: { tenantId: tenantId!, programId } }, + include: { + discount: true, + }, }); if (!partner) { @@ -202,11 +206,21 @@ export const PUT = withWorkspace( const partnerLink = await createLink(link); waitUntil( - sendWorkspaceWebhook({ - trigger: "link.created", - workspace, - data: linkEventSchema.parse(partnerLink), - }), + Promise.allSettled([ + sendWorkspaceWebhook({ + trigger: "link.created", + workspace, + data: linkEventSchema.parse(partnerLink), + }), + + program.couponCodeTrackingEnabledAt && partner.discount?.couponId + ? createStripePromotionCode({ + code: partnerLink.key, + couponId: partner.discount?.couponId!, + stripeConnectId: workspace.stripeConnectId, + }) + : Promise.resolve(), + ]), ); return NextResponse.json(partnerLink, { From e47b60881c686473401b7321b8f4eff51c7e1b5b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 6 Aug 2025 23:38:52 +0530 Subject: [PATCH 033/221] Refactor link creation to include discount and program details, streamline promotion code generation, and enhance error handling for missing coupon IDs. --- .../(ee)/api/embed/referrals/links/route.ts | 46 ++---------- .../programs/[programId]/links/route.ts | 36 ++------- apps/web/app/(ee)/api/partners/links/route.ts | 29 +++---- .../(ee)/api/partners/links/upsert/route.ts | 28 +++---- apps/web/lib/api/links/create-link.ts | 75 ++++++++++++++++++- .../lib/api/partners/create-partner-link.ts | 5 +- apps/web/lib/embed/referrals/auth.ts | 11 ++- apps/web/lib/stripe/create-promotion-code.ts | 9 ++- apps/web/lib/types.ts | 1 + packages/prisma/schema/link.prisma | 12 +-- 10 files changed, 134 insertions(+), 118 deletions(-) diff --git a/apps/web/app/(ee)/api/embed/referrals/links/route.ts b/apps/web/app/(ee)/api/embed/referrals/links/route.ts index 64d3387ca21..9cffdc3083b 100644 --- a/apps/web/app/(ee)/api/embed/referrals/links/route.ts +++ b/apps/web/app/(ee)/api/embed/referrals/links/route.ts @@ -3,11 +3,9 @@ import { createLink, processLink } from "@/lib/api/links"; import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-url"; import { parseRequestBody } from "@/lib/api/utils"; import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth"; -import { createStripePromotionCode } from "@/lib/stripe/create-promotion-code"; import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners"; import { ReferralsEmbedLinkSchema } from "@/lib/zod/schemas/referrals-embed"; import { prisma } from "@dub/prisma"; -import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // GET /api/embed/referrals/links – get links for a partner @@ -19,7 +17,7 @@ export const GET = withReferralsEmbedToken(async ({ links }) => { // POST /api/embed/referrals/links – create links for a partner export const POST = withReferralsEmbedToken( - async ({ req, programEnrollment, program, links }) => { + async ({ req, programEnrollment, program, links, discount }) => { const { url, key } = createPartnerLinkSchema .pick({ url: true, key: true }) .parse(await parseRequestBody(req)); @@ -86,43 +84,11 @@ export const POST = withReferralsEmbedToken( }); } - const partnerLink = await createLink(link); - - waitUntil( - (async () => { - if (!programEnrollment.discountId) { - return; - } - - const discount = await prisma.discount.findUniqueOrThrow({ - where: { - id: programEnrollment.discountId, - }, - select: { - couponId: true, - program: { - select: { - workspace: { - select: { - stripeConnectId: true, - }, - }, - }, - }, - }, - }); - - if (!program.couponCodeTrackingEnabledAt || !discount.couponId) { - return; - } - - await createStripePromotionCode({ - code: partnerLink.key, - couponId: discount.couponId, - stripeConnectId: discount.program.workspace.stripeConnectId, - }); - })(), - ); + const partnerLink = await createLink({ + ...link, + program, + discount, + }); return NextResponse.json(ReferralsEmbedLinkSchema.parse(partnerLink), { status: 201, 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 f4bb6560e8e..0b1a085681c 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 @@ -4,11 +4,8 @@ import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-ur import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withPartnerProfile } from "@/lib/auth/partner"; -import { createStripePromotionCode } from "@/lib/stripe/create-promotion-code"; import { PartnerProfileLinkSchema } from "@/lib/zod/schemas/partner-profile"; import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners"; -import { prisma } from "@dub/prisma"; -import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/links - get a partner's links in a program @@ -90,34 +87,11 @@ export const POST = withPartnerProfile( }); } - const partnerLink = await createLink(link); - - waitUntil( - (async () => { - if ( - !discount || - !discount.couponId || - !program.couponCodeTrackingEnabledAt - ) { - return; - } - - const workspace = await prisma.project.findUniqueOrThrow({ - where: { - id: program.workspaceId, - }, - select: { - stripeConnectId: true, - }, - }); - - await createStripePromotionCode({ - code: partnerLink.key, - couponId: discount.couponId, - stripeConnectId: workspace.stripeConnectId, - }); - })(), - ); + const partnerLink = await createLink({ + ...link, + program, + discount, + }); return NextResponse.json(PartnerProfileLinkSchema.parse(partnerLink), { status: 201, diff --git a/apps/web/app/(ee)/api/partners/links/route.ts b/apps/web/app/(ee)/api/partners/links/route.ts index 90c9fdf45fa..0bb22b3e42b 100644 --- a/apps/web/app/(ee)/api/partners/links/route.ts +++ b/apps/web/app/(ee)/api/partners/links/route.ts @@ -5,7 +5,6 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-progr import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; -import { createStripePromotionCode } from "@/lib/stripe/create-promotion-code"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { linkEventSchema } from "@/lib/zod/schemas/links"; import { @@ -100,6 +99,7 @@ export const POST = withWorkspace( tenantId: true, discount: { select: { + id: true, couponId: true, }, }, @@ -137,24 +137,19 @@ export const POST = withWorkspace( }); } - const partnerLink = await createLink(link); + const partnerLink = await createLink({ + ...link, + program, + discount: partner.discount, + stripeConnectId: workspace.stripeConnectId, + }); waitUntil( - Promise.allSettled([ - sendWorkspaceWebhook({ - trigger: "link.created", - workspace, - data: linkEventSchema.parse(partnerLink), - }), - - program.couponCodeTrackingEnabledAt && partner.discount?.couponId - ? createStripePromotionCode({ - code: partnerLink.key, - couponId: partner.discount?.couponId!, - stripeConnectId: workspace.stripeConnectId, - }) - : Promise.resolve(), - ]), + sendWorkspaceWebhook({ + trigger: "link.created", + workspace, + data: linkEventSchema.parse(partnerLink), + }), ); return NextResponse.json(partnerLink, { status: 201 }); diff --git a/apps/web/app/(ee)/api/partners/links/upsert/route.ts b/apps/web/app/(ee)/api/partners/links/upsert/route.ts index 702b02b8c46..399162012f1 100644 --- a/apps/web/app/(ee)/api/partners/links/upsert/route.ts +++ b/apps/web/app/(ee)/api/partners/links/upsert/route.ts @@ -11,7 +11,6 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-progr import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; -import { createStripePromotionCode } from "@/lib/stripe/create-promotion-code"; import { NewLinkProps } from "@/lib/types"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { linkEventSchema } from "@/lib/zod/schemas/links"; @@ -203,24 +202,19 @@ export const PUT = withWorkspace( }); } - const partnerLink = await createLink(link); + const partnerLink = await createLink({ + ...link, + program, + discount: partner.discount, + stripeConnectId: workspace.stripeConnectId, + }); waitUntil( - Promise.allSettled([ - sendWorkspaceWebhook({ - trigger: "link.created", - workspace, - data: linkEventSchema.parse(partnerLink), - }), - - program.couponCodeTrackingEnabledAt && partner.discount?.couponId - ? createStripePromotionCode({ - code: partnerLink.key, - couponId: partner.discount?.couponId!, - stripeConnectId: workspace.stripeConnectId, - }) - : Promise.resolve(), - ]), + sendWorkspaceWebhook({ + trigger: "link.created", + workspace, + data: linkEventSchema.parse(partnerLink), + }), ); return NextResponse.json(partnerLink, { diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts index b90b95f1535..f48ff50d956 100644 --- a/apps/web/lib/api/links/create-link.ts +++ b/apps/web/lib/api/links/create-link.ts @@ -1,8 +1,9 @@ import { qstash } from "@/lib/cron"; import { getPartnerAndDiscount } from "@/lib/planetscale/get-partner-discount"; import { isNotHostedImage, storage } from "@/lib/storage"; +import { createStripePromotionCode } from "@/lib/stripe/create-promotion-code"; import { recordLink } from "@/lib/tinybird"; -import { ProcessedLinkProps } from "@/lib/types"; +import { DiscountProps, ProcessedLinkProps, ProgramProps } from "@/lib/types"; import { propagateWebhookTriggerChanges } from "@/lib/webhook/update-webhook"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; @@ -23,7 +24,13 @@ import { includeTags } from "./include-tags"; import { updateLinksUsage } from "./update-links-usage"; import { transformLink } from "./utils"; -export async function createLink(link: ProcessedLinkProps) { +type CreateLinkProps = ProcessedLinkProps & { + program?: Pick; + discount?: Pick | null; + stripeConnectId?: string | null; +}; + +export async function createLink(link: CreateLinkProps) { let { key, url, @@ -37,6 +44,7 @@ export async function createLink(link: ProcessedLinkProps) { testVariants, testStartedAt, testCompletedAt, + stripeConnectId, } = link; const combinedTagIds = combineTagIds(link); @@ -44,13 +52,18 @@ export async function createLink(link: ProcessedLinkProps) { const { utm_source, utm_medium, utm_campaign, utm_term, utm_content } = getParamsFromURL(url); - const { tagId, tagIds, tagNames, webhookIds, ...rest } = link; + let { tagId, tagIds, tagNames, webhookIds, program, discount, ...rest } = + link; key = encodeKeyIfCaseSensitive({ domain: link.domain, key, }); + let shouldCreateCouponCode = Boolean( + program?.couponCodeTrackingEnabledAt && discount?.couponId, + ); + const response = await prisma.link.create({ data: { ...rest, @@ -130,10 +143,55 @@ export async function createLink(link: ProcessedLinkProps) { include: { ...includeTags, webhooks: webhookIds ? true : false, + project: shouldCreateCouponCode && stripeConnectId === undefined, }, }); + + // TODO: + // Move to waitUntil + if (response.programId && response.partnerId && !program && !discount) { + const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({ + where: { + partnerId_programId: { + partnerId: response.partnerId, + programId: response.programId, + }, + }, + select: { + discount: { + select: { + id: true, + couponId: true, + }, + }, + program: { + select: { + id: true, + couponCodeTrackingEnabledAt: true, + workspace: { + select: { + stripeConnectId: true, + }, + }, + }, + }, + }, + }); + + const workspace = programEnrollment.program.workspace; + + program = programEnrollment.program; + discount = programEnrollment.discount; + stripeConnectId = workspace.stripeConnectId; + + shouldCreateCouponCode = Boolean( + program.couponCodeTrackingEnabledAt && discount?.couponId, + ); + } + const uploadedImageUrl = `${R2_URL}/images/${response.id}`; + stripeConnectId = stripeConnectId || response.project?.stripeConnectId; waitUntil( Promise.allSettled([ @@ -192,9 +250,20 @@ export async function createLink(link: ProcessedLinkProps) { }), testVariants && testCompletedAt && scheduleABTestCompletion(response), + + shouldCreateCouponCode && + stripeConnectId && + createStripePromotionCode({ + code: response.key, + couponId: discount?.couponId!, + stripeConnectId, + }), ]), ); + // @ts-expect-error + delete response?.project; + return { ...transformLink(response), // optimistically set the image URL to the uploaded image URL diff --git a/apps/web/lib/api/partners/create-partner-link.ts b/apps/web/lib/api/partners/create-partner-link.ts index 8863acf673f..4590d0e6d12 100644 --- a/apps/web/lib/api/partners/create-partner-link.ts +++ b/apps/web/lib/api/partners/create-partner-link.ts @@ -15,7 +15,10 @@ import { createLink } from "../links/create-link"; import { processLink } from "../links/process-link"; type PartnerLinkArgs = { - workspace: Pick; + workspace: Pick< + WorkspaceProps, + "id" | "plan" | "webhookEnabled" | "stripeConnectId" + >; program: Pick; partner: Pick< CreatePartnerProps, diff --git a/apps/web/lib/embed/referrals/auth.ts b/apps/web/lib/embed/referrals/auth.ts index 1e154844e5c..8e4fa848128 100644 --- a/apps/web/lib/embed/referrals/auth.ts +++ b/apps/web/lib/embed/referrals/auth.ts @@ -1,4 +1,5 @@ import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { DiscountProps } from "@/lib/types"; import { ratelimit } from "@/lib/upstash"; import { prisma } from "@dub/prisma"; import { Link, Program, ProgramEnrollment } from "@dub/prisma/client"; @@ -21,6 +22,7 @@ interface WithReferralsEmbedTokenHandler { searchParams: Record; program: Program; programEnrollment: ProgramEnrollment; + discount: DiscountProps | null; links: Link[]; embedToken: string; }): Promise; @@ -77,10 +79,13 @@ export const withReferralsEmbedToken = ( }); } - const { program, links, ...programEnrollment } = + const { program, links, discount, ...programEnrollment } = await prisma.programEnrollment.findUniqueOrThrow({ where: { - partnerId_programId: { partnerId, programId }, + partnerId_programId: { + partnerId, + programId, + }, }, include: { links: { @@ -97,6 +102,7 @@ export const withReferralsEmbedToken = ( ], }, program: true, + discount: true, }, }); @@ -107,6 +113,7 @@ export const withReferralsEmbedToken = ( program, programEnrollment, links, + discount, embedToken, }); } catch (error) { diff --git a/apps/web/lib/stripe/create-promotion-code.ts b/apps/web/lib/stripe/create-promotion-code.ts index c73ba766a8a..beb6ef9e71f 100644 --- a/apps/web/lib/stripe/create-promotion-code.ts +++ b/apps/web/lib/stripe/create-promotion-code.ts @@ -11,9 +11,16 @@ export async function createStripePromotionCode({ stripeConnectId, }: { code: string; - couponId: string; + couponId: string | null; stripeConnectId: string | null; }) { + if (!couponId) { + console.error( + "couponId not found for the discount. Stripe promotion code creation skipped.", + ); + return; + } + if (!stripeConnectId) { console.error( "stripeConnectId not found for the workspace. Stripe promotion code creation skipped.", diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 966787abeba..a5177678da8 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -263,6 +263,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; diff --git a/packages/prisma/schema/link.prisma b/packages/prisma/schema/link.prisma index e33c5880c09..86d501c4384 100644 --- a/packages/prisma/schema/link.prisma +++ b/packages/prisma/schema/link.prisma @@ -9,12 +9,12 @@ model Link { expiredUrl String? @db.Text // URL to redirect the user to when the link is expired password String? // password to access the link trackConversion Boolean @default(false) // whether to track conversions or not - - proxy Boolean @default(false) // Proxy to use custom OG tags (stored on redis) – if false, will use OG tags from target url - title String? // OG title for the link (e.g. Dub - open-source link attribution platform) - description String? @db.VarChar(280) // OG description for the link (e.g. The modern link attribution platform for short links, conversion tracking, and affiliate programs.) - image String? @db.LongText // OG image for the link (e.g. https://d.to/og) - video String? @db.Text // OG video for the link + couponCode String? + proxy Boolean @default(false) // Proxy to use custom OG tags (stored on redis) – if false, will use OG tags from target url + title String? // OG title for the link (e.g. Dub - open-source link attribution platform) + description String? @db.VarChar(280) // OG description for the link (e.g. The modern link attribution platform for short links, conversion tracking, and affiliate programs.) + image String? @db.LongText // OG image for the link (e.g. https://d.to/og) + video String? @db.Text // OG video for the link // UTM parameters utm_source String? // UTM source for the link (e.g. youtube.com) From 94a12102259f11976ce08feda35b04553c48a551 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 7 Aug 2025 09:38:13 +0530 Subject: [PATCH 034/221] Create disable-promotion-code.ts --- apps/web/lib/stripe/disable-promotion-code.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/web/lib/stripe/disable-promotion-code.ts diff --git a/apps/web/lib/stripe/disable-promotion-code.ts b/apps/web/lib/stripe/disable-promotion-code.ts new file mode 100644 index 00000000000..beb6ef9e71f --- /dev/null +++ b/apps/web/lib/stripe/disable-promotion-code.ts @@ -0,0 +1,48 @@ +import { stripeAppClient } from "."; + +const stripe = stripeAppClient({ + ...(process.env.VERCEL_ENV && { livemode: true }), +}); + +// Create a promotion code on Stripe for connected accounts +export async function createStripePromotionCode({ + code, + couponId, + stripeConnectId, +}: { + code: string; + couponId: string | null; + stripeConnectId: string | null; +}) { + if (!couponId) { + console.error( + "couponId not found for the discount. Stripe promotion code creation skipped.", + ); + return; + } + + if (!stripeConnectId) { + console.error( + "stripeConnectId not found for the workspace. Stripe promotion code creation skipped.", + ); + return; + } + + try { + return await stripe.promotionCodes.create( + { + coupon: couponId, + code: code.toUpperCase(), + }, + { + stripeAccount: stripeConnectId, + }, + ); + } catch (error) { + console.error( + `Failed to create Stripe promotion code for ${stripeConnectId}: ${error}`, + ); + + throw new Error(error instanceof Error ? error.message : "Unknown error"); + } +} From befd2af356820ae708b7f3e803046f8adadc9f51 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 7 Aug 2025 15:17:59 +0530 Subject: [PATCH 035/221] Update disable-promotion-code.ts --- apps/web/lib/stripe/disable-promotion-code.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/web/lib/stripe/disable-promotion-code.ts b/apps/web/lib/stripe/disable-promotion-code.ts index beb6ef9e71f..0d0ce4e66a5 100644 --- a/apps/web/lib/stripe/disable-promotion-code.ts +++ b/apps/web/lib/stripe/disable-promotion-code.ts @@ -4,35 +4,40 @@ const stripe = stripeAppClient({ ...(process.env.VERCEL_ENV && { livemode: true }), }); -// Create a promotion code on Stripe for connected accounts -export async function createStripePromotionCode({ +// Disable a promotion code on Stripe for connected accounts +export async function disableStripePromotionCode({ code, - couponId, stripeConnectId, }: { code: string; - couponId: string | null; stripeConnectId: string | null; }) { - if (!couponId) { + if (!stripeConnectId) { console.error( - "couponId not found for the discount. Stripe promotion code creation skipped.", + "stripeConnectId not found for the workspace. Stripe promotion code update skipped.", ); return; } - if (!stripeConnectId) { + const promotionCodes = await stripe.promotionCodes.list({ + code, + limit: 1, + }); + + if (promotionCodes.data.length === 0) { console.error( - "stripeConnectId not found for the workspace. Stripe promotion code creation skipped.", + `Promotion code ${code} not found in the connected account ${stripeConnectId}. Stripe promotion code update skipped.`, ); return; } try { - return await stripe.promotionCodes.create( + const promotionCode = promotionCodes.data[0]; + + return await stripe.promotionCodes.update( + promotionCode.id, { - coupon: couponId, - code: code.toUpperCase(), + active: false, }, { stripeAccount: stripeConnectId, @@ -40,7 +45,7 @@ export async function createStripePromotionCode({ ); } catch (error) { console.error( - `Failed to create Stripe promotion code for ${stripeConnectId}: ${error}`, + `Failed to disable Stripe promotion code ${code} for ${stripeConnectId}: ${error}`, ); throw new Error(error instanceof Error ? error.message : "Unknown error"); From b5d55b63cc13472c7c5bc70eb068ad683cf434ec Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 7 Aug 2025 17:43:43 +0530 Subject: [PATCH 036/221] Refactor bulk link deletion to include workspace across multiple API routes. --- .../(ee)/api/cron/cleanup/e2e-tests/route.ts | 133 ++++++++++-------- .../(ee)/api/cron/workspaces/delete/route.ts | 5 +- apps/web/app/api/links/bulk/route.ts | 7 +- .../actions/partners/delete-program-invite.ts | 5 +- apps/web/lib/api/links/bulk-delete-links.ts | 23 ++- apps/web/lib/api/links/delete-link.ts | 13 ++ .../lib/api/partners/bulk-delete-partners.ts | 8 +- apps/web/lib/api/partners/delete-partner.ts | 16 ++- apps/web/lib/stripe/disable-promotion-code.ts | 12 +- 9 files changed, 146 insertions(+), 76 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/cleanup/e2e-tests/route.ts b/apps/web/app/(ee)/api/cron/cleanup/e2e-tests/route.ts index 2c82bc01320..e68df4270ce 100644 --- a/apps/web/app/(ee)/api/cron/cleanup/e2e-tests/route.ts +++ b/apps/web/app/(ee)/api/cron/cleanup/e2e-tests/route.ts @@ -24,77 +24,88 @@ export async function GET(req: Request) { const oneHourAgo = new Date(Date.now() - 1000 * 60 * 60); - const [links, domains, tags, partners, users] = await Promise.all([ - prisma.link.findMany({ - where: { - userId: E2E_USER_ID, - projectId: E2E_WORKSPACE_ID, - createdAt: { - lt: oneHourAgo, + const [links, domains, tags, partners, users, workspace] = + await Promise.all([ + prisma.link.findMany({ + where: { + userId: E2E_USER_ID, + projectId: E2E_WORKSPACE_ID, + createdAt: { + lt: oneHourAgo, + }, }, - }, - include: { - tags: { - select: { - tag: true, + include: { + tags: { + select: { + tag: true, + }, }, }, - }, - take: 100, - }), - - prisma.domain.findMany({ - where: { - projectId: E2E_WORKSPACE_ID, - slug: { - endsWith: ".dub-internal-test.com", + take: 100, + }), + + prisma.domain.findMany({ + where: { + projectId: E2E_WORKSPACE_ID, + slug: { + endsWith: ".dub-internal-test.com", + }, + createdAt: { + lt: oneHourAgo, + }, }, - createdAt: { - lt: oneHourAgo, + select: { + slug: true, }, - }, - select: { - slug: true, - }, - }), + }), - prisma.tag.findMany({ - where: { - projectId: E2E_WORKSPACE_ID, - name: { - startsWith: "e2e-", - }, - createdAt: { - lt: oneHourAgo, + prisma.tag.findMany({ + where: { + projectId: E2E_WORKSPACE_ID, + name: { + startsWith: "e2e-", + }, + createdAt: { + lt: oneHourAgo, + }, }, - }, - }), + }), - prisma.partner.findMany({ - where: { - email: { - endsWith: "@dub-internal-test.com", + prisma.partner.findMany({ + where: { + email: { + endsWith: "@dub-internal-test.com", + }, + createdAt: { + lt: oneHourAgo, + }, }, - createdAt: { - lt: oneHourAgo, + select: { + id: true, }, - }, - select: { - id: true, - }, - }), + }), - prisma.user.findMany({ - where: { - email: { - endsWith: "@dub-internal-test.com", + prisma.user.findMany({ + where: { + email: { + endsWith: "@dub-internal-test.com", + }, + createdAt: { + lt: oneHourAgo, + }, + }, + }), + + prisma.project.findUniqueOrThrow({ + where: { + id: E2E_WORKSPACE_ID, }, - createdAt: { - lt: oneHourAgo, + select: { + id: true, + stripeConnectId: true, }, - }, - }), - ]); + }), + ]); // Delete the links if (links.length > 0) { @@ -107,7 +118,10 @@ export async function GET(req: Request) { }); // Post delete cleanup - await bulkDeleteLinks(links); + await bulkDeleteLinks({ + links, + workspace, + }); } // Delete the domains @@ -136,6 +150,7 @@ export async function GET(req: Request) { if (partners.length > 0) { await bulkDeletePartners({ partnerIds: partners.map((partner) => partner.id), + workspace, }); } diff --git a/apps/web/app/(ee)/api/cron/workspaces/delete/route.ts b/apps/web/app/(ee)/api/cron/workspaces/delete/route.ts index ec101cdf02b..404ad76a0de 100644 --- a/apps/web/app/(ee)/api/cron/workspaces/delete/route.ts +++ b/apps/web/app/(ee)/api/cron/workspaces/delete/route.ts @@ -55,7 +55,10 @@ export async function POST(req: Request) { }, }), - bulkDeleteLinks(links), + bulkDeleteLinks({ + links, + workspace, + }), ]); console.log(res); diff --git a/apps/web/app/api/links/bulk/route.ts b/apps/web/app/api/links/bulk/route.ts index 0bfc4a5c682..a2dc40bf0a6 100644 --- a/apps/web/app/api/links/bulk/route.ts +++ b/apps/web/app/api/links/bulk/route.ts @@ -549,7 +549,12 @@ export const DELETE = withWorkspace( }, }); - waitUntil(bulkDeleteLinks(links)); + waitUntil( + bulkDeleteLinks({ + links, + workspace, + }), + ); return NextResponse.json( { diff --git a/apps/web/lib/actions/partners/delete-program-invite.ts b/apps/web/lib/actions/partners/delete-program-invite.ts index 6cea83dc062..63d2e17fc52 100644 --- a/apps/web/lib/actions/partners/delete-program-invite.ts +++ b/apps/web/lib/actions/partners/delete-program-invite.ts @@ -55,7 +55,10 @@ export const deleteProgramInviteAction = authActionClient where: { id: { in: linksToDelete.map((link) => link.id) } }, }), - bulkDeleteLinks(linksToDelete), + bulkDeleteLinks({ + links: linksToDelete, + workspace, + }), recordAuditLog({ workspaceId: workspace.id, diff --git a/apps/web/lib/api/links/bulk-delete-links.ts b/apps/web/lib/api/links/bulk-delete-links.ts index 413250e2257..59822335252 100644 --- a/apps/web/lib/api/links/bulk-delete-links.ts +++ b/apps/web/lib/api/links/bulk-delete-links.ts @@ -1,16 +1,24 @@ import { storage } from "@/lib/storage"; +import { disableStripePromotionCode } from "@/lib/stripe/disable-promotion-code"; import { recordLinkTB, transformLinkTB } from "@/lib/tinybird"; +import { WorkspaceProps } from "@/lib/types"; import { prisma } from "@dub/prisma"; import { R2_URL } from "@dub/utils"; import { linkCache } from "./cache"; import { ExpandedLink } from "./utils"; -export async function bulkDeleteLinks(links: ExpandedLink[]) { +export async function bulkDeleteLinks({ + links, + workspace, +}: { + links: ExpandedLink[]; + workspace: Pick; +}) { if (links.length === 0) { return; } - return await Promise.all([ + return await Promise.allSettled([ // Delete the links from Redis linkCache.deleteMany(links), @@ -30,11 +38,20 @@ export async function bulkDeleteLinks(links: ExpandedLink[]) { // Update totalLinks for the workspace prisma.project.update({ where: { - id: links[0].projectId!, + id: workspace.id, }, data: { totalLinks: { decrement: links.length }, }, }), + + links + .filter((link) => link.couponCode) + .map((link) => + disableStripePromotionCode({ + code: link.couponCode, + stripeConnectId: workspace.stripeConnectId, + }), + ), ]); } diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index c498a8b5806..bf373b69bb1 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -1,4 +1,5 @@ import { storage } from "@/lib/storage"; +import { disableStripePromotionCode } from "@/lib/stripe/disable-promotion-code"; import { recordLinkTB, transformLinkTB } from "@/lib/tinybird"; import { prisma } from "@dub/prisma"; import { R2_URL } from "@dub/utils"; @@ -14,6 +15,11 @@ export async function deleteLink(linkId: string) { }, include: { ...includeTags, + project: { + select: { + stripeConnectId: true, + }, + }, }, }); @@ -42,6 +48,13 @@ export async function deleteLink(linkId: string) { totalLinks: { decrement: 1 }, }, }), + + link.couponCode && + link.project && + disableStripePromotionCode({ + code: link.couponCode, + stripeConnectId: link.project.stripeConnectId, + }), ]), ); diff --git a/apps/web/lib/api/partners/bulk-delete-partners.ts b/apps/web/lib/api/partners/bulk-delete-partners.ts index c4bc69d084c..ddba02bd273 100644 --- a/apps/web/lib/api/partners/bulk-delete-partners.ts +++ b/apps/web/lib/api/partners/bulk-delete-partners.ts @@ -1,3 +1,4 @@ +import { WorkspaceProps } from "@/lib/types"; import { prisma } from "@dub/prisma"; import { bulkDeleteLinks } from "../links/bulk-delete-links"; @@ -5,8 +6,10 @@ import { bulkDeleteLinks } from "../links/bulk-delete-links"; // currently only used for the cron/cleanup/e2e-tests job export async function bulkDeletePartners({ partnerIds, + workspace, }: { partnerIds: string[]; + workspace: Pick; }) { const partners = await prisma.partner.findMany({ where: { @@ -38,7 +41,10 @@ export async function bulkDeletePartners({ }, }); - await bulkDeleteLinks(linksToDelete); + await bulkDeleteLinks({ + links: linksToDelete, + workspace, + }); await prisma.link.deleteMany({ where: { diff --git a/apps/web/lib/api/partners/delete-partner.ts b/apps/web/lib/api/partners/delete-partner.ts index 19f584bf7b3..05b818139c9 100644 --- a/apps/web/lib/api/partners/delete-partner.ts +++ b/apps/web/lib/api/partners/delete-partner.ts @@ -1,12 +1,19 @@ import { storage } from "@/lib/storage"; import { stripe } from "@/lib/stripe"; +import { WorkspaceProps } from "@/lib/types"; 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 }) { +// Not using this anymore +export async function deletePartner({ + partnerId, + workspace, +}: { + partnerId: string; + workspace: Pick; +}) { const partner = await prisma.partner.findUnique({ where: { id: partnerId, @@ -36,7 +43,10 @@ export async function deletePartner({ partnerId }: { partnerId: string }) { }, }); - await bulkDeleteLinks(links); + await bulkDeleteLinks({ + links, + workspace, + }); await prisma.link.deleteMany({ where: { diff --git a/apps/web/lib/stripe/disable-promotion-code.ts b/apps/web/lib/stripe/disable-promotion-code.ts index 0d0ce4e66a5..34ff734f4da 100644 --- a/apps/web/lib/stripe/disable-promotion-code.ts +++ b/apps/web/lib/stripe/disable-promotion-code.ts @@ -9,13 +9,14 @@ export async function disableStripePromotionCode({ code, stripeConnectId, }: { - code: string; + code: string | null; stripeConnectId: string | null; }) { + if (!code) { + return; + } + if (!stripeConnectId) { - console.error( - "stripeConnectId not found for the workspace. Stripe promotion code update skipped.", - ); return; } @@ -25,9 +26,6 @@ export async function disableStripePromotionCode({ }); if (promotionCodes.data.length === 0) { - console.error( - `Promotion code ${code} not found in the connected account ${stripeConnectId}. Stripe promotion code update skipped.`, - ); return; } From 6ab362feb78cc149fb7a999907ccd4eb69205421 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 7 Aug 2025 17:49:40 +0530 Subject: [PATCH 037/221] rename --- .../web/app/(ee)/api/cron/links/create-promotion-codes/route.ts | 2 +- apps/web/lib/api/links/bulk-delete-links.ts | 2 +- apps/web/lib/api/links/create-link.ts | 2 +- apps/web/lib/api/links/delete-link.ts | 2 +- ...create-promotion-code.ts => create-stripe-promotion-code.ts} | 0 ...sable-promotion-code.ts => disable-stripe-promotion-code.ts} | 0 6 files changed, 4 insertions(+), 4 deletions(-) rename apps/web/lib/stripe/{create-promotion-code.ts => create-stripe-promotion-code.ts} (100%) rename apps/web/lib/stripe/{disable-promotion-code.ts => disable-stripe-promotion-code.ts} (100%) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index f6c1860e898..7c56e649736 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -1,7 +1,7 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; -import { createStripePromotionCode } from "@/lib/stripe/create-promotion-code"; +import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, chunk, log } from "@dub/utils"; import { z } from "zod"; diff --git a/apps/web/lib/api/links/bulk-delete-links.ts b/apps/web/lib/api/links/bulk-delete-links.ts index 59822335252..640fe670e1b 100644 --- a/apps/web/lib/api/links/bulk-delete-links.ts +++ b/apps/web/lib/api/links/bulk-delete-links.ts @@ -1,5 +1,5 @@ import { storage } from "@/lib/storage"; -import { disableStripePromotionCode } from "@/lib/stripe/disable-promotion-code"; +import { disableStripePromotionCode } from "@/lib/stripe/disable-stripe-promotion-code"; import { recordLinkTB, transformLinkTB } from "@/lib/tinybird"; import { WorkspaceProps } from "@/lib/types"; import { prisma } from "@dub/prisma"; diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts index f48ff50d956..0b2021cbd66 100644 --- a/apps/web/lib/api/links/create-link.ts +++ b/apps/web/lib/api/links/create-link.ts @@ -1,7 +1,7 @@ import { qstash } from "@/lib/cron"; import { getPartnerAndDiscount } from "@/lib/planetscale/get-partner-discount"; import { isNotHostedImage, storage } from "@/lib/storage"; -import { createStripePromotionCode } from "@/lib/stripe/create-promotion-code"; +import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; import { recordLink } from "@/lib/tinybird"; import { DiscountProps, ProcessedLinkProps, ProgramProps } from "@/lib/types"; import { propagateWebhookTriggerChanges } from "@/lib/webhook/update-webhook"; diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index bf373b69bb1..9f3a44b042b 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -1,5 +1,5 @@ import { storage } from "@/lib/storage"; -import { disableStripePromotionCode } from "@/lib/stripe/disable-promotion-code"; +import { disableStripePromotionCode } from "@/lib/stripe/disable-stripe-promotion-code"; import { recordLinkTB, transformLinkTB } from "@/lib/tinybird"; import { prisma } from "@dub/prisma"; import { R2_URL } from "@dub/utils"; diff --git a/apps/web/lib/stripe/create-promotion-code.ts b/apps/web/lib/stripe/create-stripe-promotion-code.ts similarity index 100% rename from apps/web/lib/stripe/create-promotion-code.ts rename to apps/web/lib/stripe/create-stripe-promotion-code.ts diff --git a/apps/web/lib/stripe/disable-promotion-code.ts b/apps/web/lib/stripe/disable-stripe-promotion-code.ts similarity index 100% rename from apps/web/lib/stripe/disable-promotion-code.ts rename to apps/web/lib/stripe/disable-stripe-promotion-code.ts From 1545e7f9a4fa1d56248c6f9f72ae36b3928358e7 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 7 Aug 2025 17:51:20 +0530 Subject: [PATCH 038/221] Update route.ts --- apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 7c56e649736..4c1696cb3ab 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -16,7 +16,6 @@ const schema = z.object({ const PAGE_LIMIT = 20; const MAX_BATCHES = 5; -// This route is used to create promotion codes for each link for link-based coupon codes tracking. // POST /api/cron/links/create-promotion-codes export async function POST(req: Request) { let discountId: string | undefined; From 747c734220c826b70a58c891954c43259b3187c9 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 7 Aug 2025 17:53:40 +0530 Subject: [PATCH 039/221] Update route.ts --- .../api/cron/links/create-promotion-codes/route.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 4c1696cb3ab..c6ec122cf9b 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -18,16 +18,14 @@ const MAX_BATCHES = 5; // POST /api/cron/links/create-promotion-codes export async function POST(req: Request) { - let discountId: string | undefined; + let parsedBody: z.infer | undefined; try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); - const parsedBody = schema.parse(JSON.parse(rawBody)); - - const { page } = parsedBody; - discountId = parsedBody.discountId; + parsedBody = schema.parse(JSON.parse(rawBody)); + const { discountId, page } = parsedBody; const { couponId, @@ -159,8 +157,10 @@ export async function POST(req: Request) { return new Response("OK"); } catch (error) { + console.error(parsedBody); + await log({ - message: `Error creating Stripe promotion codes for discount ${discountId}: ${error.message}`, + message: `Error creating Stripe promotion codes: ${error.message} - ${JSON.stringify(parsedBody)}`, type: "errors", }); From f83ad9f8dcac68783138a9483588d16505f33a5f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 7 Aug 2025 18:24:54 +0530 Subject: [PATCH 040/221] update the link with new coupon code --- .../links/create-promotion-codes/route.ts | 5 +- apps/web/lib/api/links/create-link.ts | 3 +- .../stripe/create-stripe-promotion-code.ts | 75 ++++++++++++++----- 3 files changed, 62 insertions(+), 21 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index c6ec122cf9b..8f55c57fad6 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -105,6 +105,7 @@ export async function POST(req: Request) { }, }, select: { + id: true, key: true, }, }); @@ -119,9 +120,9 @@ export async function POST(req: Request) { for (const linksChunk of linksChunks) { const results = await Promise.allSettled( - linksChunk.map(({ key }) => + linksChunk.map((link) => createStripePromotionCode({ - code: key, + link, couponId, stripeConnectId, }), diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts index 0b2021cbd66..5a8060c0538 100644 --- a/apps/web/lib/api/links/create-link.ts +++ b/apps/web/lib/api/links/create-link.ts @@ -147,7 +147,6 @@ export async function createLink(link: CreateLinkProps) { }, }); - // TODO: // Move to waitUntil if (response.programId && response.partnerId && !program && !discount) { @@ -254,7 +253,7 @@ export async function createLink(link: CreateLinkProps) { shouldCreateCouponCode && stripeConnectId && createStripePromotionCode({ - code: response.key, + link: response, couponId: discount?.couponId!, stripeConnectId, }), diff --git a/apps/web/lib/stripe/create-stripe-promotion-code.ts b/apps/web/lib/stripe/create-stripe-promotion-code.ts index beb6ef9e71f..21e83776bbc 100644 --- a/apps/web/lib/stripe/create-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/create-stripe-promotion-code.ts @@ -1,16 +1,20 @@ +import { prisma } from "@dub/prisma"; import { stripeAppClient } from "."; +import { LinkProps } from "../types"; const stripe = stripeAppClient({ ...(process.env.VERCEL_ENV && { livemode: true }), }); +const MAX_RETRIES = 2; + // Create a promotion code on Stripe for connected accounts export async function createStripePromotionCode({ - code, + link, couponId, stripeConnectId, }: { - code: string; + link: Pick; couponId: string | null; stripeConnectId: string | null; }) { @@ -28,21 +32,58 @@ export async function createStripePromotionCode({ return; } - try { - return await stripe.promotionCodes.create( - { - coupon: couponId, - code: code.toUpperCase(), - }, - { - stripeAccount: stripeConnectId, - }, - ); - } catch (error) { - console.error( - `Failed to create Stripe promotion code for ${stripeConnectId}: ${error}`, - ); + let lastError: Error | null = null; + let couponCode: string | undefined; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + // Add DUB_ prefix after first retry + couponCode = attempt === 1 ? link.key : `DUB_${link.key}`; + + const promotionCode = await stripe.promotionCodes.create( + { + coupon: couponId, + code: couponCode.toUpperCase(), + }, + { + stripeAccount: stripeConnectId, + }, + ); - throw new Error(error instanceof Error ? error.message : "Unknown error"); + if (promotionCode) { + await prisma.link.update({ + where: { + id: link.id, + }, + data: { + couponCode: promotionCode.code, + }, + }); + } + + return promotionCode; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + const isDuplicateError = + error instanceof Error && + error.message.includes("An active promotion code with `code:") && + error.message.includes("already exists"); + + if (isDuplicateError) { + if (attempt === MAX_RETRIES) { + throw lastError; + } + + continue; + } + + throw lastError; + } } + + throw ( + lastError || + new Error("Unknown error occurred while creating promotion code on Stripe.") + ); } From 4d814f03204e9ab6f1d31788992249c478af8140 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 7 Aug 2025 19:24:48 +0530 Subject: [PATCH 041/221] handle the coupon code management in link update --- .../links/create-promotion-codes/route.ts | 6 +- .../embed/referrals/links/[linkId]/route.ts | 1 + .../[programId]/links/[linkId]/route.ts | 1 + .../(ee)/api/partners/links/upsert/route.ts | 1 + apps/web/app/api/links/[linkId]/route.ts | 1 + apps/web/app/api/links/upsert/route.ts | 1 + apps/web/lib/api/links/bulk-delete-links.ts | 4 +- apps/web/lib/api/links/create-link.ts | 2 +- apps/web/lib/api/links/delete-link.ts | 4 +- apps/web/lib/api/links/update-link.ts | 66 +++++++++++++++++++ .../stripe/create-stripe-promotion-code.ts | 10 +-- .../stripe/disable-stripe-promotion-code.ts | 22 +++---- 12 files changed, 93 insertions(+), 26 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 8f55c57fad6..6528f3f744e 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -58,7 +58,7 @@ export async function POST(req: Request) { ); } - const { stripeConnectId } = await prisma.project.findUniqueOrThrow({ + const workspace = await prisma.project.findUniqueOrThrow({ where: { defaultProgramId: programId, }, @@ -67,7 +67,7 @@ export async function POST(req: Request) { }, }); - if (!stripeConnectId) { + if (!workspace.stripeConnectId) { return new Response("stripeConnectId doesn't exist for the workspace."); } @@ -122,9 +122,9 @@ export async function POST(req: Request) { const results = await Promise.allSettled( linksChunk.map((link) => createStripePromotionCode({ + workspace, link, couponId, - stripeConnectId, }), ), ); diff --git a/apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts b/apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts index 73389cdf208..5b1c2f59269 100644 --- a/apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts +++ b/apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts @@ -77,6 +77,7 @@ export const PATCH = withReferralsEmbedToken( domain: link.domain, key: link.key, image: link.image, + couponCode: link.couponCode, }, updatedLink: processedLink, }); 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 ec90b2a1d16..28cda05d7b1 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 @@ -102,6 +102,7 @@ export const PATCH = withPartnerProfile( domain: link.domain, key: link.key, image: link.image, + couponCode: link.couponCode, }, updatedLink: processedLink, }); diff --git a/apps/web/app/(ee)/api/partners/links/upsert/route.ts b/apps/web/app/(ee)/api/partners/links/upsert/route.ts index 399162012f1..f6fdfba1d33 100644 --- a/apps/web/app/(ee)/api/partners/links/upsert/route.ts +++ b/apps/web/app/(ee)/api/partners/links/upsert/route.ts @@ -155,6 +155,7 @@ export const PUT = withWorkspace( domain: link.domain, key: link.key, image: link.image, + couponCode: link.couponCode, }, updatedLink: processedLink, }); diff --git a/apps/web/app/api/links/[linkId]/route.ts b/apps/web/app/api/links/[linkId]/route.ts index 3b97169cd95..b3b3a317681 100644 --- a/apps/web/app/api/links/[linkId]/route.ts +++ b/apps/web/app/api/links/[linkId]/route.ts @@ -179,6 +179,7 @@ export const PATCH = withWorkspace( domain: link.domain, key: link.key, image: link.image, + couponCode: link.couponCode, }, updatedLink: processedLink, }); diff --git a/apps/web/app/api/links/upsert/route.ts b/apps/web/app/api/links/upsert/route.ts index 5789fb1d6d1..9f9fa9df1a1 100644 --- a/apps/web/app/api/links/upsert/route.ts +++ b/apps/web/app/api/links/upsert/route.ts @@ -149,6 +149,7 @@ export const PUT = withWorkspace( domain: link.domain, key: link.key, image: link.image, + couponCode: link.couponCode, }, updatedLink: processedLink, }); diff --git a/apps/web/lib/api/links/bulk-delete-links.ts b/apps/web/lib/api/links/bulk-delete-links.ts index 640fe670e1b..7c2686bbeba 100644 --- a/apps/web/lib/api/links/bulk-delete-links.ts +++ b/apps/web/lib/api/links/bulk-delete-links.ts @@ -49,8 +49,8 @@ export async function bulkDeleteLinks({ .filter((link) => link.couponCode) .map((link) => disableStripePromotionCode({ - code: link.couponCode, - stripeConnectId: workspace.stripeConnectId, + link, + workspace, }), ), ]); diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts index 5a8060c0538..98e79b57022 100644 --- a/apps/web/lib/api/links/create-link.ts +++ b/apps/web/lib/api/links/create-link.ts @@ -253,9 +253,9 @@ export async function createLink(link: CreateLinkProps) { shouldCreateCouponCode && stripeConnectId && createStripePromotionCode({ + workspace: { stripeConnectId }, link: response, couponId: discount?.couponId!, - stripeConnectId, }), ]), ); diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index 9f3a44b042b..1f1c48ea8a1 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -52,8 +52,8 @@ export async function deleteLink(linkId: string) { link.couponCode && link.project && disableStripePromotionCode({ - code: link.couponCode, - stripeConnectId: link.project.stripeConnectId, + link, + workspace: link.project, }), ]), ); diff --git a/apps/web/lib/api/links/update-link.ts b/apps/web/lib/api/links/update-link.ts index 90e0e681234..2e222046e69 100644 --- a/apps/web/lib/api/links/update-link.ts +++ b/apps/web/lib/api/links/update-link.ts @@ -1,5 +1,7 @@ import { getPartnerAndDiscount } from "@/lib/planetscale/get-partner-discount"; import { isNotHostedImage, storage } from "@/lib/storage"; +import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; +import { disableStripePromotionCode } from "@/lib/stripe/disable-stripe-promotion-code"; import { recordLink } from "@/lib/tinybird"; import { LinkProps, ProcessedLinkProps } from "@/lib/types"; import { propagateWebhookTriggerChanges } from "@/lib/webhook/update-webhook"; @@ -30,6 +32,7 @@ export async function updateLink({ key: string; image?: string | null; testCompletedAt?: Date | null; + couponCode: string | null; }; updatedLink: ProcessedLinkProps & Pick; @@ -208,8 +211,71 @@ export async function updateLink({ testVariants && testCompletedAt && scheduleABTestCompletion(response), + + changedKey && + updateStripePromotionCode({ + oldLink, + updatedLink: response, + }), ]), ); return transformLink(response); } + +export async function updateStripePromotionCode({ + oldLink, + updatedLink, +}: { + oldLink: Pick; + updatedLink: Pick; +}) { + if (!updatedLink.partnerId || !updatedLink.programId) { + return; + } + + const { program, discount } = + await prisma.programEnrollment.findUniqueOrThrow({ + where: { + partnerId_programId: { + partnerId: updatedLink.partnerId, + programId: updatedLink.programId, + }, + }, + select: { + program: { + select: { + couponCodeTrackingEnabledAt: true, + workspace: { + select: { + stripeConnectId: true, + }, + }, + }, + }, + discount: { + select: { + couponId: true, + }, + }, + }, + }); + + const { couponCodeTrackingEnabledAt, workspace } = program; + + await Promise.allSettled([ + oldLink.couponCode && + disableStripePromotionCode({ + workspace, + link: oldLink, + }), + + couponCodeTrackingEnabledAt && + discount?.couponId && + createStripePromotionCode({ + workspace, + link: updatedLink, + couponId: discount.couponId, + }), + ]); +} diff --git a/apps/web/lib/stripe/create-stripe-promotion-code.ts b/apps/web/lib/stripe/create-stripe-promotion-code.ts index 21e83776bbc..dde8a4b867e 100644 --- a/apps/web/lib/stripe/create-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/create-stripe-promotion-code.ts @@ -1,6 +1,6 @@ import { prisma } from "@dub/prisma"; import { stripeAppClient } from "."; -import { LinkProps } from "../types"; +import { LinkProps, WorkspaceProps } from "../types"; const stripe = stripeAppClient({ ...(process.env.VERCEL_ENV && { livemode: true }), @@ -10,13 +10,13 @@ const MAX_RETRIES = 2; // Create a promotion code on Stripe for connected accounts export async function createStripePromotionCode({ + workspace, link, couponId, - stripeConnectId, }: { + workspace: Pick; link: Pick; couponId: string | null; - stripeConnectId: string | null; }) { if (!couponId) { console.error( @@ -25,7 +25,7 @@ export async function createStripePromotionCode({ return; } - if (!stripeConnectId) { + if (!workspace.stripeConnectId) { console.error( "stripeConnectId not found for the workspace. Stripe promotion code creation skipped.", ); @@ -46,7 +46,7 @@ export async function createStripePromotionCode({ code: couponCode.toUpperCase(), }, { - stripeAccount: stripeConnectId, + stripeAccount: workspace.stripeConnectId, }, ); diff --git a/apps/web/lib/stripe/disable-stripe-promotion-code.ts b/apps/web/lib/stripe/disable-stripe-promotion-code.ts index 34ff734f4da..572abc5b7dc 100644 --- a/apps/web/lib/stripe/disable-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/disable-stripe-promotion-code.ts @@ -1,27 +1,23 @@ import { stripeAppClient } from "."; +import { LinkProps, WorkspaceProps } from "../types"; const stripe = stripeAppClient({ ...(process.env.VERCEL_ENV && { livemode: true }), }); -// Disable a promotion code on Stripe for connected accounts export async function disableStripePromotionCode({ - code, - stripeConnectId, + workspace, + link, }: { - code: string | null; - stripeConnectId: string | null; + workspace: Pick; + link: Pick; }) { - if (!code) { - return; - } - - if (!stripeConnectId) { + if (!link.couponCode || !workspace.stripeConnectId) { return; } const promotionCodes = await stripe.promotionCodes.list({ - code, + code: link.couponCode, limit: 1, }); @@ -38,12 +34,12 @@ export async function disableStripePromotionCode({ active: false, }, { - stripeAccount: stripeConnectId, + stripeAccount: workspace.stripeConnectId, }, ); } catch (error) { console.error( - `Failed to disable Stripe promotion code ${code} for ${stripeConnectId}: ${error}`, + `Failed to disable Stripe promotion code ${link.couponCode} for ${workspace.stripeConnectId}: ${error}`, ); throw new Error(error instanceof Error ? error.message : "Unknown error"); From 073c64d243e97980e8866de4049d478a035e7acd Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 12:43:56 +0530 Subject: [PATCH 042/221] Update route.ts --- .../links/create-promotion-codes/route.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 6528f3f744e..3ef8c1f4a47 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -30,7 +30,7 @@ export async function POST(req: Request) { const { couponId, programId, - program: { couponCodeTrackingEnabledAt }, + program: { couponCodeTrackingEnabledAt, workspace }, } = await prisma.discount.findUniqueOrThrow({ where: { id: discountId, @@ -41,6 +41,11 @@ export async function POST(req: Request) { program: { select: { couponCodeTrackingEnabledAt: true, + workspace: { + select: { + stripeConnectId: true, + }, + }, }, }, }, @@ -58,19 +63,6 @@ export async function POST(req: Request) { ); } - const workspace = await prisma.project.findUniqueOrThrow({ - where: { - defaultProgramId: programId, - }, - select: { - stripeConnectId: true, - }, - }); - - if (!workspace.stripeConnectId) { - return new Response("stripeConnectId doesn't exist for the workspace."); - } - let hasMore = true; let currentPage = page; let processedBatches = 0; From d06627579c5e7c9f4bb5fa2761c59fdca5402ee4 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 12:45:38 +0530 Subject: [PATCH 043/221] rename --- apps/web/lib/actions/partners/create-discount.ts | 2 +- apps/web/lib/api/partners/delete-discount.ts | 2 +- .../lib/stripe/{create-coupon.ts => create-stripe-coupon.ts} | 0 .../lib/stripe/{delete-coupon.ts => delete-stripe-coupon.ts} | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename apps/web/lib/stripe/{create-coupon.ts => create-stripe-coupon.ts} (100%) rename apps/web/lib/stripe/{delete-coupon.ts => delete-stripe-coupon.ts} (100%) diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 784b0859c81..a74677c567e 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -4,7 +4,7 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { createId } from "@/lib/api/create-id"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { qstash } from "@/lib/cron"; -import { createStripeCoupon } from "@/lib/stripe/create-coupon"; +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"; diff --git a/apps/web/lib/api/partners/delete-discount.ts b/apps/web/lib/api/partners/delete-discount.ts index d99c620f25c..379002699e5 100644 --- a/apps/web/lib/api/partners/delete-discount.ts +++ b/apps/web/lib/api/partners/delete-discount.ts @@ -1,5 +1,5 @@ import { qstash } from "@/lib/cron"; -import { deleteStripeCoupon } from "@/lib/stripe/delete-coupon"; +import { deleteStripeCoupon } from "@/lib/stripe/delete-stripe-coupon"; import { redis } from "@/lib/upstash"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; diff --git a/apps/web/lib/stripe/create-coupon.ts b/apps/web/lib/stripe/create-stripe-coupon.ts similarity index 100% rename from apps/web/lib/stripe/create-coupon.ts rename to apps/web/lib/stripe/create-stripe-coupon.ts diff --git a/apps/web/lib/stripe/delete-coupon.ts b/apps/web/lib/stripe/delete-stripe-coupon.ts similarity index 100% rename from apps/web/lib/stripe/delete-coupon.ts rename to apps/web/lib/stripe/delete-stripe-coupon.ts From a14a75061afafe50d0ab190ae8fc590140f96a8d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 12:53:10 +0530 Subject: [PATCH 044/221] Update coupon-deleted.ts --- .../(ee)/api/stripe/integration/webhook/coupon-deleted.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 index cdc649be271..b320aac2d1c 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts @@ -30,10 +30,12 @@ export async function couponDeleted(event: Stripe.Event) { return `Workspace ${workspace.id} for stripe account ${stripeAccountId} has no programs.`; } - const discount = await prisma.discount.findFirst({ + const discount = await prisma.discount.findUnique({ where: { - programId: workspace.defaultProgramId, - couponId: coupon.id, + programId_couponId: { + programId: workspace.defaultProgramId, + couponId: coupon.id, + }, }, }); From d91e9e351ecdbe6a7592b5ce865d56353ccf8c93 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 12:53:12 +0530 Subject: [PATCH 045/221] Update discount.prisma --- packages/prisma/schema/discount.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/prisma/schema/discount.prisma b/packages/prisma/schema/discount.prisma index b1e1cc35415..975b7f5d44e 100644 --- a/packages/prisma/schema/discount.prisma +++ b/packages/prisma/schema/discount.prisma @@ -14,5 +14,5 @@ model Discount { programEnrollments ProgramEnrollment[] program Program @relation("ProgramDiscounts", fields: [programId], references: [id], onDelete: Cascade, onUpdate: Cascade) - @@index(programId) + @@unique([programId, couponId]) } From 1ce2c45f529aff7fc3305502f4b0f91ad406bac8 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 12:57:54 +0530 Subject: [PATCH 046/221] revert some changes (will do this in separate PR) --- .../lib/actions/partners/create-commission.ts | 47 +++++++++++++++---- apps/web/lib/partnerstack/import-customers.ts | 37 +++++++++++---- apps/web/lib/rewardful/import-customers.ts | 32 +++++++++++-- apps/web/lib/tolt/import-customers.ts | 44 ++++++++++++----- 4 files changed, 127 insertions(+), 33 deletions(-) diff --git a/apps/web/lib/actions/partners/create-commission.ts b/apps/web/lib/actions/partners/create-commission.ts index f1c918f421b..a220254df37 100644 --- a/apps/web/lib/actions/partners/create-commission.ts +++ b/apps/web/lib/actions/partners/create-commission.ts @@ -5,10 +5,11 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-progr import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; import { getLeadEvent } from "@/lib/tinybird"; -import { recordFakeClick } from "@/lib/tinybird/record-fake-click"; +import { recordClick } from "@/lib/tinybird/record-click"; import { recordLeadWithTimestamp } from "@/lib/tinybird/record-lead"; import { recordSaleWithTimestamp } from "@/lib/tinybird/record-sale"; import { ClickEventTB, LeadEventTB } from "@/lib/types"; +import { clickEventSchemaTB } from "@/lib/zod/schemas/clicks"; import { createCommissionSchema } from "@/lib/zod/schemas/commissions"; import { leadEventSchemaTB } from "@/lib/zod/schemas/leads"; import { prisma } from "@dub/prisma"; @@ -126,20 +127,46 @@ export const createCommissionAction = authActionClient } else { // else, if there's no existing lead event and there is also no custom leadEventName/Date // we need to create a dummy click + lead event (using the customer's country if available) + 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", + ...(customer.country + ? { + "x-vercel-ip-country": customer.country.toUpperCase(), + "x-vercel-ip-continent": + COUNTRIES_TO_CONTINENTS[customer.country.toUpperCase()], + } + : { + "x-vercel-ip-country": "US", + "x-vercel-ip-country-region": "CA", + "x-vercel-ip-continent": "NA", + }), + }), + }); const finalLeadEventDate = leadEventDate ?? saleEventDate ?? new Date(); - clickEvent = await recordFakeClick({ - link, - timestamp: new Date(finalLeadEventDate).getTime() - 5 * 60 * 1000, - ...(customer.country && { - country: customer.country, - continent: COUNTRIES_TO_CONTINENTS[customer.country.toUpperCase()], - }), + const clickData = await recordClick({ + req: dummyRequest, + linkId, + clickId: nanoid(16), + url: link.url, + domain: link.domain, + key: link.key, + workspaceId: workspace.id, + skipRatelimit: true, + timestamp: new Date( + new Date(finalLeadEventDate).getTime() - 5 * 60 * 1000, + ).toISOString(), }); + clickEvent = clickEventSchemaTB.parse({ + ...clickData, + bot: 0, + qr: 0, + }); const leadEventId = nanoid(16); - leadEvent = leadEventSchemaTB.parse({ ...clickEvent, event_id: leadEventId, @@ -147,7 +174,7 @@ export const createCommissionAction = authActionClient customer_id: customerId, }); - shouldUpdateCustomer = !customer.linkId && clickEvent ? true : false; + shouldUpdateCustomer = !customer.linkId && clickData ? true : false; await Promise.allSettled([ recordLeadWithTimestamp({ diff --git a/apps/web/lib/partnerstack/import-customers.ts b/apps/web/lib/partnerstack/import-customers.ts index 8ca03ab9173..165c7d8372c 100644 --- a/apps/web/lib/partnerstack/import-customers.ts +++ b/apps/web/lib/partnerstack/import-customers.ts @@ -2,11 +2,10 @@ import { prisma } from "@dub/prisma"; import { nanoid } from "@dub/utils"; import { Link, Project } from "@prisma/client"; import { createId } from "../api/create-id"; - -import { recordLeadWithTimestamp } from "../tinybird"; +import { recordClick, recordLeadWithTimestamp } from "../tinybird"; import { logImportError } from "../tinybird/log-import-error"; -import { recordFakeClick } from "../tinybird/record-fake-click"; import { redis } from "../upstash"; +import { clickEventSchemaTB } from "../zod/schemas/clicks"; import { PartnerStackApi } from "./api"; import { MAX_BATCHES, @@ -92,7 +91,6 @@ export async function importCustomers(payload: PartnerStackImportPayload) { key: true, domain: true, url: true, - projectId: true, }, }, }, @@ -147,7 +145,7 @@ async function createCustomer({ importId, }: { workspace: Pick; - links: Pick[]; + links: Pick[]; customer: PartnerStackCustomer; importId: string; }) { @@ -194,9 +192,32 @@ async function createCustomer({ const link = links[0]; - const clickEvent = await recordFakeClick({ - link, - timestamp: customer.created_at, + 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": "US", + "x-vercel-ip-country-region": "CA", + "x-vercel-ip-continent": "NA", + }), + }); + + const clickData = await recordClick({ + req: dummyRequest, + linkId: link.id, + clickId: nanoid(16), + url: link.url, + domain: link.domain, + key: link.key, + workspaceId: workspace.id, + skipRatelimit: true, + timestamp: new Date(customer.created_at).toISOString(), + }); + + const clickEvent = clickEventSchemaTB.parse({ + ...clickData, + bot: 0, + qr: 0, }); const customerId = createId({ prefix: "cus_" }); diff --git a/apps/web/lib/rewardful/import-customers.ts b/apps/web/lib/rewardful/import-customers.ts index 52d29c1760c..02e2bc7bdc0 100644 --- a/apps/web/lib/rewardful/import-customers.ts +++ b/apps/web/lib/rewardful/import-customers.ts @@ -3,8 +3,9 @@ import { nanoid } from "@dub/utils"; import { Program, Project } from "@prisma/client"; import { createId } from "../api/create-id"; import { logImportError } from "../tinybird/log-import-error"; -import { recordFakeClick } from "../tinybird/record-fake-click"; +import { recordClick } from "../tinybird/record-click"; import { recordLeadWithTimestamp } from "../tinybird/record-lead"; +import { clickEventSchemaTB } from "../zod/schemas/clicks"; import { RewardfulApi } from "./api"; import { MAX_BATCHES, rewardfulImporter } from "./importer"; import { RewardfulImportPayload, RewardfulReferral } from "./types"; @@ -157,9 +158,32 @@ async function createCustomer({ return; } - const clickEvent = await recordFakeClick({ - link, - timestamp: referral.created_at, + 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": "US", + "x-vercel-ip-country-region": "CA", + "x-vercel-ip-continent": "NA", + }), + }); + + const clickData = await recordClick({ + req: dummyRequest, + linkId: link.id, + clickId: nanoid(16), + url: link.url, + domain: link.domain, + key: link.key, + workspaceId: workspace.id, + skipRatelimit: true, + timestamp: new Date(referral.created_at).toISOString(), + }); + + const clickEvent = clickEventSchemaTB.parse({ + ...clickData, + bot: 0, + qr: 0, }); const customerId = createId({ prefix: "cus_" }); diff --git a/apps/web/lib/tolt/import-customers.ts b/apps/web/lib/tolt/import-customers.ts index e9ffcc05f61..40cc54387e0 100644 --- a/apps/web/lib/tolt/import-customers.ts +++ b/apps/web/lib/tolt/import-customers.ts @@ -2,13 +2,12 @@ import { prisma } from "@dub/prisma"; import { nanoid } from "@dub/utils"; import { Link, Project } from "@prisma/client"; import { createId } from "../api/create-id"; - -import { recordLeadWithTimestamp } from "../tinybird"; +import { recordClick, recordLeadWithTimestamp } from "../tinybird"; import { logImportError } from "../tinybird/log-import-error"; -import { recordFakeClick } from "../tinybird/record-fake-click"; +import { clickEventSchemaTB } from "../zod/schemas/clicks"; import { ToltApi } from "./api"; import { MAX_BATCHES, toltImporter } from "./importer"; -import { ToltCustomer, ToltImportPayload } from "./types"; +import { ToltAffiliate, ToltCustomer, ToltImportPayload } from "./types"; export async function importCustomers(payload: ToltImportPayload) { let { importId, programId, toltProgramId, startingAfter } = payload; @@ -71,7 +70,6 @@ export async function importCustomers(payload: ToltImportPayload) { key: true, domain: true, url: true, - projectId: true, }, }, }, @@ -95,7 +93,7 @@ export async function importCustomers(payload: ToltImportPayload) { createReferral({ workspace, customer, - + partner, links: partnerEmailToLinks.get(partner.email) ?? [], importId, }), @@ -120,12 +118,13 @@ async function createReferral({ customer, workspace, links, + partner, importId, }: { customer: Omit; - + partner: ToltAffiliate; workspace: Pick; - links: Pick[]; + links: Pick[]; importId: string; }) { const commonImportLogInputs = { @@ -164,9 +163,32 @@ async function createReferral({ const link = links[0]; - const clickEvent = await recordFakeClick({ - link, - timestamp: customer.created_at, + 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": "US", + "x-vercel-ip-country-region": "CA", + "x-vercel-ip-continent": "NA", + }), + }); + + const clickData = await recordClick({ + req: dummyRequest, + linkId: link.id, + clickId: nanoid(16), + url: link.url, + domain: link.domain, + key: link.key, + workspaceId: workspace.id, + skipRatelimit: true, + timestamp: new Date(customer.created_at).toISOString(), + }); + + const clickEvent = clickEventSchemaTB.parse({ + ...clickData, + bot: 0, + qr: 0, }); const customerId = createId({ prefix: "cus_" }); From 931a23f1c462ccd3b329a3b67fe600baedfeba94 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 13:05:22 +0530 Subject: [PATCH 047/221] Refactor discount creation to include program data --- apps/web/lib/actions/partners/create-discount.ts | 14 ++++---------- apps/web/lib/tinybird/record-fake-click.ts | 2 ++ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index a74677c567e..6047fdcecec 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -112,6 +112,9 @@ export const createDiscountAction = authActionClient couponTestId, default: isDefault, }, + include: { + program: true, + }, }); await prisma.programEnrollment.updateMany({ @@ -139,15 +142,6 @@ export const createDiscountAction = authActionClient waitUntil( (async () => { - const program = await prisma.program.findUniqueOrThrow({ - where: { - id: programId, - }, - select: { - couponCodeTrackingEnabledAt: true, - }, - }); - await Promise.allSettled([ qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, @@ -172,7 +166,7 @@ export const createDiscountAction = authActionClient ], }), - program.couponCodeTrackingEnabledAt && + discount.program.couponCodeTrackingEnabledAt && qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, body: { diff --git a/apps/web/lib/tinybird/record-fake-click.ts b/apps/web/lib/tinybird/record-fake-click.ts index 8e2613c1929..1b67897e1cf 100644 --- a/apps/web/lib/tinybird/record-fake-click.ts +++ b/apps/web/lib/tinybird/record-fake-click.ts @@ -3,6 +3,8 @@ 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, From 4a567972b0b05eb251031e692b9173edc7cce66b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 13:12:23 +0530 Subject: [PATCH 048/221] Refactor disableStripePromotionCode to use explicit params --- apps/web/lib/api/links/bulk-delete-links.ts | 4 ++-- apps/web/lib/api/links/delete-link.ts | 4 ++-- apps/web/lib/api/links/update-link.ts | 4 ++-- apps/web/lib/api/partners/delete-discount.ts | 2 ++ .../lib/stripe/disable-stripe-promotion-code.ts | 17 ++++++++--------- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/web/lib/api/links/bulk-delete-links.ts b/apps/web/lib/api/links/bulk-delete-links.ts index 7c2686bbeba..775dbf935b9 100644 --- a/apps/web/lib/api/links/bulk-delete-links.ts +++ b/apps/web/lib/api/links/bulk-delete-links.ts @@ -49,8 +49,8 @@ export async function bulkDeleteLinks({ .filter((link) => link.couponCode) .map((link) => disableStripePromotionCode({ - link, - workspace, + couponCode: link.couponCode, + stripeConnectId: workspace.stripeConnectId, }), ), ]); diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index 1f1c48ea8a1..8fe8b00d5d7 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -52,8 +52,8 @@ export async function deleteLink(linkId: string) { link.couponCode && link.project && disableStripePromotionCode({ - link, - workspace: link.project, + couponCode: link.couponCode, + stripeConnectId: link.project.stripeConnectId, }), ]), ); diff --git a/apps/web/lib/api/links/update-link.ts b/apps/web/lib/api/links/update-link.ts index 2e222046e69..060e835f0e6 100644 --- a/apps/web/lib/api/links/update-link.ts +++ b/apps/web/lib/api/links/update-link.ts @@ -266,8 +266,8 @@ export async function updateStripePromotionCode({ await Promise.allSettled([ oldLink.couponCode && disableStripePromotionCode({ - workspace, - link: oldLink, + couponCode: oldLink.couponCode, + stripeConnectId: workspace.stripeConnectId, }), couponCodeTrackingEnabledAt && diff --git a/apps/web/lib/api/partners/delete-discount.ts b/apps/web/lib/api/partners/delete-discount.ts index 379002699e5..b9686964b19 100644 --- a/apps/web/lib/api/partners/delete-discount.ts +++ b/apps/web/lib/api/partners/delete-discount.ts @@ -115,6 +115,8 @@ export async function deleteDiscount({ ], }), + // Question: + // Would this be a problem if the coupon is used in their application? discount.couponId && deleteStripeCoupon({ couponId: discount.couponId, diff --git a/apps/web/lib/stripe/disable-stripe-promotion-code.ts b/apps/web/lib/stripe/disable-stripe-promotion-code.ts index 572abc5b7dc..457cfabf3bf 100644 --- a/apps/web/lib/stripe/disable-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/disable-stripe-promotion-code.ts @@ -1,23 +1,22 @@ import { stripeAppClient } from "."; -import { LinkProps, WorkspaceProps } from "../types"; const stripe = stripeAppClient({ ...(process.env.VERCEL_ENV && { livemode: true }), }); export async function disableStripePromotionCode({ - workspace, - link, + couponCode, + stripeConnectId, }: { - workspace: Pick; - link: Pick; + couponCode: string | null; + stripeConnectId: string | null; }) { - if (!link.couponCode || !workspace.stripeConnectId) { + if (!couponCode || !stripeConnectId) { return; } const promotionCodes = await stripe.promotionCodes.list({ - code: link.couponCode, + code: couponCode, limit: 1, }); @@ -34,12 +33,12 @@ export async function disableStripePromotionCode({ active: false, }, { - stripeAccount: workspace.stripeConnectId, + stripeAccount: stripeConnectId, }, ); } catch (error) { console.error( - `Failed to disable Stripe promotion code ${link.couponCode} for ${workspace.stripeConnectId}: ${error}`, + `Failed to disable Stripe promotion code ${couponCode} for ${stripeConnectId}: ${error}`, ); throw new Error(error instanceof Error ? error.message : "Unknown error"); From 3bd7196b21cdfc245878976c662c93e4d464b286 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 13:15:40 +0530 Subject: [PATCH 049/221] Update create-partner-link.ts --- apps/web/lib/api/partners/create-partner-link.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/web/lib/api/partners/create-partner-link.ts b/apps/web/lib/api/partners/create-partner-link.ts index 4590d0e6d12..8863acf673f 100644 --- a/apps/web/lib/api/partners/create-partner-link.ts +++ b/apps/web/lib/api/partners/create-partner-link.ts @@ -15,10 +15,7 @@ import { createLink } from "../links/create-link"; import { processLink } from "../links/process-link"; type PartnerLinkArgs = { - workspace: Pick< - WorkspaceProps, - "id" | "plan" | "webhookEnabled" | "stripeConnectId" - >; + workspace: Pick; program: Pick; partner: Pick< CreatePartnerProps, From 98f520b7354db71b34ee47ccaf949f56b4061980 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 13:50:33 +0530 Subject: [PATCH 050/221] fix build --- .../scripts/partners/delete-partner-profile.ts | 17 ++++++++++++++++- .../partners/delete-partners-for-program.ts | 15 ++++++++++++++- .../partners/delete-program-enrollment.ts | 10 +++++++++- .../web/ui/partners/add-edit-discount-sheet.tsx | 1 - 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/web/scripts/partners/delete-partner-profile.ts b/apps/web/scripts/partners/delete-partner-profile.ts index 2997fb0bc4f..d4d12164c95 100644 --- a/apps/web/scripts/partners/delete-partner-profile.ts +++ b/apps/web/scripts/partners/delete-partner-profile.ts @@ -13,6 +13,13 @@ async function main() { select: { links: true, }, + include: { + program: { + select: { + workspace: true, + }, + }, + }, }, }, }); @@ -25,8 +32,16 @@ async function main() { } const links = programEnrollment.links; + const program = programEnrollment.program; + + const deleteLinkCaches = await bulkDeleteLinks({ + links, + workspace: { + id: program.workspace.id, + stripeConnectId: program.workspace.stripeConnectId, + }, + }); - const deleteLinkCaches = await bulkDeleteLinks(links); console.log("Deleted link caches", deleteLinkCaches); const deleteCustomers = await prisma.customer.deleteMany({ diff --git a/apps/web/scripts/partners/delete-partners-for-program.ts b/apps/web/scripts/partners/delete-partners-for-program.ts index 199bfa4cd79..36de4fa9e8c 100644 --- a/apps/web/scripts/partners/delete-partners-for-program.ts +++ b/apps/web/scripts/partners/delete-partners-for-program.ts @@ -41,7 +41,20 @@ async function main() { console.log("finalPartnersToDelete", finalPartnersToDelete.length); - await bulkDeleteLinks(finalPartnersToDelete.flatMap((p) => p.links)); + const workspace = await prisma.project.findUniqueOrThrow({ + where: { + defaultProgramId: programId, + }, + select: { + id: true, + stripeConnectId: true, + }, + }); + + await bulkDeleteLinks({ + links: finalPartnersToDelete.flatMap((p) => p.links), + workspace, + }); const deleteLinkPrisma = await prisma.link.deleteMany({ where: { diff --git a/apps/web/scripts/partners/delete-program-enrollment.ts b/apps/web/scripts/partners/delete-program-enrollment.ts index 8d0b692f35a..16fa596b193 100644 --- a/apps/web/scripts/partners/delete-program-enrollment.ts +++ b/apps/web/scripts/partners/delete-program-enrollment.ts @@ -12,10 +12,18 @@ async function main() { }, include: { links: true, + program: { + select: { + workspace: true, + }, + }, }, }); - await bulkDeleteLinks(programEnrollment.links); + await bulkDeleteLinks({ + links: programEnrollment.links, + workspace: programEnrollment.program.workspace, + }); const deleteLinkPrisma = await prisma.link.deleteMany({ where: { diff --git a/apps/web/ui/partners/add-edit-discount-sheet.tsx b/apps/web/ui/partners/add-edit-discount-sheet.tsx index 21fbb7eaccd..ba0d7c883f4 100644 --- a/apps/web/ui/partners/add-edit-discount-sheet.tsx +++ b/apps/web/ui/partners/add-edit-discount-sheet.tsx @@ -104,7 +104,6 @@ function DiscountSheetContent({ couponTestId: discount?.couponTestId || "", includedPartnerIds: null, excludedPartnerIds: null, - maxDuration: 0, }, }); From 1404d5baa7e5e9f641cbf7a8d7e5e39beafc8b15 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 14:07:00 +0530 Subject: [PATCH 051/221] address coderabbit feedback --- .../api/cron/links/create-promotion-codes/route.ts | 3 +++ .../cron/links/invalidate-for-discounts/route.ts | 8 +++----- .../stripe/integration/webhook/coupon-deleted.ts | 2 +- apps/web/lib/api/links/bulk-delete-links.ts | 2 +- .../web/lib/stripe/disable-stripe-promotion-code.ts | 13 +++++++++---- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 3ef8c1f4a47..2978f226f0a 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -95,6 +95,7 @@ export async function POST(req: Request) { partnerId: { in: enrollments.map(({ partnerId }) => partnerId), }, + couponCode: null, }, select: { id: true, @@ -104,6 +105,8 @@ export async function POST(req: Request) { if (links.length === 0) { console.log("No more links found."); + currentPage++; + processedBatches++; continue; } 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 0ee44952120..56be60c9208 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 @@ -17,7 +17,7 @@ const schema = z.object({ .boolean() .optional() .describe("Must be passed for discount-deleted action"), - action: z.enum(["discount-created", "discount-updated", "discount-deleted"]), + action: z.enum(["discount-created", "discount-deleted"]), }); // This route is used to invalidate the partnerlink cache when a discount is created/updated/deleted. @@ -30,7 +30,7 @@ export async function POST(req: Request) { const body = schema.parse(JSON.parse(rawBody)); const { programId, discountId, isDefault, action } = body; - if (action === "discount-created" || action === "discount-updated") { + if (action === "discount-created") { const discount = await prisma.discount.findUnique({ where: { id: discountId, @@ -69,9 +69,7 @@ export async function POST(req: Request) { } return new Response(`Invalidated ${total} links.`); - } - - if (action === "discount-deleted") { + } else if (action === "discount-deleted") { let page = 0; let total = 0; const take = 1000; 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 index b320aac2d1c..253207de719 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts @@ -73,7 +73,7 @@ export async function couponDeleted(event: Stripe.Event) { if (workspaceUsers) { const { user } = workspaceUsers; - sendEmail({ + await sendEmail({ subject: `${process.env.NEXT_PUBLIC_APP_NAME}: Discount has been deleted`, email: user.email!, react: DiscountDeleted({ diff --git a/apps/web/lib/api/links/bulk-delete-links.ts b/apps/web/lib/api/links/bulk-delete-links.ts index 775dbf935b9..5bba04e53c7 100644 --- a/apps/web/lib/api/links/bulk-delete-links.ts +++ b/apps/web/lib/api/links/bulk-delete-links.ts @@ -45,7 +45,7 @@ export async function bulkDeleteLinks({ }, }), - links + ...links .filter((link) => link.couponCode) .map((link) => disableStripePromotionCode({ diff --git a/apps/web/lib/stripe/disable-stripe-promotion-code.ts b/apps/web/lib/stripe/disable-stripe-promotion-code.ts index 457cfabf3bf..ab3bf99bd80 100644 --- a/apps/web/lib/stripe/disable-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/disable-stripe-promotion-code.ts @@ -15,10 +15,15 @@ export async function disableStripePromotionCode({ return; } - const promotionCodes = await stripe.promotionCodes.list({ - code: couponCode, - limit: 1, - }); + const promotionCodes = await stripe.promotionCodes.list( + { + code: couponCode, + limit: 1, + }, + { + stripeAccount: stripeConnectId, + }, + ); if (promotionCodes.data.length === 0) { return; From d214f2f35ceac10cc236bf1efafddcebac52c2b6 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 14:11:29 +0530 Subject: [PATCH 052/221] Update bulk-delete-links.ts --- apps/web/lib/api/links/bulk-delete-links.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/web/lib/api/links/bulk-delete-links.ts b/apps/web/lib/api/links/bulk-delete-links.ts index 5bba04e53c7..8157e9e457b 100644 --- a/apps/web/lib/api/links/bulk-delete-links.ts +++ b/apps/web/lib/api/links/bulk-delete-links.ts @@ -45,13 +45,15 @@ export async function bulkDeleteLinks({ }, }), - ...links - .filter((link) => link.couponCode) - .map((link) => - disableStripePromotionCode({ - couponCode: link.couponCode, - stripeConnectId: workspace.stripeConnectId, - }), - ), + workspace.stripeConnectId + ? links + .filter((link) => link.couponCode) + .map((link) => + disableStripePromotionCode({ + couponCode: link.couponCode, + stripeConnectId: workspace.stripeConnectId, + }), + ) + : [], ]); } From 3c9e93a51ff5b26e38004746efb1f45dd553c397 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 14:48:27 +0530 Subject: [PATCH 053/221] Refactor link creation to use workspace and skip coupon option --- .../api/commissions/[commissionId]/route.ts | 44 ++- .../web/app/(ee)/api/cron/import/csv/route.ts | 1 + .../links/create-promotion-codes/route.ts | 2 +- .../(ee)/api/embed/referrals/links/route.ts | 4 + apps/web/app/(ee)/api/partners/links/route.ts | 2 +- .../(ee)/api/partners/links/upsert/route.ts | 2 +- apps/web/app/api/domains/route.ts | 1 + apps/web/app/api/links/route.ts | 5 +- apps/web/app/api/links/upsert/route.ts | 5 +- .../lib/api/domains/claim-dot-link-domain.ts | 1 + apps/web/lib/api/links/create-link.ts | 261 ++++++++++-------- apps/web/lib/api/links/update-link.ts | 2 +- .../lib/api/partners/create-partner-link.ts | 10 +- apps/web/lib/partnerstack/import-links.ts | 5 +- .../stripe/create-stripe-promotion-code.ts | 11 +- apps/web/lib/tolt/import-links.ts | 5 +- apps/web/scripts/bulk-create-domains.ts | 1 + apps/web/scripts/fix-broken-root-domains.ts | 1 + 18 files changed, 212 insertions(+), 151 deletions(-) diff --git a/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts b/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts index 4b4c9944f2f..e42ff7e9f6f 100644 --- a/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts +++ b/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts @@ -157,29 +157,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/import/csv/route.ts b/apps/web/app/(ee)/api/cron/import/csv/route.ts index 1dd62a9af45..c8cd91742e9 100644 --- a/apps/web/app/(ee)/api/cron/import/csv/route.ts +++ b/apps/web/app/(ee)/api/cron/import/csv/route.ts @@ -346,6 +346,7 @@ const processMappedLinks = async ({ key: "_root", url: "", tags: undefined, + skipCouponCreation: true, }), ), ]); diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 2978f226f0a..51d18fa91d7 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -117,9 +117,9 @@ export async function POST(req: Request) { const results = await Promise.allSettled( linksChunk.map((link) => createStripePromotionCode({ - workspace, link, couponId, + stripeConnectId: workspace.stripeConnectId, }), ), ); diff --git a/apps/web/app/(ee)/api/embed/referrals/links/route.ts b/apps/web/app/(ee)/api/embed/referrals/links/route.ts index 9cffdc3083b..5325d9f4e44 100644 --- a/apps/web/app/(ee)/api/embed/referrals/links/route.ts +++ b/apps/web/app/(ee)/api/embed/referrals/links/route.ts @@ -54,6 +54,9 @@ export const POST = withReferralsEmbedToken( orderBy: { createdAt: "desc", }, + include: { + project: true, + }, }); const { link, error, code } = await processLink({ @@ -86,6 +89,7 @@ export const POST = withReferralsEmbedToken( const partnerLink = await createLink({ ...link, + workspace: workspaceOwner?.project, program, discount, }); diff --git a/apps/web/app/(ee)/api/partners/links/route.ts b/apps/web/app/(ee)/api/partners/links/route.ts index 0bb22b3e42b..d375ba44186 100644 --- a/apps/web/app/(ee)/api/partners/links/route.ts +++ b/apps/web/app/(ee)/api/partners/links/route.ts @@ -139,9 +139,9 @@ export const POST = withWorkspace( const partnerLink = await createLink({ ...link, + workspace, program, discount: partner.discount, - stripeConnectId: workspace.stripeConnectId, }); waitUntil( diff --git a/apps/web/app/(ee)/api/partners/links/upsert/route.ts b/apps/web/app/(ee)/api/partners/links/upsert/route.ts index f6fdfba1d33..a9ab68ea650 100644 --- a/apps/web/app/(ee)/api/partners/links/upsert/route.ts +++ b/apps/web/app/(ee)/api/partners/links/upsert/route.ts @@ -205,9 +205,9 @@ export const PUT = withWorkspace( const partnerLink = await createLink({ ...link, + workspace, program, discount: partner.discount, - stripeConnectId: workspace.stripeConnectId, }); waitUntil( diff --git a/apps/web/app/api/domains/route.ts b/apps/web/app/api/domains/route.ts index 69321377c6b..7bcb1d5c236 100644 --- a/apps/web/app/api/domains/route.ts +++ b/apps/web/app/api/domains/route.ts @@ -197,6 +197,7 @@ export const POST = withWorkspace( tags: undefined, userId: session.user.id, projectId: workspace.id, + skipCouponCreation: true, }); return NextResponse.json( diff --git a/apps/web/app/api/links/route.ts b/apps/web/app/api/links/route.ts index 15426b8e364..312841d598a 100644 --- a/apps/web/app/api/links/route.ts +++ b/apps/web/app/api/links/route.ts @@ -123,7 +123,10 @@ export const POST = withWorkspace( } try { - const response = await createLink(link); + const response = await createLink({ + ...link, + workspace, + }); if (response.projectId && response.userId) { waitUntil( diff --git a/apps/web/app/api/links/upsert/route.ts b/apps/web/app/api/links/upsert/route.ts index 9f9fa9df1a1..01dcf699682 100644 --- a/apps/web/app/api/links/upsert/route.ts +++ b/apps/web/app/api/links/upsert/route.ts @@ -187,7 +187,10 @@ export const PUT = withWorkspace( } try { - const response = await createLink(link); + const response = await createLink({ + ...link, + workspace, + }); return NextResponse.json(response, { headers }); } catch (error) { throw new DubApiError({ diff --git a/apps/web/lib/api/domains/claim-dot-link-domain.ts b/apps/web/lib/api/domains/claim-dot-link-domain.ts index 79f889b1d81..882f2ad8c07 100644 --- a/apps/web/lib/api/domains/claim-dot-link-domain.ts +++ b/apps/web/lib/api/domains/claim-dot-link-domain.ts @@ -124,6 +124,7 @@ export async function claimDotLinkDomain({ tags: undefined, userId: userId, projectId: workspace.id, + skipCouponCreation: true, }), ]); diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts index 98e79b57022..5e69a0dbf75 100644 --- a/apps/web/lib/api/links/create-link.ts +++ b/apps/web/lib/api/links/create-link.ts @@ -3,7 +3,12 @@ import { getPartnerAndDiscount } from "@/lib/planetscale/get-partner-discount"; import { isNotHostedImage, storage } from "@/lib/storage"; import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; import { recordLink } from "@/lib/tinybird"; -import { DiscountProps, ProcessedLinkProps, ProgramProps } from "@/lib/types"; +import { + DiscountProps, + ProcessedLinkProps, + ProgramProps, + WorkspaceProps, +} from "@/lib/types"; import { propagateWebhookTriggerChanges } from "@/lib/webhook/update-webhook"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; @@ -25,9 +30,10 @@ import { updateLinksUsage } from "./update-links-usage"; import { transformLink } from "./utils"; type CreateLinkProps = ProcessedLinkProps & { + workspace?: Pick; program?: Pick; discount?: Pick | null; - stripeConnectId?: string | null; + skipCouponCreation?: boolean; // Skip Stripe promotion code creation for the link }; export async function createLink(link: CreateLinkProps) { @@ -44,7 +50,7 @@ export async function createLink(link: CreateLinkProps) { testVariants, testStartedAt, testCompletedAt, - stripeConnectId, + skipCouponCreation, } = link; const combinedTagIds = combineTagIds(link); @@ -52,18 +58,22 @@ export async function createLink(link: CreateLinkProps) { const { utm_source, utm_medium, utm_campaign, utm_term, utm_content } = getParamsFromURL(url); - let { tagId, tagIds, tagNames, webhookIds, program, discount, ...rest } = - link; + let { + tagId, + tagIds, + tagNames, + webhookIds, + workspace, + program, + discount, + ...rest + } = link; key = encodeKeyIfCaseSensitive({ domain: link.domain, key, }); - let shouldCreateCouponCode = Boolean( - program?.couponCodeTrackingEnabledAt && discount?.couponId, - ); - const response = await prisma.link.create({ data: { ...rest, @@ -143,125 +153,152 @@ export async function createLink(link: CreateLinkProps) { include: { ...includeTags, webhooks: webhookIds ? true : false, - project: shouldCreateCouponCode && stripeConnectId === undefined, }, }); - // TODO: - // Move to waitUntil - if (response.programId && response.partnerId && !program && !discount) { - const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({ - where: { - partnerId_programId: { - partnerId: response.partnerId, - programId: response.programId, - }, - }, - select: { - discount: { - select: { - id: true, - couponId: true, + const uploadedImageUrl = `${R2_URL}/images/${response.id}`; + + waitUntil( + (async () => { + // Fetch the workspace if: + // 1. Coupon creation isn’t skipped + // 2. Coupon code tracking is enabled + // 3. projectId exists + // 4. couponId exists + // 5. workspace is not provided + if ( + !workspace && + link.projectId && + discount?.couponId && + !skipCouponCreation && + program?.couponCodeTrackingEnabledAt + ) { + workspace = await prisma.project.findUniqueOrThrow({ + where: { + id: link.projectId, }, - }, - program: { select: { id: true, - couponCodeTrackingEnabledAt: true, - workspace: { - select: { - stripeConnectId: true, - }, - }, + stripeConnectId: true, }, - }, - }, - }); + }); + } - const workspace = programEnrollment.program.workspace; - - program = programEnrollment.program; - discount = programEnrollment.discount; - stripeConnectId = workspace.stripeConnectId; - - shouldCreateCouponCode = Boolean( - program.couponCodeTrackingEnabledAt && discount?.couponId, - ); - } + // Fetch programEnrollment if: + // 1. link.partnerId exists + // 2. link.programId exists + // 3. program not provided + // 4. discount not provided + // 5. skipCouponCreation is false + if ( + link.programId && + link.partnerId && + !program && + !discount && + !skipCouponCreation + ) { + const programEnrollment = + await prisma.programEnrollment.findUniqueOrThrow({ + where: { + partnerId_programId: { + partnerId: link.partnerId, + programId: link.programId, + }, + }, + include: { + program: { + select: { + id: true, + couponCodeTrackingEnabledAt: true, + }, + }, + discount: { + select: { + id: true, + couponId: true, + }, + }, + }, + }); - const uploadedImageUrl = `${R2_URL}/images/${response.id}`; - stripeConnectId = stripeConnectId || response.project?.stripeConnectId; + program = programEnrollment.program; + discount = programEnrollment.discount; + } - waitUntil( - Promise.allSettled([ - // cache link in Redis - linkCache.set({ - ...response, - ...(response.programId && - (await getPartnerAndDiscount({ - programId: response.programId, - partnerId: response.partnerId, - }))), - }), + const shouldCreateCouponCode = Boolean( + !skipCouponCreation && + link.projectId && + program?.couponCodeTrackingEnabledAt && + discount?.couponId && + workspace?.stripeConnectId, + ); - // record link in Tinybird - recordLink(response), - // Upload image to R2 and update the link with the uploaded image URL when - // proxy is enabled and image is set and is not a hosted image URL - ...(proxy && image && isNotHostedImage(image) - ? [ - // upload image to R2 - storage.upload(`images/${response.id}`, image, { - width: 1200, - height: 630, - }), - // update the null image we set earlier to the uploaded image URL - prisma.link.update({ - where: { - id: response.id, - }, - data: { - image: uploadedImageUrl, - }, - }), - ] - : []), - // delete public links after 30 mins - !response.userId && - qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/delete`, - // delete after 30 mins - delay: 30 * 60, - body: { - linkId: response.id, - }, - }), - // update links usage for workspace - link.projectId && - updateLinksUsage({ - workspaceId: link.projectId, - increment: 1, + Promise.allSettled([ + // cache link in Redis + linkCache.set({ + ...response, + ...(response.programId && + (await getPartnerAndDiscount({ + programId: response.programId, + partnerId: response.partnerId, + }))), }), - webhookIds && - propagateWebhookTriggerChanges({ - webhookIds, - }), + // record link in Tinybird + recordLink(response), + // Upload image to R2 and update the link with the uploaded image URL when + // proxy is enabled and image is set and is not a hosted image URL + ...(proxy && image && isNotHostedImage(image) + ? [ + // upload image to R2 + storage.upload(`images/${response.id}`, image, { + width: 1200, + height: 630, + }), + // update the null image we set earlier to the uploaded image URL + prisma.link.update({ + where: { + id: response.id, + }, + data: { + image: uploadedImageUrl, + }, + }), + ] + : []), + // delete public links after 30 mins + !response.userId && + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/delete`, + // delete after 30 mins + delay: 30 * 60, + body: { + linkId: response.id, + }, + }), + // update links usage for workspace + link.projectId && + updateLinksUsage({ + workspaceId: link.projectId, + increment: 1, + }), - testVariants && testCompletedAt && scheduleABTestCompletion(response), + webhookIds && + propagateWebhookTriggerChanges({ + webhookIds, + }), - shouldCreateCouponCode && - stripeConnectId && - createStripePromotionCode({ - workspace: { stripeConnectId }, - link: response, - couponId: discount?.couponId!, - }), - ]), - ); + testVariants && testCompletedAt && scheduleABTestCompletion(response), - // @ts-expect-error - delete response?.project; + shouldCreateCouponCode && + createStripePromotionCode({ + link: response, + couponId: discount?.couponId!, + stripeConnectId: workspace?.stripeConnectId!, + }), + ]); + })(), + ); return { ...transformLink(response), diff --git a/apps/web/lib/api/links/update-link.ts b/apps/web/lib/api/links/update-link.ts index 060e835f0e6..8e6266a0f6b 100644 --- a/apps/web/lib/api/links/update-link.ts +++ b/apps/web/lib/api/links/update-link.ts @@ -273,9 +273,9 @@ export async function updateStripePromotionCode({ couponCodeTrackingEnabledAt && discount?.couponId && createStripePromotionCode({ - workspace, link: updatedLink, couponId: discount.couponId, + stripeConnectId: workspace.stripeConnectId, }), ]); } diff --git a/apps/web/lib/api/partners/create-partner-link.ts b/apps/web/lib/api/partners/create-partner-link.ts index 8863acf673f..ca675839b89 100644 --- a/apps/web/lib/api/partners/create-partner-link.ts +++ b/apps/web/lib/api/partners/create-partner-link.ts @@ -15,7 +15,10 @@ import { createLink } from "../links/create-link"; import { processLink } from "../links/process-link"; type PartnerLinkArgs = { - workspace: Pick; + workspace: Pick< + WorkspaceProps, + "id" | "plan" | "webhookEnabled" | "stripeConnectId" + >; program: Pick; partner: Pick< CreatePartnerProps, @@ -34,7 +37,10 @@ export const createPartnerLink = async (args: PartnerLinkArgs) => { const link = await generatePartnerLink(args); - const partnerLink = await createLink(link); + const partnerLink = await createLink({ + ...link, + workspace, + }); waitUntil( sendWorkspaceWebhook({ diff --git a/apps/web/lib/partnerstack/import-links.ts b/apps/web/lib/partnerstack/import-links.ts index 4ff85240bdb..a81831a620e 100644 --- a/apps/web/lib/partnerstack/import-links.ts +++ b/apps/web/lib/partnerstack/import-links.ts @@ -169,7 +169,10 @@ async function createPartnerLink({ userId, }); - return createLink(partnerLink); + return createLink({ + ...partnerLink, + skipCouponCreation: true, + }); } catch (error) { console.error("Error creating partner link", error, link); return null; diff --git a/apps/web/lib/stripe/create-stripe-promotion-code.ts b/apps/web/lib/stripe/create-stripe-promotion-code.ts index dde8a4b867e..5bb53035493 100644 --- a/apps/web/lib/stripe/create-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/create-stripe-promotion-code.ts @@ -1,6 +1,6 @@ import { prisma } from "@dub/prisma"; import { stripeAppClient } from "."; -import { LinkProps, WorkspaceProps } from "../types"; +import { LinkProps } from "../types"; const stripe = stripeAppClient({ ...(process.env.VERCEL_ENV && { livemode: true }), @@ -8,15 +8,14 @@ const stripe = stripeAppClient({ const MAX_RETRIES = 2; -// Create a promotion code on Stripe for connected accounts export async function createStripePromotionCode({ - workspace, link, couponId, + stripeConnectId, }: { - workspace: Pick; link: Pick; couponId: string | null; + stripeConnectId: string | null; }) { if (!couponId) { console.error( @@ -25,7 +24,7 @@ export async function createStripePromotionCode({ return; } - if (!workspace.stripeConnectId) { + if (!stripeConnectId) { console.error( "stripeConnectId not found for the workspace. Stripe promotion code creation skipped.", ); @@ -46,7 +45,7 @@ export async function createStripePromotionCode({ code: couponCode.toUpperCase(), }, { - stripeAccount: workspace.stripeConnectId, + stripeAccount: stripeConnectId, }, ); diff --git a/apps/web/lib/tolt/import-links.ts b/apps/web/lib/tolt/import-links.ts index 4fa4d287210..702cddb6a66 100644 --- a/apps/web/lib/tolt/import-links.ts +++ b/apps/web/lib/tolt/import-links.ts @@ -130,7 +130,10 @@ async function createPartnerLink({ userId, }); - return createLink(partnerLink); + return createLink({ + ...partnerLink, + skipCouponCreation: true, + }); } catch (error) { console.error("Error creating partner link", error, link); return null; diff --git a/apps/web/scripts/bulk-create-domains.ts b/apps/web/scripts/bulk-create-domains.ts index ee9a128246f..ae3758441ff 100644 --- a/apps/web/scripts/bulk-create-domains.ts +++ b/apps/web/scripts/bulk-create-domains.ts @@ -61,6 +61,7 @@ async function main() { trackConversion: false, proxy: false, rewrite: false, + skipCouponCreation: true, }); console.log({ vercelResponse, prisma: response.id, effects }); diff --git a/apps/web/scripts/fix-broken-root-domains.ts b/apps/web/scripts/fix-broken-root-domains.ts index 8ef5f68da50..dcac7f89629 100644 --- a/apps/web/scripts/fix-broken-root-domains.ts +++ b/apps/web/scripts/fix-broken-root-domains.ts @@ -56,6 +56,7 @@ async function main() { createdAt: domain.createdAt, projectId: domain.projectId, userId: domain.project?.users[0].userId, + skipCouponCreation: true, }); console.log({ res }); }), From f9230415529fadb834a242ef5c5932e15d49a6cc Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 14:58:45 +0530 Subject: [PATCH 054/221] Update bulk-approve-partners.ts --- apps/web/lib/partners/bulk-approve-partners.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/partners/bulk-approve-partners.ts b/apps/web/lib/partners/bulk-approve-partners.ts index 599cb52b97b..50c5d648efb 100644 --- a/apps/web/lib/partners/bulk-approve-partners.ts +++ b/apps/web/lib/partners/bulk-approve-partners.ts @@ -29,7 +29,10 @@ export async function bulkApprovePartners({ discount, user, }: { - workspace: Pick; + workspace: Pick< + WorkspaceProps, + "id" | "plan" | "webhookEnabled" | "stripeConnectId" + >; program: ProgramWithLanderDataProps; programEnrollments: (ProgramEnrollment & { partner: Partner & { users: { user: { email: string | null } }[] }; From 741aadd3a2e7397b3c7433d78588304da519a171 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 14:58:47 +0530 Subject: [PATCH 055/221] Update import-partners.ts --- apps/web/scripts/partners/import-partners.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/web/scripts/partners/import-partners.ts b/apps/web/scripts/partners/import-partners.ts index c24bf8c7ae3..5dc8c151cfc 100644 --- a/apps/web/scripts/partners/import-partners.ts +++ b/apps/web/scripts/partners/import-partners.ts @@ -1,3 +1,4 @@ +import { WorkspaceProps } from "@/lib/types"; import { prisma } from "@dub/prisma"; import { nanoid } from "@dub/utils"; import slugify from "@sindresorhus/slugify"; @@ -55,11 +56,7 @@ async function main() { }; const partnerLink = await createPartnerLink({ - workspace: { - id: program.workspace.id, - plan: program.workspace.plan as "advanced", - webhookEnabled: program.workspace.webhookEnabled, - }, + workspace: program.workspace as WorkspaceProps, program: { id: programId, domain: program.domain, From 699979f7c6a2e0d40d242318b6f877aa34d4469c Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 15:00:20 +0530 Subject: [PATCH 056/221] Update update-link.ts --- apps/web/lib/api/links/update-link.ts | 44 +++++++++++++++------------ 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/apps/web/lib/api/links/update-link.ts b/apps/web/lib/api/links/update-link.ts index 8e6266a0f6b..05cdbc24442 100644 --- a/apps/web/lib/api/links/update-link.ts +++ b/apps/web/lib/api/links/update-link.ts @@ -234,33 +234,37 @@ export async function updateStripePromotionCode({ return; } - const { program, discount } = - await prisma.programEnrollment.findUniqueOrThrow({ - where: { - partnerId_programId: { - partnerId: updatedLink.partnerId, - programId: updatedLink.programId, - }, + const programEnrollment = await prisma.programEnrollment.findUnique({ + where: { + partnerId_programId: { + partnerId: updatedLink.partnerId, + programId: updatedLink.programId, }, - select: { - program: { - select: { - couponCodeTrackingEnabledAt: true, - workspace: { - select: { - stripeConnectId: true, - }, + }, + select: { + program: { + select: { + couponCodeTrackingEnabledAt: true, + workspace: { + select: { + stripeConnectId: true, }, }, }, - discount: { - select: { - couponId: true, - }, + }, + discount: { + select: { + couponId: true, }, }, - }); + }, + }); + + if (!programEnrollment) { + return; + } + const { program, discount } = programEnrollment; const { couponCodeTrackingEnabledAt, workspace } = program; await Promise.allSettled([ From f0c8422c19a97fa4ee1cc79593b1ecca7cf72f86 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 15:02:23 +0530 Subject: [PATCH 057/221] Update create-link.ts --- apps/web/lib/api/links/create-link.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts index 5e69a0dbf75..9ffc06bb787 100644 --- a/apps/web/lib/api/links/create-link.ts +++ b/apps/web/lib/api/links/create-link.ts @@ -50,7 +50,6 @@ export async function createLink(link: CreateLinkProps) { testVariants, testStartedAt, testCompletedAt, - skipCouponCreation, } = link; const combinedTagIds = combineTagIds(link); @@ -66,6 +65,7 @@ export async function createLink(link: CreateLinkProps) { workspace, program, discount, + skipCouponCreation, ...rest } = link; From 6aff4857fe18e74808930e73a1fcf10999e5e152 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 16:15:53 +0530 Subject: [PATCH 058/221] Update create-stripe-promotion-code.ts --- apps/web/lib/stripe/create-stripe-promotion-code.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/lib/stripe/create-stripe-promotion-code.ts b/apps/web/lib/stripe/create-stripe-promotion-code.ts index 5bb53035493..8b1c78966ba 100644 --- a/apps/web/lib/stripe/create-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/create-stripe-promotion-code.ts @@ -64,6 +64,8 @@ export async function createStripePromotionCode({ } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); + console.error(lastError); + const isDuplicateError = error instanceof Error && error.message.includes("An active promotion code with `code:") && From 1644fa45b6b0f3bcfdd747d95cb5598d79b75760 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 16:50:45 +0530 Subject: [PATCH 059/221] Update route.ts --- .../links/invalidate-for-discounts/route.ts | 203 ++++++++++-------- 1 file changed, 110 insertions(+), 93 deletions(-) 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 56be60c9208..141e8980346 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 @@ -20,6 +20,8 @@ const schema = z.object({ action: z.enum(["discount-created", "discount-deleted"]), }); +type Payload = Omit, "action">; + // This route is used to invalidate the partnerlink cache when a discount is created/updated/deleted. // POST /api/cron/links/invalidate-for-discounts export async function POST(req: Request) { @@ -27,105 +29,120 @@ export async function POST(req: Request) { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); - const body = schema.parse(JSON.parse(rawBody)); - const { programId, discountId, isDefault, action } = body; + const { action, ...payload } = schema.parse(JSON.parse(rawBody)); - if (action === "discount-created") { - const discount = await prisma.discount.findUnique({ - where: { - id: discountId, - }, - }); + switch (action) { + case "discount-created": + return handleDiscountCreated(payload); + case "discount-deleted": + return handleDiscountDeleted(payload); + default: + return new Response("Invalid action."); + } + } catch (error) { + return handleAndReturnErrorResponse(error); + } +} - if (!discount) { - return new Response("Discount not found."); - } +// Discount has been created +const handleDiscountCreated = async ({ discountId }: Payload) => { + const discount = await prisma.discount.findUnique({ + where: { + id: discountId, + }, + }); - let page = 0; - let total = 0; - const take = 1000; - - while (true) { - const links = await prisma.link.findMany({ - where: { - programEnrollment: { discountId }, - }, - select: { - domain: true, - key: true, - }, - take, - skip: page * take, - }); - - if (links.length === 0) { - break; - } - - await linkCache.expireMany(links); - - page += 1; - total += links.length; - } + if (!discount) { + return new Response(`Discount ${discountId} not found.`); + } - return new Response(`Invalidated ${total} links.`); - } else if (action === "discount-deleted") { - let page = 0; - let total = 0; - const take = 1000; - - while (true) { - let partnerIds: string[] = []; - - if (!isDefault) { - partnerIds = - (await redis.lpop( - `discount-partners:${discountId}`, - take, - )) || []; - - // There won't be any entries in Redis for the default discount - if (!partnerIds || partnerIds.length === 0) { - await redis.del(`discount-partners:${discountId}`); - break; - } - } - - const links = await prisma.link.findMany({ - where: { - programId, - programEnrollment: { - ...(partnerIds.length > 0 && { - partnerId: { - in: partnerIds, - }, - }), - discountId: null, - }, - }, - select: { - domain: true, - key: true, - }, - take, - skip: page * take, - }); - - if (links.length === 0) { - break; - } - - await linkCache.expireMany(links); - - page += 1; - total += links.length; + let page = 0; + let total = 0; + const take = 1000; + + while (true) { + const links = await prisma.link.findMany({ + where: { + programEnrollment: { discountId }, + }, + select: { + domain: true, + key: true, + }, + take, + skip: page * take, + }); + + if (links.length === 0) { + break; + } + + await linkCache.expireMany(links); + + page += 1; + total += links.length; + } + + return new Response(`Invalidated ${total} links for discount ${discountId}.`); +}; + +// Discount has been deleted +const handleDiscountDeleted = async ({ + programId, + discountId, + isDefault, +}: Payload) => { + let page = 0; + let total = 0; + const take = 1000; + + while (true) { + let partnerIds: string[] = []; + + if (!isDefault) { + partnerIds = + (await redis.lpop(`discount-partners:${discountId}`, take)) || + []; + + // There won't be any entries in Redis for the default discount + if (!partnerIds || partnerIds.length === 0) { + await redis.del(`discount-partners:${discountId}`); + break; } + } + + // TODO: + // Unset couponCode for the links - return new Response(`Invalidated ${total} links.`); + const links = await prisma.link.findMany({ + where: { + programId, + programEnrollment: { + ...(partnerIds.length > 0 && { + partnerId: { + in: partnerIds, + }, + }), + discountId: null, + }, + }, + select: { + domain: true, + key: true, + }, + take, + skip: page * take, + }); + + if (links.length === 0) { + break; } - return new Response("OK"); - } catch (error) { - return handleAndReturnErrorResponse(error); + await linkCache.expireMany(links); + + page += 1; + total += links.length; } -} + + return new Response(`Invalidated ${total} links for discount ${discountId}.`); +}; From dae24a8c9b5a5adb1357ff071be7c87932e93f64 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 16:59:44 +0530 Subject: [PATCH 060/221] Update route.ts --- .../links/invalidate-for-discounts/route.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) 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 141e8980346..75b0fb0a6fe 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 @@ -94,7 +94,7 @@ const handleDiscountDeleted = async ({ }: Payload) => { let page = 0; let total = 0; - const take = 1000; + const take = 1; while (true) { let partnerIds: string[] = []; @@ -111,9 +111,6 @@ const handleDiscountDeleted = async ({ } } - // TODO: - // Unset couponCode for the links - const links = await prisma.link.findMany({ where: { programId, @@ -127,6 +124,7 @@ const handleDiscountDeleted = async ({ }, }, select: { + id: true, domain: true, key: true, }, @@ -138,7 +136,20 @@ const handleDiscountDeleted = async ({ break; } - await linkCache.expireMany(links); + await Promise.allSettled([ + linkCache.expireMany(links), + + prisma.link.updateMany({ + where: { + id: { + in: links.map((link) => link.id), + }, + }, + data: { + couponCode: null, + }, + }), + ]); page += 1; total += links.length; From 54dab41c1a899dbc831c28943ffc65828e923727 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 16:59:46 +0530 Subject: [PATCH 061/221] Update delete-discount.ts --- apps/web/lib/api/partners/delete-discount.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/api/partners/delete-discount.ts b/apps/web/lib/api/partners/delete-discount.ts index b9686964b19..7425e068319 100644 --- a/apps/web/lib/api/partners/delete-discount.ts +++ b/apps/web/lib/api/partners/delete-discount.ts @@ -43,7 +43,7 @@ export async function deleteDiscount({ await redis.lpush( `discount-partners:${discount.id}`, - partners.map((partner) => partner.partnerId), + ...partners.map((partner) => partner.partnerId), ); offset += 1000; From db9c336228ccabcdbc07cc9eb86de8f304534959 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 8 Aug 2025 22:36:35 +0530 Subject: [PATCH 062/221] Update route.ts --- .../cron/links/invalidate-for-discounts/route.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) 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 75b0fb0a6fe..4524f99d9ac 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 @@ -92,16 +92,12 @@ const handleDiscountDeleted = async ({ discountId, isDefault, }: Payload) => { - let page = 0; - let total = 0; - const take = 1; - while (true) { let partnerIds: string[] = []; if (!isDefault) { partnerIds = - (await redis.lpop(`discount-partners:${discountId}`, take)) || + (await redis.lpop(`discount-partners:${discountId}`, 100)) || []; // There won't be any entries in Redis for the default discount @@ -128,12 +124,10 @@ const handleDiscountDeleted = async ({ domain: true, key: true, }, - take, - skip: page * take, }); if (links.length === 0) { - break; + continue; } await Promise.allSettled([ @@ -150,10 +144,7 @@ const handleDiscountDeleted = async ({ }, }), ]); - - page += 1; - total += links.length; } - return new Response(`Invalidated ${total} links for discount ${discountId}.`); + return new Response(`Invalidated links for discount ${discountId}.`); }; From 07f10a3eeaf21a133d0827f70a1ed0361d9d73fd Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 18 Aug 2025 10:28:52 +0530 Subject: [PATCH 063/221] Update add-edit-discount-sheet.tsx --- .../ui/partners/add-edit-discount-sheet.tsx | 530 +++++------------- 1 file changed, 147 insertions(+), 383 deletions(-) diff --git a/apps/web/ui/partners/add-edit-discount-sheet.tsx b/apps/web/ui/partners/add-edit-discount-sheet.tsx index ce4e56b9c4f..02da291f823 100644 --- a/apps/web/ui/partners/add-edit-discount-sheet.tsx +++ b/apps/web/ui/partners/add-edit-discount-sheet.tsx @@ -9,24 +9,23 @@ 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 { BadgePercent } from "lucide-react"; +import { Button, Sheet } from "@dub/ui"; +import { CircleCheckFill, Tag } from "@dub/ui/icons"; +import { cn } from "@dub/utils"; import { useAction } from "next-safe-action/hooks"; -import { Dispatch, SetStateAction, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +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 { ProgramRewardDescription } from "./program-reward-description"; -import { - ProgramSheetAccordion, - ProgramSheetAccordionContent, - ProgramSheetAccordionItem, - ProgramSheetAccordionTrigger, -} from "./program-sheet-accordion"; interface DiscountSheetProps { setIsOpen: Dispatch>; @@ -36,6 +35,8 @@ interface DiscountSheetProps { type FormData = z.infer; +export const useAddEditDiscountForm = () => useFormContext(); + const discountTypes = [ { label: "One-off", @@ -49,14 +50,14 @@ const discountTypes = [ }, ] as const; -const couponTypes = [ +const COUPON_CREATION_OPTIONS = [ { - label: "New coupon", + label: "New Stripe coupon", description: "Create a new coupon", useExisting: false, }, { - label: "Existing coupon", + label: "Use Stripe coupon ID", description: "Use an existing coupon", useExisting: true, }, @@ -83,19 +84,7 @@ function DiscountSheetContent({ Boolean(discount?.couponId), ); - const [accordionValues, setAccordionValues] = useState([ - "discount-type", - "discount-details", - "stripe-coupon-details", - ]); - - const { - register, - handleSubmit, - watch, - setValue, - formState: { errors }, - } = useForm({ + const form = useForm({ defaultValues: { amount: defaultValuesSource?.type === "flat" @@ -111,7 +100,8 @@ function DiscountSheetContent({ }, }); - const [type, amount] = watch(["type", "amount"]); + const { handleSubmit, watch, setValue, register } = form; + const [type, amount, maxDuration] = watch(["type", "amount", "maxDuration"]); const { executeAsync: createDiscount, isPending: isCreating } = useAction( createDiscountAction, @@ -200,7 +190,7 @@ function DiscountSheetContent({ }; return ( - <> +
-
- - {!discount && ( - - - Discount Type - - -
-

- Set how the discount will be applied -

-
- -
-
-
- {discountTypes.map( - ({ label, description, recurring }) => { - const isSelected = isRecurring === recurring; - - return ( - - ); - }, - )} -
- - {isRecurring && ( -
-
- -
- -
-
-
- )} -
-
-
-
+
+ +
+ +
+ Coupon connection + + } + content={ +
+
+ +
+
- - - )} +
- {!discount && ( - - - Discount Details - - -
-

- Set the discount amount and configuration -

- -
- -
- -
-
- -
- -
- {type === "flat" && ( - - $ - - )} - - - {type === "flat" ? "USD" : "%"} - -
-
- - {/* Display the coupon switcher if the discount is being created */} - {!discount && ( -
-

- Create a new discount code or connect an existing one -

-
-
- {couponTypes.map( - ({ label, description, useExisting }) => { - const isSelected = - useExistingCoupon === useExisting; - - return ( - - ); - }, - )} -
-
-
- )} - - {useExistingCoupon && ( - <> -
- -
- -
+
+ {COUPON_CREATION_OPTIONS.map( + ({ label, description, useExisting }) => { + const isSelected = useExistingCoupon === useExisting; -

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

-
- -
- -
- -
-
- - )} -
- - - )} - - {discount && ( - - - Stripe coupon - - -
-
- -
- -
-
- - {discount.couponTestId && ( -
- -
{ + if (e.target.checked) { + setUseExistingCoupon(useExisting); + } + }} + /> +
+ {label} + {description} +
+ -
-
- )} - -
-
-
- -
- - - -
-

- Discounts cannot be changed after creation, only their - partner eligibility. -

-
-
-
-
- )} - + + ); + }, + )} +
+
+ } + /> + +
@@ -616,7 +314,73 @@ function DiscountSheetContent({
- + + ); +} + +function DiscountSheetCard({ + title, + content, +}: PropsWithChildren<{ title: ReactNode; content: ReactNode }>) { + return ( +
+
+ {title} +
+ {content && ( +
+ {content} +
+ )} +
+ ); +} + +const VerticalLine = () => ( +
+); + +function DiscountIconSquare({ + icon: Icon, +}: { + icon: React.ComponentType; +}) { + return ( +
+ +
+ ); +} + +function AmountInput() { + 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" : "%"} + +
); } From 744276c0a853ef00a7df12067783f6e60dd57e88 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 18 Aug 2025 10:51:41 +0530 Subject: [PATCH 064/221] Update add-edit-discount-sheet.tsx --- .../ui/partners/add-edit-discount-sheet.tsx | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/web/ui/partners/add-edit-discount-sheet.tsx b/apps/web/ui/partners/add-edit-discount-sheet.tsx index 02da291f823..bb0d17db7d4 100644 --- a/apps/web/ui/partners/add-edit-discount-sheet.tsx +++ b/apps/web/ui/partners/add-edit-discount-sheet.tsx @@ -10,7 +10,13 @@ import useWorkspace from "@/lib/swr/use-workspace"; import { DiscountProps } from "@/lib/types"; import { createDiscountSchema } from "@/lib/zod/schemas/discount"; import { X } from "@/ui/shared/icons"; -import { Button, Sheet } from "@dub/ui"; +import { + Button, + InfoTooltip, + Sheet, + SimpleTooltipContent, + Switch, +} from "@dub/ui"; import { CircleCheckFill, Tag } from "@dub/ui/icons"; import { cn } from "@dub/utils"; import { useAction } from "next-safe-action/hooks"; @@ -273,6 +279,36 @@ function DiscountSheetContent({ }, )}
+ +
+ + setValue( + "enableCouponTracking", + !watch("enableCouponTracking"), + ) + } + checked={watch("enableCouponTracking")} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> +
+

+ Enable automatic coupon code tracking +

+ + + } + /> +
+
} /> From d2a63d79658b690d68204d373a2be6c719bfce29 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 18 Aug 2025 11:07:02 +0530 Subject: [PATCH 065/221] Update add-edit-discount-sheet.tsx --- .../ui/partners/add-edit-discount-sheet.tsx | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/apps/web/ui/partners/add-edit-discount-sheet.tsx b/apps/web/ui/partners/add-edit-discount-sheet.tsx index bb0d17db7d4..f451a310e24 100644 --- a/apps/web/ui/partners/add-edit-discount-sheet.tsx +++ b/apps/web/ui/partners/add-edit-discount-sheet.tsx @@ -90,6 +90,10 @@ function DiscountSheetContent({ Boolean(discount?.couponId), ); + const [useStripeTestCouponId, setUseStripeTestCouponId] = useState( + Boolean(discount?.couponTestId), + ); + const form = useForm({ defaultValues: { amount: @@ -280,6 +284,71 @@ function DiscountSheetContent({ )}
+ {useExistingCoupon && ( + <> +
+ +
+ +
+
+ +
+ +
+

+ Use Stripe test coupon ID +

+ + + } + /> +
+
+ + {useStripeTestCouponId && ( +
+ +
+ +
+
+ )} + + )} +
From cc3d03b8e162bc78da4b075124e131caed6b0170 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 18 Aug 2025 12:16:11 +0530 Subject: [PATCH 066/221] Refactor discount sheet and update related logic --- .../[groupSlug]/discount/group-discount.tsx | 2 +- .../lib/actions/partners/create-discount.ts | 2 +- .../lib/actions/partners/update-discount.ts | 11 - apps/web/lib/zod/schemas/discount.ts | 1 + .../ui/partners/add-edit-discount-sheet.tsx | 518 ---------------- .../discounts/add-edit-discount-sheet.tsx | 563 ++++++++++++++++++ .../reward-discount-partners-card.tsx} | 16 +- .../rewards/add-edit-reward-sheet.tsx | 4 +- 8 files changed, 578 insertions(+), 539 deletions(-) delete mode 100644 apps/web/lib/actions/partners/update-discount.ts delete mode 100644 apps/web/ui/partners/add-edit-discount-sheet.tsx create mode 100644 apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx rename apps/web/ui/partners/{rewards/reward-partners-card.tsx => groups/reward-discount-partners-card.tsx} (96%) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/discount/group-discount.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/discount/group-discount.tsx index 168463f42af..cc77bc57d6f 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/discount/group-discount.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/discount/group-discount.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/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 7b8efc0fc1b..8850664e591 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -16,7 +16,7 @@ export const createDiscountAction = authActionClient .schema(createDiscountSchema) .action(async ({ parsedInput, ctx }) => { const { workspace, user } = ctx; - let { amount, type, maxDuration, couponId, couponTestId, groupId } = + let { amount, type, maxDuration, couponId, couponTestId, groupId, enableCouponTracking } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts deleted file mode 100644 index eef952eb364..00000000000 --- a/apps/web/lib/actions/partners/update-discount.ts +++ /dev/null @@ -1,11 +0,0 @@ -"use server"; - -import { updateDiscountSchema } from "@/lib/zod/schemas/discount"; -import { authActionClient } from "../safe-action"; - -export const updateDiscountAction = authActionClient - .schema(updateDiscountSchema) - .action(async ({ parsedInput, ctx }) => { - // TODO: - // Remove this, Stripe coupon can't be updated - }); diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index 1c0afa7e715..856e7bf91e7 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -29,6 +29,7 @@ export const createDiscountSchema = z.object({ couponId: z.string().nullish(), couponTestId: z.string().nullish(), groupId: z.string(), + enableCouponTracking: z.boolean().default(false), }); export const updateDiscountSchema = createDiscountSchema 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 f451a310e24..00000000000 --- a/apps/web/ui/partners/add-edit-discount-sheet.tsx +++ /dev/null @@ -1,518 +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 { X } from "@/ui/shared/icons"; -import { - Button, - InfoTooltip, - Sheet, - SimpleTooltipContent, - Switch, -} from "@dub/ui"; -import { CircleCheckFill, Tag } from "@dub/ui/icons"; -import { cn } 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"; - -interface DiscountSheetProps { - setIsOpen: Dispatch>; - discount?: DiscountProps; - defaultDiscountValues?: DiscountProps; -} - -type FormData = z.infer; - -export const useAddEditDiscountForm = () => useFormContext(); - -const discountTypes = [ - { - label: "One-off", - description: "Offer a one-time discount", - recurring: false, - }, - { - label: "Recurring", - description: "Offer an ongoing discount", - recurring: true, - }, -] as const; - -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 } = useWorkspace(); - - const defaultValuesSource = discount || defaultDiscountValues; - - const [isRecurring, setIsRecurring] = useState( - defaultValuesSource ? defaultValuesSource.maxDuration !== 0 : false, - ); - - const [useExistingCoupon, setUseExistingCoupon] = useState( - Boolean(discount?.couponId), - ); - - const [useStripeTestCouponId, setUseStripeTestCouponId] = useState( - Boolean(discount?.couponTestId), - ); - - const form = 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 { 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 }) { - 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 - - -
- -
- -
- -
- Coupon connection - - } - content={ -
-
- -
- -
-
- -
- {COUPON_CREATION_OPTIONS.map( - ({ label, description, useExisting }) => { - const isSelected = useExistingCoupon === useExisting; - - return ( - - ); - }, - )} -
- - {useExistingCoupon && ( - <> -
- -
- -
-
- -
- -
-

- Use Stripe test coupon ID -

- - - } - /> -
-
- - {useStripeTestCouponId && ( -
- -
- -
-
- )} - - )} - -
- - setValue( - "enableCouponTracking", - !watch("enableCouponTracking"), - ) - } - checked={watch("enableCouponTracking")} - trackDimensions="w-8 h-4" - thumbDimensions="w-3 h-3" - thumbTranslate="translate-x-4" - /> -
-

- Enable automatic coupon code tracking -

- - - } - /> -
-
-
- } - /> - - -
- -
-
- {discount && ( -
- -
-
-
-
-
- ); -} - -function DiscountSheetCard({ - title, - content, -}: PropsWithChildren<{ title: ReactNode; content: ReactNode }>) { - return ( -
-
- {title} -
- {content && ( -
- {content} -
- )} -
- ); -} - -const VerticalLine = () => ( -
-); - -function DiscountIconSquare({ - icon: Icon, -}: { - icon: React.ComponentType; -}) { - return ( -
- -
- ); -} - -function AmountInput() { - 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/add-edit-discount-sheet.tsx b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx new file mode 100644 index 00000000000..d8aeac56c0e --- /dev/null +++ b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx @@ -0,0 +1,563 @@ +"use client"; + +import { createDiscountAction } from "@/lib/actions/partners/create-discount"; +import { deleteDiscountAction } from "@/lib/actions/partners/delete-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 { + 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 { RewardDiscountPartnersCard } from "../groups/reward-discount-partners-card"; +import { + InlineBadgePopover, + InlineBadgePopoverMenu, +} from "../rewards/inline-badge-popover"; + +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 } = useWorkspace(); + + const [useExistingCoupon, setUseExistingCoupon] = useState( + Boolean(discount?.couponId), + ); + + 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 }) { + 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; + } + + 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={ +
+
+
+ +
+ +
+
+ +
+ {COUPON_CREATION_OPTIONS.map( + ({ label, description, useExisting }) => { + const isSelected = useExistingCoupon === useExisting; + + return ( + + ); + }, + )} +
+ + {useExistingCoupon && ( + <> +
+ +
+ +
+
+ +
+ +
+

+ Use Stripe test coupon ID +

+ + + } + /> +
+
+ + {useStripeTestCouponId && ( +
+ +
+ +
+
+ )} + + )} + +
+ + setValue( + "enableCouponTracking", + !watch("enableCouponTracking"), + ) + } + checked={watch("enableCouponTracking")} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> +
+

+ Enable automatic coupon code tracking +

+ + + } + /> +
+
+
+
+ } + /> + + {!useExistingCoupon && ( + <> + + + + + + 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, +}: PropsWithChildren<{ title: ReactNode; content: ReactNode }>) { + return ( +
+
+ {title} +
+ {content && <>{content}} +
+ ); +} + +const VerticalLine = () => ( +
+); + +function AmountInput() { + 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/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 fd2eba7a6d7..08f7fa9e15b 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 "framer-motion"; 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/rewards/add-edit-reward-sheet.tsx b/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx index 558b367deda..9b380def2f4 100644 --- a/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx +++ b/apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx @@ -42,6 +42,7 @@ import { FormProvider, useForm, useFormContext } from "react-hook-form"; import { toast } from "sonner"; import { mutate } from "swr"; import { z } from "zod"; +import { RewardDiscountPartnersCard } from "../groups/reward-discount-partners-card"; import { usePartnersUpgradeModal } from "../partners-upgrade-modal"; import { InlineBadgePopover, @@ -49,7 +50,6 @@ import { InlineBadgePopoverMenu, } from "./inline-badge-popover"; import { RewardIconSquare } from "./reward-icon-square"; -import { RewardPartnersCard } from "./reward-partners-card"; import { RewardsLogic } from "./rewards-logic"; interface RewardSheetProps { @@ -362,7 +362,7 @@ function RewardSheetContent({ - {group && } + {group && }
From e64deb87a75003c4c6a4de1bebe7d378a99d5f44 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 18 Aug 2025 22:43:06 +0530 Subject: [PATCH 067/221] Refactor discount actions and schema, remove promotion code cron --- .../links/create-promotion-codes/route.ts | 165 ------------------ .../lib/actions/partners/create-discount.ts | 29 ++- .../lib/actions/partners/delete-discount.ts | 9 - .../lib/actions/partners/update-discount.ts | 70 ++++++++ .../lib/api/partners/get-discount-or-throw.ts | 21 +-- apps/web/lib/stripe/delete-stripe-coupon.ts | 32 ---- apps/web/lib/zod/schemas/discount.ts | 2 + packages/prisma/schema/discount.prisma | 21 +-- packages/prisma/schema/program.prisma | 1 - 9 files changed, 98 insertions(+), 252 deletions(-) delete mode 100644 apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts create mode 100644 apps/web/lib/actions/partners/update-discount.ts delete mode 100644 apps/web/lib/stripe/delete-stripe-coupon.ts diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts deleted file mode 100644 index 51d18fa91d7..00000000000 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; -import { qstash } from "@/lib/cron"; -import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; -import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; -import { prisma } from "@dub/prisma"; -import { APP_DOMAIN_WITH_NGROK, chunk, log } from "@dub/utils"; -import { z } from "zod"; - -export const dynamic = "force-dynamic"; - -const schema = z.object({ - discountId: z.string(), - page: z.number().optional().default(1), -}); - -const PAGE_LIMIT = 20; -const MAX_BATCHES = 5; - -// POST /api/cron/links/create-promotion-codes -export async function POST(req: Request) { - let parsedBody: z.infer | undefined; - - try { - const rawBody = await req.text(); - await verifyQstashSignature({ req, rawBody }); - - parsedBody = schema.parse(JSON.parse(rawBody)); - const { discountId, page } = parsedBody; - - const { - couponId, - programId, - program: { couponCodeTrackingEnabledAt, workspace }, - } = await prisma.discount.findUniqueOrThrow({ - where: { - id: discountId, - }, - select: { - couponId: true, - programId: true, - program: { - select: { - couponCodeTrackingEnabledAt: true, - workspace: { - select: { - stripeConnectId: true, - }, - }, - }, - }, - }, - }); - - if (!couponCodeTrackingEnabledAt) { - return new Response( - "couponCodeTrackingEnabledAt is not set for the program. Skipping promotion code creation.", - ); - } - - if (!couponId) { - return new Response( - "couponId doesn't set for the discount. Skipping promotion code creation.", - ); - } - - let hasMore = true; - let currentPage = page; - let processedBatches = 0; - - while (hasMore && processedBatches < MAX_BATCHES) { - const enrollments = await prisma.programEnrollment.findMany({ - where: { - programId, - discountId, - }, - select: { - partnerId: true, - }, - orderBy: { - id: "desc", - }, - take: PAGE_LIMIT, - skip: (currentPage - 1) * PAGE_LIMIT, - }); - - if (enrollments.length === 0) { - console.log("No more enrollments found."); - hasMore = false; - break; - } - - const links = await prisma.link.findMany({ - where: { - programId, - partnerId: { - in: enrollments.map(({ partnerId }) => partnerId), - }, - couponCode: null, - }, - select: { - id: true, - key: true, - }, - }); - - if (links.length === 0) { - console.log("No more links found."); - currentPage++; - processedBatches++; - continue; - } - - const linksChunks = chunk(links, 10); - const failedRequests: Error[] = []; - - for (const linksChunk of linksChunks) { - const results = await Promise.allSettled( - linksChunk.map((link) => - createStripePromotionCode({ - link, - couponId, - stripeConnectId: workspace.stripeConnectId, - }), - ), - ); - - results.forEach((result) => { - if (result.status === "rejected") { - failedRequests.push(result.reason); - } - }); - - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - - if (failedRequests.length > 0) { - console.error(failedRequests); - } - - currentPage++; - processedBatches++; - } - - if (hasMore) { - await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, - body: { - discountId, - page: currentPage, - }, - }); - } - - return new Response("OK"); - } catch (error) { - console.error(parsedBody); - - await log({ - message: `Error creating Stripe promotion codes: ${error.message} - ${JSON.stringify(parsedBody)}`, - type: "errors", - }); - - return handleAndReturnErrorResponse(error); - } -} diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 8850664e591..5f7e348cf39 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -16,8 +16,15 @@ export const createDiscountAction = authActionClient .schema(createDiscountSchema) .action(async ({ parsedInput, ctx }) => { const { workspace, user } = ctx; - let { amount, type, maxDuration, couponId, couponTestId, groupId, enableCouponTracking } = - parsedInput; + let { + amount, + type, + maxDuration, + couponId, + couponTestId, + groupId, + enableCouponTracking, + } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -69,13 +76,9 @@ export const createDiscountAction = authActionClient maxDuration, couponId, couponTestId, - }, - include: { - program: { - select: { - couponCodeTrackingEnabledAt: true, - }, - }, + ...(enableCouponTracking && { + couponCodeTrackingEnabledAt: new Date(), + }), }, }); @@ -110,14 +113,6 @@ export const createDiscountAction = authActionClient }, }), - discount.program.couponCodeTrackingEnabledAt && - qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, - body: { - discountId: discount.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 432dee6724f..e4397c41a73 100644 --- a/apps/web/lib/actions/partners/delete-discount.ts +++ b/apps/web/lib/actions/partners/delete-discount.ts @@ -4,7 +4,6 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; 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"; -import { deleteStripeCoupon } from "@/lib/stripe/delete-stripe-coupon"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; @@ -66,14 +65,6 @@ export const deleteDiscountAction = authActionClient }, }), - // Question: - // Would this be a problem if the coupon is used in their application? - discount.couponId && - deleteStripeCoupon({ - couponId: discount.couponId, - stripeConnectId: workspace.stripeConnectId, - }), - 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 new file mode 100644 index 00000000000..90a82bbdc5e --- /dev/null +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -0,0 +1,70 @@ +"use server"; + +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +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"; +import { updateDiscountSchema } from "@/lib/zod/schemas/discount"; +import { prisma } from "@dub/prisma"; +import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; +import { waitUntil } from "@vercel/functions"; +import { authActionClient } from "../safe-action"; + +export const updateDiscountAction = authActionClient + .schema(updateDiscountSchema) + .action(async ({ parsedInput, ctx }) => { + const { workspace, user } = ctx; + let { discountId, couponId, couponTestId } = parsedInput; + + const programId = getDefaultProgramIdOrThrow(workspace); + + const discount = await getDiscountOrThrow({ + programId, + discountId, + }); + + const { partnerGroup, ...updatedDiscount } = await prisma.discount.update({ + where: { + id: discountId, + }, + data: { + couponId, + couponTestId, + }, + include: { + partnerGroup: { + select: { + id: true, + }, + }, + }, + }); + + waitUntil( + (async () => { + await Promise.allSettled([ + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, + body: { + groupId: partnerGroup?.id, + }, + }), + + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "discount.updated", + description: `Discount ${discount.id} updated`, + actor: user, + targets: [ + { + type: "discount", + id: discount.id, + metadata: updatedDiscount, + }, + ], + }), + ]); + })(), + ); + }); 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 4b82013b0c2..1d15b7e6e45 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/stripe/delete-stripe-coupon.ts b/apps/web/lib/stripe/delete-stripe-coupon.ts deleted file mode 100644 index dc5496db534..00000000000 --- a/apps/web/lib/stripe/delete-stripe-coupon.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { stripeAppClient } from "."; - -const stripe = stripeAppClient({ - ...(process.env.VERCEL_ENV && { livemode: true }), -}); - -// Delete a coupon on Stripe for connected accounts -export async function deleteStripeCoupon({ - couponId, - stripeConnectId, -}: { - couponId: string; - stripeConnectId: string | null; -}) { - if (!stripeConnectId) { - console.error( - "stripeConnectId not found for the workspace. Stripe coupon creation skipped.", - ); - return; - } - - try { - return await stripe.coupons.del(couponId, { - stripeAccount: stripeConnectId, - }); - } catch (error) { - console.error( - `Failed to delete Stripe coupon for ${stripeConnectId}: ${error}`, - ); - return null; - } -} diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index 856e7bf91e7..938b2695b69 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -35,6 +35,8 @@ export const createDiscountSchema = z.object({ export const updateDiscountSchema = createDiscountSchema .pick({ workspaceId: true, + couponId: true, + couponTestId: true, }) .extend({ discountId: z.string(), diff --git a/packages/prisma/schema/discount.prisma b/packages/prisma/schema/discount.prisma index 153d6069992..d5a50cfe5f9 100644 --- a/packages/prisma/schema/discount.prisma +++ b/packages/prisma/schema/discount.prisma @@ -1,14 +1,15 @@ model Discount { - id String @id @default(cuid()) - programId String - amount Int @default(0) - type RewardStructure @default(percentage) - maxDuration Int? // in months (0 -> not recurring, null -> lifetime) - description String? - couponId String? - couponTestId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + programId String + amount Int @default(0) + type RewardStructure @default(percentage) + maxDuration Int? // in months (0 -> not recurring, null -> lifetime) + description String? + couponId String? + couponTestId String? + couponCodeTrackingEnabledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt programEnrollments ProgramEnrollment[] program Program @relation("ProgramDiscounts", fields: [programId], references: [id], onDelete: Cascade, onUpdate: Cascade) diff --git a/packages/prisma/schema/program.prisma b/packages/prisma/schema/program.prisma index 61b47f914ae..3cb00b85c0b 100644 --- a/packages/prisma/schema/program.prisma +++ b/packages/prisma/schema/program.prisma @@ -56,7 +56,6 @@ model Program { supportEmail String? ageVerification Int? autoApprovePartnersEnabledAt DateTime? - couponCodeTrackingEnabledAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From a5c546aed56ff0b5729452a677bb2ae13f810c71 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 18 Aug 2025 22:47:23 +0530 Subject: [PATCH 068/221] Update add-edit-discount-sheet.tsx --- .../discounts/add-edit-discount-sheet.tsx | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx index d8aeac56c0e..8db2e7dbfed 100644 --- a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx +++ b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx @@ -2,6 +2,7 @@ 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"; @@ -125,6 +126,21 @@ function DiscountSheetContent({ }, ); + 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, { @@ -145,13 +161,23 @@ function DiscountSheetContent({ return; } - await createDiscount({ - ...data, + if (!discount) { + 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, + }); + return; + } + + await updateDiscount({ workspaceId, - groupId: group.id, - amount: data.type === "flat" ? data.amount * 100 : data.amount || 0, - maxDuration: - Number(data.maxDuration) === Infinity ? null : data.maxDuration, + discountId: discount.id, + couponId: data.couponId, + couponTestId: data.couponTestId, }); }; @@ -466,15 +492,15 @@ function DiscountSheetContent({ onClick={() => setIsOpen(false)} text="Cancel" className="w-fit" - disabled={isCreating || isDeleting} + disabled={isCreating || isDeleting || isUpdating} />
From e736d0c47bc8d2111fbd0251290ab062335d1d22 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 18 Aug 2025 23:09:28 +0530 Subject: [PATCH 069/221] Update create-discount.ts --- apps/web/lib/actions/partners/create-discount.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 5f7e348cf39..1a86e39f048 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -59,7 +59,9 @@ export const createDiscountAction = authActionClient }); if (!stripeCoupon) { - throw new Error("Failed to create Stripe coupon. Please try again."); + throw new Error( + "Failed to create Stripe coupon. Make sure you installed the latest version of the Dub Stripe app.", + ); } couponId = stripeCoupon.id; From 088e98b712f0a45443ba652250ddd53be429c73a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 18 Aug 2025 23:09:34 +0530 Subject: [PATCH 070/221] Update add-edit-discount-sheet.tsx --- .../discounts/add-edit-discount-sheet.tsx | 159 ++++++++++-------- 1 file changed, 85 insertions(+), 74 deletions(-) diff --git a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx index 8db2e7dbfed..4337235ece2 100644 --- a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx +++ b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx @@ -75,9 +75,7 @@ function DiscountSheetContent({ const { mutate: mutateProgram } = useProgram(); const { id: workspaceId, defaultProgramId } = useWorkspace(); - const [useExistingCoupon, setUseExistingCoupon] = useState( - Boolean(discount?.couponId), - ); + const [useExistingCoupon, setUseExistingCoupon] = useState(false); const [useStripeTestCouponId, setUseStripeTestCouponId] = useState( Boolean(discount?.couponTestId), @@ -130,7 +128,7 @@ function DiscountSheetContent({ updateDiscountAction, { onSuccess: async () => { - // setIsOpen(false); + setIsOpen(false); toast.success("Discount updated!"); await mutateProgram(); await mutateGroup(); @@ -240,49 +238,51 @@ function DiscountSheetContent({
-
- {COUPON_CREATION_OPTIONS.map( - ({ label, description, useExisting }) => { - const isSelected = useExistingCoupon === useExisting; + {!discount && ( +
+ {COUPON_CREATION_OPTIONS.map( + ({ label, description, useExisting }) => { + const isSelected = useExistingCoupon === useExisting; - return ( - - ); - }, - )} -
+ > + { + if (e.target.checked) { + setUseExistingCoupon(useExisting); + } + }} + /> +
+ {label} + {description} +
+ + + ); + }, + )} +
+ )} - {useExistingCoupon && ( + {(useExistingCoupon || discount) && ( <>
} /> - {!useExistingCoupon && ( + {(!useExistingCoupon || discount) && ( <> @@ -391,7 +396,10 @@ function DiscountSheetContent({ Discount a{" "} - + @@ -422,8 +430,9 @@ function DiscountSheetContent({ : "amount" } invalid={!amount} + disabled={!!discount} > - + {" "} (
); -function AmountInput() { +function AmountInput({ disabled }: { disabled?: boolean }) { const { watch, register } = useAddEditDiscountForm(); const type = watch("type"); @@ -544,6 +554,7 @@ function AmountInput() { "block w-full rounded-md border-neutral-300 px-1.5 py-1 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm", type === "flat" ? "pl-4 pr-12" : "pr-7", )} + disabled={disabled} {...register("amount", { required: true, setValueAs: (value: string) => (value === "" ? undefined : +value), From 1ded8e82b8fd07f46361c218f20f08262c952bfa Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 18 Aug 2025 23:09:37 +0530 Subject: [PATCH 071/221] Update inline-badge-popover.tsx --- apps/web/ui/partners/rewards/inline-badge-popover.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/ui/partners/rewards/inline-badge-popover.tsx b/apps/web/ui/partners/rewards/inline-badge-popover.tsx index f61d31c56c3..d2641236af0 100644 --- a/apps/web/ui/partners/rewards/inline-badge-popover.tsx +++ b/apps/web/ui/partners/rewards/inline-badge-popover.tsx @@ -35,8 +35,13 @@ export const InlineBadgePopoverContext = createContext<{ export function InlineBadgePopover({ text, invalid, + disabled, children, -}: PropsWithChildren<{ text: ReactNode; invalid?: boolean }>) { +}: PropsWithChildren<{ + text: ReactNode; + invalid?: boolean; + disabled?: boolean; +}>) { const [isOpen, setIsOpen] = useState(false); return ( @@ -58,6 +63,7 @@ export function InlineBadgePopover({ >
@@ -341,8 +345,9 @@ function DiscountSheetContent({ @@ -510,8 +515,8 @@ function DiscountSheetContent({ variant="primary" text={discount ? "Update discount" : "Create discount"} className="w-fit" - loading={isCreating || isDeleting || isUpdating} - disabled={!discount && amount == null} + loading={isCreating || isUpdating} + disabled={(!discount && amount == null) || isDeleting} /> diff --git a/packages/prisma/schema/discount.prisma b/packages/prisma/schema/discount.prisma index 744d1a1f634..6655a9ff1ca 100644 --- a/packages/prisma/schema/discount.prisma +++ b/packages/prisma/schema/discount.prisma @@ -15,5 +15,5 @@ model Discount { program Program @relation("ProgramDiscounts", fields: [programId], references: [id], onDelete: Cascade, onUpdate: Cascade) partnerGroup PartnerGroup? - @@unique(programId) + @@index(programId) } From eec3829bdd9f0abf3abb1c28fdfc6c637c410525 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 19 Aug 2025 13:18:33 +0530 Subject: [PATCH 077/221] Refactor promotion code creation to use partner group ID instead of discount ID, and update related error handling and logging messages. --- .../links/create-promotion-codes/route.ts | 42 +++++----- .../links/invalidate-for-discounts/route.ts | 25 +++--- .../lib/actions/partners/create-discount.ts | 4 +- .../lib/actions/partners/update-discount.ts | 53 +++++++++---- apps/web/lib/zod/schemas/discount.ts | 2 +- .../discounts/add-edit-discount-sheet.tsx | 77 +++++++++---------- 6 files changed, 114 insertions(+), 89 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 02c9869acd3..3e77edfb002 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -9,7 +9,7 @@ import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const schema = z.object({ - discountId: z.string(), + groupId: z.string(), }); // POST /api/cron/links/create-promotion-codes @@ -18,49 +18,51 @@ export async function POST(req: Request) { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); - const { discountId } = schema.parse(JSON.parse(rawBody)); + const { groupId } = schema.parse(JSON.parse(rawBody)); - const discount = await prisma.discount.findUnique({ + const group = await prisma.partnerGroup.findUnique({ where: { - id: discountId, + id: groupId, }, include: { - partnerGroup: true, program: true, + discount: true, }, }); - if (!discount) { + if (!group) { return logAndRespond({ - message: `Discount ${discountId} not found.`, + message: `Partner group ${groupId} not found.`, logLevel: "error", }); } - if (!discount.couponId) { + const { discount, program } = group; + + if (!discount) { return logAndRespond({ - message: `Discount ${discountId} does not have a couponId set.`, + message: `Partner group ${groupId} does not have a discount.`, logLevel: "error", }); } - if (!discount.couponCodeTrackingEnabledAt) { + if (!discount.couponId) { return logAndRespond({ - message: `Discount ${discountId} is not enabled for coupon code tracking.`, + message: `Discount ${discount.id} does not have a couponId set.`, + logLevel: "error", }); } - if (!discount.partnerGroup) { + if (!discount.couponCodeTrackingEnabledAt) { return logAndRespond({ - message: `Discount ${discountId} is not associated with a partner group.`, - logLevel: "error", + message: `Discount ${discount.id} is not enabled for coupon code tracking.`, }); } // Find the workspace for the program const workspace = await prisma.project.findUnique({ where: { - id: discount.program.workspaceId, + id: program.workspaceId, }, select: { stripeConnectId: true, @@ -69,14 +71,14 @@ export async function POST(req: Request) { if (!workspace) { return logAndRespond({ - message: `Workspace ${discount.program.workspaceId} not found.`, + message: `Workspace ${program.workspaceId} not found.`, logLevel: "error", }); } if (!workspace.stripeConnectId) { return logAndRespond({ - message: `Workspace ${discount.program.workspaceId} does not have a stripeConnectId set.`, + message: `Workspace ${program.workspaceId} does not have a stripeConnectId set.`, logLevel: "error", }); } @@ -89,7 +91,7 @@ export async function POST(req: Request) { // Find all enrollments for the partner group const enrollments = await prisma.programEnrollment.findMany({ where: { - groupId: discount.partnerGroup.id, + groupId: group.id, }, orderBy: { id: "desc", @@ -106,7 +108,7 @@ export async function POST(req: Request) { // Find all links for the enrollments const links = await prisma.link.findMany({ where: { - programId: discount.programId, + programId: program.id, partnerId: { in: enrollments.map(({ partnerId }) => partnerId), }, @@ -155,7 +157,7 @@ export async function POST(req: Request) { } return logAndRespond({ - message: `Promotion codes created for discount ${discountId}.`, + message: `Promotion codes created for discount ${discount.id} in group ${groupId}.`, }); } catch (error) { console.log(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 ae5db84003b..2a3fc5f3d13 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,10 @@ export async function POST(req: Request) { }); if (!group) { - console.error(`Group ${groupId} not found.`); - return new Response("OK"); + return logAndRespond({ + message: `Group ${groupId} not found.`, + logLevel: "error", + }); } // Find all the links of the partners in the group @@ -59,30 +62,30 @@ 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({ + message: `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({ + message: `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({ + message: `Expired cache for ${links.length} links.`, + }); } catch (error) { return handleAndReturnErrorResponse(error); } diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index d4abf2499e6..51f81c1de5a 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -77,7 +77,7 @@ export const createDiscountAction = authActionClient type, maxDuration, couponId, - couponTestId, + ...(couponTestId && { couponTestId }), ...(enableCouponTracking && { couponCodeTrackingEnabledAt: new Date(), }), @@ -119,7 +119,7 @@ export const createDiscountAction = authActionClient qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, body: { - discountId: discount.id, + groupId, }, }), diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index a4dc7c585f7..d3f31f0a1d6 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -10,16 +10,11 @@ import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { authActionClient } from "../safe-action"; -// TODO: -// Allow updating couponCodeTrackingEnabledAt -// If couponCodeTrackingEnabledAt is set to true, create promotion codes for all links in the group -// If couponCodeTrackingEnabledAt is set to false, remove promotion codes for all links in the group - export const updateDiscountAction = authActionClient .schema(updateDiscountSchema) .action(async ({ parsedInput, ctx }) => { const { workspace, user } = ctx; - let { discountId, couponId, couponTestId } = parsedInput; + let { discountId, enableCouponTracking, couponTestId } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -33,8 +28,8 @@ export const updateDiscountAction = authActionClient id: discountId, }, data: { - couponId, - couponTestId, + couponTestId: couponTestId || null, + couponCodeTrackingEnabledAt: enableCouponTracking ? new Date() : null, }, include: { partnerGroup: { @@ -45,13 +40,23 @@ export const updateDiscountAction = authActionClient }, }); - const discountChanged = - discount.couponId !== updatedDiscount.couponId || + const couponTestIdChanged = discount.couponTestId !== updatedDiscount.couponTestId; + const trackingEnabledChanged = + discount.couponCodeTrackingEnabledAt !== + updatedDiscount.couponCodeTrackingEnabledAt; + + const shouldCreatePromotionCodes = + discount.couponCodeTrackingEnabledAt === null && + updatedDiscount.couponCodeTrackingEnabledAt !== null; - if (discountChanged) { - waitUntil( - Promise.allSettled([ + const shouldDeletePromotionCodes = + discount.couponCodeTrackingEnabledAt !== null && + updatedDiscount.couponCodeTrackingEnabledAt === null; + + waitUntil( + Promise.allSettled([ + couponTestIdChanged && qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, body: { @@ -59,6 +64,23 @@ export const updateDiscountAction = authActionClient }, }), + shouldCreatePromotionCodes && + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, + body: { + groupId: partnerGroup?.id, + }, + }), + + shouldDeletePromotionCodes && + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/delete-promotion-codes`, + body: { + groupId: partnerGroup?.id, + }, + }), + + (couponTestIdChanged || trackingEnabledChanged) && recordAuditLog({ workspaceId: workspace.id, programId, @@ -73,7 +95,6 @@ export const updateDiscountAction = authActionClient }, ], }), - ]), - ); - } + ]), + ); }); diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index 8ed393fabfa..01231963a0f 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -36,8 +36,8 @@ export const createDiscountSchema = z.object({ export const updateDiscountSchema = createDiscountSchema .pick({ workspaceId: true, - couponId: true, couponTestId: true, + enableCouponTracking: true, }) .extend({ discountId: z.string(), diff --git a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx index fce5323b230..97845c57644 100644 --- a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx +++ b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx @@ -162,23 +162,23 @@ function DiscountSheetContent({ return; } - if (!discount) { - await createDiscount({ - ...data, + if (discount) { + await updateDiscount({ workspaceId, - groupId: group.id, - amount: data.type === "flat" ? data.amount * 100 : data.amount || 0, - maxDuration: - Number(data.maxDuration) === Infinity ? null : data.maxDuration, + discountId: discount.id, + couponTestId: data.couponTestId, + enableCouponTracking: data.enableCouponTracking, }); return; } - await updateDiscount({ + await createDiscount({ + ...data, workspaceId, - discountId: discount.id, - couponId: data.couponId, - couponTestId: data.couponTestId, + groupId: group.id, + amount: data.type === "flat" ? data.amount * 100 : data.amount || 0, + maxDuration: + Number(data.maxDuration) === Infinity ? null : data.maxDuration, }); }; @@ -303,6 +303,7 @@ function DiscountSheetContent({ className="border-border-subtle block w-full rounded-lg bg-white px-3 py-2 text-neutral-800 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" {...register("couponId")} placeholder="XZuejd0Q" + disabled={!!discount} // we don't allow updating the coupon ID for existing discounts /> @@ -355,37 +356,35 @@ function DiscountSheetContent({ )} - {!discount && ( -
- - setValue( - "enableCouponTracking", - !watch("enableCouponTracking"), - ) +
+ + setValue( + "enableCouponTracking", + !watch("enableCouponTracking"), + ) + } + checked={watch("enableCouponTracking")} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> +
+

+ Enable automatic coupon code tracking +

+ + } - checked={watch("enableCouponTracking")} - trackDimensions="w-8 h-4" - thumbDimensions="w-3 h-3" - thumbTranslate="translate-x-4" /> -
-

- Enable automatic coupon code tracking -

- - - } - /> -
- )} +
} From 0d809d3cec5c034442ba93faea4439297d943e7c Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 19 Aug 2025 13:24:00 +0530 Subject: [PATCH 078/221] Update route.ts --- apps/web/app/(ee)/api/partners/links/route.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/web/app/(ee)/api/partners/links/route.ts b/apps/web/app/(ee)/api/partners/links/route.ts index 62b17b02a8f..9aaf20889e1 100644 --- a/apps/web/app/(ee)/api/partners/links/route.ts +++ b/apps/web/app/(ee)/api/partners/links/route.ts @@ -94,15 +94,8 @@ export const POST = withWorkspace( where: partnerId ? { partnerId_programId: { partnerId, programId } } : { tenantId_programId: { tenantId: tenantId!, programId } }, - select: { - partnerId: true, - tenantId: true, - discount: { - select: { - id: true, - couponId: true, - }, - }, + include: { + discount: true, }, }); From 0d3a7b6ba79a3f00517795d88ee1488b2f9590c7 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 19 Aug 2025 13:29:21 +0530 Subject: [PATCH 079/221] Delete delete-partner.ts --- apps/web/lib/api/partners/delete-partner.ts | 85 --------------------- 1 file changed, 85 deletions(-) delete mode 100644 apps/web/lib/api/partners/delete-partner.ts 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 05b818139c9..00000000000 --- a/apps/web/lib/api/partners/delete-partner.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { storage } from "@/lib/storage"; -import { stripe } from "@/lib/stripe"; -import { WorkspaceProps } from "@/lib/types"; -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 -// Not using this anymore -export async function deletePartner({ - partnerId, - workspace, -}: { - partnerId: string; - workspace: Pick; -}) { - 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, - workspace, - }); - - 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}/`, "")); - } -} From 2636391302e590fdbf3d64ed153fb1656d7b3ebb Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 19 Aug 2025 13:30:06 +0530 Subject: [PATCH 080/221] Revert "Delete delete-partner.ts" This reverts commit 0d3a7b6ba79a3f00517795d88ee1488b2f9590c7. --- apps/web/lib/api/partners/delete-partner.ts | 85 +++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 apps/web/lib/api/partners/delete-partner.ts diff --git a/apps/web/lib/api/partners/delete-partner.ts b/apps/web/lib/api/partners/delete-partner.ts new file mode 100644 index 00000000000..05b818139c9 --- /dev/null +++ b/apps/web/lib/api/partners/delete-partner.ts @@ -0,0 +1,85 @@ +import { storage } from "@/lib/storage"; +import { stripe } from "@/lib/stripe"; +import { WorkspaceProps } from "@/lib/types"; +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 +// Not using this anymore +export async function deletePartner({ + partnerId, + workspace, +}: { + partnerId: string; + workspace: Pick; +}) { + 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, + workspace, + }); + + 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}/`, "")); + } +} From 03637bc4820602ff6e54c56ef246474067555a68 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 19 Aug 2025 13:31:45 +0530 Subject: [PATCH 081/221] Update discount.ts --- apps/web/lib/zod/schemas/discount.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index 01231963a0f..0cceb436564 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -27,7 +27,7 @@ export const createDiscountSchema = z.object({ amount: z.number().min(0), type: z.nativeEnum(RewardStructure).default("flat"), maxDuration: maxDurationSchema, - couponId: z.string().nullish(), + couponId: z.string(), couponTestId: z.string().nullish(), groupId: z.string(), enableCouponTracking: z.boolean().default(false), From bcd9639e24b1db439a7ea241b2ceaffffb43fb89 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 19 Aug 2025 14:58:58 +0530 Subject: [PATCH 082/221] Update route.ts --- apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts b/apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts index 5b1c2f59269..73389cdf208 100644 --- a/apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts +++ b/apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts @@ -77,7 +77,6 @@ export const PATCH = withReferralsEmbedToken( domain: link.domain, key: link.key, image: link.image, - couponCode: link.couponCode, }, updatedLink: processedLink, }); From 0535e839947461aff64df066f5109481214f2266 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 19 Aug 2025 15:00:11 +0530 Subject: [PATCH 083/221] Update checkout-session-completed.ts --- .../stripe/integration/webhook/checkout-session-completed.ts | 5 ----- 1 file changed, 5 deletions(-) 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 6f129418da7..0235ee83bc3 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 @@ -221,7 +221,6 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { select: { id: true, domain: true, - couponCodeTrackingEnabledAt: true, }, }, }, @@ -237,10 +236,6 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { const program = workspace.programs[0]; - if (!program.couponCodeTrackingEnabledAt) { - return `Program ${program.id} not enabled coupon code tracking, skipping...`; - } - if (!program.domain) { return `Program ${program.id} has no domain, skipping...`; } From 8ad7b785b72a9d494ad104f4acfaa6bf4452216d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 19 Aug 2025 15:02:56 +0530 Subject: [PATCH 084/221] Update add-edit-discount-sheet.tsx --- apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx index 97845c57644..ce67c8ebf33 100644 --- a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx +++ b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx @@ -102,7 +102,7 @@ function DiscountSheetContent({ defaultValuesSource.maxDuration === null ? Infinity : defaultValuesSource.maxDuration, - couponId: defaultValuesSource.couponId, + couponId: defaultValuesSource.couponId || "", couponTestId: defaultValuesSource.couponTestId, enableCouponTracking: defaultValuesSource.couponCodeTrackingEnabledAt !== null, From 460ad471e1948e7b029c4c95695ccb0f980142ed Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 19 Aug 2025 22:47:27 +0530 Subject: [PATCH 085/221] Add coupon code support to PartnerLinkCard and update schemas - Enhanced PartnerLinkCard to include a button for copying coupon codes to clipboard with user feedback. - Updated LinkSchema to include a nullable couponCode field for tracking. - Modified PartnerProfileLinkSchema to extend LinkSchema with couponCode. --- .../(enrolled)/links/partner-link-card.tsx | 61 ++++++++++++++----- apps/web/lib/zod/schemas/links.ts | 7 ++- apps/web/lib/zod/schemas/partner-profile.ts | 1 + 3 files changed, 52 insertions(+), 17 deletions(-) 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 11f7c717120..9a6b409f347 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 @@ -15,6 +15,8 @@ import { LinkLogo, LoadingSpinner, PenWriting, + Tag, + useCopyToClipboard, useInViewport, UserCheck, useRouterStuff, @@ -24,6 +26,7 @@ import { cn, getApexDomain, getPrettyUrl } from "@dub/utils"; import NumberFlow from "@number-flow/react"; import Link from "next/link"; import { ComponentProps, useMemo, useRef } from "react"; +import { toast } from "sonner"; import { usePartnerLinksContext } from "./page-client"; const CHARTS = [ @@ -109,6 +112,8 @@ export function PartnerLinkCard({ link }: { link: PartnerProfileLinkProps }) { linkKey: link.key, }); + const [copied, copyToClipboard] = useCopyToClipboard(); + return (
-
- - {getPrettyUrl(partnerLink)} - - - - +
+ + {link.comments && } + + {link.couponCode && ( + + )}
+ {/* The max width implementation here is a bit hacky, we should improve in the future */}
diff --git a/apps/web/lib/zod/schemas/links.ts b/apps/web/lib/zod/schemas/links.ts index a0e613d4a4e..db07a6a1f13 100644 --- a/apps/web/lib/zod/schemas/links.ts +++ b/apps/web/lib/zod/schemas/links.ts @@ -695,6 +695,12 @@ export const LinkSchema = z .number() .default(0) .describe("The number of leads that converted to paying customers."), + couponCode: z + .string() + .nullable() + .describe( + "The Stripe coupon code linked to this partner link, used for coupon code-based tracking.", + ), sales: z .number() .default(0) @@ -707,7 +713,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 662c0ccc310..0c5d1ea0b7c 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -82,6 +82,7 @@ export const PartnerProfileLinkSchema = LinkSchema.pick({ sales: true, saleAmount: true, comments: true, + couponCode: true, }).extend({ createdAt: z.string().or(z.date()), }); From 78b76aef7e629a4f8e9b0f118e2b3c98acc0d9f2 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 15:19:27 +0530 Subject: [PATCH 086/221] Update import-partners.ts --- apps/web/scripts/partners/import-partners.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/scripts/partners/import-partners.ts b/apps/web/scripts/partners/import-partners.ts index 5dc8c151cfc..04ba40ab57d 100644 --- a/apps/web/scripts/partners/import-partners.ts +++ b/apps/web/scripts/partners/import-partners.ts @@ -1,4 +1,3 @@ -import { WorkspaceProps } from "@/lib/types"; import { prisma } from "@dub/prisma"; import { nanoid } from "@dub/utils"; import slugify from "@sindresorhus/slugify"; @@ -56,7 +55,12 @@ async function main() { }; const partnerLink = await createPartnerLink({ - workspace: program.workspace as WorkspaceProps, + workspace: { + id: program.workspace.id, + plan: program.workspace.plan as "advanced", + webhookEnabled: program.workspace.webhookEnabled, + stripeConnectId: program.workspace.stripeConnectId, + }, program: { id: programId, domain: program.domain, From 3325476e870f9d321da98332df4204e52a784738 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 15:29:33 +0530 Subject: [PATCH 087/221] Update route.ts --- .../links/create-promotion-codes/route.ts | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 3e77edfb002..26418a148c1 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -31,8 +31,7 @@ export async function POST(req: Request) { }); if (!group) { - return logAndRespond({ - message: `Partner group ${groupId} not found.`, + return logAndRespond(`Partner group ${groupId} not found.`, { logLevel: "error", }); } @@ -40,23 +39,27 @@ export async function POST(req: Request) { const { discount, program } = group; if (!discount) { - return logAndRespond({ - message: `Partner group ${groupId} does not have a discount.`, - logLevel: "error", - }); + return logAndRespond( + `Partner group ${groupId} does not have a discount.`, + { + logLevel: "error", + }, + ); } if (!discount.couponId) { - return logAndRespond({ - message: `Discount ${discount.id} does not have a couponId set.`, - logLevel: "error", - }); + return logAndRespond( + `Discount ${discount.id} does not have a couponId set.`, + { + logLevel: "error", + }, + ); } if (!discount.couponCodeTrackingEnabledAt) { - return logAndRespond({ - message: `Discount ${discount.id} is not enabled for coupon code tracking.`, - }); + return logAndRespond( + `Discount ${discount.id} is not enabled for coupon code tracking.`, + ); } // Find the workspace for the program @@ -70,17 +73,18 @@ export async function POST(req: Request) { }); if (!workspace) { - return logAndRespond({ - message: `Workspace ${program.workspaceId} not found.`, + return logAndRespond(`Workspace ${program.workspaceId} not found.`, { logLevel: "error", }); } if (!workspace.stripeConnectId) { - return logAndRespond({ - message: `Workspace ${program.workspaceId} does not have a stripeConnectId set.`, - logLevel: "error", - }); + return logAndRespond( + `Workspace ${program.workspaceId} does not have a stripeConnectId set.`, + { + logLevel: "error", + }, + ); } let page = 0; @@ -156,9 +160,9 @@ export async function POST(req: Request) { page++; } - return logAndRespond({ - message: `Promotion codes created for discount ${discount.id} in group ${groupId}.`, - }); + return logAndRespond( + `Promotion codes created for discount ${discount.id} in group ${groupId}.`, + ); } catch (error) { console.log(error); From 748a25e89873a45783b7848d7a243dbdf791ce40 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 15:29:34 +0530 Subject: [PATCH 088/221] Update disable-stripe-promotion-code.ts --- .../lib/stripe/disable-stripe-promotion-code.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/stripe/disable-stripe-promotion-code.ts b/apps/web/lib/stripe/disable-stripe-promotion-code.ts index 041a1be8e8d..a906807a609 100644 --- a/apps/web/lib/stripe/disable-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/disable-stripe-promotion-code.ts @@ -26,13 +26,16 @@ export async function disableStripePromotionCode({ ); if (promotionCodes.data.length === 0) { + console.error( + `Stripe promotion code ${promotionCode} not found in the connected account ${stripeConnectId}.`, + ); return; } try { - const promotionCode = promotionCodes.data[0]; + let promotionCode = promotionCodes.data[0]; - return await stripe.promotionCodes.update( + promotionCode = await stripe.promotionCodes.update( promotionCode.id, { active: false, @@ -41,9 +44,16 @@ export async function disableStripePromotionCode({ stripeAccount: stripeConnectId, }, ); + + console.info( + `Stripe promotion code ${promotionCode} in the connected account ${stripeConnectId} has been disabled.`, + ); + + return promotionCode; } catch (error) { console.error( - `Failed to disable Stripe promotion code ${promotionCode} for ${stripeConnectId}: ${error}`, + `Failed to disable Stripe promotion code ${promotionCode} in the connected account ${stripeConnectId}.`, + error, ); throw new Error(error instanceof Error ? error.message : "Unknown error"); From cf966cd5bfdd8cf176617f4a49511913352ac67a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 15:34:14 +0530 Subject: [PATCH 089/221] Update create-discount.ts --- apps/web/lib/actions/partners/create-discount.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 51f81c1de5a..7abc1776ea3 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -50,12 +50,15 @@ export const createDiscountAction = authActionClient } const stripeCoupon = await createStripeCoupon({ - stripeConnectId: workspace.stripeConnectId, coupon: { amount, type, maxDuration: maxDuration ?? null, }, + workspace: { + id: workspace.id, + stripeConnectId: workspace.stripeConnectId, + }, }); if (!stripeCoupon) { From 958b8628c8809968ba27cf471ba6362380172bd1 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 15:34:15 +0530 Subject: [PATCH 090/221] Update create-stripe-coupon.ts --- apps/web/lib/stripe/create-stripe-coupon.ts | 26 ++++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/apps/web/lib/stripe/create-stripe-coupon.ts b/apps/web/lib/stripe/create-stripe-coupon.ts index 0432becb473..4b28b283dc9 100644 --- a/apps/web/lib/stripe/create-stripe-coupon.ts +++ b/apps/web/lib/stripe/create-stripe-coupon.ts @@ -1,5 +1,6 @@ import { Discount } from "@prisma/client"; import { stripeAppClient } from "."; +import { WorkspaceProps } from "../types"; const stripe = stripeAppClient({ ...(process.env.VERCEL_ENV && { livemode: true }), @@ -8,14 +9,14 @@ const stripe = stripeAppClient({ // Create a coupon on Stripe for connected accounts export async function createStripeCoupon({ coupon, - stripeConnectId, + workspace, }: { coupon: Pick; - stripeConnectId: string | null; + workspace: Pick; }) { - if (!stripeConnectId) { + if (!workspace.stripeConnectId) { console.error( - "stripeConnectId not found for the workspace. Stripe coupon creation skipped.", + `stripeConnectId not found for the workspace ${workspace.id}. Skipping Stripe coupon creation.`, ); return; } @@ -36,7 +37,7 @@ export async function createStripeCoupon({ } try { - return await stripe.coupons.create( + const stripeCoupon = await stripe.coupons.create( { currency: "usd", duration, @@ -48,14 +49,21 @@ export async function createStripeCoupon({ : { amount_off: coupon.amount }), }, { - stripeAccount: stripeConnectId, + stripeAccount: workspace.stripeConnectId, }, ); + + console.info( + `Stripe coupon ${stripeCoupon.id} created for workspace ${workspace.id}.`, + ); + + return stripeCoupon; } catch (error) { - console.error( - `Failed to create Stripe coupon for ${stripeConnectId}: ${error}`, + console.log(`Failed create Stripe coupon for workspace ${workspace.id}.`, { + error, coupon, - ); + }); + return null; } } From ebf3178c12967fdc27e5407a95136b36035c578e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 15:57:23 +0530 Subject: [PATCH 091/221] Refactor Stripe promotion code and coupon APIs --- .../links/create-promotion-codes/route.ts | 18 ++++++++-- .../links/delete-promotion-codes/route.ts | 33 ++++++++++-------- .../lib/actions/partners/create-discount.ts | 10 +++--- apps/web/lib/api/links/bulk-delete-links.ts | 5 ++- apps/web/lib/api/links/create-link.ts | 24 ++++++++++--- apps/web/lib/api/links/delete-link.ts | 11 ++++-- apps/web/lib/stripe/create-stripe-coupon.ts | 20 +++++------ .../stripe/create-stripe-promotion-code.ts | 34 +++++++++++-------- .../stripe/disable-stripe-promotion-code.ts | 24 ++++++++----- 9 files changed, 115 insertions(+), 64 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 26418a148c1..1cc060e7f1c 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -68,6 +68,7 @@ export async function POST(req: Request) { id: program.workspaceId, }, select: { + id: true, stripeConnectId: true, }, }); @@ -137,9 +138,20 @@ export async function POST(req: Request) { const results = await Promise.allSettled( linksChunk.map((link) => createStripePromotionCode({ - link, - coupon: discount, - stripeConnectId: workspace.stripeConnectId, + workspace: { + id: workspace.id, + stripeConnectId: workspace.stripeConnectId, + }, + link: { + id: link.id, + key: link.key, + }, + discount: { + id: discount.id, + couponId: discount.couponId, + amount: discount.amount, + type: discount.type, + }, }), ), ); diff --git a/apps/web/app/(ee)/api/cron/links/delete-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/delete-promotion-codes/route.ts index 90b23b3c509..e5825fd9a41 100644 --- a/apps/web/app/(ee)/api/cron/links/delete-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/delete-promotion-codes/route.ts @@ -30,8 +30,7 @@ export async function POST(req: Request) { }); if (!group) { - return logAndRespond({ - message: `Partner group ${groupId} not found.`, + return logAndRespond(`Partner group ${groupId} not found.`, { logLevel: "error", }); } @@ -42,22 +41,27 @@ export async function POST(req: Request) { id: group.program.workspaceId, }, select: { + id: true, stripeConnectId: true, }, }); if (!workspace) { - return logAndRespond({ - message: `Workspace ${group.program.workspaceId} not found.`, - logLevel: "error", - }); + return logAndRespond( + `Workspace ${group.program.workspaceId} not found.`, + { + logLevel: "error", + }, + ); } if (!workspace.stripeConnectId) { - return logAndRespond({ - message: `Workspace ${group.program.workspaceId} does not have a stripeConnectId set.`, - logLevel: "error", - }); + return logAndRespond( + `Workspace ${group.program.workspaceId} does not have a stripeConnectId set.`, + { + logLevel: "error", + }, + ); } let page = 0; @@ -112,8 +116,11 @@ export async function POST(req: Request) { const results = await Promise.allSettled( linksChunk.map((link) => disableStripePromotionCode({ + workspace: { + id: workspace.id, + stripeConnectId: workspace.stripeConnectId, + }, promotionCode: link.couponCode, - stripeConnectId: workspace.stripeConnectId, }), ), ); @@ -145,9 +152,7 @@ export async function POST(req: Request) { page++; } - return logAndRespond({ - message: `Promotion codes deleted for group ${groupId}.`, - }); + return logAndRespond(`Promotion codes deleted for group ${groupId}.`); } catch (error) { console.log(error); diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 7abc1776ea3..f2beab8f049 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -50,15 +50,15 @@ export const createDiscountAction = authActionClient } const stripeCoupon = await createStripeCoupon({ - coupon: { - amount, - type, - maxDuration: maxDuration ?? null, - }, workspace: { id: workspace.id, stripeConnectId: workspace.stripeConnectId, }, + discount: { + amount, + type, + maxDuration: maxDuration ?? null, + }, }); if (!stripeCoupon) { diff --git a/apps/web/lib/api/links/bulk-delete-links.ts b/apps/web/lib/api/links/bulk-delete-links.ts index 33ba76e6700..efe21428727 100644 --- a/apps/web/lib/api/links/bulk-delete-links.ts +++ b/apps/web/lib/api/links/bulk-delete-links.ts @@ -50,8 +50,11 @@ export async function bulkDeleteLinks({ .filter((link) => link.couponCode) .map((link) => disableStripePromotionCode({ + workspace: { + id: workspace.id, + stripeConnectId: workspace.stripeConnectId, + }, promotionCode: link.couponCode, - stripeConnectId: workspace.stripeConnectId, }), ) : [], diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts index 2cd0b4c89d2..044b08efad6 100644 --- a/apps/web/lib/api/links/create-link.ts +++ b/apps/web/lib/api/links/create-link.ts @@ -28,7 +28,7 @@ type CreateLinkProps = ProcessedLinkProps & { workspace?: Pick; discount?: Pick< DiscountProps, - "couponId" | "couponCodeTrackingEnabledAt" | "amount" | "type" + "id" | "couponId" | "couponCodeTrackingEnabledAt" | "amount" | "type" > | null; skipCouponCreation?: boolean; // Skip Stripe promotion code creation for the link }; @@ -191,6 +191,7 @@ export async function createLink(link: CreateLinkProps) { select: { discount: { select: { + id: true, couponId: true, couponCodeTrackingEnabledAt: true, amount: true, @@ -262,12 +263,25 @@ export async function createLink(link: CreateLinkProps) { // Create promotion code for the link !skipCouponCreation && - link.projectId && + link && discount?.couponCodeTrackingEnabledAt && + workspace && + link.id && createStripePromotionCode({ - link: response, - coupon: discount, - stripeConnectId: workspace?.stripeConnectId!, + workspace: { + id: workspace.id, + stripeConnectId: workspace.stripeConnectId, + }, + link: { + id: link.id, + key: link.key, + }, + discount: { + id: discount.id, + couponId: discount.couponId, + amount: discount.amount, + type: discount.type, + }, }), ]); })(), diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index 59fb557d7e8..7cc3e75485e 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -17,12 +17,15 @@ export async function deleteLink(linkId: string) { ...includeTags, project: { select: { + id: true, stripeConnectId: true, }, }, }, }); + const { project: workspace } = link; + waitUntil( Promise.allSettled([ // if there's a valid image and it has the same link ID, delete it @@ -49,11 +52,13 @@ export async function deleteLink(linkId: string) { }, }), - link.project && - link.couponCode && + workspace && disableStripePromotionCode({ + workspace: { + id: workspace.id, + stripeConnectId: workspace.stripeConnectId, + }, promotionCode: link.couponCode, - stripeConnectId: link.project.stripeConnectId, }), ]), ); diff --git a/apps/web/lib/stripe/create-stripe-coupon.ts b/apps/web/lib/stripe/create-stripe-coupon.ts index 4b28b283dc9..015f0eb34ec 100644 --- a/apps/web/lib/stripe/create-stripe-coupon.ts +++ b/apps/web/lib/stripe/create-stripe-coupon.ts @@ -8,11 +8,11 @@ const stripe = stripeAppClient({ // Create a coupon on Stripe for connected accounts export async function createStripeCoupon({ - coupon, workspace, + discount, }: { - coupon: Pick; workspace: Pick; + discount: Pick; }) { if (!workspace.stripeConnectId) { console.error( @@ -24,16 +24,16 @@ export async function createStripeCoupon({ let duration: "once" | "repeating" | "forever" = "once"; let durationInMonths: number | undefined = undefined; - if (coupon.maxDuration === null) { + if (discount.maxDuration === null) { duration = "forever"; - } else if (coupon.maxDuration === 0) { + } else if (discount.maxDuration === 0) { duration = "once"; } else { duration = "repeating"; } - if (duration === "repeating" && coupon.maxDuration) { - durationInMonths = coupon.maxDuration; + if (duration === "repeating" && discount.maxDuration) { + durationInMonths = discount.maxDuration; } try { @@ -44,9 +44,9 @@ export async function createStripeCoupon({ ...(duration === "repeating" && { duration_in_months: durationInMonths, }), - ...(coupon.type === "percentage" - ? { percent_off: coupon.amount } - : { amount_off: coupon.amount }), + ...(discount.type === "percentage" + ? { percent_off: discount.amount } + : { amount_off: discount.amount }), }, { stripeAccount: workspace.stripeConnectId, @@ -61,7 +61,7 @@ export async function createStripeCoupon({ } catch (error) { console.log(`Failed create Stripe coupon for workspace ${workspace.id}.`, { error, - coupon, + discount, }); return null; diff --git a/apps/web/lib/stripe/create-stripe-promotion-code.ts b/apps/web/lib/stripe/create-stripe-promotion-code.ts index db55e96bf8d..aeb7dd2cb23 100644 --- a/apps/web/lib/stripe/create-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/create-stripe-promotion-code.ts @@ -1,32 +1,32 @@ import { prisma } from "@dub/prisma"; import { stripeAppClient } from "."; -import { DiscountProps, LinkProps } from "../types"; +import { DiscountProps, LinkProps, WorkspaceProps } from "../types"; const stripe = stripeAppClient({ ...(process.env.VERCEL_ENV && { livemode: true }), }); -const MAX_RETRIES = 2; +const MAX_RETRIES = 3; export async function createStripePromotionCode({ + workspace, + discount, link, - coupon, - stripeConnectId, }: { + workspace: Pick; + discount: Pick; link: Pick; - coupon: Pick; - stripeConnectId: string | null; }) { - if (!coupon.couponId) { + if (!workspace.stripeConnectId) { console.error( - "couponId not found for the discount. Stripe promotion code creation skipped.", + `stripeConnectId not found for the workspace ${workspace.id}. Stripe promotion code creation skipped.`, ); return; } - if (!stripeConnectId) { + if (!discount.couponId) { console.error( - "stripeConnectId not found for the workspace. Stripe promotion code creation skipped.", + `couponId not found for the discount ${discount.id}. Stripe promotion code creation skipped.`, ); return; } @@ -35,7 +35,7 @@ export async function createStripePromotionCode({ let couponCode: string | undefined; const amount = - coupon.type === "percentage" ? coupon.amount : coupon.amount / 100; + discount.type === "percentage" ? discount.amount : discount.amount / 100; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { @@ -43,11 +43,11 @@ export async function createStripePromotionCode({ const promotionCode = await stripe.promotionCodes.create( { - coupon: coupon.couponId, + coupon: discount.couponId, code: couponCode.toUpperCase(), }, { - stripeAccount: stripeConnectId, + stripeAccount: workspace.stripeConnectId, }, ); @@ -60,19 +60,23 @@ export async function createStripePromotionCode({ couponCode: promotionCode.code, }, }); + + console.info( + `Promotion code ${promotionCode.code} created for link ${link.id} for the discount ${discount.id}.`, + ); } return promotionCode; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); - console.error(lastError); - const isDuplicateError = error instanceof Error && error.message.includes("An active promotion code with `code:") && error.message.includes("already exists"); + console.error(error.message); + if (isDuplicateError) { if (attempt === MAX_RETRIES) { throw lastError; diff --git a/apps/web/lib/stripe/disable-stripe-promotion-code.ts b/apps/web/lib/stripe/disable-stripe-promotion-code.ts index a906807a609..861c0b5116d 100644 --- a/apps/web/lib/stripe/disable-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/disable-stripe-promotion-code.ts @@ -1,17 +1,25 @@ import { stripeAppClient } from "."; +import { WorkspaceProps } from "../types"; const stripe = stripeAppClient({ ...(process.env.VERCEL_ENV && { livemode: true }), }); export async function disableStripePromotionCode({ + workspace, promotionCode, - stripeConnectId, }: { + workspace: Pick; promotionCode: string | null; - stripeConnectId: string | null; }) { - if (!promotionCode || !stripeConnectId) { + if (!promotionCode) { + return; + } + + if (!workspace.stripeConnectId) { + console.error( + `stripeConnectId not found for the workspace ${workspace.id}. Skipping Stripe coupon creation.`, + ); return; } @@ -21,13 +29,13 @@ export async function disableStripePromotionCode({ limit: 1, }, { - stripeAccount: stripeConnectId, + stripeAccount: workspace.stripeConnectId, }, ); if (promotionCodes.data.length === 0) { console.error( - `Stripe promotion code ${promotionCode} not found in the connected account ${stripeConnectId}.`, + `Stripe promotion code ${promotionCode} not found in the connected account ${workspace.stripeConnectId}.`, ); return; } @@ -41,18 +49,18 @@ export async function disableStripePromotionCode({ active: false, }, { - stripeAccount: stripeConnectId, + stripeAccount: workspace.stripeConnectId, }, ); console.info( - `Stripe promotion code ${promotionCode} in the connected account ${stripeConnectId} has been disabled.`, + `Stripe promotion code ${promotionCode} in the connected account ${workspace.stripeConnectId} has been disabled.`, ); return promotionCode; } catch (error) { console.error( - `Failed to disable Stripe promotion code ${promotionCode} in the connected account ${stripeConnectId}.`, + `Failed to disable Stripe promotion code ${promotionCode} in the connected account ${workspace.stripeConnectId}.`, error, ); From a8e9b8f3a6fe7eec37ad9f87bf585fca1ab47234 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 16:43:36 +0530 Subject: [PATCH 092/221] Refactor promotion code creation logic --- .../links/create-promotion-codes/route.ts | 17 +---- .../lib/actions/partners/update-discount.ts | 71 +++++++++++++------ apps/web/lib/api/links/create-link.ts | 13 +--- .../partners/approve-partner-enrollment.ts | 11 ++- .../stripe/create-stripe-promotion-code.ts | 2 +- 5 files changed, 66 insertions(+), 48 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 1cc060e7f1c..3fe22e689ad 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -138,20 +138,9 @@ export async function POST(req: Request) { const results = await Promise.allSettled( linksChunk.map((link) => createStripePromotionCode({ - workspace: { - id: workspace.id, - stripeConnectId: workspace.stripeConnectId, - }, - link: { - id: link.id, - key: link.key, - }, - discount: { - id: discount.id, - couponId: discount.couponId, - amount: discount.amount, - type: discount.type, - }, + workspace, + link, + discount, }), ), ); diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index d3f31f0a1d6..500f48c7f67 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -4,6 +4,7 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; 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"; +import { DiscountProps } from "@/lib/types"; import { updateDiscountSchema } from "@/lib/zod/schemas/discount"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; @@ -14,7 +15,7 @@ export const updateDiscountAction = authActionClient .schema(updateDiscountSchema) .action(async ({ parsedInput, ctx }) => { const { workspace, user } = ctx; - let { discountId, enableCouponTracking, couponTestId } = parsedInput; + const { discountId, enableCouponTracking, couponTestId } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -23,13 +24,19 @@ export const updateDiscountAction = authActionClient discountId, }); + const couponCodeTrackingEnabledAt = enableCouponTracking + ? discount.couponCodeTrackingEnabledAt ?? new Date() // enable once + : discount.couponCodeTrackingEnabledAt + ? null // disable + : null; // already disabled, keep null + const { partnerGroup, ...updatedDiscount } = await prisma.discount.update({ where: { id: discountId, }, data: { couponTestId: couponTestId || null, - couponCodeTrackingEnabledAt: enableCouponTracking ? new Date() : null, + couponCodeTrackingEnabledAt, }, include: { partnerGroup: { @@ -40,47 +47,43 @@ export const updateDiscountAction = authActionClient }, }); - const couponTestIdChanged = - discount.couponTestId !== updatedDiscount.couponTestId; - const trackingEnabledChanged = - discount.couponCodeTrackingEnabledAt !== - updatedDiscount.couponCodeTrackingEnabledAt; - - const shouldCreatePromotionCodes = - discount.couponCodeTrackingEnabledAt === null && - updatedDiscount.couponCodeTrackingEnabledAt !== null; - - const shouldDeletePromotionCodes = - discount.couponCodeTrackingEnabledAt !== null && - updatedDiscount.couponCodeTrackingEnabledAt === null; + const { + couponTestIdChanged, + trackingStatusChanged, + trackingEnabled, + promotionCodesDisabled, + } = detectDiscountChanges(discount, updatedDiscount); waitUntil( Promise.allSettled([ couponTestIdChanged && + partnerGroup && qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, body: { - groupId: partnerGroup?.id, + groupId: partnerGroup.id, }, }), - shouldCreatePromotionCodes && + trackingEnabled && + partnerGroup && qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, body: { - groupId: partnerGroup?.id, + groupId: partnerGroup.id, }, }), - shouldDeletePromotionCodes && + promotionCodesDisabled && + partnerGroup && qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/delete-promotion-codes`, body: { - groupId: partnerGroup?.id, + groupId: partnerGroup.id, }, }), - (couponTestIdChanged || trackingEnabledChanged) && + (couponTestIdChanged || trackingStatusChanged) && recordAuditLog({ workspaceId: workspace.id, programId, @@ -98,3 +101,29 @@ export const updateDiscountAction = authActionClient ]), ); }); + +function detectDiscountChanges( + prev: Pick, + next: Pick, +) { + const couponTestIdChanged = prev.couponTestId !== next.couponTestId; + + const trackingStatusChanged = + prev.couponCodeTrackingEnabledAt?.getTime() !== + next.couponCodeTrackingEnabledAt?.getTime(); + + const trackingEnabled = + prev.couponCodeTrackingEnabledAt === null && + next.couponCodeTrackingEnabledAt !== null; + + const promotionCodesDisabled = + prev.couponCodeTrackingEnabledAt !== null && + next.couponCodeTrackingEnabledAt === null; + + return { + couponTestIdChanged, + trackingStatusChanged, + trackingEnabled, + promotionCodesDisabled, + }; +} diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts index 044b08efad6..fc23a3e4861 100644 --- a/apps/web/lib/api/links/create-link.ts +++ b/apps/web/lib/api/links/create-link.ts @@ -263,25 +263,16 @@ export async function createLink(link: CreateLinkProps) { // Create promotion code for the link !skipCouponCreation && - link && discount?.couponCodeTrackingEnabledAt && workspace && link.id && createStripePromotionCode({ - workspace: { - id: workspace.id, - stripeConnectId: workspace.stripeConnectId, - }, + workspace, link: { id: link.id, key: link.key, }, - discount: { - id: discount.id, - couponId: discount.couponId, - amount: discount.amount, - type: discount.type, - }, + discount }), ]); })(), diff --git a/apps/web/lib/partners/approve-partner-enrollment.ts b/apps/web/lib/partners/approve-partner-enrollment.ts index 844c684f0cc..24faa492463 100644 --- a/apps/web/lib/partners/approve-partner-enrollment.ts +++ b/apps/web/lib/partners/approve-partner-enrollment.ts @@ -7,6 +7,7 @@ import { waitUntil } from "@vercel/functions"; import { recordAuditLog } from "../api/audit-logs/record-audit-log"; import { getGroupOrThrow } from "../api/groups/get-group-or-throw"; import { createPartnerLink } from "../api/partners/create-partner-link"; +import { createStripePromotionCode } from "../stripe/create-stripe-promotion-code"; import { recordLink } from "../tinybird/record-link"; import { ProgramPartnerLinkProps, RewardProps, WorkspaceProps } from "../types"; import { sendWorkspaceWebhook } from "../webhook/publish"; @@ -84,6 +85,7 @@ export async function approvePartnerEnrollment({ discountId: group.discountId, }, include: { + discount: true, partner: { include: { users: { @@ -124,7 +126,7 @@ export async function approvePartnerEnrollment({ ]); let partnerLink: ProgramPartnerLinkProps; - const { partner, ...enrollment } = programEnrollment; + const { partner, discount, ...enrollment } = programEnrollment; const workspace = program.workspace as WorkspaceProps; if (updatedLink) { @@ -223,6 +225,13 @@ export async function approvePartnerEnrollment({ }, ], }), + + discount?.couponCodeTrackingEnabledAt && + createStripePromotionCode({ + workspace, + link: partnerLink, + discount, + }), ]); })(), ); diff --git a/apps/web/lib/stripe/create-stripe-promotion-code.ts b/apps/web/lib/stripe/create-stripe-promotion-code.ts index aeb7dd2cb23..dad2a267eda 100644 --- a/apps/web/lib/stripe/create-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/create-stripe-promotion-code.ts @@ -62,7 +62,7 @@ export async function createStripePromotionCode({ }); console.info( - `Promotion code ${promotionCode.code} created for link ${link.id} for the discount ${discount.id}.`, + `Created promotion code ${promotionCode.code} (discount=${discount.id}, link=${link.id})`, ); } From ad1097d13f3c7213ee9ad65b75d87107db740949 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 16:53:16 +0530 Subject: [PATCH 093/221] skip coupon code creation if existing code exists --- .../(ee)/api/cron/links/create-promotion-codes/route.ts | 4 ++++ apps/web/lib/api/links/create-link.ts | 7 ++----- apps/web/lib/stripe/create-stripe-promotion-code.ts | 9 ++++++++- apps/web/lib/zod/schemas/programs.ts | 1 + 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 3fe22e689ad..932a1fcf375 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -97,6 +97,9 @@ export async function POST(req: Request) { const enrollments = await prisma.programEnrollment.findMany({ where: { groupId: group.id, + status: { + in: ["approved"], + }, }, orderBy: { id: "desc", @@ -122,6 +125,7 @@ export async function POST(req: Request) { select: { id: true, key: true, + couponCode: true, }, }); diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts index fc23a3e4861..efc99c86b56 100644 --- a/apps/web/lib/api/links/create-link.ts +++ b/apps/web/lib/api/links/create-link.ts @@ -268,11 +268,8 @@ export async function createLink(link: CreateLinkProps) { link.id && createStripePromotionCode({ workspace, - link: { - id: link.id, - key: link.key, - }, - discount + link: response, + discount, }), ]); })(), diff --git a/apps/web/lib/stripe/create-stripe-promotion-code.ts b/apps/web/lib/stripe/create-stripe-promotion-code.ts index dad2a267eda..711e2a41719 100644 --- a/apps/web/lib/stripe/create-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/create-stripe-promotion-code.ts @@ -15,8 +15,15 @@ export async function createStripePromotionCode({ }: { workspace: Pick; discount: Pick; - link: Pick; + link: Pick; }) { + if (link.couponCode) { + console.log( + `Promotion code ${link.couponCode} already exists for link ${link.id}. Stripe promotion code creation skipped.`, + ); + return; + } + if (!workspace.stripeConnectId) { console.error( `stripeConnectId not found for the workspace ${workspace.id}. Stripe promotion code creation skipped.`, diff --git a/apps/web/lib/zod/schemas/programs.ts b/apps/web/lib/zod/schemas/programs.ts index 62c7084b7f1..eb682d0bf04 100644 --- a/apps/web/lib/zod/schemas/programs.ts +++ b/apps/web/lib/zod/schemas/programs.ts @@ -86,6 +86,7 @@ export const ProgramPartnerLinkSchema = LinkSchema.pick({ leads: true, sales: true, saleAmount: true, + couponCode: true, }); export const ProgramEnrollmentSchema = z.object({ From feb38cffd0d61c2a0c1da1a05796ca5a35657b31 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 17:36:13 +0530 Subject: [PATCH 094/221] Update ban-partner.ts --- apps/web/lib/actions/partners/ban-partner.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/lib/actions/partners/ban-partner.ts b/apps/web/lib/actions/partners/ban-partner.ts index 976c8c699a3..14b268f9dc0 100644 --- a/apps/web/lib/actions/partners/ban-partner.ts +++ b/apps/web/lib/actions/partners/ban-partner.ts @@ -5,6 +5,7 @@ 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"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; +import { disableStripePromotionCode } from "@/lib/stripe/disable-stripe-promotion-code"; import { BAN_PARTNER_REASONS, banPartnerSchema, @@ -105,6 +106,7 @@ export const banPartnerAction = authActionClient select: { domain: true, key: true, + couponCode: true, }, }); @@ -143,6 +145,13 @@ export const banPartnerAction = authActionClient }, ], }), + + ...links.map((link) => + disableStripePromotionCode({ + workspace, + promotionCode: link.couponCode, + }), + ), ]); })(), ); From ac1c411372b5982401caa52f263fe38231a9de4b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 17:36:18 +0530 Subject: [PATCH 095/221] Update create-link.ts --- apps/web/lib/api/links/create-link.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts index efc99c86b56..2326d4e7edd 100644 --- a/apps/web/lib/api/links/create-link.ts +++ b/apps/web/lib/api/links/create-link.ts @@ -188,16 +188,8 @@ export async function createLink(link: CreateLinkProps) { programId: link.programId, }, }, - select: { - discount: { - select: { - id: true, - couponId: true, - couponCodeTrackingEnabledAt: true, - amount: true, - type: true, - }, - }, + include: { + discount: true, }, }); @@ -261,11 +253,10 @@ export async function createLink(link: CreateLinkProps) { testVariants && testCompletedAt && scheduleABTestCompletion(response), - // Create promotion code for the link + // Create promotion code for the partner link !skipCouponCreation && - discount?.couponCodeTrackingEnabledAt && workspace && - link.id && + discount && createStripePromotionCode({ workspace, link: response, @@ -283,4 +274,4 @@ export async function createLink(link: CreateLinkProps) { ? uploadedImageUrl : response.image, }; -} +} \ No newline at end of file From 257e0a110336cf5574ae394fc728eeba487b397a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 17:36:21 +0530 Subject: [PATCH 096/221] Update create-stripe-promotion-code.ts --- apps/web/lib/stripe/create-stripe-promotion-code.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/stripe/create-stripe-promotion-code.ts b/apps/web/lib/stripe/create-stripe-promotion-code.ts index 711e2a41719..09646882185 100644 --- a/apps/web/lib/stripe/create-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/create-stripe-promotion-code.ts @@ -14,9 +14,19 @@ export async function createStripePromotionCode({ link, }: { workspace: Pick; - discount: Pick; + discount: Pick< + DiscountProps, + "id" | "couponId" | "amount" | "type" | "couponCodeTrackingEnabledAt" + >; link: Pick; }) { + if (!discount.couponCodeTrackingEnabledAt) { + console.log( + `Coupon code tracking is not enabled for discount ${discount.id}. Stripe promotion code creation skipped.`, + ); + return; + } + if (link.couponCode) { console.log( `Promotion code ${link.couponCode} already exists for link ${link.id}. Stripe promotion code creation skipped.`, From f51c562ceb05f92f839935c60572869a68822010 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 17:36:23 +0530 Subject: [PATCH 097/221] Update disable-stripe-promotion-code.ts --- apps/web/lib/stripe/disable-stripe-promotion-code.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/stripe/disable-stripe-promotion-code.ts b/apps/web/lib/stripe/disable-stripe-promotion-code.ts index 861c0b5116d..4213ebbfbc4 100644 --- a/apps/web/lib/stripe/disable-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/disable-stripe-promotion-code.ts @@ -35,7 +35,7 @@ export async function disableStripePromotionCode({ if (promotionCodes.data.length === 0) { console.error( - `Stripe promotion code ${promotionCode} not found in the connected account ${workspace.stripeConnectId}.`, + `Stripe promotion code ${promotionCode} not found (stripeConnectId=${workspace.stripeConnectId}).`, ); return; } @@ -54,13 +54,13 @@ export async function disableStripePromotionCode({ ); console.info( - `Stripe promotion code ${promotionCode} in the connected account ${workspace.stripeConnectId} has been disabled.`, + `Disabled Stripe promotion code ${promotionCode.code} (id=${promotionCode.id}, stripeConnectId=${workspace.stripeConnectId}).`, ); return promotionCode; } catch (error) { console.error( - `Failed to disable Stripe promotion code ${promotionCode} in the connected account ${workspace.stripeConnectId}.`, + `Failed to disable Stripe promotion code ${promotionCode} (stripeConnectId=${workspace.stripeConnectId}).`, error, ); From 2edcffa90b0af23f5f5def78815bf28ab8673df0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 17:48:15 +0530 Subject: [PATCH 098/221] fix coupon deletion --- .../links/delete-promotion-codes/route.ts | 5 +---- apps/web/lib/api/links/bulk-delete-links.ts | 19 ++++++------------- apps/web/lib/api/links/delete-link.ts | 5 +---- apps/web/lib/zod/schemas/links.ts | 6 ------ apps/web/lib/zod/schemas/partner-profile.ts | 2 +- 5 files changed, 9 insertions(+), 28 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/delete-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/delete-promotion-codes/route.ts index e5825fd9a41..63564d72646 100644 --- a/apps/web/app/(ee)/api/cron/links/delete-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/delete-promotion-codes/route.ts @@ -116,10 +116,7 @@ export async function POST(req: Request) { const results = await Promise.allSettled( linksChunk.map((link) => disableStripePromotionCode({ - workspace: { - id: workspace.id, - stripeConnectId: workspace.stripeConnectId, - }, + workspace, promotionCode: link.couponCode, }), ), diff --git a/apps/web/lib/api/links/bulk-delete-links.ts b/apps/web/lib/api/links/bulk-delete-links.ts index efe21428727..55b9ef9da29 100644 --- a/apps/web/lib/api/links/bulk-delete-links.ts +++ b/apps/web/lib/api/links/bulk-delete-links.ts @@ -45,18 +45,11 @@ export async function bulkDeleteLinks({ }, }), - workspace.stripeConnectId - ? links - .filter((link) => link.couponCode) - .map((link) => - disableStripePromotionCode({ - workspace: { - id: workspace.id, - stripeConnectId: workspace.stripeConnectId, - }, - promotionCode: link.couponCode, - }), - ) - : [], + ...links.map((link) => + disableStripePromotionCode({ + workspace, + promotionCode: link.couponCode, + }), + ), ]); } diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index 7cc3e75485e..1f2b59936c5 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -54,10 +54,7 @@ export async function deleteLink(linkId: string) { workspace && disableStripePromotionCode({ - workspace: { - id: workspace.id, - stripeConnectId: workspace.stripeConnectId, - }, + workspace, promotionCode: link.couponCode, }), ]), diff --git a/apps/web/lib/zod/schemas/links.ts b/apps/web/lib/zod/schemas/links.ts index db07a6a1f13..4c082a3f4b7 100644 --- a/apps/web/lib/zod/schemas/links.ts +++ b/apps/web/lib/zod/schemas/links.ts @@ -695,12 +695,6 @@ export const LinkSchema = z .number() .default(0) .describe("The number of leads that converted to paying customers."), - couponCode: z - .string() - .nullable() - .describe( - "The Stripe coupon code linked to this partner link, used for coupon code-based tracking.", - ), sales: z .number() .default(0) diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index 0c5d1ea0b7c..d584590e55f 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -82,9 +82,9 @@ export const PartnerProfileLinkSchema = LinkSchema.pick({ sales: true, saleAmount: true, comments: true, - couponCode: true, }).extend({ createdAt: z.string().or(z.date()), + couponCode: z.string().nullable(), }); export const PartnerProfileCustomerSchema = CustomerEnrichedSchema.pick({ From 13c4a5e8ea0396fd79e453349354e67337412265 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 20:45:51 +0530 Subject: [PATCH 099/221] improve the cron job --- .../links/create-promotion-codes/route.ts | 215 ++++++++++-------- .../lib/actions/partners/create-discount.ts | 2 +- .../lib/actions/partners/update-discount.ts | 2 +- 3 files changed, 125 insertions(+), 94 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 932a1fcf375..1f7a204cca1 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -1,15 +1,17 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; import { prisma } from "@dub/prisma"; -import { chunk } from "@dub/utils"; +import { APP_DOMAIN_WITH_NGROK, chunk, log } from "@dub/utils"; import { z } from "zod"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const schema = z.object({ - groupId: z.string(), + discountId: z.string(), + page: z.number().optional().default(0), }); // POST /api/cron/links/create-promotion-codes @@ -18,35 +20,25 @@ export async function POST(req: Request) { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); - const { groupId } = schema.parse(JSON.parse(rawBody)); + let { discountId, page } = schema.parse(JSON.parse(rawBody)); - const group = await prisma.partnerGroup.findUnique({ + // Find the discount + const discount = await prisma.discount.findUnique({ where: { - id: groupId, + id: discountId, }, include: { program: true, - discount: true, + partnerGroup: true, }, }); - if (!group) { - return logAndRespond(`Partner group ${groupId} not found.`, { + if (!discount) { + return logAndRespond(`Discount ${discountId} not found.`, { logLevel: "error", }); } - const { discount, program } = group; - - if (!discount) { - return logAndRespond( - `Partner group ${groupId} does not have a discount.`, - { - logLevel: "error", - }, - ); - } - if (!discount.couponId) { return logAndRespond( `Discount ${discount.id} does not have a couponId set.`, @@ -62,6 +54,17 @@ export async function POST(req: Request) { ); } + const { program, partnerGroup: group } = discount; + + if (!group) { + return logAndRespond( + `Discount ${discountId} does not associate with a partner group.`, + { + logLevel: "error", + }, + ); + } + // Find the workspace for the program const workspace = await prisma.project.findUnique({ where: { @@ -88,88 +91,116 @@ export async function POST(req: Request) { ); } - let page = 0; - let hasMore = true; - const pageSize = 50; - - while (hasMore) { - // Find all enrollments for the partner group - const enrollments = await prisma.programEnrollment.findMany({ - where: { - groupId: group.id, - status: { - in: ["approved"], - }, - }, - orderBy: { - id: "desc", + const PAGE_SIZE = 50; + const STRIPE_PROMO_BATCH_SIZE = 10; + + // Find the program enrollments for the partner group + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + groupId: group.id, + status: { + in: ["approved"], }, - take: pageSize, - skip: page * pageSize, - }); + }, + orderBy: { + id: "desc", + }, + take: PAGE_SIZE, + skip: page * PAGE_SIZE, + }); - if (enrollments.length === 0) { - hasMore = false; - break; - } - - // Find all links for the enrollments - const links = await prisma.link.findMany({ - where: { - programId: program.id, - partnerId: { - in: enrollments.map(({ partnerId }) => partnerId), - }, - couponCode: null, + // Finished processing all the program enrollments + if (programEnrollments.length === 0) { + return logAndRespond( + `No more program enrollments found in the partner group ${group.id}.`, + ); + } + + page++; + + // Find the partner links for the enrollments + const links = await prisma.link.findMany({ + where: { + programId: program.id, + partnerId: { + in: programEnrollments.map(({ partnerId }) => partnerId), }, - select: { - id: true, - key: true, - couponCode: true, + couponCode: null, + }, + select: { + id: true, + key: true, + couponCode: true, + }, + orderBy: { + id: "desc", + }, + }); + + // If no links are found, schedule the next batch + if (links.length === 0) { + await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, + body: { + discountId: discount.id, + page, }, }); - if (links.length === 0) { - page++; - continue; - } - - const linksChunks = chunk(links, 50); - const failedRequests: Error[] = []; - - // Create promotion codes in batches for the partner links - for (const linksChunk of linksChunks) { - const results = await Promise.allSettled( - linksChunk.map((link) => - createStripePromotionCode({ - workspace, - link, - discount, - }), - ), - ); - - results.forEach((result) => { - if (result.status === "rejected") { - failedRequests.push(result.reason); - } - }); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - - if (failedRequests.length > 0) { - console.error(failedRequests); - } - - page++; + return logAndRespond(`Scheduled the next batch (page=${page}).`); } - return logAndRespond( - `Promotion codes created for discount ${discount.id} in group ${groupId}.`, - ); + const linksChunks = chunk(links, STRIPE_PROMO_BATCH_SIZE); + const failedRequests: Error[] = []; + + // Create promotion codes in batches for the partner links + for (const linksChunk of linksChunks) { + const results = await Promise.allSettled( + linksChunk.map((link) => + createStripePromotionCode({ + workspace, + link, + discount, + }), + ), + ); + + results.forEach((result) => { + if (result.status === "rejected") { + failedRequests.push(result.reason); + } + }); + + // Wait for 2 second before the next batch + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + if (failedRequests.length > 0) { + await log({ + message: `Error creating promotion codes for discount ${discount.id} - ${failedRequests.map((error) => error.message).join(", ")}`, + type: "alerts", + }); + + console.error(failedRequests); + } + + // Schedule the next batch + await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, + body: { + discountId: discount.id, + page, + }, + }); + + return logAndRespond(`Scheduled the next batch (page=${page}).`); } catch (error) { - console.log(error); + await log({ + message: `Error creating promotion codes for discount - ${error.message}`, + type: "alerts", + }); + + console.error(error); return handleAndReturnErrorResponse(error); } diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index f2beab8f049..d1ad3286aa7 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -122,7 +122,7 @@ export const createDiscountAction = authActionClient qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, body: { - groupId, + discountId: discount.id, }, }), diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index 500f48c7f67..4f265b32bd8 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -70,7 +70,7 @@ export const updateDiscountAction = authActionClient qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, body: { - groupId: partnerGroup.id, + discountId: discount.id, }, }), From 079e4b76374596298007efa1bef9d62dd678774d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 20:48:46 +0530 Subject: [PATCH 100/221] Update route.ts --- .../api/cron/links/create-promotion-codes/route.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 1f7a204cca1..8eba5df6985 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -14,6 +14,17 @@ const schema = z.object({ page: z.number().optional().default(0), }); +/** + * Cron job to create Stripe promotion codes for partner links + * + * This job processes partner links in batches to create unique Stripe promotion codes + * for each link associated with a specific discount. It paginates through program + * enrollments in a partner group, finds links that don't have coupon codes yet, + * and creates promotion codes in batches of 10 to respect Stripe's rate limits. + * + * It automatically schedules the next batch until all eligible links have been processed. + */ + // POST /api/cron/links/create-promotion-codes export async function POST(req: Request) { try { From 257ec79fd4dbc3dd3484ae89dea48839df2c4f69 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 29 Aug 2025 21:41:07 +0530 Subject: [PATCH 101/221] fix cron job --- .../links/create-promotion-codes/route.ts | 163 +++++++++++------- .../partners/approve-partner-enrollment.ts | 4 +- apps/web/lib/zod/schemas/programs.ts | 3 +- 3 files changed, 107 insertions(+), 63 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts index 8eba5df6985..ca535f68dad 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts @@ -4,6 +4,7 @@ import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, chunk, log } from "@dub/utils"; +import { Link } from "@prisma/client"; import { z } from "zod"; import { logAndRespond } from "../../utils"; @@ -12,17 +13,30 @@ export const dynamic = "force-dynamic"; const schema = z.object({ discountId: z.string(), page: z.number().optional().default(0), + linkIds: z + .array(z.string()) + .optional() + .describe( + "Process only the provided linkIds. Make sure the array is always small.", + ), }); +const PAGE_SIZE = 50; +const STRIPE_PROMO_BATCH_SIZE = 10; + /** * Cron job to create Stripe promotion codes for partner links * - * This job processes partner links in batches to create unique Stripe promotion codes - * for each link associated with a specific discount. It paginates through program - * enrollments in a partner group, finds links that don't have coupon codes yet, - * and creates promotion codes in batches of 10 to respect Stripe's rate limits. + * This job processes partner links to create unique Stripe promotion codes + * for each link associated with a specific discount. It can work in two modes: + * + * 1. Group mode (linkIds not provided): Processes ALL partner links in a partner group + * by paginating through program enrollments, finding links that don't have coupon codes yet + * + * 2. Specific mode (linkIds provided): Processes only the specified link IDs * - * It automatically schedules the next batch until all eligible links have been processed. + * In both modes, it creates promotion codes in batches of STRIPE_PROMO_BATCH_SIZE to respect Stripe's rate limits + * and automatically schedules the next batch until all eligible links have been processed. */ // POST /api/cron/links/create-promotion-codes @@ -31,7 +45,7 @@ export async function POST(req: Request) { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); - let { discountId, page } = schema.parse(JSON.parse(rawBody)); + let { discountId, page, linkIds } = schema.parse(JSON.parse(rawBody)); // Find the discount const discount = await prisma.discount.findUnique({ @@ -102,63 +116,90 @@ export async function POST(req: Request) { ); } - const PAGE_SIZE = 50; - const STRIPE_PROMO_BATCH_SIZE = 10; + let links: Pick[] = []; - // Find the program enrollments for the partner group - const programEnrollments = await prisma.programEnrollment.findMany({ - where: { - groupId: group.id, - status: { - in: ["approved"], + // Specific mode: Process only the provided linkIds + if (linkIds) { + links = await prisma.link.findMany({ + where: { + id: { + in: linkIds, + }, }, - }, - orderBy: { - id: "desc", - }, - take: PAGE_SIZE, - skip: page * PAGE_SIZE, - }); + select: { + id: true, + key: true, + couponCode: true, + }, + orderBy: { + id: "desc", + }, + }); - // Finished processing all the program enrollments - if (programEnrollments.length === 0) { - return logAndRespond( - `No more program enrollments found in the partner group ${group.id}.`, - ); + // Finished processing all the links + if (links.length === 0) { + return logAndRespond( + `No links found to process for discount ${discount.id}.`, + ); + } } - page++; - - // Find the partner links for the enrollments - const links = await prisma.link.findMany({ - where: { - programId: program.id, - partnerId: { - in: programEnrollments.map(({ partnerId }) => partnerId), + // Group mode: Process all links in the partner group + else { + // Find the program enrollments for the partner group + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + groupId: group.id, + status: { + in: ["approved"], + }, }, - couponCode: null, - }, - select: { - id: true, - key: true, - couponCode: true, - }, - orderBy: { - id: "desc", - }, - }); + orderBy: { + id: "desc", + }, + take: PAGE_SIZE, + skip: page * PAGE_SIZE, + }); - // If no links are found, schedule the next batch - if (links.length === 0) { - await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, - body: { - discountId: discount.id, - page, + // Finished processing all the program enrollments + if (programEnrollments.length === 0) { + return logAndRespond( + `No more program enrollments found in the partner group ${group.id}.`, + ); + } + + page++; + + // Find the partner links for the enrollments + links = await prisma.link.findMany({ + where: { + programId: program.id, + partnerId: { + in: programEnrollments.map(({ partnerId }) => partnerId), + }, + }, + select: { + id: true, + key: true, + couponCode: true, + }, + orderBy: { + id: "desc", }, }); - return logAndRespond(`Scheduled the next batch (page=${page}).`); + // If no links are found, schedule the next batch + if (links.length === 0) { + await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, + body: { + discountId: discount.id, + page, + }, + }); + + return logAndRespond(`Scheduled the next batch (page=${page}).`); + } } const linksChunks = chunk(links, STRIPE_PROMO_BATCH_SIZE); @@ -196,13 +237,15 @@ export async function POST(req: Request) { } // Schedule the next batch - await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, - body: { - discountId: discount.id, - page, - }, - }); + if (!linkIds) { + await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, + body: { + discountId: discount.id, + page, + }, + }); + } return logAndRespond(`Scheduled the next batch (page=${page}).`); } catch (error) { diff --git a/apps/web/lib/partners/approve-partner-enrollment.ts b/apps/web/lib/partners/approve-partner-enrollment.ts index 24faa492463..d20e67140ce 100644 --- a/apps/web/lib/partners/approve-partner-enrollment.ts +++ b/apps/web/lib/partners/approve-partner-enrollment.ts @@ -9,7 +9,7 @@ import { getGroupOrThrow } from "../api/groups/get-group-or-throw"; import { createPartnerLink } from "../api/partners/create-partner-link"; import { createStripePromotionCode } from "../stripe/create-stripe-promotion-code"; import { recordLink } from "../tinybird/record-link"; -import { ProgramPartnerLinkProps, RewardProps, WorkspaceProps } from "../types"; +import { LinkProps, RewardProps, WorkspaceProps } from "../types"; import { sendWorkspaceWebhook } from "../webhook/publish"; import { EnrolledPartnerSchema } from "../zod/schemas/partners"; @@ -125,7 +125,7 @@ export async function approvePartnerEnrollment({ : Promise.resolve(null), ]); - let partnerLink: ProgramPartnerLinkProps; + let partnerLink: LinkProps; const { partner, discount, ...enrollment } = programEnrollment; const workspace = program.workspace as WorkspaceProps; diff --git a/apps/web/lib/zod/schemas/programs.ts b/apps/web/lib/zod/schemas/programs.ts index eb682d0bf04..923bf4c9951 100644 --- a/apps/web/lib/zod/schemas/programs.ts +++ b/apps/web/lib/zod/schemas/programs.ts @@ -86,7 +86,8 @@ export const ProgramPartnerLinkSchema = LinkSchema.pick({ leads: true, sales: true, saleAmount: true, - couponCode: true, +}).extend({ + couponCode: z.string().nullable(), }); export const ProgramEnrollmentSchema = z.object({ From c7f4f7ca86dec20c8f1739e12a677e732a65d360 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 2 Sep 2025 15:33:46 +0530 Subject: [PATCH 102/221] Refactor promotion code creation jobs and API routes --- .../discounts/create-promotion-codes/route.ts | 126 +++++++++ .../cron/links/create-promotion-code/route.ts | 129 +++++++++ .../links/create-promotion-codes/route.ts | 261 ------------------ .../lib/actions/partners/create-discount.ts | 8 + .../discounts/promotion-code-creation-job.ts | 54 ++++ .../stripe/create-stripe-promotion-code.ts | 99 +------ .../discounts/add-edit-discount-sheet.tsx | 8 +- 7 files changed, 335 insertions(+), 350 deletions(-) create mode 100644 apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts create mode 100644 apps/web/app/(ee)/api/cron/links/create-promotion-code/route.ts delete mode 100644 apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts create mode 100644 apps/web/lib/api/discounts/promotion-code-creation-job.ts diff --git a/apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts new file mode 100644 index 00000000000..0b6508a3c5c --- /dev/null +++ b/apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts @@ -0,0 +1,126 @@ +import { dispatchPromotionCodeCreationJob } from "@/lib/api/discounts/promotion-code-creation-job"; +import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { qstash } from "@/lib/cron"; +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { prisma } from "@dub/prisma"; +import { APP_DOMAIN_WITH_NGROK, chunk } from "@dub/utils"; +import { z } from "zod"; +import { logAndRespond } from "../../utils"; + +export const dynamic = "force-dynamic"; + +const PAGE_SIZE = 100; +const MAX_BATCH = 10; + +const schema = z.object({ + discountId: z.string(), + cursor: z.string().optional(), +}); + +// POST /api/cron/discounts/create-promotion-codes +export async function POST(req: Request) { + try { + const rawBody = await req.text(); + await verifyQstashSignature({ req, rawBody }); + + let { discountId, cursor } = schema.parse(JSON.parse(rawBody)); + + // Find the discount + const discount = await prisma.discount.findUnique({ + where: { + id: discountId, + }, + include: { + partnerGroup: true, + }, + }); + + if (!discount) { + return logAndRespond(`Discount ${discountId} not found.`, { + logLevel: "error", + }); + } + + if (!discount.couponCodeTrackingEnabledAt) { + return logAndRespond( + `Discount ${discountId} does not have coupon code tracking enabled.`, + ); + } + + const group = discount.partnerGroup; + + if (!group) { + return logAndRespond( + `Discount ${discountId} does not have a partner group.`, + ); + } + + let hasMore = true; + let processedBatches = 0; + + while (processedBatches < MAX_BATCH) { + // Find program enrollments for the group + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + groupId: group.id, + ...(cursor && { + createdAt: { + gt: cursor, + }, + }), + }, + select: { + id: true, + links: { + select: { + id: true, + key: true, + couponCode: true, + }, + }, + }, + take: PAGE_SIZE, + orderBy: { + createdAt: "asc", + }, + }); + + if (programEnrollments.length === 0) { + hasMore = false; + break; + } + + // Find links without a coupon code + const links = programEnrollments.flatMap(({ links }) => + links.filter(({ couponCode }) => !couponCode), + ); + + // Enqueue the coupon creation job for each link + if (links.length > 0) { + const linkChunks = chunk(links, 100); + + for (const linkChunk of linkChunks) { + await dispatchPromotionCodeCreationJob(linkChunk); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + + cursor = programEnrollments[programEnrollments.length - 1].id; + processedBatches++; + } + + if (hasMore) { + await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/create-promotion-codes`, + body: { + discountId, + cursor, + }, + }); + } + + return new Response("Enqueued coupon creation job for the links."); + } catch (error) { + return handleAndReturnErrorResponse(error); + } +} diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-code/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-code/route.ts new file mode 100644 index 00000000000..44cd40f7b6b --- /dev/null +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-code/route.ts @@ -0,0 +1,129 @@ +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; +import { prisma } from "@dub/prisma"; +import { nanoid } from "@dub/utils"; +import { z } from "zod"; +import { logAndRespond } from "../../utils"; + +export const dynamic = "force-dynamic"; + +const schema = z.object({ + linkId: z.string(), + code: z.string(), +}); + +// POST /api/cron/links/create-promotion-code +export async function POST(req: Request) { + let payload: z.infer | undefined = undefined; + const allHeaders = req.headers; + + try { + const rawBody = await req.text(); + await verifyQstashSignature({ req, rawBody }); + + payload = schema.parse(JSON.parse(rawBody)); + } catch (error) { + return logAndRespond(error.message, { + logLevel: "error", + }); + } + + const { linkId, code } = payload; + + // Find the link + const link = await prisma.link.findUnique({ + where: { + id: linkId, + }, + select: { + id: true, + key: true, + projectId: true, + couponCode: true, + programEnrollment: { + select: { + discount: true, + }, + }, + project: { + select: { + id: true, + stripeConnectId: true, + }, + }, + }, + }); + + if (!link) { + return logAndRespond(`Link ${linkId} not found.`, { + logLevel: "error", + }); + } + + if (link.couponCode) { + return logAndRespond(`Link ${linkId} already has a coupon code.`); + } + + const discount = link.programEnrollment?.discount; + const workspace = link.project; + + if (!discount) { + return logAndRespond(`Link ${linkId} does not have a discount.`); + } + + if (!discount.couponId) { + return logAndRespond(`Link ${linkId} does not have a couponId set.`); + } + + if (!discount.couponCodeTrackingEnabledAt) { + return logAndRespond( + `Link ${linkId} does not have coupon code tracking enabled.`, + ); + } + + if (!workspace) { + return logAndRespond(`Link ${linkId} does not have a workspace.`, { + logLevel: "error", + }); + } + + const retried = Number(allHeaders.get("upstash-retried")); + const amount = + discount.type === "percentage" ? discount.amount : discount.amount / 100; + + try { + // Create the promotion code using Stripe API + const promotionCode = await createStripePromotionCode({ + workspace: { + id: workspace.id, + stripeConnectId: workspace.stripeConnectId, + }, + discount: { + id: discount.id, + couponId: discount.couponId, + amount: discount.amount, + type: discount.type, + }, + code: retried === 0 ? `${code}${amount}` : `${code}${nanoid(4)}`, + }); + + if (promotionCode?.code) { + console.log( + `Stripe promotion code ${promotionCode.code} created for the link ${linkId}.`, + ); + + await prisma.link.update({ + where: { + id: linkId, + }, + data: { + couponCode: promotionCode.code, + }, + }); + } + } catch (error) { + return logAndRespond(error.raw?.message, { status: 400 }); + } + + return logAndRespond(`Finished executing the job for the link ${linkId}.`); +} diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts deleted file mode 100644 index ca535f68dad..00000000000 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-codes/route.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; -import { qstash } from "@/lib/cron"; -import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; -import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; -import { prisma } from "@dub/prisma"; -import { APP_DOMAIN_WITH_NGROK, chunk, log } from "@dub/utils"; -import { Link } from "@prisma/client"; -import { z } from "zod"; -import { logAndRespond } from "../../utils"; - -export const dynamic = "force-dynamic"; - -const schema = z.object({ - discountId: z.string(), - page: z.number().optional().default(0), - linkIds: z - .array(z.string()) - .optional() - .describe( - "Process only the provided linkIds. Make sure the array is always small.", - ), -}); - -const PAGE_SIZE = 50; -const STRIPE_PROMO_BATCH_SIZE = 10; - -/** - * Cron job to create Stripe promotion codes for partner links - * - * This job processes partner links to create unique Stripe promotion codes - * for each link associated with a specific discount. It can work in two modes: - * - * 1. Group mode (linkIds not provided): Processes ALL partner links in a partner group - * by paginating through program enrollments, finding links that don't have coupon codes yet - * - * 2. Specific mode (linkIds provided): Processes only the specified link IDs - * - * In both modes, it creates promotion codes in batches of STRIPE_PROMO_BATCH_SIZE to respect Stripe's rate limits - * and automatically schedules the next batch until all eligible links have been processed. - */ - -// POST /api/cron/links/create-promotion-codes -export async function POST(req: Request) { - try { - const rawBody = await req.text(); - await verifyQstashSignature({ req, rawBody }); - - let { discountId, page, linkIds } = schema.parse(JSON.parse(rawBody)); - - // Find the discount - const discount = await prisma.discount.findUnique({ - where: { - id: discountId, - }, - include: { - program: true, - partnerGroup: true, - }, - }); - - if (!discount) { - return logAndRespond(`Discount ${discountId} not found.`, { - logLevel: "error", - }); - } - - if (!discount.couponId) { - return logAndRespond( - `Discount ${discount.id} does not have a couponId set.`, - { - logLevel: "error", - }, - ); - } - - if (!discount.couponCodeTrackingEnabledAt) { - return logAndRespond( - `Discount ${discount.id} is not enabled for coupon code tracking.`, - ); - } - - const { program, partnerGroup: group } = discount; - - if (!group) { - return logAndRespond( - `Discount ${discountId} does not associate with a partner group.`, - { - logLevel: "error", - }, - ); - } - - // Find the workspace for the program - const workspace = await prisma.project.findUnique({ - where: { - id: program.workspaceId, - }, - select: { - id: true, - stripeConnectId: true, - }, - }); - - if (!workspace) { - return logAndRespond(`Workspace ${program.workspaceId} not found.`, { - logLevel: "error", - }); - } - - if (!workspace.stripeConnectId) { - return logAndRespond( - `Workspace ${program.workspaceId} does not have a stripeConnectId set.`, - { - logLevel: "error", - }, - ); - } - - let links: Pick[] = []; - - // Specific mode: Process only the provided linkIds - if (linkIds) { - links = await prisma.link.findMany({ - where: { - id: { - in: linkIds, - }, - }, - select: { - id: true, - key: true, - couponCode: true, - }, - orderBy: { - id: "desc", - }, - }); - - // Finished processing all the links - if (links.length === 0) { - return logAndRespond( - `No links found to process for discount ${discount.id}.`, - ); - } - } - - // Group mode: Process all links in the partner group - else { - // Find the program enrollments for the partner group - const programEnrollments = await prisma.programEnrollment.findMany({ - where: { - groupId: group.id, - status: { - in: ["approved"], - }, - }, - orderBy: { - id: "desc", - }, - take: PAGE_SIZE, - skip: page * PAGE_SIZE, - }); - - // Finished processing all the program enrollments - if (programEnrollments.length === 0) { - return logAndRespond( - `No more program enrollments found in the partner group ${group.id}.`, - ); - } - - page++; - - // Find the partner links for the enrollments - links = await prisma.link.findMany({ - where: { - programId: program.id, - partnerId: { - in: programEnrollments.map(({ partnerId }) => partnerId), - }, - }, - select: { - id: true, - key: true, - couponCode: true, - }, - orderBy: { - id: "desc", - }, - }); - - // If no links are found, schedule the next batch - if (links.length === 0) { - await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, - body: { - discountId: discount.id, - page, - }, - }); - - return logAndRespond(`Scheduled the next batch (page=${page}).`); - } - } - - const linksChunks = chunk(links, STRIPE_PROMO_BATCH_SIZE); - const failedRequests: Error[] = []; - - // Create promotion codes in batches for the partner links - for (const linksChunk of linksChunks) { - const results = await Promise.allSettled( - linksChunk.map((link) => - createStripePromotionCode({ - workspace, - link, - discount, - }), - ), - ); - - results.forEach((result) => { - if (result.status === "rejected") { - failedRequests.push(result.reason); - } - }); - - // Wait for 2 second before the next batch - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - - if (failedRequests.length > 0) { - await log({ - message: `Error creating promotion codes for discount ${discount.id} - ${failedRequests.map((error) => error.message).join(", ")}`, - type: "alerts", - }); - - console.error(failedRequests); - } - - // Schedule the next batch - if (!linkIds) { - await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, - body: { - discountId: discount.id, - page, - }, - }); - } - - return logAndRespond(`Scheduled the next batch (page=${page}).`); - } catch (error) { - await log({ - message: `Error creating promotion codes for discount - ${error.message}`, - type: "alerts", - }); - - console.error(error); - - return handleAndReturnErrorResponse(error); - } -} diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index c469ff36b8a..18f2b55a97d 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -117,6 +117,14 @@ export const createDiscountAction = authActionClient }, }), + discount.couponCodeTrackingEnabledAt && + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/create-promotion-codes`, + body: { + discountId: discount.id, + }, + }), + recordAuditLog({ workspaceId: workspace.id, programId, diff --git a/apps/web/lib/api/discounts/promotion-code-creation-job.ts b/apps/web/lib/api/discounts/promotion-code-creation-job.ts new file mode 100644 index 00000000000..6becc515d2f --- /dev/null +++ b/apps/web/lib/api/discounts/promotion-code-creation-job.ts @@ -0,0 +1,54 @@ +import { qstash } from "@/lib/cron"; +import { APP_DOMAIN_WITH_NGROK, isRejected } from "@dub/utils"; +import { Link } from "@prisma/client"; + +const queue = qstash.queue({ + queueName: "coupon-creation-1", +}); + +type DispatchPromotionCodeCreationJobInput = + | Pick + | Pick[]; + +// Dispatch promotion code creation job for a link or multiple links +export async function dispatchPromotionCodeCreationJob( + input: DispatchPromotionCodeCreationJobInput, +) { + await queue.upsert({ + parallelism: 10, + }); + + const finalLinks = Array.isArray(input) ? input : [input]; + + const response = await Promise.allSettled( + finalLinks.map((link) => + queue.enqueueJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-code`, + method: "POST", + body: { + linkId: link.id, + code: link.key, + }, + }), + ), + ); + + const rejected = response + .map((result, index) => ({ result, linkId: finalLinks[index].id })) + .filter(({ result }) => isRejected(result)); + + if (rejected.length > 0) { + console.error( + `Failed to dispatch coupon creation job for ${rejected.length} links.`, + ); + + rejected.forEach(({ result: promiseResult, linkId }) => { + if (isRejected(promiseResult)) { + console.error( + `Failed to enqueue coupon creation job for link ${linkId}:`, + promiseResult.reason, + ); + } + }); + } +} diff --git a/apps/web/lib/stripe/create-stripe-promotion-code.ts b/apps/web/lib/stripe/create-stripe-promotion-code.ts index 09646882185..24c83877336 100644 --- a/apps/web/lib/stripe/create-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/create-stripe-promotion-code.ts @@ -1,39 +1,19 @@ -import { prisma } from "@dub/prisma"; import { stripeAppClient } from "."; -import { DiscountProps, LinkProps, WorkspaceProps } from "../types"; +import { DiscountProps, WorkspaceProps } from "../types"; const stripe = stripeAppClient({ ...(process.env.VERCEL_ENV && { livemode: true }), }); -const MAX_RETRIES = 3; - export async function createStripePromotionCode({ workspace, discount, - link, + code, }: { workspace: Pick; - discount: Pick< - DiscountProps, - "id" | "couponId" | "amount" | "type" | "couponCodeTrackingEnabledAt" - >; - link: Pick; + discount: Pick; + code: string; }) { - if (!discount.couponCodeTrackingEnabledAt) { - console.log( - `Coupon code tracking is not enabled for discount ${discount.id}. Stripe promotion code creation skipped.`, - ); - return; - } - - if (link.couponCode) { - console.log( - `Promotion code ${link.couponCode} already exists for link ${link.id}. Stripe promotion code creation skipped.`, - ); - return; - } - if (!workspace.stripeConnectId) { console.error( `stripeConnectId not found for the workspace ${workspace.id}. Stripe promotion code creation skipped.`, @@ -48,66 +28,15 @@ export async function createStripePromotionCode({ return; } - let lastError: Error | null = null; - let couponCode: string | undefined; - - const amount = - discount.type === "percentage" ? discount.amount : discount.amount / 100; - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - couponCode = attempt === 1 ? link.key : `${link.key}${amount}`; // eg: DAVID30 - - const promotionCode = await stripe.promotionCodes.create( - { - coupon: discount.couponId, - code: couponCode.toUpperCase(), - }, - { - stripeAccount: workspace.stripeConnectId, - }, - ); - - if (promotionCode) { - await prisma.link.update({ - where: { - id: link.id, - }, - data: { - couponCode: promotionCode.code, - }, - }); - - console.info( - `Created promotion code ${promotionCode.code} (discount=${discount.id}, link=${link.id})`, - ); - } - - return promotionCode; - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - - const isDuplicateError = - error instanceof Error && - error.message.includes("An active promotion code with `code:") && - error.message.includes("already exists"); - - console.error(error.message); - - if (isDuplicateError) { - if (attempt === MAX_RETRIES) { - throw lastError; - } - - continue; - } - - throw lastError; - } - } - - throw ( - lastError || - new Error("Unknown error occurred while creating promotion code on Stripe.") + const promotionCode = await stripe.promotionCodes.create( + { + coupon: discount.couponId, + code: code.toUpperCase(), + }, + { + stripeAccount: workspace.stripeConnectId, + }, ); + + return promotionCode; } diff --git a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx index ce67c8ebf33..8e97e5174e3 100644 --- a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx +++ b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx @@ -13,6 +13,10 @@ 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 { Button, InfoTooltip, @@ -36,10 +40,6 @@ import { toast } from "sonner"; import { mutate } from "swr"; import { z } from "zod"; import { RewardDiscountPartnersCard } from "../groups/reward-discount-partners-card"; -import { - InlineBadgePopover, - InlineBadgePopoverMenu, -} from "../rewards/inline-badge-popover"; interface DiscountSheetProps { setIsOpen: Dispatch>; From d4d515ad1120afd269719c5827c4fc06aebdfefb Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 2 Sep 2025 20:47:21 +0530 Subject: [PATCH 103/221] improve the cron job --- .../discounts/create-promotion-codes/route.ts | 5 +- .../cron/links/create-promotion-code/route.ts | 67 +++++++++++++++---- .../discounts/promotion-code-creation-job.ts | 10 ++- .../stripe/create-stripe-promotion-code.ts | 2 +- 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts index 0b6508a3c5c..5f15bda44d7 100644 --- a/apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts @@ -100,7 +100,10 @@ export async function POST(req: Request) { const linkChunks = chunk(links, 100); for (const linkChunk of linkChunks) { - await dispatchPromotionCodeCreationJob(linkChunk); + await dispatchPromotionCodeCreationJob({ + links: linkChunk, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); } } diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-code/route.ts b/apps/web/app/(ee)/api/cron/links/create-promotion-code/route.ts index 44cd40f7b6b..a6dce71bbaf 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-code/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-promotion-code/route.ts @@ -1,5 +1,7 @@ +import { dispatchPromotionCodeCreationJob } from "@/lib/api/discounts/promotion-code-creation-job"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; +import { DiscountProps } from "@/lib/types"; import { prisma } from "@dub/prisma"; import { nanoid } from "@dub/utils"; import { z } from "zod"; @@ -15,7 +17,6 @@ const schema = z.object({ // POST /api/cron/links/create-promotion-code export async function POST(req: Request) { let payload: z.infer | undefined = undefined; - const allHeaders = req.headers; try { const rawBody = await req.text(); @@ -87,13 +88,9 @@ export async function POST(req: Request) { }); } - const retried = Number(allHeaders.get("upstash-retried")); - const amount = - discount.type === "percentage" ? discount.amount : discount.amount / 100; - try { // Create the promotion code using Stripe API - const promotionCode = await createStripePromotionCode({ + const stripePromotionCode = await createStripePromotionCode({ workspace: { id: workspace.id, stripeConnectId: workspace.stripeConnectId, @@ -101,29 +98,71 @@ export async function POST(req: Request) { discount: { id: discount.id, couponId: discount.couponId, - amount: discount.amount, - type: discount.type, }, - code: retried === 0 ? `${code}${amount}` : `${code}${nanoid(4)}`, + code, }); - if (promotionCode?.code) { + // Update the link with the promotion code + if (stripePromotionCode?.code) { console.log( - `Stripe promotion code ${promotionCode.code} created for the link ${linkId}.`, + `Stripe promotion code ${stripePromotionCode.code} created for the link ${link.id}.`, ); await prisma.link.update({ where: { - id: linkId, + id: link.id, }, data: { - couponCode: promotionCode.code, + couponCode: stripePromotionCode.code, }, }); } } catch (error) { - return logAndRespond(error.raw?.message, { status: 400 }); + if (error?.type === "StripeInvalidRequestError") { + const errorMessage = error.raw?.message || error.message; + const isDuplicateError = errorMessage?.includes("already exists"); + + if (isDuplicateError) { + const newCode = constructPromotionCode({ + code, + discount: { + amount: discount.amount, + type: discount.type, + }, + }); + + await dispatchPromotionCodeCreationJob({ + link: { + id: link.id, + key: newCode, + }, + }); + + return logAndRespond(`${errorMessage} Retrying...`, { + logLevel: "info", + }); + } + } + + return logAndRespond(error.raw?.message || error.message, { status: 400 }); } return logAndRespond(`Finished executing the job for the link ${linkId}.`); } + +function constructPromotionCode({ + 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/api/discounts/promotion-code-creation-job.ts b/apps/web/lib/api/discounts/promotion-code-creation-job.ts index 6becc515d2f..94fa637d3fd 100644 --- a/apps/web/lib/api/discounts/promotion-code-creation-job.ts +++ b/apps/web/lib/api/discounts/promotion-code-creation-job.ts @@ -7,8 +7,12 @@ const queue = qstash.queue({ }); type DispatchPromotionCodeCreationJobInput = - | Pick - | Pick[]; + | { + link: Pick; + } + | { + links: Pick[]; + }; // Dispatch promotion code creation job for a link or multiple links export async function dispatchPromotionCodeCreationJob( @@ -18,7 +22,7 @@ export async function dispatchPromotionCodeCreationJob( parallelism: 10, }); - const finalLinks = Array.isArray(input) ? input : [input]; + const finalLinks = "links" in input ? input.links : [input.link]; const response = await Promise.allSettled( finalLinks.map((link) => diff --git a/apps/web/lib/stripe/create-stripe-promotion-code.ts b/apps/web/lib/stripe/create-stripe-promotion-code.ts index 24c83877336..0b260f414ba 100644 --- a/apps/web/lib/stripe/create-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/create-stripe-promotion-code.ts @@ -11,7 +11,7 @@ export async function createStripePromotionCode({ code, }: { workspace: Pick; - discount: Pick; + discount: Pick; code: string; }) { if (!workspace.stripeConnectId) { From 35fe8c0ef2d0499fd6e1742a7b8c01701954ca9d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 3 Sep 2025 13:24:19 +0530 Subject: [PATCH 104/221] rename --- .../discounts/create-promotion-codes/route.ts | 4 +- .../route.ts | 71 +++++++------------ .../cron/links/create-coupon-code/utils.ts | 19 +++++ ...-job.ts => enqueue-promotion-code-jobs.ts} | 10 +-- 4 files changed, 51 insertions(+), 53 deletions(-) rename apps/web/app/(ee)/api/cron/links/{create-promotion-code => create-coupon-code}/route.ts (68%) create mode 100644 apps/web/app/(ee)/api/cron/links/create-coupon-code/utils.ts rename apps/web/lib/api/discounts/{promotion-code-creation-job.ts => enqueue-promotion-code-jobs.ts} (80%) diff --git a/apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts index 5f15bda44d7..e31bd735c65 100644 --- a/apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts @@ -1,4 +1,4 @@ -import { dispatchPromotionCodeCreationJob } from "@/lib/api/discounts/promotion-code-creation-job"; +import { enqueuePromotionCodeJobs } from "@/lib/api/discounts/enqueue-promotion-code-jobs"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; @@ -100,7 +100,7 @@ export async function POST(req: Request) { const linkChunks = chunk(links, 100); for (const linkChunk of linkChunks) { - await dispatchPromotionCodeCreationJob({ + await enqueuePromotionCodeJobs({ links: linkChunk, }); diff --git a/apps/web/app/(ee)/api/cron/links/create-promotion-code/route.ts b/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts similarity index 68% rename from apps/web/app/(ee)/api/cron/links/create-promotion-code/route.ts rename to apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts index a6dce71bbaf..50ea97a6def 100644 --- a/apps/web/app/(ee)/api/cron/links/create-promotion-code/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts @@ -1,11 +1,10 @@ -import { dispatchPromotionCodeCreationJob } from "@/lib/api/discounts/promotion-code-creation-job"; +import { enqueuePromotionCodeJobs } from "@/lib/api/discounts/enqueue-promotion-code-jobs"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; -import { DiscountProps } from "@/lib/types"; import { prisma } from "@dub/prisma"; -import { nanoid } from "@dub/utils"; import { z } from "zod"; import { logAndRespond } from "../../utils"; +import { constructPromotionCode } from "./utils"; export const dynamic = "force-dynamic"; @@ -14,7 +13,7 @@ const schema = z.object({ code: z.string(), }); -// POST /api/cron/links/create-promotion-code +// POST /api/cron/links/create-coupon-code export async function POST(req: Request) { let payload: z.infer | undefined = undefined; @@ -118,51 +117,31 @@ export async function POST(req: Request) { }); } } catch (error) { - if (error?.type === "StripeInvalidRequestError") { - const errorMessage = error.raw?.message || error.message; - const isDuplicateError = errorMessage?.includes("already exists"); - - if (isDuplicateError) { - const newCode = constructPromotionCode({ - code, - discount: { - amount: discount.amount, - type: discount.type, - }, - }); - - await dispatchPromotionCodeCreationJob({ - link: { - id: link.id, - key: newCode, - }, - }); - - return logAndRespond(`${errorMessage} Retrying...`, { - logLevel: "info", - }); - } + const errorMessage = error.raw?.message || error.message; + const isDuplicateError = errorMessage?.includes("already exists"); + + if (isDuplicateError) { + const newCode = constructPromotionCode({ + code, + discount: { + amount: discount.amount, + type: discount.type, + }, + }); + + await enqueuePromotionCodeJobs({ + link: { + id: link.id, + key: newCode, + }, + }); } - return logAndRespond(error.raw?.message || error.message, { status: 400 }); + return logAndRespond(errorMessage, { + logLevel: "info", + status: isDuplicateError ? 200 : 400, + }); } return logAndRespond(`Finished executing the job for the link ${linkId}.`); } - -function constructPromotionCode({ - 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/app/(ee)/api/cron/links/create-coupon-code/utils.ts b/apps/web/app/(ee)/api/cron/links/create-coupon-code/utils.ts new file mode 100644 index 00000000000..a2baea47b34 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/links/create-coupon-code/utils.ts @@ -0,0 +1,19 @@ +import { DiscountProps } from "@/lib/types"; +import { nanoid } from "@dub/utils"; + +export function constructPromotionCode({ + 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/api/discounts/promotion-code-creation-job.ts b/apps/web/lib/api/discounts/enqueue-promotion-code-jobs.ts similarity index 80% rename from apps/web/lib/api/discounts/promotion-code-creation-job.ts rename to apps/web/lib/api/discounts/enqueue-promotion-code-jobs.ts index 94fa637d3fd..1437e1732bc 100644 --- a/apps/web/lib/api/discounts/promotion-code-creation-job.ts +++ b/apps/web/lib/api/discounts/enqueue-promotion-code-jobs.ts @@ -6,7 +6,7 @@ const queue = qstash.queue({ queueName: "coupon-creation-1", }); -type DispatchPromotionCodeCreationJobInput = +type EnqueuePromotionCodeJobsInput = | { link: Pick; } @@ -14,9 +14,9 @@ type DispatchPromotionCodeCreationJobInput = links: Pick[]; }; -// Dispatch promotion code creation job for a link or multiple links -export async function dispatchPromotionCodeCreationJob( - input: DispatchPromotionCodeCreationJobInput, +// Enqueue promotion code creation jobs for links +export async function enqueuePromotionCodeJobs( + input: EnqueuePromotionCodeJobsInput, ) { await queue.upsert({ parallelism: 10, @@ -27,7 +27,7 @@ export async function dispatchPromotionCodeCreationJob( const response = await Promise.allSettled( finalLinks.map((link) => queue.enqueueJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-code`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-coupon-code`, method: "POST", body: { linkId: link.id, From 27dbcd12a0dd4c85830f1a0fed909e615a61c25b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 7 Sep 2025 22:01:26 +0530 Subject: [PATCH 105/221] Add new API routes for enqueuing promotion code creation and deletion jobs, refactor existing routes to use updated job functions, and remove deprecated delete-promotion-codes route. --- .../route.ts | 6 +- .../route.ts | 111 ++++++++++++ .../cron/links/create-coupon-code/route.ts | 4 +- .../cron/links/delete-coupon-code/route.ts | 89 ++++++++++ .../links/delete-promotion-codes/route.ts | 158 ------------------ apps/web/lib/actions/partners/ban-partner.ts | 12 +- .../lib/actions/partners/create-discount.ts | 2 +- .../lib/actions/partners/update-discount.ts | 51 +++--- ...> enqueue-promotion-code-creation-jobs.ts} | 13 +- .../enqueue-promotion-code-deletion-jobs.ts | 51 ++++++ apps/web/lib/api/links/bulk-delete-links.ts | 9 +- apps/web/lib/api/links/delete-link.ts | 8 +- .../stripe/disable-stripe-promotion-code.ts | 17 +- 13 files changed, 298 insertions(+), 233 deletions(-) rename apps/web/app/(ee)/api/cron/discounts/{create-promotion-codes => enqueue-promotion-code-create-jobs}/route.ts (93%) create mode 100644 apps/web/app/(ee)/api/cron/discounts/enqueue-promotion-code-delete-jobs/route.ts create mode 100644 apps/web/app/(ee)/api/cron/links/delete-coupon-code/route.ts delete mode 100644 apps/web/app/(ee)/api/cron/links/delete-promotion-codes/route.ts rename apps/web/lib/api/discounts/{enqueue-promotion-code-jobs.ts => enqueue-promotion-code-creation-jobs.ts} (78%) create mode 100644 apps/web/lib/api/discounts/enqueue-promotion-code-deletion-jobs.ts diff --git a/apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/discounts/enqueue-promotion-code-create-jobs/route.ts similarity index 93% rename from apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts rename to apps/web/app/(ee)/api/cron/discounts/enqueue-promotion-code-create-jobs/route.ts index e31bd735c65..d2eb2a989aa 100644 --- a/apps/web/app/(ee)/api/cron/discounts/create-promotion-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/enqueue-promotion-code-create-jobs/route.ts @@ -1,4 +1,4 @@ -import { enqueuePromotionCodeJobs } from "@/lib/api/discounts/enqueue-promotion-code-jobs"; +import { enqueuePromotionCodeCreationJobs } from "@/lib/api/discounts/enqueue-promotion-code-creation-jobs"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; @@ -17,7 +17,7 @@ const schema = z.object({ cursor: z.string().optional(), }); -// POST /api/cron/discounts/create-promotion-codes +// POST /api/cron/discounts/enqueue-promotion-code-create-jobs export async function POST(req: Request) { try { const rawBody = await req.text(); @@ -100,7 +100,7 @@ export async function POST(req: Request) { const linkChunks = chunk(links, 100); for (const linkChunk of linkChunks) { - await enqueuePromotionCodeJobs({ + await enqueuePromotionCodeCreationJobs({ links: linkChunk, }); diff --git a/apps/web/app/(ee)/api/cron/discounts/enqueue-promotion-code-delete-jobs/route.ts b/apps/web/app/(ee)/api/cron/discounts/enqueue-promotion-code-delete-jobs/route.ts new file mode 100644 index 00000000000..44ea73de8b0 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/discounts/enqueue-promotion-code-delete-jobs/route.ts @@ -0,0 +1,111 @@ +import { enqueueCouponCodeDeletionJobs } from "@/lib/api/discounts/enqueue-promotion-code-deletion-jobs"; +import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { qstash } from "@/lib/cron"; +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { prisma } from "@dub/prisma"; +import { APP_DOMAIN_WITH_NGROK, chunk } from "@dub/utils"; +import { z } from "zod"; +import { logAndRespond } from "../../utils"; + +export const dynamic = "force-dynamic"; + +const PAGE_SIZE = 100; +const MAX_BATCH = 10; + +const schema = z.object({ + groupId: z.string(), + cursor: z.string().optional(), +}); + +// POST /api/cron/discounts/enqueue-promotion-code-delete-jobs +export async function POST(req: Request) { + try { + const rawBody = await req.text(); + await verifyQstashSignature({ req, rawBody }); + + let { groupId, cursor } = schema.parse(JSON.parse(rawBody)); + + // Find the group + const group = await prisma.partnerGroup.findUnique({ + where: { + id: groupId, + }, + }); + + if (!group) { + return logAndRespond(`Group ${groupId} not found.`, { + logLevel: "error", + }); + } + + let hasMore = true; + let processedBatches = 0; + + while (processedBatches < MAX_BATCH) { + // Find program enrollments for the group + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + groupId: group.id, + ...(cursor && { + createdAt: { + gt: cursor, + }, + }), + }, + select: { + id: true, + links: { + select: { + id: true, + couponCode: true, + }, + }, + }, + take: PAGE_SIZE, + orderBy: { + createdAt: "asc", + }, + }); + + if (programEnrollments.length === 0) { + hasMore = false; + break; + } + + // Find links with a coupon code + const links = programEnrollments.flatMap(({ links }) => + links.filter(({ couponCode }) => couponCode), + ); + + // Enqueue the coupon deletion job for each link + if (links.length > 0) { + const linkChunks = chunk(links, 100); + + for (const linkChunk of linkChunks) { + await enqueueCouponCodeDeletionJobs({ + links: linkChunk, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + + cursor = programEnrollments[programEnrollments.length - 1].id; + processedBatches++; + } + + if (hasMore) { + await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/enqueue-promotion-code-delete-jobs`, + body: { + groupId, + cursor, + }, + }); + } + + return new Response("Enqueued coupon deletion job for the links."); + } catch (error) { + return handleAndReturnErrorResponse(error); + } +} diff --git a/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts b/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts index 50ea97a6def..dec16b8bd59 100644 --- a/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts @@ -1,4 +1,4 @@ -import { enqueuePromotionCodeJobs } from "@/lib/api/discounts/enqueue-promotion-code-jobs"; +import { enqueuePromotionCodeCreationJobs } from "@/lib/api/discounts/enqueue-promotion-code-creation-jobs"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; import { prisma } from "@dub/prisma"; @@ -129,7 +129,7 @@ export async function POST(req: Request) { }, }); - await enqueuePromotionCodeJobs({ + await enqueuePromotionCodeCreationJobs({ link: { id: link.id, key: newCode, diff --git a/apps/web/app/(ee)/api/cron/links/delete-coupon-code/route.ts b/apps/web/app/(ee)/api/cron/links/delete-coupon-code/route.ts new file mode 100644 index 00000000000..3809d954db6 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/links/delete-coupon-code/route.ts @@ -0,0 +1,89 @@ +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { disableStripePromotionCode } from "@/lib/stripe/disable-stripe-promotion-code"; +import { prisma } from "@dub/prisma"; +import { z } from "zod"; +import { logAndRespond } from "../../utils"; + +export const dynamic = "force-dynamic"; + +const schema = z.object({ + linkId: z.string(), + couponCode: z.string(), +}); + +// POST /api/cron/links/delete-coupon-code +export async function POST(req: Request) { + let payload: z.infer | undefined = undefined; + + try { + const rawBody = await req.text(); + await verifyQstashSignature({ req, rawBody }); + + payload = schema.parse(JSON.parse(rawBody)); + } catch (error) { + return logAndRespond(error.message, { + logLevel: "error", + }); + } + + const { linkId, couponCode } = payload; + + // Find the link + const link = await prisma.link.findUnique({ + where: { + id: linkId, + }, + select: { + couponCode: true, + project: { + select: { + id: true, + stripeConnectId: true, + }, + }, + }, + }); + + if (!link) { + return logAndRespond(`Link ${linkId} not found.`, { + logLevel: "error", + }); + } + + const workspace = link.project; + + if (!workspace) { + return logAndRespond(`Link ${linkId} does not have a workspace.`, { + logLevel: "error", + }); + } + + try { + const updatedCouponCode = await disableStripePromotionCode({ + workspace: { + id: workspace.id, + stripeConnectId: workspace.stripeConnectId, + }, + link: { + couponCode, + }, + }); + + if (updatedCouponCode && link.couponCode === couponCode) { + await prisma.link.update({ + where: { + id: linkId, + }, + data: { + couponCode: null, + }, + }); + } + } catch (error) { + return logAndRespond(error.message, { + status: 400, + }); + } + + return logAndRespond(`Finished executing the job for the link ${linkId}.`); +} diff --git a/apps/web/app/(ee)/api/cron/links/delete-promotion-codes/route.ts b/apps/web/app/(ee)/api/cron/links/delete-promotion-codes/route.ts deleted file mode 100644 index 63564d72646..00000000000 --- a/apps/web/app/(ee)/api/cron/links/delete-promotion-codes/route.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; -import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; -import { disableStripePromotionCode } from "@/lib/stripe/disable-stripe-promotion-code"; -import { prisma } from "@dub/prisma"; -import { chunk } from "@dub/utils"; -import { z } from "zod"; -import { logAndRespond } from "../../utils"; - -export const dynamic = "force-dynamic"; - -const schema = z.object({ - groupId: z.string(), -}); - -// POST /api/cron/links/delete-promotion-codes -export async function POST(req: Request) { - try { - const rawBody = await req.text(); - await verifyQstashSignature({ req, rawBody }); - - const { groupId } = schema.parse(JSON.parse(rawBody)); - - const group = await prisma.partnerGroup.findUnique({ - where: { - id: groupId, - }, - include: { - program: true, - }, - }); - - if (!group) { - return logAndRespond(`Partner group ${groupId} not found.`, { - logLevel: "error", - }); - } - - // Find the workspace for the program - const workspace = await prisma.project.findUnique({ - where: { - id: group.program.workspaceId, - }, - select: { - id: true, - stripeConnectId: true, - }, - }); - - if (!workspace) { - return logAndRespond( - `Workspace ${group.program.workspaceId} not found.`, - { - logLevel: "error", - }, - ); - } - - if (!workspace.stripeConnectId) { - return logAndRespond( - `Workspace ${group.program.workspaceId} does not have a stripeConnectId set.`, - { - logLevel: "error", - }, - ); - } - - let page = 0; - let hasMore = true; - const pageSize = 50; - - while (hasMore) { - // Find all enrollments for the partner group - const enrollments = await prisma.programEnrollment.findMany({ - where: { - groupId: group.id, - }, - orderBy: { - id: "desc", - }, - take: pageSize, - skip: page * pageSize, - }); - - if (enrollments.length === 0) { - hasMore = false; - break; - } - - // Find all links for the enrollments - const links = await prisma.link.findMany({ - where: { - programId: group.program.id, - partnerId: { - in: enrollments.map(({ partnerId }) => partnerId), - }, - couponCode: { - not: null, - }, - }, - select: { - id: true, - couponCode: true, - }, - }); - - if (links.length === 0) { - page++; - continue; - } - - const linksChunks = chunk(links, 50); - const failedRequests: Error[] = []; - - // Disable promotion codes in batches for the partner links - for (const linksChunk of linksChunks) { - const results = await Promise.allSettled( - linksChunk.map((link) => - disableStripePromotionCode({ - workspace, - promotionCode: link.couponCode, - }), - ), - ); - - results.forEach((result) => { - if (result.status === "rejected") { - failedRequests.push(result.reason); - } - }); - - await prisma.link.updateMany({ - where: { - id: { - in: linksChunk.map(({ id }) => id), - }, - }, - data: { - couponCode: null, - }, - }); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - - if (failedRequests.length > 0) { - console.error(failedRequests); - } - - page++; - } - - return logAndRespond(`Promotion codes deleted for group ${groupId}.`); - } catch (error) { - console.log(error); - - return handleAndReturnErrorResponse(error); - } -} diff --git a/apps/web/lib/actions/partners/ban-partner.ts b/apps/web/lib/actions/partners/ban-partner.ts index 14b268f9dc0..1e4aba45357 100644 --- a/apps/web/lib/actions/partners/ban-partner.ts +++ b/apps/web/lib/actions/partners/ban-partner.ts @@ -1,11 +1,11 @@ "use server"; import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { enqueueCouponCodeDeletionJobs } from "@/lib/api/discounts/enqueue-promotion-code-deletion-jobs"; 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"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; -import { disableStripePromotionCode } from "@/lib/stripe/disable-stripe-promotion-code"; import { BAN_PARTNER_REASONS, banPartnerSchema, @@ -104,6 +104,7 @@ export const banPartnerAction = authActionClient const links = await prisma.link.findMany({ where, select: { + id: true, domain: true, key: true, couponCode: true, @@ -146,12 +147,9 @@ export const banPartnerAction = authActionClient ], }), - ...links.map((link) => - disableStripePromotionCode({ - workspace, - promotionCode: link.couponCode, - }), - ), + enqueueCouponCodeDeletionJobs({ + links, + }), ]); })(), ); diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 18f2b55a97d..51ebeb605f7 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -119,7 +119,7 @@ export const createDiscountAction = authActionClient discount.couponCodeTrackingEnabledAt && qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/create-promotion-codes`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/enqueue-promotion-code-jobs`, body: { discountId: discount.id, }, diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index 4f265b32bd8..4c56572affe 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -47,12 +47,8 @@ export const updateDiscountAction = authActionClient }, }); - const { - couponTestIdChanged, - trackingStatusChanged, - trackingEnabled, - promotionCodesDisabled, - } = detectDiscountChanges(discount, updatedDiscount); + const { couponTestIdChanged, trackingEnabled, trackingDisabled } = + detectDiscountChanges(discount, updatedDiscount); waitUntil( Promise.allSettled([ @@ -66,15 +62,14 @@ export const updateDiscountAction = authActionClient }), trackingEnabled && - partnerGroup && qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-promotion-codes`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/enqueue-promotion-code-jobs`, body: { discountId: discount.id, }, }), - promotionCodesDisabled && + trackingDisabled && partnerGroup && qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/delete-promotion-codes`, @@ -83,21 +78,20 @@ export const updateDiscountAction = authActionClient }, }), - (couponTestIdChanged || trackingStatusChanged) && - recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "discount.updated", - description: `Discount ${discount.id} updated`, - actor: user, - targets: [ - { - type: "discount", - id: discount.id, - metadata: updatedDiscount, - }, - ], - }), + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "discount.updated", + description: `Discount ${discount.id} updated`, + actor: user, + targets: [ + { + type: "discount", + id: discount.id, + metadata: updatedDiscount, + }, + ], + }), ]), ); }); @@ -108,22 +102,17 @@ function detectDiscountChanges( ) { const couponTestIdChanged = prev.couponTestId !== next.couponTestId; - const trackingStatusChanged = - prev.couponCodeTrackingEnabledAt?.getTime() !== - next.couponCodeTrackingEnabledAt?.getTime(); - const trackingEnabled = prev.couponCodeTrackingEnabledAt === null && next.couponCodeTrackingEnabledAt !== null; - const promotionCodesDisabled = + const trackingDisabled = prev.couponCodeTrackingEnabledAt !== null && next.couponCodeTrackingEnabledAt === null; return { couponTestIdChanged, - trackingStatusChanged, trackingEnabled, - promotionCodesDisabled, + trackingDisabled, }; } diff --git a/apps/web/lib/api/discounts/enqueue-promotion-code-jobs.ts b/apps/web/lib/api/discounts/enqueue-promotion-code-creation-jobs.ts similarity index 78% rename from apps/web/lib/api/discounts/enqueue-promotion-code-jobs.ts rename to apps/web/lib/api/discounts/enqueue-promotion-code-creation-jobs.ts index 1437e1732bc..a25aac04a8f 100644 --- a/apps/web/lib/api/discounts/enqueue-promotion-code-jobs.ts +++ b/apps/web/lib/api/discounts/enqueue-promotion-code-creation-jobs.ts @@ -3,10 +3,10 @@ import { APP_DOMAIN_WITH_NGROK, isRejected } from "@dub/utils"; import { Link } from "@prisma/client"; const queue = qstash.queue({ - queueName: "coupon-creation-1", + queueName: "coupon-creation", }); -type EnqueuePromotionCodeJobsInput = +type Input = | { link: Pick; } @@ -14,10 +14,7 @@ type EnqueuePromotionCodeJobsInput = links: Pick[]; }; -// Enqueue promotion code creation jobs for links -export async function enqueuePromotionCodeJobs( - input: EnqueuePromotionCodeJobsInput, -) { +export async function enqueuePromotionCodeCreationJobs(input: Input) { await queue.upsert({ parallelism: 10, }); @@ -42,10 +39,6 @@ export async function enqueuePromotionCodeJobs( .filter(({ result }) => isRejected(result)); if (rejected.length > 0) { - console.error( - `Failed to dispatch coupon creation job for ${rejected.length} links.`, - ); - rejected.forEach(({ result: promiseResult, linkId }) => { if (isRejected(promiseResult)) { console.error( diff --git a/apps/web/lib/api/discounts/enqueue-promotion-code-deletion-jobs.ts b/apps/web/lib/api/discounts/enqueue-promotion-code-deletion-jobs.ts new file mode 100644 index 00000000000..34c7bd29b48 --- /dev/null +++ b/apps/web/lib/api/discounts/enqueue-promotion-code-deletion-jobs.ts @@ -0,0 +1,51 @@ +import { qstash } from "@/lib/cron"; +import { APP_DOMAIN_WITH_NGROK, isRejected } from "@dub/utils"; +import { Link } from "@prisma/client"; + +const queue = qstash.queue({ + queueName: "coupon-deletion", +}); + +type Input = + | { + link: Pick; + } + | { + links: Pick[]; + }; + +export async function enqueueCouponCodeDeletionJobs(input: Input) { + await queue.upsert({ + parallelism: 10, + }); + + const finalLinks = "links" in input ? input.links : [input.link]; + + const response = await Promise.allSettled( + finalLinks.map((link) => + queue.enqueueJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/delete-coupon-code`, + method: "POST", + body: { + linkId: link.id, + couponCode: link.couponCode, + }, + }), + ), + ); + + const rejected = response + .map((result, index) => ({ result, linkId: finalLinks[index].id })) + .filter(({ result }) => isRejected(result)); + + if (rejected.length > 0) { + rejected.forEach(({ result: promiseResult, linkId }) => { + if (isRejected(promiseResult)) { + console.error( + `Failed to enqueue coupon deletion job for link ${linkId}:`, + promiseResult.reason, + ); + } + }); + } +} diff --git a/apps/web/lib/api/links/bulk-delete-links.ts b/apps/web/lib/api/links/bulk-delete-links.ts index 55b9ef9da29..fe63545ccd9 100644 --- a/apps/web/lib/api/links/bulk-delete-links.ts +++ b/apps/web/lib/api/links/bulk-delete-links.ts @@ -1,9 +1,9 @@ import { storage } from "@/lib/storage"; -import { disableStripePromotionCode } from "@/lib/stripe/disable-stripe-promotion-code"; import { recordLinkTB, transformLinkTB } from "@/lib/tinybird"; import { WorkspaceProps } from "@/lib/types"; import { prisma } from "@dub/prisma"; import { R2_URL } from "@dub/utils"; +import { enqueueCouponCodeDeletionJobs } from "../discounts/enqueue-promotion-code-deletion-jobs"; import { linkCache } from "./cache"; import { ExpandedLink } from "./utils"; @@ -45,11 +45,6 @@ export async function bulkDeleteLinks({ }, }), - ...links.map((link) => - disableStripePromotionCode({ - workspace, - promotionCode: link.couponCode, - }), - ), + enqueueCouponCodeDeletionJobs({ links }), ]); } diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index 1f2b59936c5..0126ef85354 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -1,9 +1,9 @@ import { storage } from "@/lib/storage"; -import { disableStripePromotionCode } from "@/lib/stripe/disable-stripe-promotion-code"; import { recordLinkTB, transformLinkTB } from "@/lib/tinybird"; import { prisma } from "@dub/prisma"; import { R2_URL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; +import { enqueueCouponCodeDeletionJobs } from "../discounts/enqueue-promotion-code-deletion-jobs"; import { linkCache } from "./cache"; import { includeTags } from "./include-tags"; import { transformLink } from "./utils"; @@ -52,11 +52,7 @@ export async function deleteLink(linkId: string) { }, }), - workspace && - disableStripePromotionCode({ - workspace, - promotionCode: link.couponCode, - }), + workspace && enqueueCouponCodeDeletionJobs({ link }), ]), ); diff --git a/apps/web/lib/stripe/disable-stripe-promotion-code.ts b/apps/web/lib/stripe/disable-stripe-promotion-code.ts index 4213ebbfbc4..44df0815196 100644 --- a/apps/web/lib/stripe/disable-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/disable-stripe-promotion-code.ts @@ -1,3 +1,4 @@ +import { Link } from "@prisma/client"; import { stripeAppClient } from "."; import { WorkspaceProps } from "../types"; @@ -7,12 +8,12 @@ const stripe = stripeAppClient({ export async function disableStripePromotionCode({ workspace, - promotionCode, + link, }: { workspace: Pick; - promotionCode: string | null; + link: Pick; }) { - if (!promotionCode) { + if (!link.couponCode) { return; } @@ -25,7 +26,7 @@ export async function disableStripePromotionCode({ const promotionCodes = await stripe.promotionCodes.list( { - code: promotionCode, + code: link.couponCode, limit: 1, }, { @@ -35,7 +36,7 @@ export async function disableStripePromotionCode({ if (promotionCodes.data.length === 0) { console.error( - `Stripe promotion code ${promotionCode} not found (stripeConnectId=${workspace.stripeConnectId}).`, + `Stripe promotion code ${link.couponCode} not found (stripeConnectId=${workspace.stripeConnectId}).`, ); return; } @@ -60,10 +61,10 @@ export async function disableStripePromotionCode({ return promotionCode; } catch (error) { console.error( - `Failed to disable Stripe promotion code ${promotionCode} (stripeConnectId=${workspace.stripeConnectId}).`, - error, + error.raw?.message, + `Failed to disable Stripe promotion code ${link.couponCode} (stripeConnectId=${workspace.stripeConnectId}).`, ); - throw new Error(error instanceof Error ? error.message : "Unknown error"); + throw new Error(error.raw?.message); } } From bd84c12ac3a790228c0fdd993f76cd941e42cd80 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 7 Sep 2025 22:20:31 +0530 Subject: [PATCH 106/221] Refactor promotion code to coupon code naming Renamed files, functions, and imports from 'promotion code' to 'coupon code' for consistency across the codebase. Updated API routes, job enqueuing, and related logic to use the new naming convention. Also simplified Stripe promotion code creation and disabling functions. --- .../route.ts | 6 +-- .../route.ts | 6 +-- .../cron/links/create-coupon-code/route.ts | 4 +- apps/web/lib/actions/partners/ban-partner.ts | 4 +- .../lib/actions/partners/create-discount.ts | 2 +- .../lib/actions/partners/update-discount.ts | 4 +- ....ts => enqueue-coupon-code-create-jobs.ts} | 2 +- ....ts => enqueue-coupon-code-delete-jobs.ts} | 2 +- apps/web/lib/api/links/bulk-delete-links.ts | 4 +- apps/web/lib/api/links/create-link.ts | 17 +++++---- apps/web/lib/api/links/delete-link.ts | 4 +- .../stripe/create-stripe-promotion-code.ts | 4 +- .../stripe/disable-stripe-promotion-code.ts | 37 +++++++------------ 13 files changed, 43 insertions(+), 53 deletions(-) rename apps/web/app/(ee)/api/cron/discounts/{enqueue-promotion-code-create-jobs => enqueue-coupon-code-create-jobs}/route.ts (93%) rename apps/web/app/(ee)/api/cron/discounts/{enqueue-promotion-code-delete-jobs => enqueue-coupon-code-delete-jobs}/route.ts (92%) rename apps/web/lib/api/discounts/{enqueue-promotion-code-creation-jobs.ts => enqueue-coupon-code-create-jobs.ts} (94%) rename apps/web/lib/api/discounts/{enqueue-promotion-code-deletion-jobs.ts => enqueue-coupon-code-delete-jobs.ts} (94%) diff --git a/apps/web/app/(ee)/api/cron/discounts/enqueue-promotion-code-create-jobs/route.ts b/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-create-jobs/route.ts similarity index 93% rename from apps/web/app/(ee)/api/cron/discounts/enqueue-promotion-code-create-jobs/route.ts rename to apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-create-jobs/route.ts index d2eb2a989aa..2f6040c66d6 100644 --- a/apps/web/app/(ee)/api/cron/discounts/enqueue-promotion-code-create-jobs/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-create-jobs/route.ts @@ -1,4 +1,4 @@ -import { enqueuePromotionCodeCreationJobs } from "@/lib/api/discounts/enqueue-promotion-code-creation-jobs"; +import { enqueueCouponCodeCreateJobs } from "@/lib/api/discounts/enqueue-coupon-code-create-jobs"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; @@ -17,7 +17,7 @@ const schema = z.object({ cursor: z.string().optional(), }); -// POST /api/cron/discounts/enqueue-promotion-code-create-jobs +// POST /api/cron/discounts/enqueue-coupon-code-create-jobs export async function POST(req: Request) { try { const rawBody = await req.text(); @@ -100,7 +100,7 @@ export async function POST(req: Request) { const linkChunks = chunk(links, 100); for (const linkChunk of linkChunks) { - await enqueuePromotionCodeCreationJobs({ + await enqueueCouponCodeCreateJobs({ links: linkChunk, }); diff --git a/apps/web/app/(ee)/api/cron/discounts/enqueue-promotion-code-delete-jobs/route.ts b/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-delete-jobs/route.ts similarity index 92% rename from apps/web/app/(ee)/api/cron/discounts/enqueue-promotion-code-delete-jobs/route.ts rename to apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-delete-jobs/route.ts index 44ea73de8b0..948ae440b49 100644 --- a/apps/web/app/(ee)/api/cron/discounts/enqueue-promotion-code-delete-jobs/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-delete-jobs/route.ts @@ -1,4 +1,4 @@ -import { enqueueCouponCodeDeletionJobs } from "@/lib/api/discounts/enqueue-promotion-code-deletion-jobs"; +import { enqueueCouponCodeDeleteJobs } from "@/lib/api/discounts/enqueue-coupon-code-delete-jobs"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; @@ -17,7 +17,7 @@ const schema = z.object({ cursor: z.string().optional(), }); -// POST /api/cron/discounts/enqueue-promotion-code-delete-jobs +// POST /api/cron/discounts/enqueue-coupon-code-delete-jobs export async function POST(req: Request) { try { const rawBody = await req.text(); @@ -82,7 +82,7 @@ export async function POST(req: Request) { const linkChunks = chunk(links, 100); for (const linkChunk of linkChunks) { - await enqueueCouponCodeDeletionJobs({ + await enqueueCouponCodeDeleteJobs({ links: linkChunk, }); diff --git a/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts b/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts index dec16b8bd59..074038a85aa 100644 --- a/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts @@ -1,4 +1,4 @@ -import { enqueuePromotionCodeCreationJobs } from "@/lib/api/discounts/enqueue-promotion-code-creation-jobs"; +import { enqueueCouponCodeCreateJobs } from "@/lib/api/discounts/enqueue-coupon-code-create-jobs"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; import { prisma } from "@dub/prisma"; @@ -129,7 +129,7 @@ export async function POST(req: Request) { }, }); - await enqueuePromotionCodeCreationJobs({ + await enqueueCouponCodeCreateJobs({ link: { id: link.id, key: newCode, diff --git a/apps/web/lib/actions/partners/ban-partner.ts b/apps/web/lib/actions/partners/ban-partner.ts index 1e4aba45357..8a418bf6007 100644 --- a/apps/web/lib/actions/partners/ban-partner.ts +++ b/apps/web/lib/actions/partners/ban-partner.ts @@ -1,7 +1,7 @@ "use server"; import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; -import { enqueueCouponCodeDeletionJobs } from "@/lib/api/discounts/enqueue-promotion-code-deletion-jobs"; +import { enqueueCouponCodeDeleteJobs } from "@/lib/api/discounts/enqueue-coupon-code-delete-jobs"; 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"; @@ -147,7 +147,7 @@ export const banPartnerAction = authActionClient ], }), - enqueueCouponCodeDeletionJobs({ + enqueueCouponCodeDeleteJobs({ links, }), ]); diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 51ebeb605f7..03180551160 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -119,7 +119,7 @@ export const createDiscountAction = authActionClient discount.couponCodeTrackingEnabledAt && qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/enqueue-promotion-code-jobs`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/enqueue-promotion-code-create-jobs`, body: { discountId: discount.id, }, diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index 4c56572affe..f78b6959a4c 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -63,7 +63,7 @@ export const updateDiscountAction = authActionClient trackingEnabled && qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/enqueue-promotion-code-jobs`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/enqueue-promotion-code-create-jobs`, body: { discountId: discount.id, }, @@ -72,7 +72,7 @@ export const updateDiscountAction = authActionClient trackingDisabled && partnerGroup && qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/delete-promotion-codes`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/enqueue-coupon-code-delete-jobs`, body: { groupId: partnerGroup.id, }, diff --git a/apps/web/lib/api/discounts/enqueue-promotion-code-creation-jobs.ts b/apps/web/lib/api/discounts/enqueue-coupon-code-create-jobs.ts similarity index 94% rename from apps/web/lib/api/discounts/enqueue-promotion-code-creation-jobs.ts rename to apps/web/lib/api/discounts/enqueue-coupon-code-create-jobs.ts index a25aac04a8f..1fb94205a80 100644 --- a/apps/web/lib/api/discounts/enqueue-promotion-code-creation-jobs.ts +++ b/apps/web/lib/api/discounts/enqueue-coupon-code-create-jobs.ts @@ -14,7 +14,7 @@ type Input = links: Pick[]; }; -export async function enqueuePromotionCodeCreationJobs(input: Input) { +export async function enqueueCouponCodeCreateJobs(input: Input) { await queue.upsert({ parallelism: 10, }); diff --git a/apps/web/lib/api/discounts/enqueue-promotion-code-deletion-jobs.ts b/apps/web/lib/api/discounts/enqueue-coupon-code-delete-jobs.ts similarity index 94% rename from apps/web/lib/api/discounts/enqueue-promotion-code-deletion-jobs.ts rename to apps/web/lib/api/discounts/enqueue-coupon-code-delete-jobs.ts index 34c7bd29b48..3834ceaf379 100644 --- a/apps/web/lib/api/discounts/enqueue-promotion-code-deletion-jobs.ts +++ b/apps/web/lib/api/discounts/enqueue-coupon-code-delete-jobs.ts @@ -14,7 +14,7 @@ type Input = links: Pick[]; }; -export async function enqueueCouponCodeDeletionJobs(input: Input) { +export async function enqueueCouponCodeDeleteJobs(input: Input) { await queue.upsert({ parallelism: 10, }); diff --git a/apps/web/lib/api/links/bulk-delete-links.ts b/apps/web/lib/api/links/bulk-delete-links.ts index fe63545ccd9..51c1fc5c1d3 100644 --- a/apps/web/lib/api/links/bulk-delete-links.ts +++ b/apps/web/lib/api/links/bulk-delete-links.ts @@ -3,7 +3,7 @@ import { recordLinkTB, transformLinkTB } from "@/lib/tinybird"; import { WorkspaceProps } from "@/lib/types"; import { prisma } from "@dub/prisma"; import { R2_URL } from "@dub/utils"; -import { enqueueCouponCodeDeletionJobs } from "../discounts/enqueue-promotion-code-deletion-jobs"; +import { enqueueCouponCodeDeleteJobs } from "../discounts/enqueue-coupon-code-delete-jobs"; import { linkCache } from "./cache"; import { ExpandedLink } from "./utils"; @@ -45,6 +45,6 @@ export async function bulkDeleteLinks({ }, }), - enqueueCouponCodeDeletionJobs({ links }), + enqueueCouponCodeDeleteJobs({ links }), ]); } diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts index 2326d4e7edd..198077a15f4 100644 --- a/apps/web/lib/api/links/create-link.ts +++ b/apps/web/lib/api/links/create-link.ts @@ -1,7 +1,6 @@ import { qstash } from "@/lib/cron"; import { getPartnerAndDiscount } from "@/lib/planetscale/get-partner-discount"; import { isNotHostedImage, storage } from "@/lib/storage"; -import { createStripePromotionCode } from "@/lib/stripe/create-stripe-promotion-code"; import { recordLink } from "@/lib/tinybird"; import { DiscountProps, ProcessedLinkProps, WorkspaceProps } from "@/lib/types"; import { propagateWebhookTriggerChanges } from "@/lib/webhook/update-webhook"; @@ -16,6 +15,7 @@ import { import { linkConstructorSimple } from "@dub/utils/src/functions/link-constructor"; import { waitUntil } from "@vercel/functions"; import { createId } from "../create-id"; +import { enqueueCouponCodeCreateJobs } from "../discounts/enqueue-coupon-code-create-jobs"; import { combineTagIds } from "../tags/combine-tag-ids"; import { scheduleABTestCompletion } from "./ab-test-scheduler"; import { linkCache } from "./cache"; @@ -30,7 +30,7 @@ type CreateLinkProps = ProcessedLinkProps & { DiscountProps, "id" | "couponId" | "couponCodeTrackingEnabledAt" | "amount" | "type" > | null; - skipCouponCreation?: boolean; // Skip Stripe promotion code creation for the link + skipCouponCreation?: boolean; // Skip coupon code creation for the link }; export async function createLink(link: CreateLinkProps) { @@ -253,14 +253,15 @@ export async function createLink(link: CreateLinkProps) { testVariants && testCompletedAt && scheduleABTestCompletion(response), - // Create promotion code for the partner link + // Create coupon code for the partner link !skipCouponCreation && workspace && discount && - createStripePromotionCode({ - workspace, - link: response, - discount, + enqueueCouponCodeCreateJobs({ + link: { + id: response.id, + key: response.key, + }, }), ]); })(), @@ -274,4 +275,4 @@ export async function createLink(link: CreateLinkProps) { ? uploadedImageUrl : response.image, }; -} \ No newline at end of file +} diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index 0126ef85354..71282c53c5d 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -3,7 +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 { enqueueCouponCodeDeletionJobs } from "../discounts/enqueue-promotion-code-deletion-jobs"; +import { enqueueCouponCodeDeleteJobs } from "../discounts/enqueue-coupon-code-delete-jobs"; import { linkCache } from "./cache"; import { includeTags } from "./include-tags"; import { transformLink } from "./utils"; @@ -52,7 +52,7 @@ export async function deleteLink(linkId: string) { }, }), - workspace && enqueueCouponCodeDeletionJobs({ link }), + workspace && enqueueCouponCodeDeleteJobs({ link }), ]), ); diff --git a/apps/web/lib/stripe/create-stripe-promotion-code.ts b/apps/web/lib/stripe/create-stripe-promotion-code.ts index 0b260f414ba..63df2eed0a2 100644 --- a/apps/web/lib/stripe/create-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/create-stripe-promotion-code.ts @@ -28,7 +28,7 @@ export async function createStripePromotionCode({ return; } - const promotionCode = await stripe.promotionCodes.create( + return await stripe.promotionCodes.create( { coupon: discount.couponId, code: code.toUpperCase(), @@ -37,6 +37,4 @@ export async function createStripePromotionCode({ stripeAccount: workspace.stripeConnectId, }, ); - - return promotionCode; } diff --git a/apps/web/lib/stripe/disable-stripe-promotion-code.ts b/apps/web/lib/stripe/disable-stripe-promotion-code.ts index 44df0815196..f010ed304a9 100644 --- a/apps/web/lib/stripe/disable-stripe-promotion-code.ts +++ b/apps/web/lib/stripe/disable-stripe-promotion-code.ts @@ -41,30 +41,21 @@ export async function disableStripePromotionCode({ return; } - try { - let promotionCode = promotionCodes.data[0]; + let promotionCode = promotionCodes.data[0]; - promotionCode = await stripe.promotionCodes.update( - promotionCode.id, - { - active: false, - }, - { - stripeAccount: workspace.stripeConnectId, - }, - ); - - console.info( - `Disabled Stripe promotion code ${promotionCode.code} (id=${promotionCode.id}, stripeConnectId=${workspace.stripeConnectId}).`, - ); + promotionCode = await stripe.promotionCodes.update( + promotionCode.id, + { + active: false, + }, + { + stripeAccount: workspace.stripeConnectId, + }, + ); - return promotionCode; - } catch (error) { - console.error( - error.raw?.message, - `Failed to disable Stripe promotion code ${link.couponCode} (stripeConnectId=${workspace.stripeConnectId}).`, - ); + console.info( + `Disabled Stripe promotion code ${promotionCode.code} (id=${promotionCode.id}, stripeConnectId=${workspace.stripeConnectId}).`, + ); - throw new Error(error.raw?.message); - } + return promotionCode; } From 076ea6b75c764dbb9ca2461b3ffe02a2f4c47a62 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 7 Sep 2025 22:34:55 +0530 Subject: [PATCH 107/221] Refactor enqueueCouponCodeDeleteJobs to accept links array --- .../enqueue-coupon-code-delete-jobs/route.ts | 4 +--- apps/web/lib/actions/partners/ban-partner.ts | 4 +--- .../enqueue-coupon-code-delete-jobs.ts | 18 +++++------------- apps/web/lib/api/links/bulk-delete-links.ts | 2 +- apps/web/lib/api/links/delete-link.ts | 2 +- 5 files changed, 9 insertions(+), 21 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-delete-jobs/route.ts b/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-delete-jobs/route.ts index 948ae440b49..05ff639f149 100644 --- a/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-delete-jobs/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-delete-jobs/route.ts @@ -82,9 +82,7 @@ export async function POST(req: Request) { const linkChunks = chunk(links, 100); for (const linkChunk of linkChunks) { - await enqueueCouponCodeDeleteJobs({ - links: linkChunk, - }); + await enqueueCouponCodeDeleteJobs(linkChunk); await new Promise((resolve) => setTimeout(resolve, 2000)); } diff --git a/apps/web/lib/actions/partners/ban-partner.ts b/apps/web/lib/actions/partners/ban-partner.ts index 8a418bf6007..70241db2d5e 100644 --- a/apps/web/lib/actions/partners/ban-partner.ts +++ b/apps/web/lib/actions/partners/ban-partner.ts @@ -147,9 +147,7 @@ export const banPartnerAction = authActionClient ], }), - enqueueCouponCodeDeleteJobs({ - links, - }), + enqueueCouponCodeDeleteJobs(links), ]); })(), ); diff --git a/apps/web/lib/api/discounts/enqueue-coupon-code-delete-jobs.ts b/apps/web/lib/api/discounts/enqueue-coupon-code-delete-jobs.ts index 3834ceaf379..b3ec733ae10 100644 --- a/apps/web/lib/api/discounts/enqueue-coupon-code-delete-jobs.ts +++ b/apps/web/lib/api/discounts/enqueue-coupon-code-delete-jobs.ts @@ -6,23 +6,15 @@ const queue = qstash.queue({ queueName: "coupon-deletion", }); -type Input = - | { - link: Pick; - } - | { - links: Pick[]; - }; - -export async function enqueueCouponCodeDeleteJobs(input: Input) { +export async function enqueueCouponCodeDeleteJobs( + links: Pick[], +) { await queue.upsert({ parallelism: 10, }); - const finalLinks = "links" in input ? input.links : [input.link]; - const response = await Promise.allSettled( - finalLinks.map((link) => + links.map((link) => queue.enqueueJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/delete-coupon-code`, method: "POST", @@ -35,7 +27,7 @@ export async function enqueueCouponCodeDeleteJobs(input: Input) { ); const rejected = response - .map((result, index) => ({ result, linkId: finalLinks[index].id })) + .map((result, index) => ({ result, linkId: links[index].id })) .filter(({ result }) => isRejected(result)); if (rejected.length > 0) { diff --git a/apps/web/lib/api/links/bulk-delete-links.ts b/apps/web/lib/api/links/bulk-delete-links.ts index 51c1fc5c1d3..533981fc899 100644 --- a/apps/web/lib/api/links/bulk-delete-links.ts +++ b/apps/web/lib/api/links/bulk-delete-links.ts @@ -45,6 +45,6 @@ export async function bulkDeleteLinks({ }, }), - enqueueCouponCodeDeleteJobs({ links }), + enqueueCouponCodeDeleteJobs(links), ]); } diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index 71282c53c5d..aeb4721335c 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -52,7 +52,7 @@ export async function deleteLink(linkId: string) { }, }), - workspace && enqueueCouponCodeDeleteJobs({ link }), + link.projectId && enqueueCouponCodeDeleteJobs([link]), ]), ); From a58ceeac75d18a02a2d318faff58193ce495ec07 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 7 Sep 2025 22:39:13 +0530 Subject: [PATCH 108/221] Refactor coupon job enqueuing to accept single or multiple links Updated enqueueCouponCodeCreateJobs and enqueueCouponCodeDeleteJobs to accept either a single link or an array of links, simplifying their usage. Adjusted all call sites to match the new function signatures and removed unnecessary object wrapping. --- .../enqueue-coupon-code-create-jobs/route.ts | 5 +---- .../api/cron/links/create-coupon-code/route.ts | 6 ++---- .../enqueue-coupon-code-create-jobs.ts | 18 ++++++------------ .../enqueue-coupon-code-delete-jobs.ts | 4 +++- apps/web/lib/api/links/create-link.ts | 6 ++---- apps/web/lib/api/links/delete-link.ts | 2 +- 6 files changed, 15 insertions(+), 26 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-create-jobs/route.ts b/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-create-jobs/route.ts index 2f6040c66d6..e704e968deb 100644 --- a/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-create-jobs/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-create-jobs/route.ts @@ -100,10 +100,7 @@ export async function POST(req: Request) { const linkChunks = chunk(links, 100); for (const linkChunk of linkChunks) { - await enqueueCouponCodeCreateJobs({ - links: linkChunk, - }); - + await enqueueCouponCodeCreateJobs(linkChunk); await new Promise((resolve) => setTimeout(resolve, 2000)); } } diff --git a/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts b/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts index 074038a85aa..80f59db8058 100644 --- a/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts +++ b/apps/web/app/(ee)/api/cron/links/create-coupon-code/route.ts @@ -130,10 +130,8 @@ export async function POST(req: Request) { }); await enqueueCouponCodeCreateJobs({ - link: { - id: link.id, - key: newCode, - }, + id: link.id, + key: newCode, }); } diff --git a/apps/web/lib/api/discounts/enqueue-coupon-code-create-jobs.ts b/apps/web/lib/api/discounts/enqueue-coupon-code-create-jobs.ts index 1fb94205a80..2f6ecdd9256 100644 --- a/apps/web/lib/api/discounts/enqueue-coupon-code-create-jobs.ts +++ b/apps/web/lib/api/discounts/enqueue-coupon-code-create-jobs.ts @@ -6,23 +6,17 @@ const queue = qstash.queue({ queueName: "coupon-creation", }); -type Input = - | { - link: Pick; - } - | { - links: Pick[]; - }; - -export async function enqueueCouponCodeCreateJobs(input: Input) { +export async function enqueueCouponCodeCreateJobs( + input: Pick | Pick[], +) { await queue.upsert({ parallelism: 10, }); - const finalLinks = "links" in input ? input.links : [input.link]; + const links = Array.isArray(input) ? input : [input]; const response = await Promise.allSettled( - finalLinks.map((link) => + links.map((link) => queue.enqueueJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/create-coupon-code`, method: "POST", @@ -35,7 +29,7 @@ export async function enqueueCouponCodeCreateJobs(input: Input) { ); const rejected = response - .map((result, index) => ({ result, linkId: finalLinks[index].id })) + .map((result, index) => ({ result, linkId: links[index].id })) .filter(({ result }) => isRejected(result)); if (rejected.length > 0) { diff --git a/apps/web/lib/api/discounts/enqueue-coupon-code-delete-jobs.ts b/apps/web/lib/api/discounts/enqueue-coupon-code-delete-jobs.ts index b3ec733ae10..ef09687d0f5 100644 --- a/apps/web/lib/api/discounts/enqueue-coupon-code-delete-jobs.ts +++ b/apps/web/lib/api/discounts/enqueue-coupon-code-delete-jobs.ts @@ -7,12 +7,14 @@ const queue = qstash.queue({ }); export async function enqueueCouponCodeDeleteJobs( - links: Pick[], + input: Pick | Pick[], ) { await queue.upsert({ parallelism: 10, }); + const links = Array.isArray(input) ? input : [input]; + const response = await Promise.allSettled( links.map((link) => queue.enqueueJSON({ diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts index 912e698815e..36fd3fafce0 100644 --- a/apps/web/lib/api/links/create-link.ts +++ b/apps/web/lib/api/links/create-link.ts @@ -258,10 +258,8 @@ export async function createLink(link: CreateLinkProps) { workspace && discount && enqueueCouponCodeCreateJobs({ - link: { - id: response.id, - key: response.key, - }, + id: response.id, + key: response.key, }), ]); })(), diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index aeb4721335c..6cae2185c63 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -52,7 +52,7 @@ export async function deleteLink(linkId: string) { }, }), - link.projectId && enqueueCouponCodeDeleteJobs([link]), + link.projectId && enqueueCouponCodeDeleteJobs(link), ]), ); From 545bba5ebafa78370adcf22f9c0ea91c83979bb1 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 7 Sep 2025 22:56:15 +0530 Subject: [PATCH 109/221] Refactor createLink to simplify parameters and discount logic --- .../(ee)/api/embed/referrals/links/route.ts | 6 +- .../programs/[programId]/links/route.ts | 5 +- apps/web/app/(ee)/api/partners/links/route.ts | 6 +- .../(ee)/api/partners/links/upsert/route.ts | 6 +- apps/web/app/api/links/route.ts | 5 +- apps/web/app/api/links/upsert/route.ts | 5 +- apps/web/lib/api/links/create-link.ts | 80 +++---------------- apps/web/lib/partnerstack/import-links.ts | 5 +- .../lib/planetscale/get-partner-discount.ts | 13 +-- apps/web/lib/tolt/import-links.ts | 5 +- 10 files changed, 27 insertions(+), 109 deletions(-) diff --git a/apps/web/app/(ee)/api/embed/referrals/links/route.ts b/apps/web/app/(ee)/api/embed/referrals/links/route.ts index 1a3f8dd287f..dceb4449315 100644 --- a/apps/web/app/(ee)/api/embed/referrals/links/route.ts +++ b/apps/web/app/(ee)/api/embed/referrals/links/route.ts @@ -100,11 +100,7 @@ export const POST = withReferralsEmbedToken( }); } - const partnerLink = await createLink({ - ...link, - workspace: workspaceOwner?.project, - discount, - }); + const partnerLink = await createLink(link); return NextResponse.json(ReferralsEmbedLinkSchema.parse(partnerLink), { status: 201, 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 75fc82dea3b..5086c544acb 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 @@ -114,10 +114,7 @@ export const POST = withPartnerProfile( }); } - const partnerLink = await createLink({ - ...link, - discount, - }); + const partnerLink = await createLink(link); return NextResponse.json(PartnerProfileLinkSchema.parse(partnerLink), { status: 201, diff --git a/apps/web/app/(ee)/api/partners/links/route.ts b/apps/web/app/(ee)/api/partners/links/route.ts index 7d2120fe739..c873c1131f7 100644 --- a/apps/web/app/(ee)/api/partners/links/route.ts +++ b/apps/web/app/(ee)/api/partners/links/route.ts @@ -142,11 +142,7 @@ export const POST = withWorkspace( }); } - const partnerLink = await createLink({ - ...link, - workspace, - discount: partner.discount, - }); + const partnerLink = await createLink(link); waitUntil( sendWorkspaceWebhook({ diff --git a/apps/web/app/(ee)/api/partners/links/upsert/route.ts b/apps/web/app/(ee)/api/partners/links/upsert/route.ts index db2fabb89b8..b11cfa11032 100644 --- a/apps/web/app/(ee)/api/partners/links/upsert/route.ts +++ b/apps/web/app/(ee)/api/partners/links/upsert/route.ts @@ -217,11 +217,7 @@ export const PUT = withWorkspace( }); } - const partnerLink = await createLink({ - ...link, - workspace, - discount: partner.discount, - }); + const partnerLink = await createLink(link); waitUntil( sendWorkspaceWebhook({ diff --git a/apps/web/app/api/links/route.ts b/apps/web/app/api/links/route.ts index 312841d598a..15426b8e364 100644 --- a/apps/web/app/api/links/route.ts +++ b/apps/web/app/api/links/route.ts @@ -123,10 +123,7 @@ export const POST = withWorkspace( } try { - const response = await createLink({ - ...link, - workspace, - }); + const response = await createLink(link); if (response.projectId && response.userId) { waitUntil( diff --git a/apps/web/app/api/links/upsert/route.ts b/apps/web/app/api/links/upsert/route.ts index 094191ce1da..5789fb1d6d1 100644 --- a/apps/web/app/api/links/upsert/route.ts +++ b/apps/web/app/api/links/upsert/route.ts @@ -186,10 +186,7 @@ export const PUT = withWorkspace( } try { - const response = await createLink({ - ...link, - workspace, - }); + const response = await createLink(link); return NextResponse.json(response, { headers }); } catch (error) { throw new DubApiError({ diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts index 36fd3fafce0..cbc53e4c932 100644 --- a/apps/web/lib/api/links/create-link.ts +++ b/apps/web/lib/api/links/create-link.ts @@ -2,7 +2,7 @@ import { qstash } from "@/lib/cron"; import { getPartnerAndDiscount } from "@/lib/planetscale/get-partner-discount"; import { isNotHostedImage, storage } from "@/lib/storage"; import { recordLink } from "@/lib/tinybird"; -import { DiscountProps, ProcessedLinkProps, WorkspaceProps } from "@/lib/types"; +import { ProcessedLinkProps } from "@/lib/types"; import { propagateWebhookTriggerChanges } from "@/lib/webhook/update-webhook"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; @@ -24,16 +24,7 @@ import { includeTags } from "./include-tags"; import { updateLinksUsage } from "./update-links-usage"; import { transformLink } from "./utils"; -type CreateLinkProps = ProcessedLinkProps & { - workspace?: Pick; - discount?: Pick< - DiscountProps, - "id" | "couponId" | "couponCodeTrackingEnabledAt" | "amount" | "type" - > | null; - skipCouponCreation?: boolean; // Skip coupon code creation for the link -}; - -export async function createLink(link: CreateLinkProps) { +export async function createLink(link: ProcessedLinkProps) { let { key, url, @@ -54,16 +45,7 @@ export async function createLink(link: CreateLinkProps) { const { utm_source, utm_medium, utm_campaign, utm_term, utm_content } = getParamsFromURL(url); - let { - tagId, - tagIds, - tagNames, - webhookIds, - workspace, - discount, - skipCouponCreation, - ...rest - } = link; + const { tagId, tagIds, tagNames, webhookIds, ...rest } = link; key = encodeKeyIfCaseSensitive({ domain: link.domain, @@ -156,55 +138,18 @@ export async function createLink(link: CreateLinkProps) { waitUntil( (async () => { - if ( - !workspace && - link.projectId && - discount?.couponId && - discount?.couponCodeTrackingEnabledAt && - !skipCouponCreation - ) { - workspace = await prisma.project.findUniqueOrThrow({ - where: { - id: link.projectId, - }, - select: { - id: true, - stripeConnectId: true, - }, - }); - } - - if ( - link.programId && - link.partnerId && - !discount && - !skipCouponCreation - ) { - const programEnrollment = - await prisma.programEnrollment.findUniqueOrThrow({ - where: { - partnerId_programId: { - partnerId: link.partnerId, - programId: link.programId, - }, - }, - include: { - discount: true, - }, - }); - - discount = programEnrollment.discount; - } + const partnerAndDiscount = await getPartnerAndDiscount({ + programId: response.programId, + partnerId: response.partnerId, + }); Promise.allSettled([ // cache link in Redis linkCache.set({ ...response, - ...(response.programId && - (await getPartnerAndDiscount({ - programId: response.programId, - partnerId: response.partnerId, - }))), + ...(partnerAndDiscount && { + ...partnerAndDiscount, + }), }), // record link in Tinybird @@ -253,10 +198,7 @@ export async function createLink(link: CreateLinkProps) { testVariants && testCompletedAt && scheduleABTestCompletion(response), - // Create coupon code for the partner link - !skipCouponCreation && - workspace && - discount && + partnerAndDiscount.discount?.couponCodeTrackingEnabledAt && enqueueCouponCodeCreateJobs({ id: response.id, key: response.key, diff --git a/apps/web/lib/partnerstack/import-links.ts b/apps/web/lib/partnerstack/import-links.ts index c8fa9bd0729..7cdfb15656a 100644 --- a/apps/web/lib/partnerstack/import-links.ts +++ b/apps/web/lib/partnerstack/import-links.ts @@ -175,10 +175,7 @@ async function createPartnerLink({ userId, }); - return createLink({ - ...partnerLink, - skipCouponCreation: true, - }); + return createLink(partnerLink); } catch (error) { console.error("Error creating partner link", error, link); return null; diff --git a/apps/web/lib/planetscale/get-partner-discount.ts b/apps/web/lib/planetscale/get-partner-discount.ts index abf3ec0d8a6..3939e4712e1 100644 --- a/apps/web/lib/planetscale/get-partner-discount.ts +++ b/apps/web/lib/planetscale/get-partner-discount.ts @@ -10,6 +10,7 @@ interface QueryResult { maxDuration: number | null; couponId: string | null; couponTestId: string | null; + couponCodeTrackingEnabledAt: string | null; } // Get partner and discount info for a partner link @@ -33,11 +34,12 @@ 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, + Discount.couponCodeTrackingEnabledAt FROM ProgramEnrollment LEFT JOIN Partner ON Partner.id = ProgramEnrollment.partnerId LEFT JOIN Discount ON Discount.id = ProgramEnrollment.discountId @@ -69,6 +71,7 @@ export const getPartnerAndDiscount = async ({ maxDuration: result.maxDuration, couponId: result.couponId, couponTestId: result.couponTestId, + couponCodeTrackingEnabledAt: result.couponCodeTrackingEnabledAt, } : null, }; diff --git a/apps/web/lib/tolt/import-links.ts b/apps/web/lib/tolt/import-links.ts index eaf7bc74887..8716fbe3740 100644 --- a/apps/web/lib/tolt/import-links.ts +++ b/apps/web/lib/tolt/import-links.ts @@ -140,10 +140,7 @@ async function createPartnerLink({ userId, }); - return createLink({ - ...partnerLink, - skipCouponCreation: true, - }); + return createLink(partnerLink); } catch (error) { console.error("Error creating partner link", error, link); return null; From 5e504f659315adcddf3433c27dd9da83556046ab Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 7 Sep 2025 23:13:55 +0530 Subject: [PATCH 110/221] Update links.ts --- apps/web/lib/zod/schemas/links.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/lib/zod/schemas/links.ts b/apps/web/lib/zod/schemas/links.ts index 4c082a3f4b7..cd64e2d166d 100644 --- a/apps/web/lib/zod/schemas/links.ts +++ b/apps/web/lib/zod/schemas/links.ts @@ -488,7 +488,6 @@ export const bulkUpdateLinksBodySchema = z.object({ .default([]), data: createLinkBodySchema .omit({ - id: true, domain: true, key: true, externalId: true, From 478136af9e0e63cc04f2d10716916ef27f3f646f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 7 Sep 2025 23:13:57 +0530 Subject: [PATCH 111/221] Update partners.ts --- apps/web/lib/zod/schemas/partners.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index d39e048efa5..45087edcda9 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -442,7 +442,6 @@ export const createPartnerSchema = z.object({ publicStats: true, tagId: true, geo: true, - projectId: true, programId: true, partnerId: true, webhookIds: true, From 7d726d3416f74c5ae701aef7f7096691f0300b05 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 7 Sep 2025 23:16:44 +0530 Subject: [PATCH 112/221] Update route.ts --- .../cron/links/invalidate-for-discounts/route.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) 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 2a3fc5f3d13..ee7ce34083f 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 @@ -35,8 +35,7 @@ export async function POST(req: Request) { }); if (!group) { - return logAndRespond({ - message: `Group ${groupId} not found.`, + return logAndRespond(`Group ${groupId} not found.`, { logLevel: "error", }); } @@ -62,17 +61,13 @@ export async function POST(req: Request) { }); if (programEnrollments.length === 0) { - return logAndRespond({ - message: `No program enrollments found for group ${groupId}.`, - }); + return logAndRespond(`No program enrollments found for group ${groupId}.`); } const links = programEnrollments.flatMap((enrollment) => enrollment.links); if (links.length === 0) { - return logAndRespond({ - message: `No links found for partners in the group ${groupId}.`, - }); + return logAndRespond(`No links found for partners in the group ${groupId}.`); } const linkChunks = chunk(links, 100); @@ -83,9 +78,7 @@ export async function POST(req: Request) { await linkCache.expireMany(toExpire); } - return logAndRespond({ - message: `Expired cache for ${links.length} links.`, - }); + return logAndRespond(`Expired cache for ${links.length} links.`); } catch (error) { return handleAndReturnErrorResponse(error); } From a4b7fb2ba02efb5cf4e9aee43a6de69a22bb0f44 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 7 Sep 2025 23:26:15 +0530 Subject: [PATCH 113/221] Update partner-profile.ts --- apps/web/lib/zod/schemas/partner-profile.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index 481b8861053..2a3a2180dd6 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -101,7 +101,6 @@ export const PartnerProfileCustomerSchema = CustomerEnrichedSchema.pick({ }); export const partnerProfileAnalyticsQuerySchema = analyticsQuerySchema.omit({ - workspaceId: true, externalId: true, tenantId: true, programId: true, @@ -112,7 +111,6 @@ export const partnerProfileAnalyticsQuerySchema = analyticsQuerySchema.omit({ }); export const partnerProfileEventsQuerySchema = eventsQuerySchema.omit({ - workspaceId: true, externalId: true, tenantId: true, programId: true, From 77ed8c1f9da1a4d6a859c72fd0f9ba594d41fc62 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 7 Sep 2025 23:26:17 +0530 Subject: [PATCH 114/221] Update payouts.ts --- apps/web/lib/zod/schemas/payouts.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/lib/zod/schemas/payouts.ts b/apps/web/lib/zod/schemas/payouts.ts index 7c80386c47b..1a58cd68747 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({ From 9ed32987275627dbc0d0ce102cfa0abb7a84e1d3 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 7 Sep 2025 23:32:53 +0530 Subject: [PATCH 115/221] Update pnpm-lock.yaml --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58b2ee83bcf..bdfe1895ec2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22521,4 +22521,4 @@ snapshots: zod@3.23.8: {} - zwitch@2.0.4: {} + zwitch@2.0.4: {} \ No newline at end of file From 4ae412e3a120a0c10b89312616af06069eaaea77 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 7 Sep 2025 23:54:45 +0530 Subject: [PATCH 116/221] fix build --- apps/web/lib/embed/referrals/auth.ts | 1 - apps/web/lib/partners/approve-partner-enrollment.ts | 7 ------- apps/web/scripts/partners/import-partners.ts | 1 - 3 files changed, 9 deletions(-) diff --git a/apps/web/lib/embed/referrals/auth.ts b/apps/web/lib/embed/referrals/auth.ts index 5b8bf7ccd60..60b40d667b5 100644 --- a/apps/web/lib/embed/referrals/auth.ts +++ b/apps/web/lib/embed/referrals/auth.ts @@ -123,7 +123,6 @@ export const withReferralsEmbedToken = ( programEnrollment, group: partnerGroup as PartnerGroupProps, links, - discount, embedToken, }); } catch (error) { diff --git a/apps/web/lib/partners/approve-partner-enrollment.ts b/apps/web/lib/partners/approve-partner-enrollment.ts index 5172ca7da78..d42ddf51ed9 100644 --- a/apps/web/lib/partners/approve-partner-enrollment.ts +++ b/apps/web/lib/partners/approve-partner-enrollment.ts @@ -181,13 +181,6 @@ export async function approvePartnerEnrollment({ }, ], }), - - discount?.couponCodeTrackingEnabledAt && - createStripePromotionCode({ - workspace, - link: partnerLink, - discount, - }), ]); })(), ); diff --git a/apps/web/scripts/partners/import-partners.ts b/apps/web/scripts/partners/import-partners.ts index 8b4b769a7ff..dfa6ffdb06f 100644 --- a/apps/web/scripts/partners/import-partners.ts +++ b/apps/web/scripts/partners/import-partners.ts @@ -58,7 +58,6 @@ async function main() { id: program.workspace.id, plan: program.workspace.plan as "advanced", webhookEnabled: program.workspace.webhookEnabled, - stripeConnectId: program.workspace.stripeConnectId, }, program, partner: partnerToCreate, From 9d93ef3d57558e1007f47ef21633e9ea40ec8101 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 8 Sep 2025 07:33:27 +0530 Subject: [PATCH 117/221] add coupon code creation during bulk create links --- apps/web/lib/api/links/bulk-create-links.ts | 49 ++++++++++++++++----- apps/web/lib/api/links/delete-link.ts | 5 +-- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/apps/web/lib/api/links/bulk-create-links.ts b/apps/web/lib/api/links/bulk-create-links.ts index 48b898db232..c4ffffb95e6 100644 --- a/apps/web/lib/api/links/bulk-create-links.ts +++ b/apps/web/lib/api/links/bulk-create-links.ts @@ -4,6 +4,7 @@ import { getParamsFromURL, linkConstructorSimple, truncate } from "@dub/utils"; import { Prisma } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import { createId } from "../create-id"; +import { enqueueCouponCodeCreateJobs } from "../discounts/enqueue-coupon-code-create-jobs"; import { combineTagIds } from "../tags/combine-tag-ids"; import { encodeKeyIfCaseSensitive } from "./case-sensitivity"; import { includeTags } from "./include-tags"; @@ -213,16 +214,44 @@ export async function bulkCreateLinks({ } waitUntil( - Promise.all([ - propagateBulkLinkChanges({ - links: createdLinksData, - skipRedisCache, - }), - updateLinksUsage({ - workspaceId: links[0].projectId!, // this will always be present - increment: links.length, - }), - ]), + (async () => { + // Find the links with coupon code tracking enabled + const partnerLinks = await prisma.link.findMany({ + where: { + id: { + in: createdLinksData.map((link) => link.id), + }, + }, + select: { + id: true, + key: true, + programEnrollment: { + select: { + discount: { + select: { + couponCodeTrackingEnabledAt: true, + }, + }, + }, + }, + }, + }); + + Promise.allSettled([ + propagateBulkLinkChanges({ + links: createdLinksData, + skipRedisCache, + }), + + updateLinksUsage({ + workspaceId: links[0].projectId!, // this will always be present + increment: links.length, + }), + + // Enqueue coupon code create jobs for partner links + enqueueCouponCodeCreateJobs(partnerLinks), + ]); + })(), ); // Simplified sorting using the map diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index 6cae2185c63..0d8f8623652 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -24,8 +24,6 @@ export async function deleteLink(linkId: string) { }, }); - const { project: workspace } = link; - waitUntil( Promise.allSettled([ // if there's a valid image and it has the same link ID, delete it @@ -52,7 +50,8 @@ export async function deleteLink(linkId: string) { }, }), - link.projectId && enqueueCouponCodeDeleteJobs(link), + // Delete the coupon code for the link if it exists + link.couponCode && enqueueCouponCodeDeleteJobs(link), ]), ); From 97c7b3a006e7ecfe1492b621da569fbb76a94a98 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 8 Sep 2025 08:08:39 +0530 Subject: [PATCH 118/221] Update delete-discount.ts --- apps/web/lib/actions/partners/delete-discount.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/actions/partners/delete-discount.ts b/apps/web/lib/actions/partners/delete-discount.ts index 7ef0873548f..13f2c3cc032 100644 --- a/apps/web/lib/actions/partners/delete-discount.ts +++ b/apps/web/lib/actions/partners/delete-discount.ts @@ -67,7 +67,7 @@ export const deleteDiscountAction = authActionClient discount.couponCodeTrackingEnabledAt && qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/delete-promotion-codes`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/enqueue-coupon-code-delete-jobs`, body: { groupId: group.id, }, From 8985d7fa5cf756aa96ca359ad94050b64edc59b4 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 8 Sep 2025 08:39:34 +0530 Subject: [PATCH 119/221] fix cron URL --- apps/web/lib/actions/partners/create-discount.ts | 2 +- apps/web/lib/actions/partners/update-discount.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 03180551160..b90785893f4 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -119,7 +119,7 @@ export const createDiscountAction = authActionClient discount.couponCodeTrackingEnabledAt && qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/enqueue-promotion-code-create-jobs`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/enqueue-coupon-code-create-jobs`, body: { discountId: discount.id, }, diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index f78b6959a4c..032cbe75d61 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -63,7 +63,7 @@ export const updateDiscountAction = authActionClient trackingEnabled && qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/enqueue-promotion-code-create-jobs`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/enqueue-coupon-code-create-jobs`, body: { discountId: discount.id, }, From bce917a02bea0a90ac69633a0d3d0a7dd907fa5f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 8 Sep 2025 18:20:07 +0530 Subject: [PATCH 120/221] some fixes after testing --- .../enqueue-coupon-code-create-jobs/route.ts | 4 ++-- .../enqueue-coupon-code-delete-jobs/route.ts | 5 ++--- apps/web/lib/api/partners/get-partners.ts | 1 + apps/web/lib/stripe/create-stripe-coupon.ts | 11 +++++++---- apps/web/lib/zod/schemas/links.ts | 4 ++++ apps/web/lib/zod/schemas/programs.ts | 3 +-- .../partners/groups/reward-discount-partners-card.tsx | 1 + 7 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-create-jobs/route.ts b/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-create-jobs/route.ts index e704e968deb..aae4ec97095 100644 --- a/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-create-jobs/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-create-jobs/route.ts @@ -64,7 +64,7 @@ export async function POST(req: Request) { where: { groupId: group.id, ...(cursor && { - createdAt: { + id: { gt: cursor, }, }), @@ -81,7 +81,7 @@ export async function POST(req: Request) { }, take: PAGE_SIZE, orderBy: { - createdAt: "asc", + id: "asc", }, }); diff --git a/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-delete-jobs/route.ts b/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-delete-jobs/route.ts index 05ff639f149..69ca6ebc3d3 100644 --- a/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-delete-jobs/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/enqueue-coupon-code-delete-jobs/route.ts @@ -47,7 +47,7 @@ export async function POST(req: Request) { where: { groupId: group.id, ...(cursor && { - createdAt: { + id: { gt: cursor, }, }), @@ -63,7 +63,7 @@ export async function POST(req: Request) { }, take: PAGE_SIZE, orderBy: { - createdAt: "asc", + id: "asc", }, }); @@ -83,7 +83,6 @@ export async function POST(req: Request) { for (const linkChunk of linkChunks) { await enqueueCouponCodeDeleteJobs(linkChunk); - await new Promise((resolve) => setTimeout(resolve, 2000)); } } diff --git a/apps/web/lib/api/partners/get-partners.ts b/apps/web/lib/api/partners/get-partners.ts index cffd102bcce..aa90748a9be 100644 --- a/apps/web/lib/api/partners/get-partners.ts +++ b/apps/web/lib/api/partners/get-partners.ts @@ -92,6 +92,7 @@ export async function getPartners(filters: PartnerFilters) { 'domain', l.domain, 'key', l.\`key\`, 'shortLink', l.shortLink, + 'couponCode', l.couponCode, 'url', l.url, 'clicks', CAST(l.clicks AS SIGNED), 'leads', CAST(l.leads AS SIGNED), diff --git a/apps/web/lib/stripe/create-stripe-coupon.ts b/apps/web/lib/stripe/create-stripe-coupon.ts index 015f0eb34ec..d944af69de4 100644 --- a/apps/web/lib/stripe/create-stripe-coupon.ts +++ b/apps/web/lib/stripe/create-stripe-coupon.ts @@ -59,10 +59,13 @@ export async function createStripeCoupon({ return stripeCoupon; } catch (error) { - console.log(`Failed create Stripe coupon for workspace ${workspace.id}.`, { - error, - discount, - }); + console.error( + `Failed create Stripe coupon for workspace ${workspace.id}.`, + { + error, + discount, + }, + ); return null; } diff --git a/apps/web/lib/zod/schemas/links.ts b/apps/web/lib/zod/schemas/links.ts index cd64e2d166d..595948b1056 100644 --- a/apps/web/lib/zod/schemas/links.ts +++ b/apps/web/lib/zod/schemas/links.ts @@ -544,6 +544,10 @@ export const LinkSchema = z .string() .nullable() .describe("The ID of the partner the short link is associated with."), + couponCode: z + .string() + .nullable() + .describe("The coupon code associated with the short link."), archived: z .boolean() .default(false) diff --git a/apps/web/lib/zod/schemas/programs.ts b/apps/web/lib/zod/schemas/programs.ts index 88625c992fc..0116ed9df9a 100644 --- a/apps/web/lib/zod/schemas/programs.ts +++ b/apps/web/lib/zod/schemas/programs.ts @@ -87,8 +87,7 @@ export const ProgramPartnerLinkSchema = LinkSchema.pick({ leads: true, sales: true, saleAmount: true, -}).extend({ - couponCode: z.string().nullable(), + couponCode: true, }); export const ProgramEnrollmentSchema = z.object({ diff --git a/apps/web/ui/partners/groups/reward-discount-partners-card.tsx b/apps/web/ui/partners/groups/reward-discount-partners-card.tsx index cc7aae2c0c5..3fa1e1a1389 100644 --- a/apps/web/ui/partners/groups/reward-discount-partners-card.tsx +++ b/apps/web/ui/partners/groups/reward-discount-partners-card.tsx @@ -22,6 +22,7 @@ export function RewardDiscountPartnersCard({ groupId }: { groupId: string }) { }, }); + return (
+ + + +
)}
diff --git a/apps/web/lib/zod/schemas/groups.ts b/apps/web/lib/zod/schemas/groups.ts index 5a974707079..4ba79d347cf 100644 --- a/apps/web/lib/zod/schemas/groups.ts +++ b/apps/web/lib/zod/schemas/groups.ts @@ -27,7 +27,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/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index 44bec444e1b..de1e69e4b51 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -84,10 +84,10 @@ export const PartnerProfileLinkSchema = LinkSchema.pick({ sales: true, saleAmount: true, comments: true, - couponCode: true, }).extend({ createdAt: z.string().or(z.date()), partnerGroupDefaultLinkId: z.string().nullish(), + discountCode: z.string().nullable().default(null), }); export const PartnerProfileCustomerSchema = CustomerEnrichedSchema.pick({ From 23f2eeeba3a4fbc28fd3a79d9e14c33ed03b9e59 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 24 Sep 2025 20:27:59 +0530 Subject: [PATCH 157/221] record audit log --- .../discount-codes/[discountCodeId]/route.ts | 28 +++++++++++++++---- apps/web/app/(ee)/api/discount-codes/route.ts | 21 +++++++++++++- apps/web/lib/api/audit-logs/schemas.ts | 10 ++++++- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts index a0888357515..b04b7c0cc0c 100644 --- a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts @@ -1,3 +1,4 @@ +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; @@ -8,7 +9,7 @@ import { NextResponse } from "next/server"; // DELETE /api/discount-codes/[discountCodeId] - delete a discount code export const DELETE = withWorkspace( - async ({ workspace, params }) => { + async ({ workspace, params, session }) => { const { discountCodeId } = params; const programId = getDefaultProgramIdOrThrow(workspace); @@ -39,10 +40,27 @@ export const DELETE = withWorkspace( }); waitUntil( - disableStripeDiscountCode({ - stripeConnectId: workspace.stripeConnectId, - code: discountCode.code, - }), + 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, + }, + ], + }), + + disableStripeDiscountCode({ + stripeConnectId: workspace.stripeConnectId, + code: discountCode.code, + }), + ]), ); return NextResponse.json({ id: discountCode.id }); diff --git a/apps/web/app/(ee)/api/discount-codes/route.ts b/apps/web/app/(ee)/api/discount-codes/route.ts index 00244d861ac..b44e84d4570 100644 --- a/apps/web/app/(ee)/api/discount-codes/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/route.ts @@ -1,3 +1,4 @@ +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; @@ -10,6 +11,7 @@ import { 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 @@ -45,7 +47,7 @@ export const GET = withWorkspace( // POST /api/discount-codes - create a discount code export const POST = withWorkspace( - async ({ workspace, req }) => { + async ({ workspace, req, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); let { partnerId, linkId, code } = createDiscountCodeSchema.parse( @@ -150,6 +152,23 @@ export const POST = withWorkspace( }, }); + 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({ diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index 8ba00cda590..6bbdcb7992b 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", @@ -128,6 +130,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(), From 4c6246a45c3cf3651e742b38e68e70ad5545a8de Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 24 Sep 2025 23:24:12 +0530 Subject: [PATCH 158/221] add discount code modal --- .../[partnerId]/links/page-client.tsx | 27 +- apps/web/lib/zod/schemas/discount.ts | 12 +- .../ui/partners/add-discount-code-modal.tsx | 235 ++++++++++++++++++ 3 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 apps/web/ui/partners/add-discount-code-modal.tsx diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx index dfda14895df..ca1f0e9e7cf 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx @@ -3,6 +3,7 @@ import usePartner from "@/lib/swr/use-partner"; import useWorkspace from "@/lib/swr/use-workspace"; import { EnrolledPartnerProps } from "@/lib/types"; +import { useAddDiscountCodeModal } from "@/ui/partners/add-discount-code-modal"; import { useAddPartnerLinkModal } from "@/ui/partners/add-partner-link-modal"; import { Button, CopyButton, LoadingSpinner, Table, useTable } from "@dub/ui"; import { cn, currencyFormatter, getPrettyUrl, nFormatter } from "@dub/utils"; @@ -36,6 +37,11 @@ const PartnerLinks = ({ partner }: { partner: EnrolledPartnerProps }) => { partner, }); + const { AddDiscountCodeModal, setShowAddDiscountCodeModal } = + useAddDiscountCodeModal({ + partner, + }); + const table = useTable({ data: partner.links || [], columns: [ @@ -129,14 +135,23 @@ const PartnerLinks = ({ partner }: { partner: EnrolledPartnerProps }) => { return ( <> +

Links

-
diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index 4eaaab1bf4e..54e5e9886b6 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -54,6 +54,7 @@ export const discountPartnersQuerySchema = z ); // Discount codes + export const DiscountCodeSchema = z.object({ id: z.string(), code: z.string(), @@ -64,12 +65,15 @@ export const DiscountCodeSchema = z.object({ updatedAt: z.date(), }); -export const getDiscountCodesQuerySchema = z.object({ +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 createDiscountCodeSchema = z.object({ - code: z.string().optional(), +export const getDiscountCodesQuerySchema = z.object({ partnerId: z.string(), - linkId: z.string(), }); diff --git a/apps/web/ui/partners/add-discount-code-modal.tsx b/apps/web/ui/partners/add-discount-code-modal.tsx new file mode 100644 index 00000000000..98eef01f8f8 --- /dev/null +++ b/apps/web/ui/partners/add-discount-code-modal.tsx @@ -0,0 +1,235 @@ +import { mutatePrefix } from "@/lib/swr/mutate"; +import { useApiMutation } from "@/lib/swr/use-api-mutation"; +import { EnrolledPartnerProps } from "@/lib/types"; +import { createDiscountCodeSchema } from "@/lib/zod/schemas/discount"; +import { + Button, + Combobox, + ComboboxOption, + InfoTooltip, + Modal, + SimpleTooltipContent, + useCopyToClipboard, + useMediaQuery, +} from "@dub/ui"; +import { cn } from "@dub/utils"; +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 { X } from "../shared/icons"; + +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 { isMobile } = useMediaQuery(); + 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: link.shortLink, + })); + } + + return partnerLinks + .filter((link) => + link.shortLink.toLowerCase().includes(debouncedSearch.toLowerCase()), + ) + .map((link) => ({ + value: link.id, + label: link.shortLink, + })); + }, [partnerLinks, debouncedSearch]); + + const onSubmit = async (formData: FormData) => { + await createDiscountCode("/api/discount-codes", { + method: "POST", + body: { + ...formData, + partnerId: partner.id, + }, + onSuccess: async () => { + setShowModal(false); + toast.success("Discount code created successfully."); + await mutatePrefix("/api/discount-codes"); + }, + }); + }; + + return ( + +
+
+
+

New discount code

+ +
+ +
+
+
+ + + } + /> +
+ + +
+ +
+
+ + + } + /> +
+ + { + if (!option) { + return; + } + + setValue("linkId", option.value); + }} + options={linkOptions} + caret={true} + placeholder="Select link..." + searchPlaceholder="Search links..." + 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} + /> +
+
+
+ +
+
+ +
+ ); +}; + +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], + ); +} From 10836c8a58043dba5298640b15cb3505d1a19aa2 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 09:38:08 +0530 Subject: [PATCH 159/221] wip --- apps/web/lib/actions/partners/ban-partner.ts | 3 +- .../lib/actions/partners/bulk-ban-partners.ts | 3 +- .../discounts/queue-discount-code-deletion.ts | 113 +++++++++--------- 3 files changed, 62 insertions(+), 57 deletions(-) diff --git a/apps/web/lib/actions/partners/ban-partner.ts b/apps/web/lib/actions/partners/ban-partner.ts index 9c654fca34d..7c4a2583258 100644 --- a/apps/web/lib/actions/partners/ban-partner.ts +++ b/apps/web/lib/actions/partners/ban-partner.ts @@ -105,7 +105,8 @@ export const banPartnerAction = authActionClient ...links.map((link) => queueDiscountCodeDeletion({ - discountCodeId: link.discountCode?.id, + code: link.discountCode?.code, + stripeConnectId: workspace.stripeConnectId, }), ), diff --git a/apps/web/lib/actions/partners/bulk-ban-partners.ts b/apps/web/lib/actions/partners/bulk-ban-partners.ts index 9da0a051282..28eabc30fc5 100644 --- a/apps/web/lib/actions/partners/bulk-ban-partners.ts +++ b/apps/web/lib/actions/partners/bulk-ban-partners.ts @@ -132,7 +132,8 @@ export const bulkBanPartnersAction = authActionClient await Promise.allSettled( links.map((link) => queueDiscountCodeDeletion({ - discountCodeId: link.discountCode?.id, + code: link.discountCode?.code, + stripeConnectId: workspace.stripeConnectId, }), ), ); diff --git a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts index 390cff04c5c..ad0265250e8 100644 --- a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts +++ b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts @@ -6,74 +6,77 @@ const queue = qstash.queue({ queueName: "discount-code-deletion", }); +interface Payload { + code: string | null | undefined; + stripeConnectId: string | null | undefined; +} + // Triggered in the following cases: // 1. When a discount is deleted // 2. When coupon tracking is disabled for a discount // 3. When a link is deleted that has a discount code associated with it +// 4. When a partner is banned +// 5. When a partner is moved to a different group export async function queueDiscountCodeDeletion({ - discountId, - discountCodeId, -}: { - discountId?: string; - discountCodeId?: string; -}) { + code, + stripeConnectId, +}: Payload) { await queue.upsert({ parallelism: 10, }); - // Delete a discount code - if (discountCodeId) { - return await queue.enqueueJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/delete-discount-code`, - method: "POST", - body: { - discountCodeId, - }, - }); - } - - // Delete all codes for a discount - if (discountId) { - let cursor: undefined | string = undefined; - - while (true) { - const discountCodes = await prisma.discountCode.findMany({ - where: { - discountId, - }, - select: { - id: true, - }, - orderBy: { - createdAt: "asc", - }, - cursor: { - id: cursor, - }, - take: 100, - }); + const response = await queue.enqueueJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/delete-discount-code`, + method: "POST", + body: { + code, + stripeConnectId, + }, + }); +} - if (discountCodes.length === 0) { - break; - } +export async function batchQueueDiscountCodeDeletion({ + discountId, + stripeConnectId +}: { + discountId: string, + stripeConnectId: string, +}) { + let cursor: undefined | string = undefined; - console.log("discountCodes", discountCodes); + while (true) { + const discountCodes = await prisma.discountCode.findMany({ + where: { + discountId, + }, + select: { + id: true, + }, + orderBy: { + createdAt: "asc", + }, + cursor: { + id: cursor, + }, + take: 100, + }); - const response = await Promise.allSettled( - discountCodes.map(({ id }) => - queue.enqueueJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/delete-discount-code`, - method: "POST", - body: { - discountCodeId: id, - }, - }), - ), - ); + if (discountCodes.length === 0) { + break; + } - console.log("response", response); + await Promise.allSettled( + discountCodes.map(({ id }) => + queue.enqueueJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/delete-discount-code`, + method: "POST", + body: { + + }, + }), + ), + ); - cursor = discountCodes[discountCodes.length - 1].id; - } + cursor = discountCodes[discountCodes.length - 1].id; } } From ecbfd068133aea0c74df8714d1a5d19873033053 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 12:02:26 +0530 Subject: [PATCH 160/221] fix cron jobs --- .../discounts/delete-discount-code/route.ts | 75 ------------------- .../discounts/disable-stripe-code/route.ts | 39 ++++++++++ apps/web/lib/actions/partners/ban-partner.ts | 4 +- .../lib/actions/partners/bulk-ban-partners.ts | 4 +- .../lib/actions/partners/delete-discount.ts | 5 +- .../lib/actions/partners/update-discount.ts | 5 +- .../discounts/queue-discount-code-deletion.ts | 33 +++++--- apps/web/lib/api/links/delete-link.ts | 7 +- 8 files changed, 76 insertions(+), 96 deletions(-) delete mode 100644 apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts create mode 100644 apps/web/app/(ee)/api/cron/discounts/disable-stripe-code/route.ts diff --git a/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts b/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts deleted file mode 100644 index 95de34a5899..00000000000 --- a/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 { z } from "zod"; -import { logAndRespond } from "../../utils"; - -export const dynamic = "force-dynamic"; - -const schema = z.object({ - discountCodeId: z.string(), -}); - -// POST /api/cron/discounts/delete-discount-code -export async function POST(req: Request) { - try { - const rawBody = await req.text(); - await verifyQstashSignature({ req, rawBody }); - - const { discountCodeId } = schema.parse(JSON.parse(rawBody)); - - // Find the discount code - const discountCode = await prisma.discountCode.findUnique({ - where: { - id: discountCodeId, - }, - select: { - code: true, - program: { - select: { - id: true, - workspace: { - select: { - stripeConnectId: true, - }, - }, - }, - }, - }, - }); - - if (!discountCode) { - return logAndRespond(`Discount code ${discountCodeId} not found.`); - } - - const program = discountCode.program; - const workspace = program.workspace; - - if (!workspace.stripeConnectId) { - return logAndRespond( - `stripeConnectId not found for the program ${program.id}.`, - { - status: 400, - logLevel: "error", - }, - ); - } - - // Disable the discount code on Stripe - await disableStripeDiscountCode({ - stripeConnectId: workspace.stripeConnectId, - code: discountCode.code, - }); - - await prisma.discountCode.delete({ - where: { - id: discountCodeId, - }, - }); - - return logAndRespond(`Discount code ${discountCodeId} deleted.`); - } catch (error) { - return handleAndReturnErrorResponse(error); - } -} diff --git a/apps/web/app/(ee)/api/cron/discounts/disable-stripe-code/route.ts b/apps/web/app/(ee)/api/cron/discounts/disable-stripe-code/route.ts new file mode 100644 index 00000000000..5ed23ee8d59 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/discounts/disable-stripe-code/route.ts @@ -0,0 +1,39 @@ +import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { disableStripeDiscountCode } from "@/lib/stripe/disable-stripe-discount-code"; +import { z } from "zod"; +import { logAndRespond } from "../../utils"; + +export const dynamic = "force-dynamic"; + +const schema = z.object({ + code: z.string(), + stripeConnectId: z.string(), +}); + +// POST /api/cron/discounts/disable-stripe-code +// Disable the promotion code on Stripe after the discount is deleted from Dub +export async function POST(req: Request) { + try { + const rawBody = await req.text(); + await verifyQstashSignature({ req, rawBody }); + + const { code, stripeConnectId } = schema.parse(JSON.parse(rawBody)); + + console.log( + `Disabling discount code ${code} on Stripe for ${stripeConnectId}...`, + ); + + // Disable the discount code on Stripe + await disableStripeDiscountCode({ + code, + stripeConnectId, + }); + + return logAndRespond( + `Discount code ${code} disabled from Stripe for ${stripeConnectId}.`, + ); + } catch (error) { + return handleAndReturnErrorResponse(error); + } +} diff --git a/apps/web/lib/actions/partners/ban-partner.ts b/apps/web/lib/actions/partners/ban-partner.ts index 7c4a2583258..5de684d2e65 100644 --- a/apps/web/lib/actions/partners/ban-partner.ts +++ b/apps/web/lib/actions/partners/ban-partner.ts @@ -1,7 +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 { queueStripeDiscountCodeDisable } 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"; @@ -104,7 +104,7 @@ export const banPartnerAction = authActionClient linkCache.expireMany(links), ...links.map((link) => - queueDiscountCodeDeletion({ + queueStripeDiscountCodeDisable({ code: link.discountCode?.code, stripeConnectId: workspace.stripeConnectId, }), diff --git a/apps/web/lib/actions/partners/bulk-ban-partners.ts b/apps/web/lib/actions/partners/bulk-ban-partners.ts index 28eabc30fc5..dffd5d595e8 100644 --- a/apps/web/lib/actions/partners/bulk-ban-partners.ts +++ b/apps/web/lib/actions/partners/bulk-ban-partners.ts @@ -1,7 +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 { queueStripeDiscountCodeDisable } 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"; @@ -131,7 +131,7 @@ export const bulkBanPartnersAction = authActionClient // Queue discount code deletions await Promise.allSettled( links.map((link) => - queueDiscountCodeDeletion({ + queueStripeDiscountCodeDisable({ code: link.discountCode?.code, stripeConnectId: workspace.stripeConnectId, }), diff --git a/apps/web/lib/actions/partners/delete-discount.ts b/apps/web/lib/actions/partners/delete-discount.ts index a9b43dd9b4e..ad563bfab25 100644 --- a/apps/web/lib/actions/partners/delete-discount.ts +++ b/apps/web/lib/actions/partners/delete-discount.ts @@ -1,7 +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 { batchQueueStripeDiscountCodeDisable } 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"; @@ -67,8 +67,9 @@ export const deleteDiscountAction = authActionClient }), discount.couponCodeTrackingEnabledAt && - queueDiscountCodeDeletion({ + batchQueueStripeDiscountCodeDisable({ discountId, + stripeConnectId: workspace.stripeConnectId, }), recordAuditLog({ diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index 71454ee7646..6d3b9e7ed29 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -1,7 +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 { batchQueueStripeDiscountCodeDisable } 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"; @@ -63,8 +63,9 @@ export const updateDiscountAction = authActionClient }), trackingDisabled && - queueDiscountCodeDeletion({ + batchQueueStripeDiscountCodeDisable({ discountId, + stripeConnectId: workspace.stripeConnectId, }), recordAuditLog({ diff --git a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts index ad0265250e8..5d64515fdbe 100644 --- a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts +++ b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts @@ -17,16 +17,20 @@ interface Payload { // 3. When a link is deleted that has a discount code associated with it // 4. When a partner is banned // 5. When a partner is moved to a different group -export async function queueDiscountCodeDeletion({ +export async function queueStripeDiscountCodeDisable({ code, stripeConnectId, }: Payload) { + if (!stripeConnectId) { + return; + } + await queue.upsert({ parallelism: 10, }); - const response = await queue.enqueueJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/delete-discount-code`, + await queue.enqueueJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/disable-stripe-code`, method: "POST", body: { code, @@ -35,13 +39,18 @@ export async function queueDiscountCodeDeletion({ }); } -export async function batchQueueDiscountCodeDeletion({ +// For a given discount, we'll delete the discount codes in batches +export async function batchQueueStripeDiscountCodeDisable({ discountId, - stripeConnectId + stripeConnectId, }: { - discountId: string, - stripeConnectId: string, + discountId: string; + stripeConnectId: string | null | undefined; }) { + if (!stripeConnectId) { + return; + } + let cursor: undefined | string = undefined; while (true) { @@ -61,17 +70,21 @@ export async function batchQueueDiscountCodeDeletion({ take: 100, }); + // TODO: + // Talk to Upstash to see if there is a way to batch send this + if (discountCodes.length === 0) { break; } await Promise.allSettled( - discountCodes.map(({ id }) => + discountCodes.map(({ code }) => queue.enqueueJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/delete-discount-code`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/disable-stripe-code`, method: "POST", body: { - + code, + stripeConnectId, }, }), ), diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index 78ab8a0141a..ac4befaba37 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -3,7 +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 { queueStripeDiscountCodeDisable } from "../discounts/queue-discount-code-deletion"; import { linkCache } from "./cache"; import { includeTags } from "./include-tags"; import { transformLink } from "./utils"; @@ -52,8 +52,9 @@ export async function deleteLink(linkId: string) { }), link.discountCode && - queueDiscountCodeDeletion({ - discountCodeId: link.discountCode.id, + queueStripeDiscountCodeDisable({ + code: link.discountCode.code, + stripeConnectId: link.project?.stripeConnectId, }), ]), ); From a4d12c5380a36d4570060103bb74261ea66b4f1e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 12:25:04 +0530 Subject: [PATCH 161/221] display the dicounts on the parter profile page --- .../[partnerId]/links/page-client.tsx | 139 ++++++++++++++++-- apps/web/lib/swr/use-discount-codes.ts | 30 ++++ apps/web/lib/types.ts | 4 +- .../ui/partners/add-discount-code-modal.tsx | 63 ++++---- 4 files changed, 190 insertions(+), 46 deletions(-) create mode 100644 apps/web/lib/swr/use-discount-codes.ts diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx index ca1f0e9e7cf..1b6e0f67143 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx @@ -1,5 +1,6 @@ "use client"; +import useDiscountCodes from "@/lib/swr/use-discount-codes"; import usePartner from "@/lib/swr/use-partner"; import useWorkspace from "@/lib/swr/use-workspace"; import { EnrolledPartnerProps } from "@/lib/types"; @@ -15,7 +16,10 @@ export function ProgramPartnerLinksPageClient() { const { partner, error } = usePartner({ partnerId }); return partner ? ( - +
+ + +
) : (
{error ? ( @@ -37,11 +41,6 @@ const PartnerLinks = ({ partner }: { partner: EnrolledPartnerProps }) => { partner, }); - const { AddDiscountCodeModal, setShowAddDiscountCodeModal } = - useAddDiscountCodeModal({ - partner, - }); - const table = useTable({ data: partner.links || [], columns: [ @@ -134,24 +133,140 @@ const PartnerLinks = ({ partner }: { partner: EnrolledPartnerProps }) => { return ( <> - -

Links

-
+
+
+
+ + + + ); +}; + +const PartnerDiscountCodes = ({ + partner, +}: { + partner: EnrolledPartnerProps; +}) => { + const { slug } = useWorkspace(); + const { discountCodes, loading, error } = useDiscountCodes({ + partnerId: partner.id || null, + }); + + const { AddDiscountCodeModal, setShowAddDiscountCodeModal } = + useAddDiscountCodeModal({ + partner, + }); + + const table = useTable({ + data: discountCodes || [], + columns: [ + { + 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: "code", + header: "Discount code", + cell: ({ row }) => ( +
+ {row.original.code} + +
+ ), + }, + ], + resourceName: (p) => `discount code${p ? "s" : ""}`, + thClassName: (id) => + cn(id === "total" && "[&>div]:justify-end", "border-l-0"), + tdClassName: (id) => cn(id === "total" && "text-right", "border-l-0"), + className: "[&_tr:last-child>td]:border-b-transparent", + scrollWrapperClassName: "min-h-[40px]", + } as any); + + if (loading) { + return ( + <> + +
+

+ Discount codes +

+
+ +
+ + ); + } + + if (error) { + return ( + <> + +
+

+ Discount codes +

+
+ + Failed to load discount codes + +
+ + ); + } + + return ( + <> + +
+

+ Discount codes +

+
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 00000000000..06d14dd80c3 --- /dev/null +++ b/apps/web/lib/swr/use-discount-codes.ts @@ -0,0 +1,30 @@ +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, + }, + ); + + return { + discountCodes, + loading: !discountCodes && !error, + error, + }; +} diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 0d2a6bc518d..ac2fdd099fb 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 { DiscountSchema, DiscountCodeSchema } from "./zod/schemas/discount"; import { FolderSchema } from "./zod/schemas/folders"; import { additionalPartnerLinkSchema, @@ -440,6 +440,8 @@ export type EnrolledPartnerProps = 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/ui/partners/add-discount-code-modal.tsx b/apps/web/ui/partners/add-discount-code-modal.tsx index 98eef01f8f8..b75be7e5cfb 100644 --- a/apps/web/ui/partners/add-discount-code-modal.tsx +++ b/apps/web/ui/partners/add-discount-code-modal.tsx @@ -3,16 +3,16 @@ import { useApiMutation } from "@/lib/swr/use-api-mutation"; import { EnrolledPartnerProps } from "@/lib/types"; import { createDiscountCodeSchema } from "@/lib/zod/schemas/discount"; import { + ArrowTurnLeft, Button, Combobox, ComboboxOption, - InfoTooltip, Modal, - SimpleTooltipContent, useCopyToClipboard, useMediaQuery, } from "@dub/ui"; import { cn } 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"; @@ -114,27 +114,26 @@ const AddDiscountCodeModal = ({ htmlFor="code" className="block text-sm font-medium text-neutral-700" > - Discount code + Discount code (optional) - - } - /> - +
+
+ +
+ +
+

+ Discount codes cannot be edited after creation +

@@ -145,15 +144,6 @@ const AddDiscountCodeModal = ({ > Referral link - - } - />
+ ); }; 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 fa890b000fb..d3f537e3caa 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/stripe/create-stripe-discount-code.ts b/apps/web/lib/stripe/create-stripe-discount-code.ts index 210a6de862c..da705cd26b1 100644 --- a/apps/web/lib/stripe/create-stripe-discount-code.ts +++ b/apps/web/lib/stripe/create-stripe-discount-code.ts @@ -12,10 +12,12 @@ 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( @@ -49,6 +51,10 @@ export async function createStripeDiscountCode({ throw error; } + if (!shouldRetry) { + throw error; + } + attempt++; if (attempt >= MAX_ATTEMPTS) { diff --git a/apps/web/lib/swr/use-discount-codes.ts b/apps/web/lib/swr/use-discount-codes.ts index 06d14dd80c3..cb82fbc66ef 100644 --- a/apps/web/lib/swr/use-discount-codes.ts +++ b/apps/web/lib/swr/use-discount-codes.ts @@ -19,6 +19,7 @@ export default function useDiscountCodes({ fetcher, { dedupingInterval: 60000, + keepPreviousData: true, }, ); From aec6609d58e62ed4416618760a2e7c40ebb1bcb2 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 13:07:24 +0530 Subject: [PATCH 164/221] Update discount.ts --- apps/web/lib/zod/schemas/discount.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index 948f5325dea..f3da0481b67 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -50,16 +50,12 @@ export const discountPartnersQuerySchema = z }), ); -// Discount codes - export const DiscountCodeSchema = z.object({ id: z.string(), code: z.string(), discountId: z.string(), partnerId: z.string(), linkId: z.string(), - createdAt: z.date(), - updatedAt: z.date(), }); export const createDiscountCodeSchema = z.object({ From 3378d3c81f6c1568f459364d57616e694be2d89f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 13:23:30 +0530 Subject: [PATCH 165/221] add table action button --- .../[partnerId]/links/page-client.tsx | 83 ++++++++++++++++++- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx index bed6ea80256..7073d20df9e 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx @@ -6,10 +6,23 @@ import useWorkspace from "@/lib/swr/use-workspace"; import { EnrolledPartnerProps } from "@/lib/types"; import { useAddDiscountCodeModal } from "@/ui/partners/add-discount-code-modal"; import { useAddPartnerLinkModal } from "@/ui/partners/add-partner-link-modal"; -import { Button, CopyButton, LoadingSpinner, Table, useTable } from "@dub/ui"; +import { + Button, + CopyButton, + LoadingSpinner, + MenuItem, + Popover, + Table, + Tag, + useTable, +} from "@dub/ui"; +import { Dots, Trash } from "@dub/ui/icons"; import { cn, currencyFormatter, getPrettyUrl, nFormatter } from "@dub/utils"; +import { Command } from "cmdk"; import Link from "next/link"; import { useParams } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; export function ProgramPartnerLinksPageClient() { const { partnerId } = useParams() as { partnerId: string }; @@ -193,12 +206,31 @@ const PartnerDiscountCodes = ({ id: "code", header: "Discount code", cell: ({ row }) => ( -
- {row.original.code} - +
+ +
+ {row.original.code} +
+ + +
), }, + { + id: "menu", + enableHiding: false, + minSize: 25, + size: 25, + maxSize: 25, + cell: ({ row }) => ( + + ), + }, ], resourceName: (p) => `discount code${p ? "s" : ""}`, thClassName: (id) => @@ -236,3 +268,46 @@ const PartnerDiscountCodes = ({ ); }; + +function DiscountCodeRowMenuButton({ + discountCode, +}: { + discountCode: any; // TODO: Add proper type for discount code +}) { + const [isOpen, setIsOpen] = useState(false); + + const handleDelete = () => { + // TODO: Implement delete functionality + toast.info("Delete functionality coming soon"); + setIsOpen(false); + }; + + return ( + + + + Delete code + + + + } + align="end" + > +
+ + + + ); +}; + +export function useDeleteDiscountCodeModal(discountCode: DiscountCodeProps) { + const [showDeleteDiscountCodeModal, setShowDeleteDiscountCodeModal] = + useState(false); + + const DeleteDiscountCodeModalCallback = useCallback(() => { + return ( + + ); + }, [ + showDeleteDiscountCodeModal, + setShowDeleteDiscountCodeModal, + discountCode, + ]); + + return useMemo( + () => ({ + setShowDeleteDiscountCodeModal, + DeleteDiscountCodeModal: DeleteDiscountCodeModalCallback, + }), + [setShowDeleteDiscountCodeModal, DeleteDiscountCodeModalCallback], + ); +} From b3a2a8af2d192ab810b55ec1f79e4a7d0ec0a213 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 17:27:54 +0530 Subject: [PATCH 167/221] Refactor discount code deletion --- .../discounts/disable-stripe-code/route.ts | 49 ++++++-- .../discount-codes/[discountCodeId]/route.ts | 9 +- .../(ee)/api/groups/[groupIdOrSlug]/route.ts | 43 ++++++- apps/web/lib/actions/partners/ban-partner.ts | 5 +- .../lib/actions/partners/bulk-ban-partners.ts | 5 +- .../lib/actions/partners/delete-discount.ts | 26 +++- .../discounts/queue-discount-code-deletion.ts | 116 +++++++++--------- apps/web/lib/api/links/delete-link.ts | 6 +- .../get-program-enrollment-or-throw.ts | 8 +- apps/web/lib/zod/schemas/discount.ts | 2 +- apps/web/scripts/discount.ts | 14 +++ packages/prisma/schema/discount.prisma | 4 +- 12 files changed, 190 insertions(+), 97 deletions(-) create mode 100644 apps/web/scripts/discount.ts diff --git a/apps/web/app/(ee)/api/cron/discounts/disable-stripe-code/route.ts b/apps/web/app/(ee)/api/cron/discounts/disable-stripe-code/route.ts index 5ed23ee8d59..76fc9503c3e 100644 --- a/apps/web/app/(ee)/api/cron/discounts/disable-stripe-code/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/disable-stripe-code/route.ts @@ -1,37 +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 { z } from "zod"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const schema = z.object({ - code: z.string(), - stripeConnectId: z.string(), + discountCodeId: z.string(), }); // POST /api/cron/discounts/disable-stripe-code -// Disable the promotion code on Stripe after the discount is deleted from Dub export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); - const { code, stripeConnectId } = schema.parse(JSON.parse(rawBody)); + const { discountCodeId } = schema.parse(JSON.parse(rawBody)); - console.log( - `Disabling discount code ${code} on Stripe for ${stripeConnectId}...`, - ); + const discountCode = await prisma.discountCode.findUnique({ + where: { + id: discountCodeId, + }, + }); + + if (!discountCode) { + return logAndRespond(`Discount code ${discountCodeId} not found.`); + } + + if (discountCode.discountId) { + return logAndRespond(`Discount code ${discountCodeId} is not deleted.`); + } - // Disable the discount code on Stripe - await disableStripeDiscountCode({ - code, - stripeConnectId, + 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 ${code} disabled from Stripe for ${stripeConnectId}.`, + `Discount code ${discountCode.code} disabled from Stripe for ${workspace.stripeConnectId}.`, ); } 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 index b04b7c0cc0c..5e7971c1639 100644 --- a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts @@ -7,7 +7,7 @@ import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; -// DELETE /api/discount-codes/[discountCodeId] - delete a discount code +// DELETE /api/discount-codes/[discountCodeId] - soft delete a discount code export const DELETE = withWorkspace( async ({ workspace, params, session }) => { const { discountCodeId } = params; @@ -19,7 +19,7 @@ export const DELETE = withWorkspace( }, }); - if (!discountCode) { + if (!discountCode || !discountCode.discountId) { throw new DubApiError({ message: `Discount code (${discountCodeId}) not found.`, code: "bad_request", @@ -33,10 +33,13 @@ export const DELETE = withWorkspace( }); } - await prisma.discountCode.delete({ + await prisma.discountCode.update({ where: { id: discountCodeId, }, + data: { + discountId: null, + }, }); waitUntil( diff --git a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts index 3d2aea3c33c..fe609d7f87a 100644 --- a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts +++ b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts @@ -1,4 +1,8 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; +import { + queueStripeDiscountCodeDisable, + shouldKeepStripeDiscountCode, +} 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"; @@ -13,7 +17,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"; @@ -227,8 +231,10 @@ export const DELETE = withWorkspace( }, include: { partners: true, + discount: true, }, }), + prisma.partnerGroup.findUniqueOrThrow({ where: { programId_slug: { @@ -236,6 +242,9 @@ export const DELETE = withWorkspace( slug: DEFAULT_PARTNER_GROUP.slug, }, }, + include: { + discount: true, + }, }), ]); @@ -245,6 +254,22 @@ export const DELETE = withWorkspace( message: "You cannot delete the default group of your program.", }); } + + const keepDiscountCodes = shouldKeepStripeDiscountCode({ + groupDiscount: group.discount, + defaultGroupDiscount: defaultGroup.discount, + }); + + // Cache discount codes to delete them later + let discountCodes: DiscountCode[] = []; + if (group.discountId && !keepDiscountCodes) { + discountCodes = await prisma.discountCode.findMany({ + where: { + discountId: group.discountId, + }, + }); + } + await prisma.$transaction(async (tx) => { // 1. Update all partners in the group to the default group await tx.programEnrollment.updateMany({ @@ -290,6 +315,18 @@ export const DELETE = withWorkspace( id: group.id, }, }); + + // 5. Update the discount codes + if (keepDiscountCodes) { + await tx.discountCode.updateMany({ + where: { + discountId: group.discountId, + }, + data: { + discountId: defaultGroup.discountId, + }, + }); + } }); waitUntil( @@ -305,6 +342,10 @@ export const DELETE = withWorkspace( }, }), + ...discountCodes.map((discountCode) => + queueStripeDiscountCodeDisable(discountCode.id), + ), + recordAuditLog({ workspaceId: workspace.id, programId, diff --git a/apps/web/lib/actions/partners/ban-partner.ts b/apps/web/lib/actions/partners/ban-partner.ts index 5de684d2e65..c610a54a73e 100644 --- a/apps/web/lib/actions/partners/ban-partner.ts +++ b/apps/web/lib/actions/partners/ban-partner.ts @@ -104,10 +104,7 @@ export const banPartnerAction = authActionClient linkCache.expireMany(links), ...links.map((link) => - queueStripeDiscountCodeDisable({ - code: link.discountCode?.code, - stripeConnectId: workspace.stripeConnectId, - }), + queueStripeDiscountCodeDisable(link.discountCode?.id), ), partner.email && diff --git a/apps/web/lib/actions/partners/bulk-ban-partners.ts b/apps/web/lib/actions/partners/bulk-ban-partners.ts index dffd5d595e8..0b9facfea9c 100644 --- a/apps/web/lib/actions/partners/bulk-ban-partners.ts +++ b/apps/web/lib/actions/partners/bulk-ban-partners.ts @@ -131,10 +131,7 @@ export const bulkBanPartnersAction = authActionClient // Queue discount code deletions await Promise.allSettled( links.map((link) => - queueStripeDiscountCodeDisable({ - code: link.discountCode?.code, - stripeConnectId: workspace.stripeConnectId, - }), + queueStripeDiscountCodeDisable(link.discountCode?.id), ), ); diff --git a/apps/web/lib/actions/partners/delete-discount.ts b/apps/web/lib/actions/partners/delete-discount.ts index c625c11ad2b..17d082a75c1 100644 --- a/apps/web/lib/actions/partners/delete-discount.ts +++ b/apps/web/lib/actions/partners/delete-discount.ts @@ -1,7 +1,7 @@ "use server"; import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; -import { batchQueueStripeDiscountCodeDisable } from "@/lib/api/discounts/queue-discount-code-deletion"; +import { queueStripeDiscountCodeDisable } 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"; @@ -29,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: { @@ -48,6 +55,16 @@ export const deleteDiscountAction = authActionClient }, }); + await tx.discountCode.updateMany({ + where: { + discountId: discount.id, + }, + data: { + discountId: null, + deletedAt: new Date(), + }, + }); + await tx.discount.delete({ where: { id: discount.id, @@ -66,10 +83,9 @@ export const deleteDiscountAction = authActionClient }, }), - batchQueueStripeDiscountCodeDisable({ - discountId, - stripeConnectId: workspace.stripeConnectId, - }), + ...discountCodes.map((discountCode) => + queueStripeDiscountCodeDisable(discountCode.id), + ), recordAuditLog({ workspaceId: workspace.id, diff --git a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts index 5d64515fdbe..2f7d9547433 100644 --- a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts +++ b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts @@ -1,27 +1,21 @@ import { qstash } from "@/lib/cron"; -import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; +import { Discount } from "@prisma/client"; const queue = qstash.queue({ queueName: "discount-code-deletion", }); -interface Payload { - code: string | null | undefined; - stripeConnectId: string | null | undefined; -} - // Triggered in the following cases: // 1. When a discount is deleted // 2. When coupon tracking is disabled for a discount // 3. When a link is deleted that has a discount code associated with it // 4. When a partner is banned // 5. When a partner is moved to a different group -export async function queueStripeDiscountCodeDisable({ - code, - stripeConnectId, -}: Payload) { - if (!stripeConnectId) { +export async function queueStripeDiscountCodeDisable( + discountCodeId: string | null | undefined, +) { + if (!discountCodeId) { return; } @@ -33,63 +27,67 @@ export async function queueStripeDiscountCodeDisable({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/disable-stripe-code`, method: "POST", body: { - code, - stripeConnectId, + discountCodeId, }, }); } -// For a given discount, we'll delete the discount codes in batches -export async function batchQueueStripeDiscountCodeDisable({ - discountId, - stripeConnectId, -}: { - discountId: string; - stripeConnectId: string | null | undefined; -}) { - if (!stripeConnectId) { - return; - } +// Determine if we should delete the discount code from Stripe +// Used in the following cases: +// 1. When a group is deleted +// 2. When a partner is moved to a different group +// export function shouldDeleteStripeDiscountCode({ +// groupDiscount, +// defaultGroupDiscount, +// }: { +// groupDiscount: Discount | null | undefined; +// defaultGroupDiscount: Discount | null | undefined; +// }): boolean { +// if (!groupDiscount || !defaultGroupDiscount) { +// return true; +// } - let cursor: undefined | string = undefined; +// // If both groups use the same Stripe coupon +// if (groupDiscount.couponId === defaultGroupDiscount.couponId) { +// return false; +// } - while (true) { - const discountCodes = await prisma.discountCode.findMany({ - where: { - discountId, - }, - select: { - id: true, - }, - orderBy: { - createdAt: "asc", - }, - cursor: { - id: cursor, - }, - take: 100, - }); +// // If both discounts are effectively equivalent +// if ( +// groupDiscount.amount === defaultGroupDiscount.amount && +// groupDiscount.type === defaultGroupDiscount.type && +// groupDiscount.maxDuration === defaultGroupDiscount.maxDuration +// ) { +// return false; +// } - // TODO: - // Talk to Upstash to see if there is a way to batch send this +// return true; +// } - if (discountCodes.length === 0) { - break; - } +export function shouldKeepStripeDiscountCode({ + groupDiscount, + defaultGroupDiscount, +}: { + groupDiscount: Discount | null | undefined; + defaultGroupDiscount: Discount | null | undefined; +}): boolean { + if (!groupDiscount || !defaultGroupDiscount) { + return false; + } - await Promise.allSettled( - discountCodes.map(({ code }) => - queue.enqueueJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/disable-stripe-code`, - method: "POST", - body: { - code, - stripeConnectId, - }, - }), - ), - ); + // If both groups use the same Stripe coupon + if (groupDiscount.couponId === defaultGroupDiscount.couponId) { + return true; + } - cursor = discountCodes[discountCodes.length - 1].id; + // If both discounts are effectively equivalent + if ( + groupDiscount.amount === defaultGroupDiscount.amount && + groupDiscount.type === defaultGroupDiscount.type && + groupDiscount.maxDuration === defaultGroupDiscount.maxDuration + ) { + return true; } + + return false; } diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index ac4befaba37..aaeb39bffc6 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -51,11 +51,7 @@ export async function deleteLink(linkId: string) { }, }), - link.discountCode && - queueStripeDiscountCodeDisable({ - code: link.discountCode.code, - stripeConnectId: link.project?.stripeConnectId, - }), + link.discountCode && queueStripeDiscountCodeDisable(link.discountCode.id), ]), ); 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 aa504161e40..31b6fd0eea2 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 @@ -60,7 +60,13 @@ export async function getProgramEnrollmentOrThrow({ discount: true, }), ...(includeDiscountCodes && { - discountCodes: true, + discountCodes: { + where: { + discountId: { + not: null, + }, + }, + }, }), ...(includeGroup && { partnerGroup: true, diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index f3da0481b67..06d025f1028 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -53,7 +53,7 @@ export const discountPartnersQuerySchema = z export const DiscountCodeSchema = z.object({ id: z.string(), code: z.string(), - discountId: z.string(), + discountId: z.string().nullable(), partnerId: z.string(), linkId: z.string(), }); diff --git a/apps/web/scripts/discount.ts b/apps/web/scripts/discount.ts new file mode 100644 index 00000000000..421c8f5c265 --- /dev/null +++ b/apps/web/scripts/discount.ts @@ -0,0 +1,14 @@ +import { prisma } from "@dub/prisma"; +import "dotenv-flow/config"; + +async function main() { + await prisma.$transaction(async (tx) => { + await tx.discount.delete({ + where: { + id: "disc_1K5ZTBDHE4B18SZFZSRPPMNYF", + }, + }); + }); +} + +main(); diff --git a/packages/prisma/schema/discount.prisma b/packages/prisma/schema/discount.prisma index d998c2cb621..28a7b7a4038 100644 --- a/packages/prisma/schema/discount.prisma +++ b/packages/prisma/schema/discount.prisma @@ -22,14 +22,14 @@ model DiscountCode { id String @id @default(cuid()) code String programId String - discountId String + discountId String? partnerId String linkId String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt program Program @relation(fields: [programId], references: [id]) - discount Discount @relation(fields: [discountId], references: [id]) + discount Discount? @relation(fields: [discountId], references: [id], onDelete: SetNull) partner Partner @relation(fields: [partnerId], references: [id]) link Link @relation(fields: [linkId], references: [id]) programEnrollment ProgramEnrollment? @relation(fields: [programId, partnerId], references: [programId, partnerId]) From 26959fce24815cbe98037ad5329de5db2925db69 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 17:47:53 +0530 Subject: [PATCH 168/221] Refactor discount code handling to use queueDiscountCodeDeletion across multiple actions --- .../discount-codes/[discountCodeId]/route.ts | 7 ++----- .../(ee)/api/groups/[groupIdOrSlug]/route.ts | 4 ++-- apps/web/lib/actions/partners/ban-partner.ts | 19 +++++++++++++++---- .../lib/actions/partners/bulk-ban-partners.ts | 4 ++-- .../lib/actions/partners/delete-discount.ts | 5 ++--- .../discounts/queue-discount-code-deletion.ts | 9 ++++----- apps/web/lib/api/links/delete-link.ts | 4 ++-- .../stripe/disable-stripe-discount-code.ts | 1 + 8 files changed, 30 insertions(+), 23 deletions(-) diff --git a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts index 5e7971c1639..e559c6857a1 100644 --- a/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts @@ -1,8 +1,8 @@ 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 { disableStripeDiscountCode } from "@/lib/stripe/disable-stripe-discount-code"; import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -59,10 +59,7 @@ export const DELETE = withWorkspace( ], }), - disableStripeDiscountCode({ - stripeConnectId: workspace.stripeConnectId, - code: discountCode.code, - }), + queueDiscountCodeDeletion(discountCode.id), ]), ); diff --git a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts index fe609d7f87a..6ee7fc3d5bd 100644 --- a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts +++ b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts @@ -1,6 +1,6 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { - queueStripeDiscountCodeDisable, + queueDiscountCodeDeletion, shouldKeepStripeDiscountCode, } from "@/lib/api/discounts/queue-discount-code-deletion"; import { DubApiError } from "@/lib/api/errors"; @@ -343,7 +343,7 @@ export const DELETE = withWorkspace( }), ...discountCodes.map((discountCode) => - queueStripeDiscountCodeDisable(discountCode.id), + queueDiscountCodeDeletion(discountCode.id), ), recordAuditLog({ diff --git a/apps/web/lib/actions/partners/ban-partner.ts b/apps/web/lib/actions/partners/ban-partner.ts index c610a54a73e..9fe1c944b7b 100644 --- a/apps/web/lib/actions/partners/ban-partner.ts +++ b/apps/web/lib/actions/partners/ban-partner.ts @@ -1,7 +1,7 @@ "use server"; import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; -import { queueStripeDiscountCodeDisable } from "@/lib/api/discounts/queue-discount-code-deletion"; +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"; @@ -30,6 +30,7 @@ export const banPartnerAction = authActionClient partnerId, programId, includePartner: true, + includeDiscountCodes: true, }); if (programEnrollment.status === "banned") { @@ -81,6 +82,16 @@ export const banPartnerAction = authActionClient status: "canceled", }, }), + + prisma.discountCode.updateMany({ + where: { + programId, + partnerId, + }, + data: { + discountId: null, + }, + }), ]); waitUntil( @@ -98,13 +109,13 @@ export const banPartnerAction = authActionClient }, }); - const { program, partner } = programEnrollment; + const { program, partner, discountCodes } = programEnrollment; await Promise.allSettled([ linkCache.expireMany(links), - ...links.map((link) => - queueStripeDiscountCodeDisable(link.discountCode?.id), + ...discountCodes.map((discountCode) => + queueDiscountCodeDeletion(discountCode.id), ), partner.email && diff --git a/apps/web/lib/actions/partners/bulk-ban-partners.ts b/apps/web/lib/actions/partners/bulk-ban-partners.ts index 0b9facfea9c..140d452fd15 100644 --- a/apps/web/lib/actions/partners/bulk-ban-partners.ts +++ b/apps/web/lib/actions/partners/bulk-ban-partners.ts @@ -1,7 +1,7 @@ "use server"; import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; -import { queueStripeDiscountCodeDisable } from "@/lib/api/discounts/queue-discount-code-deletion"; +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"; @@ -131,7 +131,7 @@ export const bulkBanPartnersAction = authActionClient // Queue discount code deletions await Promise.allSettled( links.map((link) => - queueStripeDiscountCodeDisable(link.discountCode?.id), + queueDiscountCodeDeletion(link.discountCode?.id), ), ); diff --git a/apps/web/lib/actions/partners/delete-discount.ts b/apps/web/lib/actions/partners/delete-discount.ts index 17d082a75c1..ec8cda97753 100644 --- a/apps/web/lib/actions/partners/delete-discount.ts +++ b/apps/web/lib/actions/partners/delete-discount.ts @@ -1,7 +1,7 @@ "use server"; import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; -import { queueStripeDiscountCodeDisable } from "@/lib/api/discounts/queue-discount-code-deletion"; +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"; @@ -61,7 +61,6 @@ export const deleteDiscountAction = authActionClient }, data: { discountId: null, - deletedAt: new Date(), }, }); @@ -84,7 +83,7 @@ export const deleteDiscountAction = authActionClient }), ...discountCodes.map((discountCode) => - queueStripeDiscountCodeDisable(discountCode.id), + queueDiscountCodeDeletion(discountCode.id), ), recordAuditLog({ diff --git a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts index 2f7d9547433..8e64b371cce 100644 --- a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts +++ b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts @@ -8,11 +8,10 @@ const queue = qstash.queue({ // Triggered in the following cases: // 1. When a discount is deleted -// 2. When coupon tracking is disabled for a discount -// 3. When a link is deleted that has a discount code associated with it -// 4. When a partner is banned -// 5. When a partner is moved to a different group -export async function queueStripeDiscountCodeDisable( +// 2. When a link is deleted that has a discount code associated with it +// 3. When a partner is banned +// 4. When a partner is moved to a different group +export async function queueDiscountCodeDeletion( discountCodeId: string | null | undefined, ) { if (!discountCodeId) { diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index aaeb39bffc6..1f090e827b8 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -3,7 +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 { queueStripeDiscountCodeDisable } from "../discounts/queue-discount-code-deletion"; +import { queueDiscountCodeDeletion } from "../discounts/queue-discount-code-deletion"; import { linkCache } from "./cache"; import { includeTags } from "./include-tags"; import { transformLink } from "./utils"; @@ -51,7 +51,7 @@ export async function deleteLink(linkId: string) { }, }), - link.discountCode && queueStripeDiscountCodeDisable(link.discountCode.id), + link.discountCode && queueDiscountCodeDeletion(link.discountCode.id), ]), ); diff --git a/apps/web/lib/stripe/disable-stripe-discount-code.ts b/apps/web/lib/stripe/disable-stripe-discount-code.ts index 77cb27f56c8..5080bc84f09 100644 --- a/apps/web/lib/stripe/disable-stripe-discount-code.ts +++ b/apps/web/lib/stripe/disable-stripe-discount-code.ts @@ -4,6 +4,7 @@ const stripe = stripeAppClient({ ...(process.env.VERCEL_ENV && { livemode: true }), }); + export async function disableStripeDiscountCode({ stripeConnectId, code, From 961196b0547ee1b438c5111d4da42b073062cec7 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 18:29:16 +0530 Subject: [PATCH 169/221] Add API endpoint to delete discount codes and refactor related logic --- .../route.ts | 2 +- .../(ee)/api/groups/[groupIdOrSlug]/route.ts | 104 +++++++++--------- .../trigger-draft-bounty-submissions.ts | 3 - .../discounts/queue-discount-code-deletion.ts | 38 +------ 4 files changed, 58 insertions(+), 89 deletions(-) rename apps/web/app/(ee)/api/cron/discounts/{disable-stripe-code => delete-discount-code}/route.ts (97%) diff --git a/apps/web/app/(ee)/api/cron/discounts/disable-stripe-code/route.ts b/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts similarity index 97% rename from apps/web/app/(ee)/api/cron/discounts/disable-stripe-code/route.ts rename to apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts index 76fc9503c3e..fe827976e88 100644 --- a/apps/web/app/(ee)/api/cron/discounts/disable-stripe-code/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts @@ -11,7 +11,7 @@ const schema = z.object({ discountCodeId: z.string(), }); -// POST /api/cron/discounts/disable-stripe-code +// POST /api/cron/discounts/delete-discount-code export async function POST(req: Request) { try { const rawBody = await req.text(); diff --git a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts index 6ee7fc3d5bd..abcbac61fb6 100644 --- a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts +++ b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts @@ -1,7 +1,7 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { queueDiscountCodeDeletion, - shouldKeepStripeDiscountCode, + shouldKeepDiscountCodes, } from "@/lib/api/discounts/queue-discount-code-deletion"; import { DubApiError } from "@/lib/api/errors"; import { getGroupOrThrow } from "@/lib/api/groups/get-group-or-throw"; @@ -255,22 +255,24 @@ export const DELETE = withWorkspace( }); } - const keepDiscountCodes = shouldKeepStripeDiscountCode({ + const keepDiscountCodes = shouldKeepDiscountCodes({ groupDiscount: group.discount, defaultGroupDiscount: defaultGroup.discount, }); // Cache discount codes to delete them later - let discountCodes: DiscountCode[] = []; + let discountCodesToDelete: DiscountCode[] = []; if (group.discountId && !keepDiscountCodes) { - discountCodes = await prisma.discountCode.findMany({ + discountCodesToDelete = await prisma.discountCode.findMany({ where: { discountId: group.discountId, }, }); } - await prisma.$transaction(async (tx) => { + console.log({ discountCodesToDelete, keepDiscountCodes }); + + const deletedGroup = await prisma.$transaction(async (tx) => { // 1. Update all partners in the group to the default group await tx.programEnrollment.updateMany({ where: { @@ -300,8 +302,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, @@ -309,59 +321,51 @@ export const DELETE = withWorkspace( }); } - // 4. Delete the group + // 5. Delete the group await tx.partnerGroup.delete({ where: { id: group.id, }, }); - // 5. Update the discount codes - if (keepDiscountCodes) { - await tx.discountCode.updateMany({ - where: { - discountId: group.discountId, - }, - data: { - discountId: defaultGroup.discountId, - }, - }); - } + return true; }); - waitUntil( - Promise.allSettled([ - qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-default-links`, - body: { - programId, - groupId: defaultGroup.id, - partnerIds: group.partners.map(({ partnerId }) => partnerId), - userId: session.user.id, - isGroupDeleted: true, - }, - }), - - ...discountCodes.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, + if (deletedGroup) { + waitUntil( + Promise.allSettled([ + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-default-links`, + body: { + programId, + groupId: defaultGroup.id, + partnerIds: group.partners.map(({ partnerId }) => partnerId), + userId: session.user.id, + isGroupDeleted: true, }, - ], - }), - ]), - ); + }), + + ...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/lib/api/bounties/trigger-draft-bounty-submissions.ts b/apps/web/lib/api/bounties/trigger-draft-bounty-submissions.ts index d3e420b7f46..97c6db94dd9 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/discounts/queue-discount-code-deletion.ts b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts index 8e64b371cce..0b6e1dd1913 100644 --- a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts +++ b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts @@ -23,7 +23,7 @@ export async function queueDiscountCodeDeletion( }); await queue.enqueueJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/disable-stripe-code`, + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discounts/delete-discount-code`, method: "POST", body: { discountCodeId, @@ -31,46 +31,14 @@ export async function queueDiscountCodeDeletion( }); } -// Determine if we should delete the discount code from Stripe -// Used in the following cases: -// 1. When a group is deleted -// 2. When a partner is moved to a different group -// export function shouldDeleteStripeDiscountCode({ -// groupDiscount, -// defaultGroupDiscount, -// }: { -// groupDiscount: Discount | null | undefined; -// defaultGroupDiscount: Discount | null | undefined; -// }): boolean { -// if (!groupDiscount || !defaultGroupDiscount) { -// return true; -// } - -// // If both groups use the same Stripe coupon -// if (groupDiscount.couponId === defaultGroupDiscount.couponId) { -// return false; -// } - -// // If both discounts are effectively equivalent -// if ( -// groupDiscount.amount === defaultGroupDiscount.amount && -// groupDiscount.type === defaultGroupDiscount.type && -// groupDiscount.maxDuration === defaultGroupDiscount.maxDuration -// ) { -// return false; -// } - -// return true; -// } - -export function shouldKeepStripeDiscountCode({ +export function shouldKeepDiscountCodes({ groupDiscount, defaultGroupDiscount, }: { groupDiscount: Discount | null | undefined; defaultGroupDiscount: Discount | null | undefined; }): boolean { - if (!groupDiscount || !defaultGroupDiscount) { + if (!defaultGroupDiscount || !groupDiscount) { return false; } From 1c24905f7616fb6465f0e6c1983ecb2d618af343 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 19:58:25 +0530 Subject: [PATCH 170/221] Update ban-partner.ts --- apps/web/lib/actions/partners/ban-partner.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/web/lib/actions/partners/ban-partner.ts b/apps/web/lib/actions/partners/ban-partner.ts index 9fe1c944b7b..cfd0b51a018 100644 --- a/apps/web/lib/actions/partners/ban-partner.ts +++ b/apps/web/lib/actions/partners/ban-partner.ts @@ -84,10 +84,7 @@ export const banPartnerAction = authActionClient }), prisma.discountCode.updateMany({ - where: { - programId, - partnerId, - }, + where, data: { discountId: null, }, From 77f267d15f9be4150ecc8f7c6be2f01c37179842 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 19:59:45 +0530 Subject: [PATCH 171/221] Update route.ts --- apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts index abcbac61fb6..955a3f6f6f0 100644 --- a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts +++ b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts @@ -270,8 +270,6 @@ export const DELETE = withWorkspace( }); } - console.log({ discountCodesToDelete, keepDiscountCodes }); - const deletedGroup = await prisma.$transaction(async (tx) => { // 1. Update all partners in the group to the default group await tx.programEnrollment.updateMany({ From c0ae40e5bf8f259ed5dc5dd81cb6cd1326ea3411 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 20:03:54 +0530 Subject: [PATCH 172/221] Update bulk-ban-partners.ts --- apps/web/lib/actions/partners/bulk-ban-partners.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/actions/partners/bulk-ban-partners.ts b/apps/web/lib/actions/partners/bulk-ban-partners.ts index 140d452fd15..e40df5385e5 100644 --- a/apps/web/lib/actions/partners/bulk-ban-partners.ts +++ b/apps/web/lib/actions/partners/bulk-ban-partners.ts @@ -109,6 +109,15 @@ export const bulkBanPartnersAction = authActionClient status: "canceled", }, }), + + prisma.discountCode.updateMany({ + where: { + ...commonWhere, + }, + data: { + discountId: null, + }, + }), ]); waitUntil( @@ -130,9 +139,7 @@ export const bulkBanPartnersAction = authActionClient // Queue discount code deletions await Promise.allSettled( - links.map((link) => - queueDiscountCodeDeletion(link.discountCode?.id), - ), + links.map((link) => queueDiscountCodeDeletion(link.discountCode?.id)), ); // Record audit log for each partner From 3e90815b391bc05894a75ad3b1514b2ba6a3f156 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 20:03:56 +0530 Subject: [PATCH 173/221] Update queue-discount-code-deletion.ts --- apps/web/lib/api/discounts/queue-discount-code-deletion.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts index 0b6e1dd1913..84f862cabd6 100644 --- a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts +++ b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts @@ -9,7 +9,7 @@ const queue = qstash.queue({ // 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 a partner is banned +// 3. When partners are banned // 4. When a partner is moved to a different group export async function queueDiscountCodeDeletion( discountCodeId: string | null | undefined, From db21f37d5db37ac70b5e3e2692abb2a8b46e307f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 20:07:02 +0530 Subject: [PATCH 174/221] Update update-discount.ts --- apps/web/lib/actions/partners/update-discount.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index f8eed0ef88d..40221274228 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -39,12 +39,12 @@ export const updateDiscountAction = authActionClient }, }); - const couponTestIdChanged = + const shouldExpireCache = discount.couponTestId !== updatedDiscount.couponTestId; waitUntil( Promise.allSettled([ - couponTestIdChanged && + shouldExpireCache && partnerGroup && qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, From a52b099ba760ba134b76cbb95425bc348f7bd2dc Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 20:08:23 +0530 Subject: [PATCH 175/221] Update delete-link.ts --- apps/web/lib/api/links/delete-link.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/web/lib/api/links/delete-link.ts b/apps/web/lib/api/links/delete-link.ts index 1f090e827b8..6c255b50a1c 100644 --- a/apps/web/lib/api/links/delete-link.ts +++ b/apps/web/lib/api/links/delete-link.ts @@ -15,12 +15,6 @@ export async function deleteLink(linkId: string) { }, include: { ...includeTags, - project: { - select: { - id: true, - stripeConnectId: true, - }, - }, discountCode: true, }, }); From a16a10fc90d185642fd349265558100dd197f85f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 20:08:25 +0530 Subject: [PATCH 176/221] Update get-program-enrollment-or-throw.ts --- apps/web/lib/api/programs/get-program-enrollment-or-throw.ts | 1 + 1 file changed, 1 insertion(+) 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 31b6fd0eea2..c449fd59f9c 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 @@ -62,6 +62,7 @@ export async function getProgramEnrollmentOrThrow({ ...(includeDiscountCodes && { discountCodes: { where: { + // Omit soft deleted discount codes discountId: { not: null, }, From 0f45b51cd31d8990d872229aed51476d26661da6 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 20:09:03 +0530 Subject: [PATCH 177/221] Delete discount.ts --- apps/web/scripts/discount.ts | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 apps/web/scripts/discount.ts diff --git a/apps/web/scripts/discount.ts b/apps/web/scripts/discount.ts deleted file mode 100644 index 421c8f5c265..00000000000 --- a/apps/web/scripts/discount.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { prisma } from "@dub/prisma"; -import "dotenv-flow/config"; - -async function main() { - await prisma.$transaction(async (tx) => { - await tx.discount.delete({ - where: { - id: "disc_1K5ZTBDHE4B18SZFZSRPPMNYF", - }, - }); - }); -} - -main(); From d1d21c722ae17905ecf1dd820529778ac1e98607 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 20:09:19 +0530 Subject: [PATCH 178/221] Delete workflow.ts --- apps/web/scripts/workflow.ts | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 apps/web/scripts/workflow.ts diff --git a/apps/web/scripts/workflow.ts b/apps/web/scripts/workflow.ts deleted file mode 100644 index 1cd5290da36..00000000000 --- a/apps/web/scripts/workflow.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Client } from "@upstash/workflow"; -import "dotenv-flow/config"; - -const client = new Client({ token: process.env.QSTASH_TOKEN }); - -async function main() { - const response = await client.trigger({ - url: "https://accurate-caribou-strictly.ngrok-free.app/api/workflows/partner-approved", - }); - - console.log(response); -} - -main(); From 0a15fc4460fd0e5368912f0382e10a41c78071fa Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 21:10:59 +0530 Subject: [PATCH 179/221] Update page-client.tsx --- .../[partnerId]/links/page-client.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx index f68d573a4cb..be21bac4c81 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx @@ -22,7 +22,7 @@ import { cn, currencyFormatter, getPrettyUrl, nFormatter } from "@dub/utils"; import { Command } from "cmdk"; import Link from "next/link"; import { useParams } from "next/navigation"; -import { useState } from "react"; +import { useMemo, useState } from "react"; export function ProgramPartnerLinksPageClient() { const { partnerId } = useParams() as { partnerId: string }; @@ -169,6 +169,7 @@ const PartnerDiscountCodes = ({ partner: EnrolledPartnerProps; }) => { const { slug } = useWorkspace(); + const { discountCodes, loading, error } = useDiscountCodes({ partnerId: partner.id || null, }); @@ -242,6 +243,22 @@ const PartnerDiscountCodes = ({ error: error ? "Failed to load discount codes" : undefined, } as any); + const disabledReason = useMemo(() => { + if (!partner.discountId) { + return "No discount assigned to this partner group. Please add a discount before you can create a discount code."; + } + + if (partner.links?.length === 0) { + return "No links assigned to this partner group. Please add a link before you can create a discount code."; + } + + if (partner.links?.length === discountCodes?.length) { + return "All links have a discount code assigned to them. Please add a new link before you can create a discount code."; + } + + return undefined; + }, [partner.discountId, partner.links, discountCodes]); + return ( <>
@@ -253,12 +270,8 @@ const PartnerDiscountCodes = ({ text="Create code" className="h-8 w-fit rounded-lg px-3 py-2 font-medium" onClick={() => setShowAddDiscountCodeModal(true)} - disabled={!partner.discountId} - disabledTooltip={ - !partner.discountId - ? "No discount assigned to this partner group. Please add a discount before you can create a discount code." - : undefined - } + disabled={!!disabledReason} + disabledTooltip={disabledReason} />
From fc7d6785b17ddf543bc097d7403ef4f660427d2c Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 21:11:01 +0530 Subject: [PATCH 180/221] Update use-api-mutation.ts --- apps/web/lib/swr/use-api-mutation.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/web/lib/swr/use-api-mutation.ts b/apps/web/lib/swr/use-api-mutation.ts index 70d94e65f5c..dd19eb41df6 100644 --- a/apps/web/lib/swr/use-api-mutation.ts +++ b/apps/web/lib/swr/use-api-mutation.ts @@ -2,22 +2,20 @@ 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; } @@ -43,7 +41,10 @@ export function useApiMutation< const [isSubmitting, setIsSubmitting] = useState(false); const makeRequest = useCallback( - async (endpoint: string, options: ApiRequestOptions = {}) => { + async ( + endpoint: string, + options: ApiRequestOptions = {}, + ) => { const { method = "GET", body, @@ -89,7 +90,7 @@ export function useApiMutation< // Handle success const data = (await response.json()) as TResponse; setData(data); - onSuccess?.(); + onSuccess?.(data); debug("Response received", data); } catch (error) { @@ -99,7 +100,7 @@ export function useApiMutation< : "Something went wrong. Please try again."; setError(errorMessage); - onError?.(); + onError?.(errorMessage); if (showToast) { toast.error(errorMessage); @@ -115,8 +116,6 @@ export function useApiMutation< ); return { - data, - error, isSubmitting, makeRequest, }; From b2bdc6be70f71a3195e7b6b29b7cf73801848b1b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 21:11:03 +0530 Subject: [PATCH 181/221] Update add-discount-code-modal.tsx --- apps/web/ui/partners/add-discount-code-modal.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/ui/partners/add-discount-code-modal.tsx b/apps/web/ui/partners/add-discount-code-modal.tsx index b75be7e5cfb..caaa6ee7350 100644 --- a/apps/web/ui/partners/add-discount-code-modal.tsx +++ b/apps/web/ui/partners/add-discount-code-modal.tsx @@ -1,6 +1,6 @@ import { mutatePrefix } from "@/lib/swr/mutate"; import { useApiMutation } from "@/lib/swr/use-api-mutation"; -import { EnrolledPartnerProps } from "@/lib/types"; +import { DiscountCodeProps, EnrolledPartnerProps } from "@/lib/types"; import { createDiscountCodeSchema } from "@/lib/zod/schemas/discount"; import { ArrowTurnLeft, @@ -40,7 +40,8 @@ const AddDiscountCodeModal = ({ const formRef = useRef(null); const [debouncedSearch] = useDebounce(search, 500); const [, copyToClipboard] = useCopyToClipboard(); - const { makeRequest: createDiscountCode, isSubmitting } = useApiMutation(); + const { makeRequest: createDiscountCode, isSubmitting } = + useApiMutation(); const { register, handleSubmit, setValue, watch } = useForm({ defaultValues: { @@ -80,10 +81,11 @@ const AddDiscountCodeModal = ({ ...formData, partnerId: partner.id, }, - onSuccess: async () => { + onSuccess: async (data) => { setShowModal(false); - toast.success("Discount code created successfully."); await mutatePrefix("/api/discount-codes"); + copyToClipboard(data.code); + toast.success("Discount code created and copied to clipboard!"); }, }); }; From 3d705bb29507f5b483ef376a45cafd577eecfeaa Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 21:19:06 +0530 Subject: [PATCH 182/221] fix coupon creation modal --- .../discounts/add-edit-discount-sheet.tsx | 176 +++++++++--------- 1 file changed, 86 insertions(+), 90 deletions(-) diff --git a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx index aa52cdacc82..57eab8adf09 100644 --- a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx +++ b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx @@ -356,96 +356,92 @@ function DiscountSheetContent({ } /> - {(!useExistingCoupon || discount) && ( - <> - - - - - - 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={<>} - /> - - )} + + + + + + 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={<>} + /> From 638caced6af6daa792d083a5d8db694d70908079 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 21:26:39 +0530 Subject: [PATCH 183/221] Update add-edit-discount-sheet.tsx --- .../discounts/add-edit-discount-sheet.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx index 57eab8adf09..4bdfd65f5d1 100644 --- a/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx +++ b/apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx @@ -359,6 +359,9 @@ function DiscountSheetContent({ @@ -489,9 +492,19 @@ function DiscountSheetContent({ function DiscountSheetCard({ title, content, -}: PropsWithChildren<{ title: ReactNode; content: ReactNode }>) { + className, +}: PropsWithChildren<{ + title: ReactNode; + content: ReactNode; + className?: string; +}>) { return ( -
+
{title}
From ee5a7c391477f78437b0bc81c45ede330fb7e6fe Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 22:43:07 +0530 Subject: [PATCH 184/221] add /api/cron/discounts/remap-discount-codes --- .../discounts/delete-discount-code/route.ts | 6 +- .../discounts/remap-discount-codes/route.ts | 119 ++++++++++++++++++ .../groups/[groupIdOrSlug]/partners/route.ts | 9 ++ .../(ee)/api/groups/[groupIdOrSlug]/route.ts | 10 +- .../discounts/queue-discount-code-deletion.ts | 21 ++-- 5 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 apps/web/app/(ee)/api/cron/discounts/remap-discount-codes/route.ts diff --git a/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts b/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts index fe827976e88..d2c2c2915f7 100644 --- a/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts @@ -15,7 +15,11 @@ const schema = z.object({ export async function POST(req: Request) { try { const rawBody = await req.text(); - await verifyQstashSignature({ req, rawBody }); + + await verifyQstashSignature({ + req, + rawBody, + }); const { discountCodeId } = schema.parse(JSON.parse(rawBody)); diff --git a/apps/web/app/(ee)/api/cron/discounts/remap-discount-codes/route.ts b/apps/web/app/(ee)/api/cron/discounts/remap-discount-codes/route.ts new file mode 100644 index 00000000000..05cfce144ad --- /dev/null +++ b/apps/web/app/(ee)/api/cron/discounts/remap-discount-codes/route.ts @@ -0,0 +1,119 @@ +import { + isDiscountEquivalent, + 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/discounts/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); + } + } + + console.log({ discountCodesToUpdate, discountCodesToRemove }); + + if (discountCodesToUpdate.length > 0) { + await prisma.discountCode.updateMany({ + where: { + id: { + in: discountCodesToUpdate, + }, + }, + data: { + discountId: group.discount?.id, + }, + }); + } + + if (discountCodesToRemove.length > 0) { + await Promise.allSettled( + discountCodesToRemove.map((discountCodeId) => + queueDiscountCodeDeletion(discountCodeId), + ), + ); + } + + 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/groups/[groupIdOrSlug]/partners/route.ts b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/partners/route.ts index b638dbdd235..13b7f5db56d 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/discounts/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 955a3f6f6f0..f2f5f66b133 100644 --- a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts +++ b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts @@ -1,7 +1,7 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { queueDiscountCodeDeletion, - shouldKeepDiscountCodes, + isDiscountEquivalent, } from "@/lib/api/discounts/queue-discount-code-deletion"; import { DubApiError } from "@/lib/api/errors"; import { getGroupOrThrow } from "@/lib/api/groups/get-group-or-throw"; @@ -255,10 +255,10 @@ export const DELETE = withWorkspace( }); } - const keepDiscountCodes = shouldKeepDiscountCodes({ - groupDiscount: group.discount, - defaultGroupDiscount: defaultGroup.discount, - }); + const keepDiscountCodes = isDiscountEquivalent( + group.discount, + defaultGroup.discount, + ); // Cache discount codes to delete them later let discountCodesToDelete: DiscountCode[] = []; diff --git a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts index 84f862cabd6..e805557f3d9 100644 --- a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts +++ b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts @@ -31,27 +31,24 @@ export async function queueDiscountCodeDeletion( }); } -export function shouldKeepDiscountCodes({ - groupDiscount, - defaultGroupDiscount, -}: { - groupDiscount: Discount | null | undefined; - defaultGroupDiscount: Discount | null | undefined; -}): boolean { - if (!defaultGroupDiscount || !groupDiscount) { +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 (groupDiscount.couponId === defaultGroupDiscount.couponId) { + if (firstDiscount.couponId === secondDiscount.couponId) { return true; } // If both discounts are effectively equivalent if ( - groupDiscount.amount === defaultGroupDiscount.amount && - groupDiscount.type === defaultGroupDiscount.type && - groupDiscount.maxDuration === defaultGroupDiscount.maxDuration + firstDiscount.amount === secondDiscount.amount && + firstDiscount.type === secondDiscount.type && + firstDiscount.maxDuration === secondDiscount.maxDuration ) { return true; } From 81d5c9837c494fd33a2aec84e69c98837fb8e8c8 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 23:04:42 +0530 Subject: [PATCH 185/221] Refactor discount code deletion logic --- .../app/(ee)/api/cron/discounts/delete-discount-code/route.ts | 4 ---- .../app/(ee)/api/cron/discounts/remap-discount-codes/route.ts | 2 ++ 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts b/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts index d2c2c2915f7..b75927607b2 100644 --- a/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts @@ -33,10 +33,6 @@ export async function POST(req: Request) { return logAndRespond(`Discount code ${discountCodeId} not found.`); } - if (discountCode.discountId) { - return logAndRespond(`Discount code ${discountCodeId} is not deleted.`); - } - const workspace = await prisma.project.findUniqueOrThrow({ where: { defaultProgramId: discountCode.programId, diff --git a/apps/web/app/(ee)/api/cron/discounts/remap-discount-codes/route.ts b/apps/web/app/(ee)/api/cron/discounts/remap-discount-codes/route.ts index 05cfce144ad..82a1f78b86d 100644 --- a/apps/web/app/(ee)/api/cron/discounts/remap-discount-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/discounts/remap-discount-codes/route.ts @@ -89,6 +89,7 @@ export async function POST(req: Request) { console.log({ discountCodesToUpdate, discountCodesToRemove }); + // Update the discount codes to use the new discount if (discountCodesToUpdate.length > 0) { await prisma.discountCode.updateMany({ where: { @@ -102,6 +103,7 @@ export async function POST(req: Request) { }); } + // Remove the discount codes from the group if (discountCodesToRemove.length > 0) { await Promise.allSettled( discountCodesToRemove.map((discountCodeId) => From 0e9894d356d6344ed5101d9924463ad2fb7caeb6 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 25 Sep 2025 23:36:05 +0530 Subject: [PATCH 186/221] format --- apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts | 2 +- apps/web/lib/integrations/hubspot/ui/settings.tsx | 3 ++- apps/web/lib/stripe/disable-stripe-discount-code.ts | 1 - apps/web/lib/types.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts index f2f5f66b133..97290dc985d 100644 --- a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts +++ b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts @@ -1,7 +1,7 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { - queueDiscountCodeDeletion, isDiscountEquivalent, + 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"; diff --git a/apps/web/lib/integrations/hubspot/ui/settings.tsx b/apps/web/lib/integrations/hubspot/ui/settings.tsx index 255ae186d8f..0f2932f5e93 100644 --- a/apps/web/lib/integrations/hubspot/ui/settings.tsx +++ b/apps/web/lib/integrations/hubspot/ui/settings.tsx @@ -15,7 +15,8 @@ export const HubSpotSettings = ({ }: InstalledIntegrationInfoProps) => { const { id: workspaceId } = useWorkspace(); const [closedWonDealStageId, setClosedWonDealStageId] = useState( - (settings as any)?.closedWonDealStageId || HUBSPOT_DEFAULT_CLOSED_WON_DEAL_STAGE_ID, + (settings as any)?.closedWonDealStageId || + HUBSPOT_DEFAULT_CLOSED_WON_DEAL_STAGE_ID, ); const { executeAsync, isPending } = useAction(updateHubSpotSettingsAction, { diff --git a/apps/web/lib/stripe/disable-stripe-discount-code.ts b/apps/web/lib/stripe/disable-stripe-discount-code.ts index 5080bc84f09..77cb27f56c8 100644 --- a/apps/web/lib/stripe/disable-stripe-discount-code.ts +++ b/apps/web/lib/stripe/disable-stripe-discount-code.ts @@ -4,7 +4,6 @@ const stripe = stripeAppClient({ ...(process.env.VERCEL_ENV && { livemode: true }), }); - export async function disableStripeDiscountCode({ stripeConnectId, code, diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index ac2fdd099fb..eb223f80b29 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, DiscountCodeSchema } from "./zod/schemas/discount"; +import { DiscountCodeSchema, DiscountSchema } from "./zod/schemas/discount"; import { FolderSchema } from "./zod/schemas/folders"; import { additionalPartnerLinkSchema, From c3f85e260e45aac242c823596414bb5e63331458 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 26 Sep 2025 09:22:13 +0530 Subject: [PATCH 187/221] update the discount table UI --- .../[partnerId]/links/page-client.tsx | 89 ++++++------------- 1 file changed, 29 insertions(+), 60 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx index be21bac4c81..8309cff2b11 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx @@ -11,18 +11,15 @@ import { Button, CopyButton, LoadingSpinner, - MenuItem, - Popover, Table, Tag, useTable, } from "@dub/ui"; -import { Dots, Trash } from "@dub/ui/icons"; +import { Trash } from "@dub/ui/icons"; import { cn, currencyFormatter, getPrettyUrl, nFormatter } from "@dub/utils"; -import { Command } from "cmdk"; import Link from "next/link"; import { useParams } from "next/navigation"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; export function ProgramPartnerLinksPageClient() { const { partnerId } = useParams() as { partnerId: string }; @@ -182,30 +179,9 @@ const PartnerDiscountCodes = ({ const table = useTable({ data: discountCodes || [], columns: [ - { - 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: "code", - header: "Discount code", + header: "Code", cell: ({ row }) => (
@@ -222,6 +198,24 @@ const PartnerDiscountCodes = ({
), }, + { + 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, @@ -229,7 +223,7 @@ const PartnerDiscountCodes = ({ size: 25, maxSize: 25, cell: ({ row }) => ( - + ), }, ], @@ -282,47 +276,22 @@ const PartnerDiscountCodes = ({ ); }; -function DiscountCodeRowMenuButton({ +function DiscountCodeDeleteButton({ discountCode, }: { discountCode: DiscountCodeProps; }) { - const [isOpen, setIsOpen] = useState(false); - const { setShowDeleteDiscountCodeModal, DeleteDiscountCodeModal } = useDeleteDiscountCodeModal(discountCode); return ( <> - - - { - setShowDeleteDiscountCodeModal(true); - setIsOpen(false); - }} - > - Delete code - - - - } - align="end" - > -
- - - ); -}; -function DiscountCodeDeleteButton({ - discountCode, -}: { - discountCode: DiscountCodeProps; -}) { - const { setShowDeleteDiscountCodeModal, DeleteDiscountCodeModal } = - useDeleteDiscountCodeModal(discountCode); + - return ( - <> -
+ {!loading && + !error && + (!discountCodes || discountCodes.length === 0) ? ( +
+ +

+ No codes created +

+

+ Create a discount code for each link +

+
+ ) : ( +
+ )} From 1859ed60490a1725df9a44e103496290f7c1c7f9 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 26 Sep 2025 09:41:25 +0530 Subject: [PATCH 190/221] add a loading state --- .../partners/[partnerId]/links/page-client.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx index 9b7a73fc5d6..a6ba3d8a44a 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx @@ -284,9 +284,11 @@ const PartnerDiscountCodes = ({
- {!loading && - !error && - (!discountCodes || discountCodes.length === 0) ? ( + {loading ? ( +
+ +
+ ) : !error && (!discountCodes || discountCodes.length === 0) ? (

@@ -296,6 +298,12 @@ const PartnerDiscountCodes = ({ Create a discount code for each link

+ ) : error ? ( +
+ + Failed to load discount codes + +
) : (
)} From db03a5b74113b1a416d5eced61c7056b710fd4e8 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 26 Sep 2025 10:33:23 +0530 Subject: [PATCH 191/221] add UpgradeRequiredToast --- apps/web/app/(ee)/api/discount-codes/route.ts | 2 +- .../[partnerId]/links/page-client.tsx | 16 ++++++++++++-- apps/web/lib/swr/use-api-mutation.ts | 22 ++++--------------- .../ui/partners/add-discount-code-modal.tsx | 18 +++++++++++++++ 4 files changed, 37 insertions(+), 21 deletions(-) diff --git a/apps/web/app/(ee)/api/discount-codes/route.ts b/apps/web/app/(ee)/api/discount-codes/route.ts index cecdb3971b9..a6b6a786ed5 100644 --- a/apps/web/app/(ee)/api/discount-codes/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/route.ts @@ -58,7 +58,7 @@ export const POST = withWorkspace( throw new DubApiError({ code: "bad_request", message: - "Stripe connect ID not found for your workspace. Please install Dub Stripe app.", + "Your workspace isn't connected to Stripe yet. Please install the Dub Stripe app in settings to create discount codes.", }); } diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx index a6ba3d8a44a..b75f94e7e89 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx @@ -13,6 +13,7 @@ import { LoadingSpinner, Table, Tag, + TooltipContent, useTable, } from "@dub/ui"; import { Trash } from "@dub/ui/icons"; @@ -165,7 +166,7 @@ const PartnerDiscountCodes = ({ }: { partner: EnrolledPartnerProps; }) => { - const { slug } = useWorkspace(); + const { slug, stripeConnectId } = useWorkspace(); const [selectedDiscountCode, setSelectedDiscountCode] = useState(null); @@ -252,6 +253,17 @@ const PartnerDiscountCodes = ({ } as any); const disabledReason = useMemo(() => { + if (!stripeConnectId) { + return ( + + ); + } + if (!partner.discountId) { return "No discount assigned to this partner group. Please add a discount before you can create a discount code."; } @@ -265,7 +277,7 @@ const PartnerDiscountCodes = ({ } return undefined; - }, [partner.discountId, partner.links, discountCodes]); + }, [partner.discountId, partner.links, discountCodes, stripeConnectId]); return ( <> diff --git a/apps/web/lib/swr/use-api-mutation.ts b/apps/web/lib/swr/use-api-mutation.ts index dd19eb41df6..eec91bcccab 100644 --- a/apps/web/lib/swr/use-api-mutation.ts +++ b/apps/web/lib/swr/use-api-mutation.ts @@ -6,7 +6,6 @@ interface ApiRequestOptions { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; body?: TBody; headers?: Record; - showToast?: boolean; onSuccess?: (data: TResponse) => void; onError?: (error: string) => void; } @@ -36,8 +35,6 @@ 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( @@ -45,18 +42,9 @@ export function useApiMutation< endpoint: string, options: ApiRequestOptions = {}, ) => { - const { - method = "GET", - body, - headers, - showToast = true, - onSuccess, - onError, - } = options; + const { method = "GET", body, headers, onSuccess, onError } = options; setIsSubmitting(true); - setError(null); - setData(null); try { debug("Starting request", { @@ -89,7 +77,6 @@ export function useApiMutation< // Handle success const data = (await response.json()) as TResponse; - setData(data); onSuccess?.(data); debug("Response received", data); @@ -99,10 +86,9 @@ export function useApiMutation< ? error.message : "Something went wrong. Please try again."; - setError(errorMessage); - onError?.(errorMessage); - - if (showToast) { + if (onError) { + onError?.(errorMessage); + } else { toast.error(errorMessage); } diff --git a/apps/web/ui/partners/add-discount-code-modal.tsx b/apps/web/ui/partners/add-discount-code-modal.tsx index caaa6ee7350..9e083a09234 100644 --- a/apps/web/ui/partners/add-discount-code-modal.tsx +++ b/apps/web/ui/partners/add-discount-code-modal.tsx @@ -19,6 +19,7 @@ import { toast } from "sonner"; import { useDebounce } from "use-debounce"; import { z } from "zod"; import { X } from "../shared/icons"; +import { UpgradeRequiredToast } from "../shared/upgrade-required-toast"; type FormData = z.infer; @@ -87,6 +88,23 @@ const AddDiscountCodeModal = ({ copyToClipboard(data.code); toast.success("Discount code created and copied to clipboard!"); }, + onError: (error) => { + // Check if the error is related to Stripe permissions + if ( + error.includes( + "Having the 'read_write' scope would allow this request to continue", + ) + ) { + toast.custom(() => ( + + )); + } else { + toast.error(error); + } + }, }); }; From 0224f20609e123515d17f4472006d2442d837aee Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 26 Sep 2025 10:48:48 +0530 Subject: [PATCH 192/221] add custom cta to UpgradeRequiredToast --- .../web/ui/partners/add-discount-code-modal.tsx | 11 ++++++++--- apps/web/ui/shared/upgrade-required-toast.tsx | 17 ++++++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/web/ui/partners/add-discount-code-modal.tsx b/apps/web/ui/partners/add-discount-code-modal.tsx index 9e083a09234..0a6bdf002bd 100644 --- a/apps/web/ui/partners/add-discount-code-modal.tsx +++ b/apps/web/ui/partners/add-discount-code-modal.tsx @@ -1,5 +1,6 @@ import { mutatePrefix } from "@/lib/swr/mutate"; import { useApiMutation } from "@/lib/swr/use-api-mutation"; +import useWorkspace from "@/lib/swr/use-workspace"; import { DiscountCodeProps, EnrolledPartnerProps } from "@/lib/types"; import { createDiscountCodeSchema } from "@/lib/zod/schemas/discount"; import { @@ -34,6 +35,7 @@ const AddDiscountCodeModal = ({ setShowModal, partner, }: AddDiscountCodeModalProps) => { + const { stripeConnectId } = useWorkspace(); const [search, setSearch] = useState(""); const [isOpen, setIsOpen] = useState(false); @@ -93,12 +95,15 @@ const AddDiscountCodeModal = ({ if ( error.includes( "Having the 'read_write' scope would allow this request to continue", - ) + ) && + stripeConnectId ) { toast.custom(() => ( )); } else { diff --git a/apps/web/ui/shared/upgrade-required-toast.tsx b/apps/web/ui/shared/upgrade-required-toast.tsx index ebb5cd9d4af..21cb85230e4 100644 --- a/apps/web/ui/shared/upgrade-required-toast.tsx +++ b/apps/web/ui/shared/upgrade-required-toast.tsx @@ -9,18 +9,29 @@ export const UpgradeRequiredToast = ({ title, planToUpgradeTo, message, + ctaLabel, + ctaUrl, }: { title?: string; planToUpgradeTo?: string; message: string; + ctaLabel?: string; + ctaUrl?: string; }) => { const { slug, nextPlan } = useWorkspace(); planToUpgradeTo = planToUpgradeTo || nextPlan?.name; + // Defaults + const defaultCtaLabel = planToUpgradeTo + ? `Upgrade to ${capitalize(planToUpgradeTo)}` + : "Contact support"; + + const defaultCtaUrl = slug ? `/${slug}/upgrade` : "https://dub.co/pricing"; + return (
- {" "} +

{title || `You've discovered a ${capitalize(planToUpgradeTo)} feature!`} @@ -28,11 +39,11 @@ export const UpgradeRequiredToast = ({

{message}

- {planToUpgradeTo ? `Upgrade to ${planToUpgradeTo}` : "Contact support"} + {ctaLabel || defaultCtaLabel}
); From 0967b8c4e290287c987d3c52ccabd6cd678841fc Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 26 Sep 2025 18:19:32 +0530 Subject: [PATCH 193/221] fix ban partner to include program --- apps/web/lib/actions/partners/ban-partner.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/lib/actions/partners/ban-partner.ts b/apps/web/lib/actions/partners/ban-partner.ts index e34b0b309bd..815db81a712 100644 --- a/apps/web/lib/actions/partners/ban-partner.ts +++ b/apps/web/lib/actions/partners/ban-partner.ts @@ -29,6 +29,7 @@ export const banPartnerAction = authActionClient const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId, programId, + includeProgram: true, includePartner: true, includeDiscountCodes: true, }); From 21b0ddcb0eddce8095cf3e1f6a99ebf7e6eb14be Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 26 Sep 2025 19:39:49 +0530 Subject: [PATCH 194/221] final cleanup --- .../[discountCodeId]/delete}/route.ts | 22 ++++++---- .../remap}/route.ts | 8 +--- .../[partnerId]/links/page-client.tsx | 4 +- apps/web/lib/actions/partners/ban-partner.ts | 4 +- .../lib/actions/partners/bulk-ban-partners.ts | 6 ++- .../lib/actions/partners/create-discount.ts | 44 +++++++++++-------- .../lib/actions/partners/delete-discount.ts | 4 +- .../discounts/queue-discount-code-deletion.ts | 26 ++++++----- apps/web/lib/stripe/create-stripe-coupon.ts | 2 +- .../discounts/add-edit-discount-sheet.tsx | 40 ++++++++++++++++- 10 files changed, 104 insertions(+), 56 deletions(-) rename apps/web/app/(ee)/api/cron/{discounts/delete-discount-code => discount-codes/[discountCodeId]/delete}/route.ts (78%) rename apps/web/app/(ee)/api/cron/{discounts/remap-discount-codes => discount-codes/remap}/route.ts (93%) diff --git a/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts b/apps/web/app/(ee)/api/cron/discount-codes/[discountCodeId]/delete/route.ts similarity index 78% rename from apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts rename to apps/web/app/(ee)/api/cron/discount-codes/[discountCodeId]/delete/route.ts index b75927607b2..e2e2676996f 100644 --- a/apps/web/app/(ee)/api/cron/discounts/delete-discount-code/route.ts +++ b/apps/web/app/(ee)/api/cron/discount-codes/[discountCodeId]/delete/route.ts @@ -2,18 +2,21 @@ 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 { z } from "zod"; -import { logAndRespond } from "../../utils"; +import { logAndRespond } from "../../../utils"; export const dynamic = "force-dynamic"; -const schema = z.object({ - discountCodeId: z.string(), -}); +interface RequestParams { + params: { + discountCodeId: string; + }; +} -// POST /api/cron/discounts/delete-discount-code -export async function POST(req: Request) { +// POST /api/cron/discount-codes/[discountCodeId]/delete +export async function POST(req: Request, { params }: RequestParams) { try { + const { discountCodeId } = params; + const rawBody = await req.text(); await verifyQstashSignature({ @@ -21,14 +24,15 @@ export async function POST(req: Request) { rawBody, }); - const { discountCodeId } = schema.parse(JSON.parse(rawBody)); - const discountCode = await prisma.discountCode.findUnique({ where: { id: discountCodeId, }, }); + // Fake wait 5 seconds + await new Promise((resolve) => setTimeout(resolve, 5000)); + if (!discountCode) { return logAndRespond(`Discount code ${discountCodeId} not found.`); } diff --git a/apps/web/app/(ee)/api/cron/discounts/remap-discount-codes/route.ts b/apps/web/app/(ee)/api/cron/discount-codes/remap/route.ts similarity index 93% rename from apps/web/app/(ee)/api/cron/discounts/remap-discount-codes/route.ts rename to apps/web/app/(ee)/api/cron/discount-codes/remap/route.ts index 82a1f78b86d..dd833e620bf 100644 --- a/apps/web/app/(ee)/api/cron/discounts/remap-discount-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/discount-codes/remap/route.ts @@ -16,7 +16,7 @@ const schema = z.object({ groupId: z.string(), }); -// POST /api/cron/discounts/remap-discount-codes +// POST /api/cron/discount-codes/remap export async function POST(req: Request) { try { const rawBody = await req.text(); @@ -105,11 +105,7 @@ export async function POST(req: Request) { // Remove the discount codes from the group if (discountCodesToRemove.length > 0) { - await Promise.allSettled( - discountCodesToRemove.map((discountCodeId) => - queueDiscountCodeDeletion(discountCodeId), - ), - ); + await queueDiscountCodeDeletion(discountCodesToRemove); } return logAndRespond( diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx index b75f94e7e89..885b7e7b704 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/links/page-client.tsx @@ -231,9 +231,9 @@ const PartnerDiscountCodes = ({ maxSize: 25, cell: ({ row }) => (
- +
); @@ -189,21 +190,7 @@ const PartnerDiscountCodes = ({ { id: "code", header: "Code", - cell: ({ row }) => ( -
- -
- {row.original.code} -
- - - -
- ), + cell: ({ row }) => , }, { id: "shortLink", @@ -226,14 +213,14 @@ const PartnerDiscountCodes = ({ { id: "menu", enableHiding: false, - minSize: 35, - size: 35, - maxSize: 35, + minSize: 18, + size: 18, + maxSize: 18, cell: ({ row }) => (
- )} - + {loading ? ( +
+ +
+ ) : !error && (!discountCodes || discountCodes.length === 0) ? ( +
+ +

+ No codes created +

+

+ Create a discount code for each link +

+
+ ) : error ? ( +
+ + Failed to load discount codes + +
+ ) : ( +
+ )} diff --git a/apps/web/ui/modals/add-discount-code-modal.tsx b/apps/web/ui/modals/add-discount-code-modal.tsx index d213e1449ed..887c2ae489d 100644 --- a/apps/web/ui/modals/add-discount-code-modal.tsx +++ b/apps/web/ui/modals/add-discount-code-modal.tsx @@ -12,7 +12,7 @@ import { useCopyToClipboard, useMediaQuery, } from "@dub/ui"; -import { cn } from "@dub/utils"; +import { cn, getPrettyUrl } from "@dub/utils"; import { Tag } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -64,7 +64,7 @@ const AddDiscountCodeModal = ({ if (!debouncedSearch) { return partnerLinks.map((link) => ({ value: link.id, - label: link.shortLink, + label: getPrettyUrl(link.shortLink), })); } @@ -74,7 +74,7 @@ const AddDiscountCodeModal = ({ ) .map((link) => ({ value: link.id, - label: link.shortLink, + label: getPrettyUrl(link.shortLink), })); }, [partnerLinks, debouncedSearch]); @@ -144,7 +144,7 @@ const AddDiscountCodeModal = ({ htmlFor="code" className="block text-sm font-medium text-neutral-700" > - Discount code (optional) + Discount code @@ -181,7 +181,7 @@ const AddDiscountCodeModal = ({ selectedLink ? { value: selectedLink.id, - label: selectedLink.shortLink, + label: getPrettyUrl(selectedLink.shortLink), } : null } 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 00000000000..0645dcd96d1 --- /dev/null +++ b/apps/web/ui/partners/discounts/discount-code-badge.tsx @@ -0,0 +1,56 @@ +import { + DynamicTooltipWrapper, + SimpleTooltipContent, + Tag, + useCopyToClipboard, +} from "@dub/ui"; +import { cn } from "@dub/utils"; +import { toast } from "sonner"; + +export function DiscountCodeBadge({ + code, + showTooltip, +}: { + code: string; + showTooltip?: boolean; +}) { + const [copied, copyToClipboard] = useCopyToClipboard(); + return ( + + ), + } + : undefined + } + > + + + ); +} From 7c27b6eb9b2f879fe08466a7a861d78d13c081f6 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 3 Oct 2025 12:16:39 -0700 Subject: [PATCH 214/221] rearrange isDiscountEquivalent --- .../cron/groups/remap-discount-codes/route.ts | 6 ++--- .../(ee)/api/groups/[groupIdOrSlug]/route.ts | 6 ++--- .../api/discounts/is-discount-equivalent.ts | 26 +++++++++++++++++++ .../discounts/queue-discount-code-deletion.ts | 26 ------------------- 4 files changed, 30 insertions(+), 34 deletions(-) create mode 100644 apps/web/lib/api/discounts/is-discount-equivalent.ts diff --git a/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts b/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts index 44387de4947..33dda378cb0 100644 --- a/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts @@ -1,7 +1,5 @@ -import { - isDiscountEquivalent, - queueDiscountCodeDeletion, -} from "@/lib/api/discounts/queue-discount-code-deletion"; +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"; diff --git a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts index 5240a879d40..d5d9be53b44 100644 --- a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts +++ b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts @@ -1,8 +1,6 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; -import { - isDiscountEquivalent, - queueDiscountCodeDeletion, -} from "@/lib/api/discounts/queue-discount-code-deletion"; +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"; 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 00000000000..797725c3266 --- /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 index fc59fddbefc..19fb6342234 100644 --- a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts +++ b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts @@ -1,6 +1,5 @@ import { qstash } from "@/lib/cron"; import { APP_DOMAIN_WITH_NGROK, chunk } from "@dub/utils"; -import { Discount } from "@prisma/client"; const queue = qstash.queue({ queueName: "discount-code-deletion", @@ -40,28 +39,3 @@ export async function queueDiscountCodeDeletion( ); } } - -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; -} From 6c3800d50139b50f806665af33e7ec6555f999bf Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 3 Oct 2025 12:38:38 -0700 Subject: [PATCH 215/221] use dcode_ prefix --- apps/web/app/(ee)/api/discount-codes/route.ts | 2 ++ apps/web/lib/actions/partners/deactivate-partner.ts | 7 +++++++ apps/web/lib/api/create-id.ts | 1 + apps/web/lib/api/discounts/queue-discount-code-deletion.ts | 2 +- apps/web/ui/modals/delete-discount-code-modal.tsx | 4 ++-- 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(ee)/api/discount-codes/route.ts b/apps/web/app/(ee)/api/discount-codes/route.ts index 2baef1cdc84..40325aea4dc 100644 --- a/apps/web/app/(ee)/api/discount-codes/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/route.ts @@ -1,4 +1,5 @@ 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"; @@ -139,6 +140,7 @@ export const POST = withWorkspace( const discountCode = await prisma.discountCode.create({ data: { + id: createId({ prefix: "dcode_" }), code: stripeDiscountCode.code, programId, partnerId, diff --git a/apps/web/lib/actions/partners/deactivate-partner.ts b/apps/web/lib/actions/partners/deactivate-partner.ts index d351f2f5a0b..93e6377b3ba 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/api/create-id.ts b/apps/web/lib/api/create-id.ts index ff7308f91f8..0f93e6047c0 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/queue-discount-code-deletion.ts b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts index 19fb6342234..9e2e4bc1ef0 100644 --- a/apps/web/lib/api/discounts/queue-discount-code-deletion.ts +++ b/apps/web/lib/api/discounts/queue-discount-code-deletion.ts @@ -8,7 +8,7 @@ const queue = qstash.queue({ // 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 +// 3. When partners are banned / deactivated // 4. When a partner is moved to a different group export async function queueDiscountCodeDeletion( input: string | string[] | undefined, diff --git a/apps/web/ui/modals/delete-discount-code-modal.tsx b/apps/web/ui/modals/delete-discount-code-modal.tsx index b33d212a569..0406cb06054 100644 --- a/apps/web/ui/modals/delete-discount-code-modal.tsx +++ b/apps/web/ui/modals/delete-discount-code-modal.tsx @@ -26,9 +26,9 @@ export const DeleteDiscountCodeModal = ({ await deleteDiscountCode(`/api/discount-codes/${discountCode.id}`, { method: "DELETE", onSuccess: async () => { - toast.success(`Discount code deleted successfully!`); - await mutatePrefix("/api/discount-codes"); setShowModal(false); + await mutatePrefix("/api/discount-codes"); + toast.success(`Discount code deleted successfully!`); }, }); }; From 6fc58ccdffa07b333c2eb4f592e07dbe4edddff4 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 3 Oct 2025 12:56:24 -0700 Subject: [PATCH 216/221] rearrange some stuff --- .../(ee)/program/partners/partners-table.tsx | 29 --------- .../web/ui/modals/add-discount-code-modal.tsx | 62 +++++++++---------- 2 files changed, 30 insertions(+), 61 deletions(-) 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 4336cf00e3f..6ff820f1dd2 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,7 +4,6 @@ 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"; @@ -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/ui/modals/add-discount-code-modal.tsx b/apps/web/ui/modals/add-discount-code-modal.tsx index 887c2ae489d..5974d5b5856 100644 --- a/apps/web/ui/modals/add-discount-code-modal.tsx +++ b/apps/web/ui/modals/add-discount-code-modal.tsx @@ -1,6 +1,5 @@ import { mutatePrefix } from "@/lib/swr/mutate"; import { useApiMutation } from "@/lib/swr/use-api-mutation"; -import useWorkspace from "@/lib/swr/use-workspace"; import { DiscountCodeProps, EnrolledPartnerProps } from "@/lib/types"; import { createDiscountCodeSchema } from "@/lib/zod/schemas/discount"; import { @@ -10,7 +9,6 @@ import { ComboboxOption, Modal, useCopyToClipboard, - useMediaQuery, } from "@dub/ui"; import { cn, getPrettyUrl } from "@dub/utils"; import { Tag } from "lucide-react"; @@ -36,11 +34,9 @@ const AddDiscountCodeModal = ({ setShowModal, partner, }: AddDiscountCodeModalProps) => { - const { stripeConnectId } = useWorkspace(); const [search, setSearch] = useState(""); const [isOpen, setIsOpen] = useState(false); - const { isMobile } = useMediaQuery(); const formRef = useRef(null); const [debouncedSearch] = useDebounce(search, 500); const [, copyToClipboard] = useCopyToClipboard(); @@ -138,34 +134,6 @@ const AddDiscountCodeModal = ({
-
-
- -
- -
-
- -
- -
-

- Discount codes cannot be edited after creation -

-
-
+ +
+
+ +
+ +
+
+ +
+ +
+

+ Discount codes cannot be edited after creation +

From 29752820d360ac967040103eba6eaac9314e2adb Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 3 Oct 2025 14:42:11 -0700 Subject: [PATCH 217/221] finalize checkout.session.completed webhook --- .../webhook/checkout-session-completed.ts | 314 ++++++++++-------- .../integration/webhook/customer-created.ts | 4 +- .../integration/webhook/customer-updated.ts | 4 +- .../integration/webhook/invoice-paid.ts | 16 +- .../api/stripe/integration/webhook/route.ts | 2 +- .../create-new-customer.ts} | 114 ------- .../webhook/utils/get-connected-customer.ts | 28 ++ .../webhook/utils/get-promotion-code.ts | 26 ++ .../utils/get-subscription-product-id.ts | 27 ++ ...update-customer-with-stripe-customer-id.ts | 36 ++ apps/web/lib/analytics/is-first-conversion.ts | 2 +- packages/utils/src/constants/regions.ts | 4 + 12 files changed, 319 insertions(+), 258 deletions(-) rename apps/web/app/(ee)/api/stripe/integration/webhook/{utils.ts => utils/create-new-customer.ts} (60%) create mode 100644 apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-connected-customer.ts create mode 100644 apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-promotion-code.ts create mode 100644 apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-subscription-product-id.ts create mode 100644 apps/web/app/(ee)/api/stripe/integration/webhook/utils/update-customer-with-stripe-customer-id.ts 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 2a530f0ddac..fb7ee7a901b 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 @@ -22,19 +22,18 @@ import { import { prisma } from "@dub/prisma"; import { Customer, WorkflowTrigger } from "@dub/prisma/client"; import { COUNTRIES_TO_CONTINENTS, nanoid } from "@dub/utils"; +import { REGION_CODE_LOOKUP } from "@dub/utils/src/constants/regions"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; -import { - getConnectedCustomer, - getPromotionCode, - 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; @@ -46,8 +45,8 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { 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; /* @@ -144,7 +143,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 @@ -154,17 +153,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({ @@ -174,7 +181,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({ @@ -183,126 +190,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], - ); - - linkId = leadEvent.link_id; - } else if (promotionCodeId) { - // Find the promotion code for the promotion code id - const promotionCode = await getPromotionCode({ - promotionCodeId, - stripeAccountId, - livemode: event.livemode, - }); - - if (!promotionCode) { - return `Promotion code ${promotionCodeId} not found in connected account ${stripeAccountId}, skipping...`; - } - - // Find the workspace - const workspace = await prisma.project.findUnique({ - where: { - stripeConnectId: stripeAccountId, - }, - select: { - id: true, - stripeConnectId: true, - defaultProgramId: true, - }, - }); - - if (!workspace) { - return `Workspace with stripeConnectId ${stripeAccountId} not found, skipping...`; - } - - if (!workspace.defaultProgramId) { - return `Workspace with stripeConnectId ${stripeAccountId} has no default program, skipping...`; - } - - const discountCode = await prisma.discountCode.findUnique({ - where: { - programId_code: { - programId: workspace.defaultProgramId, - code: promotionCode.code, - }, - }, - select: { - link: true, - }, - }); + // 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...`; + } - if (!discountCode) { - return `Couldn't find link associated with promotion code ${promotionCode.code}, skipping...`; + linkId = leadEvent.link_id as string; } - - const link = discountCode.link; - linkId = link.id; - - // Record a fake click for this event - const customerDetails = charge.customer_details; - const customerAddress = customerDetails?.address; - - clickEvent = await recordFakeClick({ - link, - customer: { - continent: customerAddress?.country - ? COUNTRIES_TO_CONTINENTS[customerAddress.country] - : "NA", - country: customerAddress?.country ?? "US", - region: customerAddress?.state ?? "CA", - }, - }); - - customer = await prisma.customer.create({ - data: { - id: createId({ prefix: "cus_" }), - name: - customerDetails?.name || - customerDetails?.email || - generateRandomName(), - email: customerDetails?.email, - externalId: clickEvent.click_id, - 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; - - leadEvent = { - ...rest, - event_id: nanoid(16), - event_name: "Sign up", - customer_id: customer.id, - metadata: "", - }; - - shouldSendLeadWebhook = false; } 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) { @@ -319,7 +248,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, @@ -549,5 +478,130 @@ 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 + ? REGION_CODE_LOOKUP[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, + 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: "", + }; + + return { + linkId, + customer, + clickEvent, + leadEvent, + }; } 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 7439c9c4f04..4a330a7120b 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 9335dcc73ad..36a97b0afdd 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 1f2e41a0554..30dfab13625 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/route.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts index 2ac7876f2fb..8f2d5f41bef 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts @@ -87,5 +87,5 @@ export const POST = withAxiom(async (req: Request) => { 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 60% 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 2c6f101564a..06ce243137b 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,116 +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; - } -} - -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-connected-customer.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-connected-customer.ts new file mode 100644 index 00000000000..45e4dded129 --- /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 00000000000..4bd492b1f71 --- /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 00000000000..90bc607792f --- /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 00000000000..52bfd38ac57 --- /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/lib/analytics/is-first-conversion.ts b/apps/web/lib/analytics/is-first-conversion.ts index 9c1db221fd5..60fc4f32f41 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/packages/utils/src/constants/regions.ts b/packages/utils/src/constants/regions.ts index 31afcf88088..44c736fb369 100644 --- a/packages/utils/src/constants/regions.ts +++ b/packages/utils/src/constants/regions.ts @@ -3485,3 +3485,7 @@ export const REGIONS: { [key: string]: string } = { }; export const REGION_CODES = Object.keys(REGIONS) as [string, ...string[]]; + +export const REGION_CODE_LOOKUP = Object.fromEntries( + Object.entries(REGIONS).map(([key, value]) => [value, key]), +) as Record; From 52fa2b15d8a70ff76914453c37d477b4ced9c7cc Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 3 Oct 2025 14:52:29 -0700 Subject: [PATCH 218/221] address coderabbit feedback --- .../integration/webhook/checkout-session-completed.ts | 6 ++---- packages/utils/src/constants/regions.ts | 4 ---- 2 files changed, 2 insertions(+), 8 deletions(-) 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 fb7ee7a901b..4bb0515badf 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 @@ -22,7 +22,6 @@ import { import { prisma } from "@dub/prisma"; import { Customer, WorkflowTrigger } from "@dub/prisma/client"; import { COUNTRIES_TO_CONTINENTS, nanoid } from "@dub/utils"; -import { REGION_CODE_LOOKUP } from "@dub/utils/src/constants/regions"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; import { getConnectedCustomer } from "./utils/get-connected-customer"; @@ -565,9 +564,7 @@ async function attributeViaPromoCode({ ? COUNTRIES_TO_CONTINENTS[customerAddress.country] : "Unknown", country: customerAddress?.country ?? "Unknown", - region: customerAddress?.state - ? REGION_CODE_LOOKUP[customerAddress.state] - : "Unknown", + region: customerAddress?.state ?? "Unknown", }, }); @@ -578,6 +575,7 @@ async function attributeViaPromoCode({ 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"), diff --git a/packages/utils/src/constants/regions.ts b/packages/utils/src/constants/regions.ts index 44c736fb369..31afcf88088 100644 --- a/packages/utils/src/constants/regions.ts +++ b/packages/utils/src/constants/regions.ts @@ -3485,7 +3485,3 @@ export const REGIONS: { [key: string]: string } = { }; export const REGION_CODES = Object.keys(REGIONS) as [string, ...string[]]; - -export const REGION_CODE_LOOKUP = Object.fromEntries( - Object.entries(REGIONS).map(([key, value]) => [value, key]), -) as Record; From c4395ac4c051250c485dcc949fd75ed0bcfcea5e Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 3 Oct 2025 15:23:25 -0700 Subject: [PATCH 219/221] update PartnerLinkCard, misc final changes --- apps/web/app/(ee)/api/discount-codes/route.ts | 2 +- .../(enrolled)/links/partner-link-card.tsx | 19 ++++- .../lib/actions/partners/create-discount.ts | 2 +- apps/web/lib/api/partners/delete-partner.ts | 75 ------------------- .../partners/delete-partner-profile.ts | 20 ++--- .../discounts/discount-code-badge.tsx | 67 +++++------------ 6 files changed, 50 insertions(+), 135 deletions(-) delete mode 100644 apps/web/lib/api/partners/delete-partner.ts diff --git a/apps/web/app/(ee)/api/discount-codes/route.ts b/apps/web/app/(ee)/api/discount-codes/route.ts index 40325aea4dc..87fb0666257 100644 --- a/apps/web/app/(ee)/api/discount-codes/route.ts +++ b/apps/web/app/(ee)/api/discount-codes/route.ts @@ -172,7 +172,7 @@ export const POST = withWorkspace( 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 app permissions in the dashboard or reach out to our support team for help." + ? "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, }); } 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 2db69d08534..6a451aca6dd 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 @@ -14,6 +14,8 @@ import { InvoiceDollar, LinkLogo, LoadingSpinner, + SimpleTooltipContent, + Tooltip, useCopyToClipboard, useInViewport, UserCheck, @@ -182,7 +184,22 @@ export function PartnerLinkCard({ link }: { link: PartnerProfileLinkProps }) {
{link.discountCode && ( - + + } + > +
+ + Discount code + + +
+
)}
diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index b1c70325c70..7dacfd71817 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -61,7 +61,7 @@ export const createDiscountAction = authActionClient } 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 app permissions in the dashboard or reach out to our support team for help." + ? "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, ); } 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 b5449bb425a..00000000000 --- 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 -// Not using this anymore -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/scripts/partners/delete-partner-profile.ts b/apps/web/scripts/partners/delete-partner-profile.ts index 2997fb0bc4f..eb2a1689709 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/partners/discounts/discount-code-badge.tsx b/apps/web/ui/partners/discounts/discount-code-badge.tsx index 0645dcd96d1..a51d3a01081 100644 --- a/apps/web/ui/partners/discounts/discount-code-badge.tsx +++ b/apps/web/ui/partners/discounts/discount-code-badge.tsx @@ -1,56 +1,29 @@ -import { - DynamicTooltipWrapper, - SimpleTooltipContent, - Tag, - useCopyToClipboard, -} from "@dub/ui"; +import { Tag, useCopyToClipboard } from "@dub/ui"; import { cn } from "@dub/utils"; import { toast } from "sonner"; -export function DiscountCodeBadge({ - code, - showTooltip, -}: { - code: string; - showTooltip?: boolean; -}) { +export function DiscountCodeBadge({ code }: { code: string }) { const [copied, copyToClipboard] = useCopyToClipboard(); return ( - - ), - } - : undefined + - + +
+ {code} +
+ ); } From f5083cf51c6916b7e112bb22a9df9c070ae4b121 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 3 Oct 2025 15:32:32 -0700 Subject: [PATCH 220/221] use group name in discount name --- apps/web/lib/actions/partners/create-discount.ts | 3 ++- apps/web/lib/stripe/create-stripe-coupon.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 7dacfd71817..d32e65bd28c 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -8,7 +8,7 @@ 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"; @@ -49,6 +49,7 @@ export const createDiscountAction = authActionClient stripeConnectId: workspace.stripeConnectId, }, discount: { + name: `Dub Discount (${truncate(group.name, 25)})`, amount, type, maxDuration: maxDuration ?? null, diff --git a/apps/web/lib/stripe/create-stripe-coupon.ts b/apps/web/lib/stripe/create-stripe-coupon.ts index ab7b8877939..7f31edabe96 100644 --- a/apps/web/lib/stripe/create-stripe-coupon.ts +++ b/apps/web/lib/stripe/create-stripe-coupon.ts @@ -12,7 +12,9 @@ export async function createStripeCoupon({ discount, }: { workspace: Pick; - discount: Pick; + discount: Pick & { + name: string; + }; }) { if (!workspace.stripeConnectId) { console.error( @@ -47,6 +49,7 @@ export async function createStripeCoupon({ ...(discount.type === "percentage" ? { percent_off: discount.amount } : { amount_off: discount.amount }), + ...(discount.name && { name: discount.name }), }, { stripeAccount: workspace.stripeConnectId, From db09f4869c53b0c3f60ba13a12fcba4789c1921c Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 3 Oct 2025 15:46:07 -0700 Subject: [PATCH 221/221] final bug fixes --- .../integration/webhook/checkout-session-completed.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 4bb0515badf..988f986d772 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 @@ -39,7 +39,10 @@ export async function checkoutSessionCompleted(event: Stripe.Event) { 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; + const promotionCodeId = charge.discounts?.[0]?.promotion_code as + | string + | null + | undefined; let customer: Customer | null = null; let existingCustomer: Customer | null = null; @@ -596,6 +599,8 @@ async function attributeViaPromoCode({ metadata: "", }; + await recordLead(leadEvent); + return { linkId, customer,