Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
c3914eb
Add auto-renew support for registered domains
devkiran Jul 31, 2025
c83df75
Update add-edit-domain-form.tsx
devkiran Jul 31, 2025
3e07b10
Update add-edit-domain-form.tsx
devkiran Jul 31, 2025
01af054
Update types.ts
devkiran Jul 31, 2025
0dc62ef
Update add-edit-domain-form.tsx
devkiran Jul 31, 2025
2a8c568
Add auto-renew toggle for domains in UI
devkiran Jul 31, 2025
96f8607
Refactor auto-renewal modals for domains
devkiran Jul 31, 2025
86df4eb
Add Dynadot renew option API integration and invoice type
devkiran Jul 31, 2025
e491706
Rename autoRenewDisabledAt to autoRenewalDisabledAt
devkiran Jul 31, 2025
f18f772
Update enable-auto-renewal-modal.tsx
devkiran Jul 31, 2025
b642584
Update disable-auto-renewal-modal.tsx
devkiran Jul 31, 2025
ed6a7f3
Update domain-card.tsx
devkiran Jul 31, 2025
d4b647e
Add domain renewal reminders cron and refactor Stripe webhook handlers
devkiran Jul 31, 2025
28dd4fb
Add automated retry for failed domain renewal invoices
devkiran Jul 31, 2025
03c90f9
Update route.ts
devkiran Jul 31, 2025
5e5874a
Update page-client.tsx
devkiran Jul 31, 2025
825b0f0
Merge branch 'main' into domain-renewal
devkiran Aug 1, 2025
5a1ae59
Add domain lifecycle email templates and update schema
devkiran Aug 1, 2025
82873d6
Update invoice.prisma
devkiran Aug 1, 2025
78173c0
Update charge-failed.ts
devkiran Aug 1, 2025
13c0ce7
Update charge-failed.ts
devkiran Aug 1, 2025
529ed54
Support multiple domains in renewal failed email
devkiran Aug 1, 2025
d115436
Update charge-failed.ts
devkiran Aug 1, 2025
09c3122
Update charge-failed.ts
devkiran Aug 1, 2025
5b5e0bd
Add renewal payments API for domains and enhance invoice processing
devkiran Aug 1, 2025
0d3871a
Send domain renewal email to workspace owners
devkiran Aug 1, 2025
309dc63
Update charge-failed.ts
devkiran Aug 1, 2025
52e657e
Update domain-card.tsx
devkiran Aug 1, 2025
44c9fdd
Update charge-failed.ts
devkiran Aug 1, 2025
b95d0cd
Add Stripe charge.refunded webhook handler
devkiran Aug 1, 2025
d8a1c59
Refactor invoice PDF generation logic
devkiran Aug 1, 2025
12a3fea
Merge branch 'main' into domain-renewal
devkiran Aug 3, 2025
fa0e289
Merge branch 'main' into domain-renewal
devkiran Aug 4, 2025
87cc968
Add daily cron jobs for domain renewal reminders and payments
devkiran Aug 4, 2025
4d1a713
add idempotencyKey
devkiran Aug 4, 2025
8d304f5
Delete domain.ts
devkiran Aug 4, 2025
f25f27d
Update route.ts
devkiran Aug 4, 2025
c713a78
send reminder email
devkiran Aug 4, 2025
6281082
Update charge-failed.ts
devkiran Aug 4, 2025
36d7914
Update domain-expired.tsx
devkiran Aug 4, 2025
4eac8b5
Update charge-succeeded.ts
devkiran Aug 4, 2025
5c04559
Update domain-renewal-invoice.tsx
devkiran Aug 4, 2025
b72793e
Update route.ts
devkiran Aug 4, 2025
32796cf
Merge branch 'main' into domain-renewal
steven-tey Aug 4, 2025
fa53815
Merge branch 'main' into domain-renewal
steven-tey Aug 4, 2025
6fa7d25
Merge branch 'main' into domain-renewal
steven-tey Aug 5, 2025
52ed3ea
Merge branch 'main' into domain-renewal
devkiran Aug 5, 2025
1e67d2f
Merge branch 'main' into domain-renewal
steven-tey Aug 6, 2025
62891a6
Merge branch 'main' into domain-renewal
steven-tey Aug 6, 2025
28ff5ea
Merge branch 'main' into domain-renewal
steven-tey Aug 6, 2025
f0afd0a
set newExpiresAt by using existing exipiresAt
steven-tey Aug 6, 2025
917210d
Merge branch 'main' into domain-renewal
steven-tey Aug 6, 2025
16fa943
improve admin/payouts route
steven-tey Aug 6, 2025
f572698
small email updates
steven-tey Aug 6, 2025
c405bb8
update domain modals
steven-tey Aug 6, 2025
fa7bfce
combine DomainAutoRenewalModal, fix setRenewOption response
steven-tey Aug 6, 2025
8cf526a
fix domain emails
steven-tey Aug 6, 2025
17075ce
Merge branch 'main' into domain-renewal
steven-tey Aug 6, 2025
c6f183b
use transaction for creating invoice
steven-tey Aug 6, 2025
b68c3fa
simplify DomainRenewalInvoice
steven-tey Aug 6, 2025
b2bc68f
fix non-null assertions
steven-tey Aug 6, 2025
ab32588
Update add-edit-domain-form.tsx
steven-tey Aug 6, 2025
e89ffc7
use createPaymentIntent in more places
steven-tey Aug 6, 2025
73b9a16
fix invoice page tabs
steven-tey Aug 6, 2025
0f1432c
small final updates
steven-tey Aug 6, 2025
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
20 changes: 15 additions & 5 deletions apps/web/app/(ee)/api/admin/payouts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,18 @@ export const GET = withAdmin(async ({ searchParams }) => {
// Fetch invoices
const invoices = await prisma.invoice.findMany({
where: {
programId: {
not: ACME_PROGRAM_ID,
},
AND: [
{
programId: {
not: ACME_PROGRAM_ID,
},
},
{
program: {
isNot: null,
},
},
],
status: {
not: "failed",
},
Expand Down Expand Up @@ -90,8 +99,9 @@ export const GET = withAdmin(async ({ searchParams }) => {

const formattedInvoices = invoices.map((invoice) => ({
date: invoice.createdAt,
programName: invoice.program.name,
programLogo: invoice.program.logo,
// we're coercing this cause we've filtered out invoices without a programId above
programName: invoice.program!.name,
programLogo: invoice.program!.logo,
status: invoice.status,
amount: invoice.amount,
fee: invoice.fee,
Expand Down
151 changes: 151 additions & 0 deletions apps/web/app/(ee)/api/cron/domains/renewal-payments/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { createId } from "@/lib/api/create-id";
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { verifyVercelSignature } from "@/lib/cron/verify-vercel";
import { createPaymentIntent } from "@/lib/stripe/create-payment-intent";
import { prisma } from "@dub/prisma";
import { log } from "@dub/utils";
import { Invoice, Project, RegisteredDomain } from "@prisma/client";
import { addDays, endOfDay, startOfDay } from "date-fns";
import { NextResponse } from "next/server";

/**
* Daily cron job to create payment intents for `.link` domain renewals.
*
* Payment intents are created 14 days before domain expiration to ensure
* timely processing and avoid domain expiration.
*/

export const dynamic = "force-dynamic";

interface GroupedWorkspace {
workspace: Pick<Project, "id" | "stripeId" | "invoicePrefix">;
domains: Pick<RegisteredDomain, "id" | "slug" | "expiresAt" | "renewalFee">[];
}

// GET /api/cron/domains/renewal-payments
export async function GET(req: Request) {
try {
await verifyVercelSignature(req);

const targetDate = addDays(new Date(), 14);

// Find all domains expiring in 14 days
const domains = await prisma.registeredDomain.findMany({
where: {
autoRenewalDisabledAt: null,
expiresAt: {
gte: startOfDay(targetDate),
lte: endOfDay(targetDate),
},
},
include: {
project: {
select: {
id: true,
stripeId: true,
invoicePrefix: true,
},
},
},
});

if (domains.length === 0) {
return NextResponse.json(
"No domains found expiring exactly 14 days from today.",
);
}

// Group domains by workspaceId
const groupedByWorkspace = domains.reduce(
(acc, domain) => {
const workspaceId = domain.projectId;

if (!acc[workspaceId]) {
acc[workspaceId] = {
workspace: domain.project,
domains: [],
};
}

acc[workspaceId].domains.push({
id: domain.id,
slug: domain.slug,
expiresAt: domain.expiresAt,
renewalFee: domain.renewalFee,
});

return acc;
},
{} as Record<string, GroupedWorkspace>,
);

const invoices: Invoice[] = [];

// Create invoice for each workspace + domains group
for (const workspaceId in groupedByWorkspace) {
const { workspace, domains } = groupedByWorkspace[workspaceId];

const invoice = await prisma.$transaction(async (tx) => {
// Generate the next invoice number by counting the number of invoices for the workspace
const totalInvoices = await tx.invoice.count({
where: {
workspaceId: workspace.id,
},
});
const paddedNumber = String(totalInvoices + 1).padStart(4, "0");
const invoiceNumber = `${workspace.invoicePrefix}-${paddedNumber}`;

const totalAmount = domains.reduce(
(acc, domain) => acc + domain.renewalFee,
0,
);

return await tx.invoice.create({
data: {
id: createId({ prefix: "inv_" }),
workspaceId: workspace.id,
number: invoiceNumber,
type: "domainRenewal",
amount: totalAmount,
total: totalAmount,
registeredDomains: domains.map(({ slug }) => slug), // array of domain slugs,
},
});
});

console.log(
`Invoice ${invoice.id} created for workspace ${workspace.id} to renew ${domains.length} domains.`,
);

invoices.push(invoice);
}

// Create payment intent for each invoice
for (const invoice of invoices) {
const { workspace } = groupedByWorkspace[invoice.workspaceId];

if (!workspace.stripeId) {
console.log(`Workspace ${workspace.id} has no stripeId, skipping...`);
continue;
}

await createPaymentIntent({
stripeId: workspace.stripeId!,
amount: invoice.total,
invoiceId: invoice.id,
statementDescriptor: "Dub",
description: `Domain renewal invoice (${invoice.id})`,
idempotencyKey: `${invoice.id}-${invoice.failedAttempts}`,
});
}

return NextResponse.json("OK");
} catch (error) {
await log({
message: "Domains renewal cron failed. Error: " + error.message,
type: "errors",
});

return handleAndReturnErrorResponse(error);
Comment on lines +144 to +149
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Ensure safe error message extraction

The error might not have a message property. Use a type guard or fallback.

   } catch (error) {
     await log({
-      message: "Domains renewal cron failed. Error: " + error.message,
+      message: "Domains renewal cron failed. Error: " + (error instanceof Error ? error.message : String(error)),
       type: "errors",
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await log({
message: "Domains renewal cron failed. Error: " + error.message,
type: "errors",
});
return handleAndReturnErrorResponse(error);
await log({
message: "Domains renewal cron failed. Error: " + (error instanceof Error ? error.message : String(error)),
type: "errors",
});
return handleAndReturnErrorResponse(error);
🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/cron/domains/renewal-payments/route.ts around lines 141
to 146, the code assumes the error object has a message property, which might
not always be true. Modify the error message extraction to safely check if
error.message exists and is a string; if not, use a fallback string like a
generic error description or convert the error to a string. This ensures the log
call does not fail due to undefined or non-string error messages.

}
}
136 changes: 136 additions & 0 deletions apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { verifyVercelSignature } from "@/lib/cron/verify-vercel";
import { resend } from "@dub/email/resend";
import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants";
import DomainRenewalReminder from "@dub/email/templates/domain-renewal-reminder";
import { prisma } from "@dub/prisma";
import { chunk, log } from "@dub/utils";
import {
addDays,
differenceInCalendarDays,
endOfDay,
formatDistanceStrict,
startOfDay,
subDays,
} from "date-fns";
import { NextResponse } from "next/server";

/**
* Daily cron job to send `.link` domain renewal reminders.
*
* Reminders are sent at the following intervals before the domain expiration date:
* - First reminder: 30 days prior
* - Second reminder: 23 days prior
* - Third reminder: 16 days prior
*/

export const dynamic = "force-dynamic";

const REMINDER_WINDOWS = [30, 23, 16];

// GET /api/cron/domains/renewal-reminders
export async function GET(req: Request) {
try {
await verifyVercelSignature(req);

const now = new Date();

const targetDates = REMINDER_WINDOWS.map((days) => {
const date = subDays(now, -days);

return {
start: startOfDay(date),
end: endOfDay(date),
days,
};
});

// Find all domains that are eligible for renewal reminders
const domains = await prisma.registeredDomain.findMany({
where: {
autoRenewalDisabledAt: null,
OR: targetDates.map((t) => ({
expiresAt: {
gte: t.start,
lte: t.end,
},
})),
},
include: {
project: {
include: {
users: {
where: {
role: "owner",
},
include: {
user: true,
},
},
},
},
},
});

if (domains.length === 0) {
return NextResponse.json("No domains found to send reminders for.");
}

const reminderDomains = domains.flatMap(
({ slug, expiresAt, renewalFee, project }) => {
const reminderWindow = differenceInCalendarDays(expiresAt, now);
const chargeAt: Date = addDays(now, reminderWindow);

return project.users.map(({ user }) => ({
domain: {
slug,
renewalFee,
expiresAt,
reminderWindow,
chargeAt,
chargeInText: formatDistanceStrict(chargeAt, now),
},
workspace: {
slug: project.slug,
},
user: {
email: user.email,
},
}));
},
);

const reminderDomainsChunks = chunk(reminderDomains, 100);

if (!resend) {
return NextResponse.json(
"Resend is not configured, skipping email sending.",
);
}

for (const reminderDomainsChunk of reminderDomainsChunks) {
await resend.batch.send(
reminderDomainsChunk.map(({ workspace, user, domain }) => ({
from: VARIANT_TO_FROM_MAP.notifications,
to: user.email!,
subject: "Your domain is expiring soon",
variant: "notifications",
react: DomainRenewalReminder({
email: user.email!,
workspace,
domain,
}),
})),
);
}

return NextResponse.json(reminderDomains);
} catch (error) {
await log({
message: "Domains renewal reminders cron failed. Error: " + error.message,
type: "errors",
});

return handleAndReturnErrorResponse(error);
}
}
Loading