diff --git a/apps/web/app/[domain]/banned/page.tsx b/apps/web/app/[domain]/banned/page.tsx index 09d2319915a..9c67f90270d 100644 --- a/apps/web/app/[domain]/banned/page.tsx +++ b/apps/web/app/[domain]/banned/page.tsx @@ -24,7 +24,7 @@ export function generateStaticParams() { return []; } -export default async function BannedPage() { +export default function BannedPage() { return (
diff --git a/apps/web/app/[domain]/notfound/page.tsx b/apps/web/app/[domain]/notfound/page.tsx index 6e82cd53060..3c5eb02a618 100644 --- a/apps/web/app/[domain]/notfound/page.tsx +++ b/apps/web/app/[domain]/notfound/page.tsx @@ -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 @@ -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 (
diff --git a/apps/web/app/api/domains/[domain]/route.ts b/apps/web/app/api/domains/[domain]/route.ts index e85f966360b..fe9e45d78a6 100644 --- a/apps/web/app/api/domains/[domain]/route.ts +++ b/apps/web/app/api/domains/[domain]/route.ts @@ -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"; @@ -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 diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx index d7ade26b1d6..72d2c034a85 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx @@ -677,7 +677,11 @@ function CreateCommissionSheetContent({

{formatDate(inv.createdAt)} - {inv.dubCommissionId && ( + {inv.refunded ? ( + + Refunded + + ) : inv.dubCommissionId ? ( Already imported - )} + ) : null}

invoice.id === saleEvent.invoice_id, + )?.refunded && { + status: "refunded", + }), user, context: { customer: { country: customer.country }, diff --git a/apps/web/lib/api/customers/get-customer-stripe-invoices.ts b/apps/web/lib/api/customers/get-customer-stripe-invoices.ts index 4bd0f65e6e6..837b8c50148 100644 --- a/apps/web/lib/api/customers/get-customer-stripe-invoices.ts +++ b/apps/web/lib/api/customers/get-customer-stripe-invoices.ts @@ -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, @@ -20,20 +34,37 @@ 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, }, @@ -41,19 +72,54 @@ export async function getCustomerStripeInvoices({ const invoiceIdCommissionIdMap = commissions.reduce( (acc, commission) => { - acc[commission.invoiceId!] = commission.id; + if (commission.invoiceId) { + acc[commission.invoiceId] = commission.id; + } return acc; }, {} as Record, ); - 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, }), ); diff --git a/apps/web/lib/api/links/cache.ts b/apps/web/lib/api/links/cache.ts index 081e483f552..cdb40a5a885 100644 --- a/apps/web/lib/api/links/cache.ts +++ b/apps/web/lib/api/links/cache.ts @@ -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(); @@ -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, { @@ -170,12 +170,15 @@ class LinkCache { return caseSensitive ? cacheKey : cacheKey.toLowerCase(); } - _createNotFoundCacheKeys({ domain, key }: Pick) { + _createStaticPagesCacheKeys({ + domain, + key, + }: Pick) { 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 diff --git a/apps/web/lib/middleware/link.ts b/apps/web/lib/middleware/link.ts index 0a2d9f45030..dde2f48d6e4 100644 --- a/apps/web/lib/middleware/link.ts +++ b/apps/web/lib/middleware/link.ts @@ -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"; @@ -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)) { @@ -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); @@ -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 @@ -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" }), }, }); @@ -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); } diff --git a/apps/web/lib/middleware/utils/crawl-bitly.ts b/apps/web/lib/middleware/utils/crawl-bitly.ts index f687f7ca07a..de2d6af5cf5 100644 --- a/apps/web/lib/middleware/utils/crawl-bitly.ts +++ b/apps/web/lib/middleware/utils/crawl-bitly.ts @@ -1,19 +1,70 @@ +import { createId } from "@/lib/api/create-id"; import { linkCache } from "@/lib/api/links/cache"; -import { redis } from "@/lib/upstash"; -import { DUB_HEADERS } from "@dub/utils"; +import { encodeKeyIfCaseSensitive } from "@/lib/api/links/case-sensitivity"; +import { recordLink } from "@/lib/tinybird"; +import { prisma } from "@dub/prisma"; +import { + DUB_HEADERS, + getUrlFromStringIfValid, + linkConstructorSimple, +} from "@dub/utils"; +import { waitUntil } from "@vercel/functions"; import { NextRequest, NextResponse } from "next/server"; import { parse } from "./parse"; +const BUFFER_WORKSPACE_ID = "cm05wnnpo000711ztj05wwdbu"; +const BUFFER_USER_ID = "cm05wnd49000411ztg2xbup0i"; +const BUFFER_FOLDER_ID = "fold_LIZsdjTgFVbQVGYSUmYAi5vT"; +const BUFFER_BITLY_API_KEY = process.env.BUFFER_BITLY_API_KEY; + export const crawlBitly = async (req: NextRequest) => { - const { domain, fullKey } = parse(req); + const { domain, fullKey: key } = parse(req); - // bitly doesn't support the following characters: ` ~ , . < > ; ‘ : “ / \ [ ] ^ { } ( ) = + ! * @ & $ £ ? % # | + // bitly doesn't support the following characters: ` ~ , . < > ; ‘ : " / \ [ ] ^ { } ( ) = + ! * @ & $ £ ? % # | // @see: https://support.bitly.com/hc/en-us/articles/360030780892-What-characters-are-supported-when-customizing-links const invalidBitlyKeyRegex = /[`~,.<>;':"/\\[\]^{}()=+!*@&$£?%#|]/; - if (fullKey && !invalidBitlyKeyRegex.test(fullKey)) { - const link = await fetchBitlyLink({ domain, key: fullKey }); + if (key && !invalidBitlyKeyRegex.test(key)) { + const link = await fetchBitlyLink({ domain, key }); if (link) { + const sanitizedUrl = getUrlFromStringIfValid(link.long_url); + if (sanitizedUrl) { + console.log( + `[Bitly] Creating link on-demand: ${domain}/${key} (createdAt: ${link.created_at})`, + ); + const encodedKey = encodeKeyIfCaseSensitive({ domain, key }); + waitUntil( + prisma.link + .create({ + data: { + id: createId({ prefix: "link_" }), + domain, + key: encodedKey, + url: sanitizedUrl, + shortLink: linkConstructorSimple({ domain, key: encodedKey }), + projectId: BUFFER_WORKSPACE_ID, + userId: BUFFER_USER_ID, + folderId: BUFFER_FOLDER_ID, + createdAt: new Date(link.created_at), + }, + }) + .then((data) => + Promise.allSettled([ + // console log outputs + recordLink(data), + prisma.project.update({ + where: { + id: BUFFER_WORKSPACE_ID, + }, + data: { + linksUsage: { increment: 1 }, + }, + }), + ]), + ), + ); + } + return NextResponse.redirect(link.long_url, { headers: DUB_HEADERS, status: 302, @@ -25,17 +76,15 @@ export const crawlBitly = async (req: NextRequest) => { headers: { ...DUB_HEADERS, "Vercel-CDN-Cache-Control": "public, s-maxage=86400", - "Vercel-Cache-Tag": linkCache._createNotFoundCacheKeys({ + "Vercel-Cache-Tag": linkCache._createStaticPagesCacheKeys({ domain, - key: fullKey, + key, }), }, status: 302, }); }; -const BUFFER_WORKSPACE_ID = "cm05wnnpo000711ztj05wwdbu"; - async function fetchBitlyLink({ domain, key, @@ -43,20 +92,11 @@ async function fetchBitlyLink({ domain: string; key: string; }) { - const apiKey = await redis.get(`import:bitly:${BUFFER_WORKSPACE_ID}`); - - if (!apiKey) { - console.error( - `[Bitly] No API key found for workspace ${BUFFER_WORKSPACE_ID}`, - ); - return null; - } - const response = await fetch( `https://api-ssl.bitly.com/v4/bitlinks/${domain}/${key}`, { headers: { - Authorization: `Bearer ${apiKey}`, + Authorization: `Bearer ${BUFFER_BITLY_API_KEY}`, }, }, ); diff --git a/apps/web/lib/middleware/utils/handle-not-found-link.ts b/apps/web/lib/middleware/utils/handle-not-found-link.ts deleted file mode 100644 index 12ddeb19529..00000000000 --- a/apps/web/lib/middleware/utils/handle-not-found-link.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { linkCache } from "@/lib/api/links/cache"; -import { getDomainViaEdge } from "@/lib/planetscale/get-domain-via-edge"; -import { DUB_HEADERS } from "@dub/utils"; -import { NextRequest, NextResponse } from "next/server"; -import { parse } from "./parse"; - -export const handleNotFoundLink = async (req: NextRequest) => { - const { domain, fullKey } = parse(req); - - let response: NextResponse; - // check if domain has notFoundUrl configured - const domainData = await getDomainViaEdge(domain); - if (domainData?.notFoundUrl) { - response = NextResponse.redirect(domainData.notFoundUrl, { - headers: { - ...DUB_HEADERS, - "X-Robots-Tag": "googlebot: noindex", - // pass the Referer [sic] value to the not found URL - Referer: req.url, - }, - status: 302, - }); - } else { - response = NextResponse.rewrite(new URL(`/${domain}/notfound`, req.url), { - headers: DUB_HEADERS, - }); - } - response.headers.set("Vercel-CDN-Cache-Control", "public, s-maxage=86400"); - response.headers.set( - "Vercel-Cache-Tag", - linkCache._createNotFoundCacheKeys({ domain, key: fullKey }), - ); - return response; -}; diff --git a/apps/web/lib/zod/schemas/customers.ts b/apps/web/lib/zod/schemas/customers.ts index aeb02e6e7f1..36ac2b377ab 100644 --- a/apps/web/lib/zod/schemas/customers.ts +++ b/apps/web/lib/zod/schemas/customers.ts @@ -213,8 +213,9 @@ export const StripeCustomerInvoiceSchema = z.object({ id: z.string(), amount: z.number(), createdAt: z.date(), - metadata: z.any(), + refunded: z.boolean(), dubCommissionId: z.string().nullish(), + metadata: z.any(), }); export const CUSTOMER_EXPORT_COLUMNS = [ diff --git a/apps/web/tests/redirects/index.test.ts b/apps/web/tests/redirects/index.test.ts index 0fe6a5906cd..94379fb06c5 100644 --- a/apps/web/tests/redirects/index.test.ts +++ b/apps/web/tests/redirects/index.test.ts @@ -82,9 +82,12 @@ describe.runIf(env.CI)("Link Redirects", async () => { test("disabled link", async () => { const response = await fetch(`${h.baseUrl}/disabled`, fetchOptions); - expect(response.headers.get("location")).toBe("https://dub.co/"); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); + // Special case for disabled/notfound links since we're using redirect() from next/navigation: + // - doesn't support x-powered-by header + // - uses 307 status code instead of 302 + // This is the same as case-sensitive (incorrect) key test below. + expect(response.headers.get("location")).toBe("https://dub.co/links"); + expect(response.status).toBe(307); }); test("with slash", async () => { @@ -281,9 +284,8 @@ describe.runIf(env.CI)("Link Redirects", async () => { fetchOptions, ); - expect(response.headers.get("location")).toBe("https://dub.co/"); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("https://dub.co/links"); + expect(response.status).toBe(307); }); test("with case-sensitive key (and dub_id)", async () => { diff --git a/packages/stripe-app/package.json b/packages/stripe-app/package.json index 64efaca39da..e10825cb1e6 100644 --- a/packages/stripe-app/package.json +++ b/packages/stripe-app/package.json @@ -1,7 +1,7 @@ { "name": "com.example.dub", - "version": "0.0.5", - "description": "Dub Conversions", + "version": "0.0.19", + "description": "Dub Partners", "private": true, "license": "~~proprietary~~", "dependencies": { diff --git a/packages/stripe-app/stripe-app.json b/packages/stripe-app/stripe-app.json index 1dd4df826e3..fc0284b5f29 100644 --- a/packages/stripe-app/stripe-app.json +++ b/packages/stripe-app/stripe-app.json @@ -1,6 +1,6 @@ { "id": "dub.co", - "version": "0.0.18", + "version": "0.0.19", "name": "Dub Partners", "icon": "./stripe-icon.png", "permissions": [ @@ -16,6 +16,10 @@ "permission": "invoice_read", "purpose": "Allows Dub to read invoice information." }, + { + "permission": "charge_read", + "purpose": "Allows Dub to read charges to detect refunded payments." + }, { "permission": "checkout_session_read", "purpose": "Allows Dub to read checkout session information." @@ -57,7 +61,6 @@ "purpose": "Allows Dub to read promotion codes for an account." } ], - "connect_permissions": null, "ui_extension": { "views": [ { @@ -77,13 +80,13 @@ } }, "post_install_action": { - "type": "settings" + "type": "settings", + "url": "" }, "allowed_redirect_uris": [ "https://app.dub.co/api/stripe/integration/callback", "https://preview.dub.co/api/stripe/integration/callback" ], "stripe_api_access_type": "oauth", - "distribution_type": "public", - "sandbox_install_compatible": true + "distribution_type": "public" } \ No newline at end of file