import { accountDb } from "../scoped-db"; import { forgetLocator, locate } from "../scoped-db/locators"; import { claimLocator } from "../metering/directory"; import { badRequest, forbidden, HttpError } from "../errors"; import { publicUrl } from "../events/emit"; import type { BillingAccount } from "../metering/access"; import { clearSiteCap } from "../metering/quota"; import type { Viewer } from "~/lib/types"; import { CUSTOM_DOMAIN_PRICE_CENTS } from "~/lib/pricing"; import { customDomainCount, domainAddon } from "./domain-addon"; import { billingNotConfigured, checkoutDisabled, proPriceVerified, stripe, stripeEnabled, verifiedPacks, } from "./config"; import type { TopupPack } from "./config"; import { env } from "cloudflare:workers"; import { assertVerifiedForWrites } from "../verified-writes"; export type CustomerRow = { account_id: string; stripe_customer_id: string; subscription_id: string | null; subscription_status: string | null; price_id: string | null; current_period_end: number | null; cancel_at_period_end: number; subscription_event_at: number; }; /** Subscription states that keep the Pro plan. past_due keeps it while Stripe retries the card. */ export const ACTIVE_STATUSES = new Set(["active", "trialing", "past_due"]); export async function customerFor(accountId: string): Promise { return accountDb(accountId).first("SELECT * FROM billing_customers WHERE account_id = ?", accountId); } /** * Unpins an admin-set plan (BILL-P8) and, in the same statement, applies the stored subscription: no live * subscription means Free, a live one on the Pro price means Pro, and a live one on a price we cannot * confirm keeps the current plan for the next subscription event to settle. */ export async function followBillingPlan(accountId: string): Promise { await accountDb(accountId).run( `UPDATE account_billing SET plan_override = 0, updated_at = ?3, plan = COALESCE((SELECT CASE WHEN c.subscription_status IS NULL OR c.subscription_status NOT IN (${[...ACTIVE_STATUSES].map((status) => `'${status}'`).join(", ")}) THEN 'free' WHEN ?2 IS NOT NULL AND c.price_id = ?2 THEN 'pro' END FROM billing_customers c WHERE c.account_id = ?1), CASE WHEN EXISTS (SELECT 1 FROM billing_customers c WHERE c.account_id = ?1) THEN plan ELSE 'free' END) WHERE account_id = ?1`, accountId, env.STRIPE_PRICE_PRO ?? null, Date.now(), ); await clearSiteCap(accountId); } /** The account a Stripe customer bills, through the `stripe_customer` locator written by linkCustomer. */ export async function accountForCustomer(customerId: string): Promise { const accountId = await locate("stripe_customer", customerId); if (!accountId) return null; return (await customerFor(accountId))?.stripe_customer_id === customerId ? accountId : null; } /** * Links a Stripe customer to an account once: an account keeps its first customer, and a customer already * linked to another account never moves (the locator is first-claim-wins, standing in for D1's unique index). */ export async function linkCustomer(accountId: string, customerId: string): Promise { const existing = await customerFor(accountId); if (existing) return; if ((await claimLocator("stripe_customer", customerId, accountId)) !== accountId) return; const result = await accountDb(accountId).run( `INSERT INTO billing_customers (account_id, stripe_customer_id, updated_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING`, accountId, customerId, Date.now(), ); // A concurrent link gave the account another customer first: this one stays unlinked. if (result.meta.changes === 0 && (await customerFor(accountId))?.stripe_customer_id !== customerId) { await forgetLocator("stripe_customer", customerId); } } /** The account's Stripe customer, created on first use. The idempotency key makes concurrent first uses converge. */ async function ensureCustomer(account: BillingAccount, viewer: Viewer): Promise { const existing = await customerFor(account.accountId); if (existing) return existing.stripe_customer_id; const customer = await stripe().customers.create( { name: account.name, email: account.kind === "user" ? viewer.email : undefined, metadata: { account_id: account.accountId, account_kind: account.kind }, }, { idempotencyKey: `customer:${account.accountId}` }, ); await linkCustomer(account.accountId, customer.id); return (await customerFor(account.accountId))?.stripe_customer_id ?? customer.id; } function billingPath(account: BillingAccount): string { return account.kind === "org" ? `/orgs/${encodeURIComponent(account.name)}/billing` : "/settings/billing"; } function requireEditor(account: BillingAccount): void { if (!account.canEdit) throw forbidden("Only account owners can manage billing"); } /** The BILLING_CHECKOUT_DISABLED kill switch: no new purchase starts while it is on. */ function requireCheckoutOpen(): void { if (checkoutDisabled()) throw new HttpError(503, "New purchases are paused right now; try again later"); } /** Checkout for the Pro subscription; the webhook, not the redirect, changes the plan. */ export async function createSubscriptionCheckout(account: BillingAccount, viewer: Viewer): Promise<{ url: string }> { requireEditor(account); assertVerifiedForWrites(viewer); requireCheckoutOpen(); if (!stripeEnabled() || !env.STRIPE_PRICE_PRO) throw billingNotConfigured(); if (!(await proPriceVerified())) throw badRequest("The Pro price is being updated; try again later"); const current = await customerFor(account.accountId); if (current?.subscription_status && ACTIVE_STATUSES.has(current.subscription_status)) { throw badRequest("This account already has a subscription; manage it from the billing portal"); } const customer = await ensureCustomer(account, viewer); const back = billingPath(account); const metadata = { account_id: account.accountId, kind: "pro" }; const session = await stripe().checkout.sessions.create({ mode: "subscription", customer, client_reference_id: account.accountId, line_items: [{ price: env.STRIPE_PRICE_PRO, quantity: 1 }], success_url: publicUrl(`${back}?checkout=success`), cancel_url: publicUrl(`${back}?checkout=cancelled`), metadata, subscription_data: { metadata }, }); if (!session.url) throw badRequest("Stripe did not return a checkout URL"); return { url: session.url }; } /** * Checkout for the custom domain add-on, a separate subscription so it works on any plan. It starts at the * account's current domain count (at least one); later adds and removals update the quantity. */ export async function createDomainAddonCheckout(account: BillingAccount, viewer: Viewer): Promise<{ url: string }> { requireEditor(account); assertVerifiedForWrites(viewer); requireCheckoutOpen(); if (!stripeEnabled() || !env.STRIPE_PRICE_CUSTOM_DOMAIN) throw billingNotConfigured(); const addon = await domainAddon(account.accountId); if (addon?.status && (ACTIVE_STATUSES.has(addon.status) || addon.status === "incomplete")) { throw badRequest("The custom domain add-on is already active or awaiting payment"); } const customer = await ensureCustomer(account, viewer); const back = billingPath(account); const metadata = { account_id: account.accountId, kind: "custom_domains" }; const count = await customDomainCount(account.accountId); const session = await stripe().checkout.sessions.create({ mode: "subscription", customer, client_reference_id: account.accountId, line_items: [{ price: env.STRIPE_PRICE_CUSTOM_DOMAIN, quantity: Math.max(1, count) }], success_url: publicUrl(`${back}?checkout=success`), cancel_url: publicUrl(`${back}?checkout=cancelled`), metadata, subscription_data: { metadata }, }); if (!session.url) throw badRequest("Stripe did not return a checkout URL"); return { url: session.url }; } /** Checkout for a one-time prepaid pack; credits land when checkout.session.completed arrives paid. */ export async function createTopupCheckout( account: BillingAccount, viewer: Viewer, packId: string, ): Promise<{ url: string }> { requireEditor(account); assertVerifiedForWrites(viewer); requireCheckoutOpen(); if (!stripeEnabled()) throw billingNotConfigured(); const pack = (await verifiedPacks()).find((item) => item.id === packId); if (!pack) throw badRequest("That pack is not available right now"); const customer = await ensureCustomer(account, viewer); const back = billingPath(account); const metadata = { account_id: account.accountId, kind: "topup", pack: pack.id, meter: pack.pool, quantity: String(pack.quantity), }; const session = await stripe().checkout.sessions.create({ mode: "payment", customer, client_reference_id: account.accountId, line_items: [{ price: pack.price, quantity: 1 }], success_url: publicUrl(`${back}?checkout=success`), cancel_url: publicUrl(`${back}?checkout=cancelled`), metadata, payment_intent_data: { metadata }, }); if (!session.url) throw badRequest("Stripe did not return a checkout URL"); return { url: session.url }; } /** Stripe's Customer Portal: manage or cancel the subscription and update the card. */ export async function createPortalSession(account: BillingAccount): Promise<{ url: string }> { requireEditor(account); if (!stripeEnabled()) throw billingNotConfigured(); const current = await customerFor(account.accountId); if (!current) throw badRequest("This account has no billing history yet"); // While checkout is paused the portal only updates the card, so a past_due customer can still pay // but nobody can change plans or quantities. const session = await stripe().billingPortal.sessions.create({ customer: current.stripe_customer_id, return_url: publicUrl(billingPath(account)), ...(checkoutDisabled() ? { flow_data: { type: "payment_method_update" as const } } : {}), }); return { url: session.url }; } export type StripeBillingView = { enabled: boolean; /** BILLING_CHECKOUT_DISABLED is on: purchases are hidden, the portal only updates the card. */ checkoutPaused: boolean; publishableKey: string | null; proAvailable: boolean; subscription: { status: string | null; currentPeriodEnd: number | null; cancelAtPeriodEnd: boolean; } | null; hasCustomer: boolean; domainAddon: { available: boolean; status: string | null; quantity: number; priceCents: number }; packs: Pick[]; }; /** What the billing page needs about Stripe; `enabled: false` hides every Stripe control. */ export async function stripeBillingView(accountId: string): Promise { if (!stripeEnabled()) { return { enabled: false, checkoutPaused: false, publishableKey: null, proAvailable: false, subscription: null, hasCustomer: false, domainAddon: { available: false, status: null, quantity: 0, priceCents: CUSTOM_DOMAIN_PRICE_CENTS }, packs: [], }; } const row = await customerFor(accountId); const addon = await domainAddon(accountId); const paused = checkoutDisabled(); return { enabled: true, checkoutPaused: paused, publishableKey: env.STRIPE_PUBLISHABLE_KEY ?? null, proAvailable: !paused && (await proPriceVerified()), subscription: row?.subscription_id ? { status: row.subscription_status, currentPeriodEnd: row.current_period_end, cancelAtPeriodEnd: row.cancel_at_period_end === 1, } : null, hasCustomer: row !== null, domainAddon: { available: !paused && Boolean(env.STRIPE_PRICE_CUSTOM_DOMAIN), status: addon?.status ?? null, quantity: addon?.quantity ?? 0, priceCents: CUSTOM_DOMAIN_PRICE_CENTS, }, packs: (paused ? [] : await verifiedPacks()).map(({ id, pool, quantity, label, cents }) => ({ id, pool, quantity, label, cents, })), }; }