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