g1t/services/billing/src/statement.rs

444 lines18,767 bytesCodeBlame
1//! 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::{
6 Covered, LedgerEntry, MeterUsage, Statement, StatementArgs, StatementEntriesArgs, StatementGroup, StatementLine, StatementTotals,
7 TermsKind, UsageMetersArgs,
8};
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.
23/// `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.
25pub(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'
30 WHEN task = 'deployments' THEN 'Deployments'
31 WHEN task = 'security' THEN 'Security scans'
32 WHEN task = 'context' THEN 'Search embeddings'
33 WHEN task = 'storage' THEN 'Private storage'
34 WHEN task = 'git' THEN 'Git operations'
35 WHEN billed_to = 'workspace' THEN 'Runs on your own model provider'
36 ELSE 'Agent runs' END";
37
38/// The order lines appear in within a group.
39pub(crate) fn kind_order(kind: &str) -> u8 {
40 match kind {
41 "Agent runs" => 0,
42 "Runs on your own model provider" => 1,
43 "Sandbox time" => 2,
44 "Deployments" => 3,
45 "Private storage" => 4,
46 "Git operations" => 5,
47 "Search embeddings" => 6,
48 "Security scans" => 7,
49 "Payments" => 8,
50 "Credits from g1t" => 9,
51 "Refunds" => 10,
52 _ => 11,
53 }
54}
55
56/// The Billing page's meters, in order: key and label.
57pub(crate) const METERS: [(&str, &str); 6] = [
58 ("agents", "Agents & sandboxes"),
59 ("builds", "Builds"),
60 ("requests", "Requests & CPU"),
61 ("domains", "Custom domains"),
62 ("git_storage", "Git operations & storage"),
63 ("search_scans", "Search & security scans"),
64];
65
66/// The meter a ledger row's usage is on. One SQL expression, as `KIND_SQL`.
67pub(crate) const METER_SQL: &str = "CASE
68 WHEN task = 'deployments' AND reference LIKE 'deploy/%' THEN 'builds'
69 WHEN task = 'deployments' THEN 'requests'
70 WHEN task IN ('storage', 'git') THEN 'git_storage'
71 WHEN task IN ('security', 'context') THEN 'search_scans'
72 ELSE 'agents' END";
73
74/// The meter usage noted through the month (`pending_usage.source`) is on.
75pub(crate) fn meter_of_source(source: &str) -> &'static str {
76 match source {
77 "deployments" => "requests",
78 "domains" => "domains",
79 "storage" | "git" => "git_storage",
80 _ => "search_scans",
81 }
82}
83
84/// `41 build minutes`, from seconds, rounded up.
85pub(crate) fn build_minutes(seconds: i64) -> Option<String> {
86 let minutes = (seconds.max(0) + 59) / 60;
87 (seconds > 0).then(|| format!("{} build minute{}", crate::features::thousands(minutes as u64), if minutes == 1 { "" } else { "s" }))
88}
89
90/// `12 runs`.
91pub(crate) fn runs(count: u32) -> Option<String> {
92 (count > 0).then(|| format!("{} run{}", crate::features::thousands(count.into()), if count == 1 { "" } else { "s" }))
93}
94
95/// `12,345 git operations, 0.42 GB private`.
96pub(crate) fn git_and_storage(operations: u64, private_bytes: i64) -> Option<String> {
97 let mut parts = vec![];
98 if operations > 0 {
99 parts.push(format!("{} git operation{}", crate::features::thousands(operations), if operations == 1 { "" } else { "s" }));
100 }
101 if private_bytes > 0 {
102 parts.push(format!("{:.2} GB private", private_bytes as f64 / 1e9));
103 }
104 (!parts.is_empty()).then(|| parts.join(", "))
105}
106
107/// What paid for usage before it was charged, as the statement names it,
108/// with the ledger column that holds it.
109pub(crate) const COVERED: [(&str, &str, &str); 4] = [
110 ("included", "credit_micros", "Paid by your plan's included usage"),
111 ("trial", "trial_micros", "Paid by your trial credit"),
112 ("oss_pool", "oss_micros", "Paid by g1t's open-source pool"),
113 ("given", "given_micros", "Covered by g1t"),
114];
115
116/// The statement's lines for what paid: each source with anything to show.
117pub(crate) fn covered_lines(sums: [i64; 4]) -> Vec<Covered> {
118 COVERED
119 .iter()
120 .zip(sums)
121 .filter(|(_, micros)| *micros > 0)
122 .map(|((source, _, label), micros)| Covered { source: (*source).to_owned(), label: (*label).to_owned(), micros })
123 .collect()
124}
125
126/// `2026-10` and the first instant of the next month, for a range.
127pub(crate) fn month_range(month: &str) -> Option<(String, String)> {
128 let year: i32 = month.get(..4)?.parse().ok()?;
129 let number: u32 = month.get(5..7)?.parse().ok()?;
130 if month.len() != 7 || !(1..=12).contains(&number) {
131 return None;
132 }
133 let next = if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) };
134 Some((format!("{month}-01"), format!("{next}-01")))
135}
136
137#[derive(Deserialize)]
138struct Row {
139 group_key: Option<String>,
140 kind: String,
141 count: u32,
142 amount: Option<i64>,
143 cost: Option<i64>,
144 credit: Option<i64>,
145 trial: Option<i64>,
146 oss: Option<i64>,
147 given: Option<i64>,
148}
149
150#[derive(Deserialize)]
151struct Month {
152 month: String,
153}
154
155impl Billing {
156 pub(crate) async fn statement(&self, a: StatementArgs) -> Result<Outcome<Statement>> {
157 let workspace = a.workspace.to_lowercase();
158 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
159 return Ok(members_only());
160 }
161 let current = rfc3339(now_ms())[..7].to_owned();
162 let month = a.month.filter(|m| month_range(m).is_some()).unwrap_or(current);
163 let (from, until) = month_range(&month).expect("a valid month");
164 let by_project = a.group.as_deref() == Some("project");
165 let group_sql = if by_project { "COALESCE(repo, '')" } else { "substr(created_at, 1, 10)" };
166 let rows = self
167 .db
168 .prepare(format!(
169 "SELECT {group_sql} AS group_key, {KIND_SQL} AS kind, COUNT(*) AS count,
170 SUM(amount_micros) AS amount, SUM(cost_micros) AS cost,
171 SUM(credit_micros) AS credit, SUM(trial_micros) AS trial, SUM(oss_micros) AS oss,
172 SUM(given_micros) AS given
173 FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3
174 GROUP BY 1, 2"
175 ))
176 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
177 .all()
178 .await?
179 .results::<Row>()?;
180
181 let mut groups: Vec<StatementGroup> = vec![];
182 let mut covered = [0i64; 4];
183 for row in rows {
184 let key = row.group_key.unwrap_or_default();
185 let amount = row.amount.unwrap_or(0);
186 let paid_for = [row.credit.unwrap_or(0), row.trial.unwrap_or(0), row.oss.unwrap_or(0), row.given.unwrap_or(0)];
187 for (total, micros) in covered.iter_mut().zip(paid_for) {
188 *total += micros;
189 }
190 let line = StatementLine {
191 kind: row.kind.clone(),
192 count: row.count,
193 // Charges positive, money in negative, as a statement reads.
194 charged_micros: -amount,
195 cost_micros: row.cost.unwrap_or(0),
196 covered_micros: paid_for.iter().sum(),
197 };
198 match groups.iter_mut().find(|g| g.key == key) {
199 Some(group) => group.lines.push(line),
200 None => groups.push(StatementGroup {
201 label: if key.is_empty() { "Not one project".to_owned() } else { key.clone() },
202 key,
203 lines: vec![line],
204 charged_micros: 0,
205 }),
206 }
207 }
208 for group in &mut groups {
209 group.lines.sort_by_key(|line| kind_order(&line.kind));
210 group.charged_micros = group.lines.iter().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum();
211 }
212 if by_project {
213 groups.sort_by(|a, b| b.charged_micros.cmp(&a.charged_micros));
214 } else {
215 groups.sort_by(|a, b| b.key.cmp(&a.key));
216 }
217 let lines = groups.iter().flat_map(|g| g.lines.iter());
218 let totals = StatementTotals {
219 charged_micros: lines.clone().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum(),
220 paid_micros: lines.clone().filter(|l| l.charged_micros < 0).map(|l| -l.charged_micros).sum(),
221 cost_micros: lines.clone().map(|l| l.cost_micros).sum(),
222 entries: lines.map(|l| l.count).sum(),
223 covered: covered_lines(covered),
224 carried_micros: self.carried(&workspace, &month).await?,
225 };
226 let months = self
227 .db
228 .prepare("SELECT DISTINCT substr(created_at, 1, 7) AS month FROM ledger WHERE workspace = ? ORDER BY 1 DESC LIMIT 36")
229 .bind(&[workspace.as_str().into()])?
230 .all()
231 .await?
232 .results::<Month>()?
233 .into_iter()
234 .map(|m| m.month)
235 .collect();
236 Ok(Outcome::Ok(Statement { month, months, groups, totals }))
237 }
238
239 /// `usage_meters`: this month's usage by meter, at cost plus the margin
240 /// (with a custom discount, if the account has one) before the plan's
241 /// included usage or a pool paid for any of it. Comped workspaces see
242 /// what it would cost. Usage charged through the month is on the
243 /// ledger; usage charged when the month closes (app traffic, custom
244 /// domains, storage, git operations, search and scans) is what has been
245 /// noted so far.
246 pub(crate) async fn usage_meters(&self, a: UsageMetersArgs) -> Result<Outcome<Vec<MeterUsage>>> {
247 let workspace = a.workspace.to_lowercase();
248 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
249 return Ok(members_only());
250 }
251 let now = rfc3339(now_ms());
252 let month = now[..7].to_owned();
253 let (from, until) = month_range(&month).expect("a valid month");
254 #[derive(Deserialize)]
255 struct Used {
256 meter: String,
257 count: u32,
258 cost: Option<f64>,
259 }
260 // A month's close for deployments is charged in the next month;
261 // it is last month's, so it is left out here.
262 let ledger = self
263 .db
264 .prepare(format!(
265 "SELECT {METER_SQL} AS meter, COUNT(*) AS count, SUM(cost_micros) AS cost
266 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
267 AND COALESCE(reference, '') NOT LIKE 'deployments/%'
268 GROUP BY 1"
269 ))
270 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
271 .all()
272 .await?
273 .results::<Used>()?;
274 #[derive(Deserialize)]
275 struct Pending {
276 source: String,
277 cost_micros: Option<f64>,
278 detail: Option<String>,
279 }
280 let pending = self
281 .db
282 .prepare("SELECT source, cost_micros, detail FROM pending_usage WHERE workspace = ? AND month = ? AND charged_at IS NULL")
283 .bind(&[workspace.as_str().into(), month.as_str().into()])?
284 .all()
285 .await?
286 .results::<Pending>()?;
287 let terms = self.terms_of(&workspace).await?;
288 let price = |cost: i64| {
289 let charge = crate::credits::with_margin(cost, self.margin_percent);
290 if terms.kind == TermsKind::Custom { terms.apply(charge) } else { charge }
291 };
292 let mut meters: Vec<MeterUsage> = METERS
293 .iter()
294 .map(|(key, label)| MeterUsage { key: (*key).to_owned(), label: (*label).to_owned(), micros: 0, quantity: None })
295 .collect();
296 let mut agent_runs = 0;
297 for used in ledger {
298 if let Some(meter) = meters.iter_mut().find(|m| m.key == used.meter) {
299 meter.micros += price(used.cost.unwrap_or(0.0).round() as i64);
300 }
301 if used.meter == "agents" {
302 agent_runs = used.count;
303 }
304 }
305 for row in &pending {
306 let key = meter_of_source(&row.source);
307 if let Some(meter) = meters.iter_mut().find(|m| m.key == key) {
308 meter.micros += price(row.cost_micros.unwrap_or(0.0).round() as i64);
309 if matches!(key, "requests" | "domains") {
310 meter.quantity = row.detail.clone().filter(|d| !d.is_empty());
311 }
312 }
313 }
314 let build_seconds = self.allowance_used("build_seconds", &workspace, &month).await?;
315 let operations = self.git_operations_this_month(&workspace).await?;
316 let stored = self.private_storage(&workspace).await?;
317 for meter in &mut meters {
318 match meter.key.as_str() {
319 "agents" => meter.quantity = runs(agent_runs),
320 "builds" => meter.quantity = build_minutes(build_seconds),
321 "git_storage" => meter.quantity = git_and_storage(operations, stored),
322 _ => {}
323 }
324 }
325 Ok(Outcome::Ok(meters))
326 }
327
328 /// What was owed when `month` closed but was under the minimum charge,
329 /// and so carried over to the next invoice.
330 async fn carried(&self, workspace: &str, month: &str) -> Result<i64> {
331 #[derive(Deserialize)]
332 struct Row {
333 amount_micros: i64,
334 }
335 Ok(self
336 .db
337 .prepare("SELECT amount_micros FROM month_closes WHERE workspace = ? AND month = ? AND status = 'carried'")
338 .bind(&[workspace.into(), month.into()])?
339 .first::<Row>(None)
340 .await?
341 .map_or(0, |row| row.amount_micros))
342 }
343
344 /// One line's entries, newest first, a page at a time.
345 pub(crate) async fn statement_entries(&self, a: StatementEntriesArgs) -> Result<Outcome<Vec<LedgerEntry>>> {
346 let workspace = a.workspace.to_lowercase();
347 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
348 return Ok(members_only());
349 }
350 let Some((from, until)) = month_range(&a.month) else {
351 return Ok(Outcome::Ok(vec![]));
352 };
353 let mut filters = vec![format!("({KIND_SQL}) = ?4")];
354 let mut values: Vec<JsValue> = vec![
355 workspace.as_str().into(),
356 from.as_str().into(),
357 until.as_str().into(),
358 a.kind.as_str().into(),
359 ];
360 if let Some(day) = a.day.as_deref().filter(|d| d.len() == 10) {
361 values.push(day.into());
362 filters.push(format!("substr(created_at, 1, 10) = ?{}", values.len()));
363 }
364 if let Some(project) = &a.project {
365 values.push(project.as_str().into());
366 filters.push(format!("COALESCE(repo, '') = ?{}", values.len()));
367 }
368 if let Some(before) = &a.before {
369 values.push(before.as_str().into());
370 filters.push(format!("id < ?{}", values.len()));
371 }
372 let rows = self
373 .db
374 .prepare(format!(
375 "SELECT * FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 AND {}
376 ORDER BY id DESC LIMIT {PAGE}",
377 filters.join(" AND ")
378 ))
379 .bind(&values)?
380 .all()
381 .await?
382 .results::<LedgerRow>()?;
383 Ok(Outcome::Ok(rows.into_iter().map(LedgerEntry::from).collect()))
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn a_month_runs_to_the_first_of_the_next() {
393 assert_eq!(month_range("2026-10"), Some(("2026-10-01".into(), "2026-11-01".into())));
394 assert_eq!(month_range("2026-12"), Some(("2026-12-01".into(), "2027-01-01".into())));
395 assert_eq!(month_range("2026-13"), None);
396 assert_eq!(month_range("oops"), None);
397 }
398
399 #[test]
400 fn usage_lines_come_before_money_in() {
401 assert!(kind_order("Agent runs") < kind_order("Sandbox time"));
402 assert!(kind_order("Deployments") < kind_order("Payments"));
403 assert!(kind_order("Security scans") < kind_order("Payments"));
404 }
405
406 #[test]
407 fn usage_falls_on_the_meters_the_billing_page_shows() {
408 let keys: Vec<_> = METERS.iter().map(|(key, _)| *key).collect();
409 assert_eq!(keys, ["agents", "builds", "requests", "domains", "git_storage", "search_scans"]);
410 assert_eq!(meter_of_source("deployments"), "requests");
411 assert_eq!(meter_of_source("domains"), "domains");
412 assert_eq!(meter_of_source("git"), "git_storage");
413 assert_eq!(meter_of_source("storage"), "git_storage");
414 assert_eq!(meter_of_source("context"), "search_scans");
415 assert_eq!(meter_of_source("security"), "search_scans");
416 for key in ["builds", "requests", "git_storage", "search_scans", "agents"] {
417 assert!(METER_SQL.contains(&format!("'{key}'")));
418 }
419 }
420
421 #[test]
422 fn quantities_read_plainly() {
423 assert_eq!(build_minutes(0), None);
424 assert_eq!(build_minutes(1).as_deref(), Some("1 build minute"));
425 assert_eq!(build_minutes(2_461).as_deref(), Some("42 build minutes"));
426 assert_eq!(runs(1).as_deref(), Some("1 run"));
427 assert_eq!(runs(1_200).as_deref(), Some("1,200 runs"));
428 assert_eq!(git_and_storage(0, 0), None);
429 assert_eq!(git_and_storage(12_345, 420_000_000).as_deref(), Some("12,345 git operations, 0.42 GB private"));
430 assert_eq!(git_and_storage(1, 0).as_deref(), Some("1 git operation"));
431 }
432
433 #[test]
434 fn the_statement_says_what_paid_before_the_workspace_did() {
435 let lines = covered_lines([0, 250_000, 120_000, 0]);
436 assert_eq!(lines.len(), 2);
437 assert_eq!(lines[0].label, "Paid by your trial credit");
438 assert_eq!(lines[1], Covered { source: "oss_pool".into(), label: "Paid by g1t's open-source pool".into(), micros: 120_000 });
439 assert!(covered_lines([0, 0, 0, 0]).is_empty());
440 let lines = covered_lines([4_000_000, 0, 0, 30_000]);
441 assert_eq!(lines[0].label, "Paid by your plan's included usage");
442 assert_eq!(lines[1].label, "Covered by g1t");
443 }
444}