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 statement is a month at a time, a line per kind of charge | 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 | ||
| 5 | use g1t_contracts::billing::{ | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 6 | Covered, LedgerEntry, MeterUsage, Statement, StatementArgs, StatementEntriesArgs, StatementGroup, StatementLine, StatementTotals, |
| 7 | TermsKind, UsageMetersArgs, | |
| The statement is a month at a time, a line per kind of charge | 8 | }; |
| 9 | use g1t_contracts::time::rfc3339; | |
| 10 | use g1t_contracts::Outcome; | |
| 11 | use g1t_kit::now_ms; | |
| 12 | use serde::Deserialize; | |
| 13 | use worker::Result; | |
| 14 | use worker::wasm_bindgen::JsValue; | |
| 15 | ||
| 16 | use crate::{Billing, LedgerRow, members_only}; | |
| 17 | ||
| 18 | /// Entries in one page of a line. | |
| 19 | const 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. | |
| Prices are what g1t pays plus 20%, from the first second | 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. | |
| The statement is a month at a time, a line per kind of charge | 25 | pub(crate) const KIND_SQL: &str = "CASE |
| 26 | WHEN kind = 'top_up' AND reference LIKE 'crd%' THEN 'Credits from g1t' | |
| 27 | WHEN kind = 'top_up' AND amount_micros < 0 THEN 'Refunds' | |
| 28 | WHEN kind = 'top_up' THEN 'Payments' | |
| 29 | WHEN task = 'sandbox' THEN 'Sandbox time' | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 30 | WHEN task = 'self_hosted' THEN 'Self-hosted runner time' |
| The statement is a month at a time, a line per kind of charge | 31 | WHEN task = 'deployments' THEN 'Deployments' |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 32 | WHEN task = 'security' THEN 'Security scans' |
| 33 | WHEN task = 'context' THEN 'Search embeddings' | |
| 34 | WHEN task = 'storage' THEN 'Private storage' | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 35 | WHEN task = 'cache' THEN 'Actions cache storage' |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 36 | WHEN task = 'git' THEN 'Git operations' |
| The statement is a month at a time, a line per kind of charge | 37 | WHEN billed_to = 'workspace' THEN 'Runs on your own model provider' |
| 38 | ELSE 'Agent runs' END"; | |
| 39 | ||
| 40 | /// The order lines appear in within a group. | |
| 41 | pub(crate) fn kind_order(kind: &str) -> u8 { | |
| 42 | match kind { | |
| 43 | "Agent runs" => 0, | |
| 44 | "Runs on your own model provider" => 1, | |
| 45 | "Sandbox time" => 2, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 46 | "Self-hosted runner time" => 2, |
| The statement is a month at a time, a line per kind of charge | 47 | "Deployments" => 3, |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 48 | "Private storage" => 4, |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 49 | "Actions cache storage" => 4, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 50 | "Git operations" => 5, |
| 51 | "Search embeddings" => 6, | |
| 52 | "Security scans" => 7, | |
| 53 | "Payments" => 8, | |
| 54 | "Credits from g1t" => 9, | |
| 55 | "Refunds" => 10, | |
| 56 | _ => 11, | |
| The statement is a month at a time, a line per kind of charge | 57 | } |
| 58 | } | |
| 59 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 60 | /// The Billing page's meters, in order: key and label. |
| 61 | pub(crate) const METERS: [(&str, &str); 6] = [ | |
| 62 | ("agents", "Agents & sandboxes"), | |
| 63 | ("builds", "Builds"), | |
| 64 | ("requests", "Requests & CPU"), | |
| 65 | ("domains", "Custom domains"), | |
| 66 | ("git_storage", "Git operations & storage"), | |
| 67 | ("search_scans", "Search & security scans"), | |
| 68 | ]; | |
| 69 | ||
| 70 | /// The meter a ledger row's usage is on. One SQL expression, as `KIND_SQL`. | |
| 71 | pub(crate) const METER_SQL: &str = "CASE | |
| 72 | WHEN task = 'deployments' AND reference LIKE 'deploy/%' THEN 'builds' | |
| 73 | WHEN task = 'deployments' THEN 'requests' | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 74 | WHEN task IN ('storage', 'git', 'cache') THEN 'git_storage' |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 75 | WHEN task IN ('security', 'context') THEN 'search_scans' |
| 76 | ELSE 'agents' END"; | |
| 77 | ||
| 78 | /// The meter usage noted through the month (`pending_usage.source`) is on. | |
| 79 | pub(crate) fn meter_of_source(source: &str) -> &'static str { | |
| 80 | match source { | |
| 81 | "deployments" => "requests", | |
| 82 | "domains" => "domains", | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 83 | "storage" | "git" | "cache" => "git_storage", |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 84 | _ => "search_scans", |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | /// `41 build minutes`, from seconds, rounded up. | |
| 89 | pub(crate) fn build_minutes(seconds: i64) -> Option<String> { | |
| 90 | let minutes = (seconds.max(0) + 59) / 60; | |
| 91 | (seconds > 0).then(|| format!("{} build minute{}", crate::features::thousands(minutes as u64), if minutes == 1 { "" } else { "s" })) | |
| 92 | } | |
| 93 | ||
| 94 | /// `12 runs`. | |
| 95 | pub(crate) fn runs(count: u32) -> Option<String> { | |
| 96 | (count > 0).then(|| format!("{} run{}", crate::features::thousands(count.into()), if count == 1 { "" } else { "s" })) | |
| 97 | } | |
| 98 | ||
| 99 | /// `12,345 git operations, 0.42 GB private`. | |
| 100 | pub(crate) fn git_and_storage(operations: u64, private_bytes: i64) -> Option<String> { | |
| 101 | let mut parts = vec![]; | |
| 102 | if operations > 0 { | |
| 103 | parts.push(format!("{} git operation{}", crate::features::thousands(operations), if operations == 1 { "" } else { "s" })); | |
| 104 | } | |
| 105 | if private_bytes > 0 { | |
| 106 | parts.push(format!("{:.2} GB private", private_bytes as f64 / 1e9)); | |
| 107 | } | |
| 108 | (!parts.is_empty()).then(|| parts.join(", ")) | |
| 109 | } | |
| 110 | ||
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 111 | /// What paid for usage before it was charged, as the statement names it, |
| 112 | /// with the ledger column that holds it. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 113 | pub(crate) const COVERED: [(&str, &str, &str); 4] = [ |
| 114 | ("included", "credit_micros", "Paid by your plan's included usage"), | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 115 | ("trial", "trial_micros", "Paid by your trial credit"), |
| 116 | ("oss_pool", "oss_micros", "Paid by g1t's open-source pool"), | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 117 | ("given", "given_micros", "Covered by g1t"), |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 118 | ]; |
| 119 | ||
| 120 | /// The statement's lines for what paid: each source with anything to show. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 121 | pub(crate) fn covered_lines(sums: [i64; 4]) -> Vec<Covered> { |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 122 | COVERED |
| 123 | .iter() | |
| 124 | .zip(sums) | |
| 125 | .filter(|(_, micros)| *micros > 0) | |
| 126 | .map(|((source, _, label), micros)| Covered { source: (*source).to_owned(), label: (*label).to_owned(), micros }) | |
| 127 | .collect() | |
| 128 | } | |
| 129 | ||
| The statement is a month at a time, a line per kind of charge | 130 | /// `2026-10` and the first instant of the next month, for a range. |
| 131 | pub(crate) fn month_range(month: &str) -> Option<(String, String)> { | |
| 132 | let year: i32 = month.get(..4)?.parse().ok()?; | |
| 133 | let number: u32 = month.get(5..7)?.parse().ok()?; | |
| 134 | if month.len() != 7 || !(1..=12).contains(&number) { | |
| 135 | return None; | |
| 136 | } | |
| 137 | let next = if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) }; | |
| 138 | Some((format!("{month}-01"), format!("{next}-01"))) | |
| 139 | } | |
| 140 | ||
| 141 | #[derive(Deserialize)] | |
| 142 | struct Row { | |
| 143 | group_key: Option<String>, | |
| 144 | kind: String, | |
| 145 | count: u32, | |
| 146 | amount: Option<i64>, | |
| 147 | cost: Option<i64>, | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 148 | credit: Option<i64>, |
| 149 | trial: Option<i64>, | |
| 150 | oss: Option<i64>, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 151 | given: Option<i64>, |
| The statement is a month at a time, a line per kind of charge | 152 | } |
| 153 | ||
| 154 | #[derive(Deserialize)] | |
| 155 | struct Month { | |
| 156 | month: String, | |
| 157 | } | |
| 158 | ||
| 159 | impl Billing { | |
| 160 | pub(crate) async fn statement(&self, a: StatementArgs) -> Result<Outcome<Statement>> { | |
| 161 | let workspace = a.workspace.to_lowercase(); | |
| 162 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 163 | return Ok(members_only()); | |
| 164 | } | |
| 165 | let current = rfc3339(now_ms())[..7].to_owned(); | |
| 166 | let month = a.month.filter(|m| month_range(m).is_some()).unwrap_or(current); | |
| 167 | let (from, until) = month_range(&month).expect("a valid month"); | |
| 168 | let by_project = a.group.as_deref() == Some("project"); | |
| 169 | let group_sql = if by_project { "COALESCE(repo, '')" } else { "substr(created_at, 1, 10)" }; | |
| 170 | let rows = self | |
| 171 | .db | |
| 172 | .prepare(format!( | |
| 173 | "SELECT {group_sql} AS group_key, {KIND_SQL} AS kind, COUNT(*) AS count, | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 174 | SUM(amount_micros) AS amount, SUM(cost_micros) AS cost, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 175 | SUM(credit_micros) AS credit, SUM(trial_micros) AS trial, SUM(oss_micros) AS oss, |
| 176 | SUM(given_micros) AS given | |
| The statement is a month at a time, a line per kind of charge | 177 | FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 |
| 178 | GROUP BY 1, 2" | |
| 179 | )) | |
| 180 | .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])? | |
| 181 | .all() | |
| 182 | .await? | |
| 183 | .results::<Row>()?; | |
| 184 | ||
| 185 | let mut groups: Vec<StatementGroup> = vec![]; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 186 | let mut covered = [0i64; 4]; |
| The statement is a month at a time, a line per kind of charge | 187 | for row in rows { |
| 188 | let key = row.group_key.unwrap_or_default(); | |
| 189 | let amount = row.amount.unwrap_or(0); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 190 | let paid_for = [row.credit.unwrap_or(0), row.trial.unwrap_or(0), row.oss.unwrap_or(0), row.given.unwrap_or(0)]; |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 191 | for (total, micros) in covered.iter_mut().zip(paid_for) { |
| 192 | *total += micros; | |
| 193 | } | |
| The statement is a month at a time, a line per kind of charge | 194 | let line = StatementLine { |
| 195 | kind: row.kind.clone(), | |
| 196 | count: row.count, | |
| 197 | // Charges positive, money in negative, as a statement reads. | |
| 198 | charged_micros: -amount, | |
| 199 | cost_micros: row.cost.unwrap_or(0), | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 200 | covered_micros: paid_for.iter().sum(), |
| The statement is a month at a time, a line per kind of charge | 201 | }; |
| 202 | match groups.iter_mut().find(|g| g.key == key) { | |
| 203 | Some(group) => group.lines.push(line), | |
| 204 | None => groups.push(StatementGroup { | |
| 205 | label: if key.is_empty() { "Not one project".to_owned() } else { key.clone() }, | |
| 206 | key, | |
| 207 | lines: vec![line], | |
| 208 | charged_micros: 0, | |
| 209 | }), | |
| 210 | } | |
| 211 | } | |
| 212 | for group in &mut groups { | |
| 213 | group.lines.sort_by_key(|line| kind_order(&line.kind)); | |
| 214 | group.charged_micros = group.lines.iter().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum(); | |
| 215 | } | |
| 216 | if by_project { | |
| 217 | groups.sort_by(|a, b| b.charged_micros.cmp(&a.charged_micros)); | |
| 218 | } else { | |
| 219 | groups.sort_by(|a, b| b.key.cmp(&a.key)); | |
| 220 | } | |
| 221 | let lines = groups.iter().flat_map(|g| g.lines.iter()); | |
| 222 | let totals = StatementTotals { | |
| 223 | charged_micros: lines.clone().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum(), | |
| 224 | paid_micros: lines.clone().filter(|l| l.charged_micros < 0).map(|l| -l.charged_micros).sum(), | |
| 225 | cost_micros: lines.clone().map(|l| l.cost_micros).sum(), | |
| 226 | entries: lines.map(|l| l.count).sum(), | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 227 | covered: covered_lines(covered), |
| 228 | carried_micros: self.carried(&workspace, &month).await?, | |
| The statement is a month at a time, a line per kind of charge | 229 | }; |
| 230 | let months = self | |
| 231 | .db | |
| 232 | .prepare("SELECT DISTINCT substr(created_at, 1, 7) AS month FROM ledger WHERE workspace = ? ORDER BY 1 DESC LIMIT 36") | |
| 233 | .bind(&[workspace.as_str().into()])? | |
| 234 | .all() | |
| 235 | .await? | |
| 236 | .results::<Month>()? | |
| 237 | .into_iter() | |
| 238 | .map(|m| m.month) | |
| 239 | .collect(); | |
| 240 | Ok(Outcome::Ok(Statement { month, months, groups, totals })) | |
| 241 | } | |
| 242 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 243 | /// `usage_meters`: this month's usage by meter, at cost plus the margin |
| 244 | /// (with a custom discount, if the account has one) before the plan's | |
| 245 | /// included usage or a pool paid for any of it. Comped workspaces see | |
| 246 | /// what it would cost. Usage charged through the month is on the | |
| 247 | /// ledger; usage charged when the month closes (app traffic, custom | |
| 248 | /// domains, storage, git operations, search and scans) is what has been | |
| 249 | /// noted so far. | |
| 250 | pub(crate) async fn usage_meters(&self, a: UsageMetersArgs) -> Result<Outcome<Vec<MeterUsage>>> { | |
| 251 | let workspace = a.workspace.to_lowercase(); | |
| 252 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 253 | return Ok(members_only()); | |
| 254 | } | |
| 255 | let now = rfc3339(now_ms()); | |
| 256 | let month = now[..7].to_owned(); | |
| 257 | let (from, until) = month_range(&month).expect("a valid month"); | |
| 258 | #[derive(Deserialize)] | |
| 259 | struct Used { | |
| 260 | meter: String, | |
| 261 | count: u32, | |
| 262 | cost: Option<f64>, | |
| 263 | } | |
| 264 | // A month's close for deployments is charged in the next month; | |
| 265 | // it is last month's, so it is left out here. | |
| 266 | let ledger = self | |
| 267 | .db | |
| 268 | .prepare(format!( | |
| 269 | "SELECT {METER_SQL} AS meter, COUNT(*) AS count, SUM(cost_micros) AS cost | |
| 270 | FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3 | |
| 271 | AND COALESCE(reference, '') NOT LIKE 'deployments/%' | |
| 272 | GROUP BY 1" | |
| 273 | )) | |
| 274 | .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])? | |
| 275 | .all() | |
| 276 | .await? | |
| 277 | .results::<Used>()?; | |
| 278 | #[derive(Deserialize)] | |
| 279 | struct Pending { | |
| 280 | source: String, | |
| 281 | cost_micros: Option<f64>, | |
| 282 | detail: Option<String>, | |
| 283 | } | |
| 284 | let pending = self | |
| 285 | .db | |
| 286 | .prepare("SELECT source, cost_micros, detail FROM pending_usage WHERE workspace = ? AND month = ? AND charged_at IS NULL") | |
| 287 | .bind(&[workspace.as_str().into(), month.as_str().into()])? | |
| 288 | .all() | |
| 289 | .await? | |
| 290 | .results::<Pending>()?; | |
| 291 | let terms = self.terms_of(&workspace).await?; | |
| 292 | let price = |cost: i64| { | |
| 293 | let charge = crate::credits::with_margin(cost, self.margin_percent); | |
| 294 | if terms.kind == TermsKind::Custom { terms.apply(charge) } else { charge } | |
| 295 | }; | |
| 296 | let mut meters: Vec<MeterUsage> = METERS | |
| 297 | .iter() | |
| 298 | .map(|(key, label)| MeterUsage { key: (*key).to_owned(), label: (*label).to_owned(), micros: 0, quantity: None }) | |
| 299 | .collect(); | |
| 300 | let mut agent_runs = 0; | |
| 301 | for used in ledger { | |
| 302 | if let Some(meter) = meters.iter_mut().find(|m| m.key == used.meter) { | |
| 303 | meter.micros += price(used.cost.unwrap_or(0.0).round() as i64); | |
| 304 | } | |
| 305 | if used.meter == "agents" { | |
| 306 | agent_runs = used.count; | |
| 307 | } | |
| 308 | } | |
| 309 | for row in &pending { | |
| 310 | let key = meter_of_source(&row.source); | |
| 311 | if let Some(meter) = meters.iter_mut().find(|m| m.key == key) { | |
| 312 | meter.micros += price(row.cost_micros.unwrap_or(0.0).round() as i64); | |
| 313 | if matches!(key, "requests" | "domains") { | |
| 314 | meter.quantity = row.detail.clone().filter(|d| !d.is_empty()); | |
| 315 | } | |
| 316 | } | |
| 317 | } | |
| 318 | let build_seconds = self.allowance_used("build_seconds", &workspace, &month).await?; | |
| 319 | let operations = self.git_operations_this_month(&workspace).await?; | |
| 320 | let stored = self.private_storage(&workspace).await?; | |
| 321 | for meter in &mut meters { | |
| 322 | match meter.key.as_str() { | |
| 323 | "agents" => meter.quantity = runs(agent_runs), | |
| 324 | "builds" => meter.quantity = build_minutes(build_seconds), | |
| 325 | "git_storage" => meter.quantity = git_and_storage(operations, stored), | |
| 326 | _ => {} | |
| 327 | } | |
| 328 | } | |
| 329 | Ok(Outcome::Ok(meters)) | |
| 330 | } | |
| 331 | ||
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 332 | /// What was owed when `month` closed but was under the minimum charge, |
| 333 | /// and so carried over to the next invoice. | |
| 334 | async fn carried(&self, workspace: &str, month: &str) -> Result<i64> { | |
| 335 | #[derive(Deserialize)] | |
| 336 | struct Row { | |
| 337 | amount_micros: i64, | |
| 338 | } | |
| 339 | Ok(self | |
| 340 | .db | |
| 341 | .prepare("SELECT amount_micros FROM month_closes WHERE workspace = ? AND month = ? AND status = 'carried'") | |
| 342 | .bind(&[workspace.into(), month.into()])? | |
| 343 | .first::<Row>(None) | |
| 344 | .await? | |
| 345 | .map_or(0, |row| row.amount_micros)) | |
| 346 | } | |
| 347 | ||
| The statement is a month at a time, a line per kind of charge | 348 | /// One line's entries, newest first, a page at a time. |
| 349 | pub(crate) async fn statement_entries(&self, a: StatementEntriesArgs) -> Result<Outcome<Vec<LedgerEntry>>> { | |
| 350 | let workspace = a.workspace.to_lowercase(); | |
| 351 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 352 | return Ok(members_only()); | |
| 353 | } | |
| 354 | let Some((from, until)) = month_range(&a.month) else { | |
| 355 | return Ok(Outcome::Ok(vec![])); | |
| 356 | }; | |
| 357 | let mut filters = vec![format!("({KIND_SQL}) = ?4")]; | |
| 358 | let mut values: Vec<JsValue> = vec![ | |
| 359 | workspace.as_str().into(), | |
| 360 | from.as_str().into(), | |
| 361 | until.as_str().into(), | |
| 362 | a.kind.as_str().into(), | |
| 363 | ]; | |
| 364 | if let Some(day) = a.day.as_deref().filter(|d| d.len() == 10) { | |
| 365 | values.push(day.into()); | |
| 366 | filters.push(format!("substr(created_at, 1, 10) = ?{}", values.len())); | |
| 367 | } | |
| 368 | if let Some(project) = &a.project { | |
| 369 | values.push(project.as_str().into()); | |
| 370 | filters.push(format!("COALESCE(repo, '') = ?{}", values.len())); | |
| 371 | } | |
| 372 | if let Some(before) = &a.before { | |
| 373 | values.push(before.as_str().into()); | |
| 374 | filters.push(format!("id < ?{}", values.len())); | |
| 375 | } | |
| 376 | let rows = self | |
| 377 | .db | |
| 378 | .prepare(format!( | |
| 379 | "SELECT * FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 AND {} | |
| 380 | ORDER BY id DESC LIMIT {PAGE}", | |
| 381 | filters.join(" AND ") | |
| 382 | )) | |
| 383 | .bind(&values)? | |
| 384 | .all() | |
| 385 | .await? | |
| 386 | .results::<LedgerRow>()?; | |
| 387 | Ok(Outcome::Ok(rows.into_iter().map(LedgerEntry::from).collect())) | |
| 388 | } | |
| 389 | } | |
| 390 | ||
| 391 | #[cfg(test)] | |
| 392 | mod tests { | |
| 393 | use super::*; | |
| 394 | ||
| 395 | #[test] | |
| 396 | fn a_month_runs_to_the_first_of_the_next() { | |
| 397 | assert_eq!(month_range("2026-10"), Some(("2026-10-01".into(), "2026-11-01".into()))); | |
| 398 | assert_eq!(month_range("2026-12"), Some(("2026-12-01".into(), "2027-01-01".into()))); | |
| 399 | assert_eq!(month_range("2026-13"), None); | |
| 400 | assert_eq!(month_range("oops"), None); | |
| 401 | } | |
| 402 | ||
| 403 | #[test] | |
| 404 | fn usage_lines_come_before_money_in() { | |
| 405 | assert!(kind_order("Agent runs") < kind_order("Sandbox time")); | |
| 406 | assert!(kind_order("Deployments") < kind_order("Payments")); | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 407 | assert!(kind_order("Security scans") < kind_order("Payments")); |
| 408 | } | |
| 409 | ||
| 410 | #[test] | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 411 | fn usage_falls_on_the_meters_the_billing_page_shows() { |
| 412 | let keys: Vec<_> = METERS.iter().map(|(key, _)| *key).collect(); | |
| 413 | assert_eq!(keys, ["agents", "builds", "requests", "domains", "git_storage", "search_scans"]); | |
| 414 | assert_eq!(meter_of_source("deployments"), "requests"); | |
| 415 | assert_eq!(meter_of_source("domains"), "domains"); | |
| 416 | assert_eq!(meter_of_source("git"), "git_storage"); | |
| 417 | assert_eq!(meter_of_source("storage"), "git_storage"); | |
| 418 | assert_eq!(meter_of_source("context"), "search_scans"); | |
| 419 | assert_eq!(meter_of_source("security"), "search_scans"); | |
| 420 | for key in ["builds", "requests", "git_storage", "search_scans", "agents"] { | |
| 421 | assert!(METER_SQL.contains(&format!("'{key}'"))); | |
| 422 | } | |
| 423 | } | |
| 424 | ||
| 425 | #[test] | |
| 426 | fn quantities_read_plainly() { | |
| 427 | assert_eq!(build_minutes(0), None); | |
| 428 | assert_eq!(build_minutes(1).as_deref(), Some("1 build minute")); | |
| 429 | assert_eq!(build_minutes(2_461).as_deref(), Some("42 build minutes")); | |
| 430 | assert_eq!(runs(1).as_deref(), Some("1 run")); | |
| 431 | assert_eq!(runs(1_200).as_deref(), Some("1,200 runs")); | |
| 432 | assert_eq!(git_and_storage(0, 0), None); | |
| 433 | assert_eq!(git_and_storage(12_345, 420_000_000).as_deref(), Some("12,345 git operations, 0.42 GB private")); | |
| 434 | assert_eq!(git_and_storage(1, 0).as_deref(), Some("1 git operation")); | |
| 435 | } | |
| 436 | ||
| 437 | #[test] | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 438 | fn the_statement_says_what_paid_before_the_workspace_did() { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 439 | let lines = covered_lines([0, 250_000, 120_000, 0]); |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 440 | assert_eq!(lines.len(), 2); |
| 441 | assert_eq!(lines[0].label, "Paid by your trial credit"); | |
| 442 | assert_eq!(lines[1], Covered { source: "oss_pool".into(), label: "Paid by g1t's open-source pool".into(), micros: 120_000 }); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 443 | assert!(covered_lines([0, 0, 0, 0]).is_empty()); |
| 444 | let lines = covered_lines([4_000_000, 0, 0, 30_000]); | |
| 445 | assert_eq!(lines[0].label, "Paid by your plan's included usage"); | |
| 446 | assert_eq!(lines[1].label, "Covered by g1t"); | |
| The statement is a month at a time, a line per kind of charge | 447 | } |
| 448 | } |