g1t/services/billing/src/statement.rs
| 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 | |
| 5 | use g1t_contracts::billing::{ |
| 6 | LedgerEntry, Statement, StatementArgs, StatementEntriesArgs, StatementGroup, StatementLine, StatementTotals, |
| 7 | }; |
| 8 | use g1t_contracts::time::rfc3339; |
| 9 | use g1t_contracts::Outcome; |
| 10 | use g1t_kit::now_ms; |
| 11 | use serde::Deserialize; |
| 12 | use worker::Result; |
| 13 | use worker::wasm_bindgen::JsValue; |
| 14 | |
| 15 | use crate::{Billing, LedgerRow, members_only}; |
| 16 | |
| 17 | /// Entries in one page of a line. |
| 18 | const PAGE: u32 = 50; |
| 19 | |
| 20 | /// The kind of charge a ledger row is, as the statement names it. One SQL |
| 21 | /// expression, so grouping and filtering agree. |
| 22 | /// `Runs on your own model provider` is only on older months: those runs |
| 23 | /// carried a flat fee then, and pay only their sandbox time now. |
| 24 | pub(crate) const KIND_SQL: &str = "CASE |
| 25 | WHEN kind = 'top_up' AND reference LIKE 'crd%' THEN 'Credits from g1t' |
| 26 | WHEN kind = 'top_up' AND amount_micros < 0 THEN 'Refunds' |
| 27 | WHEN kind = 'top_up' THEN 'Payments' |
| 28 | WHEN task = 'sandbox' THEN 'Sandbox time' |
| 29 | WHEN task = 'deployments' THEN 'Deployments' |
| 30 | WHEN billed_to = 'workspace' THEN 'Runs on your own model provider' |
| 31 | ELSE 'Agent runs' END"; |
| 32 | |
| 33 | /// The order lines appear in within a group. |
| 34 | pub(crate) fn kind_order(kind: &str) -> u8 { |
| 35 | match kind { |
| 36 | "Agent runs" => 0, |
| 37 | "Runs on your own model provider" => 1, |
| 38 | "Sandbox time" => 2, |
| 39 | "Deployments" => 3, |
| 40 | "Payments" => 4, |
| 41 | "Credits from g1t" => 5, |
| 42 | "Refunds" => 6, |
| 43 | _ => 7, |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | /// `2026-10` and the first instant of the next month, for a range. |
| 48 | pub(crate) fn month_range(month: &str) -> Option<(String, String)> { |
| 49 | let year: i32 = month.get(..4)?.parse().ok()?; |
| 50 | let number: u32 = month.get(5..7)?.parse().ok()?; |
| 51 | if month.len() != 7 || !(1..=12).contains(&number) { |
| 52 | return None; |
| 53 | } |
| 54 | let next = if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) }; |
| 55 | Some((format!("{month}-01"), format!("{next}-01"))) |
| 56 | } |
| 57 | |
| 58 | #[derive(Deserialize)] |
| 59 | struct Row { |
| 60 | group_key: Option<String>, |
| 61 | kind: String, |
| 62 | count: u32, |
| 63 | amount: Option<i64>, |
| 64 | cost: Option<i64>, |
| 65 | } |
| 66 | |
| 67 | #[derive(Deserialize)] |
| 68 | struct Month { |
| 69 | month: String, |
| 70 | } |
| 71 | |
| 72 | impl Billing { |
| 73 | pub(crate) async fn statement(&self, a: StatementArgs) -> Result<Outcome<Statement>> { |
| 74 | let workspace = a.workspace.to_lowercase(); |
| 75 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { |
| 76 | return Ok(members_only()); |
| 77 | } |
| 78 | let current = rfc3339(now_ms())[..7].to_owned(); |
| 79 | let month = a.month.filter(|m| month_range(m).is_some()).unwrap_or(current); |
| 80 | let (from, until) = month_range(&month).expect("a valid month"); |
| 81 | let by_project = a.group.as_deref() == Some("project"); |
| 82 | let group_sql = if by_project { "COALESCE(repo, '')" } else { "substr(created_at, 1, 10)" }; |
| 83 | let rows = self |
| 84 | .db |
| 85 | .prepare(format!( |
| 86 | "SELECT {group_sql} AS group_key, {KIND_SQL} AS kind, COUNT(*) AS count, |
| 87 | SUM(amount_micros) AS amount, SUM(cost_micros) AS cost |
| 88 | FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 |
| 89 | GROUP BY 1, 2" |
| 90 | )) |
| 91 | .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])? |
| 92 | .all() |
| 93 | .await? |
| 94 | .results::<Row>()?; |
| 95 | |
| 96 | let mut groups: Vec<StatementGroup> = vec![]; |
| 97 | for row in rows { |
| 98 | let key = row.group_key.unwrap_or_default(); |
| 99 | let amount = row.amount.unwrap_or(0); |
| 100 | let line = StatementLine { |
| 101 | kind: row.kind.clone(), |
| 102 | count: row.count, |
| 103 | // Charges positive, money in negative, as a statement reads. |
| 104 | charged_micros: -amount, |
| 105 | cost_micros: row.cost.unwrap_or(0), |
| 106 | }; |
| 107 | match groups.iter_mut().find(|g| g.key == key) { |
| 108 | Some(group) => group.lines.push(line), |
| 109 | None => groups.push(StatementGroup { |
| 110 | label: if key.is_empty() { "Not one project".to_owned() } else { key.clone() }, |
| 111 | key, |
| 112 | lines: vec![line], |
| 113 | charged_micros: 0, |
| 114 | }), |
| 115 | } |
| 116 | } |
| 117 | for group in &mut groups { |
| 118 | group.lines.sort_by_key(|line| kind_order(&line.kind)); |
| 119 | group.charged_micros = group.lines.iter().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum(); |
| 120 | } |
| 121 | if by_project { |
| 122 | groups.sort_by(|a, b| b.charged_micros.cmp(&a.charged_micros)); |
| 123 | } else { |
| 124 | groups.sort_by(|a, b| b.key.cmp(&a.key)); |
| 125 | } |
| 126 | let lines = groups.iter().flat_map(|g| g.lines.iter()); |
| 127 | let totals = StatementTotals { |
| 128 | charged_micros: lines.clone().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum(), |
| 129 | paid_micros: lines.clone().filter(|l| l.charged_micros < 0).map(|l| -l.charged_micros).sum(), |
| 130 | cost_micros: lines.clone().map(|l| l.cost_micros).sum(), |
| 131 | entries: lines.map(|l| l.count).sum(), |
| 132 | }; |
| 133 | let months = self |
| 134 | .db |
| 135 | .prepare("SELECT DISTINCT substr(created_at, 1, 7) AS month FROM ledger WHERE workspace = ? ORDER BY 1 DESC LIMIT 36") |
| 136 | .bind(&[workspace.as_str().into()])? |
| 137 | .all() |
| 138 | .await? |
| 139 | .results::<Month>()? |
| 140 | .into_iter() |
| 141 | .map(|m| m.month) |
| 142 | .collect(); |
| 143 | Ok(Outcome::Ok(Statement { month, months, groups, totals })) |
| 144 | } |
| 145 | |
| 146 | /// One line's entries, newest first, a page at a time. |
| 147 | pub(crate) async fn statement_entries(&self, a: StatementEntriesArgs) -> Result<Outcome<Vec<LedgerEntry>>> { |
| 148 | let workspace = a.workspace.to_lowercase(); |
| 149 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { |
| 150 | return Ok(members_only()); |
| 151 | } |
| 152 | let Some((from, until)) = month_range(&a.month) else { |
| 153 | return Ok(Outcome::Ok(vec![])); |
| 154 | }; |
| 155 | let mut filters = vec![format!("({KIND_SQL}) = ?4")]; |
| 156 | let mut values: Vec<JsValue> = vec![ |
| 157 | workspace.as_str().into(), |
| 158 | from.as_str().into(), |
| 159 | until.as_str().into(), |
| 160 | a.kind.as_str().into(), |
| 161 | ]; |
| 162 | if let Some(day) = a.day.as_deref().filter(|d| d.len() == 10) { |
| 163 | values.push(day.into()); |
| 164 | filters.push(format!("substr(created_at, 1, 10) = ?{}", values.len())); |
| 165 | } |
| 166 | if let Some(project) = &a.project { |
| 167 | values.push(project.as_str().into()); |
| 168 | filters.push(format!("COALESCE(repo, '') = ?{}", values.len())); |
| 169 | } |
| 170 | if let Some(before) = &a.before { |
| 171 | values.push(before.as_str().into()); |
| 172 | filters.push(format!("id < ?{}", values.len())); |
| 173 | } |
| 174 | let rows = self |
| 175 | .db |
| 176 | .prepare(format!( |
| 177 | "SELECT * FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 AND {} |
| 178 | ORDER BY id DESC LIMIT {PAGE}", |
| 179 | filters.join(" AND ") |
| 180 | )) |
| 181 | .bind(&values)? |
| 182 | .all() |
| 183 | .await? |
| 184 | .results::<LedgerRow>()?; |
| 185 | Ok(Outcome::Ok(rows.into_iter().map(LedgerEntry::from).collect())) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | #[cfg(test)] |
| 190 | mod tests { |
| 191 | use super::*; |
| 192 | |
| 193 | #[test] |
| 194 | fn a_month_runs_to_the_first_of_the_next() { |
| 195 | assert_eq!(month_range("2026-10"), Some(("2026-10-01".into(), "2026-11-01".into()))); |
| 196 | assert_eq!(month_range("2026-12"), Some(("2026-12-01".into(), "2027-01-01".into()))); |
| 197 | assert_eq!(month_range("2026-13"), None); |
| 198 | assert_eq!(month_range("oops"), None); |
| 199 | } |
| 200 | |
| 201 | #[test] |
| 202 | fn usage_lines_come_before_money_in() { |
| 203 | assert!(kind_order("Agent runs") < kind_order("Sandbox time")); |
| 204 | assert!(kind_order("Deployments") < kind_order("Payments")); |
| 205 | } |
| 206 | } |