# Billing 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, Events (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_TOPUP_PRICES` | secret | JSON map of packs: `{"": {"price": "price_...", "meter": "", "quantity": n, "label"?: "..."}}` | | `STRIPE_DEV_REPLAY` | dev | `true` turns on the loopback-only replay route described below. | `quantity` is in the meter's own unit: seconds for `compute.*` and tokens for `ai.tokens`. Malformed packs are skipped, and so are packs on meters that can't hold credit. ### Webhook Point a Stripe webhook endpoint at `/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` - `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. | | `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. | | `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. | ### 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. For the meters that can hold credit (priced `compute.*` meters and `ai.tokens`), 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. 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`: ```sh 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 `/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.