Skip to content

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.sh
660 linesCodeBlameRaw
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, MeterUsage, Statement, StatementArgs, StatementEntriesArgs, StatementGroup, StatementLine, StatementTotals,
7 UsageMetersArgs,
8};
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/// The agent kinds (`Agent runs`, `Agent rate`, `AI Gateway`, `Runs on
24/// your own model provider`, `AI credit`) are only on older months, from
25/// when g1t ran agents and sold model access. A line on a task this does
26/// not know is `Other usage`, never left out.
27pub(crate) const KIND_SQL: &str = "CASE
28 WHEN kind = 'top_up' AND credit_kind = 'purchased' THEN 'AI credit'
29 WHEN kind = 'top_up' AND reference LIKE 'crd%' THEN 'Credits from g1t'
30 WHEN kind = 'top_up' AND amount_micros < 0 THEN 'Refunds'
31 WHEN kind = 'top_up' THEN 'Payments'
32 WHEN task = 'sandbox' AND machine IS NOT NULL THEN 'Runner minutes, ' || machine
33 WHEN task = 'sandbox' THEN 'Sandbox time'
34 WHEN task = 'self_hosted' THEN 'Self-hosted runner time'
35 WHEN task = 'deployments' THEN 'Deployments'
36 WHEN task = 'security' THEN 'Security scans'
37 WHEN task = 'context' THEN 'Search embeddings'
38 WHEN task = 'storage' THEN 'Private storage'
39 WHEN task = 'cache' THEN 'Actions cache storage'
40 WHEN task = 'git' THEN 'Git operations'
41 WHEN reference LIKE '%/agent-own%' THEN 'Agent rate, your own model key'
42 WHEN billed_to = 'workspace' THEN 'Runs on your own model provider'
43 WHEN reference LIKE '%/agent%' THEN 'Agent rate'
44 WHEN task = 'gateway' THEN 'AI Gateway'
45 WHEN task = 'domains' THEN 'Custom domains'
46 WHEN task = 'package_storage' THEN 'Package storage'
47 WHEN task = 'telemetry' THEN 'Observability data'
48 WHEN reference LIKE 'run%' THEN 'Agent runs'
49 ELSE 'Other usage' END";
50
51/// How a runner-minutes line begins: `Runner minutes, 2-core`, by machine.
52pub(crate) const RUNNER_MINUTES: &str = "Runner minutes, ";
53
54/// A usage line at its price, in SQL: what was charged, what paid for it
55/// first (the plan's included usage, the trial, a pool, g1t), and what a
56/// discount took off. `line_price`, for a whole column. The one measure of
57/// usage every page shows.
58pub(crate) const PRICE_SQL: &str = "(-amount_micros + COALESCE(credit_micros, 0) + COALESCE(gift_micros, 0) + COALESCE(trial_micros, 0) + COALESCE(oss_micros, 0)
59 + COALESCE(given_micros, 0) + COALESCE(discount_micros, 0))";
60
61/// Whether a usage line is an agent run's own line (`run_…`, from when g1t
62/// ran agents), not its agent rate, a correction or anything else.
63pub(crate) const RUN_SQL: &str = "(reference LIKE 'run%' AND instr(reference, '/') = 0)";
64
65/// The order lines appear in within a group.
66pub(crate) fn kind_order(kind: &str) -> u8 {
67 if kind.starts_with(RUNNER_MINUTES) {
68 return 2;
69 }
70 match kind {
71 "Agent runs" => 0,
72 "Agent rate" => 0,
73 "Agent rate, your own model key" => 0,
74 "AI Gateway" => 1,
75 "Runs on your own model provider" => 1,
76 "Sandbox time" => 2,
77 "Self-hosted runner time" => 2,
78 "Deployments" => 3,
79 "Private storage" => 4,
80 "Actions cache storage" => 4,
81 "Git operations" => 5,
82 "Search embeddings" => 6,
83 "Security scans" => 7,
84 "Custom domains" => 3,
85 "Package storage" => 4,
86 "Observability data" => 4,
87 "Payments" => 8,
88 "AI credit" => 8,
89 "Credits from g1t" => 9,
90 "Refunds" => 10,
91 "Tax" => 8,
92 "Card processing fees" => 8,
93 _ => 11,
94 }
95}
96
97/// Payments, credits and refunds: money in, which has no price.
98pub(crate) fn is_money_in(kind: &str) -> bool {
99 matches!(kind, "Payments" | "AI credit" | "Credits from g1t" | "Refunds" | "Tax" | "Card processing fees")
100}
101
102/// A usage line at its price: what was charged, what paid for it first,
103/// and what the account's discount took off.
104pub(crate) fn line_price(charged: i64, covered: i64, discount: i64) -> i64 {
105 charged + covered + discount
106}
107
108/// The Billing page's meters, in order: key and label.
109pub(crate) const METERS: [(&str, &str); 6] = [
110 ("agents", "Runner minutes & other usage"),
111 ("builds", "Builds"),
112 ("requests", "Requests & CPU"),
113 ("domains", "Custom domains"),
114 ("git_storage", "Git operations & storage"),
115 ("search_scans", "Search & security scans"),
116];
117
118/// The meter a ledger row's usage is on. One SQL expression, as `KIND_SQL`.
119pub(crate) const METER_SQL: &str = "CASE
120 WHEN task = 'deployments' AND reference LIKE 'deploy/%' THEN 'builds'
121 WHEN task = 'deployments' THEN 'requests'
122 WHEN task IN ('storage', 'git', 'cache', 'package_storage', 'telemetry') THEN 'git_storage'
123 WHEN task IN ('security', 'context') THEN 'search_scans'
124 ELSE 'agents' END";
125
126/// The meter usage noted through the month (`pending_usage.source`) is on.
127pub(crate) fn meter_of_source(source: &str) -> &'static str {
128 match source {
129 "deployments" => "requests",
130 "domains" => "domains",
131 "storage" | "git" | "cache" | "package_storage" | "telemetry" => "git_storage",
132 _ => "search_scans",
133 }
134}
135
136/// `41 build minutes`, from seconds, rounded up.
137pub(crate) fn build_minutes(seconds: i64) -> Option<String> {
138 let minutes = (seconds.max(0) + 59) / 60;
139 (seconds > 0).then(|| format!("{} build minute{}", crate::features::thousands(minutes as u64), if minutes == 1 { "" } else { "s" }))
140}
141
142/// `40 runner minutes on 2-core, 6 on 4-core`, by machine; None without any.
143pub(crate) fn runner_minutes(by_machine: &[(String, i64)]) -> Option<String> {
144 let used: Vec<_> = by_machine.iter().filter(|(_, minutes)| *minutes > 0).collect();
145 let (first, rest) = used.split_first()?;
146 let mut text = format!(
147 "{} runner minute{} on {}",
148 crate::features::thousands(first.1 as u64),
149 if first.1 == 1 { "" } else { "s" },
150 first.0
151 );
152 for (machine, minutes) in rest {
153 text.push_str(&format!(", {} on {machine}", crate::features::thousands(*minutes as u64)));
154 }
155 Some(text)
156}
157
158/// `12 runs`.
159pub(crate) fn runs(count: u32) -> Option<String> {
160 (count > 0).then(|| format!("{} run{}", crate::features::thousands(count.into()), if count == 1 { "" } else { "s" }))
161}
162
163/// `12,345 git operations, 0.42 GB private`.
164pub(crate) fn git_and_storage(operations: u64, private_bytes: i64) -> Option<String> {
165 let mut parts = vec![];
166 if operations > 0 {
167 parts.push(format!("{} git operation{}", crate::features::thousands(operations), if operations == 1 { "" } else { "s" }));
168 }
169 if private_bytes > 0 {
170 parts.push(format!("{:.2} GB private", private_bytes as f64 / 1e9));
171 }
172 (!parts.is_empty()).then(|| parts.join(", "))
173}
174
175/// What paid for usage before it was charged, as the statement names it,
176/// with the ledger column that holds it.
177pub(crate) const COVERED: [(&str, &str, &str); 5] = [
178 ("included", "credit_micros", "Paid by your plan's included usage"),
179 ("complimentary", "gift_micros", "Paid by your complimentary allowance"),
180 ("trial", "trial_micros", "Paid by your trial credit"),
181 ("oss_pool", "oss_micros", "Paid by g1t's open-source pool"),
182 ("given", "given_micros", "Covered by g1t"),
183];
184
185/// The statement's lines for what paid: each source with anything to show.
186pub(crate) fn covered_lines(sums: [i64; 5]) -> Vec<Covered> {
187 COVERED
188 .iter()
189 .zip(sums)
190 .filter(|(_, micros)| *micros > 0)
191 .map(|((source, _, label), micros)| Covered { source: (*source).to_owned(), label: (*label).to_owned(), micros })
192 .collect()
193}
194
195/// `2026-10` and the first instant of the next month, for a range.
196pub(crate) fn month_range(month: &str) -> Option<(String, String)> {
197 let year: i32 = month.get(..4)?.parse().ok()?;
198 let number: u32 = month.get(5..7)?.parse().ok()?;
199 if month.len() != 7 || !(1..=12).contains(&number) {
200 return None;
201 }
202 let next = if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) };
203 Some((format!("{month}-01"), format!("{next}-01")))
204}
205
206#[derive(Deserialize)]
207struct Row {
208 group_key: Option<String>,
209 kind: String,
210 count: u32,
211 amount: Option<i64>,
212 cost: Option<i64>,
213 credit: Option<i64>,
214 #[serde(default)]
215 gift: Option<i64>,
216 trial: Option<i64>,
217 oss: Option<i64>,
218 given: Option<i64>,
219 discount: Option<i64>,
220 #[serde(default)]
221 minutes: Option<f64>,
222}
223
224#[derive(Deserialize)]
225struct Month {
226 month: String,
227}
228
229#[derive(Deserialize)]
230struct ExtraRow {
231 group_key: Option<String>,
232 kind: String,
233 count: u32,
234 amount: Option<i64>,
235}
236
237/// How the statement names a `tax_and_fees` kind.
238pub(crate) fn extra_kind(kind: &str) -> &'static str {
239 if kind == "tax" { "Tax" } else { "Card processing fees" }
240}
241
242/// Puts a line in its group, starting the group if it is new.
243fn add_line(groups: &mut Vec<StatementGroup>, key: String, line: StatementLine) {
244 match groups.iter_mut().find(|g| g.key == key) {
245 Some(group) => group.lines.push(line),
246 None => groups.push(StatementGroup {
247 label: if key.is_empty() { "Not one project".to_owned() } else { key.clone() },
248 key,
249 lines: vec![line],
250 charged_micros: 0,
251 price_micros: 0,
252 discount_micros: 0,
253 }),
254 }
255}
256
257impl Billing {
258 pub(crate) async fn statement(&self, a: StatementArgs) -> Result<Outcome<Statement>> {
259 let workspace = a.workspace.to_lowercase();
260 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
261 return Ok(members_only());
262 }
263 let current = rfc3339(now_ms())[..7].to_owned();
264 let month = a.month.filter(|m| month_range(m).is_some()).unwrap_or(current);
265 let (from, until) = month_range(&month).expect("a valid month");
266 let by_project = a.group.as_deref() == Some("project");
267 let group_sql = if by_project { "COALESCE(repo, '')" } else { "substr(created_at, 1, 10)" };
268 let rows = self
269 .db
270 .prepare(format!(
271 "SELECT {group_sql} AS group_key, {KIND_SQL} AS kind, COUNT(*) AS count,
272 SUM(amount_micros) AS amount, SUM(cost_micros) AS cost,
273 SUM(credit_micros) AS credit, SUM(gift_micros) AS gift, SUM(trial_micros) AS trial, SUM(oss_micros) AS oss,
274 SUM(given_micros) AS given, SUM(discount_micros) AS discount,
275 SUM(CASE WHEN task = 'sandbox' AND machine IS NOT NULL THEN quantity END) AS minutes
276 FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3
277 GROUP BY 1, 2"
278 ))
279 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
280 .all()
281 .await?
282 .results::<Row>()?;
283
284 let mut groups: Vec<StatementGroup> = vec![];
285 let mut covered = [0i64; 5];
286 for row in rows {
287 let key = row.group_key.unwrap_or_default();
288 let amount = row.amount.unwrap_or(0);
289 let paid_for = [row.credit.unwrap_or(0), row.gift.unwrap_or(0), row.trial.unwrap_or(0), row.oss.unwrap_or(0), row.given.unwrap_or(0)];
290 for (total, micros) in covered.iter_mut().zip(paid_for) {
291 *total += micros;
292 }
293 let discount = row.discount.unwrap_or(0);
294 let line = StatementLine {
295 // Charges positive, money in negative, as a statement reads.
296 charged_micros: -amount,
297 cost_micros: row.cost.unwrap_or(0),
298 covered_micros: paid_for.iter().sum(),
299 price_micros: if is_money_in(&row.kind) { 0 } else { line_price(-amount, paid_for.iter().sum(), discount) },
300 discount_micros: discount,
301 kind: row.kind,
302 count: row.count,
303 passed_micros: 0,
304 minutes: row.minutes.map(|m| m.round() as i64),
305 };
306 add_line(&mut groups, key, line);
307 }
308 // Tax and card fees paid with the month's payments: their own
309 // lines, beside the payment, never in what was charged or paid.
310 let extras = self
311 .db
312 .prepare(format!(
313 "SELECT {} AS group_key, kind, COUNT(*) AS count, SUM(amount_micros) AS amount
314 FROM tax_and_fees WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 GROUP BY 1, 2",
315 if by_project { "''" } else { "substr(created_at, 1, 10)" }
316 ))
317 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
318 .all()
319 .await?
320 .results::<ExtraRow>()?;
321 let (mut tax_micros, mut card_fee_micros) = (0, 0);
322 for extra in extras {
323 let amount = extra.amount.unwrap_or(0);
324 if extra.kind == "tax" {
325 tax_micros += amount;
326 } else {
327 card_fee_micros += amount;
328 }
329 let line = StatementLine {
330 kind: extra_kind(&extra.kind).to_owned(),
331 count: extra.count,
332 charged_micros: 0,
333 cost_micros: 0,
334 covered_micros: 0,
335 price_micros: 0,
336 discount_micros: 0,
337 passed_micros: amount,
338 minutes: None,
339 };
340 add_line(&mut groups, extra.group_key.unwrap_or_default(), line);
341 }
342 for group in &mut groups {
343 group.lines.sort_by_key(|line| kind_order(&line.kind));
344 group.charged_micros = group.lines.iter().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum();
345 group.price_micros = group.lines.iter().map(|l| l.price_micros).sum();
346 group.discount_micros = group.lines.iter().map(|l| l.discount_micros).sum();
347 }
348 if by_project {
349 groups.sort_by_key(|a| std::cmp::Reverse(a.charged_micros));
350 } else {
351 groups.sort_by(|a, b| b.key.cmp(&a.key));
352 }
353 let lines = groups.iter().flat_map(|g| g.lines.iter());
354 let totals = StatementTotals {
355 charged_micros: lines.clone().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum(),
356 paid_micros: lines.clone().filter(|l| l.charged_micros < 0).map(|l| -l.charged_micros).sum(),
357 cost_micros: lines.clone().map(|l| l.cost_micros).sum(),
358 price_micros: lines.clone().map(|l| l.price_micros).sum(),
359 discount_micros: lines.clone().map(|l| l.discount_micros).sum(),
360 discount_percent: Some(self.terms_of(&workspace).await?.percent_off()).filter(|p| *p > 0),
361 entries: lines.map(|l| l.count).sum(),
362 covered: covered_lines(covered),
363 carried_micros: self.carried(&workspace, &month).await?,
364 tax_micros,
365 card_fee_micros,
366 };
367 let months = self
368 .db
369 .prepare("SELECT DISTINCT substr(created_at, 1, 7) AS month FROM ledger WHERE workspace = ? ORDER BY 1 DESC LIMIT 36")
370 .bind(&[workspace.as_str().into()])?
371 .all()
372 .await?
373 .results::<Month>()?
374 .into_iter()
375 .map(|m| m.month)
376 .collect();
377 Ok(Outcome::Ok(Statement { month, months, groups, totals }))
378 }
379
380 /// `usage_meters`: this month's usage by meter, at cost plus the margin
381 /// (with a custom discount, if the account has one) before the plan's
382 /// included usage or a pool paid for any of it. Comped workspaces see
383 /// what it would cost. Usage charged through the month is on the
384 /// ledger; usage charged when the month closes (app traffic, custom
385 /// domains, storage, git operations, search and scans) is what has been
386 /// noted so far.
387 pub(crate) async fn usage_meters(&self, a: UsageMetersArgs) -> Result<Outcome<Vec<MeterUsage>>> {
388 let workspace = a.workspace.to_lowercase();
389 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
390 return Ok(members_only());
391 }
392 let now = rfc3339(now_ms());
393 let month = now[..7].to_owned();
394 let (from, until) = month_range(&month).expect("a valid month");
395 #[derive(Deserialize)]
396 struct Used {
397 meter: String,
398 count: u32,
399 price: Option<f64>,
400 }
401 // A month's close for deployments is charged in the next month;
402 // it is last month's, so it is left out here.
403 let ledger = self
404 .db
405 .prepare(format!(
406 "SELECT {METER_SQL} AS meter, SUM(CASE WHEN {RUN_SQL} THEN 1 ELSE 0 END) AS count, SUM({PRICE_SQL}) AS price
407 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
408 AND COALESCE(reference, '') NOT LIKE 'deployments/%'
409 -- Runs on the workspace's own model provider are its provider's to bill, never g1t's.
410 AND COALESCE(billed_to, 'g1t') = 'g1t'
411 GROUP BY 1"
412 ))
413 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
414 .all()
415 .await?
416 .results::<Used>()?;
417 #[derive(Deserialize)]
418 struct Pending {
419 source: String,
420 cost_micros: Option<f64>,
421 detail: Option<String>,
422 }
423 let pending = self
424 .db
425 // Less what was metered before a test-mode detach: never charged.
426 .prepare(format!(
427 "SELECT source, {} AS cost_micros, detail FROM pending_usage WHERE workspace = ? AND month = ? AND charged_at IS NULL",
428 crate::storage::NET_COST
429 ))
430 .bind(&[workspace.as_str().into(), month.as_str().into()])?
431 .all()
432 .await?
433 .results::<Pending>()?;
434 // At price, before any discount, as every page shows usage: the
435 // ledger's lines as they were charged, and what is metered so far
436 // at cost plus the margin.
437 let price = |cost: i64| crate::credits::with_margin(cost, self.margin_percent);
438 let mut meters: Vec<MeterUsage> = METERS
439 .iter()
440 .map(|(key, label)| MeterUsage { key: (*key).to_owned(), label: (*label).to_owned(), micros: 0, quantity: None })
441 .collect();
442 let mut agent_runs = 0;
443 for used in ledger {
444 if let Some(meter) = meters.iter_mut().find(|m| m.key == used.meter) {
445 meter.micros += used.price.unwrap_or(0.0).round() as i64;
446 }
447 if used.meter == "agents" {
448 agent_runs = used.count;
449 }
450 }
451 for row in &pending {
452 let key = meter_of_source(&row.source);
453 if let Some(meter) = meters.iter_mut().find(|m| m.key == key) {
454 meter.micros += price(row.cost_micros.unwrap_or(0.0).round() as i64);
455 if matches!(key, "requests" | "domains") {
456 meter.quantity = row.detail.clone().filter(|d| !d.is_empty());
457 }
458 }
459 }
460 // Runner minutes this month, by machine.
461 #[derive(Deserialize)]
462 struct Minutes {
463 machine: String,
464 minutes: Option<f64>,
465 }
466 let minutes: Vec<(String, i64)> = self
467 .db
468 .prepare(
469 "SELECT machine, SUM(quantity) AS minutes FROM ledger
470 WHERE workspace = ?1 AND kind = 'usage' AND task = 'sandbox' AND machine IS NOT NULL AND created_at >= ?2 AND created_at < ?3
471 GROUP BY machine ORDER BY machine",
472 )
473 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
474 .all()
475 .await?
476 .results::<Minutes>()?
477 .into_iter()
478 .map(|m| (m.machine, m.minutes.unwrap_or(0.0).round() as i64))
479 .collect();
480 let build_seconds = self.allowance_used("build_seconds", &workspace, &month).await?;
481 let operations = self.git_operations_this_month(&workspace).await?;
482 let stored = self.private_storage(&workspace).await?;
483 for meter in &mut meters {
484 match meter.key.as_str() {
485 "agents" => meter.quantity = runner_minutes(&minutes).or_else(|| runs(agent_runs)),
486 "builds" => meter.quantity = build_minutes(build_seconds),
487 "git_storage" => meter.quantity = git_and_storage(operations, stored),
488 _ => {}
489 }
490 }
491 Ok(Outcome::Ok(meters))
492 }
493
494 /// What was owed when `month` closed but was under the minimum charge,
495 /// and so carried over to the next invoice.
496 async fn carried(&self, workspace: &str, month: &str) -> Result<i64> {
497 #[derive(Deserialize)]
498 struct Row {
499 amount_micros: i64,
500 }
501 Ok(self
502 .db
503 .prepare("SELECT amount_micros FROM month_closes WHERE workspace = ? AND month = ? AND status = 'carried'")
504 .bind(&[workspace.into(), month.into()])?
505 .first::<Row>(None)
506 .await?
507 .map_or(0, |row| row.amount_micros))
508 }
509
510 /// One line's entries, newest first, a page at a time.
511 pub(crate) async fn statement_entries(&self, a: StatementEntriesArgs) -> Result<Outcome<Vec<LedgerEntry>>> {
512 let workspace = a.workspace.to_lowercase();
513 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
514 return Ok(members_only());
515 }
516 let Some((from, until)) = month_range(&a.month) else {
517 return Ok(Outcome::Ok(vec![]));
518 };
519 let mut filters = vec![format!("({KIND_SQL}) = ?4")];
520 let mut values: Vec<JsValue> = vec![
521 workspace.as_str().into(),
522 from.as_str().into(),
523 until.as_str().into(),
524 a.kind.as_str().into(),
525 ];
526 if let Some(day) = a.day.as_deref().filter(|d| d.len() == 10) {
527 values.push(day.into());
528 filters.push(format!("substr(created_at, 1, 10) = ?{}", values.len()));
529 }
530 if let Some(project) = &a.project {
531 values.push(project.as_str().into());
532 filters.push(format!("COALESCE(repo, '') = ?{}", values.len()));
533 }
534 if let Some(before) = &a.before {
535 values.push(before.as_str().into());
536 filters.push(format!("id < ?{}", values.len()));
537 }
538 let rows = self
539 .db
540 .prepare(format!(
541 "SELECT * FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 AND {}
542 ORDER BY id DESC LIMIT {PAGE}",
543 filters.join(" AND ")
544 ))
545 .bind(&values)?
546 .all()
547 .await?
548 .results::<LedgerRow>()?;
549 Ok(Outcome::Ok(rows.into_iter().map(LedgerEntry::from).collect()))
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 #[test]
558 fn tax_and_card_fees_are_their_own_lines_beside_payments_never_charges() {
559 assert_eq!(extra_kind("tax"), "Tax");
560 assert_eq!(extra_kind("card_fee"), "Card processing fees");
561 for kind in ["Tax", "Card processing fees"] {
562 // Money that came in with a payment: no price, no usage.
563 assert!(is_money_in(kind));
564 assert_eq!(kind_order(kind), kind_order("Payments"));
565 }
566 let mut groups = vec![];
567 let line = |kind: &str, passed: i64| StatementLine {
568 kind: kind.to_owned(),
569 count: 1,
570 charged_micros: 0,
571 cost_micros: 0,
572 covered_micros: 0,
573 price_micros: 0,
574 discount_micros: 0,
575 passed_micros: passed,
576 minutes: None,
577 };
578 add_line(&mut groups, "2026-10-08".into(), line("Tax", 1_640_000));
579 add_line(&mut groups, "2026-10-08".into(), line("Card processing fees", 920_000));
580 assert_eq!(groups.len(), 1);
581 assert!(groups[0].lines.iter().all(|l| l.charged_micros == 0));
582 }
583
584 #[test]
585 fn a_month_runs_to_the_first_of_the_next() {
586 assert_eq!(month_range("2026-10"), Some(("2026-10-01".into(), "2026-11-01".into())));
587 assert_eq!(month_range("2026-12"), Some(("2026-12-01".into(), "2027-01-01".into())));
588 assert_eq!(month_range("2026-13"), None);
589 assert_eq!(month_range("oops"), None);
590 }
591
592 #[test]
593 fn a_discounted_line_shows_its_price_and_the_discount() {
594 // $1.20 of usage on a 100% discount: charged nothing, all of it off.
595 assert_eq!(line_price(0, 0, 1_200_000), 1_200_000);
596 // 30% off, $0.20 of it paid by included usage first.
597 assert_eq!(line_price(700_000, 140_000, 360_000), 1_200_000);
598 assert!(is_money_in("Payments") && is_money_in("Credits from g1t") && is_money_in("Refunds"));
599 assert!(!is_money_in("Agent runs"));
600 }
601
602 #[test]
603 fn usage_lines_come_before_money_in() {
604 assert!(kind_order("Agent runs") < kind_order("Sandbox time"));
605 // Runner minutes, by machine, sit with sandbox time.
606 assert_eq!(kind_order("Runner minutes, 4-core"), kind_order("Sandbox time"));
607 assert!(KIND_SQL.contains("'Runner minutes, ' || machine") && RUNNER_MINUTES == "Runner minutes, ");
608 assert!(KIND_SQL.find("'Runner minutes, '") < KIND_SQL.find("'Sandbox time'"));
609 assert!(kind_order("Deployments") < kind_order("Payments"));
610 assert!(kind_order("Security scans") < kind_order("Payments"));
611 }
612
613 #[test]
614 fn usage_falls_on_the_meters_the_billing_page_shows() {
615 let keys: Vec<_> = METERS.iter().map(|(key, _)| *key).collect();
616 assert_eq!(keys, ["agents", "builds", "requests", "domains", "git_storage", "search_scans"]);
617 assert_eq!(meter_of_source("deployments"), "requests");
618 assert_eq!(meter_of_source("domains"), "domains");
619 assert_eq!(meter_of_source("git"), "git_storage");
620 assert_eq!(meter_of_source("storage"), "git_storage");
621 assert_eq!(meter_of_source("context"), "search_scans");
622 assert_eq!(meter_of_source("security"), "search_scans");
623 for key in ["builds", "requests", "git_storage", "search_scans", "agents"] {
624 assert!(METER_SQL.contains(&format!("'{key}'")));
625 }
626 }
627
628 #[test]
629 fn quantities_read_plainly() {
630 assert_eq!(build_minutes(0), None);
631 assert_eq!(build_minutes(1).as_deref(), Some("1 build minute"));
632 assert_eq!(build_minutes(2_461).as_deref(), Some("42 build minutes"));
633 assert_eq!(runs(1).as_deref(), Some("1 run"));
634 assert_eq!(runner_minutes(&[]), None);
635 assert_eq!(runner_minutes(&[("2-core".into(), 0)]), None);
636 assert_eq!(runner_minutes(&[("2-core".into(), 1)]).as_deref(), Some("1 runner minute on 2-core"));
637 assert_eq!(
638 runner_minutes(&[("2-core".into(), 1_240), ("4-core".into(), 6)]).as_deref(),
639 Some("1,240 runner minutes on 2-core, 6 on 4-core")
640 );
641 assert_eq!(runs(1_200).as_deref(), Some("1,200 runs"));
642 assert_eq!(git_and_storage(0, 0), None);
643 assert_eq!(git_and_storage(12_345, 420_000_000).as_deref(), Some("12,345 git operations, 0.42 GB private"));
644 assert_eq!(git_and_storage(1, 0).as_deref(), Some("1 git operation"));
645 }
646
647 #[test]
648 fn the_statement_says_what_paid_before_the_workspace_did() {
649 let lines = covered_lines([0, 0, 250_000, 120_000, 0]);
650 assert_eq!(lines.len(), 2);
651 assert_eq!(lines[0].label, "Paid by your trial credit");
652 assert_eq!(lines[1], Covered { source: "oss_pool".into(), label: "Paid by g1t's open-source pool".into(), micros: 120_000 });
653 assert!(covered_lines([0, 0, 0, 0, 0]).is_empty());
654 let lines = covered_lines([4_000_000, 0, 0, 0, 30_000]);
655 assert_eq!(lines[0].label, "Paid by your plan's included usage");
656 assert_eq!(lines[1].label, "Covered by g1t");
657 let lines = covered_lines([0, 7_000_000, 0, 0, 0]);
658 assert_eq!(lines[0].label, "Paid by your complimentary allowance");
659 }
660}