g1t/services/billing/src/statement.rs

450 lines19,139 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.

The statement is a month at a time, a line per kind of charge1//! A workspace's statement: a month of its ledger, grouped so it can be
2//! read at a glance (by day or by project, a line per kind of charge),
3//! with each line's entries a page at a time.
4
5use g1t_contracts::billing::{
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas6 Covered, LedgerEntry, MeterUsage, Statement, StatementArgs, StatementEntriesArgs, StatementGroup, StatementLine, StatementTotals,
7 TermsKind, UsageMetersArgs,
The statement is a month at a time, a line per kind of charge8};
9use g1t_contracts::time::rfc3339;
10use g1t_contracts::Outcome;
11use g1t_kit::now_ms;
12use serde::Deserialize;
13use worker::Result;
14use worker::wasm_bindgen::JsValue;
15
16use crate::{Billing, LedgerRow, members_only};
17
18/// Entries in one page of a line.
19const PAGE: u32 = 50;
20
21/// The kind of charge a ledger row is, as the statement names it. One SQL
22/// expression, so grouping and filtering agree.
Prices are what g1t pays plus 20%, from the first second23/// `Runs on your own model provider` is only on older months: those runs
24/// carried a flat fee then, and pay only their sandbox time now.
The statement is a month at a time, a line per kind of charge25pub(crate) const KIND_SQL: &str = "CASE
26 WHEN kind = 'top_up' AND reference LIKE 'crd%' THEN 'Credits from g1t'
27 WHEN kind = 'top_up' AND amount_micros < 0 THEN 'Refunds'
28 WHEN kind = 'top_up' THEN 'Payments'
29 WHEN task = 'sandbox' THEN 'Sandbox time'
Fast pages, required checks on the branch, self-hosted runners, honest incidents30 WHEN task = 'self_hosted' THEN 'Self-hosted runner time'
The statement is a month at a time, a line per kind of charge31 WHEN task = 'deployments' THEN 'Deployments'
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put32 WHEN task = 'security' THEN 'Security scans'
33 WHEN task = 'context' THEN 'Search embeddings'
34 WHEN task = 'storage' THEN 'Private storage'
Fast pages, required checks on the branch, self-hosted runners, honest incidents35 WHEN task = 'cache' THEN 'Actions cache storage'
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look36 WHEN task = 'git' THEN 'Git operations'
The statement is a month at a time, a line per kind of charge37 WHEN billed_to = 'workspace' THEN 'Runs on your own model provider'
38 ELSE 'Agent runs' END";
39
40/// The order lines appear in within a group.
41pub(crate) fn kind_order(kind: &str) -> u8 {
42 match kind {
43 "Agent runs" => 0,
44 "Runs on your own model provider" => 1,
45 "Sandbox time" => 2,
Fast pages, required checks on the branch, self-hosted runners, honest incidents46 "Self-hosted runner time" => 2,
The statement is a month at a time, a line per kind of charge47 "Deployments" => 3,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put48 "Private storage" => 4,
Fast pages, required checks on the branch, self-hosted runners, honest incidents49 "Actions cache storage" => 4,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look50 "Git operations" => 5,
51 "Search embeddings" => 6,
52 "Security scans" => 7,
53 "Payments" => 8,
54 "Credits from g1t" => 9,
55 "Refunds" => 10,
56 _ => 11,
The statement is a month at a time, a line per kind of charge57 }
58}
59
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas60/// The Billing page's meters, in order: key and label.
61pub(crate) const METERS: [(&str, &str); 6] = [
62 ("agents", "Agents & sandboxes"),
63 ("builds", "Builds"),
64 ("requests", "Requests & CPU"),
65 ("domains", "Custom domains"),
66 ("git_storage", "Git operations & storage"),
67 ("search_scans", "Search & security scans"),
68];
69
70/// The meter a ledger row's usage is on. One SQL expression, as `KIND_SQL`.
71pub(crate) const METER_SQL: &str = "CASE
72 WHEN task = 'deployments' AND reference LIKE 'deploy/%' THEN 'builds'
73 WHEN task = 'deployments' THEN 'requests'
Fast pages, required checks on the branch, self-hosted runners, honest incidents74 WHEN task IN ('storage', 'git', 'cache') THEN 'git_storage'
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas75 WHEN task IN ('security', 'context') THEN 'search_scans'
76 ELSE 'agents' END";
77
78/// The meter usage noted through the month (`pending_usage.source`) is on.
79pub(crate) fn meter_of_source(source: &str) -> &'static str {
80 match source {
81 "deployments" => "requests",
82 "domains" => "domains",
Fast pages, required checks on the branch, self-hosted runners, honest incidents83 "storage" | "git" | "cache" => "git_storage",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas84 _ => "search_scans",
85 }
86}
87
88/// `41 build minutes`, from seconds, rounded up.
89pub(crate) fn build_minutes(seconds: i64) -> Option<String> {
90 let minutes = (seconds.max(0) + 59) / 60;
91 (seconds > 0).then(|| format!("{} build minute{}", crate::features::thousands(minutes as u64), if minutes == 1 { "" } else { "s" }))
92}
93
94/// `12 runs`.
95pub(crate) fn runs(count: u32) -> Option<String> {
96 (count > 0).then(|| format!("{} run{}", crate::features::thousands(count.into()), if count == 1 { "" } else { "s" }))
97}
98
99/// `12,345 git operations, 0.42 GB private`.
100pub(crate) fn git_and_storage(operations: u64, private_bytes: i64) -> Option<String> {
101 let mut parts = vec![];
102 if operations > 0 {
103 parts.push(format!("{} git operation{}", crate::features::thousands(operations), if operations == 1 { "" } else { "s" }));
104 }
105 if private_bytes > 0 {
106 parts.push(format!("{:.2} GB private", private_bytes as f64 / 1e9));
107 }
108 (!parts.is_empty()).then(|| parts.join(", "))
109}
110
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put111/// What paid for usage before it was charged, as the statement names it,
112/// with the ledger column that holds it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look113pub(crate) const COVERED: [(&str, &str, &str); 4] = [
114 ("included", "credit_micros", "Paid by your plan's included usage"),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put115 ("trial", "trial_micros", "Paid by your trial credit"),
116 ("oss_pool", "oss_micros", "Paid by g1t's open-source pool"),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look117 ("given", "given_micros", "Covered by g1t"),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put118];
119
120/// The statement's lines for what paid: each source with anything to show.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look121pub(crate) fn covered_lines(sums: [i64; 4]) -> Vec<Covered> {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put122 COVERED
123 .iter()
124 .zip(sums)
125 .filter(|(_, micros)| *micros > 0)
126 .map(|((source, _, label), micros)| Covered { source: (*source).to_owned(), label: (*label).to_owned(), micros })
127 .collect()
128}
129
The statement is a month at a time, a line per kind of charge130/// `2026-10` and the first instant of the next month, for a range.
131pub(crate) fn month_range(month: &str) -> Option<(String, String)> {
132 let year: i32 = month.get(..4)?.parse().ok()?;
133 let number: u32 = month.get(5..7)?.parse().ok()?;
134 if month.len() != 7 || !(1..=12).contains(&number) {
135 return None;
136 }
137 let next = if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) };
138 Some((format!("{month}-01"), format!("{next}-01")))
139}
140
141#[derive(Deserialize)]
142struct Row {
143 group_key: Option<String>,
144 kind: String,
145 count: u32,
146 amount: Option<i64>,
147 cost: Option<i64>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put148 credit: Option<i64>,
149 trial: Option<i64>,
150 oss: Option<i64>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look151 given: Option<i64>,
The statement is a month at a time, a line per kind of charge152}
153
154#[derive(Deserialize)]
155struct Month {
156 month: String,
157}
158
159impl Billing {
160 pub(crate) async fn statement(&self, a: StatementArgs) -> Result<Outcome<Statement>> {
161 let workspace = a.workspace.to_lowercase();
162 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
163 return Ok(members_only());
164 }
165 let current = rfc3339(now_ms())[..7].to_owned();
166 let month = a.month.filter(|m| month_range(m).is_some()).unwrap_or(current);
167 let (from, until) = month_range(&month).expect("a valid month");
168 let by_project = a.group.as_deref() == Some("project");
169 let group_sql = if by_project { "COALESCE(repo, '')" } else { "substr(created_at, 1, 10)" };
170 let rows = self
171 .db
172 .prepare(format!(
173 "SELECT {group_sql} AS group_key, {KIND_SQL} AS kind, COUNT(*) AS count,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put174 SUM(amount_micros) AS amount, SUM(cost_micros) AS cost,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look175 SUM(credit_micros) AS credit, SUM(trial_micros) AS trial, SUM(oss_micros) AS oss,
176 SUM(given_micros) AS given
The statement is a month at a time, a line per kind of charge177 FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3
178 GROUP BY 1, 2"
179 ))
180 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
181 .all()
182 .await?
183 .results::<Row>()?;
184
185 let mut groups: Vec<StatementGroup> = vec![];
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look186 let mut covered = [0i64; 4];
The statement is a month at a time, a line per kind of charge187 for row in rows {
188 let key = row.group_key.unwrap_or_default();
189 let amount = row.amount.unwrap_or(0);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look190 let paid_for = [row.credit.unwrap_or(0), row.trial.unwrap_or(0), row.oss.unwrap_or(0), row.given.unwrap_or(0)];
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put191 for (total, micros) in covered.iter_mut().zip(paid_for) {
192 *total += micros;
193 }
The statement is a month at a time, a line per kind of charge194 let line = StatementLine {
195 kind: row.kind.clone(),
196 count: row.count,
197 // Charges positive, money in negative, as a statement reads.
198 charged_micros: -amount,
199 cost_micros: row.cost.unwrap_or(0),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put200 covered_micros: paid_for.iter().sum(),
The statement is a month at a time, a line per kind of charge201 };
202 match groups.iter_mut().find(|g| g.key == key) {
203 Some(group) => group.lines.push(line),
204 None => groups.push(StatementGroup {
205 label: if key.is_empty() { "Not one project".to_owned() } else { key.clone() },
206 key,
207 lines: vec![line],
208 charged_micros: 0,
209 }),
210 }
211 }
212 for group in &mut groups {
213 group.lines.sort_by_key(|line| kind_order(&line.kind));
214 group.charged_micros = group.lines.iter().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum();
215 }
216 if by_project {
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept217 groups.sort_by_key(|a| std::cmp::Reverse(a.charged_micros));
The statement is a month at a time, a line per kind of charge218 } else {
219 groups.sort_by(|a, b| b.key.cmp(&a.key));
220 }
221 let lines = groups.iter().flat_map(|g| g.lines.iter());
222 let totals = StatementTotals {
223 charged_micros: lines.clone().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum(),
224 paid_micros: lines.clone().filter(|l| l.charged_micros < 0).map(|l| -l.charged_micros).sum(),
225 cost_micros: lines.clone().map(|l| l.cost_micros).sum(),
226 entries: lines.map(|l| l.count).sum(),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put227 covered: covered_lines(covered),
228 carried_micros: self.carried(&workspace, &month).await?,
The statement is a month at a time, a line per kind of charge229 };
230 let months = self
231 .db
232 .prepare("SELECT DISTINCT substr(created_at, 1, 7) AS month FROM ledger WHERE workspace = ? ORDER BY 1 DESC LIMIT 36")
233 .bind(&[workspace.as_str().into()])?
234 .all()
235 .await?
236 .results::<Month>()?
237 .into_iter()
238 .map(|m| m.month)
239 .collect();
240 Ok(Outcome::Ok(Statement { month, months, groups, totals }))
241 }
242
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas243 /// `usage_meters`: this month's usage by meter, at cost plus the margin
244 /// (with a custom discount, if the account has one) before the plan's
245 /// included usage or a pool paid for any of it. Comped workspaces see
246 /// what it would cost. Usage charged through the month is on the
247 /// ledger; usage charged when the month closes (app traffic, custom
248 /// domains, storage, git operations, search and scans) is what has been
249 /// noted so far.
250 pub(crate) async fn usage_meters(&self, a: UsageMetersArgs) -> Result<Outcome<Vec<MeterUsage>>> {
251 let workspace = a.workspace.to_lowercase();
252 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
253 return Ok(members_only());
254 }
255 let now = rfc3339(now_ms());
256 let month = now[..7].to_owned();
257 let (from, until) = month_range(&month).expect("a valid month");
258 #[derive(Deserialize)]
259 struct Used {
260 meter: String,
261 count: u32,
262 cost: Option<f64>,
263 }
264 // A month's close for deployments is charged in the next month;
265 // it is last month's, so it is left out here.
266 let ledger = self
267 .db
268 .prepare(format!(
269 "SELECT {METER_SQL} AS meter, COUNT(*) AS count, SUM(cost_micros) AS cost
270 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
271 AND COALESCE(reference, '') NOT LIKE 'deployments/%'
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged272 -- Runs on the workspace's own model provider are its provider's to bill, never g1t's.
273 AND COALESCE(billed_to, 'g1t') = 'g1t'
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas274 GROUP BY 1"
275 ))
276 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
277 .all()
278 .await?
279 .results::<Used>()?;
280 #[derive(Deserialize)]
281 struct Pending {
282 source: String,
283 cost_micros: Option<f64>,
284 detail: Option<String>,
285 }
286 let pending = self
287 .db
288 .prepare("SELECT source, cost_micros, detail FROM pending_usage WHERE workspace = ? AND month = ? AND charged_at IS NULL")
289 .bind(&[workspace.as_str().into(), month.as_str().into()])?
290 .all()
291 .await?
292 .results::<Pending>()?;
293 let terms = self.terms_of(&workspace).await?;
294 let price = |cost: i64| {
295 let charge = crate::credits::with_margin(cost, self.margin_percent);
296 if terms.kind == TermsKind::Custom { terms.apply(charge) } else { charge }
297 };
298 let mut meters: Vec<MeterUsage> = METERS
299 .iter()
300 .map(|(key, label)| MeterUsage { key: (*key).to_owned(), label: (*label).to_owned(), micros: 0, quantity: None })
301 .collect();
302 let mut agent_runs = 0;
303 for used in ledger {
304 if let Some(meter) = meters.iter_mut().find(|m| m.key == used.meter) {
305 meter.micros += price(used.cost.unwrap_or(0.0).round() as i64);
306 }
307 if used.meter == "agents" {
308 agent_runs = used.count;
309 }
310 }
311 for row in &pending {
312 let key = meter_of_source(&row.source);
313 if let Some(meter) = meters.iter_mut().find(|m| m.key == key) {
314 meter.micros += price(row.cost_micros.unwrap_or(0.0).round() as i64);
315 if matches!(key, "requests" | "domains") {
316 meter.quantity = row.detail.clone().filter(|d| !d.is_empty());
317 }
318 }
319 }
320 let build_seconds = self.allowance_used("build_seconds", &workspace, &month).await?;
321 let operations = self.git_operations_this_month(&workspace).await?;
322 let stored = self.private_storage(&workspace).await?;
323 for meter in &mut meters {
324 match meter.key.as_str() {
325 "agents" => meter.quantity = runs(agent_runs),
326 "builds" => meter.quantity = build_minutes(build_seconds),
327 "git_storage" => meter.quantity = git_and_storage(operations, stored),
328 _ => {}
329 }
330 }
331 Ok(Outcome::Ok(meters))
332 }
333
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put334 /// What was owed when `month` closed but was under the minimum charge,
335 /// and so carried over to the next invoice.
336 async fn carried(&self, workspace: &str, month: &str) -> Result<i64> {
337 #[derive(Deserialize)]
338 struct Row {
339 amount_micros: i64,
340 }
341 Ok(self
342 .db
343 .prepare("SELECT amount_micros FROM month_closes WHERE workspace = ? AND month = ? AND status = 'carried'")
344 .bind(&[workspace.into(), month.into()])?
345 .first::<Row>(None)
346 .await?
347 .map_or(0, |row| row.amount_micros))
348 }
349
The statement is a month at a time, a line per kind of charge350 /// One line's entries, newest first, a page at a time.
351 pub(crate) async fn statement_entries(&self, a: StatementEntriesArgs) -> Result<Outcome<Vec<LedgerEntry>>> {
352 let workspace = a.workspace.to_lowercase();
353 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
354 return Ok(members_only());
355 }
356 let Some((from, until)) = month_range(&a.month) else {
357 return Ok(Outcome::Ok(vec![]));
358 };
359 let mut filters = vec![format!("({KIND_SQL}) = ?4")];
360 let mut values: Vec<JsValue> = vec![
361 workspace.as_str().into(),
362 from.as_str().into(),
363 until.as_str().into(),
364 a.kind.as_str().into(),
365 ];
366 if let Some(day) = a.day.as_deref().filter(|d| d.len() == 10) {
367 values.push(day.into());
368 filters.push(format!("substr(created_at, 1, 10) = ?{}", values.len()));
369 }
370 if let Some(project) = &a.project {
371 values.push(project.as_str().into());
372 filters.push(format!("COALESCE(repo, '') = ?{}", values.len()));
373 }
374 if let Some(before) = &a.before {
375 values.push(before.as_str().into());
376 filters.push(format!("id < ?{}", values.len()));
377 }
378 let rows = self
379 .db
380 .prepare(format!(
381 "SELECT * FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 AND {}
382 ORDER BY id DESC LIMIT {PAGE}",
383 filters.join(" AND ")
384 ))
385 .bind(&values)?
386 .all()
387 .await?
388 .results::<LedgerRow>()?;
389 Ok(Outcome::Ok(rows.into_iter().map(LedgerEntry::from).collect()))
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn a_month_runs_to_the_first_of_the_next() {
399 assert_eq!(month_range("2026-10"), Some(("2026-10-01".into(), "2026-11-01".into())));
400 assert_eq!(month_range("2026-12"), Some(("2026-12-01".into(), "2027-01-01".into())));
401 assert_eq!(month_range("2026-13"), None);
402 assert_eq!(month_range("oops"), None);
403 }
404
405 #[test]
406 fn usage_lines_come_before_money_in() {
407 assert!(kind_order("Agent runs") < kind_order("Sandbox time"));
408 assert!(kind_order("Deployments") < kind_order("Payments"));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put409 assert!(kind_order("Security scans") < kind_order("Payments"));
410 }
411
412 #[test]
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas413 fn usage_falls_on_the_meters_the_billing_page_shows() {
414 let keys: Vec<_> = METERS.iter().map(|(key, _)| *key).collect();
415 assert_eq!(keys, ["agents", "builds", "requests", "domains", "git_storage", "search_scans"]);
416 assert_eq!(meter_of_source("deployments"), "requests");
417 assert_eq!(meter_of_source("domains"), "domains");
418 assert_eq!(meter_of_source("git"), "git_storage");
419 assert_eq!(meter_of_source("storage"), "git_storage");
420 assert_eq!(meter_of_source("context"), "search_scans");
421 assert_eq!(meter_of_source("security"), "search_scans");
422 for key in ["builds", "requests", "git_storage", "search_scans", "agents"] {
423 assert!(METER_SQL.contains(&format!("'{key}'")));
424 }
425 }
426
427 #[test]
428 fn quantities_read_plainly() {
429 assert_eq!(build_minutes(0), None);
430 assert_eq!(build_minutes(1).as_deref(), Some("1 build minute"));
431 assert_eq!(build_minutes(2_461).as_deref(), Some("42 build minutes"));
432 assert_eq!(runs(1).as_deref(), Some("1 run"));
433 assert_eq!(runs(1_200).as_deref(), Some("1,200 runs"));
434 assert_eq!(git_and_storage(0, 0), None);
435 assert_eq!(git_and_storage(12_345, 420_000_000).as_deref(), Some("12,345 git operations, 0.42 GB private"));
436 assert_eq!(git_and_storage(1, 0).as_deref(), Some("1 git operation"));
437 }
438
439 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put440 fn the_statement_says_what_paid_before_the_workspace_did() {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look441 let lines = covered_lines([0, 250_000, 120_000, 0]);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put442 assert_eq!(lines.len(), 2);
443 assert_eq!(lines[0].label, "Paid by your trial credit");
444 assert_eq!(lines[1], Covered { source: "oss_pool".into(), label: "Paid by g1t's open-source pool".into(), micros: 120_000 });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look445 assert!(covered_lines([0, 0, 0, 0]).is_empty());
446 let lines = covered_lines([4_000_000, 0, 0, 30_000]);
447 assert_eq!(lines[0].label, "Paid by your plan's included usage");
448 assert_eq!(lines[1].label, "Covered by g1t");
The statement is a month at a time, a line per kind of charge449 }
450}