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

206 lines8,098 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.
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.
24pub(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.
34pub(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.
48pub(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)]
59struct 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)]
68struct Month {
69 month: String,
70}
71
72impl 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)]
190mod 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}