| 1 | # Billing operations: costs, margin and prices |
| 2 | |
| 3 | How g1t checks what it charges against what Cloudflare charges it, keeps |
| 4 | prices at cost plus 20%, and tells staff when the margin slips. Internal. |
| 5 | Code: `services/billing/src/costs.rs` (reading the bill), `margin.rs` |
| 6 | (reconciliation, drift, alerts), `pricing.rs` (versions, proposals, |
| 7 | notice), `keeper.rs` (sandbox and Workers for Platforms measurements), |
| 8 | `budget.rs` (what g1t pays for itself, and its caps; see |
| 9 | [Spend caps](#spend-caps)). |
| 10 | Page: sudo **Costs & margin** (`/costs`). |
| 11 | |
| 12 | Several Cloudflare products g1t runs on are new. Artifacts bills |
| 13 | "operations" from 2026-10-14 without defining them (see |
| 14 | [ARTIFACTS.md](ARTIFACTS.md), M1). So nothing here hard-codes a product |
| 15 | list or a unit: every line Cloudflare bills is kept, and how a line maps |
| 16 | to what g1t sells is data you change from sudo, without a deploy. |
| 17 | |
| 18 | ## Data sources |
| 19 | |
| 20 | | Source | What | Where it lands | |
| 21 | | --- | --- | --- | |
| 22 | | Billable usage, `GET /accounts/{account}/billable-usage?from=&to=` | One row per service per day in FOCUS columns: `ServiceFamilyName`, `ServiceName`, `ChargePeriodStart`, `PricingQuantity`, `ContractedCost` / `BilledCost` / `ListCost`. Every product g1t uses appears once it is used: Workers, Workers for Platforms, D1, KV, R2, Queues, Containers, Durable Objects, Artifacts, Browser Rendering, Workers AI, Vectorize, Cloudflare for SaaS, Email. Inside an included amount the cost is 0. | `cost_lines`, source `billable_usage` | |
| 23 | | GraphQL `artifactsEventsAdaptiveGroups` | Artifacts' own count by `date`, `eventType` and `repositoryName`. Operations are `create`, `fork`, `push`, `pull`, `delete`; errors (`rateLimited`, `serverError`, …) are kept but not counted. | `cost_lines`, source `artifacts_events`; per workspace (from the store key `<workspace>--<repo>`; a pull request's working copy, `pulls--<id>`, is its repository's workspace's, from repos' `pull_owners`) in `own_counts` as `cloudflare_git` | |
| 24 | | GraphQL `aiGatewayRequestsAdaptiveGroups`, filtered to `AI_GATEWAY_ID` | What AI Gateway priced g1t's own provider traffic at, by `date`, `provider`, `model` and `wholesale`: `count`, `sum.cost` (dollars), `sum.tokensIn`/`tokensOut`/`cacheReadTokens`/`cacheWriteTokens`. Field names checked against Cloudflare's schema (introspection of `AccountAiGatewayRequestsAdaptiveGroups{Sum,Dimensions,Filter_InputObject}`). An adaptive (sampled) dataset: an estimate, close at g1t's volumes. Only g1t's hosted models go through this gateway: a workspace's own provider is called at its own address, never here. | `cost_lines`, source `ai_gateway`, product `ai_gateway_requests`: per day and model a line `<provider>_<model>` (requests, at the gateway's cost), and at no cost `…__tokens`, `…__cache_read_tokens`, `…__cache_write_tokens`; Cloudflare-billed (unified billing) requests are prefixed `wholesale__`. Mapped to `models` (migration 0036). A re-read day replaces all its gateway lines. | |
| 25 | | The ledger | Every charge: its cost at the price book's cost, what it was charged at price, what paid for it. | read, never written | |
| 26 | | `pending_usage` | Month-end meters (git, storage, scans, embeddings, the cache) as they stand. | snapshotted daily into `pending_days` | |
| 27 | | `plan_payments` | The plan's $20. | read | |
| 28 | | repos `git_operations` | Operations customers are charged for, per workspace, counted by repos through its `operation_mapping`. | `own_counts` meter `git_operations` | |
| 29 | | Subscriptions, `GET /accounts/{account}/subscriptions` | What g1t pays each month whatever it uses (Workers Paid, add-ons): each subscription that is paid, trialing or awaiting payment, at its price over its frequency. Not on the billable-usage bill. Read in the daily run with the bill's token; a failure is logged and the last read stays. | `cf_subscriptions` (one row) | |
| 30 | | repos `artifacts_usage` | Every raw meter of the git store (`git.fetch`, `git.receive_pack`, `binding.*`, …) per day and workspace, with repos' `operation_mapping`. | `own_counts` meters `artifacts_<raw meter>`, and `cost_operations` (raw counts × the mapping's `cost_operations`: what g1t expects Cloudflare to bill) | |
| 31 | |
| 32 | A meter's slug is Cloudflare's name lower-cased with words joined by `_` |
| 33 | and the "(First … included)" note dropped: `Workers for Platforms CPU ms |
| 34 | (First 60M ms are included)` under `Workers` is product `workers`, meter |
| 35 | `workers_for_platforms_cpu_ms`. Several rows of the same day and meter |
| 36 | (regions, tiers) are added together before they are stored. |
| 37 | |
| 38 | ## Credentials |
| 39 | |
| 40 | | Secret on g1t-billing | Permissions | Used for | |
| 41 | | --- | --- | --- | |
| 42 | | `CLOUDFLARE_BILLING_TOKEN` (optional) | Account: **Billing Read**, Account: **Account Analytics Read**, for the g1t account only | Reading the bill, the Artifacts events and the subscriptions | |
| 43 | | `CLOUDFLARE_USAGE_TOKEN` (exists) | Billing Read, Account Analytics Read, AI Gateway Read | The keeper (settling runs from the gateway's logs); also the bill when `CLOUDFLARE_BILLING_TOKEN` is not set. AI Gateway's analytics are read with this token first and, if that is refused, with the bill's: Cloudflare answers a token without AI Gateway Read with no rows rather than an error, so the bill's token would read as a gateway that priced nothing | |
| 44 | |
| 45 | With neither, the daily run reconciles only what g1t counted itself, and |
| 46 | the page says the bill cannot be read. Nothing fails. To set the scoped one: |
| 47 | |
| 48 | 1. Cloudflare dashboard → My Profile → API Tokens → Create Token → Custom token. |
| 49 | 2. Permissions: Account · Billing · Read; Account · Account Analytics · Read. |
| 50 | 3. Account resources: Include · the g1t account. No zone permissions. |
| 51 | 4. `cd services/billing && npx wrangler secret put CLOUDFLARE_BILLING_TOKEN`. |
| 52 | 5. In sudo, Costs & margin → **Run the analysis now**. |
| 53 | |
| 54 | Alerts are emailed through the `EMAIL` binding (Cloudflare Email Sending) |
| 55 | to `COSTS_ALERT_EMAIL` (`hey@flagon.io`). An empty value sends none. |
| 56 | |
| 57 | ## Schedule |
| 58 | |
| 59 | The daily cron (`17 4 * * *`, `keeper::DAILY`) runs, in order: |
| 60 | |
| 61 | 1. The keeper's measurements (sandbox seconds, app requests and CPU), each |
| 62 | a proposal now, not a direct change. |
| 63 | 2. `costs_daily`: |
| 64 | 1. Read the bill, the Artifacts events and AI Gateway's analytics. The first run reads the last |
| 65 | 31 days (GraphQL keeps 31); later runs the last 4, since Cloudflare |
| 66 | restates recent days, or back to the last day read after a gap. |
| 67 | Lines are upserted on `(day, source, product, meter)`, so a re-read |
| 68 | replaces, never adds. |
| 69 | 2. g1t's own counts for the same days (replaced per day). |
| 70 | 3. Snapshot `pending_usage` into `pending_days`. |
| 71 | 4. Reconcile the last 31 days (further back after a gap) into |
| 72 | `margin_days` and `workspace_costs` |
| 73 | (replaced per day). |
| 74 | 5. Drift over the last 7 days into `cost_drift`. |
| 75 | 6. Unit costs over the last 30 days, proposed to the price book. |
| 76 | 7. Apply price versions whose date has come. |
| 77 | 8. Open, update and close margin alerts; email new ones. |
| 78 | 9. Email owners on the plan about rises to come. |
| 79 | |
| 80 | **Run the analysis now** at the top of sudo's Costs & margin and Bill & pricing pages |
| 81 | (`admin_run_costs`) runs all of step 2 at once, alerts included, with no |
| 82 | need to wait for 04:17 UTC. Running it twice is safe: every step replaces |
| 83 | what it wrote. |
| 84 | |
| 85 | ## Reconciliation math |
| 86 | |
| 87 | Every Cloudflare line goes to one of g1t's products ("buckets") by |
| 88 | `cost_map`: the row for its product with the longest matching meter |
| 89 | prefix, `*` last. A line no row claims goes to `unmapped`. |
| 90 | |
| 91 | | Bucket | Cloudflare | Paid for by (`revenue_map`) | |
| 92 | | --- | --- | --- | |
| 93 | | `sandboxes` | Containers, Durable Objects compute duration | `sandbox`, `self_hosted`, `builds` | |
| 94 | | `deployments` | Workers for Platforms | `deployments` | |
| 95 | | `git` | Artifacts operations (and its events, as counts) | `git` | |
| 96 | | `repo_storage` | Artifacts storage | `storage` | |
| 97 | | `actions_cache` | R2 | `cache` | |
| 98 | | `embeddings` | Workers AI, Vectorize | `context` | |
| 99 | | `security` | (Workers CPU, under `platform`) | `security` | |
| 100 | | `domains` | Cloudflare for SaaS | `domains` | |
| 101 | | `models` | not Cloudflare: AI Gateway's settled cost on the ledger; AI Gateway's own daily total (`ai_gateway_requests`) beside it, to check it | every other task (agent runs) | |
| 102 | | `platform` | Workers, D1, KV, Queues, Email, Browser Rendering, other Durable Objects | the plan's price | |
| 103 | |
| 104 | For each day and bucket: |
| 105 | |
| 106 | - **Cloudflare cost** = Σ the bucket's lines' cost, as billed: after the |
| 107 | included allowances, so a month inside them costs $0 here as on |
| 108 | Cloudflare's Billable usage page. `models` uses the ledger's cost of the |
| 109 | tokens instead; that is paid to the model providers and is not on |
| 110 | Cloudflare's bill. Its "Cloudflare" column is what AI Gateway priced the |
| 111 | same traffic at, which drift compares with the ledger (below); it is |
| 112 | never added to the cost. |
| 113 | - **Own cost** = Σ the ledger's `cost_micros` for the bucket's keys (the |
| 114 | price book's cost when charged), plus month-end deltas. A workspace's own |
| 115 | model provider is no cost to g1t. |
| 116 | - **Value** = what customers were charged at price: `-amount_micros` plus |
| 117 | what the plan's included usage, a trial, the open-source pool or g1t paid. |
| 118 | g1t's own (comped) workspaces are valued at cost plus the margin. |
| 119 | - **Cash** = what workspaces paid: `-amount_micros`, and the plan's price. |
| 120 | Never tax or card fees: a payment credits the balance, and |
| 121 | `plan_payments`, without them (see [Tax and the card fee](#tax-and-the-card-fee)). |
| 122 | - **Given away** = the part of the cost that went on usage g1t paid for |
| 123 | itself on purpose, by why: |
| 124 | - **comped**: all of a comped workspace's cost, every bucket; |
| 125 | - **free use**: a free period's usage, the overruns g1t covered |
| 126 | (`ledger.given_micros`), and all of a workspace's cost on a day it had |
| 127 | nothing priced (free allowances); |
| 128 | - **trial** and **open-source pool**: what `trial_micros` and |
| 129 | `oss_micros` paid; |
| 130 | - **discount**: what a discount on an account's custom terms took below |
| 131 | cost plus the margin (`ledger.discount_micros`, see |
| 132 | [Margin floor](#margin-floor)). The usage is valued at its price, so a |
| 133 | discounted sale never reads as margin lost; |
| 134 | - **promotional credit** and **goodwill credit**: what credit staff gave |
| 135 | paid for, when it is spent (`given_credit_promotional_micros`, |
| 136 | `given_credit_goodwill_micros`, migration 0038). That usage's charge is |
| 137 | taken out of cash, so it is never money in. A refund is not here: see |
| 138 | [Credits from g1t](#credits-from-g1t); |
| 139 | - **testing resets**: what a workspace's usage cost g1t before staff |
| 140 | reset its billing (`reset_costs`, `given_reset_micros`, migration |
| 141 | 0046). The model calls and Cloudflare usage still happened, so the |
| 142 | reconciliation reads the kept rows back as that workspace's usage on |
| 143 | their days: valued as before, no cash, all of it given. See |
| 144 | [Resetting a test workspace](#resetting-a-test-workspace). |
| 145 | |
| 146 | Otherwise a workspace's day is split by those shares of its value at |
| 147 | price, and the same shares of each of its buckets' cost are given, its |
| 148 | part of running g1t included. The Team plan's included usage is sold: |
| 149 | the plan's price paid for it. Stored on `margin_days` (`given_micros` |
| 150 | and `given_<why>_micros`, `given_discount_micros` from migration 0036) |
| 151 | and `workspace_costs` (`given_micros`). Sudo's Costs & margin page lists |
| 152 | each why by name, testing resets included (`givenResetMicros`). |
| 153 | - **Month-end meters**: a day's figure is that day's `pending_days` |
| 154 | snapshot less the day before's, within a month. Their month-end ledger |
| 155 | entries are left out, so nothing is counted twice. |
| 156 | - **Product margin** = (value − cost) / value. |
| 157 | - **Sudo's statement** keeps apart: |
| 158 | - **Usage sold**: cash for usage against the cost of the usage buckets |
| 159 | less what was given. Its margin is the headline; at cost plus 20% it |
| 160 | sits near 16.7%. |
| 161 | - **Running g1t**: the plan's price against `platform` less its given |
| 162 | share. |
| 163 | - **Cloudflare subscriptions**: what Cloudflare lists, a month, over |
| 164 | the range (`cf_subscriptions`); until a read has worked, |
| 165 | `CLOUDFLARE_FIXED_MONTHLY_MICROS`, an estimate. |
| 166 | - **Not mapped**: billed, charged for by nothing. |
| 167 | - **Given away**: by why. A budget, watched under g1t's own spend, never |
| 168 | shown as a loss. |
| 169 | - **All in**: money in against all of it, with the figure without what |
| 170 | was given beside it. **Who g1t paid** splits the cost into Cloudflare |
| 171 | and the model providers. |
| 172 | |
| 173 | The overall alert is (Σ cash − (Σ cost − Σ given)) / Σ cash. |
| 174 | - **Quantities**: where a mapping names an `own_meter`, Cloudflare's |
| 175 | billed quantity of those lines (or, without one, Artifacts' operation |
| 176 | events) against g1t's own count. |
| 177 | |
| 178 | **Shared costs to workspaces.** A bucket's cost is shared in proportion |
| 179 | to, first available: Cloudflare's own per-workspace count |
| 180 | (`cloudflare_<bucket>`, today the Artifacts events by repository), g1t's |
| 181 | own count, what its usage cost (so free use carries its own cost), what |
| 182 | each was charged for it. `platform` |
| 183 | and `unmapped` are shared by each workspace's share of all usage that |
| 184 | day. Shares are whole micros that add up to the bill exactly (largest |
| 185 | remainder). |
| 186 | |
| 187 | ## Drift (last 7 days) |
| 188 | |
| 189 | | Kind | When | What to do | |
| 190 | | --- | --- | --- | |
| 191 | | Count | g1t's count and Cloudflare's differ by more than the mapping's `drift_percent` (10%) | Find out what Cloudflare counts: compare its events with `own_counts` `artifacts_*` and `cost_operations`. If it counts more (binding reads, `ls-refs`), either change repos' `operation_mapping` so customers are charged for what Cloudflare counts, or leave it and let the per-unit cost rise (below). | |
| 192 | | Cost | Cloudflare charged more than `drift_percent` away from the price book's cost of the same usage, with at least `min_daily_cost` | A price is stale: check the proposals. | |
| 193 | | Cost, on `models` | What AI Gateway priced g1t's own provider traffic at over the 7 days, against the ledger's model cost for the same days (billed to g1t: comped, free and trial use included, a workspace's own provider not) plus the model cost testing resets kept for those days (`reset_costs`), more than the `ai_gateway_requests` mapping's `drift_percent` (10%) apart, with at least `min_daily_cost`. A ledger with none of the gateway's cost is drift too, and so is a gateway that priced nothing against a ledger with at least `min_daily_cost` of model cost (no percentage): that is not agreement, it is a token that cannot see AI Gateway, or calls that went around it | The gateway higher: model calls g1t paid for and charged no one: runs not settled yet (they catch up within the hour), runs with no session, a run started without a billing ticket, or something else on g1t's gateway. The ledger higher: runs that reached a provider without the gateway. The detail adds why the gateway's own figure may be off: prompt-cache read and write tokens (the gateway prices them at its rates for cache tokens, which can lag the provider's; check against the provider's invoice), requests Cloudflare billed itself (unified billing: on Cloudflare's bill, not a provider's), and models with no price. A testing reset in the window is named in the detail: one that kept its cost says how much of the ledger's side it is; one from before resets kept their cost (the audit log has it, `reset_costs` does not) says the gateway's figure includes usage the ledger no longer has, so that part is not a leak, and the day it leaves the 7 days; while such a reset is in the window the `models` leak is not raised. Days are UTC by when a request ran (gateway) and when a charge was entered (ledger), so a run across midnight shifts a little between days; the 7-day sum absorbs it. | |
| 194 | | Unpriced | Over the 7 days, a model in AI Gateway's analytics with tokens and $0 cost, or runs settled with `runs.gateway_note` (the gateway could not price all of a run) | The gateway has no price for a model g1t runs: add it in the gateway (custom cost) or route away from it. Until then those runs are charged no less than the sandbox reported (Claude Code's own price table), never $0 silently. | |
| 195 | | Leak | Cost of at least `min_daily_cost` and nothing charged for it (never for `platform`), or a meter in `unmapped` | Map the meter (below), or decide it is overhead (`platform`). | |
| 196 | |
| 197 | ## Prices: versions, proposals, notice |
| 198 | |
| 199 | - `price_versions` holds every price ever, never edited. `prices` is the |
| 200 | version in force. The daily run applies a version once its |
| 201 | `effective_at` has come, and adds the public `price_changes` record. |
| 202 | Ledger entries made from the price book carry `price_version` (the |
| 203 | version ids, comma-separated), so a past statement is always explained |
| 204 | by the prices of its day. |
| 205 | - Proposals come from the keeper (sandbox seconds, app requests and CPU) |
| 206 | and the reconciler (mappings with `scale_to_own`: today git operations). |
| 207 | For git operations: Cloudflare's rate per its own operation (the median |
| 208 | over charged days of cost ÷ quantity) × (Cloudflare's operations ÷ g1t's) |
| 209 | × 1,000. If Cloudflare counts three for each one g1t counts, the per-1,000 |
| 210 | price triples. At least 1,000 of g1t's operations are needed. |
| 211 | - Decision (`pricing::decide`): under 2% is noise; more than 4× either way |
| 212 | is suspect and waits for staff; within `auto_apply_percent` (25%) it is |
| 213 | applied on its own when `auto_apply` is on; anything else waits. |
| 214 | - Notice: a fall applies at once. A rise applies `notice_days` (14) after |
| 215 | the decision, and for a monthly meter (git, storage, cache, domains, |
| 216 | embeddings, scans) at the start of the month after that, so no month |
| 217 | is charged at two prices. Owners of workspaces on the plan are emailed |
| 218 | once per rise (`price_notices`), and the pricing page lists it with |
| 219 | "takes effect". Rises are never retroactive; margin protection is for |
| 220 | new usage once notice has run. |
| 221 | - Staff approve or reject in sudo. A rejection needs a note. |
| 222 | |
| 223 | ## Changing a mapping |
| 224 | |
| 225 | In sudo, Costs & margin → Bill & pricing → **Mappings**: Cloudflare's product and meter |
| 226 | prefix (as **Cloudflare's lines** lists them; `*` for the rest of the |
| 227 | product), g1t's product, and optionally: |
| 228 | |
| 229 | - **Price meter**: the price book meter the line measures. |
| 230 | - **Own meter**: g1t's count of the same units (`own_counts.meter`). |
| 231 | - **Scale to g1t's count**: price one of g1t's units at as many of |
| 232 | Cloudflare's as it took (proposals as above). |
| 233 | - **Drift threshold**. |
| 234 | |
| 235 | It applies from the next run; **Run the analysis now** applies it at once. |
| 236 | Every change is in the audit log (`cost_mapping`). |
| 237 | |
| 238 | ## Which raw meters are operations |
| 239 | |
| 240 | There is one mapping, and the repos service owns it: `operation_mapping` |
| 241 | in g1t-repos' database, one row per raw meter with `cost_operations` (how |
| 242 | many operations Cloudflare bills for it) and `billable_operations` (how |
| 243 | many the customer is charged for). Change it with repos' |
| 244 | `set_operation_mapping` RPC (services only), or |
| 245 | `npx wrangler d1 execute g1t-repos --remote` until sudo has a form. A |
| 246 | change applies to counts from then on, never to what was counted. |
| 247 | Billing keeps no mapping of its own: it reads repos' `git_operations` |
| 248 | (already mapped) for what customers are charged, and `artifacts_usage` |
| 249 | (raw counts with the mapping) for `cost_operations`. Migration 0023 drops |
| 250 | the `billable_units` table 0022 made for this, which was never written. |
| 251 | |
| 252 | **Charged at price** (a product's value) is what each day's usage was paid: |
| 253 | charged to a card or credit, or drawn from the plan's included usage, a |
| 254 | trial, a pool or a gift. Usage nothing paid for, as in a free period, is |
| 255 | valued at price (cost plus the margin), since it was given away at its price |
| 256 | rather than sold for nothing; so are g1t's own workspaces. Runs on a |
| 257 | workspace's own model provider have no cost to g1t. Every daily run |
| 258 | reconciles the whole 31-day window from what is already kept, so a change in |
| 259 | how a day is valued reaches every day sudo shows. |
| 260 | |
| 261 | ## Alerts runbook |
| 262 | |
| 263 | | Alert | Raised when | First steps | |
| 264 | | --- | --- | --- | |
| 265 | | Margin under the floor | A product's value against cost under `margin_floor_percent` (10%) for `alert_days` (3) days running, each with at least `min_daily_cost` | Open the product on Costs & margin. Cost up? Check proposals (approve a rise; it waits out the notice). Value down? A mapping or `revenue_map` may have moved. | |
| 266 | | All of g1t under the floor | The same for money in against the cost of what was sold: every cost less what was given away (comped workspaces, free periods, the trial, the pools), which is a budget watched in budget.rs. While less than $1 a day comes in, it says the dollars, not a percentage | Look at which products moved; check `platform` (it has no revenue of its own and grows with traffic). Before launch, with little paid usage, expect it. | |
| 267 | | Leak | Drift of kind leak | Map the meter, or decide it is overhead. | |
| 268 | | Drift | Count drift | See Drift above. Cloudflare's definitions change in beta: ask them in writing ([ARTIFACTS.md](ARTIFACTS.md), §7). | |
| 269 | | Costs more than it pays | A workspace's shared cost over 30 days above what its usage was priced at (`value_micros`, whoever paid: card, trial, gift or included usage) × `anomaly_factor`, at least `anomaly_floor`; not comped workspaces | Shown on Reach out as "Costs more than it pays": its usage is priced below what it costs. Abuse (Abuse & fraud page) or a gap in pricing. Not emailed. A trial or gift paying for usage does not raise it. | |
| 270 | |
| 271 | Alerts close on their own when the condition clears. Open ones are |
| 272 | emailed again weekly. The red bar on every sudo page shows margin, |
| 273 | overall and leak alerts. |
| 274 | |
| 275 | ## Model costs |
| 276 | |
| 277 | Every model call g1t pays for is an agent run's (the `claude` CLI in the |
| 278 | sandbox, `crates/runner`) or a customer's AI Gateway request (a workspace |
| 279 | token with `models:write` at `models.g1t.sh/anthropic` or |
| 280 | `models.g1t.sh/openai/v1`, `gateway.rs`, to Claude on Anthropic or to open |
| 281 | models on Workers AI through the same AI Gateway); the only other model is |
| 282 | Workers AI's embeddings for g1t's own search, which are on Cloudflare's bill |
| 283 | (`embeddings`). How each reaches the ledger: |
| 284 | |
| 285 | | Call | Who pays | Run and session | Ledger cost | Settled to the gateway | |
| 286 | | --- | --- | --- | --- | --- | |
| 287 | | Agent run through the model proxy (`services/models`) on g1t's hosted models | g1t | `runs` row; session `ms_…` in `cf-aig-metadata` | On finish, the sandbox's figure (Claude Code's `total_cost_usd`, at its own price table, cache tokens included) | Yes, every 15 minutes | |
| 288 | | Agent run straight to the gateway (no `MODELS_URL`) | g1t | `runs` row; session `rs_…` in `cf-aig-metadata` (`services/runner` `gatewaySession`) | As above | Yes | |
| 289 | | Agent run with no gateway (`AI_GATEWAY_ID` empty, self-hosting) | g1t's key | `runs` row, no session | The sandbox's figure | No: nothing to settle against | |
| 290 | | Agent run on a workspace's own provider | The workspace | `runs` row, `billed_to = 'workspace'`, session `ms_…` (the proxy counts its tokens by it) | No model cost (none to g1t); the agent rate on `agent_tokens_own`, line `<run>/agent-own` | No; never on g1t's gateway. Closed by the cron's `settle_own_runs`, which charges tokens counted late | |
| 291 | | AI Gateway request on g1t's key | The workspace, from included usage and AI credit (`gateway_admit` refuses at $0, over the limit, or with no plan) | No run; `gateway_requests` row; session `gw_<token>_<YYYYMMDDHH>` in `cf-aig-metadata` (`task: gateway`) | Per request: its tokens at `gateway_models` (the table, migrations 0045 and 0047: list price per million by kind, five-minute and one-hour cache writes apart, and for a model priced by prompt length such as Claude Haiku 5.5 a `threshold` above which the whole request is at the `over_` prices) plus the `gateway_models` meter's markup (0 in beta); a `usage` line with task `gateway`, reference the request id, quantity 1 | No, not yet: the session tag is there for it. **Cost** drift on `models` compares the gateway's total with these lines too. Requests billed other than by tokens (fast mode, `inference_geo`, fallbacks, server tools, containers) are refused by the proxy | |
| 292 | | AI Gateway request on a workspace's own provider (any model connection whose `gateway_models` takes the model) | The workspace's provider | `gateway_requests` row, `own_key = 1`, `provider` and `connection` | None | No; never on g1t's gateway | |
| 293 | | A sandbox that died before reporting | g1t | as its route | Charged from the gateway when settled | Yes | |
| 294 | | Embeddings (indexing) | g1t | none (Workers AI) | Month-end `context` meter | No: Cloudflare's bill, `embeddings` bucket | |
| 295 | | Embeddings (queries, search and agent context) | g1t | none | None: not charged, by design | No: in Cloudflare's `embeddings` line, shared out | |
| 296 | |
| 297 | **Settling.** A run's charge is corrected to what AI Gateway priced its |
| 298 | session's requests at (`settled_cost` in `keeper.rs`). The gateway's |
| 299 | figure is trusted in full: it is not held to the $100 cap on a sandbox's |
| 300 | own report. It is never taken below what the sandbox reported when it |
| 301 | cannot be the whole cost: a request with tokens and no cost (a model the |
| 302 | gateway has no price for) or more logs than are read (2,000). Such a run |
| 303 | keeps `runs.gateway_note`, its correction says why, and it raises the |
| 304 | **Unpriced** drift. For agent runs g1t keeps no token rates of its own: |
| 305 | the first figure is Claude Code's, the final one the gateway's. AI Gateway |
| 306 | requests are the exception: they are charged from `gateway_models` (one |
| 307 | row per model offered), which has to follow the provider's price list by |
| 308 | hand until they are settled like runs. It is not part of the price book's |
| 309 | `price_versions`: a price change is a migration that updates the row and |
| 310 | its `updated_at` (as 0047 did for Sonnet 5.5's cache reads), and the |
| 311 | `gateway_models` meter's markup is the only price-book number on it. Open |
| 312 | models need `WORKERS_AI_TOKEN` (a Cloudflare API token with Workers AI on |
| 313 | g1t's account) on the model proxy; without it, and without |
| 314 | `AI_GATEWAY_TOKEN` holding that permission, they are refused with `503`. |
| 315 | |
| 316 | **The daily total.** AI Gateway's analytics for the day (above) against |
| 317 | the ledger's model cost is the check that nothing slips past: a model call |
| 318 | with no run, or a run never settled, shows as **Cost** drift on `models`. |
| 319 | The gateway's per-request `cost` is its estimate from its own price list: |
| 320 | it can be off for prompt-cache tokens, for requests Cloudflare bills |
| 321 | itself, and for models it has no price for. The drift's detail says when |
| 322 | any of those were in the window; the provider's invoice is the last word. |
| 323 | |
| 324 | ### Margin floor |
| 325 | |
| 326 | A sold charge is cost × (1 + `MARGIN_PERCENT`), rounded up (`margin_on`; |
| 327 | `charge_micros` for a sandbox's own report). Terms change it only as |
| 328 | follows (`Terms::discounted`, `Billing::charged`): |
| 329 | |
| 330 | - **Standard**: charged in full. |
| 331 | - **A 100% discount** (what was "comped"; see [Discounts](#discounts)), |
| 332 | `FREE_WHILE_BUILDING`, the plan's included usage, the trial, |
| 333 | the open-source pool, and overruns g1t covers: given, and counted by why |
| 334 | (above). |
| 335 | - **Custom, with a discount**: the discount comes off, and what it took |
| 336 | below cost plus the margin is written on the entry as |
| 337 | `ledger.discount_micros` and counted as given (**discount**), so the sale |
| 338 | is valued at its price and charged plus given is never under cost plus the |
| 339 | margin. On a settlement correction it moves with the charge (less than |
| 340 | nothing when the charge comes down). |
| 341 | - **Goodwill credits** (overages) are separate, given by staff on purpose: |
| 342 | their margin part first, the cost only up to the cap, each audited. |
| 343 | |
| 344 | Every usage path goes through this: `finish_run`, settling, sandbox time, |
| 345 | features and builds (`charge_feature`), and the month-end meters. |
| 346 | |
| 347 | ## Discounts |
| 348 | |
| 349 | An account's terms are standard, or custom: a **discount** from 1 to 100%, |
| 350 | a limit of its own, or both, with a reason (the terms' note) and an |
| 351 | optional end date. What used to be "comped" is a **100% discount** |
| 352 | (`Terms::full_discount`; migration `0039_discounts_not_comped.sql` moved |
| 353 | every `comped` row to `custom` at 100%, and code reads a leftover `comped` |
| 354 | row as 100%). In SQL, `sales::FULL_DISCOUNT_SQL`. |
| 355 | |
| 356 | - **Charging.** Every charge records what the discount took off it |
| 357 | (`ledger.discount_micros`), 100% included: the entry is charged nothing |
| 358 | and the discount is its whole price. Migration 0039 backfilled the |
| 359 | discount on a 100%-discounted workspace's earlier entries that were |
| 360 | charged nothing and paid by nothing, at cost plus 20%. |
| 361 | - **What a 100% discount still does as "comped" did.** The plan is on |
| 362 | without its price (`PlanKind::Internal`), trust is `internal` (no limit |
| 363 | on unpaid usage), nothing is invoiced or closed, and g1t's own spend on |
| 364 | it is held to the monthly budget (the terms' limit, at cost; see |
| 365 | [Spend caps](#spend-caps)). |
| 366 | - **The statement and Usage.** The customer sees every usage line at its |
| 367 | price (`StatementLine.price_micros`: charged, plus what paid for it, plus |
| 368 | the discount), the discount per day or project and in the totals |
| 369 | (`StatementTotals.price_micros`, `discount_micros`, `discount_percent`), |
| 370 | and the CSV has price and discount columns. The Usage page measures at |
| 371 | price for a discounted account (`Usage.discount_micros`, |
| 372 | `discount_percent`). |
| 373 | - **Margin.** A 100% discount's usage is given away as before, in the |
| 374 | bucket still named `comped` (`given_comped_micros`); sudo calls it |
| 375 | **100% discounts**. A partial discount's part below cost plus the margin |
| 376 | is `given_discount_micros` (**partial discounts**). Both are kept apart |
| 377 | from margin on what was sold. |
| 378 | - **sudo.** The workspace's **Terms** form takes a discount (None, 25%, |
| 379 | 50%, 100%, or Custom, a whole percent; the custom field shows by CSS |
| 380 | alone), a limit, an end date and the reason. Badges and filters say |
| 381 | *100% discount* or *N% off*. Each change is audited (`terms`), such as |
| 382 | `standard → 100% discount, monthly budget $150.00: g1t's own`. |
| 383 | |
| 384 | ## Credits from g1t |
| 385 | |
| 386 | Staff give a workspace credit from sudo; the code is |
| 387 | `services/billing/src/grants.rs`, the tables `credit_grants` and |
| 388 | `ledger.credit_kind` (migration `0038_staff_credits.sql`). |
| 389 | |
| 390 | ### Giving credit |
| 391 | |
| 392 | sudo → the workspace (or an enterprise, choosing one of its workspaces) → |
| 393 | **Give credit**: |
| 394 | |
| 395 | 1. **Amount**: $10, $20, $25, $50, $100, or **Custom** (up to $10,000). |
| 396 | Up to $100 it is one step; over $100, type the workspace's slug as well. |
| 397 | 2. **Kind**: promotional (a welcome, a referral, an event), goodwill (an |
| 398 | apology), or refund (money back for something that went wrong: say what |
| 399 | it refunds and, optionally, the day). |
| 400 | 3. **Expires**: never, 30, 90 or 365 days, or the end of a chosen day |
| 401 | (UTC). A refund never expires. |
| 402 | 4. **Note**: required. It is on the statement and in the owners' email. |
| 403 | |
| 404 | The form needs no JavaScript: the fields for one choice (the custom amount, |
| 405 | a refund's details, the expiry date) show by CSS alone, and all show where |
| 406 | `:has()` is not supported. `admin_credit` checks everything again. |
| 407 | |
| 408 | A grant is a `crd_…` ledger line (kind `top_up`, so never a payment) with |
| 409 | `credit_kind`, and a `credit_grants` row. The balance rises at once. The |
| 410 | owners are emailed through identity's `notify_owners` (the same path as |
| 411 | limit notices). It is audited as `credit`. The Overages queue's one-click |
| 412 | goodwill credit is a grant too, of kind goodwill. |
| 413 | |
| 414 | The inbox is not told: its items are threads on a repository, built from |
| 415 | events, and a credit is a workspace's. That needs a workspace-level inbox |
| 416 | thread first. |
| 417 | |
| 418 | ### How it is spent |
| 419 | |
| 420 | Credit is spent before anything prepaid, the soonest-expiring grant first |
| 421 | (never-expiring last, then the oldest). Given while the workspace owes, it |
| 422 | pays what is owed first, the most recent usage first. What each grant paid |
| 423 | for is never stored: `grants::replay` works it out from the ledger in order, |
| 424 | so the charge paths do not know about credit and the answer is always what |
| 425 | the ledger says. A charge that comes down (a settled run) gives back to |
| 426 | the grant that paid last, while it can still be spent. |
| 427 | |
| 428 | ### Expiry and revoking |
| 429 | |
| 430 | - **Expiry.** The daily run (`expire_credits`, before the reconciliation) |
| 431 | closes grants past `expires_at` and enters what was left as a negative |
| 432 | `crd…_expired` line; audited as `credit_expired`. A grant past its expiry |
| 433 | pays for nothing even before the run. |
| 434 | - **Revoke.** sudo → the workspace's **Credits** (or **Credits & refunds**) |
| 435 | → **Revoke unused**, with why (`admin_revoke_credit`): what is left, as a |
| 436 | `crd…_revoked` line, audited as `credit_revoked`. What was spent stays |
| 437 | spent. |
| 438 | |
| 439 | Neither takes the balance below zero: at most the balance, if a refunded |
| 440 | payment left less there than the credit. |
| 441 | |
| 442 | ### How margin treats them |
| 443 | |
| 444 | | Kind | When spent | On the day it was given | |
| 445 | | --- | --- | --- | |
| 446 | | Promotional | The usage is valued at its price, its charge comes out of cash and is given (`given_credit_promotional_micros`) | Nothing | |
| 447 | | Goodwill | The same, as `given_credit_goodwill_micros` | Nothing | |
| 448 | | Refund | Paid for: cash, as any usage | Its amount (less what was revoked) comes off cash on the day it refunds, shared over that day's paid usage | |
| 449 | |
| 450 | A refund gives back money already collected, so counting it as given would |
| 451 | make it look like a budget g1t chose to spend. Taking it off cash for the |
| 452 | day it refunds says that day's sale was worth less, and counting what it |
| 453 | later pays for as cash keeps money in equal to what was collected. The |
| 454 | refund's day is clamped to the last 30 days, the days the reconciliation |
| 455 | recomputes; an older one lands on the oldest. Refunds never expire, so |
| 456 | cash taken back is never stranded. |
| 457 | |
| 458 | What credit paid of a month-end meter (storage, git, scans) is its own |
| 459 | row on the day it was charged, since those meters are reconciled from |
| 460 | snapshots. Sudo's Costs & margin lists promotional and goodwill credit |
| 461 | under **Given away**, and below the statement the range's credits given, |
| 462 | spent, and refunded. **Credits & refunds** (`/credits`, `admin_credits`) |
| 463 | lists every grant (by kind, month, staff and workspace) and the last 12 |
| 464 | months by kind: given, spent, expired, revoked. |
| 465 | |
| 466 | ### Purchased and scoped credit (prepaid AI) |
| 467 | |
| 468 | `credit_grants` also has `scope` (`all`, or `models`: model usage only, |
| 469 | `grants::is_model_usage`, which includes the agent rate) and `source` |
| 470 | (`staff`, `purchase`, `promo_code`, `upgrade`), and `CreditKind::Purchased`. |
| 471 | Spending takes credit scoped to models first, then the soonest-expiring. A |
| 472 | grant scoped to models given while the workspace owes pays only what models |
| 473 | owed, never other usage (`replay`). Purchased credit is money paid in: its |
| 474 | ledger line is a payment (Stripe's id, never `crd…`, `credit_kind` |
| 475 | `purchased`, statement kind *AI credit*), and the usage it pays for stays |
| 476 | money in, never given. Staff cannot give it (`admin_credit` refuses the |
| 477 | kind). Code: `services/billing/src/ai.rs`; migration `0040_ai_credit.sql`. |
| 478 | |
| 479 | - **Buying.** `buy_ai_credit` opens Stripe Checkout (payment mode, $10 to |
| 480 | $1,000, a second line *Card processing fee* when the `card_fee` cost |
| 481 | setting is on, `setup_future_usage=off_session`), recorded in `checkouts` |
| 482 | with `feature = 'ai_credit'`, `amount_cents` the credit and `fee_cents` |
| 483 | the fee. The credit is entered by whichever comes first, the person coming |
| 484 | back (`confirm_ai_credit`, `?ai_credit=cs_…`) or |
| 485 | `checkout.session.completed`: both claim the row `open → paid`, the |
| 486 | grant's id is the session's id (`INSERT OR IGNORE`) and the ledger's |
| 487 | reference is unique, so a payment is credited exactly once. Expires 365 |
| 488 | days after purchase (the daily `expire_credits`). |
| 489 | - **Owed.** AI credit props up the balance but is money only for models, so |
| 490 | what is owed is `max(0, AI credit left − balance)` (`owed_with`; at a |
| 491 | month's close, `models_left_before` the month's start). |
| 492 | - **Auto-reload.** `ai_reload` (settings; off by default) and `ai_reloads` |
| 493 | (one row per attempt). Each cron run (and a run that would be refused) |
| 494 | calls `reload_now`: below the threshold, it charges the customer's default |
| 495 | payment method off-session for the target less the balance (whole |
| 496 | dollars, at least $10, within the month's maximum), with the idempotency |
| 497 | key `reload/<workspace>/<YYYY-MM>/<n>` (a retry after a crash is the same |
| 498 | PaymentIntent), and grants purchased credit with the PaymentIntent's id. A |
| 499 | decline or a payment needing the person turns auto-reload off |
| 500 | (`failed_at`, `error`), emails the owners and audits `ai_reload_failed`. |
| 501 | - **At $0.** `start_run` on g1t's models refuses with `payment_required` |
| 502 | when the workspace is on the paid plan (not a 100% discount, not an |
| 503 | enterprise), its included usage is used, and AI credit is $0 or less. |
| 504 | - **Upgrade credit.** The first time a plan subscription is recorded active |
| 505 | (`features::record`), $5 of promotional credit scoped to models, id |
| 506 | `crd_upgrade_<workspace>`, expiring in a year: given, never revenue. Never |
| 507 | for a workspace with a 100% discount. |
| 508 | |
| 509 | ### The agent rate and models' markup |
| 510 | |
| 511 | Price-book meters (migration 0040, each with versions and a public change): |
| 512 | `agent_models` (per provider dollar; markup 20% until 2026-10-08, then 0, |
| 513 | a fall applied at once), `agent_tokens` ($0 until 2026-10-22, then $0.25 a |
| 514 | million tokens: a rise, after the 14 days' notice, emailed to owners on the |
| 515 | plan by `tell_owners_of_rises`), `gateway_models` (markup 0 during beta), |
| 516 | `card_fee_percent` (29,000 micros per dollar) and `card_fee_fixed` |
| 517 | (300,000). Changing any is a price-book change, never a deploy. `finish_run` |
| 518 | and `settle` charge models at `agent_models`' markup; `charge_agent_rate` |
| 519 | charges the weighted tokens of the run's session since it was last charged |
| 520 | (`runs.agent_tokens`, the weighted tokens charged so far, claimed with a |
| 521 | compare-and-set), on a line `<run>/agent` (later `<run>/agent/<tokens>`), |
| 522 | with `quantity` the weighted tokens. What it counts is the more of what |
| 523 | `token_usage` holds for the session and what the sandbox reported with its |
| 524 | cost (`finish_run`'s `tokens`, from Claude Code's closing `usage`), each |
| 525 | weighted by kind. **Card fee switch:** sudo → Costs → Guardrails → *Card fee |
| 526 | on card payments* (`cost_settings.card_fee`, `on`/`off`, on by default). It |
| 527 | covers every card payment now, not only AI credit: see |
| 528 | [Tax and the card fee](#tax-and-the-card-fee). |
| 529 | |
| 530 | **On a workspace's own model key** (migration `0041_agent_rate_own_key.sql`): |
| 531 | the run keeps its model session (`runs.session_id`, `ms_…`) so the proxy's |
| 532 | counts reach it, and the agent rate is charged at `agent_tokens_own` ($0 until |
| 533 | 2026-10-22, then $0.25 a million, a rise from nothing with its notice), on |
| 534 | `<run>/agent-own` (later `<run>/agent-own/<tokens>`), `billed_to = 'g1t'` |
| 535 | (g1t's own charge: it counts toward limits and spend), named *Agent rate, |
| 536 | your own model key* on Usage and the statement. The model is never charged. |
| 537 | `settle_runs` skips these runs (nothing on g1t's gateway); the cron's |
| 538 | `settle_own_runs` closes them 5 minutes after they finish (3 hours after |
| 539 | they start, for a sandbox that never reported) and charges tokens counted |
| 540 | late. Runs from before have no session and are never charged the rate. |
| 541 | |
| 542 | ### The agent rate's token weights |
| 543 | |
| 544 | How much each kind of token counts toward the agent rate, on g1t's models |
| 545 | and own keys alike, is four price-book meters (migration |
| 546 | `0042_agent_rate_weights.sql`): `agent_token_weight_input`, `_output`, |
| 547 | `_cache_read` and `_cache_write`, each a weight in millionths in |
| 548 | `cost_micros` (1,000,000 counts a token once). They started at 1, which is |
| 549 | what the rate always counted; since 2026-10-08 cache reads count a tenth |
| 550 | (100,000; migration `0044_cache_reads_count_a_tenth.sql`), as model providers |
| 551 | price them. Input, output and cache writes count once. A cached agent run reads most of its context from |
| 552 | cache (about 90% of its tokens on a typical Sonnet implement run), so the |
| 553 | cache-read weight is the lever: at 1 the rate adds about 44% to such a run's |
| 554 | model cost; at 0.1, far less. |
| 555 | |
| 556 | To change a weight (cache reads went to a tenth this way): |
| 557 | |
| 558 | 1. Add the version, effective at once (a lower weight is a fall): |
| 559 | |
| 560 | ```sql |
| 561 | INSERT INTO price_versions (id, meter, version, cost_micros, markup_percent, effective_at, reason, created_by, created_at) |
| 562 | VALUES ('pv_agent_token_weight_cache_read_2', 'agent_token_weight_cache_read', 2, 100000, 0, |
| 563 | '2026-10-08T00:00:00Z', 'Cache reads count a tenth toward the agent rate', 'staff', '2026-10-08T00:00:00Z'); |
| 564 | ``` |
| 565 | |
| 566 | in a migration, or with `npx wrangler d1 execute g1t-billing --remote` |
| 567 | until sudo has a form. |
| 568 | 2. The daily run applies it once `effective_at` has come and writes the |
| 569 | public `price_changes` record (**Run the analysis now** applies it at |
| 570 | once). Raising a weight later is a rise: give it an `effective_at` 14 |
| 571 | days out, and owners on the plan are emailed. |
| 572 | 3. Check `/pricing`: the agent rate's row lists the weights, and Usage's |
| 573 | agent-rate lines name them. |
| 574 | |
| 575 | `weighted` (`ai.rs`) rounds down to a whole token; a weight is never below 0. |
| 576 | |
| 577 | ### Budgets |
| 578 | |
| 579 | The owners' spend limit is the budget. `limits.alert_levels` (comma |
| 580 | separated, default every level), `limits.pause_at_limit` (default 1; off, |
| 581 | 100% is a warning, never a stop; the trust ceiling still stops work) and |
| 582 | `limits.budget_webhook` (an https address, not g1t's; posted once per alert |
| 583 | level a month by `warn_limits`). `set_budget` sets them, with `keepLimit` |
| 584 | to leave the limit itself alone. A free workspace (trust `new`) has no |
| 585 | spend limit to set: `set_spend_limit` and `set_budget` say so. |
| 586 | |
| 587 | ### Earlier credits |
| 588 | |
| 589 | Migration 0038 makes every earlier `Credit from g1t:` line a goodwill |
| 590 | grant with no expiry: the old form asked for "a refund or goodwill" with |
| 591 | no way to tell them apart, and goodwill never reads as money in. |
| 592 | |
| 593 | ## Token usage |
| 594 | |
| 595 | The model proxy (`services/models`) reads Anthropic's `usage` from every |
| 596 | `/v1/messages` answer, streamed or whole, on g1t's models and on a |
| 597 | workspace's own provider alike (OpenAI-shaped providers are translated |
| 598 | first). Count-tokens requests are not answers and are skipped. After the |
| 599 | answer, it calls `record_tokens`, which adds input, output, cache reads and |
| 600 | cache writes to one row per day, workspace, person, session and model in |
| 601 | `token_usage` (migration `0030_token_usage.sql`). The person is who the run |
| 602 | was for, from the model session's `requested_by`; never g1t's agent. A |
| 603 | report that fails is dropped and never affects the answer. |
| 604 | |
| 605 | `token_usage` reads a window (42 days by default, 366 at most) for the |
| 606 | workspace or one person: totals, every day's tokens and the active days, |
| 607 | with `costMicros` the window's run charges from the ledger, measured as |
| 608 | `usage` measures them. Usage's report also lists tokens by model |
| 609 | (`UsageReport.models`). The agent rate is charged on these counts (above); |
| 610 | a run's model is still priced from AI Gateway's logs, never from them. |
| 611 | |
| 612 | ## Tables (migration `0022_costs_and_margin.sql`) |
| 613 | |
| 614 | `cost_lines`, `cost_map`, `revenue_map`, `own_counts`, |
| 615 | `pending_days`, `margin_days`, `workspace_costs`, `cost_drift`, |
| 616 | `margin_alerts`, `price_versions` (seeded with every current price as |
| 617 | version 1), `price_proposals`, `price_notices`, `cost_settings` (the |
| 618 | guardrails, seeded), the `actions_cache` price, and `ledger.price_version`. |
| 619 | Every create is `IF NOT EXISTS` and every seed `INSERT OR IGNORE`; the one |
| 620 | `ALTER` is applied once by D1's migration tracking. Migration |
| 621 | `0023_one_operation_mapping.sql` drops `billable_units` (see above). |
| 622 | Migration `0036_model_costs_in_full.sql` adds `ledger.discount_micros`, |
| 623 | `margin_days.given_discount_micros`, `runs.gateway_note` and the |
| 624 | `ai_gateway_requests` → `models` mapping. Migration |
| 625 | `0038_staff_credits.sql` adds `credit_grants`, `ledger.credit_kind` and |
| 626 | `margin_days.given_credit_{promotional,goodwill}_micros`, and backfills |
| 627 | earlier credits (see [Credits from g1t](#credits-from-g1t)). Migration |
| 628 | `0046_reset_costs.sql` adds `reset_costs` and `margin_days.given_reset_micros` |
| 629 | (see [Resetting a test workspace](#resetting-a-test-workspace)). |
| 630 | |
| 631 | ## Spend caps |
| 632 | |
| 633 | Two caps keep what g1t pays for itself bounded while billing takes no |
| 634 | real money. Both are measured at **cost** (what Cloudflare and the model |
| 635 | providers charge g1t), never at price. Code: `services/billing/src/budget.rs`. |
| 636 | Page: sudo **Costs & margin** → **g1t's own spend** (`/costs#spend`). |
| 637 | |
| 638 | ### What counts as g1t's own spend |
| 639 | |
| 640 | Every charge that settles (an agent run's model cost from `finish_run` or |
| 641 | AI Gateway's settlement, sandbox time from `record_sandbox`, a build from |
| 642 | `charge_feature`) is split by what paid for it and g1t's part is added to |
| 643 | `g1t_spend` (day, bucket, billing account): |
| 644 | |
| 645 | | Bucket | What | |
| 646 | | --- | --- | |
| 647 | | `comped` | All of a comped account's work (flagon-io) | |
| 648 | | `trial` | The trial credit's share | |
| 649 | | `oss` | The open-source pool's share | |
| 650 | | `given` | A free workspace's overrun past its last bit of trial | |
| 651 | | `unpaid` | Charged, but with no real money behind it: Stripe's test key, or `FREE_WHILE_BUILDING` | |
| 652 | |
| 653 | The plan's included usage and on-demand charges count as revenue only |
| 654 | with live payments; in test mode they are `unpaid`. A workspace's own |
| 655 | model provider costs g1t nothing and is not counted. Month-end meters |
| 656 | (git, storage, scans, embeddings, the cache) are not counted here; the |
| 657 | daily reconciliation covers them. Migration `0024_spend_caps.sql` |
| 658 | backfills the current month from the ledger. |
| 659 | |
| 660 | ### Caps |
| 661 | |
| 662 | | Cap | Variable (g1t-billing) | Default | At the cap | |
| 663 | | --- | --- | --- | --- | |
| 664 | | A comped account's monthly budget | `COMPED_MONTHLY_CEILING_MICROS`, or the account's own **Limit** in its terms | $150 a month | New work on the account (agents, checks, workflows, builds) is refused with "<name>'s monthly budget for g1t's own agents is used up … Staff can raise it in sudo". Runs already going finish; the per-run cap still applies to them. It lifts when staff raise the budget or the month turns (UTC). | |
| 665 | | The daily breaker | `PLATFORM_DAILY_SPEND_CAP_MICROS` | $75 a day (UTC) | New agent runs on g1t's hosted models that g1t would pay for are refused until 00:00 UTC. Not paused: agents on the workspace's own model provider, checks and builds, and workspaces paying with live payments on the plan (not given by staff) or an enterprise contract. In test mode that exemption covers no one. | |
| 666 | |
| 667 | `0` turns either off. Cloudflare's subscriptions are read from Cloudflare |
| 668 | each day (`cf_subscriptions`) and shown on the page only; |
| 669 | `CLOUDFLARE_FIXED_MONTHLY_MICROS` ($30) stands in until a read works. |
| 670 | |
| 671 | The checks are cheap: `reserve` reads today's total (one indexed sum) and, |
| 672 | for a comped account, its month's comped rows. Refusals come back as |
| 673 | `paused`, which the compute gate honours for every plan, internal and |
| 674 | enterprise included (`packages/contracts/src/compute.ts`). The runner tells |
| 675 | billing whether an agent run is on hosted models (`hostedModel` on |
| 676 | `reserve`); a caller that does not say is treated as hosted. |
| 677 | |
| 678 | ### Alerts |
| 679 | |
| 680 | All to `COSTS_ALERT_EMAIL` (`hey@flagon.io`), through the `EMAIL` binding: |
| 681 | |
| 682 | - **Comped budget**: at 50, 75, 90 and 100%, once each per account and month |
| 683 | (`budget_alerts`), checked every 15 minutes. A jump past several levels |
| 684 | sends only the highest. |
| 685 | - **Breaker**: at once, from the charge that trips it; if that email fails, |
| 686 | the 15-minute cron sends it (`spend_breaker.told_at`). |
| 687 | |
| 688 | While the breaker is open or a comped budget is used up, every sudo page |
| 689 | shows a red **Spend cap** bar. |
| 690 | |
| 691 | ### Raising and lifting |
| 692 | |
| 693 | - **Raise a comped budget**: sudo → the workspace → Billing → **Terms**, set |
| 694 | **Limit $** to the new monthly budget (blank goes back to the default), |
| 695 | with a note. It applies to the next start; nothing to deploy. The change |
| 696 | is in the account's audit log. |
| 697 | - **Lift the breaker for today**: sudo → Costs & margin → **g1t's own |
| 698 | spend** → **Lift for today**, with why (`admin_lift_breaker`; audit action |
| 699 | `breaker_lifted`). It resets by itself at 00:00 UTC. |
| 700 | - **Change a default**: edit the variable in `services/billing/wrangler.jsonc` |
| 701 | and deploy g1t-billing. |
| 702 | |
| 703 | ## Resetting a test workspace |
| 704 | |
| 705 | sudo → the workspace → **Reset billing (testing)** (`admin_reset_billing`) |
| 706 | returns a workspace used for testing to how a new customer starts. It |
| 707 | deletes the workspace's rows from every billing table: ledger and balance, |
| 708 | plan and plan payments, limits and limit requests, trial grant, invoices, |
| 709 | holds, card checks, alerts sent, price notices, month-end snapshots and |
| 710 | closes, storage meters, token usage, spikes, sales records and |
| 711 | notes, `workspace_costs`, its workspace margin alert and its own billing |
| 712 | account. It keeps `own_counts` (what Cloudflare's bill is compared with) |
| 713 | and the audit log, which records the reset with the note, the number of |
| 714 | rows and what g1t had paid for. The workspace, its members and its |
| 715 | repositories are identity's and repos' and stay. |
| 716 | |
| 717 | **What g1t paid for is kept.** The wiped usage still happened: AI Gateway |
| 718 | still prices its model calls and Cloudflare still bills its sandboxes. So, |
| 719 | in the same batch as the deletes, the reset writes `reset_costs`: a row per |
| 720 | day and bucket the workspace had cost on (the ledger's cost, month-end |
| 721 | meters' cost, and the value the reconciliation gave it), plus one row for |
| 722 | the reset itself (bucket `''`, nothing in it) so every reset is on record. |
| 723 | `reset_at` is the same instant as the reset's `admin_actions` entry. The |
| 724 | costs run reads the rows back as the workspace's usage on their days, all |
| 725 | of it given away as **testing resets**: `models` drift compares AI |
| 726 | Gateway with the ledger's model cost plus what resets kept, the statement |
| 727 | lists it under **Given away**, and the workspace stays in **Who g1t paid** |
| 728 | with its cost given. The rows are never wiped by a later reset, and a |
| 729 | rename moves them. Re-running the analysis reads the same rows and gives |
| 730 | the same answer. |
| 731 | |
| 732 | Resets before migration 0046 kept nothing; their wiped rows are gone and |
| 733 | nothing is made up for them. The costs run finds them in the audit log |
| 734 | (`admin_actions`, action `reset`) with no `reset_costs` at the same |
| 735 | instant, and the `models` drift detail says AI Gateway's figure includes |
| 736 | usage wiped by a testing reset of that workspace on that day, rather than |
| 737 | calling it a leak. syntaqx was reset on 2026-10-07 after about $8.60 of |
| 738 | model usage from 2026-10-02 to 2026-10-07; that usage is in the 7-day |
| 739 | window until the run of 2026-10-13 and leaves it on 2026-10-14. |
| 740 | |
| 741 | Billing refuses it while `STRIPE_SECRET_KEY` is a live key, for comped |
| 742 | workspaces, and for a workspace an enterprise pays for. It then runs the |
| 743 | costs analysis again (as **Run the analysis now** does), so the margin |
| 744 | figures drop the workspace's past usage at once; if that run does not |
| 745 | finish, the page says so and the button does it. |
| 746 | |
| 747 | ## Stripe |
| 748 | |
| 749 | Billing keeps what it needs from Stripe so reads never wait on it, and |
| 750 | hears of changes three ways (`webhooks.rs`, `stripe_sync.rs`). |
| 751 | |
| 752 | **API version.** Every request sends `Stripe-Version: 2025-02-24.acacia` |
| 753 | (`stripe::STRIPE_VERSION`), the version billing's field reads are written |
| 754 | for; without it Stripe answers at the account's default. Webhook events |
| 755 | come at the destination's own version: billing reads an invoice's |
| 756 | subscription from `subscription` or `parent.subscription_details.subscription`. |
| 757 | Raising the version is a code change: read Stripe's upgrade notes for every |
| 758 | field billing reads. |
| 759 | |
| 760 | **Failures.** No Stripe failure reaches a page as a 500: each payment page |
| 761 | (`page_opened`), the portal, confirmations and plan changes turn it into |
| 762 | `stripe::friendly` (Stripe's own message, never the request or a key), and |
| 763 | log the full error with the workspace. Every payment page is recorded |
| 764 | through one insert (`CHECKOUT_INSERT`), checked against the migrations by |
| 765 | `every_checkout_insert_fills_the_table`, and has an idempotency key |
| 766 | (`page/<purpose>/<workspace>/…/<10-minute bucket>`). |
| 767 | |
| 768 | **The webhook.** A destination made in Stripe's dashboard (Developers → |
| 769 | Webhooks → Add destination) with the endpoint URL |
| 770 | `https://api.g1t.sh/stripe/webhook`, in the mode of billing's key (at |
| 771 | launch, make one in live mode and put its secret). Its signing secret |
| 772 | (`whsec_…`: the destination, Signing secret, Reveal) is the billing |
| 773 | Worker's secret: |
| 774 | |
| 775 | ```sh |
| 776 | cd services/billing && npx wrangler secret put STRIPE_WEBHOOK_SECRET |
| 777 | ``` |
| 778 | |
| 779 | Without it every event is refused with 400. After rolling the secret in |
| 780 | Stripe, put the new one; during the roll Stripe signs with both, so there |
| 781 | is no gap. The event list need not be exact: billing adds any event it |
| 782 | handles that the destination does not send (daily, or **Fix destination** |
| 783 | in sudo → Stripe), and enables it again if Stripe disabled it. It never |
| 784 | changes the secret. sudo → Stripe shows whether the secret is set, the |
| 785 | destination and its status, missing events, and the latest events. |
| 786 | Events are claimed once each in `stripe_events`; a handler that fails |
| 787 | forgets its claim, and Stripe retries. |
| 788 | |
| 789 | **What is kept, and how it stays current** |
| 790 | |
| 791 | | Kept | Where | Refreshed by | |
| 792 | | --- | --- | --- | |
| 793 | | Saved card (brand, last 4, expiry) | `accounts.card_*`, `card_synced_at` | `customer.updated`, `payment_method.*`, `setup_intent.succeeded`; forgotten when g1t sets a default card, so the next read asks once; the daily pass for cards older than 7 days | |
| 794 | | Plans | `subscriptions` | `customer.subscription.*`, `invoice.*`; a read asks Stripe at most hourly once a period is over; the daily pass for rows older than a day | |
| 795 | | Payments, refunds, disputes | ledger, `checkouts`, invoices | their events | |
| 796 | |
| 797 | **Every cron run (every 15 minutes)** replays missed events: Stripe's event |
| 798 | list from an hour before `stripe_sync.through`, oldest first, through the |
| 799 | same once-only claim. `through` moves to 5 minutes before now when all were |
| 800 | handled, back to the first failure otherwise, and stays when more than |
| 801 | 1,000 events were listed. Claims stuck at `handling` for 10 minutes are |
| 802 | dropped so the replay retries them. The first run reads 3 days back. |
| 803 | |
| 804 | **Daily** (`keeper::DAILY`): the destination at billing's address is |
| 805 | enabled again if Stripe disabled it and given any missing event, audited as |
| 806 | `stripe`/`webhook`; then up to 25 stale cards and 25 stale plans are read |
| 807 | again. |
| 808 | |
| 809 | **What still calls Stripe on a request**: starting a payment page, a plan |
| 810 | or a card check; opening the billing portal; settling a page the person |
| 811 | came back from; renaming a workspace (the customer's name). Nothing a page |
| 812 | view reads. |
| 813 | |
| 814 | ## Tax and the card fee |
| 815 | |
| 816 | Owner decision 2026-10-08. Code: `services/billing/src/tax.rs` (what is |
| 817 | kept, the address hold), `stripe.rs` (every request's tax fields), |
| 818 | `invoices.rs`, `webhooks.rs`, `ai.rs`; migration |
| 819 | `0043_tax_and_card_fees.sql`. |
| 820 | |
| 821 | ### What Stripe is asked |
| 822 | |
| 823 | Every price excludes tax (`tax_behavior=exclusive`) and carries tax code |
| 824 | `txcd_10103001` (software as a service, business use; the card fee too, |
| 825 | since a fee for paying for a sale follows the sale). Fields are valid for |
| 826 | `2025-02-24.acacia`. |
| 827 | |
| 828 | | Request | Tax | Card fee | |
| 829 | | --- | --- | --- | |
| 830 | | Checkout, plan or Security (`subscription_fields`) | `automatic_tax[enabled]`, `billing_address_collection=required`, `tax_id_collection[enabled]`, with a customer `customer_update[address]=auto` and `[name]=auto`; the subscription keeps automatic tax for every renewal | A second recurring line, *Card processing fee* | |
| 831 | | Subscription on a saved card (`saved_subscription_fields`) | `automatic_tax[enabled]`; a customer Stripe Tax cannot place fails, and the person is sent to Checkout, which asks for the address | `items[1]`, the `card_fee` product | |
| 832 | | Checkout, prepay (`prepay_fields`) | As above | By card only; none by bank transfer | |
| 833 | | Checkout, AI credit (`credit_fields`) | As above | Its own line | |
| 834 | | Checkout, card check (`card_check_fields`) | Setup mode: nothing charged, nothing taxed. `billing_address_collection=required`, and the card's address is copied onto a customer with none (`fill_address`) | — | |
| 835 | | Auto-reload (`charge_saved`) | `POST /tax/calculations` first (credit and fee as lines), the PaymentIntent for the total, then `POST /tax/transactions/create_from_calculation` with the PaymentIntent as reference; a refund reverses its share (`create_reversal`, `mode=partial`) | In the calculation and the amount | |
| 836 | | Workspace invoice, month close and threshold (`invoice_workspace`) | `automatic_tax[enabled]` on the draft; each item `tax_behavior`, `tax_code` | An item *Card processing fee*, when the default payment method is a card | |
| 837 | | Enterprise invoice (`invoice_enterprise`) | The same | Never | |
| 838 | | Products (`Stripe::product`) | Made with `tax_code`; one found without it is given it | The `card_fee` product, `metadata[g1t]=card_fee` | |
| 839 | |
| 840 | ### What is kept |
| 841 | |
| 842 | A payment credits the balance with what it paid for, never its tax or fee: |
| 843 | prepay credits the page's `amount_subtotal` less the fee line |
| 844 | (`credit_prepayment`), a workspace invoice `amount_paid − tax − fee` |
| 845 | (`credit_invoice`), AI credit its credit amount, and `plan_payments` the |
| 846 | plan's invoice less its tax and *Card processing fee* lines |
| 847 | (`stripe::invoice_split`). Each payment's tax and fee are rows in |
| 848 | `tax_and_fees` (`<reference>/tax`, `<reference>/card_fee`; the enterprise's |
| 849 | account id in `workspace` for its invoices), with the PaymentIntent, so a |
| 850 | refund (`charge.refunded`) gives back the balance, tax and fee in |
| 851 | proportion (`tax::refund_split`, negative rows under `refund/<charge>/…`). |
| 852 | `workspace_invoices.fee_micros` and `tax_micros` sit beside each usage |
| 853 | invoice. |
| 854 | |
| 855 | - **Statement.** *Tax* and *Card processing fees* are their own lines per |
| 856 | day (`StatementLine.passed_micros`), never in `charged_micros`; totals |
| 857 | `tax_micros`, `card_fee_micros`. The CSV has a row a day for each. |
| 858 | - **Margin.** Cash never holds them, so margin is untouched. sudo → Costs |
| 859 | shows **Tax collected** and **Card fees passed on** for the range |
| 860 | (`OverallMargin.tax_collected_micros`, `card_fees_micros`). Tax is owed to |
| 861 | the authorities: file it from Stripe Tax's reports, never from g1t's. |
| 862 | |
| 863 | ### No address |
| 864 | |
| 865 | Stripe Tax needs a country (in the US a ZIP code, in Canada a postal code |
| 866 | or province: `stripe::address_places_customer`). Before a workspace |
| 867 | invoice is drafted, g1t checks the customer; without an address, or when |
| 868 | Stripe leaves the draft at `requires_location_inputs` or refuses with |
| 869 | `customer_tax_location_invalid`, nothing is charged: |
| 870 | `accounts.tax_address_needed_at` is set, the owners are emailed once |
| 871 | (`notify_owners`), and Billing shows **Add a billing address**. Saving |
| 872 | Invoice details with an address Stripe Tax can use clears it, as does a |
| 873 | charge that goes through. Work is not stopped for it; the limits still |
| 874 | apply. Auto-reload without an address fails like a declined card (turned |
| 875 | off, owners told). An enterprise's invoice is not sent without an address: |
| 876 | sudo → the enterprise → Invoices → **Billing address** (`admin_enterprise_address`, |
| 877 | with its tax ID; audited `billing_address`). |
| 878 | |
| 879 | ### The card fee |
| 880 | |
| 881 | `card_fee_cents` grosses Stripe's fee up so the amount paid for is left |
| 882 | after it: `(amount + 30¢) / (1 − 2.9%)`, rounded up; $0.91 on $20, $1.06 |
| 883 | on $25. It is worked out on the amount before tax, so Stripe's fee on the |
| 884 | tax itself (a few cents) is g1t's. It is shown before paying: the plan card |
| 885 | and pricing page (`Plan.card_fee_cents`), AI credit (*Card processing fee |
| 886 | $1.06, plus tax where it applies*), Prepay. Never on a bank transfer or an |
| 887 | enterprise's (`send_invoice`) invoice. Meters stay at cost + 20% and models |
| 888 | at the provider's price plus the agent rate (price versions in migration |
| 889 | 0040); the fee is passed through, not margin. |
| 890 | |
| 891 | ### Tax-exempt customers and tax IDs |
| 892 | |
| 893 | g1t never sets `tax_exempt`. For a customer who sends an exemption |
| 894 | certificate, set it in Stripe's dashboard (Customers → the customer → Tax |
| 895 | status: Exempt, or Reverse charge); Billing then says so. Tax IDs come |
| 896 | from Checkout (`tax_id_collection`) or Invoice details (`set_billing_details`, |
| 897 | validated against Stripe's types in `details::TAX_ID_TYPES`); Stripe checks |
| 898 | EU VAT numbers and Stripe Tax applies a reverse charge where it should. |
| 899 | Billing shows Stripe's `verification.status`. |
| 900 | |
| 901 | ### In Stripe's dashboard (not done by g1t) |
| 902 | |
| 903 | 1. **Settings → Tax → Get started**: turn on Stripe Tax in live and test |
| 904 | mode. |
| 905 | 2. **Origin address**: Flagon, Inc.'s head office address. |
| 906 | 3. **Default tax code**: Software as a service, business use |
| 907 | (`txcd_10103001`); **default tax behavior**: exclusive. |
| 908 | 4. **Registrations**: add each jurisdiction where Flagon is registered to |
| 909 | collect (its home state at least; then states as thresholds are |
| 910 | crossed, which Stripe Tax's monitoring flags; the EU's OSS and the UK |
| 911 | if selling there). Stripe collects only where a registration exists. |
| 912 | 5. **Customer portal**: tax ID and address updates are already allowed |
| 913 | (`portal_configuration`). |
| 914 | 6. Refund an invoice through a **credit note**, so its tax is reversed in |
| 915 | Stripe Tax. |
| 916 | |
| 917 | ## Free workspaces |
| 918 | |
| 919 | Owner decision 2026-10-08: one free workspace per person, and a free |
| 920 | workspace adds no one. Billing answers one question, `free_workspaces` |
| 921 | (`credits.rs`): which of the given workspaces are on no paid plan |
| 922 | (`plan_kind_for` is `Free`). The plan, an enterprise's terms and a 100% |
| 923 | discount (flagon-io) count as paid; with payments off nothing is free. |
| 924 | Identity asks it (`services/identity/src/paid.rs`) and refuses with |
| 925 | `payment_required`: |
| 926 | |
| 927 | | Where | Refused when | |
| 928 | | --- | --- | |
| 929 | | `create_workspace` | The person owns any free workspace. Several from before are kept (grandfathered); none can be added until each is paid for or deleted. | |
| 930 | | `add_member`, `invite_member` | The workspace is free. | |
| 931 | | `add_collaborator` | Someone outside a free workspace (a username who is not a member, or an address). Members' roles are fine. | |
| 932 | | `accept_invite`, `respond_repo_invitation` | The invite's workspace (or repository's) is free now: it waits. A sign-up with such an invite makes the account without joining. | |
| 933 | |
| 934 | `@g1t` is never counted as someone added. If billing cannot be asked, the |
| 935 | change is refused for now ("try again"), never let through. The site says |
| 936 | so first (New workspace, People, a repository's Access, from the same RPC); |
| 937 | the API and MCP pass identity's refusal on as `402`. |
| 938 | |
| 939 | ## The Security and quality activation |
| 940 | |
| 941 | A second monthly subscription a workspace can hold beside the plan |
| 942 | (`Feature::Security`, `feature = 'security'` in `subscriptions`). It turns |
| 943 | on the security suite's paid features for the workspace's private |
| 944 | repositories: custom secret patterns, validity checks, delegated bypass, |
| 945 | code scanning, dependency review and the security overview. Public |
| 946 | repositories have them free; secret scanning, push protection, |
| 947 | vulnerability alerts and security updates are free everywhere. |
| 948 | |
| 949 | - **Price.** The price book's `security_activation` meter (unit |
| 950 | `workspace-month`, `source` `list`, markup 0): 10,000,000 micros, $10, |
| 951 | from migration `0037_security_activation.sql` with its first |
| 952 | `price_versions` row and a public `price_changes` record. `plan()` and the |
| 953 | `prices` RPC read it (`features::security_plan_at`); if the price book |
| 954 | cannot be read, $10. Nothing in the web app hard-codes it. A change is a |
| 955 | new price version, like any other: noticed on the pricing page and |
| 956 | applied from its `effective_at` to new subscriptions. Subscriptions |
| 957 | already running keep the amount Stripe has until they are changed in |
| 958 | Stripe. |
| 959 | - **Stripe.** Its own subscription and its own product, tagged |
| 960 | `metadata[g1t]=security` (the plan's is `plan`). Started from the Billing |
| 961 | page with `subscribe` (`feature: security`) on the checked card, or |
| 962 | through Checkout; ended with `cancel_subscription` (`feature: security`) |
| 963 | at the period's end. Its invoices count in `plan_payments` like the |
| 964 | plan's, as paid revenue. |
| 965 | - **Who has it.** `has_feature(workspace, security)`: on with an active |
| 966 | subscription, with comped terms or as an enterprise's workspace, or when |
| 967 | Stripe is not configured. The plan's allowance (`allowances.plan`) does |
| 968 | not include it. Refusals are `PaymentRequired` with the price from the |
| 969 | price book and the Billing page's address. |
| 970 | - **Where it is checked.** The security service, on each paid call for a |
| 971 | private repository (`suite::entitled`) and before using custom patterns |
| 972 | in a push (`patterns_for`). When billing cannot be reached it is taken |
| 973 | as off: a paid feature waits rather than running unpaid. |
| 974 | - **Fixes.** "Fix with g1t" runs g1t's agent, charged as agent usage, never |
| 975 | to the activation. |
| 976 | - **Sales figures.** MRR in sudo counts `feature = 'plan'` only; the |
| 977 | activation's subscriptions are not in it yet. |