import { env } from "cloudflare:workers"; import type Stripe from "stripe"; import { all, first } from "../db"; import { accountDb } from "../scoped-db"; import { customDomainCounts } from "../metering/directory"; import { locatedScopes, registerLocator } from "../scoped-db/locators"; import { backfillAccounts } from "../backfill"; import { sitesOwnedBy } from "../sites/store"; import { badRequest, conflict, paymentRequired } from "../errors"; import { billingSettings } from "../metering/quota"; import { CUSTOM_DOMAIN_LIMIT } from "~/lib/pricing"; import { ACTIVE_STATUSES } from "./checkout"; import { stripe, stripeEnabled } from "./config"; import { notifyUsers } from "../notify/deliver"; import { publicUrl } from "../events/emit"; export const DOMAIN_ADDON = "custom_domains"; /** How long custom domains keep serving after the add-on lapses, so a failed card can be fixed first. */ export const DOMAIN_GRACE_MS = 7 * 24 * 60 * 60 * 1000; export type AddonRow = { account_id: string; subscription_id: string | null; item_id: string | null; status: string | null; quantity: number; lapsed_at: number | null; }; /** Whether custom domains are sold as the Stripe add-on; otherwise plan flags and admin grants decide. */ export function domainAddonBilled(): boolean { return stripeEnabled() && Boolean(env.STRIPE_PRICE_CUSTOM_DOMAIN); } export function isDomainAddonPrice(price: Pick | null | undefined) { if (!price) return false; if (env.STRIPE_PRICE_CUSTOM_DOMAIN && price.id === env.STRIPE_PRICE_CUSTOM_DOMAIN) return true; return price.metadata?.addon === DOMAIN_ADDON || price.lookup_key === "gitfor_custom_domain_monthly"; } export async function domainAddon(accountId: string): Promise { return accountDb(accountId).first( "SELECT account_id, subscription_id, item_id, status, quantity, lapsed_at FROM billing_addons WHERE account_id = ? AND addon = ?", accountId, DOMAIN_ADDON, ); } /** Custom domains the account holds across its Sites, claimed or verified. */ export async function customDomainCount(accountId: string): Promise { return (await sitesOwnedBy(accountId)).filter((site) => site.owner_id === accountId && site.custom_domain).length; } /** Admin grant first; then the paid add-on when billing sells it, else the plan's `customDomains` flag. */ export async function customDomainsAllowed(accountId: string): Promise { const grant = await accountDb(accountId).first<{ custom_domains_grant: number }>( "SELECT custom_domains_grant FROM account_billing WHERE account_id = ?", accountId, ); if (grant?.custom_domains_grant === 1) return true; if (domainAddonBilled()) { const addon = await domainAddon(accountId); return Boolean(addon?.status && ACTIVE_STATUSES.has(addon.status)); } return (await billingSettings(accountId)).plan.customDomains === true; } /** Before a site takes a new custom domain; `replacing` is true when it already holds one. */ export async function assertCanAddCustomDomain(accountId: string | null, replacing: boolean): Promise { if (!accountId) throw badRequest("Only sites owned by an account can use a custom domain"); if (!(await customDomainsAllowed(accountId))) { throw paymentRequired( domainAddonBilled() ? "Custom domains need the custom domain add-on; add it from the account's billing page" : "Custom domains are not enabled for this account; ask a site administrator", { meter: "domains.custom", reason: "paid_feature", accountId }, ); } if (!replacing && (await customDomainCount(accountId)) >= CUSTOM_DOMAIN_LIMIT) { throw badRequest(`An account can use at most ${CUSTOM_DOMAIN_LIMIT} custom domains`); } } /** Keeps the add-on's Stripe quantity equal to the domain count; prorated by Stripe. Never throws. */ export async function syncDomainAddonQuantity(accountId: string | null): Promise { if (!accountId || !domainAddonBilled()) return; try { const addon = await domainAddon(accountId); if (!addon?.item_id || !addon.status || !ACTIVE_STATUSES.has(addon.status)) return; const count = await customDomainCount(accountId); if (count !== addon.quantity) { await stripe().subscriptionItems.update(addon.item_id, { quantity: count, proration_behavior: "create_prorations", }); } // Touched on every check so the reconcile cron rotates through all add-ons. await accountDb(accountId).run( "UPDATE billing_addons SET quantity = ?, updated_at = ? WHERE account_id = ? AND addon = ?", count, Date.now(), accountId, DOMAIN_ADDON, ); } catch (error) { console.warn("Custom domain add-on quantity sync failed", accountId, error); // Still rotate, so a row that keeps failing can't hold the reconcile queue's head. await accountDb(accountId) .run( "UPDATE billing_addons SET updated_at = ? WHERE account_id = ? AND addon = ?", Date.now(), accountId, DOMAIN_ADDON, ) .catch(() => undefined); } } const QUANTITY_SYNC_BATCH = 50; const DOMAIN_ADDON_INDEX = "domain-addon-holders"; /** Backfill: registers accounts that held the add-on before the locator existed. */ async function noteAddonHolder(accountId: string): Promise { const row = await accountDb(accountId).first("SELECT 1 AS ok FROM billing_addons WHERE addon = ?", DOMAIN_ADDON); if (row) await registerLocator("domain_addon", accountId, accountId); } /** * Cron: re-applies quantities a failed Stripe call left behind. Visits only accounts that ever held the add-on * (`domain_addon` locators), not every account. */ export async function reconcileDomainAddonQuantities(): Promise { if (!domainAddonBilled()) return; if (!(await backfillAccounts(DOMAIN_ADDON_INDEX, noteAddonHolder))) return; const rows: { account_id: string; updated_at: number }[] = []; for (const accountId of await locatedScopes("domain_addon")) { const row = await accountDb(accountId).first<{ account_id: string; updated_at: number }>( `SELECT account_id, updated_at FROM billing_addons WHERE addon = ? AND status IN ('active', 'trialing', 'past_due')`, DOMAIN_ADDON, ); if (row) rows.push(row); } rows.sort((left, right) => left.updated_at - right.updated_at); for (const row of rows.slice(0, QUANTITY_SYNC_BATCH)) await syncDomainAddonQuantity(row.account_id); } /** Applies an add-on subscription event; a lapse starts the grace clock and reactivation stops it. */ export async function saveDomainAddon( accountId: string, fields: { subscriptionId: string; itemId: string | null; status: string; quantity: number }, eventAt: number, ): Promise { const now = Date.now(); // Only an ended subscription starts the grace clock; a first payment still pending (incomplete) does not. const active = ACTIVE_STATUSES.has(fields.status) || fields.status === "incomplete" ? 1 : 0; await accountDb(accountId).run( `INSERT INTO billing_addons (account_id, addon, subscription_id, item_id, status, quantity, event_at, lapsed_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, CASE WHEN ?8 = 1 THEN NULL ELSE ?9 END, ?9) ON CONFLICT (account_id, addon) DO UPDATE SET subscription_id = excluded.subscription_id, item_id = excluded.item_id, status = excluded.status, quantity = excluded.quantity, event_at = excluded.event_at, updated_at = excluded.updated_at, lapsed_at = CASE WHEN ?8 = 1 THEN NULL ELSE COALESCE(billing_addons.lapsed_at, ?9) END WHERE billing_addons.event_at <= excluded.event_at AND (billing_addons.subscription_id IS NULL OR billing_addons.subscription_id = excluded.subscription_id OR billing_addons.status IS NULL OR billing_addons.status NOT IN ('active', 'trialing', 'past_due'))`, accountId, DOMAIN_ADDON, fields.subscriptionId, fields.itemId, fields.status, fields.quantity, eventAt, active, now, ); await registerLocator("domain_addon", accountId, accountId); await cancelConflictingAddon(accountId, fields); } /** A second live add-on subscription (two checkouts raced) is cancelled so the account is never billed twice. */ async function cancelConflictingAddon( accountId: string, fields: { subscriptionId: string; status: string }, ): Promise { if (!ACTIVE_STATUSES.has(fields.status)) return; const current = await domainAddon(accountId); if (!current?.subscription_id || current.subscription_id === fields.subscriptionId) return; if (!current.status || !ACTIVE_STATUSES.has(current.status) || !stripeEnabled()) return; try { // Webhooks retry and arrive out of order: act on the subscription's real state, and treat "already gone" as done. const live = await stripe().subscriptions.retrieve(fields.subscriptionId); if (!ACTIVE_STATUSES.has(live.status)) return; console.error("Cancelling a duplicate custom domain add-on subscription", accountId, fields.subscriptionId); await stripe().subscriptions.cancel(fields.subscriptionId, { prorate: true }); } catch (error) { console.error("Duplicate custom domain add-on could not be cancelled", accountId, fields.subscriptionId, error); } } const LIVE = "('active', 'trialing', 'past_due', 'incomplete')"; type AddonState = { lapsed_at: number | null; status: string | null; grant: number }; /** The add-on and grant state of each account holding custom domains (both live in the account's object). */ async function domainHolders(): Promise<{ accountId: string; state: AddonState }[]> { const out: { accountId: string; state: AddonState }[] = []; for (const accountId of [...(await customDomainCounts()).keys()].sort()) { const state = await accountDb(accountId).first( `SELECT b.lapsed_at, b.status, COALESCE(g.custom_domains_grant, 0) AS grant FROM (SELECT ?1 AS id) k LEFT JOIN billing_addons b ON b.account_id = k.id AND b.addon = ?2 LEFT JOIN account_billing g ON g.account_id = k.id`, accountId, DOMAIN_ADDON, ); if (state) out.push({ accountId, state }); } return out; } const notLive = (status: string | null) => status === null || !LIVE.includes(`'${status}'`); /** Accounts whose add-on lapsed more than the grace period ago and still hold custom domains. */ export async function lapsedDomainAccounts(now = Date.now(), limit = 20): Promise { return (await domainHolders()) .filter( ({ state }) => state.lapsed_at !== null && state.lapsed_at < now - DOMAIN_GRACE_MS && state.grant === 0 && notLive(state.status), ) .slice(0, limit) .map(({ accountId }) => accountId); } /** * Cron, when billing sells the add-on: an account holding custom domains with no live add-on and no grant (domains * from before the add-on existed, or a revoked grant) starts the grace clock, and its owners are told once. */ export async function startUnpaidGraceClocks(now = Date.now(), limit = 50): Promise { if (!domainAddonBilled()) return []; const accounts = (await domainHolders()) .filter(({ state }) => state.grant === 0 && state.lapsed_at === null && notLive(state.status)) .slice(0, limit) .map(({ accountId }) => accountId); for (const accountId of accounts) { await accountDb(accountId).run( `INSERT INTO billing_addons (account_id, addon, lapsed_at, updated_at) VALUES (?1, ?2, ?3, ?3) ON CONFLICT (account_id, addon) DO UPDATE SET lapsed_at = COALESCE(billing_addons.lapsed_at, excluded.lapsed_at)`, accountId, DOMAIN_ADDON, now, ); await notifyGraceStarted(accountId); } return accounts; } /** Tells an account's owners (the user, or an org's owners) something about its custom domains, once per day. */ export async function notifyDomainOwners(accountId: string, title: string, detail: string, key: string): Promise { const account = await first<{ kind: string; name: string }>( "SELECT kind, name FROM accounts WHERE id = ?", accountId, ); if (!account) return; const owners = account.kind === "org" ? ( await all<{ user_id: string }>( "SELECT user_id FROM org_members WHERE org_id = ? AND role = 'owner' LIMIT 50", accountId, ) ).map((row) => row.user_id) : [accountId]; const billing = account.kind === "org" ? `/orgs/${account.name}/billing` : "/settings/billing"; await notifyUsers({ category: "quota", repoId: null, accountId, access: "account", recipients: owners, title, details: [detail], url: publicUrl(billing), dedupeKey: `${key}:${accountId}:${new Date().toISOString().slice(0, 10)}`, }).catch((error: unknown) => console.warn("Custom domain notice failed", accountId, error)); } async function notifyGraceStarted(accountId: string): Promise { await notifyDomainOwners( accountId, `Custom domains now need the custom domain add-on; they stop serving in ${DOMAIN_GRACE_MS / 86_400_000} days without it`, "Add it from the account's billing page to keep every custom domain.", "domain-grace", ); } /** After a lapsed account's domains were released: the clock stops, and restarts if it ever holds a domain again. */ export async function clearDomainLapse(accountId: string): Promise { await accountDb(accountId).run( "UPDATE billing_addons SET lapsed_at = NULL WHERE account_id = ? AND addon = ?", accountId, DOMAIN_ADDON, ); } export async function setCustomDomainsGrant(accountId: string, granted: boolean): Promise { // An unpaid-domains clock restarts from the grant change (the cron starts a fresh one after a revoke); a lapse // driven by a subscription ending is left alone. const db = accountDb(accountId); await db.run( "UPDATE billing_addons SET lapsed_at = NULL WHERE account_id = ? AND addon = ? AND subscription_id IS NULL", accountId, DOMAIN_ADDON, ); await db.run( `INSERT INTO account_billing (account_id, custom_domains_grant, updated_at) VALUES (?, ?, ?) ON CONFLICT (account_id) DO UPDATE SET custom_domains_grant = excluded.custom_domains_grant, updated_at = excluded.updated_at`, accountId, granted ? 1 : 0, Date.now(), ); } /** Before an account is deleted: stops the add-on so a Stripe failure leaves the account intact. */ export async function cancelDomainAddon(accountId: string): Promise { const addon = await domainAddon(accountId); if (!addon?.subscription_id || !addon.status || !ACTIVE_STATUSES.has(addon.status)) return; if (!stripeEnabled()) throw conflict("Billing is unavailable right now; try again later"); await stripe().subscriptions.cancel(addon.subscription_id, { prorate: false }); }