flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/billing/src/statement.rs

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