import { env } from "cloudflare:workers"; import type Stripe from "stripe"; import { all, first, run } from "../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 { stripe, stripeEnabled, webCrypto } from "./config"; import { ACTIVE_STATUSES, accountForCustomer, customerFor, linkCustomer } from "./checkout"; import { captureServerEvent } from "../analytics"; export const STRIPE_WEBHOOK_PATH = "/stripe/webhook"; const MAX_BODY_BYTES = 512 * 1024; 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; } async function saveSubscription( accountId: string, fields: { id: string; status: string; priceId: string | null; periodEnd: number | null; cancelAtPeriodEnd: boolean }, eventAt: number, ): Promise { const result = await run( `UPDATE billing_customers SET subscription_id = ?, subscription_status = ?, price_id = ?, current_period_end = ?, cancel_at_period_end = ?, subscription_event_at = ?, updated_at = ? WHERE account_id = ? AND subscription_event_at <= ?`, fields.id, fields.status, fields.priceId, fields.periodEnd, fields.cancelAtPeriodEnd ? 1 : 0, eventAt, Date.now(), accountId, eventAt, ); 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 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); captureServerEvent("subscription_changed", accountId, { status: subscription.status, plan }); } async function onCheckoutPaid(session: Stripe.Checkout.Session): 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 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. await run( `UPDATE billing_customers SET subscription_id = ?1, subscription_status = CASE WHEN subscription_id = ?1 THEN COALESCE(subscription_status, 'active') ELSE 'active' END, price_id = CASE WHEN subscription_id = ?1 THEN COALESCE(price_id, ?2) ELSE ?2 END, updated_at = ?3 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, ); const row = await customerFor(accountId); if ( row?.subscription_id === subscriptionId && row.subscription_status && ACTIVE_STATUSES.has(row.subscription_status) ) { await setPlan(accountId, "pro"); } 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, }); } } /** * 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. */ 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 run( `UPDATE billing_customers SET subscription_status = 'past_due', updated_at = ? WHERE account_id = ? AND subscription_id = ? AND subscription_event_at <= ? AND subscription_status = 'active'`, Date.now(), accountId, subscriptionId, eventAt, ); } /** * 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); } } async function onDispute(dispute: Stripe.Dispute): Promise { const paymentIntent = idOf(dispute.payment_intent); if (!paymentIntent) return; 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 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; const prefix = `dispute:${dispute.id}:`; const taken = await 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), }); } } export const HANDLED_EVENTS = [ "checkout.session.completed", "checkout.session.async_payment_succeeded", "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted", "invoice.payment_failed", "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"> { 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); 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 "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 }); }