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

276 lines11,232 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, 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 task = 'security' THEN 'Security scans'
31 WHEN task = 'context' THEN 'Search embeddings'
32 WHEN task = 'storage' THEN 'Private storage'
33 WHEN task = 'git' THEN 'Git operations'
34 WHEN billed_to = 'workspace' THEN 'Runs on your own model provider'
35 ELSE 'Agent runs' END";
36
37/// The order lines appear in within a group.
38pub(crate) fn kind_order(kind: &str) -> u8 {
39 match kind {
40 "Agent runs" => 0,
41 "Runs on your own model provider" => 1,
42 "Sandbox time" => 2,
43 "Deployments" => 3,
44 "Private storage" => 4,
45 "Git operations" => 5,
46 "Search embeddings" => 6,
47 "Security scans" => 7,
48 "Payments" => 8,
49 "Credits from g1t" => 9,
50 "Refunds" => 10,
51 _ => 11,
52 }
53}
54
55/// What paid for usage before it was charged, as the statement names it,
56/// with the ledger column that holds it.
57pub(crate) const COVERED: [(&str, &str, &str); 4] = [
58 ("included", "credit_micros", "Paid by your plan's included usage"),
59 ("trial", "trial_micros", "Paid by your trial credit"),
60 ("oss_pool", "oss_micros", "Paid by g1t's open-source pool"),
61 ("given", "given_micros", "Covered by g1t"),
62];
63
64/// The statement's lines for what paid: each source with anything to show.
65pub(crate) fn covered_lines(sums: [i64; 4]) -> Vec<Covered> {
66 COVERED
67 .iter()
68 .zip(sums)
69 .filter(|(_, micros)| *micros > 0)
70 .map(|((source, _, label), micros)| Covered { source: (*source).to_owned(), label: (*label).to_owned(), micros })
71 .collect()
72}
73
74/// `2026-10` and the first instant of the next month, for a range.
75pub(crate) fn month_range(month: &str) -> Option<(String, String)> {
76 let year: i32 = month.get(..4)?.parse().ok()?;
77 let number: u32 = month.get(5..7)?.parse().ok()?;
78 if month.len() != 7 || !(1..=12).contains(&number) {
79 return None;
80 }
81 let next = if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) };
82 Some((format!("{month}-01"), format!("{next}-01")))
83}
84
85#[derive(Deserialize)]
86struct Row {
87 group_key: Option<String>,
88 kind: String,
89 count: u32,
90 amount: Option<i64>,
91 cost: Option<i64>,
92 credit: Option<i64>,
93 trial: Option<i64>,
94 oss: Option<i64>,
95 given: Option<i64>,
96}
97
98#[derive(Deserialize)]
99struct Month {
100 month: String,
101}
102
103impl Billing {
104 pub(crate) async fn statement(&self, a: StatementArgs) -> Result<Outcome<Statement>> {
105 let workspace = a.workspace.to_lowercase();
106 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
107 return Ok(members_only());
108 }
109 let current = rfc3339(now_ms())[..7].to_owned();
110 let month = a.month.filter(|m| month_range(m).is_some()).unwrap_or(current);
111 let (from, until) = month_range(&month).expect("a valid month");
112 let by_project = a.group.as_deref() == Some("project");
113 let group_sql = if by_project { "COALESCE(repo, '')" } else { "substr(created_at, 1, 10)" };
114 let rows = self
115 .db
116 .prepare(format!(
117 "SELECT {group_sql} AS group_key, {KIND_SQL} AS kind, COUNT(*) AS count,
118 SUM(amount_micros) AS amount, SUM(cost_micros) AS cost,
119 SUM(credit_micros) AS credit, SUM(trial_micros) AS trial, SUM(oss_micros) AS oss,
120 SUM(given_micros) AS given
121 FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3
122 GROUP BY 1, 2"
123 ))
124 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
125 .all()
126 .await?
127 .results::<Row>()?;
128
129 let mut groups: Vec<StatementGroup> = vec![];
130 let mut covered = [0i64; 4];
131 for row in rows {
132 let key = row.group_key.unwrap_or_default();
133 let amount = row.amount.unwrap_or(0);
134 let paid_for = [row.credit.unwrap_or(0), row.trial.unwrap_or(0), row.oss.unwrap_or(0), row.given.unwrap_or(0)];
135 for (total, micros) in covered.iter_mut().zip(paid_for) {
136 *total += micros;
137 }
138 let line = StatementLine {
139 kind: row.kind.clone(),
140 count: row.count,
141 // Charges positive, money in negative, as a statement reads.
142 charged_micros: -amount,
143 cost_micros: row.cost.unwrap_or(0),
144 covered_micros: paid_for.iter().sum(),
145 };
146 match groups.iter_mut().find(|g| g.key == key) {
147 Some(group) => group.lines.push(line),
148 None => groups.push(StatementGroup {
149 label: if key.is_empty() { "Not one project".to_owned() } else { key.clone() },
150 key,
151 lines: vec![line],
152 charged_micros: 0,
153 }),
154 }
155 }
156 for group in &mut groups {
157 group.lines.sort_by_key(|line| kind_order(&line.kind));
158 group.charged_micros = group.lines.iter().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum();
159 }
160 if by_project {
161 groups.sort_by(|a, b| b.charged_micros.cmp(&a.charged_micros));
162 } else {
163 groups.sort_by(|a, b| b.key.cmp(&a.key));
164 }
165 let lines = groups.iter().flat_map(|g| g.lines.iter());
166 let totals = StatementTotals {
167 charged_micros: lines.clone().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum(),
168 paid_micros: lines.clone().filter(|l| l.charged_micros < 0).map(|l| -l.charged_micros).sum(),
169 cost_micros: lines.clone().map(|l| l.cost_micros).sum(),
170 entries: lines.map(|l| l.count).sum(),
171 covered: covered_lines(covered),
172 carried_micros: self.carried(&workspace, &month).await?,
173 };
174 let months = self
175 .db
176 .prepare("SELECT DISTINCT substr(created_at, 1, 7) AS month FROM ledger WHERE workspace = ? ORDER BY 1 DESC LIMIT 36")
177 .bind(&[workspace.as_str().into()])?
178 .all()
179 .await?
180 .results::<Month>()?
181 .into_iter()
182 .map(|m| m.month)
183 .collect();
184 Ok(Outcome::Ok(Statement { month, months, groups, totals }))
185 }
186
187 /// What was owed when `month` closed but was under the minimum charge,
188 /// and so carried over to the next invoice.
189 async fn carried(&self, workspace: &str, month: &str) -> Result<i64> {
190 #[derive(Deserialize)]
191 struct Row {
192 amount_micros: i64,
193 }
194 Ok(self
195 .db
196 .prepare("SELECT amount_micros FROM month_closes WHERE workspace = ? AND month = ? AND status = 'carried'")
197 .bind(&[workspace.into(), month.into()])?
198 .first::<Row>(None)
199 .await?
200 .map_or(0, |row| row.amount_micros))
201 }
202
203 /// One line's entries, newest first, a page at a time.
204 pub(crate) async fn statement_entries(&self, a: StatementEntriesArgs) -> Result<Outcome<Vec<LedgerEntry>>> {
205 let workspace = a.workspace.to_lowercase();
206 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
207 return Ok(members_only());
208 }
209 let Some((from, until)) = month_range(&a.month) else {
210 return Ok(Outcome::Ok(vec![]));
211 };
212 let mut filters = vec![format!("({KIND_SQL}) = ?4")];
213 let mut values: Vec<JsValue> = vec![
214 workspace.as_str().into(),
215 from.as_str().into(),
216 until.as_str().into(),
217 a.kind.as_str().into(),
218 ];
219 if let Some(day) = a.day.as_deref().filter(|d| d.len() == 10) {
220 values.push(day.into());
221 filters.push(format!("substr(created_at, 1, 10) = ?{}", values.len()));
222 }
223 if let Some(project) = &a.project {
224 values.push(project.as_str().into());
225 filters.push(format!("COALESCE(repo, '') = ?{}", values.len()));
226 }
227 if let Some(before) = &a.before {
228 values.push(before.as_str().into());
229 filters.push(format!("id < ?{}", values.len()));
230 }
231 let rows = self
232 .db
233 .prepare(format!(
234 "SELECT * FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 AND {}
235 ORDER BY id DESC LIMIT {PAGE}",
236 filters.join(" AND ")
237 ))
238 .bind(&values)?
239 .all()
240 .await?
241 .results::<LedgerRow>()?;
242 Ok(Outcome::Ok(rows.into_iter().map(LedgerEntry::from).collect()))
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn a_month_runs_to_the_first_of_the_next() {
252 assert_eq!(month_range("2026-10"), Some(("2026-10-01".into(), "2026-11-01".into())));
253 assert_eq!(month_range("2026-12"), Some(("2026-12-01".into(), "2027-01-01".into())));
254 assert_eq!(month_range("2026-13"), None);
255 assert_eq!(month_range("oops"), None);
256 }
257
258 #[test]
259 fn usage_lines_come_before_money_in() {
260 assert!(kind_order("Agent runs") < kind_order("Sandbox time"));
261 assert!(kind_order("Deployments") < kind_order("Payments"));
262 assert!(kind_order("Security scans") < kind_order("Payments"));
263 }
264
265 #[test]
266 fn the_statement_says_what_paid_before_the_workspace_did() {
267 let lines = covered_lines([0, 250_000, 120_000, 0]);
268 assert_eq!(lines.len(), 2);
269 assert_eq!(lines[0].label, "Paid by your trial credit");
270 assert_eq!(lines[1], Covered { source: "oss_pool".into(), label: "Paid by g1t's open-source pool".into(), micros: 120_000 });
271 assert!(covered_lines([0, 0, 0, 0]).is_empty());
272 let lines = covered_lines([4_000_000, 0, 0, 30_000]);
273 assert_eq!(lines[0].label, "Paid by your plan's included usage");
274 assert_eq!(lines[1].label, "Covered by g1t");
275 }
276}