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

The statement is a month at a time, a line per kind of charge1//! 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::{
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas6 Covered, LedgerEntry, MeterUsage, Statement, StatementArgs, StatementEntriesArgs, StatementGroup, StatementLine, StatementTotals,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging7 UsageMetersArgs,
The statement is a month at a time, a line per kind of charge8};
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.
Prices are what g1t pays plus 20%, from the first second23/// `Runs on your own model provider` is only on older months: those runs
24/// carried a flat fee then, and pay only their sandbox time now.
The statement is a month at a time, a line per kind of charge25pub(crate) const KIND_SQL: &str = "CASE
Usage, Billing settings and prepaid AI credit; fixes from the UX audit26 WHEN kind = 'top_up' AND credit_kind = 'purchased' THEN 'AI credit'
The statement is a month at a time, a line per kind of charge27 WHEN kind = 'top_up' AND reference LIKE 'crd%' THEN 'Credits from g1t'
28 WHEN kind = 'top_up' AND amount_micros < 0 THEN 'Refunds'
29 WHEN kind = 'top_up' THEN 'Payments'
30 WHEN task = 'sandbox' THEN 'Sandbox time'
Fast pages, required checks on the branch, self-hosted runners, honest incidents31 WHEN task = 'self_hosted' THEN 'Self-hosted runner time'
The statement is a month at a time, a line per kind of charge32 WHEN task = 'deployments' THEN 'Deployments'
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put33 WHEN task = 'security' THEN 'Security scans'
34 WHEN task = 'context' THEN 'Search embeddings'
35 WHEN task = 'storage' THEN 'Private storage'
Fast pages, required checks on the branch, self-hosted runners, honest incidents36 WHEN task = 'cache' THEN 'Actions cache storage'
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look37 WHEN task = 'git' THEN 'Git operations'
The statement is a month at a time, a line per kind of charge38 WHEN billed_to = 'workspace' THEN 'Runs on your own model provider'
Usage, Billing settings and prepaid AI credit; fixes from the UX audit39 WHEN reference LIKE '%/agent%' THEN 'Agent rate'
40 WHEN task = 'gateway' THEN 'AI Gateway'
41 WHEN task = 'domains' THEN 'Custom domains'
42 WHEN task = 'package_storage' THEN 'Package storage'
The statement is a month at a time, a line per kind of charge43 ELSE 'Agent runs' END";
44
Usage, Billing settings and prepaid AI credit; fixes from the UX audit45/// A usage line at its price, in SQL: what was charged, what paid for it
46/// first (the plan's included usage, the trial, a pool, g1t), and what a
47/// discount took off. `line_price`, for a whole column. The one measure of
48/// usage every page shows.
49pub(crate) const PRICE_SQL: &str = "(-amount_micros + COALESCE(credit_micros, 0) + COALESCE(trial_micros, 0) + COALESCE(oss_micros, 0)
50 + COALESCE(given_micros, 0) + COALESCE(discount_micros, 0))";
51
52/// Whether a usage line is an agent run's own line (`run_…`), not its
53/// agent rate, a correction or anything else: what counts as one run.
54pub(crate) const RUN_SQL: &str = "(reference LIKE 'run%' AND instr(reference, '/') = 0)";
55
The statement is a month at a time, a line per kind of charge56/// The order lines appear in within a group.
57pub(crate) fn kind_order(kind: &str) -> u8 {
58 match kind {
59 "Agent runs" => 0,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit60 "Agent rate" => 0,
61 "AI Gateway" => 1,
The statement is a month at a time, a line per kind of charge62 "Runs on your own model provider" => 1,
63 "Sandbox time" => 2,
Fast pages, required checks on the branch, self-hosted runners, honest incidents64 "Self-hosted runner time" => 2,
The statement is a month at a time, a line per kind of charge65 "Deployments" => 3,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put66 "Private storage" => 4,
Fast pages, required checks on the branch, self-hosted runners, honest incidents67 "Actions cache storage" => 4,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look68 "Git operations" => 5,
69 "Search embeddings" => 6,
70 "Security scans" => 7,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit71 "Custom domains" => 3,
72 "Package storage" => 4,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look73 "Payments" => 8,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit74 "AI credit" => 8,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look75 "Credits from g1t" => 9,
76 "Refunds" => 10,
Billing: Stripe Tax on every payment, the card fee on every card payment, and free_workspaces77 "Tax" => 8,
78 "Card processing fees" => 8,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look79 _ => 11,
The statement is a month at a time, a line per kind of charge80 }
81}
82
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging83/// Payments, credits and refunds: money in, which has no price.
84pub(crate) fn is_money_in(kind: &str) -> bool {
Billing: Stripe Tax on every payment, the card fee on every card payment, and free_workspaces85 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 charging86}
87
88/// A usage line at its price: what was charged, what paid for it first,
89/// and what the account's discount took off.
90pub(crate) fn line_price(charged: i64, covered: i64, discount: i64) -> i64 {
91 charged + covered + discount
92}
93
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas94/// The Billing page's meters, in order: key and label.
95pub(crate) const METERS: [(&str, &str); 6] = [
96 ("agents", "Agents & sandboxes"),
97 ("builds", "Builds"),
98 ("requests", "Requests & CPU"),
99 ("domains", "Custom domains"),
100 ("git_storage", "Git operations & storage"),
101 ("search_scans", "Search & security scans"),
102];
103
104/// The meter a ledger row's usage is on. One SQL expression, as `KIND_SQL`.
105pub(crate) const METER_SQL: &str = "CASE
106 WHEN task = 'deployments' AND reference LIKE 'deploy/%' THEN 'builds'
107 WHEN task = 'deployments' THEN 'requests'
Fast pages, required checks on the branch, self-hosted runners, honest incidents108 WHEN task IN ('storage', 'git', 'cache') THEN 'git_storage'
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas109 WHEN task IN ('security', 'context') THEN 'search_scans'
110 ELSE 'agents' END";
111
112/// The meter usage noted through the month (`pending_usage.source`) is on.
113pub(crate) fn meter_of_source(source: &str) -> &'static str {
114 match source {
115 "deployments" => "requests",
116 "domains" => "domains",
Fast pages, required checks on the branch, self-hosted runners, honest incidents117 "storage" | "git" | "cache" => "git_storage",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas118 _ => "search_scans",
119 }
120}
121
122/// `41 build minutes`, from seconds, rounded up.
123pub(crate) fn build_minutes(seconds: i64) -> Option<String> {
124 let minutes = (seconds.max(0) + 59) / 60;
125 (seconds > 0).then(|| format!("{} build minute{}", crate::features::thousands(minutes as u64), if minutes == 1 { "" } else { "s" }))
126}
127
128/// `12 runs`.
129pub(crate) fn runs(count: u32) -> Option<String> {
130 (count > 0).then(|| format!("{} run{}", crate::features::thousands(count.into()), if count == 1 { "" } else { "s" }))
131}
132
133/// `12,345 git operations, 0.42 GB private`.
134pub(crate) fn git_and_storage(operations: u64, private_bytes: i64) -> Option<String> {
135 let mut parts = vec![];
136 if operations > 0 {
137 parts.push(format!("{} git operation{}", crate::features::thousands(operations), if operations == 1 { "" } else { "s" }));
138 }
139 if private_bytes > 0 {
140 parts.push(format!("{:.2} GB private", private_bytes as f64 / 1e9));
141 }
142 (!parts.is_empty()).then(|| parts.join(", "))
143}
144
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put145/// What paid for usage before it was charged, as the statement names it,
146/// with the ledger column that holds it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look147pub(crate) const COVERED: [(&str, &str, &str); 4] = [
148 ("included", "credit_micros", "Paid by your plan's included usage"),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put149 ("trial", "trial_micros", "Paid by your trial credit"),
150 ("oss_pool", "oss_micros", "Paid by g1t's open-source pool"),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look151 ("given", "given_micros", "Covered by g1t"),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put152];
153
154/// The statement's lines for what paid: each source with anything to show.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look155pub(crate) fn covered_lines(sums: [i64; 4]) -> Vec<Covered> {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put156 COVERED
157 .iter()
158 .zip(sums)
159 .filter(|(_, micros)| *micros > 0)
160 .map(|((source, _, label), micros)| Covered { source: (*source).to_owned(), label: (*label).to_owned(), micros })
161 .collect()
162}
163
The statement is a month at a time, a line per kind of charge164/// `2026-10` and the first instant of the next month, for a range.
165pub(crate) fn month_range(month: &str) -> Option<(String, String)> {
166 let year: i32 = month.get(..4)?.parse().ok()?;
167 let number: u32 = month.get(5..7)?.parse().ok()?;
168 if month.len() != 7 || !(1..=12).contains(&number) {
169 return None;
170 }
171 let next = if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) };
172 Some((format!("{month}-01"), format!("{next}-01")))
173}
174
175#[derive(Deserialize)]
176struct Row {
177 group_key: Option<String>,
178 kind: String,
179 count: u32,
180 amount: Option<i64>,
181 cost: Option<i64>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put182 credit: Option<i64>,
183 trial: Option<i64>,
184 oss: Option<i64>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look185 given: Option<i64>,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging186 discount: Option<i64>,
The statement is a month at a time, a line per kind of charge187}
188
189#[derive(Deserialize)]
190struct Month {
191 month: String,
192}
193
Billing: Stripe Tax on every payment, the card fee on every card payment, and free_workspaces194#[derive(Deserialize)]
195struct ExtraRow {
196 group_key: Option<String>,
197 kind: String,
198 count: u32,
199 amount: Option<i64>,
200}
201
202/// How the statement names a `tax_and_fees` kind.
203pub(crate) fn extra_kind(kind: &str) -> &'static str {
204 if kind == "tax" { "Tax" } else { "Card processing fees" }
205}
206
207/// Puts a line in its group, starting the group if it is new.
208fn add_line(groups: &mut Vec<StatementGroup>, key: String, line: StatementLine) {
209 match groups.iter_mut().find(|g| g.key == key) {
210 Some(group) => group.lines.push(line),
211 None => groups.push(StatementGroup {
212 label: if key.is_empty() { "Not one project".to_owned() } else { key.clone() },
213 key,
214 lines: vec![line],
215 charged_micros: 0,
216 price_micros: 0,
217 discount_micros: 0,
218 }),
219 }
220}
221
The statement is a month at a time, a line per kind of charge222impl Billing {
223 pub(crate) async fn statement(&self, a: StatementArgs) -> Result<Outcome<Statement>> {
224 let workspace = a.workspace.to_lowercase();
225 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
226 return Ok(members_only());
227 }
228 let current = rfc3339(now_ms())[..7].to_owned();
229 let month = a.month.filter(|m| month_range(m).is_some()).unwrap_or(current);
230 let (from, until) = month_range(&month).expect("a valid month");
231 let by_project = a.group.as_deref() == Some("project");
232 let group_sql = if by_project { "COALESCE(repo, '')" } else { "substr(created_at, 1, 10)" };
233 let rows = self
234 .db
235 .prepare(format!(
236 "SELECT {group_sql} AS group_key, {KIND_SQL} AS kind, COUNT(*) AS count,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put237 SUM(amount_micros) AS amount, SUM(cost_micros) AS cost,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look238 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 charging239 SUM(given_micros) AS given, SUM(discount_micros) AS discount
The statement is a month at a time, a line per kind of charge240 FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3
241 GROUP BY 1, 2"
242 ))
243 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
244 .all()
245 .await?
246 .results::<Row>()?;
247
248 let mut groups: Vec<StatementGroup> = vec![];
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look249 let mut covered = [0i64; 4];
The statement is a month at a time, a line per kind of charge250 for row in rows {
251 let key = row.group_key.unwrap_or_default();
252 let amount = row.amount.unwrap_or(0);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look253 let paid_for = [row.credit.unwrap_or(0), row.trial.unwrap_or(0), row.oss.unwrap_or(0), row.given.unwrap_or(0)];
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put254 for (total, micros) in covered.iter_mut().zip(paid_for) {
255 *total += micros;
256 }
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging257 let discount = row.discount.unwrap_or(0);
The statement is a month at a time, a line per kind of charge258 let line = StatementLine {
259 // Charges positive, money in negative, as a statement reads.
260 charged_micros: -amount,
261 cost_micros: row.cost.unwrap_or(0),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put262 covered_micros: paid_for.iter().sum(),
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging263 price_micros: if is_money_in(&row.kind) { 0 } else { line_price(-amount, paid_for.iter().sum(), discount) },
264 discount_micros: discount,
265 kind: row.kind,
266 count: row.count,
Billing: Stripe Tax on every payment, the card fee on every card payment, and free_workspaces267 passed_micros: 0,
The statement is a month at a time, a line per kind of charge268 };
Billing: Stripe Tax on every payment, the card fee on every card payment, and free_workspaces269 add_line(&mut groups, key, line);
270 }
271 // Tax and card fees paid with the month's payments: their own
272 // lines, beside the payment, never in what was charged or paid.
273 let extras = self
274 .db
275 .prepare(format!(
276 "SELECT {} AS group_key, kind, COUNT(*) AS count, SUM(amount_micros) AS amount
277 FROM tax_and_fees WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 GROUP BY 1, 2",
278 if by_project { "''" } else { "substr(created_at, 1, 10)" }
279 ))
280 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
281 .all()
282 .await?
283 .results::<ExtraRow>()?;
284 let (mut tax_micros, mut card_fee_micros) = (0, 0);
285 for extra in extras {
286 let amount = extra.amount.unwrap_or(0);
287 if extra.kind == "tax" {
288 tax_micros += amount;
289 } else {
290 card_fee_micros += amount;
The statement is a month at a time, a line per kind of charge291 }
Billing: Stripe Tax on every payment, the card fee on every card payment, and free_workspaces292 let line = StatementLine {
293 kind: extra_kind(&extra.kind).to_owned(),
294 count: extra.count,
295 charged_micros: 0,
296 cost_micros: 0,
297 covered_micros: 0,
298 price_micros: 0,
299 discount_micros: 0,
300 passed_micros: amount,
301 };
302 add_line(&mut groups, extra.group_key.unwrap_or_default(), line);
The statement is a month at a time, a line per kind of charge303 }
304 for group in &mut groups {
305 group.lines.sort_by_key(|line| kind_order(&line.kind));
306 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 charging307 group.price_micros = group.lines.iter().map(|l| l.price_micros).sum();
308 group.discount_micros = group.lines.iter().map(|l| l.discount_micros).sum();
The statement is a month at a time, a line per kind of charge309 }
310 if by_project {
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept311 groups.sort_by_key(|a| std::cmp::Reverse(a.charged_micros));
The statement is a month at a time, a line per kind of charge312 } else {
313 groups.sort_by(|a, b| b.key.cmp(&a.key));
314 }
315 let lines = groups.iter().flat_map(|g| g.lines.iter());
316 let totals = StatementTotals {
317 charged_micros: lines.clone().filter(|l| l.charged_micros > 0).map(|l| l.charged_micros).sum(),
318 paid_micros: lines.clone().filter(|l| l.charged_micros < 0).map(|l| -l.charged_micros).sum(),
319 cost_micros: lines.clone().map(|l| l.cost_micros).sum(),
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging320 price_micros: lines.clone().map(|l| l.price_micros).sum(),
321 discount_micros: lines.clone().map(|l| l.discount_micros).sum(),
322 discount_percent: Some(self.terms_of(&workspace).await?.percent_off()).filter(|p| *p > 0),
The statement is a month at a time, a line per kind of charge323 entries: lines.map(|l| l.count).sum(),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put324 covered: covered_lines(covered),
325 carried_micros: self.carried(&workspace, &month).await?,
Billing: Stripe Tax on every payment, the card fee on every card payment, and free_workspaces326 tax_micros,
327 card_fee_micros,
The statement is a month at a time, a line per kind of charge328 };
329 let months = self
330 .db
331 .prepare("SELECT DISTINCT substr(created_at, 1, 7) AS month FROM ledger WHERE workspace = ? ORDER BY 1 DESC LIMIT 36")
332 .bind(&[workspace.as_str().into()])?
333 .all()
334 .await?
335 .results::<Month>()?
336 .into_iter()
337 .map(|m| m.month)
338 .collect();
339 Ok(Outcome::Ok(Statement { month, months, groups, totals }))
340 }
341
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas342 /// `usage_meters`: this month's usage by meter, at cost plus the margin
343 /// (with a custom discount, if the account has one) before the plan's
344 /// included usage or a pool paid for any of it. Comped workspaces see
345 /// what it would cost. Usage charged through the month is on the
346 /// ledger; usage charged when the month closes (app traffic, custom
347 /// domains, storage, git operations, search and scans) is what has been
348 /// noted so far.
349 pub(crate) async fn usage_meters(&self, a: UsageMetersArgs) -> Result<Outcome<Vec<MeterUsage>>> {
350 let workspace = a.workspace.to_lowercase();
351 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
352 return Ok(members_only());
353 }
354 let now = rfc3339(now_ms());
355 let month = now[..7].to_owned();
356 let (from, until) = month_range(&month).expect("a valid month");
357 #[derive(Deserialize)]
358 struct Used {
359 meter: String,
360 count: u32,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit361 price: Option<f64>,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas362 }
363 // A month's close for deployments is charged in the next month;
364 // it is last month's, so it is left out here.
365 let ledger = self
366 .db
367 .prepare(format!(
Usage, Billing settings and prepaid AI credit; fixes from the UX audit368 "SELECT {METER_SQL} AS meter, SUM(CASE WHEN {RUN_SQL} THEN 1 ELSE 0 END) AS count, SUM({PRICE_SQL}) AS price
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas369 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
370 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 charged371 -- Runs on the workspace's own model provider are its provider's to bill, never g1t's.
372 AND COALESCE(billed_to, 'g1t') = 'g1t'
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas373 GROUP BY 1"
374 ))
375 .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])?
376 .all()
377 .await?
378 .results::<Used>()?;
379 #[derive(Deserialize)]
380 struct Pending {
381 source: String,
382 cost_micros: Option<f64>,
383 detail: Option<String>,
384 }
385 let pending = self
386 .db
387 .prepare("SELECT source, cost_micros, detail FROM pending_usage WHERE workspace = ? AND month = ? AND charged_at IS NULL")
388 .bind(&[workspace.as_str().into(), month.as_str().into()])?
389 .all()
390 .await?
391 .results::<Pending>()?;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit392 // At price, before any discount, as every page shows usage: the
393 // ledger's lines as they were charged, and what is metered so far
394 // at cost plus the margin.
395 let price = |cost: i64| crate::credits::with_margin(cost, self.margin_percent);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas396 let mut meters: Vec<MeterUsage> = METERS
397 .iter()
398 .map(|(key, label)| MeterUsage { key: (*key).to_owned(), label: (*label).to_owned(), micros: 0, quantity: None })
399 .collect();
400 let mut agent_runs = 0;
401 for used in ledger {
402 if let Some(meter) = meters.iter_mut().find(|m| m.key == used.meter) {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit403 meter.micros += used.price.unwrap_or(0.0).round() as i64;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas404 }
405 if used.meter == "agents" {
406 agent_runs = used.count;
407 }
408 }
409 for row in &pending {
410 let key = meter_of_source(&row.source);
411 if let Some(meter) = meters.iter_mut().find(|m| m.key == key) {
412 meter.micros += price(row.cost_micros.unwrap_or(0.0).round() as i64);
413 if matches!(key, "requests" | "domains") {
414 meter.quantity = row.detail.clone().filter(|d| !d.is_empty());
415 }
416 }
417 }
418 let build_seconds = self.allowance_used("build_seconds", &workspace, &month).await?;
419 let operations = self.git_operations_this_month(&workspace).await?;
420 let stored = self.private_storage(&workspace).await?;
421 for meter in &mut meters {
422 match meter.key.as_str() {
423 "agents" => meter.quantity = runs(agent_runs),
424 "builds" => meter.quantity = build_minutes(build_seconds),
425 "git_storage" => meter.quantity = git_and_storage(operations, stored),
426 _ => {}
427 }
428 }
429 Ok(Outcome::Ok(meters))
430 }
431
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put432 /// What was owed when `month` closed but was under the minimum charge,
433 /// and so carried over to the next invoice.
434 async fn carried(&self, workspace: &str, month: &str) -> Result<i64> {
435 #[derive(Deserialize)]
436 struct Row {
437 amount_micros: i64,
438 }
439 Ok(self
440 .db
441 .prepare("SELECT amount_micros FROM month_closes WHERE workspace = ? AND month = ? AND status = 'carried'")
442 .bind(&[workspace.into(), month.into()])?
443 .first::<Row>(None)
444 .await?
445 .map_or(0, |row| row.amount_micros))
446 }
447
The statement is a month at a time, a line per kind of charge448 /// One line's entries, newest first, a page at a time.
449 pub(crate) async fn statement_entries(&self, a: StatementEntriesArgs) -> Result<Outcome<Vec<LedgerEntry>>> {
450 let workspace = a.workspace.to_lowercase();
451 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
452 return Ok(members_only());
453 }
454 let Some((from, until)) = month_range(&a.month) else {
455 return Ok(Outcome::Ok(vec![]));
456 };
457 let mut filters = vec![format!("({KIND_SQL}) = ?4")];
458 let mut values: Vec<JsValue> = vec![
459 workspace.as_str().into(),
460 from.as_str().into(),
461 until.as_str().into(),
462 a.kind.as_str().into(),
463 ];
464 if let Some(day) = a.day.as_deref().filter(|d| d.len() == 10) {
465 values.push(day.into());
466 filters.push(format!("substr(created_at, 1, 10) = ?{}", values.len()));
467 }
468 if let Some(project) = &a.project {
469 values.push(project.as_str().into());
470 filters.push(format!("COALESCE(repo, '') = ?{}", values.len()));
471 }
472 if let Some(before) = &a.before {
473 values.push(before.as_str().into());
474 filters.push(format!("id < ?{}", values.len()));
475 }
476 let rows = self
477 .db
478 .prepare(format!(
479 "SELECT * FROM ledger WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 AND {}
480 ORDER BY id DESC LIMIT {PAGE}",
481 filters.join(" AND ")
482 ))
483 .bind(&values)?
484 .all()
485 .await?
486 .results::<LedgerRow>()?;
487 Ok(Outcome::Ok(rows.into_iter().map(LedgerEntry::from).collect()))
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[test]
Billing: Stripe Tax on every payment, the card fee on every card payment, and free_workspaces496 fn tax_and_card_fees_are_their_own_lines_beside_payments_never_charges() {
497 assert_eq!(extra_kind("tax"), "Tax");
498 assert_eq!(extra_kind("card_fee"), "Card processing fees");
499 for kind in ["Tax", "Card processing fees"] {
500 // Money that came in with a payment: no price, no usage.
501 assert!(is_money_in(kind));
502 assert_eq!(kind_order(kind), kind_order("Payments"));
503 }
504 let mut groups = vec![];
505 let line = |kind: &str, passed: i64| StatementLine {
506 kind: kind.to_owned(),
507 count: 1,
508 charged_micros: 0,
509 cost_micros: 0,
510 covered_micros: 0,
511 price_micros: 0,
512 discount_micros: 0,
513 passed_micros: passed,
514 };
515 add_line(&mut groups, "2026-10-08".into(), line("Tax", 1_640_000));
516 add_line(&mut groups, "2026-10-08".into(), line("Card processing fees", 920_000));
517 assert_eq!(groups.len(), 1);
518 assert!(groups[0].lines.iter().all(|l| l.charged_micros == 0));
519 }
520
521 #[test]
The statement is a month at a time, a line per kind of charge522 fn a_month_runs_to_the_first_of_the_next() {
523 assert_eq!(month_range("2026-10"), Some(("2026-10-01".into(), "2026-11-01".into())));
524 assert_eq!(month_range("2026-12"), Some(("2026-12-01".into(), "2027-01-01".into())));
525 assert_eq!(month_range("2026-13"), None);
526 assert_eq!(month_range("oops"), None);
527 }
528
529 #[test]
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging530 fn a_discounted_line_shows_its_price_and_the_discount() {
531 // $1.20 of usage on a 100% discount: charged nothing, all of it off.
532 assert_eq!(line_price(0, 0, 1_200_000), 1_200_000);
533 // 30% off, $0.20 of it paid by included usage first.
534 assert_eq!(line_price(700_000, 140_000, 360_000), 1_200_000);
535 assert!(is_money_in("Payments") && is_money_in("Credits from g1t") && is_money_in("Refunds"));
536 assert!(!is_money_in("Agent runs"));
537 }
538
539 #[test]
The statement is a month at a time, a line per kind of charge540 fn usage_lines_come_before_money_in() {
541 assert!(kind_order("Agent runs") < kind_order("Sandbox time"));
542 assert!(kind_order("Deployments") < kind_order("Payments"));
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put543 assert!(kind_order("Security scans") < kind_order("Payments"));
544 }
545
546 #[test]
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas547 fn usage_falls_on_the_meters_the_billing_page_shows() {
548 let keys: Vec<_> = METERS.iter().map(|(key, _)| *key).collect();
549 assert_eq!(keys, ["agents", "builds", "requests", "domains", "git_storage", "search_scans"]);
550 assert_eq!(meter_of_source("deployments"), "requests");
551 assert_eq!(meter_of_source("domains"), "domains");
552 assert_eq!(meter_of_source("git"), "git_storage");
553 assert_eq!(meter_of_source("storage"), "git_storage");
554 assert_eq!(meter_of_source("context"), "search_scans");
555 assert_eq!(meter_of_source("security"), "search_scans");
556 for key in ["builds", "requests", "git_storage", "search_scans", "agents"] {
557 assert!(METER_SQL.contains(&format!("'{key}'")));
558 }
559 }
560
561 #[test]
562 fn quantities_read_plainly() {
563 assert_eq!(build_minutes(0), None);
564 assert_eq!(build_minutes(1).as_deref(), Some("1 build minute"));
565 assert_eq!(build_minutes(2_461).as_deref(), Some("42 build minutes"));
566 assert_eq!(runs(1).as_deref(), Some("1 run"));
567 assert_eq!(runs(1_200).as_deref(), Some("1,200 runs"));
568 assert_eq!(git_and_storage(0, 0), None);
569 assert_eq!(git_and_storage(12_345, 420_000_000).as_deref(), Some("12,345 git operations, 0.42 GB private"));
570 assert_eq!(git_and_storage(1, 0).as_deref(), Some("1 git operation"));
571 }
572
573 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put574 fn the_statement_says_what_paid_before_the_workspace_did() {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look575 let lines = covered_lines([0, 250_000, 120_000, 0]);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put576 assert_eq!(lines.len(), 2);
577 assert_eq!(lines[0].label, "Paid by your trial credit");
578 assert_eq!(lines[1], Covered { source: "oss_pool".into(), label: "Paid by g1t's open-source pool".into(), micros: 120_000 });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look579 assert!(covered_lines([0, 0, 0, 0]).is_empty());
580 let lines = covered_lines([4_000_000, 0, 0, 30_000]);
581 assert_eq!(lines[0].label, "Paid by your plan's included usage");
582 assert_eq!(lines[1].label, "Covered by g1t");
The statement is a month at a time, a line per kind of charge583 }
584}