Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4e38b76
Add discount codes to the OpenAPI spec
devkiran Aug 18, 2026
cf72878
Update route.ts
devkiran Aug 18, 2026
cb79fc4
Add discount code update API, webhooks, and custom provider
devkiran Aug 18, 2026
ac7833f
Add custom discount code webhooks, update helper, and API tests
devkiran Aug 18, 2026
f86ca8a
Pass workspace into partner discount generation and add custom discou…
devkiran Aug 18, 2026
a0a1f75
Delete index.test.ts
devkiran Aug 18, 2026
9588e86
Retry generated discount codes on unique conflicts and emit deleted w…
devkiran Aug 18, 2026
a5bf954
Send disable/delete discount-code webhooks for all providers and skip…
devkiran Aug 18, 2026
42c9e8d
Remove discount code PATCH API and discount_code.updated webhook.
devkiran Aug 18, 2026
b7b34d1
Remove sendDiscountCodeWebhook
devkiran Aug 18, 2026
eaaecd2
Reset delete discount code confirmation when the modal closes.
devkiran Aug 18, 2026
559db95
Fix remap treating null coupon IDs as equivalent and roll back provid…
devkiran Aug 18, 2026
a8ddc7f
Merge branch 'main' into customer-discount-provider
devkiran Aug 18, 2026
8cea895
Update is-discount-equivalent.ts
devkiran Aug 18, 2026
65da0d9
CR feedback
devkiran Aug 18, 2026
2f83938
Merge branch 'main' into customer-discount-provider
steven-tey Aug 18, 2026
ceed609
Merge branch 'main' into customer-discount-provider
steven-tey Aug 19, 2026
121cf1e
Merge branch 'main' into customer-discount-provider
steven-tey Aug 19, 2026
0ff5c32
improve OpenAPI descriptions
steven-tey Aug 19, 2026
ab8dc97
discountId → idOrCode
steven-tey Aug 19, 2026
a418653
Keep create discount code optional in OpenAPI and treat empty strings…
devkiran Aug 19, 2026
f1e716c
Merge branch 'customer-discount-provider' of https://github.com/dubin…
devkiran Aug 19, 2026
5cd20af
Cover deleting discount codes by id or alphanumeric code.
devkiran Aug 19, 2026
83c1e2d
Merge branch 'main' into customer-discount-provider
devkiran Aug 19, 2026
898a430
Merge branch 'main' into customer-discount-provider
devkiran Aug 19, 2026
2b1d973
Always show program owner activity on partner applications
devkiran Aug 19, 2026
6a7a9a5
Merge pull request #4364 from dubinc/show-program-owner-activity-on-a…
steven-tey Aug 19, 2026
4caea1b
Merge pull request #4350 from dubinc/customer-discount-provider
steven-tey Aug 19, 2026
26024b1
cache api.dub.co till next deployment
steven-tey Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/web/app/(ee)/api/cron/discount-codes/create/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DubApiError } from "@/lib/api/errors";
import { withCron } from "@/lib/cron/with-cron";
import { createDiscountCode } from "@/lib/discounts/create-discount-code";
import { isNonRecoverableDiscountError } from "@/lib/discounts/discount-error";
Expand Down Expand Up @@ -44,6 +45,7 @@ export const POST = withCron(async ({ rawBody }) => {
project: {
select: {
id: true,
webhookEnabled: true,
stripeConnectId: true,
shopifyStoreId: true,
},
Expand Down Expand Up @@ -94,6 +96,13 @@ export const POST = withCron(async ({ rawBody }) => {
return logAndRespond(error.message, { logLevel: "warn" });
}

if (
error instanceof DubApiError &&
(error.code === "conflict" || error.code === "bad_request")
) {
return logAndRespond(error.message, { logLevel: "warn" });
}

throw error;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { deleteDiscountCodes } from "@/lib/discounts/delete-discount-code";
import { isDiscountProviderError } from "@/lib/discounts/discount-error";
import { isDiscountEquivalent } from "@/lib/discounts/is-discount-equivalent";
import { prisma } from "@/lib/prisma";
import { Discount, DiscountCode } from "@prisma/client";
import { DiscountCode } from "@prisma/client";
import * as z from "zod/v4";
import { logAndRespond } from "../../utils";

Expand Down Expand Up @@ -68,9 +68,7 @@ export const POST = withCron(async ({ rawBody }) => {

// Find the discount codes to update and remove
const discountCodesToUpdate: DiscountCode[] = [];
const discountCodesToRemove: (DiscountCode & {
discount: Pick<Discount, "provider"> | null;
})[] = [];
const discountCodesToRemove: typeof discountCodes = [];

for (const discountCode of discountCodes) {
const keepDiscountCode = isDiscountEquivalent(
Expand Down Expand Up @@ -149,6 +147,7 @@ export const POST = withCron(async ({ rawBody }) => {
},
select: {
id: true,
webhookEnabled: true,
stripeConnectId: true,
shopifyStoreId: true,
},
Expand Down
6 changes: 1 addition & 5 deletions apps/web/app/(ee)/api/cron/partners/ban/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,7 @@ export const POST = withCron(async ({ rawBody }) => {
},
discountCodes: {
include: {
discount: {
select: {
provider: true,
},
},
discount: true,
},
},
},
Expand Down
6 changes: 1 addition & 5 deletions apps/web/app/(ee)/api/cron/partners/deactivate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,7 @@ export const POST = withCron(async ({ rawBody }) => {
links: true,
discountCodes: {
include: {
discount: {
select: {
provider: true,
},
},
discount: true,
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,42 +7,38 @@ import { prisma } from "@/lib/prisma";
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";

// DELETE /api/discount-codes/[discountCodeId] - soft delete a discount code
// DELETE /api/discount-codes/[idOrCode] - delete a discount code
export const DELETE = withWorkspace(
async ({ workspace, params, session }) => {
const { discountCodeId } = params;
const { idOrCode } = params;
const programId = getDefaultProgramIdOrThrow(workspace);

const discountCode = await prisma.discountCode.findUnique({
where: {
id: discountCodeId,
},
where: idOrCode.startsWith("dcode_")
? { id: idOrCode }
: { programId_code: { programId, code: idOrCode } },
include: {
discount: {
select: {
provider: true,
},
},
discount: true,
},
});

if (!discountCode || !discountCode.discount) {
throw new DubApiError({
message: `Discount code (${discountCodeId}) not found.`,
code: "bad_request",
code: "not_found",
message: `Discount code (${idOrCode}) not found.`,
});
}

if (discountCode.programId !== programId) {
throw new DubApiError({
message: `Discount code (${discountCodeId}) is not associated with the program.`,
code: "bad_request",
code: "not_found",
message: `Discount code (${idOrCode}) not found.`,
});
}

await prisma.discountCode.update({
where: {
id: discountCodeId,
id: discountCode.id,
},
data: {
discountId: null,
Expand Down
93 changes: 64 additions & 29 deletions apps/web/app/(ee)/api/discount-codes/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log";
import { DubApiError } from "@/lib/api/errors";
import { getDiscountOrThrow } from "@/lib/api/partners/get-discount-or-throw";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
import { parseRequestBody } from "@/lib/api/utils";
Expand All @@ -15,26 +16,47 @@ import { APP_DOMAIN } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";

// GET /api/discount-codes - get all discount codes for a partner
// GET /api/discount-codes - list discount codes
export const GET = withWorkspace(
async ({ workspace, searchParams }) => {
const programId = getDefaultProgramIdOrThrow(workspace);

const { partnerId } = getDiscountCodesQuerySchema.parse(searchParams);

const programEnrollment = await getProgramEnrollmentOrThrow({
const {
partnerId,
programId,
include: {
discountCodes: true,
discountId,
page = 1,
pageSize,
} = getDiscountCodesQuerySchema.parse(searchParams);

if (discountId) {
await getDiscountOrThrow({
discountId,
programId,
});
}

if (partnerId) {
await getProgramEnrollmentOrThrow({
partnerId,
programId,
include: {},
});
}

const discountCodes = await prisma.discountCode.findMany({
where: {
programId,
...(partnerId && { partnerId }),
...(discountId && { discountId }),
},
orderBy: {
createdAt: "desc",
},
take: pageSize,
skip: (page - 1) * pageSize,
});

const response = DiscountCodeSchema.array().parse(
programEnrollment.discountCodes,
);

return NextResponse.json(response);
return NextResponse.json(DiscountCodeSchema.array().parse(discountCodes));
},
{
requiredPlan: ["business", "advanced", "enterprise"],
Expand All @@ -46,17 +68,30 @@ export const POST = withWorkspace(
async ({ workspace, req, session }) => {
const programId = getDefaultProgramIdOrThrow(workspace);

const { partnerId, linkId, code } = createDiscountCodeSchema.parse(
await parseRequestBody(req),
);
const body = await parseRequestBody(req);

if (typeof body.code === "string" && body.code.trim() === "") {
delete body.code;
}

const { partnerId, linkId, code } = createDiscountCodeSchema.parse(body);

const programEnrollment = await getProgramEnrollmentOrThrow({
partnerId,
programId,
include: {
links: true,
discount: true,
discountCodes: true,
links: {
select: {
id: true,
},
},
discountCodes: {
select: {
code: true,
linkId: true,
},
},
partner: {
select: {
id: true,
Expand Down Expand Up @@ -85,6 +120,18 @@ export const POST = withWorkspace(
});
}

// A link can have only one discount code
const duplicateByLink = programEnrollment.discountCodes.find(
(discountCode) => discountCode.linkId === linkId,
);

if (duplicateByLink) {
throw new DubApiError({
code: "bad_request",
message: `This link already has a discount code (${duplicateByLink.code}) assigned.`,
});
}

// Check for duplicate by code
if (code) {
const duplicateByCode = await prisma.discountCode.findUnique({
Expand All @@ -107,18 +154,6 @@ export const POST = withWorkspace(
}
}

// A link can have only one discount code
const duplicateByLink = programEnrollment.discountCodes.find(
(discountCode) => discountCode.linkId === linkId,
);

if (duplicateByLink) {
throw new DubApiError({
code: "bad_request",
message: `This link already has a discount code (${duplicateByLink.code}) assigned.`,
});
}

const discountCode = await createDiscountCode({
workspace,
partner: programEnrollment.partner,
Expand Down
18 changes: 17 additions & 1 deletion apps/web/app/(ee)/api/workflows/partner-approved/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export const { POST } = serve<Input>(
select: {
id: true,
plan: true,
webhookEnabled: true,
stripeConnectId: true,
shopifyStoreId: true,
},
});

Expand Down Expand Up @@ -172,8 +175,21 @@ export const { POST } = serve<Input>(

// Step 2: Auto-provision discount code if enabled
await context.run("create-discount-codes", async () => {
const workspace = await prisma.project.findUniqueOrThrow({
where: {
id: program.workspaceId,
},
select: {
id: true,
plan: true,
webhookEnabled: true,
stripeConnectId: true,
shopifyStoreId: true,
},
});

await generateDiscountCodeForPartner({
workspaceId: program.workspaceId,
workspace,
partner: {
id: partner.id,
name: partner.name,
Expand Down
10 changes: 8 additions & 2 deletions apps/web/app/api/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { document } from "@/lib/openapi";
import { NextResponse } from "next/server";

export const runtime = "edge";
export const dynamic = "force-static";

export function GET() {
return NextResponse.json(document);
return NextResponse.json(document, {
headers: {
// cache indefinitely till next deployment
"Vercel-CDN-Cache-Control": "s-maxage=31536000",
"Cache-Control": "public, max-age=31536000",
},
});
}
4 changes: 3 additions & 1 deletion apps/web/lib/actions/partners/accept-program-invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export const acceptProgramInviteAction = authPartnerActionClient
select: {
id: true,
webhookEnabled: true,
stripeConnectId: true,
shopifyStoreId: true,
},
});

Expand All @@ -72,7 +74,7 @@ export const acceptProgramInviteAction = authPartnerActionClient
await Promise.allSettled([
// 1. Generate discount code for partner (if enabled)
generateDiscountCodeForPartner({
workspaceId: workspace.id,
workspace,
partner: enrolledPartner,
}),
// 2. Send "partner.enrolled" webhook to workspace
Expand Down
8 changes: 6 additions & 2 deletions apps/web/lib/actions/partners/create-discount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,12 @@ export const createDiscountAction = authActionClient
type,
maxDuration,
provider,
couponId: coupon?.id || couponId || null,
...(couponTestId && { couponTestId }),
couponId:
provider === DiscountProvider.stripe
? coupon?.id || couponId || null
: null,
...(provider === DiscountProvider.stripe &&
couponTestId && { couponTestId }),
...(autoProvision && { autoProvisionEnabledAt: new Date() }),
},
});
Expand Down
11 changes: 2 additions & 9 deletions apps/web/lib/actions/partners/delete-discount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,8 @@ export const deleteDiscountAction = authActionClient
where: {
discountId: discount.id,
},
select: {
id: true,
code: true,
programId: true,
discount: {
select: {
provider: true,
},
},
include: {
discount: true,
},
});

Expand Down
6 changes: 1 addition & 5 deletions apps/web/lib/api/links/bulk-delete-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,7 @@ async function deleteLinksBatch(links: ExpandedLink[]): Promise<number> {
},
},
include: {
discount: {
select: {
provider: true,
},
},
discount: true,
},
});

Expand Down
Loading
Loading