g1t/docs/BILLING_OPERATIONS.md

420 lines25,641 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` |
Costs: Cloudflare's count for a pull request's working copy is shared out to its repository's workspace (repos pull_owners)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` |
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily24| 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` |
Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing28| 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) |
One operation mapping, owned by repos; billing reads it instead of keeping its own29| 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 daily30
31A meter's slug is Cloudflare's name lower-cased with words joined by `_`
32and 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| --- | --- | --- |
Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing41| `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 |
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily42| `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
44With neither, the daily run reconciles only what g1t counted itself, and
45the page says the bill cannot be read. Nothing fails. To set the scoped one:
46
471. Cloudflare dashboard → My Profile → API Tokens → Create Token → Custom token.
482. Permissions: Account · Billing · Read; Account · Account Analytics · Read.
493. Account resources: Include · the g1t account. No zone permissions.
504. `cd services/billing && npx wrangler secret put CLOUDFLARE_BILLING_TOKEN`.
sudo: the costs run button is named for what it does, the whole nightly analysis515. In sudo, Costs & margin → **Run the analysis now**.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily52
53Alerts are emailed through the `EMAIL` binding (Cloudflare Email Sending)
54to `COSTS_ALERT_EMAIL` (`hey@flagon.io`). An empty value sends none.
55
56## Schedule
57
58The daily cron (`17 4 * * *`, `keeper::DAILY`) runs, in order:
59
601. The keeper's measurements (sandbox seconds, app requests and CPU), each
61 a proposal now, not a direct change.
622. `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`.
sudo: the costs run button is named for what it does, the whole nightly analysis70 4. Reconcile the last 31 days (further back after a gap) into
71 `margin_days` and `workspace_costs`
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily72 (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
Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing79**Run the analysis now** at the top of sudo's Costs & margin and Bill & pricing pages
sudo: the costs run button is named for what it does, the whole nightly analysis80(`admin_run_costs`) runs all of step 2 at once, alerts included, with no
81need to wait for 04:17 UTC. Running it twice is safe: every step replaces
82what it wrote.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily83
84## Reconciliation math
85
86Every Cloudflare line goes to one of g1t's products ("buckets") by
87`cost_map`: the row for its product with the longest matching meter
88prefix, `*` 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
103For each day and bucket:
104
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running105- **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.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily110- **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.
Costs: margin is measured on what was sold; comped workspaces, free periods, the trial and the pools are given away, a budget shown beside it117- **Given away** = the part of the cost that went on usage g1t paid for
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running118 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`).
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily131- **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.
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running134- **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.
Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing141 - **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.
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running144 - **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.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily152- **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
157to, first available: Cloudflare's own per-workspace count
158(`cloudflare_<bucket>`, today the Artifacts events by repository), g1t's
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running159own count, what its usage cost (so free use carries its own cost), what
160each was charged for it. `platform`
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily161and `unmapped` are shared by each workspace's share of all usage that
162day. Shares are whole micros that add up to the bill exactly (largest
163remainder).
164
165## Drift (last 7 days)
166
167| Kind | When | What to do |
168| --- | --- | --- |
One operation mapping, owned by repos; billing reads it instead of keeping its own169| 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 daily170| 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
Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing201In sudo, Costs & margin → Bill & pricing → **Mappings**: Cloudflare's product and meter
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily202prefix (as **Cloudflare's lines** lists them; `*` for the rest of the
203product), 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
sudo: the costs run button is named for what it does, the whole nightly analysis211It applies from the next run; **Run the analysis now** applies it at once.
One operation mapping, owned by repos; billing reads it instead of keeping its own212Every change is in the audit log (`cost_mapping`).
213
214## Which raw meters are operations
215
216There is one mapping, and the repos service owns it: `operation_mapping`
217in g1t-repos' database, one row per raw meter with `cost_operations` (how
218many operations Cloudflare bills for it) and `billable_operations` (how
219many 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
222change applies to counts from then on, never to what was counted.
223Billing 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
226the `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 daily227
Models' margin read -14%: usage nothing paid for is valued at price, not $0228**Charged at price** (a product's value) is what each day's usage was paid:
229charged to a card or credit, or drawn from the plan's included usage, a
230trial, a pool or a gift. Usage nothing paid for, as in a free period, is
231valued at price (cost plus the margin), since it was given away at its price
232rather than sold for nothing; so are g1t's own workspaces. Runs on a
233workspace's own model provider have no cost to g1t. Every daily run
234reconciles the whole 31-day window from what is already kept, so a change in
235how a day is valued reaches every day sudo shows.
236
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily237## 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. |
Costs: margin is measured on what was sold; comped workspaces, free periods, the trial and the pools are given away, a budget shown beside it242| 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. |
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily243| 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). |
Margin alerts measure what is sold, and say dollars when a percentage would mislead245| 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. |
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily246
247Alerts close on their own when the condition clears. Open ones are
248emailed again weekly. The red bar on every sudo page shows margin,
249overall and leak alerts.
250
Mission control shows model usage, yours and the workspace's: tokens, cost, active days, cache share, each day, and the mix251## Token usage
252
253The model proxy (`services/models`) reads Anthropic's `usage` from every
254`/v1/messages` answer, streamed or whole, on g1t's models and on a
255workspace's own provider alike (OpenAI-shaped providers are translated
256first). Count-tokens requests are not answers and are skipped. After the
257answer, it calls `record_tokens`, which adds input, output, cache reads and
258cache 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
260was for, from the model session's `requested_by`; never g1t's agent. A
261report 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
264workspace or one person: totals, every day's tokens and the active days,
265with `costMicros` the window's run charges from the ledger, measured as
266`usage` measures them. These counts are for views only: runs are still
267priced from AI Gateway's logs, never from `token_usage`.
268
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily269## Tables (migration `0022_costs_and_margin.sql`)
270
One operation mapping, owned by repos; billing reads it instead of keeping its own271`cost_lines`, `cost_map`, `revenue_map`, `own_counts`,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily272`pending_days`, `margin_days`, `workspace_costs`, `cost_drift`,
273`margin_alerts`, `price_versions` (seeded with every current price as
274version 1), `price_proposals`, `price_notices`, `cost_settings` (the
275guardrails, seeded), the `actions_cache` price, and `ledger.price_version`.
276Every 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 own277`ALTER` is applied once by D1's migration tracking. Migration
278`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 pays279
280## Spend caps
281
282Two caps keep what g1t pays for itself bounded while billing takes no
283real money. Both are measured at **cost** (what Cloudflare and the model
284providers charge g1t), never at price. Code: `services/billing/src/budget.rs`.
285Page: sudo **Costs & margin** → **g1t's own spend** (`/costs#spend`).
286
287### What counts as g1t's own spend
288
289Every charge that settles (an agent run's model cost from `finish_run` or
290AI 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
302The plan's included usage and on-demand charges count as revenue only
303with live payments; in test mode they are `unpaid`. A workspace's own
304model provider costs g1t nothing and is not counted. Month-end meters
305(git, storage, scans, embeddings, the cache) are not counted here; the
306daily reconciliation covers them. Migration `0024_spend_caps.sql`
307backfills 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
Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing316`0` turns either off. Cloudflare's subscriptions are read from Cloudflare
317each day (`cf_subscriptions`) and shown on the page only;
318`CLOUDFLARE_FIXED_MONTHLY_MICROS` ($30) stands in until a read works.
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays319
320The checks are cheap: `reserve` reads today's total (one indexed sum) and,
321for a comped account, its month's comped rows. Refusals come back as
322`paused`, which the compute gate honours for every plan, internal and
323enterprise included (`packages/contracts/src/compute.ts`). The runner tells
324billing 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
329All 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
337While the breaker is open or a comped budget is used up, every sudo page
338shows 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.
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept351
sudo: reset a test workspace's billing so it starts again as a new customer; refused on a live Stripe key, for comped workspaces and for an enterprise's352## Resetting a test workspace
353
354sudo → the workspace → **Reset billing (testing)** (`admin_reset_billing`)
355returns a workspace used for testing to how a new customer starts. It
356deletes the workspace's rows from every billing table: ledger and balance,
357plan and plan payments, limits and limit requests, trial grant, invoices,
358holds, card checks, alerts sent, price notices, month-end snapshots and
359closes, storage and sandbox meters, token usage, spikes, sales records and
360notes, `workspace_costs`, its workspace margin alert and its own billing
361account. It keeps `own_counts` (what Cloudflare's bill is compared with)
362and the audit log, which records the reset with the note and the number of
363rows. The workspace, its members and its repositories are identity's and
364repos' and stay.
365
366Billing refuses it while `STRIPE_SECRET_KEY` is a live key, for comped
367workspaces, and for a workspace an enterprise pays for. Afterwards press
368**Run the analysis now** on Costs & margin so the margin figures drop the
369workspace's past usage.
370
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept371## Stripe
372
373Billing keeps what it needs from Stripe so reads never wait on it, and
374hears of changes three ways (`webhooks.rs`, `stripe_sync.rs`).
375
Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard376**The webhook.** A destination made in Stripe's dashboard (Developers →
377Webhooks → Add destination) with the endpoint URL
378`https://api.g1t.sh/stripe/webhook`, in the mode of billing's key (at
379launch, make one in live mode and put its secret). Its signing secret
380(`whsec_…`: the destination, Signing secret, Reveal) is the billing
381Worker's secret:
382
383```sh
384cd services/billing && npx wrangler secret put STRIPE_WEBHOOK_SECRET
385```
386
387Without it every event is refused with 400. After rolling the secret in
388Stripe, put the new one; during the roll Stripe signs with both, so there
389is no gap. The event list need not be exact: billing adds any event it
390handles that the destination does not send (daily, or **Fix destination**
391in sudo → Stripe), and enables it again if Stripe disabled it. It never
392changes the secret. sudo → Stripe shows whether the secret is set, the
393destination and its status, missing events, and the latest events.
394Events are claimed once each in `stripe_events`; a handler that fails
395forgets 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 kept396
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
406list from an hour before `stripe_sync.through`, oldest first, through the
407same once-only claim. `through` moves to 5 minutes before now when all were
408handled, back to the first failure otherwise, and stays when more than
4091,000 events were listed. Claims stuck at `handling` for 10 minutes are
410dropped so the replay retries them. The first run reads 3 days back.
411
Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard412**Daily** (`keeper::DAILY`): the destination at billing's address is
413enabled 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
415again.
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept416
417**What still calls Stripe on a request**: starting a payment page, a plan
418or a card check; opening the billing portal; settling a page the person
419came back from; renaming a workspace (the customer's name). Nothing a page
420view reads.