260 lines · 27.4 KB

Billing

Pricing

Every price lives in src/lib/pricing.ts: Cloudflare's published cost for each resource times MARKUP (1.75). Meters, plans, prepaid packs, the Stripe setup script and the billing page all derive from it, and src/lib/pricing.test.ts checks that they agree and that a Pro month using every allowance in full still costs less than PRO_PRICE_CENTS / MARKUP.

  • Compute is priced by the resources each container size reserves (vCPU-hours, GiB-hours of memory, GB-hours of disk), at full CPU. Cloudflare bills CPU only while active, so real cost is lower.
  • Storage is R2 storage cost times the markup. Bandwidth is free, since R2 egress is free.
  • AI tokens use a blended cost for the default merge-fix model; AI calls cover the Clef decision overhead.
  • Packs cost exactly what the same usage costs as overage.

To change prices, edit pricing.ts, run the tests, then run scripts/stripe-test-setup.ts: it creates new Stripe prices for changed amounts, moves the lookup key to them and archives the old ones. Existing subscriptions keep their old price and stay Pro: any recurring price with metadata.plan = "pro" (or the gitfor_pro_monthly lookup key) counts as Pro.

The billing page checks each Stripe price's amount against pricing.ts (cached for 10 minutes, failures for 1 minute) and hides any pack or upgrade whose price doesn't match, so nobody is shown one amount and charged another. Restricted keys need Prices: read for this.

The setup script only accepts test keys. In live mode, create the same prices in the Stripe dashboard: the amounts printed by the script, lookup keys gitfor_<pack id> and gitfor_pro_monthly, and metadata.plan = "pro" on the Pro price. Then set STRIPE_PRICE_PRO and STRIPE_TOPUP_PRICES.

gitfor meters usage on every instance (see src/lib/meters.ts), but charging money is optional. Billing has two modes:

Mode Enabled when Plans come from Who changes a plan
Self-hosted (default) STRIPE_SECRET_KEY unset METERING_PLANS (JSON overriding src/lib/plans.ts) A site administrator, from Admin -> Billing
Stripe STRIPE_SECRET_KEY set The same METERING_PLANS; Stripe only decides which plan Stripe webhooks (admins can still override)

No code path requires Stripe. Without it, the billing page shows usage and a "Billing is not configured" note, the /stripe/webhook route returns 404, and the Stripe server functions return 501.

Self-hosted mode

  1. Optionally tune allowances with METERING_PLANS, for example {"free": {"included": {"compute.runner": 36000}}}.
  2. Assign plans as a site administrator (adminSetPlan, on the admin billing page).
  3. Owners can still set a monthly spending limit, and alerts still fire at 50%, 80% and 100%.

Bring-your-own AI (below) works in this mode too. Prepaid credit needs Stripe to sell packs; the ledger itself has no Stripe dependency.

Stripe mode

Environment

Variable Kind Purpose
STRIPE_SECRET_KEY secret Enables billing. A restricted key (rk_) needs Customers, Checkout, Billing Portal, Subscriptions, Events (read), Charges (read) and Prices (read).
STRIPE_PUBLISHABLE_KEY secret pk_ key exposed to the browser.
STRIPE_WEBHOOK_SECRET secret Signing secret (whsec_) of the webhook endpoint.
STRIPE_PRICE_PRO secret Price id of the recurring Pro price.
STRIPE_PRICE_CUSTOM_DOMAIN secret Price id of the recurring custom domain add-on: $5/month per unit, lookup key gitfor_custom_domain_monthly, metadata.addon = "custom_domains". Unset: the add-on is not sold.
STRIPE_TOPUP_PRICES secret JSON map from pack id to Stripe price id: {"compute_10h": "price_...", ...}. Pack ids, quantities and amounts come from src/lib/pricing.ts.
STRIPE_DEV_REPLAY dev true turns on the loopback-only replay route described below.
STRIPE_WEBHOOK_MODE var test or live: the mode of the endpoint behind STRIPE_WEBHOOK_SECRET (whsec_ secrets carry no mode). Optional, but when set it must match the keys.
BILLING_CHECKOUT_DISABLED var true is the kill switch: no new checkout (Pro, add-on, packs) starts and the portal only updates the card. Webhooks keep processing.
PAST_DUE_GRACE_DAYS var Days a past_due subscription keeps Pro allowances before usage falls back to Free (default 14).

Key modes. STRIPE_SECRET_KEY must be sk_/rk_ test_ or live_, and STRIPE_PUBLISHABLE_KEY (and STRIPE_WEBHOOK_MODE, when set) must be the same mode. A mixed configuration turns billing off (stripeEnabled() is false, so checkout answers 501 and the webhook 404, which Stripe retries) and logs the reason; the rest of the forge keeps serving. STRIPE_WEBHOOK_MODE is compared case-insensitively. Webhook events whose livemode differs from the secret key's mode are answered ignored and change nothing.

Packs on sale are the PACKS in src/lib/pricing.ts that have a price mapped here; unmapped or malformed entries are skipped. scripts/stripe-test-setup.ts creates prices at exactly those amounts.

Webhook

Point a Stripe webhook endpoint at <PUBLIC_URL>/stripe/webhook and subscribe it to these events:

  • checkout.session.completed and checkout.session.async_payment_succeeded
  • customer.subscription.created, customer.subscription.updated and customer.subscription.deleted
  • invoice.payment_failed and invoice.paid
  • charge.refunded, charge.dispute.created and charge.dispute.closed

How the webhook handles events:

  • Every request is verified against Stripe-Signature with STRIPE_WEBHOOK_SECRET, and unsigned or mis-signed requests get a 400.
  • Event ids are stored in stripe_events, so a redelivered event is skipped. Every handler is idempotent anyway, so concurrent duplicates do no harm.
  • Subscription events carry their created time. An event older than the last one applied is ignored, so deliveries that arrive out of order never downgrade an account by mistake.
Event Effect
checkout.session.completed (subscription) Links the customer to the account and moves it to pro at once, stamping the event time so an older out-of-order subscription event cannot undo it.
customer.subscription.* active, trialing or past_due on the Pro price means pro; any other state means free. Uses setPlan.
invoice.payment_failed Only for the account's current subscription: marks it past_due and keeps Pro through Stripe's retries. The plan then follows the customer.subscription.updated that Stripe sends when retries run out (unpaid or canceled means free), so your dashboard's retry settings decide the outcome.
invoice.paid Only for the account's current subscription while it is past_due: marks it active again at once (stamping subscription_event_at, so an older failure cannot undo it, and clearing past_due_since) and restores Pro on the current Pro price.
checkout.session.completed (payment, paid) Adds the pack to the credit ledger.
charge.refunded / charge.dispute.created Reverses the refunded share of each pack, or the whole pack for a dispute, but never more than that pack has unspent.
charge.dispute.closed (won) Gives back exactly what the dispute took and notifies site admins. The account stays disabled.

past_due grace

Stripe can leave a subscription past_due indefinitely (the "leave as-is" retry setting). Usage checks therefore cap it server-side: once the subscription has been past_due for longer than PAST_DUE_GRACE_DAYS (measured from billing_customers.past_due_since, set when the current subscription first enters past_due and cleared when it leaves), billingSettings treats the account as Free for allowances and caps. The stored plan is unchanged, so a payment (invoice.paid or a customer.subscription.updated to active) restores Pro at once. An admin-pinned plan is exempt. Rows that were already past_due when migration 0047 ran are backfilled with the deploy time, and a missing past_due_since counts as "now", so a deploy never drops anyone to Free at once.

Kill switch

Set BILLING_CHECKOUT_DISABLED=true (a plain var, no redeploy of code needed) to stop taking money during an incident: the Pro, add-on and pack checkouts answer 503, the billing page hides them and shows a notice, and the Customer Portal opens only the card-update flow so past_due customers can still pay. Webhooks, plan changes from Stripe, refunds and disputes keep working. Automatic quantity updates of an existing custom domain add-on are not blocked.

Chargebacks

A dispute that matches no account (no mapped customer and no credit bought with its payment intent) disables nothing, but site admins are notified with the charge, payment intent, amount and reason, deduplicated per dispute.

charge.dispute.created also disables the billed account (the dispute's charge customer, else the account that bought the disputed credit) with reason payment_dispute:

  • The plan and custom domain add-on subscriptions are cancelled at once, without proration.
  • Hosting is suspended, running editor containers are stopped, and unfinished workflow runs in the account's repositories are cancelled.
  • Sign-in is refused, and every session, personal and fine-grained token, OAuth/MCP token, editor token and user-content grant of the account stops resolving, as do deploy and job tokens of its repositories.
  • The account's repositories become read-only; public ones stay readable so forks and links keep working, but the account is hidden from explore and search, and its profile and repositories are marked as disabled. Members of a disabled organization keep their own accounts and see a banner.
  • The user (or the organization's owners) is emailed to contact SUPPORT_EMAIL (falling back to LEGAL_CONTACT_EMAIL), and site admins are notified.

Retried events are harmless: every step is idempotent and only the first one sends email. Winning the dispute does not re-enable the account; re-enabling is a support decision made from /admin ("Disabled accounts"), which clears the flag and resumes hosting but does not re-create subscriptions. Admins can disable any other account from the same panel, with a note. An admin disable (reason admin, used for abuse takedowns) goes further than a dispute: the account's repositories, public ones included, stop serving pages, raw files and clones to everyone but site admins, and its profile or organization page (repositories, stars, activity, contributions, followers), hovercard and avatar return 404; it is also dropped from other accounts' follower lists. Each dispute id disables at most once (recorded in KV for a year), so a Stripe retry after a re-enable does nothing; a new dispute disables again. Job tokens also stop resolving once the user who triggered the run is disabled. Repository settings (including visibility), transfers and deletion of a disabled account's repositories, and deletion of a disabled organization, are refused to its own admins and org owners; only site admins can still change them (assertOwnerNotDisabled). A disabled account also cannot mint tokens of any kind (personal, fine-grained, deploy, OAuth/MCP, runner registration); an OAuth code issued before the disable can't be exchanged, and a refresh is refused without consuming the refresh token, so re-enabling the account brings the grant back. Commits signed with the account's keys show as unverified ("signer_disabled") while it stays disabled.

Custom domain add-on

Custom domains on Sites and Pages cost us $0.10 per Cloudflare for SaaS hostname per month past the first 100, so they are a paid add-on at CUSTOM_DOMAIN_PRICE_CENTS ($5) per domain per month (src/lib/pricing.ts).

  • The add-on is its own Stripe subscription (metadata.kind = "custom_domains"), so it works on Free and Pro and never drives the plan. Its state lives in billing_addons, not billing_customers.
  • Its quantity is the number of custom domains the account holds across Sites and Pages (claimed or verified). Adding or removing a domain updates the subscription item quantity with prorations; the cron re-applies any update that failed.
  • Adding a domain needs an active (active, trialing or past_due) add-on. An account holds at most CUSTOM_DOMAIN_LIMIT (25) domains. The domains.custom gauge records the count.
  • When the add-on stops being active, lapsed_at is set. After a 7-day grace period the cron unmaps every custom domain of the account and deletes its Cloudflare hostnames. Reactivating within the grace period clears lapsed_at.
  • Without STRIPE_PRICE_CUSTOM_DOMAIN (or without Stripe), the plan's customDomains flag decides (METERING_PLANS; on by default for Free and Pro). Site administrators can grant custom domains to any account from /admin/billing (account_billing.custom_domains_grant), which also skips the lapse.
  • Deleting an account or organization cancels the add-on first.
  • Accounts that hold custom domains without a live add-on or a grant (domains added before the add-on was sold, or after an admin revokes a grant) also start the grace clock, and their owners get one notice.
  • Checkout is refused while an add-on is active or awaiting its first payment. If two checkouts race anyway, the second live subscription is cancelled with proration.
  • Transfers, repository deletion and site teardown re-sync the quantity for both the old and the new owner. The reconcile cron rotates through every active add-on.

Customers and accounts

Each account, user or organization, has at most one Stripe customer (billing_customers). It is created the first time the account checks out and carries metadata.account_id. Once a customer is linked to an account it never moves: metadata on later events can't reassign it.

Only the user themselves, organization owners and site administrators can start checkout, open the portal or buy credit.

Server functions

All of these are in src/fns/billing.ts. org is null for the viewer's own account.

Function Input Returns
getBilling { org, period? } { account, summary, stripe, ledger, ai }. stripe.enabled is false without Stripe. Each meter in summary.meters has creditCovered and creditBalance.
startProCheckout { org } { url } of a Checkout Session (mode=subscription)
startTopupCheckout { org, pack } { url } of a Checkout Session (mode=payment)
openBillingPortal { org } { url } of a Customer Portal session (manage, cancel, update the card)
adminSetPlan { accountId, plan } Site admins only, in both modes

The browser redirects to the returned URL. The webhook, not the redirect, applies the change.

Prepaid credit

Credits are kept in a local ledger, one balance per account and meter:

  • credit_balances holds the current balance.
  • credit_ledger records purchases, refunds, disputes and adjustments, keyed by a unique ref.
  • credit_draws records one draw per usage event.

Credit lives in two pools: compute (held in standard-1 runner seconds and spent by every priced compute.* meter at its price relative to standard-1, so a standard-4 second uses about 5.4) and AI tokens. For those meters, usage is drawn down in this order:

  1. The plan's included amount for the period.
  2. Prepaid credit.
  3. Overage on paid plans, which the spending limit caps, or the hard cap on free plans.

Spending limit. Pro accounts that never set a limit get a default of $50 of overage a month (DEFAULT_PRO_SPENDING_LIMIT_CENTS). Owners can raise or lower it at any time on the billing page (Settings > Billing, or the org's Billing tab), from $0 (never pay overage) up to $1,000,000 a month (MAX_SPENDING_LIMIT_CENTS). Clearing the field returns to the default. There is no unlimited setting.

The mechanics:

  • Draws are atomic. A draw happens in the same D1 batch that inserts the usage event, keyed by the event's idempotency key. It runs only if that batch's insert landed (a per-attempt nonce on usage_events), so a retried event never draws, not even after credit is bought. Every path that records usage draws this way: recordUsage, commitUsage, the meter buffer and the sites rollup.
  • Reservations can't overshoot. reserveUsage counts the unspent balance plus what credit already covered this period as extra headroom, inside its own atomic batch.
  • The quota functions agree. assertWithinQuota, remainingQuota and the spending-limit check (periodSpendCents) all subtract credit the same way.
  • Credit carries over. The balance isn't tied to a period, so it survives the monthly rollover. usage_periods.credit_covered records how much of each period credit paid for.
  • Refunds and disputes reverse only what's unspent, per pack. Draws spend the oldest credit first, so reversing one pack never takes credit bought with another. Each reversal records the full amount owed back, so a later partial refund of the same payment only takes its own increment. Credit already spent can't be clawed back.

Why a local ledger instead of Stripe credit grants

Stripe billing credits (credit grants) apply to invoices for metered prices. That would mean reporting every meter to Stripe as Meter Events and leaving the draw-down to Stripe at invoice time. gitfor enforces hard caps in real time, inside D1 transactions, and has to work with no Stripe at all, so a ledger that lives next to the meters fits better:

  • Quota checks take no network round trip.
  • Draws are atomic with the usage that causes them.
  • Self-hosters get credit without Stripe.
  • Refunds and disputes map cleanly to ledger reversals.

Bring your own AI

AI features (merge fixes, pull request descriptions and any future caller) resolve their model through one function, resolveAiEndpoint in src/server/ai/endpoint.ts:

  • Precedence. A repository's endpoint overrides the account's endpoint, and an account endpoint overrides the instance model. A repository endpoint applies only when the billed account owns that repository, so a reader's pull request description never spends the owner's key.
  • Who can set it. Only the account owner (the user themselves, an organization owner or a site admin), for both scopes. A repository endpoint spends the owner's money and receives repository content, so other repository admins can't set it.
  • Kinds:
    • provider uses a provider key (OpenAI, Anthropic and others) through this instance's AI Gateway.
    • openai uses any OpenAI-compatible base URL plus a key.
    • cf-gateway uses your own Cloudflare AI Gateway, given its account id and gateway id.
  • Validation.
    • Custom base URLs must pass assertOutboundUrl: public https, with no IP literals, internal names or credentials.
    • Every call also resolves the hostname first, so a name that resolves to a private range is refused, and redirects are not followed. The check and the request resolve separately, so this narrows DNS rebinding but can't fully prevent it.
    • Loopback is allowed only when INTEGRATIONS_ALLOW_LOOPBACK=true (development).
  • Secrets. Keys are sealed with SECRETS_KEY and bound to their scope and owner, then never returned: settings pages see only the kind, provider, model, URL and gateway ids. Keys copied from the old account_ai_keys table are re-sealed to their owner the first time they are used.
  • Metering. BYOK calls are recorded as ai.byok_calls and ai.byok_tokens. Both are free and never capped, and BYOK calls never draw plan AI quota or prepaid credit.

Manage the account endpoint on the billing page, and a repository's on its Settings -> AI page.

Local development with test mode

  1. Put test-mode keys in .dev.vars (gitignored; see .dev.vars.example).

  2. Create the test products and prices, and write their ids into .dev.vars:

    STRIPE_SETUP_KEY=sk_test_... bun run scripts/stripe-test-setup.ts --write .dev.vars
    

    The script refuses any key that is not sk_test_ or rk_test_, and it is idempotent: it finds prices by lookup key, so a second run creates nothing.

  3. Pick a way to get webhooks without the Stripe CLI:

    • Replay (default). Set STRIPE_DEV_REPLAY=true and any STRIPE_WEBHOOK_SECRET=whsec_.... After completing a test checkout, run curl -X POST http://localhost:3000/__dev/stripe/replay.
      • The route fetches recent test-mode events from the Stripe API, signs each with your secret exactly as Stripe does, and posts it through the real webhook handler, so signature checks and idempotency are exercised end to end.
      • It only answers on loopback hosts, with a test key, and when opted in.
    • Tunnel. Run cloudflared tunnel --url http://localhost:3000 and register <tunnel>/stripe/webhook as a test-mode endpoint in the Stripe dashboard. Put that endpoint's signing secret in STRIPE_WEBHOOK_SECRET. Quick-tunnel URLs change on every run, so the replay route is usually simpler.

Not built: Connect

Stripe Connect is for paying other parties: for example, paying out marketplace sellers of paid actions or templates, or sponsorship payouts to maintainers. Nothing above needs it, since gitfor is the only merchant. Add it only when the instance starts moving money to third parties.

Out of budget: 402 Payment Required

Anything a plan, credit or spending limit blocks fails with 402 Payment Required and a machine-readable detail:

{
  "error": "Usage limit reached for Cloud runner (standard-1)",
  "code": "payment_required",
  "meter": "compute.runner",
  "reason": "plan_limit",
  "manageable": true,
  "account": { "name": "acme", "kind": "org" },
  "billingPath": "/orgs/acme/billing",
  "billingUrl": "https://gitfor.dev/orgs/acme/billing"
}

reason is one of:

  • plan_limit: an allowance or hard cap ran out.
  • spending_limit: the monthly spending limit was reached.
  • paid_feature: the plan doesn't include this at all.

account, billingPath and billingUrl are included only when the signed-in viewer pays for that account: it's their own account or an org they own. Anyone else, such as a collaborator on someone else's repository, gets manageable: false and is told to ask the owner. Internal account ids are never sent.

How each channel delivers it:

  • Server functions: the 402 goes through HttpError and httpErrorAdapter, which forwards only the known keys.
  • Upload routes: return it as the JSON error body.
  • Token-authenticated /api/v1: returns code, meter and reason.
  • Git pushes and LFS: the message includes the billing URL, and LFS returns 402.
  • Internals: QuotaExceededError itself is not an HttpError. Request boundaries convert it: viewerMiddleware, a global server-function middleware for functions without it, and the Worker entry. Background work keeps its old behaviour: CI dispatch skips the job, and integrations retry.

On the web, any 402 from an action opens one app-wide upsell dialog (UpsellDialog). For accounts the viewer manages, it shows:

  • what was used and what each plan includes for that meter;
  • the rate past that;
  • buttons to upgrade to Pro or buy that meter's prepaid credit;
  • a link to billing.

Account notices use the reusable NagBanner (src/ui/NagBanner.tsx). It's a bar under the site header with "Remind me later", which snoozes it per browser. Two notices use it today:

  • An account you pay for (yourself, or an org you own) maxed out an allowance or its spending limit this month and is still blocked. This comes from the 100% budget alerts, re-checked against current headroom.
  • You sign in with a password and have no two-factor method yet. This notice is always shown first.