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