Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion apps/web/app/[domain]/banned/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function generateStaticParams() {
return [];
}

export default async function BannedPage() {
export default function BannedPage() {
return (
<div>
<Hero>
Expand Down
17 changes: 16 additions & 1 deletion apps/web/app/[domain]/notfound/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { CTA } from "@/ui/placeholders/cta";
import { FeaturesSection } from "@/ui/placeholders/features-section";
import { Hero } from "@/ui/placeholders/hero";
import { LearnMoreButton } from "@/ui/placeholders/learn-more-button";
import { prisma } from "@dub/prisma";
import { GlobeSearch } from "@dub/ui";
import { cn, constructMetadata } from "@dub/utils";
import { redirect } from "next/navigation";

export const revalidate = false; // cache indefinitely

Expand All @@ -26,7 +28,20 @@ export function generateStaticParams() {
return [];
}

export default async function NotFoundLinkPage() {
export default async function NotFoundLinkPage(props: {
params: Promise<{ domain: string }>;
}) {
const { domain } = await props.params;
const domainData = await prisma.domain.findUnique({
where: {
slug: domain,
},
});

if (domainData?.notFoundUrl) {
redirect(domainData.notFoundUrl);
}

return (
<main className="flex min-h-screen flex-col justify-between">
<Hero>
Expand Down
21 changes: 15 additions & 6 deletions apps/web/app/api/domains/[domain]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { prisma } from "@dub/prisma";
import { Prisma } from "@dub/prisma/client";
import { combineWords, nanoid, R2_URL } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
import { revalidateTag } from "next/cache";
import { revalidatePath, revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
import * as z from "zod/v4";

Expand Down Expand Up @@ -233,13 +233,22 @@ export const PATCH = withWorkspace(
]);
}

// invalidate notfound cache
// invalidate static / isr cached for notfound links
if (
(notFoundUrl !== undefined &&
notFoundUrl !== existingDomain.notFoundUrl) ||
(expiredUrl !== undefined && expiredUrl !== existingDomain.expiredUrl)
notFoundUrl !== undefined &&
notFoundUrl !== existingDomain.notFoundUrl
) {
revalidateTag(`notfound:${domain.toLowerCase()}`);
revalidateTag(`static:${domain.toLowerCase()}`);
revalidatePath(`/${domain.toLowerCase()}/notfound`);
}

// invalidate static / isr cached for expired links
if (
expiredUrl !== undefined &&
expiredUrl !== existingDomain.expiredUrl
) {
revalidateTag(`static:${domain.toLowerCase()}`);
revalidatePath(`/${domain.toLowerCase()}/expired`);
}

// invalidate wellknown cache if any of the wellknown files have changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -677,15 +677,19 @@ function CreateCommissionSheetContent({
</a>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-neutral-500">
{formatDate(inv.createdAt)}
{inv.dubCommissionId && (
{inv.refunded ? (
<span className="rounded-md bg-neutral-200/80 px-1.5 py-0.5 text-xs text-neutral-500">
Refunded
</span>
) : inv.dubCommissionId ? (
<a
href={`/${slug}/program/commissions?partnerId=${partnerId}&customerId=${customerId}`}
target="_blank"
className="rounded bg-neutral-200/80 px-1.5 py-0.5 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-900"
>
Already imported
</a>
)}
) : null}
</p>
</div>
<span
Expand Down
6 changes: 6 additions & 0 deletions apps/web/lib/actions/partners/create-manual-commission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ export const createManualCommissionAction = authActionClient
currency: saleEvent.currency,
invoiceId: saleEvent.invoice_id,
createdAt: new Date(saleEvent.timestamp),
// if the invoice payment was refunded on Stripe, set the commission status to refunded as well
...(stripeCustomerInvoices.find(
(invoice) => invoice.id === saleEvent.invoice_id,
)?.refunded && {
status: "refunded",
}),
user,
context: {
customer: { country: customer.country },
Expand Down
82 changes: 74 additions & 8 deletions apps/web/lib/api/customers/get-customer-stripe-invoices.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import { stripeAppClient } from "@/lib/stripe";
import { StripeCustomerInvoiceSchema } from "@/lib/zod/schemas/customers";
import { prisma } from "@dub/prisma";
import Stripe from "stripe";

const stripe = stripeAppClient({
...(process.env.VERCEL_ENV && { mode: "live" }),
});

type ExpandedStripeInvoice = Stripe.Invoice & {
id: string;
payments?: {
data?: Array<{
payment?: {
type?: "charge" | "payment_intent" | "payment_record";
charge?: string | Stripe.Charge | null;
payment_intent?: string | Stripe.PaymentIntent | null;
};
}>;
};
};

export async function getCustomerStripeInvoices({
stripeCustomerId,
stripeConnectId,
Expand All @@ -20,40 +34,92 @@ export async function getCustomerStripeInvoices({
customer: stripeCustomerId,
status: "paid",
limit: 100,
expand: ["data.payments.data.payment"],
},
{
stripeAccount: stripeConnectId,
},
);
const validInvoices = data.filter(
(invoice): invoice is (typeof data)[number] & { id: string } =>
typeof invoice.id === "string",
);
const invoices = data.filter(
(invoice) => invoice.id,
) as ExpandedStripeInvoice[];

let charges: Stripe.Charge[] = [];
try {
const res = await stripe.charges.list(
{
limit: 100,
customer: stripeCustomerId,
},
{
stripeAccount: stripeConnectId,
},
);
charges = res.data;
} catch (error) {
// Stripe integration might be outdated and don't have charges.read permission
console.warn(error);
}

const commissions = await prisma.commission.findMany({
where: {
invoiceId: {
in: validInvoices.map((invoice) => invoice.id),
in: invoices.map((invoice) => invoice.id),
},
programId: programId,
},
});

const invoiceIdCommissionIdMap = commissions.reduce(
(acc, commission) => {
acc[commission.invoiceId!] = commission.id;
if (commission.invoiceId) {
acc[commission.invoiceId] = commission.id;
}
return acc;
},
{} as Record<string, string>,
);

const stripeCustomerInvoices = validInvoices.map((invoice) =>
const processInvoice = (invoice: ExpandedStripeInvoice) => {
const { payments, ...rest } = invoice;
if (!payments || !payments.data || !payments.data.length) {
return {
refunded: false,
metadata: rest,
};
}

for (const payment of payments.data) {
if (
payment.payment?.type === "payment_intent" &&
payment.payment?.payment_intent
) {
const charge = charges.find(
(charge) => charge.payment_intent === payment.payment?.payment_intent,
);
if (charge?.refunded || (charge?.amount_refunded ?? 0) > 0) {
return {
refunded: true,
metadata: rest,
};
}
}
}

return {
refunded: false,
metadata: rest,
};
};

const stripeCustomerInvoices = invoices.map((invoice) =>
StripeCustomerInvoiceSchema.parse({
id: invoice.id,
amount: invoice.amount_paid,
createdAt: new Date(invoice.created * 1000),
metadata: invoice,
refunded: processInvoice(invoice).refunded,
dubCommissionId: invoiceIdCommissionIdMap[invoice.id],
metadata: processInvoice(invoice).metadata,
}),
);

Expand Down
15 changes: 9 additions & 6 deletions apps/web/lib/api/links/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class LinkCache {
const redisLink = formatRedisLink(link);
const cacheKey = this._createKey({ domain: link.domain, key: link.key });
pipeline.set(cacheKey, redisLink, { ex: REDIS_CACHE_EXPIRATION });
revalidateTag(`notfound:${link.domain.toLowerCase()}:${link.key}`);
revalidateTag(`static:${link.domain.toLowerCase()}:${link.key}`);
});

return await pipeline.exec();
Expand All @@ -53,7 +53,7 @@ class LinkCache {

// Update LRU cache immediately to prevent stale reads
linkLRUCache.set(cacheKey, redisLink);
revalidateTag(`notfound:${link.domain.toLowerCase()}:${link.key}`);
revalidateTag(`static:${link.domain.toLowerCase()}:${link.key}`);

await Promise.all([
redisGlobal.set(cacheKey, redisLink, {
Expand Down Expand Up @@ -170,12 +170,15 @@ class LinkCache {
return caseSensitive ? cacheKey : cacheKey.toLowerCase();
}

_createNotFoundCacheKeys({ domain, key }: Pick<LinkProps, "domain" | "key">) {
_createStaticPagesCacheKeys({
domain,
key,
}: Pick<LinkProps, "domain" | "key">) {
domain = domain.toLowerCase();
// here we set 2 cache tags to invalidate the cache:
// 1. notfound:${domain}:${key} - for the specific not found link
// 2. notfound:${domain} - scope all links under the domain (for easy purging in PATCH /domains/:domain)
return `notfound:${domain}:${key},notfound:${domain}`;
// 1. static:${domain}:${key} - for the specific static page
// 2. static:${domain} - scope all links under the domain (for easy purging in PATCH /domains/:domain)
return `static:${domain}:${key},static:${domain}`;
}

// Vercel cache reads are 10x cheaper than writes, so to invalidate the cache
Expand Down
36 changes: 24 additions & 12 deletions apps/web/lib/middleware/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import { createResponseWithCookies } from "./utils/create-response-with-cookies"
import { detectBot } from "./utils/detect-bot";
import { getFinalUrl } from "./utils/get-final-url";
import { getIdentityHash } from "./utils/get-identity-hash";
import { handleNotFoundLink } from "./utils/handle-not-found-link";
import { isIosAppStoreUrl } from "./utils/is-ios-app-store-url";
import { isSingularTrackingUrl } from "./utils/is-singular-tracking-url";
import { isSupportedCustomURIScheme } from "./utils/is-supported-custom-uri-scheme";
Expand Down Expand Up @@ -62,6 +61,14 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
key = "_root";
}

const STATIC_PAGES_CACHE_HEADERS = {
"Vercel-CDN-Cache-Control": "public, s-maxage=86400",
"Vercel-Cache-Tag": linkCache._createStaticPagesCacheKeys({
domain,
key: originalKey || "_root",
}),
};

// we don't support .php links (too much bot traffic)
// hence we redirect to the root domain and add `dub-no-track` header to avoid tracking bot traffic
if (isUnsupportedKey(key)) {
Expand All @@ -88,7 +95,12 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
return await crawlBitly(req);
}

return await handleNotFoundLink(req);
return NextResponse.rewrite(new URL(`/${domain}/notfound`, req.url), {
headers: {
...DUB_HEADERS,
...STATIC_PAGES_CACHE_HEADERS,
},
});
}

isPartnerLink = Boolean(linkData.programId && linkData.partnerId);
Expand Down Expand Up @@ -198,14 +210,20 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
return NextResponse.rewrite(new URL(`/${domain}/banned`, req.url), {
headers: {
...DUB_HEADERS,
...(!shouldIndex && { "X-Robots-Tag": "googlebot: noindex" }),
...STATIC_PAGES_CACHE_HEADERS,
"X-Robots-Tag": "googlebot: noindex",
},
});
}

// handle disabled links
if (disabledAt) {
return await handleNotFoundLink(req);
return NextResponse.rewrite(new URL(`/${domain}/notfound`, req.url), {
headers: {
...DUB_HEADERS,
...STATIC_PAGES_CACHE_HEADERS,
},
});
}

// if the link has expired
Expand All @@ -222,6 +240,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
return NextResponse.rewrite(new URL(`/${domain}/expired`, req.url), {
headers: {
...DUB_HEADERS,
...STATIC_PAGES_CACHE_HEADERS,
...(!shouldIndex && { "X-Robots-Tag": "googlebot: noindex" }),
},
});
Expand Down Expand Up @@ -278,18 +297,11 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
{
headers: {
...DUB_HEADERS,
...STATIC_PAGES_CACHE_HEADERS,
...(!shouldIndex && { "X-Robots-Tag": "googlebot: noindex" }),
},
},
);
rewriteResponse.headers.set(
"Vercel-CDN-Cache-Control",
"public, s-maxage=86400",
);
rewriteResponse.headers.set(
"Vercel-Cache-Tag",
linkCache._createNotFoundCacheKeys({ domain, key: "_root" }), // set cache tag for root domain link
);
return createResponseWithCookies(rewriteResponse, cookieData);
}

Expand Down
Loading
Loading