g1t/docs/BILLING_OPERATIONS.md

492 lines33,088 bytesCodeBlame
1# Billing operations: costs, margin and prices
2
3How g1t checks what it charges against what Cloudflare charges it, keeps
4prices at cost plus 20%, and tells staff when the margin slips. Internal.
5Code: `services/billing/src/costs.rs` (reading the bill), `margin.rs`
6(reconciliation, drift, alerts), `pricing.rs` (versions, proposals,
7notice), `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)).
10Page: sudo **Costs & margin** (`/costs`).
11
12Several 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
15list or a unit: every line Cloudflare bills is kept, and how a line maps
16to 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
32A meter's slug is Cloudflare's name lower-cased with words joined by `_`
33and 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 the bill's token first and, if that is refused, with this one |
44
45With neither, the daily run reconciles only what g1t counted itself, and
46the page says the bill cannot be read. Nothing fails. To set the scoped one:
47
481. Cloudflare dashboard → My Profile → API Tokens → Create Token → Custom token.
492. Permissions: Account · Billing · Read; Account · Account Analytics · Read.
503. Account resources: Include · the g1t account. No zone permissions.
514. `cd services/billing && npx wrangler secret put CLOUDFLARE_BILLING_TOKEN`.
525. In sudo, Costs & margin → **Run the analysis now**.
53
54Alerts are emailed through the `EMAIL` binding (Cloudflare Email Sending)
55to `COSTS_ALERT_EMAIL` (`hey@flagon.io`). An empty value sends none.
56
57## Schedule
58
59The daily cron (`17 4 * * *`, `keeper::DAILY`) runs, in order:
60
611. The keeper's measurements (sandbox seconds, app requests and CPU), each
62 a proposal now, not a direct change.
632. `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
82need to wait for 04:17 UTC. Running it twice is safe: every step replaces
83what it wrote.
84
85## Reconciliation math
86
87Every Cloudflare line goes to one of g1t's products ("buckets") by
88`cost_map`: the row for its product with the longest matching meter
89prefix, `*` 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
104For 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- **Given away** = the part of the cost that went on usage g1t paid for
121 itself on purpose, by why:
122 - **comped**: all of a comped workspace's cost, every bucket;
123 - **free use**: a free period's usage, the overruns g1t covered
124 (`ledger.given_micros`), and all of a workspace's cost on a day it had
125 nothing priced (free allowances);
126 - **trial** and **open-source pool**: what `trial_micros` and
127 `oss_micros` paid;
128 - **discount**: what a discount on an account's custom terms took below
129 cost plus the margin (`ledger.discount_micros`, see
130 [Margin floor](#margin-floor)). The usage is valued at its price, so a
131 discounted sale never reads as margin lost.
132
133 Otherwise a workspace's day is split by those shares of its value at
134 price, and the same shares of each of its buckets' cost are given, its
135 part of running g1t included. The Team plan's included usage is sold:
136 the plan's price paid for it. Stored on `margin_days` (`given_micros`
137 and `given_<why>_micros`, `given_discount_micros` from migration 0036)
138 and `workspace_costs` (`given_micros`). Sudo's Bill & pricing page lists
139 comped, free use, trial and pool by name; the discount part is in the
140 total until the page names it (`givenDiscountMicros`).
141- **Month-end meters**: a day's figure is that day's `pending_days`
142 snapshot less the day before's, within a month. Their month-end ledger
143 entries are left out, so nothing is counted twice.
144- **Product margin** = (value − cost) / value.
145- **Sudo's statement** keeps apart:
146 - **Usage sold**: cash for usage against the cost of the usage buckets
147 less what was given. Its margin is the headline; at cost plus 20% it
148 sits near 16.7%.
149 - **Running g1t**: the plan's price against `platform` less its given
150 share.
151 - **Cloudflare subscriptions**: what Cloudflare lists, a month, over
152 the range (`cf_subscriptions`); until a read has worked,
153 `CLOUDFLARE_FIXED_MONTHLY_MICROS`, an estimate.
154 - **Not mapped**: billed, charged for by nothing.
155 - **Given away**: by why. A budget, watched under g1t's own spend, never
156 shown as a loss.
157 - **All in**: money in against all of it, with the figure without what
158 was given beside it. **Who g1t paid** splits the cost into Cloudflare
159 and the model providers.
160
161 The overall alert is (Σ cash − (Σ cost − Σ given)) / Σ cash.
162- **Quantities**: where a mapping names an `own_meter`, Cloudflare's
163 billed quantity of those lines (or, without one, Artifacts' operation
164 events) against g1t's own count.
165
166**Shared costs to workspaces.** A bucket's cost is shared in proportion
167to, first available: Cloudflare's own per-workspace count
168(`cloudflare_<bucket>`, today the Artifacts events by repository), g1t's
169own count, what its usage cost (so free use carries its own cost), what
170each was charged for it. `platform`
171and `unmapped` are shared by each workspace's share of all usage that
172day. Shares are whole micros that add up to the bill exactly (largest
173remainder).
174
175## Drift (last 7 days)
176
177| Kind | When | What to do |
178| --- | --- | --- |
179| 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). |
180| 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. |
181| 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), more than the `ai_gateway_requests` mapping's `drift_percent` (10%) apart, with at least `min_daily_cost`. Compared once the gateway has been read; then a ledger with none of it is drift too | 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. 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. |
182| 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. |
183| 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`). |
184
185## Prices: versions, proposals, notice
186
187- `price_versions` holds every price ever, never edited. `prices` is the
188 version in force. The daily run applies a version once its
189 `effective_at` has come, and adds the public `price_changes` record.
190 Ledger entries made from the price book carry `price_version` (the
191 version ids, comma-separated), so a past statement is always explained
192 by the prices of its day.
193- Proposals come from the keeper (sandbox seconds, app requests and CPU)
194 and the reconciler (mappings with `scale_to_own`: today git operations).
195 For git operations: Cloudflare's rate per its own operation (the median
196 over charged days of cost ÷ quantity) × (Cloudflare's operations ÷ g1t's)
197 × 1,000. If Cloudflare counts three for each one g1t counts, the per-1,000
198 price triples. At least 1,000 of g1t's operations are needed.
199- Decision (`pricing::decide`): under 2% is noise; more than 4× either way
200 is suspect and waits for staff; within `auto_apply_percent` (25%) it is
201 applied on its own when `auto_apply` is on; anything else waits.
202- Notice: a fall applies at once. A rise applies `notice_days` (14) after
203 the decision, and for a monthly meter (git, storage, cache, domains,
204 embeddings, scans) at the start of the month after that, so no month
205 is charged at two prices. Owners of workspaces on the plan are emailed
206 once per rise (`price_notices`), and the pricing page lists it with
207 "takes effect". Rises are never retroactive; margin protection is for
208 new usage once notice has run.
209- Staff approve or reject in sudo. A rejection needs a note.
210
211## Changing a mapping
212
213In sudo, Costs & margin → Bill & pricing → **Mappings**: Cloudflare's product and meter
214prefix (as **Cloudflare's lines** lists them; `*` for the rest of the
215product), g1t's product, and optionally:
216
217- **Price meter**: the price book meter the line measures.
218- **Own meter**: g1t's count of the same units (`own_counts.meter`).
219- **Scale to g1t's count**: price one of g1t's units at as many of
220 Cloudflare's as it took (proposals as above).
221- **Drift threshold**.
222
223It applies from the next run; **Run the analysis now** applies it at once.
224Every change is in the audit log (`cost_mapping`).
225
226## Which raw meters are operations
227
228There is one mapping, and the repos service owns it: `operation_mapping`
229in g1t-repos' database, one row per raw meter with `cost_operations` (how
230many operations Cloudflare bills for it) and `billable_operations` (how
231many the customer is charged for). Change it with repos'
232`set_operation_mapping` RPC (services only), or
233`npx wrangler d1 execute g1t-repos --remote` until sudo has a form. A
234change applies to counts from then on, never to what was counted.
235Billing keeps no mapping of its own: it reads repos' `git_operations`
236(already mapped) for what customers are charged, and `artifacts_usage`
237(raw counts with the mapping) for `cost_operations`. Migration 0023 drops
238the `billable_units` table 0022 made for this, which was never written.
239
240**Charged at price** (a product's value) is what each day's usage was paid:
241charged to a card or credit, or drawn from the plan's included usage, a
242trial, a pool or a gift. Usage nothing paid for, as in a free period, is
243valued at price (cost plus the margin), since it was given away at its price
244rather than sold for nothing; so are g1t's own workspaces. Runs on a
245workspace's own model provider have no cost to g1t. Every daily run
246reconciles the whole 31-day window from what is already kept, so a change in
247how a day is valued reaches every day sudo shows.
248
249## Alerts runbook
250
251| Alert | Raised when | First steps |
252| --- | --- | --- |
253| 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. |
254| 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. |
255| Leak | Drift of kind leak | Map the meter, or decide it is overhead. |
256| Drift | Count drift | See Drift above. Cloudflare's definitions change in beta: ask them in writing ([ARTIFACTS.md](ARTIFACTS.md), §7). |
257| 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. |
258
259Alerts close on their own when the condition clears. Open ones are
260emailed again weekly. The red bar on every sudo page shows margin,
261overall and leak alerts.
262
263## Model costs
264
265Every model call g1t pays for is an agent run's (the `claude` CLI in the
266sandbox, `crates/runner`); the only other model is Workers AI's embeddings,
267which are on Cloudflare's bill (`embeddings`). How each reaches the ledger:
268
269| Call | Who pays | Run and session | Ledger cost | Settled to the gateway |
270| --- | --- | --- | --- | --- |
271| 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 |
272| Agent run straight to the gateway (no `MODELS_URL`) | g1t | `runs` row; session `rs_…` in `cf-aig-metadata` (`services/runner` `gatewaySession`) | As above | Yes |
273| 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 |
274| Agent run on a workspace's own provider | The workspace | `runs` row, `billed_to = 'workspace'`, no session | None (no cost to g1t) | No; never on g1t's gateway |
275| A sandbox that died before reporting | g1t | as its route | Charged from the gateway when settled | Yes |
276| Embeddings (indexing) | g1t | none (Workers AI) | Month-end `context` meter | No: Cloudflare's bill, `embeddings` bucket |
277| Embeddings (queries, search and agent context) | g1t | none | None: not charged, by design | No: in Cloudflare's `embeddings` line, shared out |
278
279**Settling.** A run's charge is corrected to what AI Gateway priced its
280session's requests at (`settled_cost` in `keeper.rs`). The gateway's
281figure is trusted in full: it is not held to the $100 cap on a sandbox's
282own report. It is never taken below what the sandbox reported when it
283cannot be the whole cost: a request with tokens and no cost (a model the
284gateway has no price for) or more logs than are read (2,000). Such a run
285keeps `runs.gateway_note`, its correction says why, and it raises the
286**Unpriced** drift. g1t keeps no token rates of its own: the first figure
287is Claude Code's, the final one the gateway's.
288
289**The daily total.** AI Gateway's analytics for the day (above) against
290the ledger's model cost is the check that nothing slips past: a model call
291with no run, or a run never settled, shows as **Cost** drift on `models`.
292The gateway's per-request `cost` is its estimate from its own price list:
293it can be off for prompt-cache tokens, for requests Cloudflare bills
294itself, and for models it has no price for. The drift's detail says when
295any of those were in the window; the provider's invoice is the last word.
296
297### Margin floor
298
299A sold charge is cost × (1 + `MARGIN_PERCENT`), rounded up (`margin_on`;
300`charge_micros` for a sandbox's own report). Terms change it only as
301follows (`Terms::discounted`, `Billing::charged`):
302
303- **Standard**: charged in full.
304- **Comped**, `FREE_WHILE_BUILDING`, the plan's included usage, the trial,
305 the open-source pool, and overruns g1t covers: given, and counted by why
306 (above).
307- **Custom, with a discount**: the discount comes off, and what it took
308 below cost plus the margin is written on the entry as
309 `ledger.discount_micros` and counted as given (**discount**), so the sale
310 is valued at its price and charged plus given is never under cost plus the
311 margin. On a settlement correction it moves with the charge (less than
312 nothing when the charge comes down).
313- **Goodwill credits** (overages) are separate, given by staff on purpose:
314 their margin part first, the cost only up to the cap, each audited.
315
316Every usage path goes through this: `finish_run`, settling, sandbox time,
317features and builds (`charge_feature`), and the month-end meters.
318
319## Token usage
320
321The model proxy (`services/models`) reads Anthropic's `usage` from every
322`/v1/messages` answer, streamed or whole, on g1t's models and on a
323workspace's own provider alike (OpenAI-shaped providers are translated
324first). Count-tokens requests are not answers and are skipped. After the
325answer, it calls `record_tokens`, which adds input, output, cache reads and
326cache writes to one row per day, workspace, person, session and model in
327`token_usage` (migration `0030_token_usage.sql`). The person is who the run
328was for, from the model session's `requested_by`; never g1t's agent. A
329report that fails is dropped and never affects the answer.
330
331`token_usage` reads a window (42 days by default, 366 at most) for the
332workspace or one person: totals, every day's tokens and the active days,
333with `costMicros` the window's run charges from the ledger, measured as
334`usage` measures them. These counts are for views only: runs are still
335priced from AI Gateway's logs, never from `token_usage`.
336
337## Tables (migration `0022_costs_and_margin.sql`)
338
339`cost_lines`, `cost_map`, `revenue_map`, `own_counts`,
340`pending_days`, `margin_days`, `workspace_costs`, `cost_drift`,
341`margin_alerts`, `price_versions` (seeded with every current price as
342version 1), `price_proposals`, `price_notices`, `cost_settings` (the
343guardrails, seeded), the `actions_cache` price, and `ledger.price_version`.
344Every create is `IF NOT EXISTS` and every seed `INSERT OR IGNORE`; the one
345`ALTER` is applied once by D1's migration tracking. Migration
346`0023_one_operation_mapping.sql` drops `billable_units` (see above).
347Migration `0036_model_costs_in_full.sql` adds `ledger.discount_micros`,
348`margin_days.given_discount_micros`, `runs.gateway_note` and the
349`ai_gateway_requests` → `models` mapping.
350
351## Spend caps
352
353Two caps keep what g1t pays for itself bounded while billing takes no
354real money. Both are measured at **cost** (what Cloudflare and the model
355providers charge g1t), never at price. Code: `services/billing/src/budget.rs`.
356Page: sudo **Costs & margin** → **g1t's own spend** (`/costs#spend`).
357
358### What counts as g1t's own spend
359
360Every charge that settles (an agent run's model cost from `finish_run` or
361AI Gateway's settlement, sandbox time from `record_sandbox`, a build from
362`charge_feature`) is split by what paid for it and g1t's part is added to
363`g1t_spend` (day, bucket, billing account):
364
365| Bucket | What |
366| --- | --- |
367| `comped` | All of a comped account's work (flagon-io) |
368| `trial` | The trial credit's share |
369| `oss` | The open-source pool's share |
370| `given` | A free workspace's overrun past its last bit of trial |
371| `unpaid` | Charged, but with no real money behind it: Stripe's test key, or `FREE_WHILE_BUILDING` |
372
373The plan's included usage and on-demand charges count as revenue only
374with live payments; in test mode they are `unpaid`. A workspace's own
375model provider costs g1t nothing and is not counted. Month-end meters
376(git, storage, scans, embeddings, the cache) are not counted here; the
377daily reconciliation covers them. Migration `0024_spend_caps.sql`
378backfills the current month from the ledger.
379
380### Caps
381
382| Cap | Variable (g1t-billing) | Default | At the cap |
383| --- | --- | --- | --- |
384| 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). |
385| 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. |
386
387`0` turns either off. Cloudflare's subscriptions are read from Cloudflare
388each day (`cf_subscriptions`) and shown on the page only;
389`CLOUDFLARE_FIXED_MONTHLY_MICROS` ($30) stands in until a read works.
390
391The checks are cheap: `reserve` reads today's total (one indexed sum) and,
392for a comped account, its month's comped rows. Refusals come back as
393`paused`, which the compute gate honours for every plan, internal and
394enterprise included (`packages/contracts/src/compute.ts`). The runner tells
395billing whether an agent run is on hosted models (`hostedModel` on
396`reserve`); a caller that does not say is treated as hosted.
397
398### Alerts
399
400All to `COSTS_ALERT_EMAIL` (`hey@flagon.io`), through the `EMAIL` binding:
401
402- **Comped budget**: at 50, 75, 90 and 100%, once each per account and month
403 (`budget_alerts`), checked every 15 minutes. A jump past several levels
404 sends only the highest.
405- **Breaker**: at once, from the charge that trips it; if that email fails,
406 the 15-minute cron sends it (`spend_breaker.told_at`).
407
408While the breaker is open or a comped budget is used up, every sudo page
409shows a red **Spend cap** bar.
410
411### Raising and lifting
412
413- **Raise a comped budget**: sudo → the workspace → Billing → **Terms**, set
414 **Limit $** to the new monthly budget (blank goes back to the default),
415 with a note. It applies to the next start; nothing to deploy. The change
416 is in the account's audit log.
417- **Lift the breaker for today**: sudo → Costs & margin → **g1t's own
418 spend** → **Lift for today**, with why (`admin_lift_breaker`; audit action
419 `breaker_lifted`). It resets by itself at 00:00 UTC.
420- **Change a default**: edit the variable in `services/billing/wrangler.jsonc`
421 and deploy g1t-billing.
422
423## Resetting a test workspace
424
425sudo → the workspace → **Reset billing (testing)** (`admin_reset_billing`)
426returns a workspace used for testing to how a new customer starts. It
427deletes the workspace's rows from every billing table: ledger and balance,
428plan and plan payments, limits and limit requests, trial grant, invoices,
429holds, card checks, alerts sent, price notices, month-end snapshots and
430closes, storage meters, token usage, spikes, sales records and
431notes, `workspace_costs`, its workspace margin alert and its own billing
432account. It keeps `own_counts` (what Cloudflare's bill is compared with)
433and the audit log, which records the reset with the note and the number of
434rows. The workspace, its members and its repositories are identity's and
435repos' and stay.
436
437Billing refuses it while `STRIPE_SECRET_KEY` is a live key, for comped
438workspaces, and for a workspace an enterprise pays for. It then runs the
439costs analysis again (as **Run the analysis now** does), so the margin
440figures drop the workspace's past usage at once; if that run does not
441finish, the page says so and the button does it.
442
443## Stripe
444
445Billing keeps what it needs from Stripe so reads never wait on it, and
446hears of changes three ways (`webhooks.rs`, `stripe_sync.rs`).
447
448**The webhook.** A destination made in Stripe's dashboard (Developers →
449Webhooks → Add destination) with the endpoint URL
450`https://api.g1t.sh/stripe/webhook`, in the mode of billing's key (at
451launch, make one in live mode and put its secret). Its signing secret
452(`whsec_…`: the destination, Signing secret, Reveal) is the billing
453Worker's secret:
454
455```sh
456cd services/billing && npx wrangler secret put STRIPE_WEBHOOK_SECRET
457```
458
459Without it every event is refused with 400. After rolling the secret in
460Stripe, put the new one; during the roll Stripe signs with both, so there
461is no gap. The event list need not be exact: billing adds any event it
462handles that the destination does not send (daily, or **Fix destination**
463in sudo → Stripe), and enables it again if Stripe disabled it. It never
464changes the secret. sudo → Stripe shows whether the secret is set, the
465destination and its status, missing events, and the latest events.
466Events are claimed once each in `stripe_events`; a handler that fails
467forgets its claim, and Stripe retries.
468
469**What is kept, and how it stays current**
470
471| Kept | Where | Refreshed by |
472| --- | --- | --- |
473| 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 |
474| 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 |
475| Payments, refunds, disputes | ledger, `checkouts`, invoices | their events |
476
477**Every cron run (every 15 minutes)** replays missed events: Stripe's event
478list from an hour before `stripe_sync.through`, oldest first, through the
479same once-only claim. `through` moves to 5 minutes before now when all were
480handled, back to the first failure otherwise, and stays when more than
4811,000 events were listed. Claims stuck at `handling` for 10 minutes are
482dropped so the replay retries them. The first run reads 3 days back.
483
484**Daily** (`keeper::DAILY`): the destination at billing's address is
485enabled again if Stripe disabled it and given any missing event, audited as
486`stripe`/`webhook`; then up to 25 stale cards and 25 stale plans are read
487again.
488
489**What still calls Stripe on a request**: starting a payment page, a plan
490or a card check; opening the billing portal; settling a page the person
491came back from; renaming a workspace (the customer's name). Nothing a page
492view reads.