g1t/docs/BILLING_OPERATIONS.md

210 lines13,237 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).
8Page: sudo **Costs & margin** (`/costs`).
9
10Several Cloudflare products g1t runs on are new. Artifacts bills
11"operations" from 2026-10-14 without defining them (see
12[ARTIFACTS.md](ARTIFACTS.md), M1). So nothing here hard-codes a product
13list or a unit: every line Cloudflare bills is kept, and how a line maps
14to what g1t sells is data you change from sudo, without a deploy.
15
16## Data sources
17
18| Source | What | Where it lands |
19| --- | --- | --- |
20| 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` |
21| 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` |
22| 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 |
23| `pending_usage` | Month-end meters (git, storage, scans, embeddings, the cache) as they stand. | snapshotted daily into `pending_days` |
24| `plan_payments` | The plan's $20. | read |
25| repos `git_operations` | Operations customers are charged for, per workspace, counted by repos through its `operation_mapping`. | `own_counts` meter `git_operations` |
26| 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) |
27
28A meter's slug is Cloudflare's name lower-cased with words joined by `_`
29and the "(First … included)" note dropped: `Workers for Platforms CPU ms
30(First 60M ms are included)` under `Workers` is product `workers`, meter
31`workers_for_platforms_cpu_ms`. Several rows of the same day and meter
32(regions, tiers) are added together before they are stored.
33
34## Credentials
35
36| Secret on g1t-billing | Permissions | Used for |
37| --- | --- | --- |
38| `CLOUDFLARE_BILLING_TOKEN` (optional) | Account: **Billing Read**, Account: **Account Analytics Read**, for the g1t account only | Reading the bill and the Artifacts events |
39| `CLOUDFLARE_USAGE_TOKEN` (exists) | Billing Read, Account Analytics Read, AI Gateway Read | The keeper; also the bill when `CLOUDFLARE_BILLING_TOKEN` is not set |
40
41With neither, the daily run reconciles only what g1t counted itself, and
42the page says the bill cannot be read. Nothing fails. To set the scoped one:
43
441. Cloudflare dashboard → My Profile → API Tokens → Create Token → Custom token.
452. Permissions: Account · Billing · Read; Account · Account Analytics · Read.
463. Account resources: Include · the g1t account. No zone permissions.
474. `cd services/billing && npx wrangler secret put CLOUDFLARE_BILLING_TOKEN`.
485. In sudo, Costs & margin → **Read the bill now**.
49
50Alerts are emailed through the `EMAIL` binding (Cloudflare Email Sending)
51to `COSTS_ALERT_EMAIL` (`hey@flagon.io`). An empty value sends none.
52
53## Schedule
54
55The daily cron (`17 4 * * *`, `keeper::DAILY`) runs, in order:
56
571. The keeper's measurements (sandbox seconds, app requests and CPU), each
58 a proposal now, not a direct change.
592. `costs_daily`:
60 1. Read the bill and the Artifacts events. The first run reads the last
61 31 days (GraphQL keeps 31); later runs the last 4, since Cloudflare
62 restates recent days, or back to the last day read after a gap.
63 Lines are upserted on `(day, source, product, meter)`, so a re-read
64 replaces, never adds.
65 2. g1t's own counts for the same days (replaced per day).
66 3. Snapshot `pending_usage` into `pending_days`.
67 4. Reconcile those days into `margin_days` and `workspace_costs`
68 (replaced per day).
69 5. Drift over the last 7 days into `cost_drift`.
70 6. Unit costs over the last 30 days, proposed to the price book.
71 7. Apply price versions whose date has come.
72 8. Open, update and close margin alerts; email new ones.
73 9. Email owners on the plan about rises to come.
74
75**Read the bill now** in sudo (`admin_run_costs`) runs step 2 at once.
76
77## Reconciliation math
78
79Every Cloudflare line goes to one of g1t's products ("buckets") by
80`cost_map`: the row for its product with the longest matching meter
81prefix, `*` last. A line no row claims goes to `unmapped`.
82
83| Bucket | Cloudflare | Paid for by (`revenue_map`) |
84| --- | --- | --- |
85| `sandboxes` | Containers, Durable Objects compute duration | `sandbox`, `self_hosted`, `builds` |
86| `deployments` | Workers for Platforms | `deployments` |
87| `git` | Artifacts operations (and its events, as counts) | `git` |
88| `repo_storage` | Artifacts storage | `storage` |
89| `actions_cache` | R2 | `cache` |
90| `embeddings` | Workers AI, Vectorize | `context` |
91| `security` | (Workers CPU, under `platform`) | `security` |
92| `domains` | Cloudflare for SaaS | `domains` |
93| `models` | not Cloudflare: AI Gateway's settled cost on the ledger | every other task (agent runs) |
94| `platform` | Workers, D1, KV, Queues, Email, Browser Rendering, other Durable Objects | the plan's price |
95
96For each day and bucket:
97
98- **Cloudflare cost** = Σ the bucket's lines' cost. `models` uses the
99 ledger's cost instead.
100- **Own cost** = Σ the ledger's `cost_micros` for the bucket's keys (the
101 price book's cost when charged), plus month-end deltas. A workspace's own
102 model provider is no cost to g1t.
103- **Value** = what customers were charged at price: `-amount_micros` plus
104 what the plan's included usage, a trial, the open-source pool or g1t paid.
105 g1t's own (comped) workspaces are valued at cost plus the margin.
106- **Cash** = what workspaces paid: `-amount_micros`, and the plan's price.
107- **Month-end meters**: a day's figure is that day's `pending_days`
108 snapshot less the day before's, within a month. Their month-end ledger
109 entries are left out, so nothing is counted twice.
110- **Product margin** = (value − cost) / value. **Overall margin** =
111 (Σ cash − Σ cost) / Σ cash.
112- **Quantities**: where a mapping names an `own_meter`, Cloudflare's
113 billed quantity of those lines (or, without one, Artifacts' operation
114 events) against g1t's own count.
115
116**Shared costs to workspaces.** A bucket's cost is shared in proportion
117to, first available: Cloudflare's own per-workspace count
118(`cloudflare_<bucket>`, today the Artifacts events by repository), g1t's
119own count, what each was charged for it, what its usage cost. `platform`
120and `unmapped` are shared by each workspace's share of all usage that
121day. Shares are whole micros that add up to the bill exactly (largest
122remainder).
123
124## Drift (last 7 days)
125
126| Kind | When | What to do |
127| --- | --- | --- |
128| 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). |
129| 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. |
130| 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`). |
131
132## Prices: versions, proposals, notice
133
134- `price_versions` holds every price ever, never edited. `prices` is the
135 version in force. The daily run applies a version once its
136 `effective_at` has come, and adds the public `price_changes` record.
137 Ledger entries made from the price book carry `price_version` (the
138 version ids, comma-separated), so a past statement is always explained
139 by the prices of its day.
140- Proposals come from the keeper (sandbox seconds, app requests and CPU)
141 and the reconciler (mappings with `scale_to_own`: today git operations).
142 For git operations: Cloudflare's rate per its own operation (the median
143 over charged days of cost ÷ quantity) × (Cloudflare's operations ÷ g1t's)
144 × 1,000. If Cloudflare counts three for each one g1t counts, the per-1,000
145 price triples. At least 1,000 of g1t's operations are needed.
146- Decision (`pricing::decide`): under 2% is noise; more than 4× either way
147 is suspect and waits for staff; within `auto_apply_percent` (25%) it is
148 applied on its own when `auto_apply` is on; anything else waits.
149- Notice: a fall applies at once. A rise applies `notice_days` (14) after
150 the decision, and for a monthly meter (git, storage, cache, domains,
151 embeddings, scans) at the start of the month after that, so no month
152 is charged at two prices. Owners of workspaces on the plan are emailed
153 once per rise (`price_notices`), and the pricing page lists it with
154 "takes effect". Rises are never retroactive; margin protection is for
155 new usage once notice has run.
156- Staff approve or reject in sudo. A rejection needs a note.
157
158## Changing a mapping
159
160In sudo, Costs & margin → **Mappings**: Cloudflare's product and meter
161prefix (as **Cloudflare's lines** lists them; `*` for the rest of the
162product), g1t's product, and optionally:
163
164- **Price meter**: the price book meter the line measures.
165- **Own meter**: g1t's count of the same units (`own_counts.meter`).
166- **Scale to g1t's count**: price one of g1t's units at as many of
167 Cloudflare's as it took (proposals as above).
168- **Drift threshold**.
169
170It applies from the next run; **Read the bill now** applies it at once.
171Every change is in the audit log (`cost_mapping`).
172
173## Which raw meters are operations
174
175There is one mapping, and the repos service owns it: `operation_mapping`
176in g1t-repos' database, one row per raw meter with `cost_operations` (how
177many operations Cloudflare bills for it) and `billable_operations` (how
178many the customer is charged for). Change it with repos'
179`set_operation_mapping` RPC (services only), or
180`npx wrangler d1 execute g1t-repos --remote` until sudo has a form. A
181change applies to counts from then on, never to what was counted.
182Billing keeps no mapping of its own: it reads repos' `git_operations`
183(already mapped) for what customers are charged, and `artifacts_usage`
184(raw counts with the mapping) for `cost_operations`. Migration 0023 drops
185the `billable_units` table 0022 made for this, which was never written.
186
187## Alerts runbook
188
189| Alert | Raised when | First steps |
190| --- | --- | --- |
191| 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. |
192| 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). |
193| Leak | Drift of kind leak | Map the meter, or decide it is overhead. |
194| Drift | Count drift | See Drift above. Cloudflare's definitions change in beta: ask them in writing ([ARTIFACTS.md](ARTIFACTS.md), §7). |
195| 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. |
196
197Alerts close on their own when the condition clears. Open ones are
198emailed again weekly. The red bar on every sudo page shows margin,
199overall and leak alerts.
200
201## Tables (migration `0022_costs_and_margin.sql`)
202
203`cost_lines`, `cost_map`, `revenue_map`, `own_counts`,
204`pending_days`, `margin_days`, `workspace_costs`, `cost_drift`,
205`margin_alerts`, `price_versions` (seeded with every current price as
206version 1), `price_proposals`, `price_notices`, `cost_settings` (the
207guardrails, seeded), the `actions_cache` price, and `ledger.price_version`.
208Every create is `IF NOT EXISTS` and every seed `INSERT OR IGNORE`; the one
209`ALTER` is applied once by D1's migration tracking. Migration
210`0023_one_operation_mapping.sql` drops `billable_units` (see above).