import { env } from "cloudflare:workers"; import type Stripe from "stripe"; import { first, run } from "../db"; import { accountDb } from "../scoped-db"; import { applyCreditMovement, normalizePool, purchasesForPayment } from "../metering/credits"; import type { CreditPurchase } from "../metering/credits"; import type { CreditPoolId } from "~/lib/pricing"; import { setPlan } from "../metering/quota"; import { liveMode, stripe, stripeEnabled, webCrypto } from "./config"; import { ACTIVE_STATUSES, accountForCustomer, customerFor, linkCustomer } from "./checkout"; import { captureServerEvent } from "../analytics"; import { DOMAIN_ADDON, isDomainAddonPrice, saveDomainAddon } from "./domain-addon"; import { disableAccount, notifyAdminsAboutAccount } from "../account-disable-actions"; export const STRIPE_WEBHOOK_PATH = "/stripe/webhook"; const MAX_BODY_BYTES = 512 * 1024; const DISPUTE_HANDLED_TTL_SECONDS = 365 * 24 * 60 * 60; function idOf(value: string | { id: string } | null | undefined): string | null { if (!value) return null; return typeof value === "string" ? value : value.id; } /** The account a Stripe object belongs to: the mapped customer first, else our metadata (then it is linked). */ async function accountFor( customer: string | null, metadata: Stripe.Metadata | null | undefined, ): Promise { if (customer) { const mapped = await accountForCustomer(customer); if (mapped) return mapped; } const claimed = metadata?.account_id; if (!claimed || !(await first("SELECT 1 AS one FROM accounts WHERE id = ?", claimed))) return null; if (customer) await linkCustomer(claimed, customer); // A customer already linked to another account never moves: trust the mapping, not the metadata. if (customer && (await accountForCustomer(customer)) !== claimed) return accountForCustomer(customer); return claimed; } /** * Whether an event from the same second as the stored one may replace its status. Stripe's event times are whole * seconds, so same-second events (customer.subscription.created "incomplete" and checkout.session.completed, * typically) are ordered by lifecycle instead: active/trialing wins against past_due, paused and unpaid (a * recovery or resume), but never against incomplete's successors canceled and incomplete_expired; * incomplete_expired only follows incomplete; anything else applies only if it moves forward. */ export function sameSecondAppliesSql(next: string, current: string): string { const rank = (column: string) => `CASE ${column} WHEN 'incomplete' THEN 0 WHEN 'trialing' THEN 1 WHEN 'active' THEN 1 WHEN 'past_due' THEN 2 WHEN 'paused' THEN 2 WHEN 'unpaid' THEN 3 WHEN 'canceled' THEN 4 WHEN 'incomplete_expired' THEN 4 ELSE 0 END`; return `(CASE WHEN ${next} IN ('active', 'trialing') THEN COALESCE(${current}, '') NOT IN ('canceled', 'incomplete_expired') WHEN ${next} = 'incomplete_expired' THEN COALESCE(${current}, 'incomplete') IN ('incomplete', 'incomplete_expired') ELSE ${rank(next)} >= ${rank(current)} END)`; } async function saveSubscription( accountId: string, fields: { id: string; status: string; priceId: string | null; periodEnd: number | null; cancelAtPeriodEnd: boolean }, eventAt: number, ): Promise { const result = await accountDb(accountId).run( `UPDATE billing_customers SET past_due_since = CASE WHEN ?2 != 'past_due' THEN NULL WHEN subscription_id = ?1 AND subscription_status = 'past_due' THEN COALESCE(past_due_since, ?6) ELSE ?6 END, subscription_id = ?1, subscription_status = ?2, price_id = ?3, current_period_end = ?4, cancel_at_period_end = ?5, subscription_event_at = ?6, updated_at = ?7 WHERE account_id = ?8 AND (COALESCE(subscription_event_at, 0) < ?6 OR (COALESCE(subscription_event_at, 0) = ?6 AND (subscription_id IS NOT ?1 OR ${sameSecondAppliesSql("?2", "subscription_status")})))`, fields.id, fields.status, fields.priceId, fields.periodEnd, fields.cancelAtPeriodEnd ? 1 : 0, eventAt, Date.now(), accountId, ); return result.meta.changes > 0; } /** * The current Pro price, or any earlier one: a price change creates a new price and archives the old, and * subscribers on the old price are still Pro. Prices created by the setup script carry metadata.plan = "pro". */ function isProPrice( price: Pick | null | undefined, ): boolean { if (!price) return false; if (env.STRIPE_PRICE_PRO && price.id === env.STRIPE_PRICE_PRO) return true; if (!price.recurring) return false; return price.metadata?.plan === "pro" || price.lookup_key === "gitfor_pro_monthly"; } async function onSubscription(subscription: Stripe.Subscription, eventAt: number): Promise { const customer = idOf(subscription.customer); const accountId = await accountFor(customer, subscription.metadata); if (!accountId) return; const addonItem = subscription.items.data.find((entry) => isDomainAddonPrice(entry.price)); // The custom domain add-on is its own subscription and never drives the plan. if (addonItem || subscription.metadata?.kind === DOMAIN_ADDON) { await saveDomainAddon( accountId, { subscriptionId: subscription.id, itemId: addonItem?.id ?? null, status: subscription.status, quantity: addonItem?.quantity ?? 0, }, eventAt, ); captureServerEvent( "domain_addon_changed", accountId, { status: subscription.status }, { ownerAccountId: accountId }, ); return; } const item = subscription.items.data.find((entry) => isProPrice(entry.price)) ?? subscription.items.data[0]; const priceId = item?.price.id ?? null; const current = await customerFor(accountId); // Only the account's current subscription (or a new one replacing an ended one) drives the plan. if (current?.subscription_id && current.subscription_id !== subscription.id) { const replacing = !current.subscription_status || !ACTIVE_STATUSES.has(current.subscription_status); if (!replacing || !ACTIVE_STATUSES.has(subscription.status)) return; } const applied = await saveSubscription( accountId, { id: subscription.id, status: subscription.status, priceId, periodEnd: item?.current_period_end ? item.current_period_end * 1000 : null, cancelAtPeriodEnd: subscription.cancel_at_period_end, }, eventAt, ); if (!applied) return; const plan = ACTIVE_STATUSES.has(subscription.status) && isProPrice(item?.price) ? "pro" : "free"; await setPlan(accountId, plan, "billing"); captureServerEvent( "subscription_changed", accountId, { status: subscription.status, plan }, { ownerAccountId: accountId }, ); } async function onCheckoutPaid(session: Stripe.Checkout.Session, eventAt: number): Promise { const accountId = await accountFor(idOf(session.customer), session.metadata); if (!accountId) return; if (session.mode === "subscription") { const subscriptionId = idOf(session.subscription); if (!subscriptionId || session.status !== "complete") return; // The add-on's subscription events fill in its item and status; the plan subscription is left alone. if (session.metadata?.kind === DOMAIN_ADDON) return; // The subscription events carry the authoritative status; this only makes the upgrade visible immediately. // A returning customer's ended subscription is replaced; a different live one is left alone. The event time // is stamped so a subscription event older than this checkout (an earlier "incomplete") cannot undo it. await accountDb(accountId).run( `UPDATE billing_customers SET past_due_since = CASE WHEN subscription_id = ?1 THEN past_due_since END, subscription_id = ?1, subscription_status = CASE WHEN subscription_id = ?1 AND subscription_status IS NOT NULL AND NOT (?6 = 1 AND subscription_status = 'incomplete') THEN subscription_status ELSE 'active' END, price_id = CASE WHEN subscription_id = ?1 THEN COALESCE(price_id, ?2) ELSE ?2 END, updated_at = ?3, subscription_event_at = MAX(COALESCE(subscription_event_at, 0), ?5) WHERE account_id = ?4 AND (subscription_id IS NULL OR subscription_id = ?1 OR subscription_status IS NULL OR subscription_status NOT IN ('active', 'trialing', 'past_due'))`, subscriptionId, env.STRIPE_PRICE_PRO ?? null, Date.now(), accountId, eventAt, // A paid checkout means the first invoice succeeded, so an "incomplete" from the same second is stale. session.payment_status === "paid" || session.payment_status === "no_payment_required" ? 1 : 0, ); const row = await customerFor(accountId); if ( row?.subscription_id === subscriptionId && row.subscription_status && ACTIVE_STATUSES.has(row.subscription_status) ) { await setPlan(accountId, "pro", "billing"); } return; } if (session.mode !== "payment" || session.payment_status !== "paid") return; const meter = normalizePool(session.metadata?.meter ?? ""); const quantity = Number(session.metadata?.quantity); if (session.metadata?.kind !== "topup" || !meter || !Number.isFinite(quantity) || quantity <= 0) return; const added = await applyCreditMovement({ ref: `purchase:${session.id}`, accountId, meter, kind: "purchase", quantity, pack: session.metadata?.pack ?? null, paymentIntent: idOf(session.payment_intent), amountCents: session.amount_total, }); if (added > 0) { captureServerEvent( "credit_purchased", accountId, { pool: meter, quantity, pack: session.metadata?.pack ?? null, amount_cents: session.amount_total, }, { ownerAccountId: accountId }, ); } } /** * A failed renewal of the current subscription is marked past_due at once, keeping Pro through Stripe's retries. * The plan itself only follows customer.subscription.* events, so the dashboard's "when retries run out" setting * (cancel, mark unpaid, or leave past_due) decides the outcome rather than a guess here. Only a strictly newer * failure applies: in a tie with invoice.paid the payment wins, and a same-second renewal failure still arrives * as Stripe's customer.subscription.updated to past_due. */ async function onPaymentFailed(invoice: Stripe.Invoice, eventAt: number): Promise { const customer = idOf(invoice.customer); const accountId = customer ? await accountForCustomer(customer) : null; if (!accountId) return; const subscriptionId = idOf(invoice.parent?.subscription_details?.subscription ?? null); if (!subscriptionId) return; await accountDb(accountId).run( `UPDATE billing_customers SET subscription_status = 'past_due', past_due_since = COALESCE(past_due_since, ?4), updated_at = ?1 WHERE account_id = ?2 AND subscription_id = ?3 AND COALESCE(subscription_event_at, 0) < ?4 AND subscription_status = 'active'`, Date.now(), accountId, subscriptionId, eventAt, ); } /** * A paid invoice on the current subscription ends a past_due spell at once (the matching * customer.subscription.updated may lag or arrive out of order); the event time guards against older events. */ async function onInvoicePaid(invoice: Stripe.Invoice, eventAt: number): Promise { const customer = idOf(invoice.customer); const accountId = customer ? await accountForCustomer(customer) : null; if (!accountId) return; const subscriptionId = idOf(invoice.parent?.subscription_details?.subscription ?? null); if (!subscriptionId) return; const db = accountDb(accountId); const result = await db.run( `UPDATE billing_customers SET subscription_status = 'active', past_due_since = NULL, subscription_event_at = ?1, updated_at = ?2 WHERE account_id = ?3 AND subscription_id = ?4 AND COALESCE(subscription_event_at, 0) <= ?1 AND subscription_status = 'past_due'`, eventAt, Date.now(), accountId, subscriptionId, ); if (result.meta.changes === 0) { // Already active: still record the payment's time, so a failure from the same second cannot follow it. await db.run( `UPDATE billing_customers SET subscription_event_at = ?1 WHERE account_id = ?2 AND subscription_id = ?3 AND COALESCE(subscription_event_at, 0) < ?1`, eventAt, accountId, subscriptionId, ); return; } const row = await customerFor(accountId); if (env.STRIPE_PRICE_PRO && row?.price_id === env.STRIPE_PRICE_PRO) await setPlan(accountId, "pro", "billing"); captureServerEvent( "subscription_recovered", accountId, { subscription: subscriptionId }, { ownerAccountId: accountId }, ); } /** * Reverses `target` of a purchase: at most what that purchase still has unspent. `requested` records the full * amount owed back, so a later partial refund of the same purchase only takes its own increment. */ async function reverse( purchase: CreditPurchase, ref: string, kind: "refund" | "dispute", target: number, paymentIntent: string, ) { if (target <= 0) return; await applyCreditMovement({ ref, accountId: purchase.accountId, meter: purchase.meter, kind, quantity: Math.min(target, purchase.unspent), requested: target, purchaseRef: purchase.ref, paymentIntent, }); } /** Refunds reverse the refunded share of each pack bought with this payment, never more than it has unspent. */ async function onRefund(charge: Stripe.Charge): Promise { const paymentIntent = idOf(charge.payment_intent); if (!paymentIntent || charge.amount <= 0) return; const fraction = Math.min(1, charge.amount_refunded / charge.amount); for (const purchase of await purchasesForPayment(paymentIntent)) { const ref = `refund:${charge.id}:${charge.amount_refunded}:${purchase.ref}`; await reverse(purchase, ref, "refund", purchase.quantity * fraction - purchase.reversed, paymentIntent); } } /** The billed account behind a dispute: the charge's customer, else the account that bought credit with it. */ async function accountForDispute(dispute: Stripe.Dispute): Promise { const charge = typeof dispute.charge === "string" ? await stripe().charges.retrieve(dispute.charge) : dispute.charge; const customer = charge ? idOf(charge.customer) : null; const mapped = customer ? await accountForCustomer(customer) : null; if (mapped) return mapped; const paymentIntent = idOf(dispute.payment_intent); if (!paymentIntent) return null; return (await purchasesForPayment(paymentIntent))[0]?.accountId ?? null; } /** A chargeback reverses the credit it paid for and disables the billed account until support resolves it. */ async function onDispute(dispute: Stripe.Dispute): Promise { const paymentIntent = idOf(dispute.payment_intent); if (paymentIntent) { for (const purchase of await purchasesForPayment(paymentIntent)) { const ref = `dispute:${dispute.id}:${purchase.ref}`; await reverse(purchase, ref, "dispute", purchase.quantity - purchase.reversed, paymentIntent); } } // A dispute disables once: a retry arriving after an admin re-enabled the account must not undo that decision. const handledKey = `dispute-handled:${dispute.id}`; if (await env.KV.get(handledKey)) return; const accountId = await accountForDispute(dispute); if (!accountId) { // Nothing to disable automatically, but a chargeback must never go unseen: an admin has to trace it. console.error("Dispute for an unknown account", dispute.id); await notifyAdminsAboutAccount( "unknown", `Stripe dispute ${dispute.id} matches no account; review it in the Stripe dashboard`, [ `Charge: ${idOf(dispute.charge) ?? "unknown"}`, `Payment intent: ${paymentIntent ?? "none"}`, `Amount: ${dispute.amount ?? "?"} ${dispute.currency ?? ""}`.trim(), `Reason: ${dispute.reason ?? "unknown"}`, ], `dispute-unmatched:${dispute.id}`, ); return; } await disableAccount({ accountId, reason: "payment_dispute", note: `Stripe dispute ${dispute.id}`, by: "stripe" }); await env.KV.put(handledKey, accountId, { expirationTtl: DISPUTE_HANDLED_TTL_SECONDS }); captureServerEvent("account_disabled", accountId, { reason: "payment_dispute" }, { ownerAccountId: accountId }); } /** * A dispute closed in our favour gives back exactly what opening it took, and lowers the pack's reversed total * by what the dispute claimed, so later refunds of that pack work as if the dispute never happened. */ async function onDisputeClosed(dispute: Stripe.Dispute): Promise { if (dispute.status !== "won") return; // The ledger lives in the billed account's object, so the account is found first. const accountId = await accountForDispute(dispute); const prefix = `dispute:${dispute.id}:`; const taken = await (accountId ? accountDb(accountId) : null)?.all<{ ref: string; account_id: string; meter: CreditPoolId; quantity: number; requested: number | null; purchase_ref: string | null; }>( `SELECT ref, account_id, meter, quantity, requested, purchase_ref FROM credit_ledger WHERE kind = 'dispute' AND substr(ref, 1, ?) = ?`, prefix.length, prefix, ); for (const row of taken ?? []) { await applyCreditMovement({ ref: `dispute-won:${row.ref}`, accountId: row.account_id, meter: row.meter, kind: "adjust", quantity: Math.max(0, -row.quantity), requested: -(row.requested ?? 0), purchaseRef: row.purchase_ref, paymentIntent: idOf(dispute.payment_intent), }); } // Winning does not re-enable the account: that stays a support decision, so admins are told instead. console.warn("Dispute won; account stays disabled", dispute.id, accountId); if (accountId) { await notifyAdminsAboutAccount( accountId, `Dispute ${dispute.id} was won; the account stays disabled until an admin re-enables it`, [`Account: ${accountId}`], `dispute-won:${dispute.id}`, ); } } export const HANDLED_EVENTS = [ "checkout.session.completed", "checkout.session.async_payment_succeeded", "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted", "invoice.payment_failed", "invoice.paid", "charge.refunded", "charge.dispute.created", "charge.dispute.closed", ] as const; /** * Applies one verified event at most once. The event id is recorded only after the handler succeeds, so a * failure makes Stripe retry; every handler is itself idempotent, so a concurrent duplicate is harmless. */ export async function processStripeEvent(event: Stripe.Event): Promise<"processed" | "duplicate" | "ignored"> { // A test-mode event must never change a live account (or the reverse), whatever endpoint delivered it. if (event.livemode !== liveMode()) { console.warn("Ignoring Stripe event from the other mode", event.id, event.type, event.livemode); return "ignored"; } if (await first("SELECT 1 AS one FROM stripe_events WHERE id = ?", event.id)) return "duplicate"; const eventAt = event.created * 1000; let handled = true; switch (event.type) { case "checkout.session.completed": case "checkout.session.async_payment_succeeded": await onCheckoutPaid(event.data.object, eventAt); break; case "customer.subscription.created": case "customer.subscription.updated": case "customer.subscription.deleted": await onSubscription(event.data.object, eventAt); break; case "invoice.payment_failed": await onPaymentFailed(event.data.object, eventAt); break; case "invoice.paid": await onInvoicePaid(event.data.object, eventAt); break; case "charge.refunded": await onRefund(event.data.object); break; case "charge.dispute.created": await onDispute(event.data.object); break; case "charge.dispute.closed": await onDisputeClosed(event.data.object); break; default: handled = false; } await run( "INSERT OR IGNORE INTO stripe_events (id, type, processed_at) VALUES (?, ?, ?)", event.id, event.type, Date.now(), ); return handled ? "processed" : "ignored"; } /** Verifies the Stripe-Signature header against the raw body; returns null when it is missing or wrong. */ export async function verifyStripeEvent(body: string, signature: string | null): Promise { const secret = env.STRIPE_WEBHOOK_SECRET; if (!secret || !signature) return null; try { return await stripe().webhooks.constructEventAsync(body, signature, secret, undefined, webCrypto); } catch { return null; } } export async function handleStripeWebhook(request: Request): Promise { if (!stripeEnabled() || !env.STRIPE_WEBHOOK_SECRET) return new Response("Not found\n", { status: 404 }); if (request.method !== "POST") return new Response("Method not allowed\n", { status: 405 }); const length = Number(request.headers.get("content-length") ?? 0); if (length > MAX_BODY_BYTES) return new Response("Payload too large\n", { status: 413 }); const raw = await request.arrayBuffer(); if (raw.byteLength > MAX_BODY_BYTES) return new Response("Payload too large\n", { status: 413 }); const body = new TextDecoder().decode(raw); const event = await verifyStripeEvent(body, request.headers.get("stripe-signature")); if (!event) return Response.json({ error: "Invalid signature" }, { status: 400 }); const outcome = await processStripeEvent(event); return Response.json({ received: true, outcome }); }