Skip to content
589 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.

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

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