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