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

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