-
Notifications
You must be signed in to change notification settings - Fork 2.8k
.link domain renewals
#2698
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
.link domain renewals
#2698
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 c83df75
Update add-edit-domain-form.tsx
devkiran 3e07b10
Update add-edit-domain-form.tsx
devkiran 01af054
Update types.ts
devkiran 0dc62ef
Update add-edit-domain-form.tsx
devkiran 2a8c568
Add auto-renew toggle for domains in UI
devkiran 96f8607
Refactor auto-renewal modals for domains
devkiran 86df4eb
Add Dynadot renew option API integration and invoice type
devkiran e491706
Rename autoRenewDisabledAt to autoRenewalDisabledAt
devkiran f18f772
Update enable-auto-renewal-modal.tsx
devkiran b642584
Update disable-auto-renewal-modal.tsx
devkiran ed6a7f3
Update domain-card.tsx
devkiran d4b647e
Add domain renewal reminders cron and refactor Stripe webhook handlers
devkiran 28dd4fb
Add automated retry for failed domain renewal invoices
devkiran 03c90f9
Update route.ts
devkiran 5e5874a
Update page-client.tsx
devkiran 825b0f0
Merge branch 'main' into domain-renewal
devkiran 5a1ae59
Add domain lifecycle email templates and update schema
devkiran 82873d6
Update invoice.prisma
devkiran 78173c0
Update charge-failed.ts
devkiran 13c0ce7
Update charge-failed.ts
devkiran 529ed54
Support multiple domains in renewal failed email
devkiran d115436
Update charge-failed.ts
devkiran 09c3122
Update charge-failed.ts
devkiran 5b5e0bd
Add renewal payments API for domains and enhance invoice processing
devkiran 0d3871a
Send domain renewal email to workspace owners
devkiran 309dc63
Update charge-failed.ts
devkiran 52e657e
Update domain-card.tsx
devkiran 44c9fdd
Update charge-failed.ts
devkiran b95d0cd
Add Stripe charge.refunded webhook handler
devkiran d8a1c59
Refactor invoice PDF generation logic
devkiran 12a3fea
Merge branch 'main' into domain-renewal
devkiran fa0e289
Merge branch 'main' into domain-renewal
devkiran 87cc968
Add daily cron jobs for domain renewal reminders and payments
devkiran 4d1a713
add idempotencyKey
devkiran 8d304f5
Delete domain.ts
devkiran f25f27d
Update route.ts
devkiran c713a78
send reminder email
devkiran 6281082
Update charge-failed.ts
devkiran 36d7914
Update domain-expired.tsx
devkiran 4eac8b5
Update charge-succeeded.ts
devkiran 5c04559
Update domain-renewal-invoice.tsx
devkiran b72793e
Update route.ts
devkiran 32796cf
Merge branch 'main' into domain-renewal
steven-tey fa53815
Merge branch 'main' into domain-renewal
steven-tey 6fa7d25
Merge branch 'main' into domain-renewal
steven-tey 52ed3ea
Merge branch 'main' into domain-renewal
devkiran 1e67d2f
Merge branch 'main' into domain-renewal
steven-tey 62891a6
Merge branch 'main' into domain-renewal
steven-tey 28ff5ea
Merge branch 'main' into domain-renewal
steven-tey f0afd0a
set newExpiresAt by using existing exipiresAt
steven-tey 917210d
Merge branch 'main' into domain-renewal
steven-tey 16fa943
improve admin/payouts route
steven-tey f572698
small email updates
steven-tey c405bb8
update domain modals
steven-tey fa7bfce
combine DomainAutoRenewalModal, fix setRenewOption response
steven-tey 8cf526a
fix domain emails
steven-tey 17075ce
Merge branch 'main' into domain-renewal
steven-tey c6f183b
use transaction for creating invoice
steven-tey b68c3fa
simplify DomainRenewalInvoice
steven-tey b2bc68f
fix non-null assertions
steven-tey ab32588
Update add-edit-domain-form.tsx
steven-tey e89ffc7
use createPaymentIntent in more places
steven-tey 73b9a16
fix invoice page tabs
steven-tey 0f1432c
small final updates
steven-tey File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
151 changes: 151 additions & 0 deletions
151
apps/web/app/(ee)/api/cron/domains/renewal-payments/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } | ||
136 changes: 136 additions & 0 deletions
136
apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }; | ||
| }); | ||
devkiran marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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, | ||
| }), | ||
| })), | ||
| ); | ||
| } | ||
devkiran marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return NextResponse.json(reminderDomains); | ||
| } catch (error) { | ||
| await log({ | ||
| message: "Domains renewal reminders cron failed. Error: " + error.message, | ||
| type: "errors", | ||
| }); | ||
|
|
||
| return handleAndReturnErrorResponse(error); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ensure safe error message extraction
The error might not have a
messageproperty. 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
🤖 Prompt for AI Agents