g1t/docs/BILLING_OPERATIONS.md

352 lines20,933 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1# 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,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays7notice), `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)).
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily10Page: 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>`) in `own_counts` as `cloudflare_git` |
24| 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 |
25| `pending_usage` | Month-end meters (git, storage, scans, embeddings, the cache) as they stand. | snapshotted daily into `pending_days` |
26| `plan_payments` | The plan's $20. | read |
One operation mapping, owned by repos; billing reads it instead of keeping its own27| repos `git_operations` | Operations customers are charged for, per workspace, counted by repos through its `operation_mapping`. | `own_counts` meter `git_operations` |
28| 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) |
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily29
30A meter's slug is Cloudflare's name lower-cased with words joined by `_`
31and the "(First … included)" note dropped: `Workers for Platforms CPU ms
32(First 60M ms are included)` under `Workers` is product `workers`, meter
33`workers_for_platforms_cpu_ms`. Several rows of the same day and meter
34(regions, tiers) are added together before they are stored.
35
36## Credentials
37
38| Secret on g1t-billing | Permissions | Used for |
39| --- | --- | --- |
40| `CLOUDFLARE_BILLING_TOKEN` (optional) | Account: **Billing Read**, Account: **Account Analytics Read**, for the g1t account only | Reading the bill and the Artifacts events |
41| `CLOUDFLARE_USAGE_TOKEN` (exists) | Billing Read, Account Analytics Read, AI Gateway Read | The keeper; also the bill when `CLOUDFLARE_BILLING_TOKEN` is not set |
42
43With neither, the daily run reconciles only what g1t counted itself, and
44the page says the bill cannot be read. Nothing fails. To set the scoped one:
45
461. Cloudflare dashboard → My Profile → API Tokens → Create Token → Custom token.
472. Permissions: Account · Billing · Read; Account · Account Analytics · Read.
483. Account resources: Include · the g1t account. No zone permissions.
494. `cd services/billing && npx wrangler secret put CLOUDFLARE_BILLING_TOKEN`.
505. In sudo, Costs & margin → **Read the bill now**.
51
52Alerts are emailed through the `EMAIL` binding (Cloudflare Email Sending)
53to `COSTS_ALERT_EMAIL` (`hey@flagon.io`). An empty value sends none.
54
55## Schedule
56
57The daily cron (`17 4 * * *`, `keeper::DAILY`) runs, in order:
58
591. The keeper's measurements (sandbox seconds, app requests and CPU), each
60 a proposal now, not a direct change.
612. `costs_daily`:
62 1. Read the bill and the Artifacts events. The first run reads the last
63 31 days (GraphQL keeps 31); later runs the last 4, since Cloudflare
64 restates recent days, or back to the last day read after a gap.
65 Lines are upserted on `(day, source, product, meter)`, so a re-read
66 replaces, never adds.
67 2. g1t's own counts for the same days (replaced per day).
68 3. Snapshot `pending_usage` into `pending_days`.
69 4. Reconcile those days into `margin_days` and `workspace_costs`
70 (replaced per day).
71 5. Drift over the last 7 days into `cost_drift`.
72 6. Unit costs over the last 30 days, proposed to the price book.
73 7. Apply price versions whose date has come.
74 8. Open, update and close margin alerts; email new ones.
75 9. Email owners on the plan about rises to come.
76
77**Read the bill now** in sudo (`admin_run_costs`) runs step 2 at once.
78
79## Reconciliation math
80
81Every Cloudflare line goes to one of g1t's products ("buckets") by
82`cost_map`: the row for its product with the longest matching meter
83prefix, `*` last. A line no row claims goes to `unmapped`.
84
85| Bucket | Cloudflare | Paid for by (`revenue_map`) |
86| --- | --- | --- |
87| `sandboxes` | Containers, Durable Objects compute duration | `sandbox`, `self_hosted`, `builds` |
88| `deployments` | Workers for Platforms | `deployments` |
89| `git` | Artifacts operations (and its events, as counts) | `git` |
90| `repo_storage` | Artifacts storage | `storage` |
91| `actions_cache` | R2 | `cache` |
92| `embeddings` | Workers AI, Vectorize | `context` |
93| `security` | (Workers CPU, under `platform`) | `security` |
94| `domains` | Cloudflare for SaaS | `domains` |
95| `models` | not Cloudflare: AI Gateway's settled cost on the ledger | every other task (agent runs) |
96| `platform` | Workers, D1, KV, Queues, Email, Browser Rendering, other Durable Objects | the plan's price |
97
98For each day and bucket:
99
100- **Cloudflare cost** = Σ the bucket's lines' cost. `models` uses the
101 ledger's cost instead.
102- **Own cost** = Σ the ledger's `cost_micros` for the bucket's keys (the
103 price book's cost when charged), plus month-end deltas. A workspace's own
104 model provider is no cost to g1t.
105- **Value** = what customers were charged at price: `-amount_micros` plus
106 what the plan's included usage, a trial, the open-source pool or g1t paid.
107 g1t's own (comped) workspaces are valued at cost plus the margin.
108- **Cash** = what workspaces paid: `-amount_micros`, and the plan's price.
109- **Month-end meters**: a day's figure is that day's `pending_days`
110 snapshot less the day before's, within a month. Their month-end ledger
111 entries are left out, so nothing is counted twice.
112- **Product margin** = (value − cost) / value. **Overall margin** =
113 (Σ cash − Σ cost) / Σ cash.
114- **Quantities**: where a mapping names an `own_meter`, Cloudflare's
115 billed quantity of those lines (or, without one, Artifacts' operation
116 events) against g1t's own count.
117
118**Shared costs to workspaces.** A bucket's cost is shared in proportion
119to, first available: Cloudflare's own per-workspace count
120(`cloudflare_<bucket>`, today the Artifacts events by repository), g1t's
121own count, what each was charged for it, what its usage cost. `platform`
122and `unmapped` are shared by each workspace's share of all usage that
123day. Shares are whole micros that add up to the bill exactly (largest
124remainder).
125
126## Drift (last 7 days)
127
128| Kind | When | What to do |
129| --- | --- | --- |
One operation mapping, owned by repos; billing reads it instead of keeping its own130| 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). |
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily131| 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. |
132| 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`). |
133
134## Prices: versions, proposals, notice
135
136- `price_versions` holds every price ever, never edited. `prices` is the
137 version in force. The daily run applies a version once its
138 `effective_at` has come, and adds the public `price_changes` record.
139 Ledger entries made from the price book carry `price_version` (the
140 version ids, comma-separated), so a past statement is always explained
141 by the prices of its day.
142- Proposals come from the keeper (sandbox seconds, app requests and CPU)
143 and the reconciler (mappings with `scale_to_own`: today git operations).
144 For git operations: Cloudflare's rate per its own operation (the median
145 over charged days of cost ÷ quantity) × (Cloudflare's operations ÷ g1t's)
146 × 1,000. If Cloudflare counts three for each one g1t counts, the per-1,000
147 price triples. At least 1,000 of g1t's operations are needed.
148- Decision (`pricing::decide`): under 2% is noise; more than 4× either way
149 is suspect and waits for staff; within `auto_apply_percent` (25%) it is
150 applied on its own when `auto_apply` is on; anything else waits.
151- Notice: a fall applies at once. A rise applies `notice_days` (14) after
152 the decision, and for a monthly meter (git, storage, cache, domains,
153 embeddings, scans) at the start of the month after that, so no month
154 is charged at two prices. Owners of workspaces on the plan are emailed
155 once per rise (`price_notices`), and the pricing page lists it with
156 "takes effect". Rises are never retroactive; margin protection is for
157 new usage once notice has run.
158- Staff approve or reject in sudo. A rejection needs a note.
159
160## Changing a mapping
161
162In sudo, Costs & margin → **Mappings**: Cloudflare's product and meter
163prefix (as **Cloudflare's lines** lists them; `*` for the rest of the
164product), g1t's product, and optionally:
165
166- **Price meter**: the price book meter the line measures.
167- **Own meter**: g1t's count of the same units (`own_counts.meter`).
168- **Scale to g1t's count**: price one of g1t's units at as many of
169 Cloudflare's as it took (proposals as above).
170- **Drift threshold**.
171
172It applies from the next run; **Read the bill now** applies it at once.
One operation mapping, owned by repos; billing reads it instead of keeping its own173Every change is in the audit log (`cost_mapping`).
174
175## Which raw meters are operations
176
177There is one mapping, and the repos service owns it: `operation_mapping`
178in g1t-repos' database, one row per raw meter with `cost_operations` (how
179many operations Cloudflare bills for it) and `billable_operations` (how
180many the customer is charged for). Change it with repos'
181`set_operation_mapping` RPC (services only), or
182`npx wrangler d1 execute g1t-repos --remote` until sudo has a form. A
183change applies to counts from then on, never to what was counted.
184Billing keeps no mapping of its own: it reads repos' `git_operations`
185(already mapped) for what customers are charged, and `artifacts_usage`
186(raw counts with the mapping) for `cost_operations`. Migration 0023 drops
187the `billable_units` table 0022 made for this, which was never written.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily188
189## Alerts runbook
190
191| Alert | Raised when | First steps |
192| --- | --- | --- |
193| 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. |
194| All of g1t under the floor | The same for money in against every cost | Look at which products moved; check `platform` (it has no revenue of its own and grows with traffic). |
195| Leak | Drift of kind leak | Map the meter, or decide it is overhead. |
196| Drift | Count drift | See Drift above. Cloudflare's definitions change in beta: ask them in writing ([ARTIFACTS.md](ARTIFACTS.md), §7). |
197| Costs more than it pays | A workspace's shared cost over 30 days above its revenue × `anomaly_factor`, at least `anomaly_floor`; not g1t's own | Shown on Reach out as "Costs more than it pays". Abuse (Abuse & fraud page) or a gap in pricing. Not emailed. |
198
199Alerts close on their own when the condition clears. Open ones are
200emailed again weekly. The red bar on every sudo page shows margin,
201overall and leak alerts.
202
Mission control shows model usage, yours and the workspace's: tokens, cost, active days, cache share, each day, and the mix203## Token usage
204
205The model proxy (`services/models`) reads Anthropic's `usage` from every
206`/v1/messages` answer, streamed or whole, on g1t's models and on a
207workspace's own provider alike (OpenAI-shaped providers are translated
208first). Count-tokens requests are not answers and are skipped. After the
209answer, it calls `record_tokens`, which adds input, output, cache reads and
210cache writes to one row per day, workspace, person, session and model in
211`token_usage` (migration `0030_token_usage.sql`). The person is who the run
212was for, from the model session's `requested_by`; never g1t's agent. A
213report that fails is dropped and never affects the answer.
214
215`token_usage` reads a window (42 days by default, 366 at most) for the
216workspace or one person: totals, every day's tokens and the active days,
217with `costMicros` the window's run charges from the ledger, measured as
218`usage` measures them. These counts are for views only: runs are still
219priced from AI Gateway's logs, never from `token_usage`.
220
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily221## Tables (migration `0022_costs_and_margin.sql`)
222
One operation mapping, owned by repos; billing reads it instead of keeping its own223`cost_lines`, `cost_map`, `revenue_map`, `own_counts`,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily224`pending_days`, `margin_days`, `workspace_costs`, `cost_drift`,
225`margin_alerts`, `price_versions` (seeded with every current price as
226version 1), `price_proposals`, `price_notices`, `cost_settings` (the
227guardrails, seeded), the `actions_cache` price, and `ledger.price_version`.
228Every create is `IF NOT EXISTS` and every seed `INSERT OR IGNORE`; the one
One operation mapping, owned by repos; billing reads it instead of keeping its own229`ALTER` is applied once by D1's migration tracking. Migration
230`0023_one_operation_mapping.sql` drops `billable_units` (see above).
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays231
232## Spend caps
233
234Two caps keep what g1t pays for itself bounded while billing takes no
235real money. Both are measured at **cost** (what Cloudflare and the model
236providers charge g1t), never at price. Code: `services/billing/src/budget.rs`.
237Page: sudo **Costs & margin** → **g1t's own spend** (`/costs#spend`).
238
239### What counts as g1t's own spend
240
241Every charge that settles (an agent run's model cost from `finish_run` or
242AI Gateway's settlement, sandbox time from `record_sandbox`, a build from
243`charge_feature`) is split by what paid for it and g1t's part is added to
244`g1t_spend` (day, bucket, billing account):
245
246| Bucket | What |
247| --- | --- |
248| `comped` | All of a comped account's work (flagon-io) |
249| `trial` | The trial credit's share |
250| `oss` | The open-source pool's share |
251| `given` | A free workspace's overrun past its last bit of trial |
252| `unpaid` | Charged, but with no real money behind it: Stripe's test key, or `FREE_WHILE_BUILDING` |
253
254The plan's included usage and on-demand charges count as revenue only
255with live payments; in test mode they are `unpaid`. A workspace's own
256model provider costs g1t nothing and is not counted. Month-end meters
257(git, storage, scans, embeddings, the cache) are not counted here; the
258daily reconciliation covers them. Migration `0024_spend_caps.sql`
259backfills the current month from the ledger.
260
261### Caps
262
263| Cap | Variable (g1t-billing) | Default | At the cap |
264| --- | --- | --- | --- |
265| 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). |
266| 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. |
267
268`0` turns either off. `CLOUDFLARE_FIXED_MONTHLY_MICROS` ($30: Workers
269Paid and Workers for Platforms) is shown on the page only.
270
271The checks are cheap: `reserve` reads today's total (one indexed sum) and,
272for a comped account, its month's comped rows. Refusals come back as
273`paused`, which the compute gate honours for every plan, internal and
274enterprise included (`packages/contracts/src/compute.ts`). The runner tells
275billing whether an agent run is on hosted models (`hostedModel` on
276`reserve`); a caller that does not say is treated as hosted.
277
278### Alerts
279
280All to `COSTS_ALERT_EMAIL` (`hey@flagon.io`), through the `EMAIL` binding:
281
282- **Comped budget**: at 50, 75, 90 and 100%, once each per account and month
283 (`budget_alerts`), checked every 15 minutes. A jump past several levels
284 sends only the highest.
285- **Breaker**: at once, from the charge that trips it; if that email fails,
286 the 15-minute cron sends it (`spend_breaker.told_at`).
287
288While the breaker is open or a comped budget is used up, every sudo page
289shows a red **Spend cap** bar.
290
291### Raising and lifting
292
293- **Raise a comped budget**: sudo → the workspace → Billing → **Terms**, set
294 **Limit $** to the new monthly budget (blank goes back to the default),
295 with a note. It applies to the next start; nothing to deploy. The change
296 is in the account's audit log.
297- **Lift the breaker for today**: sudo → Costs & margin → **g1t's own
298 spend** → **Lift for today**, with why (`admin_lift_breaker`; audit action
299 `breaker_lifted`). It resets by itself at 00:00 UTC.
300- **Change a default**: edit the variable in `services/billing/wrangler.jsonc`
301 and deploy g1t-billing.
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept302
303## Stripe
304
305Billing keeps what it needs from Stripe so reads never wait on it, and
306hears of changes three ways (`webhooks.rs`, `stripe_sync.rs`).
307
Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard308**The webhook.** A destination made in Stripe's dashboard (Developers →
309Webhooks → Add destination) with the endpoint URL
310`https://api.g1t.sh/stripe/webhook`, in the mode of billing's key (at
311launch, make one in live mode and put its secret). Its signing secret
312(`whsec_…`: the destination, Signing secret, Reveal) is the billing
313Worker's secret:
314
315```sh
316cd services/billing && npx wrangler secret put STRIPE_WEBHOOK_SECRET
317```
318
319Without it every event is refused with 400. After rolling the secret in
320Stripe, put the new one; during the roll Stripe signs with both, so there
321is no gap. The event list need not be exact: billing adds any event it
322handles that the destination does not send (daily, or **Fix destination**
323in sudo → Stripe), and enables it again if Stripe disabled it. It never
324changes the secret. sudo → Stripe shows whether the secret is set, the
325destination and its status, missing events, and the latest events.
326Events are claimed once each in `stripe_events`; a handler that fails
327forgets its claim, and Stripe retries.
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept328
329**What is kept, and how it stays current**
330
331| Kept | Where | Refreshed by |
332| --- | --- | --- |
333| 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 |
334| 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 |
335| Payments, refunds, disputes | ledger, `checkouts`, invoices | their events |
336
337**Every cron run (every 15 minutes)** replays missed events: Stripe's event
338list from an hour before `stripe_sync.through`, oldest first, through the
339same once-only claim. `through` moves to 5 minutes before now when all were
340handled, back to the first failure otherwise, and stays when more than
3411,000 events were listed. Claims stuck at `handling` for 10 minutes are
342dropped so the replay retries them. The first run reads 3 days back.
343
Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard344**Daily** (`keeper::DAILY`): the destination at billing's address is
345enabled again if Stripe disabled it and given any missing event, audited as
346`stripe`/`webhook`; then up to 25 stale cards and 25 stale plans are read
347again.
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept348
349**What still calls Stripe on a request**: starting a payment page, a plan
350or a card check; opening the billing portal; settling a page the person
351came back from; renaming a workspace (the customer's name). Nothing a page
352view reads.