Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
649 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.

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

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