Skip to content

Commit

Merge branch 'worktree-agent-a12ebea8611c42ee9'

syntaqxcommitted Parents35ee5220de5f3aBrowse files
10 files+577−520/10 viewed
+1−1
736736 <Section
737737 id="reset"
738738 title="Reset billing (testing)"
739− description="Wipes this workspace's billing: its ledger and balance, plan, limits, trial, invoices, holds, signals and cost rows. The workspace, its members and its repositories stay. Only while billing is on Stripe's test key."
739+ description="Wipes this workspace's billing: its ledger and balance, plan, limits, trial, invoices, holds, signals and cost rows. What its usage cost g1t is kept, and counted on Costs & margin as given away (testing resets). The workspace, its members and its repositories stay. Only while billing is on Stripe's test key."
740740 >
741741 <form method="post" action={`${pathname}#reset`} className="space-y-4">
742742 <input type="hidden" name="intent" value="reset" />
+1−0
217217 ["partial discounts", o.givenDiscountMicros ?? 0],
218218 ["promotional credit", o.givenCreditPromotionalMicros ?? 0],
219219 ["goodwill credit", o.givenCreditGoodwillMicros ?? 0],
220+ ["testing resets", o.givenResetMicros ?? 0],
220221 ].filter(([, micros]) => (micros as number) > 0) as [string, number][];
221222 const rows: { title: string; note: string; in: number | null; cost: number; result: number | null; tone?: "danger" | "warn" | "muted" }[] = [
222223 {
+5−0
29272927 pub given_credit_promotional_micros: i64,
29282928 #[serde(default)]
29292929 pub given_credit_goodwill_micros: i64,
2930+ /// What testing resets wiped that g1t paid for (`reset_costs`): the
2931+ /// model calls and Cloudflare usage still happened, so their cost is
2932+ /// given, never a leak.
2933+ #[serde(default)]
2934+ pub given_reset_micros: i64,
29302935 /// Credits over the range: given (every kind), spent on usage, and
29312936 /// refunds' money given back.
29322937 #[serde(default)]
+40−9
135135 paid for, when it is spent (`given_credit_promotional_micros`,
136136 `given_credit_goodwill_micros`, migration 0038). That usage's charge is
137137 taken out of cash, so it is never money in. A refund is not here: see
138− [Credits from g1t](#credits-from-g1t).
138+ [Credits from g1t](#credits-from-g1t);
139+ - **testing resets**: what a workspace's usage cost g1t before staff
140+ reset its billing (`reset_costs`, `given_reset_micros`, migration
141+ 0046). The model calls and Cloudflare usage still happened, so the
142+ reconciliation reads the kept rows back as that workspace's usage on
143+ their days: valued as before, no cash, all of it given. See
144+ [Resetting a test workspace](#resetting-a-test-workspace).
139145
140146 Otherwise a workspace's day is split by those shares of its value at
141147 price, and the same shares of each of its buckets' cost are given, its
142148 part of running g1t included. The Team plan's included usage is sold:
143149 the plan's price paid for it. Stored on `margin_days` (`given_micros`
144150 and `given_<why>_micros`, `given_discount_micros` from migration 0036)
145− and `workspace_costs` (`given_micros`). Sudo's Bill & pricing page lists
146− comped, free use, trial and pool by name; the discount part is in the
147− total until the page names it (`givenDiscountMicros`).
151+ and `workspace_costs` (`given_micros`). Sudo's Costs & margin page lists
152+ each why by name, testing resets included (`givenResetMicros`).
148153 - **Month-end meters**: a day's figure is that day's `pending_days`
149154 snapshot less the day before's, within a month. Their month-end ledger
150155 entries are left out, so nothing is counted twice.
185190 | --- | --- | --- |
186191 | 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). |
187192 | 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. |
188−| Cost, on `models` | What AI Gateway priced g1t's own provider traffic at over the 7 days, against the ledger's model cost for the same days (billed to g1t: comped, free and trial use included, a workspace's own provider not), more than the `ai_gateway_requests` mapping's `drift_percent` (10%) apart, with at least `min_daily_cost`. A ledger with none of the gateway's cost is drift too, and so is a gateway that priced nothing against a ledger with at least `min_daily_cost` of model cost (no percentage): that is not agreement, it is a token that cannot see AI Gateway, or calls that went around it | The gateway higher: model calls g1t paid for and charged no one: runs not settled yet (they catch up within the hour), runs with no session, a run started without a billing ticket, or something else on g1t's gateway. The ledger higher: runs that reached a provider without the gateway. The detail adds why the gateway's own figure may be off: prompt-cache read and write tokens (the gateway prices them at its rates for cache tokens, which can lag the provider's; check against the provider's invoice), requests Cloudflare billed itself (unified billing: on Cloudflare's bill, not a provider's), and models with no price. Days are UTC by when a request ran (gateway) and when a charge was entered (ledger), so a run across midnight shifts a little between days; the 7-day sum absorbs it. |
193+| Cost, on `models` | What AI Gateway priced g1t's own provider traffic at over the 7 days, against the ledger's model cost for the same days (billed to g1t: comped, free and trial use included, a workspace's own provider not) plus the model cost testing resets kept for those days (`reset_costs`), more than the `ai_gateway_requests` mapping's `drift_percent` (10%) apart, with at least `min_daily_cost`. A ledger with none of the gateway's cost is drift too, and so is a gateway that priced nothing against a ledger with at least `min_daily_cost` of model cost (no percentage): that is not agreement, it is a token that cannot see AI Gateway, or calls that went around it | The gateway higher: model calls g1t paid for and charged no one: runs not settled yet (they catch up within the hour), runs with no session, a run started without a billing ticket, or something else on g1t's gateway. The ledger higher: runs that reached a provider without the gateway. The detail adds why the gateway's own figure may be off: prompt-cache read and write tokens (the gateway prices them at its rates for cache tokens, which can lag the provider's; check against the provider's invoice), requests Cloudflare billed itself (unified billing: on Cloudflare's bill, not a provider's), and models with no price. A testing reset in the window is named in the detail: one that kept its cost says how much of the ledger's side it is; one from before resets kept their cost (the audit log has it, `reset_costs` does not) says the gateway's figure includes usage the ledger no longer has, so that part is not a leak, and the day it leaves the 7 days; while such a reset is in the window the `models` leak is not raised. Days are UTC by when a request ran (gateway) and when a charge was entered (ledger), so a run across midnight shifts a little between days; the 7-day sum absorbs it. |
189194 | Unpriced | Over the 7 days, a model in AI Gateway's analytics with tokens and $0 cost, or runs settled with `runs.gateway_note` (the gateway could not price all of a run) | The gateway has no price for a model g1t runs: add it in the gateway (custom cost) or route away from it. Until then those runs are charged no less than the sandbox reported (Claude Code's own price table), never $0 silently. |
190195 | 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`). |
191196
611616 `ai_gateway_requests` → `models` mapping. Migration
612617 `0038_staff_credits.sql` adds `credit_grants`, `ledger.credit_kind` and
613618 `margin_days.given_credit_{promotional,goodwill}_micros`, and backfills
614−earlier credits (see [Credits from g1t](#credits-from-g1t)).
619+earlier credits (see [Credits from g1t](#credits-from-g1t)). Migration
620+`0046_reset_costs.sql` adds `reset_costs` and `margin_days.given_reset_micros`
621+(see [Resetting a test workspace](#resetting-a-test-workspace)).
615622
616623 ## Spend caps
617624
695702 closes, storage meters, token usage, spikes, sales records and
696703 notes, `workspace_costs`, its workspace margin alert and its own billing
697704 account. It keeps `own_counts` (what Cloudflare's bill is compared with)
698−and the audit log, which records the reset with the note and the number of
699−rows. The workspace, its members and its repositories are identity's and
700−repos' and stay.
705+and the audit log, which records the reset with the note, the number of
706+rows and what g1t had paid for. The workspace, its members and its
707+repositories are identity's and repos' and stay.
708+
709+**What g1t paid for is kept.** The wiped usage still happened: AI Gateway
710+still prices its model calls and Cloudflare still bills its sandboxes. So,
711+in the same batch as the deletes, the reset writes `reset_costs`: a row per
712+day and bucket the workspace had cost on (the ledger's cost, month-end
713+meters' cost, and the value the reconciliation gave it), plus one row for
714+the reset itself (bucket `''`, nothing in it) so every reset is on record.
715+`reset_at` is the same instant as the reset's `admin_actions` entry. The
716+costs run reads the rows back as the workspace's usage on their days, all
717+of it given away as **testing resets**: `models` drift compares AI
718+Gateway with the ledger's model cost plus what resets kept, the statement
719+lists it under **Given away**, and the workspace stays in **Who g1t paid**
720+with its cost given. The rows are never wiped by a later reset, and a
721+rename moves them. Re-running the analysis reads the same rows and gives
722+the same answer.
723+
724+Resets before migration 0046 kept nothing; their wiped rows are gone and
725+nothing is made up for them. The costs run finds them in the audit log
726+(`admin_actions`, action `reset`) with no `reset_costs` at the same
727+instant, and the `models` drift detail says AI Gateway's figure includes
728+usage wiped by a testing reset of that workspace on that day, rather than
729+calling it a leak. syntaqx was reset on 2026-10-07 after about $8.60 of
730+model usage from 2026-10-02 to 2026-10-07; that usage is in the 7-day
731+window until the run of 2026-10-13 and leaves it on 2026-10-14.
701732
702733 Billing refuses it while `STRIPE_SECRET_KEY` is a live key, for comped
703734 workspaces, and for a workspace an enterprise pays for. It then runs the
+2−0
13211321 /** Credits from g1t spent on usage, by kind: given, never money in. Refunds come off money in instead. */
13221322 givenCreditPromotionalMicros?: number;
13231323 givenCreditGoodwillMicros?: number;
1324+ /** What testing resets wiped that g1t paid for: the usage still happened, so its cost is given, never a leak. */
1325+ givenResetMicros?: number;
13241326 /** Credits over the range: given (every kind), spent on usage, and refunds' money given back. */
13251327 creditsGivenMicros?: number;
13261328 creditsUsedMicros?: number;
+32−0
1+-- What a testing reset wiped that g1t really paid for (src/reset.rs). The
2+-- model calls and Cloudflare usage behind a reset workspace's ledger still
3+-- happened: AI Gateway and Cloudflare's bill still show them. The reset
4+-- keeps their cost here, per day and product, before it deletes the
5+-- ledger, and the costs run counts it as given away on purpose (why
6+-- "testing resets"), so drift and the statement keep adding up.
7+--
8+-- One row per day and bucket the workspace had cost on, and one row for
9+-- the reset itself (bucket '', nothing in it) so every reset is on record
10+-- even when it wiped nothing g1t paid for. reset_at is the same instant as
11+-- the reset's admin_actions entry. Never wiped by a reset; moved by a
12+-- rename.
13+CREATE TABLE IF NOT EXISTS reset_costs (
14+ workspace TEXT NOT NULL,
15+ -- The UTC day the wiped usage was charged on.
16+ day TEXT NOT NULL,
17+ -- g1t's product (revenue_map's bucket; models for agent runs), or ''
18+ -- for the reset's own row.
19+ bucket TEXT NOT NULL,
20+ -- What g1t paid for it: the ledger's cost (a workspace's own model
21+ -- provider is none), and month-end meters' cost.
22+ cost_micros INTEGER NOT NULL,
23+ -- What it was valued at, at price, as the reconciliation valued it.
24+ value_micros INTEGER NOT NULL,
25+ reset_at TEXT NOT NULL,
26+ reset_by TEXT NOT NULL,
27+ PRIMARY KEY (workspace, day, bucket, reset_at)
28+);
29+CREATE INDEX IF NOT EXISTS reset_costs_by_day ON reset_costs (day);
30+
31+-- Given away, the new why: usage a testing reset wiped.
32+ALTER TABLE margin_days ADD COLUMN given_reset_micros INTEGER NOT NULL DEFAULT 0;
+8−3
252252 }
253253
254254 pub(crate) async fn audit(&self, account: &str, action: &str, detail: &str, by: &str) -> Result<()> {
255− let now = now_ms();
255+ self.audit_at(account, action, detail, by, now_ms()).await
256+ }
257+
258+ /// `audit`, at a given instant: a testing reset's entry carries the
259+ /// same `created_at` as the `reset_costs` it kept.
260+ pub(crate) async fn audit_at(&self, account: &str, action: &str, detail: &str, by: &str, at_ms: u64) -> Result<()> {
256261 self.db
257262 .prepare("INSERT INTO admin_actions (id, account, action, detail, by, created_at) VALUES (?, ?, ?, ?, ?, ?)")
258263 .bind(&[
259− new_id("adm", now).into(),
264+ new_id("adm", at_ms).into(),
260265 account.into(),
261266 action.into(),
262267 detail.into(),
263268 by.into(),
264− rfc3339(now).into(),
269+ rfc3339(at_ms).into(),
265270 ])?
266271 .run()
267272 .await?;
+385−32
113113 /// free period, free allowances, overruns g1t covered), the trial, and the
114114 /// open-source pool, and discounts on an account's terms (what they took
115115 /// below cost plus the margin, `ledger.discount_micros`), and credits g1t
116−/// staff gave, promotional and goodwill, when spent (`grants`). The Team
117−/// plan's included usage is paid for by the plan's price, so it is sold,
118−/// not given; so is what a refund pays for.
116+/// staff gave, promotional and goodwill, when spent (`grants`), and usage a
117+/// testing reset wiped (`reset_costs`): g1t paid for it and nobody will.
118+/// The Team plan's included usage is paid for by the plan's price, so it is
119+/// sold, not given; so is what a refund pays for.
119120 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
120121 pub(crate) struct Given {
121122 pub comped: i64,
125126 pub discount: i64,
126127 pub credit_promotional: i64,
127128 pub credit_goodwill: i64,
129+ pub reset: i64,
128130 }
129131
130132 impl Given {
131133 pub fn total(&self) -> i64 {
132− self.comped + self.free + self.trial + self.pool + self.discount + self.credit()
134+ self.comped + self.free + self.trial + self.pool + self.discount + self.credit() + self.reset
133135 }
134136
135137 /// Credits from g1t, both kinds.
145147 self.discount += other.discount;
146148 self.credit_promotional += other.credit_promotional;
147149 self.credit_goodwill += other.credit_goodwill;
150+ self.reset += other.reset;
148151 }
149152
150153 /// The same shares of `cost` as these are of `value`, at most all of it.
163166 discount: part(self.discount),
164167 credit_promotional: part(self.credit_promotional),
165168 credit_goodwill: part(self.credit_goodwill),
169+ reset: part(self.reset),
166170 }
167171 }
168172 }
194198 row.cash -= given.credit();
195199 row.given.add(&given);
196200 }
197− None => rows.push(UsageRow { day, workspace, key, value: 0, cash: -given.credit(), cost: 0, given }),
201+ None => rows.push(UsageRow { day, workspace, key, bucket: None, value: 0, cash: -given.credit(), cost: 0, given }),
198202 }
199203 }
200204 for refund in refunds {
229233 pub workspace: String,
230234 /// A ledger task (or `builds`), a month-end source, or `plan`.
231235 pub key: String,
236+ /// The bucket, where it is known already (what a testing reset kept,
237+ /// `reset_costs`); else `key`'s, from `revenue_map`.
238+ pub bucket: Option<String>,
232239 pub value: i64,
233240 pub cash: i64,
234241 pub cost: i64,
336343 let g = gave.entry((u.day.clone(), u.workspace.clone())).or_default();
337344 g.0.add(&u.given);
338345 g.1 += u.value;
339− let bucket = bucket_of(&u.key);
346+ let bucket = u.bucket.clone().unwrap_or_else(|| bucket_of(&u.key));
340347 let key = (u.day.clone(), bucket.clone());
341348 let row = days.entry(key.clone()).or_insert_with(|| entry(&key.0, &key.1));
342349 row.own_cost_micros += u.cost;
418425 };
419426 let (cost, charge) = ((cost - before_cost).max(0), (charge - before_charge).max(0));
420427 if cost > 0 || charge > 0 {
421− out.push(UsageRow { day: day.clone(), workspace: workspace.clone(), key: source.clone(), value: charge, cash: charge, cost, given: Given::default() });
428+ out.push(UsageRow { day: day.clone(), workspace: workspace.clone(), key: source.clone(), bucket: None, value: charge, cash: charge, cost, given: Given::default() });
422429 }
423430 previous = Some(snap);
424431 }
545552 notes
546553 }
547554
548−/// The models drift's detail: the gateway's total against the ledger's.
549−pub(crate) fn models_detail(drift: &Drift, caveats: &costs::GatewayCaveats) -> String {
555+/// Where a testing reset's history starts: all of a workspace's ledger.
556+pub(crate) const RESET_HISTORY_FROM: &str = "2000-01-01";
557+
558+/// What a testing reset wiped that g1t paid for, on one day for one
559+/// bucket (`reset_costs`).
560+#[derive(Clone, Debug, PartialEq)]
561+pub(crate) struct Wiped {
562+ pub day: String,
563+ pub bucket: String,
564+ pub cost: i64,
565+ pub value: i64,
566+}
567+
568+/// A workspace's usage rows, about to be wiped, as what g1t paid for: the
569+/// rows with a cost, by day and bucket, valued as the reconciliation
570+/// valued them (at price where nothing paid). Plan payments, credits and
571+/// a workspace's own model provider cost g1t nothing and are left out.
572+pub(crate) fn wiped(rows: &[UsageRow], revenue_map: &BTreeMap<String, String>, margin_percent: u32) -> Vec<Wiped> {
573+ let mut by: BTreeMap<(String, String), (i64, i64)> = BTreeMap::new();
574+ for u in rows.iter().filter(|u| u.cost > 0) {
575+ let bucket = u.bucket.clone().unwrap_or_else(|| revenue_map.get(&u.key).cloned().unwrap_or_else(|| NOT_CLOUDFLARE[0].to_owned()));
576+ let sums = by.entry((u.day.clone(), bucket)).or_default();
577+ sums.0 += u.cost;
578+ sums.1 += u.value.max(0);
579+ }
580+ by.into_iter()
581+ .map(|((day, bucket), (cost, value))| Wiped {
582+ day,
583+ bucket,
584+ cost,
585+ value: if value > 0 { value } else { crate::credits::with_margin(cost, margin_percent) },
586+ })
587+ .collect()
588+}
589+
590+/// What testing resets kept, each (day, workspace, bucket, cost, value),
591+/// as usage rows: valued as before, nothing paid, all of it given away
592+/// (why "testing resets"). A reset's own row (bucket '') is not usage.
593+pub(crate) fn reset_usage(kept: &[(String, String, String, i64, i64)]) -> Vec<UsageRow> {
594+ kept.iter()
595+ .filter(|(_, _, bucket, cost, value)| !bucket.is_empty() && (*cost != 0 || *value != 0))
596+ .map(|(day, workspace, bucket, cost, value)| UsageRow {
597+ day: day.clone(),
598+ workspace: workspace.clone(),
599+ key: "reset".into(),
600+ bucket: Some(bucket.clone()),
601+ value: *value,
602+ cash: 0,
603+ cost: *cost,
604+ given: Given { reset: *value, ..Given::default() },
605+ })
606+ .collect()
607+}
608+
609+/// A testing reset inside the drift window.
610+#[derive(Clone, Debug, PartialEq)]
611+pub(crate) struct ResetNote {
612+ pub workspace: String,
613+ /// The UTC day it was reset.
614+ pub day: String,
615+ /// Whether it kept what it wiped (`reset_costs`, migration 0046):
616+ /// then the ledger's side has it, given away. A reset from before
617+ /// that wiped model usage the gateway still counts.
618+ pub recorded: bool,
619+ /// Of what it kept, model cost on the window's days.
620+ pub models_micros: i64,
621+}
622+
623+/// The resets: each audit entry (account `ws_<slug>`, when) and each kept
624+/// reset (workspace, reset_at, its model cost in the window). An audit
625+/// entry with no kept reset at the same instant is from before resets kept
626+/// what they wiped.
627+pub(crate) fn reset_notes(audits: &[(String, String)], kept: &[(String, String, i64)]) -> Vec<ResetNote> {
628+ let mut notes: Vec<(String, ResetNote)> = kept
629+ .iter()
630+ .map(|(workspace, at, models)| {
631+ (at.clone(), ResetNote { workspace: workspace.clone(), day: at[..10.min(at.len())].to_owned(), recorded: true, models_micros: *models })
632+ })
633+ .collect();
634+ for (account, at) in audits {
635+ let workspace = account.strip_prefix("ws_").unwrap_or(account);
636+ if !kept.iter().any(|(w, a, _)| w == workspace && a == at) {
637+ notes.push((at.clone(), ResetNote { workspace: workspace.to_owned(), day: at[..10.min(at.len())].to_owned(), recorded: false, models_micros: 0 }));
638+ }
639+ }
640+ notes.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.workspace.cmp(&b.1.workspace)));
641+ notes.into_iter().map(|(_, n)| n).collect()
642+}
643+
644+/// Model usage a reset wiped before resets kept it is not a leak: while
645+/// such a reset is in the window the models leak is not raised, and the
646+/// models cost drift says what the gap is.
647+pub(crate) fn wiped_not_leaked(drift: &Drift, resets: &[ResetNote]) -> bool {
648+ drift.kind == DriftKind::Leak && NOT_CLOUDFLARE.contains(&drift.bucket.as_str()) && resets.iter().any(|r| !r.recorded)
649+}
650+
651+/// What the models drift says about resets in the window.
652+fn reset_sentences(resets: &[ResetNote]) -> Vec<String> {
653+ resets
654+ .iter()
655+ .filter_map(|r| {
656+ if !r.recorded {
657+ Some(format!(
658+ "AI Gateway's figure includes model usage wiped by a testing reset of {} on {}, from before resets kept what they wiped: the ledger no longer has it, so that part of the gap is the reset, not a leak. It leaves the {DRIFT_DAYS} days on {}.",
659+ r.workspace,
660+ r.day,
661+ day_after(&r.day, DRIFT_DAYS)
662+ ))
663+ } else if r.models_micros > 0 {
664+ Some(format!(
665+ "The ledger's figure includes {} of model cost wiped by a testing reset of {} on {}, counted as given away (testing resets).",
666+ dollars(r.models_micros),
667+ r.workspace,
668+ r.day
669+ ))
670+ } else {
671+ None
672+ }
673+ })
674+ .collect()
675+}
676+
677+/// The models drift's detail: the gateway's total against the ledger's,
678+/// and any testing reset in the window.
679+pub(crate) fn models_detail(drift: &Drift, caveats: &costs::GatewayCaveats, resets: &[ResetNote]) -> String {
550680 if drift.cloudflare <= 0.0 {
551681 return format!(
552682 "Models: the ledger's model cost is {} over the last {DRIFT_DAYS} days and AI Gateway priced nothing, so the two were not compared. Either the gateway's analytics cannot be seen (Cloudflare answers a token without AI Gateway: Read with no rows, not an error; billing reads them with CLOUDFLARE_USAGE_TOKEN, then CLOUDFLARE_BILLING_TOKEN), or model calls went around the gateway.",
554684 );
555685 }
556686 let lower = drift.ours < drift.cloudflare;
687+ let wiped = resets.iter().any(|r| !r.recorded);
557688 let mut detail = format!(
558689 "Models: AI Gateway priced g1t's own provider traffic at {} over the last {DRIFT_DAYS} days; the ledger's model cost for the same days is {} ({:+.1}%). {}",
559690 dollars(drift.cloudflare as i64),
560691 dollars(drift.ours as i64),
561692 drift.delta_percent.unwrap_or(0.0),
562− if lower {
693+ if lower && wiped {
694+ "The gateway counts model calls the ledger no longer has: a testing reset wiped them (below). Beyond that, runs not yet settled, runs with no session, or calls with no run."
695+ } else if lower {
563696 "Model calls g1t paid for were not charged: runs not yet settled, runs with no session, or calls with no run (the ledger catches up as runs settle; a gap that stays is a leak)."
564697 } else {
565698 "The ledger counts more than the gateway priced: runs that went to a provider without the gateway, or sandbox reports the gateway could not correct."
566699 }
567700 );
701+ for sentence in reset_sentences(resets) {
702+ detail.push(' ');
703+ detail.push_str(&sentence);
704+ }
568705 let notes = caveat_notes(caveats);
569706 if !notes.is_empty() {
570707 detail.push_str(" The gateway's cost may be off: ");
729866 rfc3339(ms.saturating_sub(days * DAY_MS))[..10].to_owned()
730867 }
731868
869+fn day_after(day: &str, days: u64) -> String {
870+ let ms = g1t_contracts::time::parse_rfc3339(&format!("{day}T00:00:00Z")).unwrap_or(0);
871+ rfc3339(ms + days * DAY_MS)[..10].to_owned()
872+}
873+
732874 /// Dollars to the cent from a dollar up, finer below: `$17.02`, `$0.063`.
733875 fn dollars(micros: i64) -> String {
734876 if micros.abs() >= 1_000_000 {
831973 given_credit_promotional_micros: Option<i64>,
832974 #[serde(default)]
833975 given_credit_goodwill_micros: Option<i64>,
976+ #[serde(default)]
977+ given_reset_micros: Option<i64>,
834978 }
835979
836980 impl From<MarginRow> for ProductDay {
852996 discount: r.given_discount_micros.unwrap_or(0),
853997 credit_promotional: r.given_credit_promotional_micros.unwrap_or(0),
854998 credit_goodwill: r.given_credit_goodwill_micros.unwrap_or(0),
999+ reset: r.given_reset_micros.unwrap_or(0),
8551000 },
8561001 }
8571002 }
9241069 Ok(())
9251070 }
9261071
927− /// What customers were charged on the days, by workspace and key.
928− async fn usage_rows(&self, since: &str, until: &str) -> Result<Vec<UsageRow>> {
1072+ /// What customers were charged on the days, by workspace and key: every
1073+ /// workspace's, or only `only`'s.
1074+ async fn usage_rows(&self, since: &str, until: &str, only: Option<&str>) -> Result<Vec<UsageRow>> {
1075+ let only_sql = only.unwrap_or("");
9291076 #[derive(Deserialize)]
9301077 struct Row {
9311078 day: String,
9591106 SUM(COALESCE(cost_micros, 0)) AS cost
9601107 FROM ledger
9611108 WHERE kind = 'usage' AND created_at >= ?1 AND created_at <= ?2 AND COALESCE(task, '') NOT IN ({charged_here})
1109+ AND (?3 = '' OR workspace = ?3)
9621110 GROUP BY 1, 2, 3, 4, 5",
9631111 internal = crate::sales::INTERNAL_SQL
9641112 ))
965− .bind(&[since.into(), end.as_str().into()])?
1113+ .bind(&[since.into(), end.as_str().into(), only_sql.into()])?
9661114 .all()
9671115 .await?
9681116 .results::<Row>()?;
9901138 if r.internal == 1 {
9911139 internal.insert(r.workspace.clone());
9921140 }
993− UsageRow { day: r.day, workspace: r.workspace, key: r.key, value, cash, cost, given }
1141+ UsageRow { day: r.day, workspace: r.workspace, key: r.key, bucket: None, value, cash, cost, given }
9941142 })
9951143 .collect();
9961144 // Credits from g1t: what promotional and goodwill credit paid for
9971145 // is given, not money in; a refund gives money back on its day.
998− let (draws, refunds) = self.credit_effects(since, until).await?;
1146+ let (mut draws, mut refunds) = self.credit_effects(since, until).await?;
1147+ if let Some(only) = only {
1148+ draws.retain(|(workspace, _)| workspace == only);
1149+ refunds.retain(|r| r.workspace == only);
1150+ }
9991151 apply_credits(&mut out, &draws, &refunds);
10001152 // Month-end sources, from their daily snapshots.
10011153 #[derive(Deserialize)]
10081160 }
10091161 let snaps = self
10101162 .db
1011− .prepare("SELECT day, workspace, source, cost_micros, charge_micros FROM pending_days WHERE day >= ?1 AND day <= ?2")
1012− .bind(&[day_before(since, 1).into(), until.into()])?
1163+ .prepare("SELECT day, workspace, source, cost_micros, charge_micros FROM pending_days WHERE day >= ?1 AND day <= ?2 AND (?3 = '' OR workspace = ?3)")
1164+ .bind(&[day_before(since, 1).into(), until.into(), only_sql.into()])?
10131165 .all()
10141166 .await?
10151167 .results::<Snap>()?
10351187 .db
10361188 .prepare(
10371189 "SELECT substr(paid_at, 1, 10) AS day, workspace, SUM(amount_micros) AS micros FROM plan_payments
1038− WHERE paid_at >= ?1 AND paid_at <= ?2 GROUP BY 1, 2",
1190+ WHERE paid_at >= ?1 AND paid_at <= ?2 AND (?3 = '' OR workspace = ?3) GROUP BY 1, 2",
10391191 )
1040− .bind(&[day_before(since, PLAN_DAYS - 1).into(), end.as_str().into()])?
1192+ .bind(&[day_before(since, PLAN_DAYS - 1).into(), end.as_str().into(), only_sql.into()])?
10411193 .all()
10421194 .await?
10431195 .results::<Plan>()?;
10441196 for p in plans {
10451197 for (day, micros) in spread(&p.day, p.micros.unwrap_or(0), PLAN_DAYS) {
10461198 if day.as_str() >= since && day.as_str() <= until {
1047− out.push(UsageRow { day, workspace: p.workspace.clone(), key: "plan".into(), value: micros, cash: micros, cost: 0, given: Given::default() });
1199+ out.push(UsageRow { day, workspace: p.workspace.clone(), key: "plan".into(), bucket: None, value: micros, cash: micros, cost: 0, given: Given::default() });
10481200 }
10491201 }
10501202 }
10511203 Ok(out)
10521204 }
10531205
1054− /// Reconciles the days and writes `margin_days` and `workspace_costs`.
1055− async fn reconcile_range(&self, since: &str, until: &str) -> Result<u32> {
1056− let rules = self.rules().await?;
1206+ /// Which bucket each ledger key (and month-end source) is revenue of.
1207+ async fn revenue_map(&self) -> Result<BTreeMap<String, String>> {
10571208 #[derive(Deserialize)]
10581209 struct Map {
10591210 key: String,
10601211 bucket: String,
10611212 }
1062− let revenue_map: BTreeMap<String, String> = self
1213+ Ok(self
10631214 .db
10641215 .prepare("SELECT key, bucket FROM revenue_map")
10651216 .all()
10671218 .results::<Map>()?
10681219 .into_iter()
10691220 .map(|m| (m.key, m.bucket))
1070− .collect();
1221+ .collect())
1222+ }
1223+
1224+ /// What a testing reset of `workspace` is about to wipe that g1t paid
1225+ /// for, a row per day and bucket: its whole ledger and month-end
1226+ /// snapshots, valued as the reconciliation values them.
1227+ pub(crate) async fn wiped_by_reset(&self, workspace: &str) -> Result<Vec<Wiped>> {
1228+ let today = rfc3339(now_ms())[..10].to_owned();
1229+ let rows = self.usage_rows(RESET_HISTORY_FROM, &today, Some(workspace)).await?;
1230+ Ok(wiped(&rows, &self.revenue_map().await?, self.margin_percent))
1231+ }
1232+
1233+ /// What testing resets kept for the days, as usage rows.
1234+ async fn reset_rows(&self, since: &str, until: &str) -> Result<Vec<UsageRow>> {
1235+ #[derive(Deserialize)]
1236+ struct Kept {
1237+ day: String,
1238+ workspace: String,
1239+ bucket: String,
1240+ cost: Option<i64>,
1241+ value: Option<i64>,
1242+ }
1243+ let kept = self
1244+ .db
1245+ .prepare(
1246+ "SELECT day, workspace, bucket, SUM(cost_micros) AS cost, SUM(value_micros) AS value FROM reset_costs
1247+ WHERE day >= ?1 AND day <= ?2 AND bucket <> '' GROUP BY day, workspace, bucket",
1248+ )
1249+ .bind(&[since.into(), until.into()])?
1250+ .all()
1251+ .await?
1252+ .results::<Kept>()?;
1253+ Ok(reset_usage(
1254+ &kept.into_iter().map(|k| (k.day, k.workspace, k.bucket, k.cost.unwrap_or(0), k.value.unwrap_or(0))).collect::<Vec<_>>(),
1255+ ))
1256+ }
1257+
1258+ /// Testing resets on or after `since` (the day they wiped usage up to
1259+ /// is their own, so one before it wiped nothing in the days): those
1260+ /// that kept what they wiped (`reset_costs`) and those from before
1261+ /// resets did, known only from the audit log.
1262+ async fn resets_since(&self, since: &str, until: &str) -> Result<Vec<ResetNote>> {
1263+ let end = format!("{until}T23:59:59.999Z");
1264+ #[derive(Deserialize)]
1265+ struct Audit {
1266+ account: String,
1267+ created_at: String,
1268+ }
1269+ let audits = self
1270+ .db
1271+ .prepare("SELECT account, created_at FROM admin_actions WHERE action = 'reset' AND created_at >= ?1 AND created_at <= ?2")
1272+ .bind(&[since.into(), end.as_str().into()])?
1273+ .all()
1274+ .await?
1275+ .results::<Audit>()?;
1276+ #[derive(Deserialize)]
1277+ struct Kept {
1278+ workspace: String,
1279+ reset_at: String,
1280+ models: Option<i64>,
1281+ }
1282+ let kept = self
1283+ .db
1284+ .prepare(
1285+ "SELECT workspace, reset_at, SUM(CASE WHEN bucket = ?3 AND day >= ?1 THEN cost_micros ELSE 0 END) AS models
1286+ FROM reset_costs WHERE reset_at >= ?1 AND reset_at <= ?2 GROUP BY workspace, reset_at",
1287+ )
1288+ .bind(&[since.into(), end.as_str().into(), NOT_CLOUDFLARE[0].into()])?
1289+ .all()
1290+ .await?
1291+ .results::<Kept>()?;
1292+ Ok(reset_notes(
1293+ &audits.into_iter().map(|a| (a.account, a.created_at)).collect::<Vec<_>>(),
1294+ &kept.into_iter().map(|k| (k.workspace, k.reset_at, k.models.unwrap_or(0))).collect::<Vec<_>>(),
1295+ ))
1296+ }
1297+
1298+ /// Reconciles the days and writes `margin_days` and `workspace_costs`.
1299+ async fn reconcile_range(&self, since: &str, until: &str) -> Result<u32> {
1300+ let rules = self.rules().await?;
1301+ let revenue_map = self.revenue_map().await?;
10711302 let lines = self
10721303 .db
10731304 .prepare("SELECT day, source, product, meter, quantity, cost_usd FROM cost_lines WHERE day >= ?1 AND day <= ?2")
10821313 .all()
10831314 .await?
10841315 .results::<OwnRow>()?;
1085− let usage = self.usage_rows(since, until).await?;
1316+ let mut usage = self.usage_rows(since, until, None).await?;
1317+ // What testing resets wiped: still paid for, now given away.
1318+ usage.extend(self.reset_rows(since, until).await?);
10861319 #[derive(Deserialize)]
10871320 struct Internal {
10881321 workspace: String,
11101343 statements.push(
11111344 self.db
11121345 .prepare(
1113− "INSERT OR REPLACE INTO margin_days (day, bucket, cf_cost_micros, own_cost_micros, value_micros, cash_micros, cf_quantity, own_quantity, given_micros, given_comped_micros, given_free_micros, given_trial_micros, given_pool_micros, given_discount_micros, given_credit_promotional_micros, given_credit_goodwill_micros, computed_at)
1114− VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1346+ "INSERT OR REPLACE INTO margin_days (day, bucket, cf_cost_micros, own_cost_micros, value_micros, cash_micros, cf_quantity, own_quantity, given_micros, given_comped_micros, given_free_micros, given_trial_micros, given_pool_micros, given_discount_micros, given_credit_promotional_micros, given_credit_goodwill_micros, given_reset_micros, computed_at)
1347+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
11151348 )
11161349 .bind(&[
11171350 d.day.as_str().into(),
11301363 (d.given.discount as f64).into(),
11311364 (d.given.credit_promotional as f64).into(),
11321365 (d.given.credit_goodwill as f64).into(),
1366+ (d.given.reset as f64).into(),
11331367 now.as_str().into(),
11341368 ])?,
11351369 );
12181452 by.entry(d.bucket.clone()).or_default().push(d);
12191453 }
12201454 let caveats = self.gateway_caveats(&since, until).await?;
1455+ let resets = self.resets_since(&since, until).await?;
12211456 let mut found = Vec::new();
12221457 if let Some(drift) = unpriced_drift(&caveats) {
12231458 found.push(drift);
12281463 let threshold = if threshold.is_finite() { threshold } else { 10.0 };
12291464 let counted = bucket_rules.iter().any(|r| r.own_meter.is_some());
12301465 for drift in drifts(bucket, days, threshold, counted, settings.min_daily_cost_micros) {
1466+ if wiped_not_leaked(&drift, &resets) {
1467+ continue;
1468+ }
12311469 let title = costs::bucket_title(bucket);
12321470 let detail = match drift.kind {
1233− DriftKind::Cost if NOT_CLOUDFLARE.contains(&bucket.as_str()) => models_detail(&drift, &caveats),
1471+ DriftKind::Cost if NOT_CLOUDFLARE.contains(&bucket.as_str()) => models_detail(&drift, &caveats, &resets),
12341472 DriftKind::Count => format!(
12351473 "{title}: g1t counted {}, Cloudflare {} over the last {DRIFT_DAYS} days ({:+.1}%). Customers are charged for what g1t counts; check what Cloudflare counts as a unit and change the repos service's operation_mapping (set_operation_mapping).",
12361474 crate::features::thousands(drift.ours.max(0.0).round() as u64),
16811919 overall.given_discount_micros += d.given.discount;
16821920 overall.given_credit_promotional_micros += d.given.credit_promotional;
16831921 overall.given_credit_goodwill_micros += d.given.credit_goodwill;
1922+ overall.given_reset_micros += d.given.reset;
16841923 let sold = (d.cost() - d.given.total()).max(0);
16851924 if OVERHEAD.contains(&d.bucket.as_str()) {
16861925 overall.plans_micros += d.cash_micros;
19502189 }
19512190
19522191 fn usage(day: &str, workspace: &str, key: &str, value: i64, cash: i64, cost: i64) -> UsageRow {
1953− UsageRow { day: day.into(), workspace: workspace.into(), key: key.into(), value, cash, cost, given: Given::default() }
2192+ UsageRow { day: day.into(), workspace: workspace.into(), key: key.into(), bucket: None, value, cash, cost, given: Given::default() }
19542193 }
19552194
19562195 #[test]
22302469 // rows), so it is said. Under the minimum, or no model cost: nothing.
22312470 let silent = drifts("models", &[day("models", 0, 1_000_000, 1_200_000, 0.0, 0.0)], 10.0, false, 100_000);
22322471 assert_eq!(silent, vec![Drift { bucket: "models".into(), kind: DriftKind::Cost, ours: 1_000_000.0, cloudflare: 0.0, delta_percent: None }]);
2233− let said = models_detail(&silent[0], &costs::GatewayCaveats::default());
2472+ let said = models_detail(&silent[0], &costs::GatewayCaveats::default(), &[]);
22342473 assert!(said.contains("$1.00") && said.contains("priced nothing") && said.contains("AI Gateway: Read"), "{said}");
22352474 assert!(drifts("models", &[day("models", 0, 50_000, 60_000, 0.0, 0.0)], 10.0, false, 100_000).is_empty());
22362475 assert!(drifts("models", &[day("models", 0, 0, 0, 0.0, 0.0)], 10.0, false, 100_000).is_empty());
22372476 // The detail says which way and why it may be off.
22382477 let caveats = costs::GatewayCaveats { cache_read_tokens: 3_000_000.0, unpriced: vec!["anthropic_claude_new_1".into()], ..Default::default() };
2239− let detail = models_detail(&short[0], &caveats);
2478+ let detail = models_detail(&short[0], &caveats, &[]);
22402479 assert!(detail.contains("$5.00") && detail.contains("$3.00") && detail.contains("were not charged"), "{detail}");
22412480 assert!(detail.contains("3,000,000 prompt-cache read") && detail.contains("no price for anthropic_claude_new_1"), "{detail}");
22422481 }
22752514 assert_eq!(unit_size("million requests"), 1e6);
22762515 assert_eq!(unit_size("second"), 1.0);
22772516 }
2517+
2518+ /// The case that started it: syntaqx's ~$8.62 of model usage was wiped
2519+ /// by a testing reset, AI Gateway still priced all $11.11, and the
2520+ /// ledger had $2.49 left.
2521+ fn gateway_and_ledger(kept: bool) -> (Vec<ProductDay>, Vec<WorkspaceDay>) {
2522+ let rules = vec![rule("ai_gateway_requests", "*", "models", None), rule("containers", "*", "sandboxes", None)];
2523+ let lines = vec![
2524+ line("2026-10-05", costs::SOURCE_GATEWAY, "ai_gateway_requests", "anthropic_claude_opus_5_5", 1.0, 11.11),
2525+ line("2026-10-05", SOURCE_BILLABLE, "containers", "container_memory", 10.0, 0.30),
2526+ ];
2527+ let mut usage = vec![usage("2026-10-05", "acme", "implement", 2_988_000, 2_988_000, 2_490_000), usage("2026-10-05", "acme", "sandbox", 120_000, 120_000, 100_000)];
2528+ if kept {
2529+ // What the reset kept (reset_costs), read back for the day.
2530+ usage.extend(reset_usage(&[
2531+ ("2026-10-05".into(), "syntaqx".into(), "models".into(), 8_620_000, 10_344_000),
2532+ ("2026-10-05".into(), "syntaqx".into(), "sandboxes".into(), 100_000, 120_000),
2533+ // The reset's own row is not usage.
2534+ ("2026-10-07".into(), "syntaqx".into(), String::new(), 0, 0),
2535+ ]));
2536+ }
2537+ fold(&rules, &revenue_map(), &lines, &[], &usage, &BTreeSet::new())
2538+ }
2539+
2540+ #[test]
2541+ fn what_a_reset_kept_is_on_the_ledgers_side_of_the_models_drift() {
2542+ let models = |days: &[ProductDay]| days.iter().find(|d| d.bucket == "models").cloned().unwrap();
2543+ // Without it: AI Gateway's $11.11 against the ledger's $2.49.
2544+ let (days, _) = gateway_and_ledger(false);
2545+ let drift = drifts("models", &[models(&days)], 10.0, false, 100_000);
2546+ assert_eq!(drift.iter().map(|d| d.kind).collect::<Vec<_>>(), vec![DriftKind::Cost]);
2547+ assert_eq!((drift[0].ours, drift[0].cloudflare), (2_490_000.0, 11_110_000.0));
2548+ // With it: the ledger's model cost and the reset's add up to the gateway's.
2549+ let (days, _) = gateway_and_ledger(true);
2550+ let m = models(&days);
2551+ assert_eq!(m.own_cost_micros, 11_110_000);
2552+ assert!(drifts("models", &[m], 10.0, false, 100_000).is_empty());
2553+ // The reset's own row makes no bucket of its own.
2554+ assert!(!days.iter().any(|d| d.bucket.is_empty()));
2555+ }
2556+
2557+ #[test]
2558+ fn what_a_reset_kept_is_given_away_as_testing_resets() {
2559+ let (days, workspaces) = gateway_and_ledger(true);
2560+ let models = days.iter().find(|d| d.bucket == "models").unwrap();
2561+ // All of syntaqx's model cost is given, none of it money in.
2562+ assert_eq!(models.given, Given { reset: 8_620_000, ..Given::default() });
2563+ assert_eq!(models.cash_micros, 2_988_000);
2564+ // Cloudflare's sandbox cost is shared by what each workspace's usage
2565+ // cost: syntaqx's half is given too.
2566+ let sandboxes = days.iter().find(|d| d.bucket == "sandboxes").unwrap();
2567+ assert_eq!((sandboxes.cost(), sandboxes.given.reset), (300_000, 150_000));
2568+ // Who g1t paid: syntaqx is still on it, all of its cost given.
2569+ let syntaqx: Vec<&WorkspaceDay> = workspaces.iter().filter(|w| w.workspace == "syntaqx").collect();
2570+ assert_eq!(syntaqx.iter().map(|w| w.cost).sum::<i64>(), 8_770_000);
2571+ assert!(syntaqx.iter().all(|w| w.given.reset == w.cost && w.given.total() == w.cost && w.revenue == 0));
2572+ // The statement reads it back from margin_days by why.
2573+ let row = MarginRow {
2574+ day: models.day.clone(),
2575+ bucket: models.bucket.clone(),
2576+ cf_cost_micros: models.cf_cost_micros,
2577+ own_cost_micros: models.own_cost_micros,
2578+ value_micros: models.value_micros,
2579+ cash_micros: models.cash_micros,
2580+ cf_quantity: 0.0,
2581+ own_quantity: 0.0,
2582+ given_comped_micros: Some(0),
2583+ given_free_micros: Some(0),
2584+ given_trial_micros: Some(0),
2585+ given_pool_micros: Some(0),
2586+ given_discount_micros: Some(0),
2587+ given_credit_promotional_micros: Some(0),
2588+ given_credit_goodwill_micros: Some(0),
2589+ given_reset_micros: Some(models.given.reset),
2590+ };
2591+ assert_eq!(ProductDay::from(row).given, models.given);
2592+ }
2593+
2594+ #[test]
2595+ fn reconciling_again_gives_the_same_answer() {
2596+ assert_eq!(gateway_and_ledger(true), gateway_and_ledger(true));
2597+ // A reset's kept rows are read back exactly as kept: running it
2598+ // again cannot count them twice.
2599+ let kept = [("2026-10-05".to_string(), "syntaqx".to_string(), "models".to_string(), 8_620_000, 10_344_000)];
2600+ assert_eq!(reset_usage(&kept), reset_usage(&kept));
2601+ assert_eq!(reset_usage(&kept).len(), 1);
2602+ }
2603+
2604+ #[test]
2605+ fn a_reset_from_before_resets_kept_their_cost_is_said_not_called_a_leak() {
2606+ let notes = reset_notes(
2607+ &[("ws_syntaqx".into(), "2026-10-07T09:41:00.000Z".into()), ("ws_acme".into(), "2026-10-08T01:00:00.000Z".into())],
2608+ &[("acme".into(), "2026-10-08T01:00:00.000Z".into(), 1_500_000)],
2609+ );
2610+ assert_eq!(
2611+ notes,
2612+ vec![
2613+ ResetNote { workspace: "syntaqx".into(), day: "2026-10-07".into(), recorded: false, models_micros: 0 },
2614+ ResetNote { workspace: "acme".into(), day: "2026-10-08".into(), recorded: true, models_micros: 1_500_000 },
2615+ ]
2616+ );
2617+ let drift = Drift { bucket: "models".into(), kind: DriftKind::Cost, ours: 2_490_000.0, cloudflare: 11_110_000.0, delta_percent: Some(-77.6) };
2618+ let detail = models_detail(&drift, &costs::GatewayCaveats::default(), &notes);
2619+ assert!(detail.contains("includes model usage wiped by a testing reset of syntaqx on 2026-10-07"), "{detail}");
2620+ assert!(detail.contains("not a leak") && detail.contains("leaves the 7 days on 2026-10-14"), "{detail}");
2621+ assert!(!detail.contains("a gap that stays is a leak"), "{detail}");
2622+ assert!(detail.contains("$1.50 of model cost wiped by a testing reset of acme on 2026-10-08, counted as given away (testing resets)"), "{detail}");
2623+ // The models leak is not raised while such a reset is in the window.
2624+ let leak = Drift { bucket: "models".into(), kind: DriftKind::Leak, ours: 0.0, cloudflare: 11_110_000.0, delta_percent: None };
2625+ assert!(wiped_not_leaked(&leak, &notes));
2626+ assert!(!wiped_not_leaked(&leak, &notes[1..]));
2627+ assert!(!wiped_not_leaked(&Drift { bucket: "actions_cache".into(), ..leak }, &notes));
2628+ // No reset: the detail is as before.
2629+ assert!(models_detail(&drift, &costs::GatewayCaveats::default(), &[]).contains("a gap that stays is a leak"));
2630+ }
22782631 }
+4−1
113113 "UPDATE OR IGNORE own_counts SET workspace = ?1 WHERE workspace = ?2",
114114 "DELETE FROM own_counts WHERE workspace = ?2",
115115 "UPDATE margin_alerts SET subject = ?1 WHERE kind = 'workspace' AND subject = ?2",
116+ // What testing resets wiped that g1t paid for: kept, under the new slug
117+ // (a row under both slugs at the same instant cannot happen).
118+ "UPDATE OR IGNORE reset_costs SET workspace = ?1 WHERE workspace = ?2",
116119 // Holds, spikes, requests and the plan's payments: many per workspace.
117120 "UPDATE reservations SET workspace = ?1 WHERE workspace = ?2",
118121 "UPDATE reservations SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'",
316319 "allowance_use", "trial_grants", "credit_grants", "storage_days",
317320 "reservations", "spikes", "limit_requests", "plan_payments", "card_checks", "alerts_sent",
318321 "package_storage_days", "pending_days", "token_usage", "price_notices", "closed_workspaces",
319− "workspace_costs", "own_counts", "ai_reload", "ai_reloads", "tax_and_fees",
322+ "workspace_costs", "own_counts", "ai_reload", "ai_reloads", "tax_and_fees", "reset_costs",
320323 ] {
321324 assert!(all.contains(&format!("FROM {table} WHERE workspace = ?2"))
322325 || all.contains(&format!("UPDATE {table} SET"))
+99−6
88 //! an enterprise pays for. The reset itself is kept in the audit log, and
99 //! g1t's own counts of the workspace's git operations stay: they are what
1010 //! Cloudflare's bill is compared with, not what the workspace owes.
11+//!
12+//! What the wiped usage cost g1t is kept too (`reset_costs`): the model
13+//! calls and Cloudflare usage still happened, and AI Gateway and the bill
14+//! still show them. The costs run counts that as given away on purpose
15+//! ("testing resets"), so it is neither drift nor a leak.
1116
1217 use g1t_contracts::billing::{AdminResetBillingArgs, BillingReset};
18+use g1t_contracts::time::rfc3339;
1319 use g1t_contracts::{FailureCode, Outcome};
20+use g1t_kit::now_ms;
1421 use serde::Deserialize;
1522 use worker::Result;
1623 use worker::wasm_bindgen::JsValue;
6067 "DELETE FROM billing_accounts WHERE id = ?2",
6168 ];
6269
70+/// Keeps one row of what a reset wiped that g1t paid for. Parameters: the
71+/// workspace, day, bucket, cost, value, when and who.
72+pub(crate) const KEEP: &str = "INSERT OR REPLACE INTO reset_costs (workspace, day, bucket, cost_micros, value_micros, reset_at, reset_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)";
73+
74+/// The `reset_costs` rows a reset at `at` writes, each (day, bucket, cost,
75+/// value): what it wiped that g1t paid for, and one for the reset itself
76+/// (bucket '', nothing in it), so every reset is on record even when it
77+/// wiped nothing.
78+pub(crate) fn kept_rows(at: &str, wiped: &[crate::margin::Wiped]) -> Vec<(String, String, i64, i64)> {
79+ let mut rows = vec![(at[..10.min(at.len())].to_owned(), String::new(), 0, 0)];
80+ rows.extend(wiped.iter().map(|w| (w.day.clone(), w.bucket.clone(), w.cost, w.value)));
81+ rows
82+}
83+
6384 impl Billing {
6485 pub(crate) async fn admin_reset_billing(&self, env: &worker::Env, a: AdminResetBillingArgs) -> Result<Outcome<BillingReset>> {
6586 let workspace = a.workspace.trim().to_lowercase();
99120 }
100121 }
101122 let account = own_account(&workspace);
102− let mut batch = Vec::with_capacity(STATEMENTS.len());
123+ // What g1t paid for is kept before it is wiped, in the same batch,
124+ // so the costs run counts it as given away (testing resets) rather
125+ // than finding AI Gateway's and Cloudflare's figures unexplained.
126+ let at_ms = now_ms();
127+ let at = rfc3339(at_ms);
128+ let wiped = self.wiped_by_reset(&workspace).await?;
129+ let kept = kept_rows(&at, &wiped);
130+ let mut batch = Vec::with_capacity(kept.len() + STATEMENTS.len());
131+ for (day, bucket, cost, value) in &kept {
132+ batch.push(self.db.prepare(KEEP).bind(&[
133+ workspace.as_str().into(),
134+ day.as_str().into(),
135+ bucket.as_str().into(),
136+ (*cost as f64).into(),
137+ (*value as f64).into(),
138+ at.as_str().into(),
139+ a.by.as_str().into(),
140+ ])?);
141+ }
103142 for sql in STATEMENTS {
104143 let values: Vec<JsValue> =
105144 [workspace.as_str(), account.as_str()][..crate::rename::parameters(sql)].iter().map(|v| (*v).into()).collect();
106145 batch.push(self.db.prepare(*sql).bind(&values)?);
107146 }
108147 let mut rows = 0usize;
109− for result in self.db.batch(batch).await? {
148+ for result in self.db.batch(batch).await?.into_iter().skip(kept.len()) {
110149 rows += result.meta()?.and_then(|m| m.changes).unwrap_or(0);
111150 }
112− self.audit(&account, "reset", &format!("billing of {workspace} reset ({rows} rows): {}", a.note.trim()), &a.by).await?;
151+ let paid: i64 = wiped.iter().map(|w| w.cost).sum();
152+ let detail = format!(
153+ "billing of {workspace} reset ({rows} rows; {} g1t paid for kept as given away): {}",
154+ crate::features::dollars(paid),
155+ a.note.trim()
156+ );
157+ // At the same instant as the kept rows: that is how the costs run
158+ // tells a reset that kept its costs from one before resets did.
159+ self.audit_at(&account, "reset", &detail, &a.by, at_ms).await?;
113160 // The margin figures still hold the workspace's past usage: redo
114161 // them now (the day's analysis: the bill, 31 days, the alerts), so
115162 // the pages show the reset at once.
131178 #[test]
132179 fn every_table_with_a_workspace_is_wiped_or_kept_on_purpose() {
133180 let all = STATEMENTS.join("\n");
134− // What is kept: the audit log, and g1t's own counts compared with
135− // Cloudflare's bill.
136− let kept = ["admin_actions", "own_counts"];
181+ // What is kept: the audit log, g1t's own counts compared with
182+ // Cloudflare's bill, and what resets wiped that g1t paid for.
183+ let kept = ["admin_actions", "own_counts", "reset_costs"];
184+ for table in kept {
185+ assert!(!all.contains(&format!("DELETE FROM {table} ")), "{table} is kept on purpose");
186+ }
137187 for table in [
138188 "ledger", "runs", "checkouts", "workspace_invoices", "workspace_invoice_lines", "sales_notes", "accounts",
139189 "pending_usage", "pending_days", "limits", "subscriptions", "month_closes", "sales_records",
195245 }
196246
197247 #[test]
248+ fn a_reset_keeps_what_g1t_paid_for_and_a_row_for_itself() {
249+ use crate::margin::{UsageRow, Wiped, wiped};
250+ let map: std::collections::BTreeMap<String, String> =
251+ [("sandbox", "sandboxes"), ("git", "git"), ("plan", "platform")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
252+ let row = |day: &str, key: &str, value: i64, cash: i64, cost: i64| UsageRow {
253+ day: day.into(),
254+ workspace: "syntaqx".into(),
255+ key: key.into(),
256+ value,
257+ cash,
258+ cost,
259+ ..UsageRow::default()
260+ };
261+ // Two agent runs and a sandbox on Oct 2, a run in a free period on
262+ // Oct 7 (valued at price), the plan's payment and a run on the
263+ // workspace's own key (no cost to g1t): only what g1t paid for.
264+ let rows = vec![
265+ row("2026-10-02", "implement", 3_600_000, 3_600_000, 3_000_000),
266+ row("2026-10-02", "review", 1_200_000, 0, 1_000_000),
267+ row("2026-10-02", "sandbox", 240_000, 240_000, 200_000),
268+ row("2026-10-07", "implement", 0, 0, 4_600_000),
269+ row("2026-10-07", "plan", 20_000_000, 20_000_000, 0),
270+ row("2026-10-07", "own_key", 50_000, 50_000, 0),
271+ ];
272+ let kept = wiped(&rows, &map, 20);
273+ assert_eq!(
274+ kept,
275+ vec![
276+ Wiped { day: "2026-10-02".into(), bucket: "models".into(), cost: 4_000_000, value: 4_800_000 },
277+ Wiped { day: "2026-10-02".into(), bucket: "sandboxes".into(), cost: 200_000, value: 240_000 },
278+ Wiped { day: "2026-10-07".into(), bucket: "models".into(), cost: 4_600_000, value: 5_520_000 },
279+ ]
280+ );
281+ let rows = kept_rows("2026-10-07T09:12:00.000Z", &kept);
282+ assert_eq!(rows[0], ("2026-10-07".to_string(), String::new(), 0, 0));
283+ assert_eq!(rows.len(), 4);
284+ // Nothing paid for: still on record.
285+ assert_eq!(kept_rows("2026-10-08T00:00:00.000Z", &[]), vec![("2026-10-08".to_string(), String::new(), 0, 0)]);
286+ assert_eq!(crate::rename::parameters(KEEP), 7);
287+ assert!(live_tables().contains("reset_costs"));
288+ }
289+
290+ #[test]
198291 fn statements_name_at_most_the_workspace_and_its_account() {
199292 assert!(STATEMENTS.iter().all(|sql| crate::rename::parameters(sql) <= 2));
200293 assert_eq!(crate::rename::parameters(STATEMENTS.last().unwrap()), 2);