Skip to content
655 linesCodeBlameRaw

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

Mission control: a place to ask g1t, shown and not on yet, with the actions under it1//! A workspace's statement: a month of its ledger, grouped so it can be
2//! read at a glance (by day or by project, a line per kind of charge),
3//! with each line's entries a page at a time.
4
5use g1t_contracts::billing::{
6 Covered, LedgerEntry, MeterUsage, Statement, StatementArgs, StatementEntriesArgs, StatementGroup, StatementLine, StatementTotals,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging7 UsageMetersArgs,
Mission control: a place to ask g1t, shown and not on yet, with the actions under it8};
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.
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 it27pub(crate) const KIND_SQL: &str = "CASE
Usage, Billing settings and prepaid AI credit; fixes from the UX audit28 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 it29 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 it33 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 it42 WHEN billed_to = 'workspace' THEN 'Runs on your own model provider'
Usage, Billing settings and prepaid AI credit; fixes from the UX audit43 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 it50
Merge branch 'worktree-agent-a56293a6f836cd343'51/// How a runner-minutes line begins: `Runner minutes, 2-core`, by machine.
52pub(crate) const RUNNER_MINUTES: &str = "Runner minutes, ";
53
Usage, Billing settings and prepaid AI credit; fixes from the UX audit54/// 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(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 audit63pub(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 it65/// The order lines appear in within a group.
66pub(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 it70 match kind {
71 "Agent runs" => 0,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit72 "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 audit74 "AI Gateway" => 1,
Mission control: a place to ask g1t, shown and not on yet, with the actions under it75 "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 audit84 "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 it87 "Payments" => 8,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit88 "AI credit" => 8,
Mission control: a place to ask g1t, shown and not on yet, with the actions under it89 "Credits from g1t" => 9,
90 "Refunds" => 10,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person91 "Tax" => 8,
92 "Card processing fees" => 8,
Mission control: a place to ask g1t, shown and not on yet, with the actions under it93 _ => 11,
94 }
95}
96
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging97/// Payments, credits and refunds: money in, which has no price.
98pub(crate) fn is_money_in(kind: &str) -> bool {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person99 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 charging100}
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
Mission control: a place to ask g1t, shown and not on yet, with the actions under it108/// The Billing page's meters, in order: key and label.
109pub(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 it111 ("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'
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 it123 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",
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 it132 _ => "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
Merge branch 'worktree-agent-a56293a6f836cd343'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
Mission control: a place to ask g1t, shown and not on yet, with the actions under it158/// `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); 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.
185pub(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.
195pub(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)]
206struct 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 charging216 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 it219}
220
221#[derive(Deserialize)]
222struct Month {
223 month: String,
224}
225
Merge Stripe Tax, the card fee on card payments, and one free workspace per person226#[derive(Deserialize)]
227struct 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.
235pub(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.
240fn 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 it254impl 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 it273 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 charging290 let discount = row.discount.unwrap_or(0);
Mission control: a place to ask g1t, shown and not on yet, with the actions under it291 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 charging296 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 person300 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 it302 };
Merge Stripe Tax, the card fee on card payments, and one free workspace per person303 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 it325 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person326 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 person336 };
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 it338 }
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 charging342 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 it344 }
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 charging355 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 it358 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 person361 tax_micros,
362 card_fee_micros,
Mission control: a place to ask g1t, shown and not on yet, with the actions under it363 };
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 audit396 price: Option<f64>,
Mission control: a place to ask g1t, shown and not on yet, with the actions under it397 }
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 audit403 "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 it404 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 it427 .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 audit431 // 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 it435 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 audit442 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 it443 }
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 it477 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 it483 "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)]
551mod tests {
552 use super::*;
553
554 #[test]
Merge Stripe Tax, the card fee on card payments, and one free workspace per person555 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 person574 };
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 it582 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 charging590 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 it600 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 it606 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 it638 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.