Skip to content
586 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1//! 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, MeterUsage, Statement, StatementArgs, StatementEntriesArgs, StatementGroup, StatementLine, StatementTotals,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging7 UsageMetersArgs,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor8};
9use g1t_contracts::time::rfc3339;
10use g1t_contracts::Outcome;
11use g1t_kit::now_ms;
12use serde::Deserialize;
13use worker::Result;
14use worker::wasm_bindgen::JsValue;
15
16use crate::{Billing, LedgerRow, members_only};
17
18/// Entries in one page of a line.
19const PAGE: u32 = 50;
20
21/// The kind of charge a ledger row is, as the statement names it. One SQL
22/// expression, so grouping and filtering agree.
23/// `Runs on your own model provider` is only on older months: those runs
24/// carried a flat fee then, and pay only their sandbox time now.
25pub(crate) const KIND_SQL: &str = "CASE
Usage, Billing settings and prepaid AI credit; fixes from the UX audit26 WHEN kind = 'top_up' AND credit_kind = 'purchased' THEN 'AI credit'
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor27 WHEN kind = 'top_up' AND reference LIKE 'crd%' THEN 'Credits from g1t'
28 WHEN kind = 'top_up' AND amount_micros < 0 THEN 'Refunds'
29 WHEN kind = 'top_up' THEN 'Payments'
30 WHEN task = 'sandbox' THEN 'Sandbox time'
31 WHEN task = 'self_hosted' THEN 'Self-hosted runner time'
32 WHEN task = 'deployments' THEN 'Deployments'
33 WHEN task = 'security' THEN 'Security scans'
34 WHEN task = 'context' THEN 'Search embeddings'
35 WHEN task = 'storage' THEN 'Private storage'
36 WHEN task = 'cache' THEN 'Actions cache storage'
37 WHEN task = 'git' THEN 'Git operations'
Merge branch 'model-routing'38 WHEN reference LIKE '%/agent-own%' THEN 'Agent rate, your own model key'
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor39 WHEN billed_to = 'workspace' THEN 'Runs on your own model provider'
Usage, Billing settings and prepaid AI credit; fixes from the UX audit40 WHEN reference LIKE '%/agent%' THEN 'Agent rate'
41 WHEN task = 'gateway' THEN 'AI Gateway'
42 WHEN task = 'domains' THEN 'Custom domains'
43 WHEN task = 'package_storage' THEN 'Package storage'
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor44 ELSE 'Agent runs' END";
45
Usage, Billing settings and prepaid AI credit; fixes from the UX audit46/// A usage line at its price, in SQL: what was charged, what paid for it
47/// first (the plan's included usage, the trial, a pool, g1t), and what a
48/// discount took off. `line_price`, for a whole column. The one measure of
49/// usage every page shows.
50pub(crate) const PRICE_SQL: &str = "(-amount_micros + COALESCE(credit_micros, 0) + COALESCE(trial_micros, 0) + COALESCE(oss_micros, 0)
51 + COALESCE(given_micros, 0) + COALESCE(discount_micros, 0))";
52
53/// Whether a usage line is an agent run's own line (`run_…`), not its
54/// agent rate, a correction or anything else: what counts as one run.
55pub(crate) const RUN_SQL: &str = "(reference LIKE 'run%' AND instr(reference, '/') = 0)";
56
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor57/// The order lines appear in within a group.
58pub(crate) fn kind_order(kind: &str) -> u8 {
59 match kind {
60 "Agent runs" => 0,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit61 "Agent rate" => 0,
Merge branch 'model-routing'62 "Agent rate, your own model key" => 0,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit63 "AI Gateway" => 1,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor64 "Runs on your own model provider" => 1,
65 "Sandbox time" => 2,
66 "Self-hosted runner time" => 2,
67 "Deployments" => 3,
68 "Private storage" => 4,
69 "Actions cache storage" => 4,
70 "Git operations" => 5,
71 "Search embeddings" => 6,
72 "Security scans" => 7,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit73 "Custom domains" => 3,
74 "Package storage" => 4,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor75 "Payments" => 8,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit76 "AI credit" => 8,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor77 "Credits from g1t" => 9,
78 "Refunds" => 10,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person79 "Tax" => 8,
80 "Card processing fees" => 8,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor81 _ => 11,
82 }
83}
84
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging85/// Payments, credits and refunds: money in, which has no price.
86pub(crate) fn is_money_in(kind: &str) -> bool {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person87 matches!(kind, "Payments" | "AI credit" | "Credits from g1t" | "Refunds" | "Tax" | "Card processing fees")
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging88}
89
90/// A usage line at its price: what was charged, what paid for it first,
91/// and what the account's discount took off.
92pub(crate) fn line_price(charged: i64, covered: i64, discount: i64) -> i64 {
93 charged + covered + discount
94}
95
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor96/// The Billing page's meters, in order: key and label.
97pub(crate) const METERS: [(&str, &str); 6] = [
98 ("agents", "Agents & sandboxes"),
99 ("builds", "Builds"),
100 ("requests", "Requests & CPU"),
101 ("domains", "Custom domains"),
102 ("git_storage", "Git operations & storage"),
103 ("search_scans", "Search & security scans"),
104];
105
106/// The meter a ledger row's usage is on. One SQL expression, as `KIND_SQL`.
107pub(crate) const METER_SQL: &str = "CASE
108 WHEN task = 'deployments' AND reference LIKE 'deploy/%' THEN 'builds'
109 WHEN task = 'deployments' THEN 'requests'
110 WHEN task IN ('storage', 'git', 'cache') THEN 'git_storage'
111 WHEN task IN ('security', 'context') THEN 'search_scans'
112 ELSE 'agents' END";
113
114/// The meter usage noted through the month (`pending_usage.source`) is on.
115pub(crate) fn meter_of_source(source: &str) -> &'static str {
116 match source {
117 "deployments" => "requests",
118 "domains" => "domains",
119 "storage" | "git" | "cache" => "git_storage",
120 _ => "search_scans",
121 }
122}
123
124/// `41 build minutes`, from seconds, rounded up.
125pub(crate) fn build_minutes(seconds: i64) -> Option<String> {
126 let minutes = (seconds.max(0) + 59) / 60;
127 (seconds > 0).then(|| format!("{} build minute{}", crate::features::thousands(minutes as u64), if minutes == 1 { "" } else { "s" }))
128}
129
130/// `12 runs`.
131pub(crate) fn runs(count: u32) -> Option<String> {
132 (count > 0).then(|| format!("{} run{}", crate::features::thousands(count.into()), if count == 1 { "" } else { "s" }))
133}
134
135/// `12,345 git operations, 0.42 GB private`.
136pub(crate) fn git_and_storage(operations: u64, private_bytes: i64) -> Option<String> {
137 let mut parts = vec![];
138 if operations > 0 {
139 parts.push(format!("{} git operation{}", crate::features::thousands(operations), if operations == 1 { "" } else { "s" }));
140 }
141 if private_bytes > 0 {
142 parts.push(format!("{:.2} GB private", private_bytes as f64 / 1e9));
143 }
144 (!parts.is_empty()).then(|| parts.join(", "))
145}
146
147/// What paid for usage before it was charged, as the statement names it,
148/// with the ledger column that holds it.
149pub(crate) const COVERED: [(&str, &str, &str); 4] = [
150 ("included", "credit_micros", "Paid by your plan's included usage"),
151 ("trial", "trial_micros", "Paid by your trial credit"),
152 ("oss_pool", "oss_micros", "Paid by g1t's open-source pool"),
153 ("given", "given_micros", "Covered by g1t"),
154];
155
156/// The statement's lines for what paid: each source with anything to show.
157pub(crate) fn covered_lines(sums: [i64; 4]) -> Vec<Covered> {
158 COVERED
159 .iter()
160 .zip(sums)
161 .filter(|(_, micros)| *micros > 0)
162 .map(|((source, _, label), micros)| Covered { source: (*source).to_owned(), label: (*label).to_owned(), micros })
163 .collect()
164}
165
166/// `2026-10` and the first instant of the next month, for a range.
167pub(crate) fn month_range(month: &str) -> Option<(String, String)> {
168 let year: i32 = month.get(..4)?.parse().ok()?;
169 let number: u32 = month.get(5..7)?.parse().ok()?;
170 if month.len() != 7 || !(1..=12).contains(&number) {
171 return None;
172 }
173 let next = if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) };
174 Some((format!("{month}-01"), format!("{next}-01")))
175}
176
177#[derive(Deserialize)]
178struct Row {
179 group_key: Option<String>,
180 kind: String,
181 count: u32,
182 amount: Option<i64>,
183 cost: Option<i64>,
184 credit: Option<i64>,
185 trial: Option<i64>,
186 oss: Option<i64>,
187 given: Option<i64>,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging188 discount: Option<i64>,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor189}
190
191#[derive(Deserialize)]
192struct Month {
193 month: String,
194}
195
Merge Stripe Tax, the card fee on card payments, and one free workspace per person196#[derive(Deserialize)]
197struct ExtraRow {
198 group_key: Option<String>,
199 kind: String,
200 count: u32,
201 amount: Option<i64>,
202}
203
204/// How the statement names a `tax_and_fees` kind.
205pub(crate) fn extra_kind(kind: &str) -> &'static str {
206 if kind == "tax" { "Tax" } else { "Card processing fees" }
207}
208
209/// Puts a line in its group, starting the group if it is new.
210fn add_line(groups: &mut Vec<StatementGroup>, key: String, line: StatementLine) {
211 match groups.iter_mut().find(|g| g.key == key) {
212 Some(group) => group.lines.push(line),
213 None => groups.push(StatementGroup {
214 label: if key.is_empty() { "Not one project".to_owned() } else { key.clone() },
215 key,
216 lines: vec![line],
217 charged_micros: 0,
218 price_micros: 0,
219 discount_micros: 0,
220 }),
221 }
222}
223
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor224impl Billing {
225 pub(crate) async fn statement(&self, a: StatementArgs) -> Result<Outcome<Statement>> {
226 let workspace = a.workspace.to_lowercase();
227 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
228 return Ok(members_only());
229 }
230 let current = rfc3339(now_ms())[..7].to_owned();
231 let month = a.month.filter(|m| month_range(m).is_some()).unwrap_or(current);
232 let (from, until) = month_range(&month).expect("a valid month");
233 let by_project = a.group.as_deref() == Some("project");
234 let group_sql = if by_project { "COALESCE(repo, '')" } else { "substr(created_at, 1, 10)" };
235 let rows = self
236 .db
237 .prepare(format!(
238 "SELECT {group_sql} AS group_key, {KIND_SQL} AS kind, COUNT(*) AS count,
239 SUM(amount_micros) AS amount, SUM(cost_micros) AS cost,
240 SUM(credit_micros) AS credit, SUM(trial_micros) AS trial, SUM(oss_micros) AS oss,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging241 SUM(given_micros) AS given, SUM(discount_micros) AS discount
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor242 FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3
243 GROUP BY 1, 2"
244 ))
245 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
246 .all()
247 .await?
248 .results::<Row>()?;
249
250 let mut groups: Vec<StatementGroup> = vec![];
251 let mut covered = [0i64; 4];
252 for row in rows {
253 let key = row.group_key.unwrap_or_default();
254 let amount = row.amount.unwrap_or(0);
255 let paid_for = [row.credit.unwrap_or(0), row.trial.unwrap_or(0), row.oss.unwrap_or(0), row.given.unwrap_or(0)];
256 for (total, micros) in covered.iter_mut().zip(paid_for) {
257 *total += micros;
258 }
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging259 let discount = row.discount.unwrap_or(0);
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor260 let line = StatementLine {
261 // Charges positive, money in negative, as a statement reads.
262 charged_micros: -amount,
263 cost_micros: row.cost.unwrap_or(0),
264 covered_micros: paid_for.iter().sum(),
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging265 price_micros: if is_money_in(&row.kind) { 0 } else { line_price(-amount, paid_for.iter().sum(), discount) },
266 discount_micros: discount,
267 kind: row.kind,
268 count: row.count,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person269 passed_micros: 0,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor270 };
Merge Stripe Tax, the card fee on card payments, and one free workspace per person271 add_line(&mut groups, key, line);
272 }
273 // Tax and card fees paid with the month's payments: their own
274 // lines, beside the payment, never in what was charged or paid.
275 let extras = self
276 .db
277 .prepare(format!(
278 "SELECT {} AS group_key, kind, COUNT(*) AS count, SUM(amount_micros) AS amount
279 FROM tax_and_fees WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 GROUP BY 1, 2",
280 if by_project { "''" } else { "substr(created_at, 1, 10)" }
281 ))
282 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
283 .all()
284 .await?
285 .results::<ExtraRow>()?;
286 let (mut tax_micros, mut card_fee_micros) = (0, 0);
287 for extra in extras {
288 let amount = extra.amount.unwrap_or(0);
289 if extra.kind == "tax" {
290 tax_micros += amount;
291 } else {
292 card_fee_micros += amount;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor293 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person294 let line = StatementLine {
295 kind: extra_kind(&extra.kind).to_owned(),
296 count: extra.count,
297 charged_micros: 0,
298 cost_micros: 0,
299 covered_micros: 0,
300 price_micros: 0,
301 discount_micros: 0,
302 passed_micros: amount,
303 };
304 add_line(&mut groups, extra.group_key.unwrap_or_default(), line);
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor305 }
306 for group in &mut groups {
307 group.lines.sort_by_key(|line| kind_order(&line.kind));
308 group.charged_micros = group.lines.iter().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum();
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging309 group.price_micros = group.lines.iter().map(|l| l.price_micros).sum();
310 group.discount_micros = group.lines.iter().map(|l| l.discount_micros).sum();
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor311 }
312 if by_project {
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept313 groups.sort_by_key(|a| std::cmp::Reverse(a.charged_micros));
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor314 } else {
315 groups.sort_by(|a, b| b.key.cmp(&a.key));
316 }
317 let lines = groups.iter().flat_map(|g| g.lines.iter());
318 let totals = StatementTotals {
319 charged_micros: lines.clone().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum(),
320 paid_micros: lines.clone().filter(|l| l.charged_micros < 0).map(|l| -l.charged_micros).sum(),
321 cost_micros: lines.clone().map(|l| l.cost_micros).sum(),
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging322 price_micros: lines.clone().map(|l| l.price_micros).sum(),
323 discount_micros: lines.clone().map(|l| l.discount_micros).sum(),
324 discount_percent: Some(self.terms_of(&workspace).await?.percent_off()).filter(|p| *p > 0),
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor325 entries: lines.map(|l| l.count).sum(),
326 covered: covered_lines(covered),
327 carried_micros: self.carried(&workspace, &month).await?,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person328 tax_micros,
329 card_fee_micros,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor330 };
331 let months = self
332 .db
333 .prepare("SELECT DISTINCT substr(created_at, 1, 7) AS month FROM ledger WHERE workspace = ? ORDER BY 1 DESC LIMIT 36")
334 .bind(&[workspace.as_str().into()])?
335 .all()
336 .await?
337 .results::<Month>()?
338 .into_iter()
339 .map(|m| m.month)
340 .collect();
341 Ok(Outcome::Ok(Statement { month, months, groups, totals }))
342 }
343
344 /// `usage_meters`: this month's usage by meter, at cost plus the margin
345 /// (with a custom discount, if the account has one) before the plan's
346 /// included usage or a pool paid for any of it. Comped workspaces see
347 /// what it would cost. Usage charged through the month is on the
348 /// ledger; usage charged when the month closes (app traffic, custom
349 /// domains, storage, git operations, search and scans) is what has been
350 /// noted so far.
351 pub(crate) async fn usage_meters(&self, a: UsageMetersArgs) -> Result<Outcome<Vec<MeterUsage>>> {
352 let workspace = a.workspace.to_lowercase();
353 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
354 return Ok(members_only());
355 }
356 let now = rfc3339(now_ms());
357 let month = now[..7].to_owned();
358 let (from, until) = month_range(&month).expect("a valid month");
359 #[derive(Deserialize)]
360 struct Used {
361 meter: String,
362 count: u32,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit363 price: Option<f64>,
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor364 }
365 // A month's close for deployments is charged in the next month;
366 // it is last month's, so it is left out here.
367 let ledger = self
368 .db
369 .prepare(format!(
Usage, Billing settings and prepaid AI credit; fixes from the UX audit370 "SELECT {METER_SQL} AS meter, SUM(CASE WHEN {RUN_SQL} THEN 1 ELSE 0 END) AS count, SUM({PRICE_SQL}) AS price
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor371 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
372 AND COALESCE(reference, '') NOT LIKE 'deployments/%'
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged373 -- Runs on the workspace's own model provider are its provider's to bill, never g1t's.
374 AND COALESCE(billed_to, 'g1t') = 'g1t'
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor375 GROUP BY 1"
376 ))
377 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
378 .all()
379 .await?
380 .results::<Used>()?;
381 #[derive(Deserialize)]
382 struct Pending {
383 source: String,
384 cost_micros: Option<f64>,
385 detail: Option<String>,
386 }
387 let pending = self
388 .db
389 .prepare("SELECT source, cost_micros, detail FROM pending_usage WHERE workspace = ? AND month = ? AND charged_at IS NULL")
390 .bind(&[workspace.as_str().into(), month.as_str().into()])?
391 .all()
392 .await?
393 .results::<Pending>()?;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit394 // At price, before any discount, as every page shows usage: the
395 // ledger's lines as they were charged, and what is metered so far
396 // at cost plus the margin.
397 let price = |cost: i64| crate::credits::with_margin(cost, self.margin_percent);
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor398 let mut meters: Vec<MeterUsage> = METERS
399 .iter()
400 .map(|(key, label)| MeterUsage { key: (*key).to_owned(), label: (*label).to_owned(), micros: 0, quantity: None })
401 .collect();
402 let mut agent_runs = 0;
403 for used in ledger {
404 if let Some(meter) = meters.iter_mut().find(|m| m.key == used.meter) {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit405 meter.micros += used.price.unwrap_or(0.0).round() as i64;
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor406 }
407 if used.meter == "agents" {
408 agent_runs = used.count;
409 }
410 }
411 for row in &pending {
412 let key = meter_of_source(&row.source);
413 if let Some(meter) = meters.iter_mut().find(|m| m.key == key) {
414 meter.micros += price(row.cost_micros.unwrap_or(0.0).round() as i64);
415 if matches!(key, "requests" | "domains") {
416 meter.quantity = row.detail.clone().filter(|d| !d.is_empty());
417 }
418 }
419 }
420 let build_seconds = self.allowance_used("build_seconds", &workspace, &month).await?;
421 let operations = self.git_operations_this_month(&workspace).await?;
422 let stored = self.private_storage(&workspace).await?;
423 for meter in &mut meters {
424 match meter.key.as_str() {
425 "agents" => meter.quantity = runs(agent_runs),
426 "builds" => meter.quantity = build_minutes(build_seconds),
427 "git_storage" => meter.quantity = git_and_storage(operations, stored),
428 _ => {}
429 }
430 }
431 Ok(Outcome::Ok(meters))
432 }
433
434 /// What was owed when `month` closed but was under the minimum charge,
435 /// and so carried over to the next invoice.
436 async fn carried(&self, workspace: &str, month: &str) -> Result<i64> {
437 #[derive(Deserialize)]
438 struct Row {
439 amount_micros: i64,
440 }
441 Ok(self
442 .db
443 .prepare("SELECT amount_micros FROM month_closes WHERE workspace = ? AND month = ? AND status = 'carried'")
444 .bind(&[workspace.into(), month.into()])?
445 .first::<Row>(None)
446 .await?
447 .map_or(0, |row| row.amount_micros))
448 }
449
450 /// One line's entries, newest first, a page at a time.
451 pub(crate) async fn statement_entries(&self, a: StatementEntriesArgs) -> Result<Outcome<Vec<LedgerEntry>>> {
452 let workspace = a.workspace.to_lowercase();
453 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
454 return Ok(members_only());
455 }
456 let Some((from, until)) = month_range(&a.month) else {
457 return Ok(Outcome::Ok(vec![]));
458 };
459 let mut filters = vec![format!("({KIND_SQL}) = ?4")];
460 let mut values: Vec<JsValue> = vec![
461 workspace.as_str().into(),
462 from.as_str().into(),
463 until.as_str().into(),
464 a.kind.as_str().into(),
465 ];
466 if let Some(day) = a.day.as_deref().filter(|d| d.len() == 10) {
467 values.push(day.into());
468 filters.push(format!("substr(created_at, 1, 10) = ?{}", values.len()));
469 }
470 if let Some(project) = &a.project {
471 values.push(project.as_str().into());
472 filters.push(format!("COALESCE(repo, '') = ?{}", values.len()));
473 }
474 if let Some(before) = &a.before {
475 values.push(before.as_str().into());
476 filters.push(format!("id < ?{}", values.len()));
477 }
478 let rows = self
479 .db
480 .prepare(format!(
481 "SELECT * FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 AND {}
482 ORDER BY id DESC LIMIT {PAGE}",
483 filters.join(" AND ")
484 ))
485 .bind(&values)?
486 .all()
487 .await?
488 .results::<LedgerRow>()?;
489 Ok(Outcome::Ok(rows.into_iter().map(LedgerEntry::from).collect()))
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 #[test]
Merge Stripe Tax, the card fee on card payments, and one free workspace per person498 fn tax_and_card_fees_are_their_own_lines_beside_payments_never_charges() {
499 assert_eq!(extra_kind("tax"), "Tax");
500 assert_eq!(extra_kind("card_fee"), "Card processing fees");
501 for kind in ["Tax", "Card processing fees"] {
502 // Money that came in with a payment: no price, no usage.
503 assert!(is_money_in(kind));
504 assert_eq!(kind_order(kind), kind_order("Payments"));
505 }
506 let mut groups = vec![];
507 let line = |kind: &str, passed: i64| StatementLine {
508 kind: kind.to_owned(),
509 count: 1,
510 charged_micros: 0,
511 cost_micros: 0,
512 covered_micros: 0,
513 price_micros: 0,
514 discount_micros: 0,
515 passed_micros: passed,
516 };
517 add_line(&mut groups, "2026-10-08".into(), line("Tax", 1_640_000));
518 add_line(&mut groups, "2026-10-08".into(), line("Card processing fees", 920_000));
519 assert_eq!(groups.len(), 1);
520 assert!(groups[0].lines.iter().all(|l| l.charged_micros == 0));
521 }
522
523 #[test]
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor524 fn a_month_runs_to_the_first_of_the_next() {
525 assert_eq!(month_range("2026-10"), Some(("2026-10-01".into(), "2026-11-01".into())));
526 assert_eq!(month_range("2026-12"), Some(("2026-12-01".into(), "2027-01-01".into())));
527 assert_eq!(month_range("2026-13"), None);
528 assert_eq!(month_range("oops"), None);
529 }
530
531 #[test]
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging532 fn a_discounted_line_shows_its_price_and_the_discount() {
533 // $1.20 of usage on a 100% discount: charged nothing, all of it off.
534 assert_eq!(line_price(0, 0, 1_200_000), 1_200_000);
535 // 30% off, $0.20 of it paid by included usage first.
536 assert_eq!(line_price(700_000, 140_000, 360_000), 1_200_000);
537 assert!(is_money_in("Payments") && is_money_in("Credits from g1t") && is_money_in("Refunds"));
538 assert!(!is_money_in("Agent runs"));
539 }
540
541 #[test]
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor542 fn usage_lines_come_before_money_in() {
543 assert!(kind_order("Agent runs") < kind_order("Sandbox time"));
544 assert!(kind_order("Deployments") < kind_order("Payments"));
545 assert!(kind_order("Security scans") < kind_order("Payments"));
546 }
547
548 #[test]
549 fn usage_falls_on_the_meters_the_billing_page_shows() {
550 let keys: Vec<_> = METERS.iter().map(|(key, _)| *key).collect();
551 assert_eq!(keys, ["agents", "builds", "requests", "domains", "git_storage", "search_scans"]);
552 assert_eq!(meter_of_source("deployments"), "requests");
553 assert_eq!(meter_of_source("domains"), "domains");
554 assert_eq!(meter_of_source("git"), "git_storage");
555 assert_eq!(meter_of_source("storage"), "git_storage");
556 assert_eq!(meter_of_source("context"), "search_scans");
557 assert_eq!(meter_of_source("security"), "search_scans");
558 for key in ["builds", "requests", "git_storage", "search_scans", "agents"] {
559 assert!(METER_SQL.contains(&format!("'{key}'")));
560 }
561 }
562
563 #[test]
564 fn quantities_read_plainly() {
565 assert_eq!(build_minutes(0), None);
566 assert_eq!(build_minutes(1).as_deref(), Some("1 build minute"));
567 assert_eq!(build_minutes(2_461).as_deref(), Some("42 build minutes"));
568 assert_eq!(runs(1).as_deref(), Some("1 run"));
569 assert_eq!(runs(1_200).as_deref(), Some("1,200 runs"));
570 assert_eq!(git_and_storage(0, 0), None);
571 assert_eq!(git_and_storage(12_345, 420_000_000).as_deref(), Some("12,345 git operations, 0.42 GB private"));
572 assert_eq!(git_and_storage(1, 0).as_deref(), Some("1 git operation"));
573 }
574
575 #[test]
576 fn the_statement_says_what_paid_before_the_workspace_did() {
577 let lines = covered_lines([0, 250_000, 120_000, 0]);
578 assert_eq!(lines.len(), 2);
579 assert_eq!(lines[0].label, "Paid by your trial credit");
580 assert_eq!(lines[1], Covered { source: "oss_pool".into(), label: "Paid by g1t's open-source pool".into(), micros: 120_000 });
581 assert!(covered_lines([0, 0, 0, 0]).is_empty());
582 let lines = covered_lines([4_000_000, 0, 0, 30_000]);
583 assert_eq!(lines[0].label, "Paid by your plan's included usage");
584 assert_eq!(lines[1].label, "Covered by g1t");
585 }
586}

This file's history is long; its oldest lines are credited to the oldest commit read.