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
1,197 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.

Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1//! Credit g1t staff give a workspace from sudo: promotional, goodwill, or a
2//! refund.
3//!
4//! A grant goes on the ledger as a `crd…` top-up, so it is never a payment,
5//! with `credit_kind` set, and in `credit_grants` with its note, who gave
6//! it, and an optional expiry. It raises the balance at once.
7//!
8//! **Spending.** Credit is spent before anything paid in advance, the
9//! soonest-expiring grant first (never-expiring last, then oldest). Given
10//! while the workspace owes, it pays what is owed first: the most recent
11//! usage not yet paid for. What each grant paid for is never stored: it is
12//! worked out from the ledger in order (`replay`), so it is always what the
13//! ledger says, and the many places that charge usage need not know about
14//! credit at all.
15//!
16//! **Expiry and revoking.** Unused credit past its expiry stops counting:
17//! the daily run enters what is left as a negative `crd…_expired` line.
18//! Staff can revoke what is left of a grant, with why (`crd…_revoked`).
19//! Neither ever takes the balance below what was paid in: at most the
20//! balance, if a refund of a payment brought it lower than the credit left.
21//!
22//! **Margin.** Usage paid for with promotional or goodwill credit is given
23//! away, never money in (`margin::usage_rows`). A refund gives back money
24//! already paid: it comes off money in on the day it refunds (at most 30
25//! days back, the days the reconciliation still recomputes), and what it
26//! pays for later is paid for. Refunds never expire.
27
28use std::collections::BTreeMap;
29
30use g1t_contracts::billing::{
31 AdminCreditArgs, AdminCredits, AdminCreditsArgs, AdminRevokeCreditArgs, CreditGrant, CreditKind, CreditMonth, Credits, EntryKind,
32 LedgerEntry, MICROS_PER_DOLLAR,
33};
34use g1t_contracts::time::{parse_rfc3339, rfc3339};
35use g1t_contracts::{FailureCode, Outcome, new_id};
36use g1t_kit::now_ms;
37use serde::Deserialize;
38use worker::Result;
39
40use crate::features::cents;
41use crate::{Billing, LedgerRow, optional};
42
43/// The most one credit can be, against a slipped finger.
44pub(crate) const MAX_CREDIT_MICROS: i64 = 10_000 * MICROS_PER_DOLLAR;
45/// The furthest an expiry can be: five years.
46const MAX_EXPIRY_DAYS: u64 = 5 * 366;
47/// How far back a refund can take money off: the days the daily
48/// reconciliation recomputes. An older day's refund lands on the oldest.
49pub(crate) const REFUND_DAYS_BACK: u64 = 30;
50const DAY_MS: u64 = 24 * 60 * 60 * 1000;
51
52// ---------------------------------------------------------------------
53// The arithmetic, apart from the database so it can be tested.
54// ---------------------------------------------------------------------
55
56/// A grant, as the replay needs it.
57#[derive(Clone, Debug, Default, PartialEq)]
58pub(crate) struct Grant {
59 pub id: String,
60 pub kind: CreditKind,
61 pub expires_at: Option<String>,
62 pub created_at: String,
63 pub closed_at: Option<String>,
64 /// Pays only for model usage (agent runs), not everything.
65 pub models_only: bool,
66}
67
68/// The tasks that are not a model's work: sandbox and runner time,
69/// deployments, and the month-end meters.
Usage, Billing settings and prepaid AI credit; fixes from the UX audit70const NOT_MODELS: [&str; 10] = [
71 "sandbox", "self_hosted", "deployments", "security", "context", "storage", "git", "cache", "domains", "package_storage",
72];
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging73
74/// Whether a usage line is model usage (an agent run's model cost), which
75/// credit scoped to models can pay for.
76pub(crate) fn is_model_usage(task: Option<&str>) -> bool {
77 task.is_some_and(|task| !NOT_MODELS.contains(&task))
78}
79
80/// A ledger line, oldest first.
81#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
82pub(crate) struct Line {
83 pub reference: String,
84 pub kind: String,
85 pub amount_micros: i64,
86 pub created_at: String,
87 #[serde(default)]
88 pub task: Option<String>,
89}
90
91/// What a grant paid of one usage line: less than nothing when a charge
92/// came down and gave some back.
93#[derive(Clone, Debug, PartialEq)]
94pub(crate) struct Draw {
95 pub grant: String,
96 pub kind: CreditKind,
97 /// The usage line it paid for, or the grant itself for what was owed
98 /// from before the lines read.
99 pub reference: String,
100 pub task: Option<String>,
101 /// When the line it paid for was entered.
102 pub at: String,
103 pub micros: i64,
104}
105
106/// What the ledger says each grant paid for.
107#[derive(Clone, Debug, Default, PartialEq)]
108pub(crate) struct Replay {
109 pub draws: Vec<Draw>,
110 /// Each grant's left, at the end; absent for a grant not on the ledger.
111 pub left: BTreeMap<String, i64>,
112}
113
114impl Replay {
115 pub fn used(&self, grant: &str) -> i64 {
116 self.draws.iter().filter(|d| d.grant == grant).map(|d| d.micros).sum()
117 }
118}
119
120/// Whether a grant can pay for something entered at `at`: on the ledger,
121/// not closed, not expired.
122fn open_at(grant: &Grant, at: &str) -> bool {
123 grant.closed_at.as_deref().is_none_or(|closed| closed > at) && grant.expires_at.as_deref().is_none_or(|expires| expires > at)
124}
125
126/// Works out what each grant paid for, from the ledger in order: every
127/// charge from the open grants, the soonest-expiring first; a grant given
128/// while the balance was below zero pays what was owed, the most recent
129/// usage first; a charge that came down gives back to the grants that paid
130/// last. `opening` is the balance before the first line.
131pub(crate) fn replay(opening: i64, lines: &[Line], grants: &[Grant]) -> Replay {
132 let mut out = Replay::default();
133 let mut balance = opening;
134 // Usage lines with what is still unpaid by credit, for a grant that
135 // pays what was owed.
136 let mut usage: Vec<(usize, i64)> = vec![];
137 for (index, line) in lines.iter().enumerate() {
138 let grant = grants.iter().find(|g| g.id == line.reference);
139 if let Some(grant) = grant.filter(|_| line.amount_micros > 0) {
140 let mut left = line.amount_micros;
141 let mut owed = (-balance).max(0).min(left);
142 for (i, unpaid) in usage.iter_mut().rev() {
143 if owed == 0 {
144 break;
145 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit146 // Credit for models pays only what models owed.
147 if grant.models_only && !is_model_usage(lines[*i].task.as_deref()) {
148 continue;
149 }
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging150 let take = (*unpaid).min(owed);
151 if take > 0 {
152 let paid = &lines[*i];
153 out.draws.push(Draw {
154 grant: grant.id.clone(),
155 kind: grant.kind,
156 reference: paid.reference.clone(),
157 task: paid.task.clone(),
158 at: paid.created_at.clone(),
159 micros: take,
160 });
161 *unpaid -= take;
162 owed -= take;
163 left -= take;
164 }
165 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit166 if owed > 0 && !grant.models_only {
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging167 // Owed from before the lines read: on the grant's own day.
168 out.draws.push(Draw {
169 grant: grant.id.clone(),
170 kind: grant.kind,
171 reference: grant.id.clone(),
172 task: None,
173 at: line.created_at.clone(),
174 micros: owed,
175 });
176 left -= owed;
177 }
178 out.left.insert(grant.id.clone(), left);
179 } else if line.kind == "usage" && line.amount_micros < 0 {
180 let charge = -line.amount_micros;
181 let mut open: Vec<&Grant> = grants
182 .iter()
183 .filter(|g| out.left.get(&g.id).is_some_and(|left| *left > 0) && open_at(g, &line.created_at))
184 .filter(|g| !g.models_only || is_model_usage(line.task.as_deref()))
185 .collect();
186 open.sort_by(|a, b| spend_order(a, b));
187 let mut due = charge;
188 for grant in open {
189 if due == 0 {
190 break;
191 }
192 let left = out.left.get_mut(&grant.id).expect("an open grant has a left");
193 let take = (*left).min(due);
194 *left -= take;
195 due -= take;
196 out.draws.push(Draw {
197 grant: grant.id.clone(),
198 kind: grant.kind,
199 reference: line.reference.clone(),
200 task: line.task.clone(),
201 at: line.created_at.clone(),
202 micros: take,
203 });
204 }
205 usage.push((index, due));
206 } else if line.kind == "usage" && line.amount_micros > 0 {
207 // A charge that came down: back to the grants that paid last,
208 // while they can still be spent.
209 let mut back = line.amount_micros;
210 let mut returned: Vec<Draw> = vec![];
211 for draw in out.draws.iter().rev() {
212 if back == 0 {
213 break;
214 }
215 let Some(grant) = grants.iter().find(|g| g.id == draw.grant) else { continue };
216 let paid: i64 = out.draws.iter().chain(returned.iter()).filter(|d| d.grant == grant.id).map(|d| d.micros).sum();
217 if draw.micros <= 0 || paid <= 0 || !open_at(grant, &line.created_at) {
218 continue;
219 }
220 let give = draw.micros.min(paid).min(back);
221 back -= give;
222 returned.push(Draw {
223 grant: grant.id.clone(),
224 kind: grant.kind,
225 reference: line.reference.clone(),
226 task: line.task.clone(),
227 at: line.created_at.clone(),
228 micros: -give,
229 });
230 }
231 for draw in returned {
232 *out.left.entry(draw.grant.clone()).or_insert(0) -= draw.micros;
233 out.draws.push(draw);
234 }
235 } else if let Some(id) = closed_grant(&line.reference) {
236 // What expired or was revoked: nothing more to spend.
237 if let Some(left) = out.left.get_mut(id) {
238 *left = 0;
239 }
240 }
241 balance += line.amount_micros;
242 }
243 // Closed or expired by now: nothing left to spend, whatever the ledger
244 // has not caught up with yet.
245 out
246}
247
248/// Credit scoped to models before credit for everything; then the
249/// soonest-expiring first, never-expiring last; then the oldest.
250fn spend_order(a: &Grant, b: &Grant) -> std::cmp::Ordering {
251 b.models_only
252 .cmp(&a.models_only)
253 .then_with(|| match (&a.expires_at, &b.expires_at) {
254 (Some(x), Some(y)) => x.cmp(y),
255 (Some(_), None) => std::cmp::Ordering::Less,
256 (None, Some(_)) => std::cmp::Ordering::Greater,
257 (None, None) => std::cmp::Ordering::Equal,
258 })
259 .then_with(|| a.created_at.cmp(&b.created_at))
260}
261
262/// The grant a `<grant>_expired` or `<grant>_revoked` line closes.
263pub(crate) fn closed_grant(reference: &str) -> Option<&str> {
264 reference.strip_suffix("_expired").or_else(|| reference.strip_suffix("_revoked"))
265}
266
267/// What expiring or revoking takes off the balance: what is left of the
268/// grant, and never the balance below zero (a refunded payment can leave
269/// less there than the credit).
270pub(crate) fn take_back(left: i64, balance: i64) -> i64 {
271 left.max(0).min(balance.max(0))
272}
273
274/// `open`, `used`, `expired` or `revoked`, and what can still be spent.
275pub(crate) fn state(left: i64, expires_at: Option<&str>, closed_reason: Option<&str>, now: &str) -> (&'static str, i64) {
276 match closed_reason {
277 Some("revoked") => ("revoked", 0),
278 Some(_) => ("expired", 0),
279 None if expires_at.is_some_and(|at| at <= now) => ("expired", 0),
280 None if left <= 0 => ("used", 0),
281 None => ("open", left),
282 }
283}
284
285/// The key a usage line is reconciled under, as `margin::usage_rows` reads
Merge costs and margin review: gateway query, own spend, discount meters, superseded rises286/// it: builds apart from a deployment's requests, and an agent's planning
287/// run apart from the plan's payments (`margin::PLANNING_KEY`).
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging288pub(crate) fn usage_key(task: Option<&str>, reference: &str) -> String {
289 match task {
290 Some("deployments") if reference.starts_with("deploy/") => "builds".to_owned(),
Merge costs and margin review: gateway query, own spend, discount meters, superseded rises291 Some("plan") => crate::margin::PLANNING_KEY.to_owned(),
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging292 Some(task) => task.to_owned(),
293 None => "other".to_owned(),
294 }
295}
296
297/// The day a refund takes money off: the day it refunds, but no further
298/// back than the reconciliation recomputes from the day it was given.
299pub(crate) fn refund_cash_day(refund_day: Option<&str>, granted_at: &str) -> String {
300 let granted = &granted_at[..10];
301 let oldest = rfc3339(parse_rfc3339(&format!("{granted}T00:00:00Z")).unwrap_or(0).saturating_sub(REFUND_DAYS_BACK * DAY_MS))[..10].to_owned();
302 match refund_day {
303 Some(day) if day.len() == 10 && day <= granted => day.max(oldest.as_str()).to_owned(),
304 _ => granted.to_owned(),
305 }
306}
307
308/// A `YYYY-MM-DD` that is a real day.
309fn is_day(day: &str) -> bool {
310 day.len() == 10 && parse_rfc3339(&format!("{day}T00:00:00Z")).is_some_and(|ms| rfc3339(ms).starts_with(day))
311}
312
313/// What is wrong with a credit as asked for, if anything.
314pub(crate) fn invalid(a: &AdminCreditArgs, now_ms: u64) -> Option<&'static str> {
315 if a.workspace.trim().is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() {
316 return Some("A credit needs a workspace, a note and who gave it.");
317 }
318 if a.note.trim().chars().count() > 500 {
319 return Some("Keep the note under 500 characters.");
320 }
321 if a.kind == CreditKind::Purchased {
322 return Some("Staff give promotional, goodwill or refund credit; purchased credit is bought by the workspace.");
323 }
324 if a.amount_micros <= 0 || a.amount_micros > MAX_CREDIT_MICROS {
325 return Some("A credit is more than $0 and at most $10,000.");
326 }
327 if let Some(expires) = &a.expires_at {
328 if a.kind == CreditKind::Refund {
329 return Some("A refund never expires: it is money the workspace already paid.");
330 }
331 match parse_rfc3339(expires) {
332 Some(at) if at > now_ms && at <= now_ms + MAX_EXPIRY_DAYS * DAY_MS => {}
333 Some(at) if at <= now_ms => return Some("The expiry has to be in the future."),
334 _ => return Some("The expiry is a date within five years."),
335 }
336 }
337 if a.kind == CreditKind::Refund {
338 if a.refund_for.as_deref().is_none_or(|f| f.trim().is_empty()) {
339 return Some("Say what the refund is for, such as the failed runs on Oct 2.");
340 }
341 if a.refund_for.as_deref().is_some_and(|f| f.trim().chars().count() > 200) {
342 return Some("Keep what the refund is for under 200 characters.");
343 }
344 if let Some(day) = &a.refund_day
345 && (!is_day(day) || day.as_str() > &rfc3339(now_ms)[..10])
346 {
347 return Some("The day refunded is a date, today or before.");
348 }
349 }
350 None
351}
352
353/// The line on the statement: `Credit from g1t (promotional): Welcome to g1t`.
354pub(crate) fn describe_grant(kind: CreditKind, note: &str, refund_for: Option<&str>, expires_at: Option<&str>) -> String {
355 let what = match (kind, refund_for) {
356 (CreditKind::Refund, Some(what)) => format!("refund for {}", what.trim()),
357 (kind, _) => kind.as_str().to_owned(),
358 };
359 let until = expires_at.map(|at| format!(", until {}", &at[..at.len().min(10)])).unwrap_or_default();
360 format!("Credit from g1t ({what}{until}): {}", note.trim())
361}
362
363/// A month's credits by kind, from the grants (given, taken back) and what
364/// they paid for.
365pub(crate) fn fold_months(grants: &[GrantRow], draws: &[Draw]) -> Vec<CreditMonth> {
366 fn at<'a>(months: &'a mut BTreeMap<(String, CreditKind), CreditMonth>, month: &str, kind: CreditKind) -> &'a mut CreditMonth {
367 months.entry((month.to_owned(), kind)).or_insert_with(|| CreditMonth { month: month.to_owned(), kind, ..CreditMonth::default() })
368 }
369 let mut months: BTreeMap<(String, CreditKind), CreditMonth> = BTreeMap::new();
370 for grant in grants {
371 let kind = grant.kind();
372 let m = at(&mut months, &grant.created_at[..7], kind);
373 m.given_micros += grant.amount_micros;
374 m.grants += 1;
375 if let Some(closed) = &grant.closed_at {
376 let m = at(&mut months, &closed[..7], kind);
377 if grant.closed_reason.as_deref() == Some("revoked") {
378 m.revoked_micros += grant.closed_micros;
379 } else {
380 m.expired_micros += grant.closed_micros;
381 }
382 }
383 }
384 for draw in draws {
385 at(&mut months, &draw.at[..7], draw.kind).used_micros += draw.micros;
386 }
387 let mut out: Vec<CreditMonth> = months.into_values().collect();
388 out.sort_by(|a, b| b.month.cmp(&a.month).then(a.kind.cmp(&b.kind)));
389 out
390}
391
392// ---------------------------------------------------------------------
393// The database.
394// ---------------------------------------------------------------------
395
396#[derive(Clone, Debug, Default, Deserialize)]
397pub(crate) struct GrantRow {
398 pub id: String,
399 pub workspace: String,
400 pub kind: String,
401 pub amount_micros: i64,
402 pub note: String,
403 pub refund_for: Option<String>,
404 pub refund_day: Option<String>,
405 pub expires_at: Option<String>,
406 pub created_by: String,
407 pub created_at: String,
408 pub closed_at: Option<String>,
409 pub closed_reason: Option<String>,
410 pub closed_note: Option<String>,
411 pub closed_by: Option<String>,
412 #[serde(default)]
413 pub closed_micros: i64,
414 /// `all` or `models`.
415 #[serde(default)]
416 pub scope: Option<String>,
417 /// `staff`, `purchase` or `promo_code`.
418 #[serde(default)]
419 pub source: Option<String>,
420}
421
422impl GrantRow {
423 pub fn kind(&self) -> CreditKind {
424 CreditKind::parse(&self.kind).unwrap_or_default()
425 }
426
427 fn facts(&self) -> Grant {
428 Grant {
429 id: self.id.clone(),
430 kind: self.kind(),
431 expires_at: self.expires_at.clone(),
432 created_at: self.created_at.clone(),
433 closed_at: self.closed_at.clone(),
434 models_only: self.scope.as_deref() == Some("models"),
435 }
436 }
437
438 fn view(&self, replay: &Replay, now: &str) -> CreditGrant {
439 let used = replay.used(&self.id);
440 let left = replay.left.get(&self.id).copied().unwrap_or(0);
441 let (state, left) = state(left, self.expires_at.as_deref(), self.closed_reason.as_deref(), now);
442 CreditGrant {
443 id: self.id.clone(),
444 workspace: self.workspace.clone(),
445 kind: self.kind(),
446 amount_micros: self.amount_micros,
447 used_micros: used,
448 left_micros: left,
449 note: self.note.clone(),
450 refund_for: self.refund_for.clone(),
451 refund_day: self.refund_day.clone(),
452 expires_at: self.expires_at.clone(),
453 created_by: self.created_by.clone(),
454 created_at: self.created_at.clone(),
455 state: state.to_owned(),
456 closed_at: self.closed_at.clone(),
457 closed_note: self.closed_note.clone(),
458 closed_by: self.closed_by.clone(),
459 closed_micros: self.closed_micros,
460 scope: self.scope.clone().unwrap_or_else(|| "all".to_owned()),
461 source: self.source.clone().unwrap_or_else(|| "staff".to_owned()),
462 }
463 }
464}
465
466/// A refund's money given back, on the day it comes off.
467#[derive(Clone, Debug, PartialEq)]
468pub(crate) struct Refunded {
469 pub workspace: String,
470 pub day: String,
471 pub micros: i64,
472}
473
474impl Billing {
475 async fn grants_of(&self, workspace: &str) -> Result<Vec<GrantRow>> {
476 self.db
477 .prepare("SELECT * FROM credit_grants WHERE workspace = ? ORDER BY created_at DESC")
478 .bind(&[workspace.into()])?
479 .all()
480 .await?
481 .results::<GrantRow>()
482 }
483
484 /// The workspace's ledger from the month before its first grant, and
485 /// the balance before it, replayed against its grants.
486 async fn replay_of(&self, workspace: &str, grants: &[GrantRow]) -> Result<(Replay, i64)> {
487 let Some(first) = grants.iter().map(|g| g.created_at.as_str()).min() else {
488 return Ok((Replay::default(), 0));
489 };
490 let since = format!("{}-01", crate::limits::previous_month(&first[..7]));
491 let lines = self
492 .db
493 .prepare(
494 "SELECT reference, kind, amount_micros, created_at, task FROM ledger
495 WHERE workspace = ? AND created_at >= ? ORDER BY created_at, id",
496 )
497 .bind(&[workspace.into(), since.into()])?
498 .all()
499 .await?
500 .results::<Line>()?;
501 let balance = self.row(workspace).await?.map_or(0, |row| row.balance_micros);
502 let opening = balance - lines.iter().map(|l| l.amount_micros).sum::<i64>();
503 let facts: Vec<Grant> = grants.iter().map(GrantRow::facts).collect();
504 Ok((replay(opening, &lines, &facts), balance))
505 }
506
507 /// `credits`: a workspace's credits from g1t, for its members.
508 pub(crate) async fn credits(&self, a: g1t_contracts::billing::AccountArgs) -> Result<Outcome<Credits>> {
509 let workspace = a.workspace.to_lowercase();
510 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
511 return Ok(crate::members_only());
512 }
513 Ok(Outcome::Ok(self.credits_of(&workspace).await?))
514 }
515
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.516 /// What is owed now, with the balance `balance`: credit scoped to
517 /// models (AI credit, from when g1t sold it) is not money for anything
518 /// else, so what is left of it is owed on top of a balance it props up.
519 pub(crate) async fn owed_with(&self, workspace: &str, balance: i64) -> Result<i64> {
520 let credits = self.credits_of(workspace).await?;
521 let left: i64 = credits.grants.iter().filter(|g| g.scope == "models").map(|g| g.left_micros).sum();
522 Ok((left - balance).max(0))
523 }
524
Usage, Billing settings and prepaid AI credit; fixes from the UX audit525 /// What was left of credit scoped to models (AI credit) just before
526 /// `before`, from the ledger up to then: what a month's close must not
527 /// count as money for anything else.
528 pub(crate) async fn models_left_before(&self, workspace: &str, before: &str) -> Result<i64> {
529 let grants: Vec<GrantRow> = self.grants_of(workspace).await?.into_iter().filter(|g| g.created_at.as_str() < before).collect();
530 if !grants.iter().any(|g| g.scope.as_deref() == Some("models")) {
531 return Ok(0);
532 }
533 let first = grants.iter().map(|g| g.created_at.as_str()).min().unwrap_or(before);
534 let since = format!("{}-01", crate::limits::previous_month(&first[..7]));
535 #[derive(Deserialize)]
536 struct Opening {
537 micros: Option<f64>,
538 }
539 let opening = self
540 .db
541 .prepare("SELECT SUM(amount_micros) AS micros FROM ledger WHERE workspace = ? AND created_at < ?")
542 .bind(&[workspace.into(), since.as_str().into()])?
543 .first::<Opening>(None)
544 .await?
545 .and_then(|o| o.micros)
546 .unwrap_or(0.0) as i64;
547 let lines = self
548 .db
549 .prepare(
550 "SELECT reference, kind, amount_micros, created_at, task FROM ledger
551 WHERE workspace = ? AND created_at >= ? AND created_at < ? ORDER BY created_at, id",
552 )
553 .bind(&[workspace.into(), since.into(), before.into()])?
554 .all()
555 .await?
556 .results::<Line>()?;
557 let facts: Vec<Grant> = grants.iter().map(GrantRow::facts).collect();
558 let replay = replay(opening, &lines, &facts);
559 Ok(facts
560 .iter()
561 .filter(|g| g.models_only && open_at(g, before))
562 .map(|g| replay.left.get(&g.id).copied().unwrap_or(0).max(0))
563 .sum())
564 }
565
566 /// What credit (AI credit and credit from g1t) paid for usage entered
567 /// from `from` until before `until` (RFC 3339 or `YYYY-MM-DD`), and the
568 /// workspace's credits now.
569 pub(crate) async fn credit_paid_between(&self, workspace: &str, from: &str, until: &str) -> Result<(i64, Credits)> {
570 let grants = self.grants_of(workspace).await?;
571 let (replay, _) = self.replay_of(workspace, &grants).await?;
572 let paid = replay
573 .draws
574 .iter()
575 .filter(|d| d.reference != d.grant && d.at.as_str() >= from && d.at.as_str() < until)
576 .map(|d| d.micros)
577 .sum();
578 let now = rfc3339(now_ms());
579 let views: Vec<CreditGrant> = grants.iter().map(|g| g.view(&replay, &now)).collect();
580 Ok((paid, Credits { left_micros: views.iter().map(|g| g.left_micros).sum(), grants: views }))
581 }
582
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging583 pub(crate) async fn credits_of(&self, workspace: &str) -> Result<Credits> {
584 let grants = self.grants_of(workspace).await?;
585 let (replay, _) = self.replay_of(workspace, &grants).await?;
586 let now = rfc3339(now_ms());
587 let grants: Vec<CreditGrant> = grants.iter().map(|g| g.view(&replay, &now)).collect();
588 Ok(Credits { left_micros: grants.iter().map(|g| g.left_micros).sum(), grants })
589 }
590
591 /// Enters a grant: the ledger line and its `credit_grants` row. Returns
592 /// the grant's reference. `reference` names it, for a grant made
593 /// elsewhere (the Overages queue's goodwill).
594 #[allow(clippy::too_many_arguments)]
595 pub(crate) async fn grant_credit(
596 &self,
597 workspace: &str,
598 kind: CreditKind,
599 amount: i64,
600 note: &str,
601 by: &str,
602 expires_at: Option<&str>,
603 refund_for: Option<&str>,
604 refund_day: Option<&str>,
605 reference: Option<String>,
606 description: Option<String>,
607 ) -> Result<String> {
608 let now = now_ms();
609 let reference = reference.unwrap_or_else(|| new_id("crd", now));
610 let description = description.unwrap_or_else(|| describe_grant(kind, note, refund_for, expires_at));
611 let refund_day = (kind == CreditKind::Refund).then(|| refund_day.map_or_else(|| rfc3339(now)[..10].to_owned(), str::to_owned));
612 self.db
613 .prepare(
614 "INSERT INTO credit_grants (id, workspace, kind, amount_micros, note, refund_for, refund_day, expires_at, created_by, created_at)
615 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
616 )
617 .bind(&[
618 reference.as_str().into(),
619 workspace.into(),
620 kind.as_str().into(),
621 (amount as f64).into(),
622 note.trim().into(),
623 optional(refund_for.map(str::trim)),
624 optional(refund_day.as_deref()),
625 optional(expires_at),
626 by.into(),
627 rfc3339(now).into(),
628 ])?
629 .run()
630 .await?;
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.631 self.enter(workspace, EntryKind::TopUp, amount, &description, &reference, None, Some(by), None).await?;
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging632 self.mark_credit(&reference, kind).await?;
633 Ok(reference)
634 }
635
636 async fn mark_credit(&self, reference: &str, kind: CreditKind) -> Result<()> {
637 self.db
638 .prepare("UPDATE ledger SET credit_kind = ? WHERE reference = ?")
639 .bind(&[kind.as_str().into(), reference.into()])?
640 .run()
641 .await?;
642 Ok(())
643 }
644
645 /// `admin_credit`: credit for a workspace, from sudo.
646 pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> {
647 if let Some(why) = invalid(&a, now_ms()) {
648 return Ok(Outcome::fail(FailureCode::Invalid, why));
649 }
650 let workspace = a.workspace.trim().to_lowercase();
651 let reference = self
652 .grant_credit(
653 &workspace,
654 a.kind,
655 a.amount_micros,
656 a.note.trim(),
657 a.by.trim(),
658 a.expires_at.as_deref(),
659 a.refund_for.as_deref(),
660 a.refund_day.as_deref(),
661 None,
662 None,
663 )
664 .await?;
665 let account = self.account_of(&workspace).await?;
666 let until = a.expires_at.as_deref().map(|at| format!(", until {}", &at[..10])).unwrap_or_default();
667 let refund = a.refund_for.as_deref().filter(|_| a.kind == CreditKind::Refund).map(|f| format!(" for {}", f.trim())).unwrap_or_default();
668 self.audit(
669 &account.id,
670 "credit",
671 &format!("{} {} credit to {workspace}{refund}{until}: {}", cents(a.amount_micros), a.kind.as_str(), a.note.trim()),
672 a.by.trim(),
673 )
674 .await?;
675 self.tell_owners_of_credit(&workspace, a.kind, a.amount_micros, a.note.trim(), a.refund_for.as_deref(), a.expires_at.as_deref()).await;
676 let row = self
677 .db
678 .prepare("SELECT * FROM ledger WHERE reference = ?")
679 .bind(&[reference.as_str().into()])?
680 .first::<LedgerRow>(None)
681 .await?;
682 Ok(match row {
683 Some(row) => Outcome::Ok(LedgerEntry::from(row)),
684 None => Outcome::fail(FailureCode::NotFound, "The credit was not saved."),
685 })
686 }
687
688 /// Emails the workspace's owners that credit was given. Never fails the
689 /// grant.
690 pub(crate) async fn tell_owners_of_credit(
691 &self,
692 workspace: &str,
693 kind: CreditKind,
694 amount: i64,
695 note: &str,
696 refund_for: Option<&str>,
697 expires_at: Option<&str>,
698 ) {
699 let Some(identity) = &self.identity else { return };
700 let (subject, intro) = credit_notice(workspace, kind, amount, note, refund_for, expires_at);
701 crate::limits::notify_with(
702 identity,
703 workspace,
704 &subject,
705 &intro,
706 "Open billing",
707 &format!("https://g1t.sh/{workspace}/-/billing#credits"),
708 "You get this because you own this workspace on g1t. Credits are explained at https://docs.g1t.sh/guides/usage-and-billing/#credits-from-g1t",
709 )
710 .await;
711 }
712
713 /// Takes what is left of a grant off the balance: expired, or revoked
714 /// by staff. Returns what it took.
715 async fn close_grant(&self, grant: &GrantRow, reason: &str, note: Option<&str>, by: &str) -> Result<i64> {
716 let grants = self.grants_of(&grant.workspace).await?;
717 let (replay, balance) = self.replay_of(&grant.workspace, &grants).await?;
718 let left = replay.left.get(&grant.id).copied().unwrap_or(0);
719 let take = take_back(left, balance);
720 let now = rfc3339(now_ms());
721 // Closed first, so a second close finds it closed and takes nothing.
722 let claimed = self
723 .db
724 .prepare(
725 "UPDATE credit_grants SET closed_at = ?1, closed_reason = ?2, closed_note = ?3, closed_by = ?4, closed_micros = ?5
726 WHERE id = ?6 AND closed_at IS NULL RETURNING id",
727 )
728 .bind(&[now.as_str().into(), reason.into(), optional(note), by.into(), (take as f64).into(), grant.id.as_str().into()])?
729 .first::<crate::Touched>(None)
730 .await?;
731 if claimed.is_none() || take == 0 {
732 return Ok(0);
733 }
734 let reference = format!("{}_{reason}", grant.id);
735 let verb = if reason == "revoked" { "withdrawn" } else { "expired" };
736 let description = format!(
737 "Credit from g1t {verb}: {} unused of the {} given on {}",
738 cents(take),
739 cents(grant.amount_micros),
740 &grant.created_at[..10]
741 );
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.742 self.enter(&grant.workspace, EntryKind::TopUp, -take, &description, &reference, None, Some(by), None).await?;
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging743 self.mark_credit(&reference, grant.kind()).await?;
744 Ok(take)
745 }
746
747 async fn grant(&self, id: &str) -> Result<Option<GrantRow>> {
748 self.db.prepare("SELECT * FROM credit_grants WHERE id = ?").bind(&[id.into()])?.first::<GrantRow>(None).await
749 }
750
751 /// `admin_revoke_credit`: what is left of a grant, taken back.
752 pub(crate) async fn admin_revoke_credit(&self, a: AdminRevokeCreditArgs) -> Result<Outcome<CreditGrant>> {
753 let note = a.note.trim();
754 if note.is_empty() || a.by.trim().is_empty() {
755 return Ok(Outcome::fail(FailureCode::Invalid, "Say why, and who is revoking it."));
756 }
757 let Some(grant) = self.grant(a.id.trim()).await? else {
758 return Ok(Outcome::fail(FailureCode::NotFound, "No such credit."));
759 };
760 if grant.closed_at.is_some() {
761 return Ok(Outcome::fail(FailureCode::Conflict, "This credit is closed already."));
762 }
763 let taken = self.close_grant(&grant, "revoked", Some(note), a.by.trim()).await?;
764 let account = self.account_of(&grant.workspace).await?;
765 self.audit(
766 &account.id,
767 "credit_revoked",
768 &format!("{} unused of {}'s {} {} credit from {}: {note}", cents(taken), grant.workspace, cents(grant.amount_micros), grant.kind, &grant.created_at[..10]),
769 a.by.trim(),
770 )
771 .await?;
772 let credits = self.credits_of(&grant.workspace).await?;
773 Ok(match credits.grants.into_iter().find(|g| g.id == grant.id) {
774 Some(grant) => Outcome::Ok(grant),
775 None => Outcome::fail(FailureCode::NotFound, "The credit was not found again."),
776 })
777 }
778
779 /// The daily run: grants past their expiry, closed, and what was left
780 /// of each taken off the balance.
781 pub(crate) async fn expire_credits(&self) -> Result<u32> {
782 let now = rfc3339(now_ms());
783 let due = self
784 .db
785 .prepare("SELECT * FROM credit_grants WHERE closed_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ? LIMIT 200")
786 .bind(&[now.as_str().into()])?
787 .all()
788 .await?
789 .results::<GrantRow>()?;
790 let mut closed = 0;
791 for grant in due {
792 let taken = self.close_grant(&grant, "expired", None, "g1t").await?;
793 let account = self.account_of(&grant.workspace).await?;
794 self.audit(
795 &account.id,
796 "credit_expired",
797 &format!("{} unused of {}'s {} {} credit from {}", cents(taken), grant.workspace, cents(grant.amount_micros), grant.kind, &grant.created_at[..10]),
798 "g1t",
799 )
800 .await?;
801 closed += 1;
802 }
803 Ok(closed)
804 }
805
806 /// `admin_credits`: every grant, filtered, with each month's totals.
807 pub(crate) async fn admin_credits(&self, a: AdminCreditsArgs) -> Result<AdminCredits> {
808 // The last 12 months, and anything older still open.
809 let mut first = rfc3339(now_ms())[..7].to_owned();
810 for _ in 0..11 {
811 first = crate::limits::previous_month(&first);
812 }
813 #[derive(Deserialize)]
814 struct Workspace {
815 workspace: String,
816 }
817 let workspaces = self
818 .db
819 .prepare("SELECT DISTINCT workspace FROM credit_grants WHERE created_at >= ? OR closed_at >= ? OR closed_at IS NULL")
820 .bind(&[format!("{first}-01").into(), format!("{first}-01").into()])?
821 .all()
822 .await?
823 .results::<Workspace>()?;
824 let now = rfc3339(now_ms());
825 let mut rows: Vec<GrantRow> = vec![];
826 let mut views: Vec<CreditGrant> = vec![];
827 let mut draws: Vec<Draw> = vec![];
828 for Workspace { workspace } in workspaces {
829 let grants = self.grants_of(&workspace).await?;
830 let (replay, _) = self.replay_of(&workspace, &grants).await?;
831 views.extend(grants.iter().map(|g| g.view(&replay, &now)));
832 draws.extend(replay.draws);
833 rows.extend(grants);
834 }
835 views.sort_by(|a, b| b.created_at.cmp(&a.created_at));
836 let months = fold_months(&rows, &draws).into_iter().filter(|m| m.month >= first).collect();
837 let mut staff: Vec<String> = views.iter().map(|g| g.created_by.clone()).collect();
838 staff.sort();
839 staff.dedup();
840 let workspace = a.workspace.as_deref().map(|w| w.trim().to_lowercase()).filter(|w| !w.is_empty());
841 let grants = views
842 .into_iter()
843 .filter(|g| workspace.as_deref().is_none_or(|w| g.workspace == w))
844 .filter(|g| a.kind.is_none_or(|k| g.kind == k))
845 .filter(|g| a.month.as_deref().is_none_or(|m| g.created_at.starts_with(m)))
846 .filter(|g| a.by.as_deref().is_none_or(|by| g.created_by == by))
847 .take(200)
848 .collect();
849 Ok(AdminCredits { grants, months, staff })
850 }
851
852 /// What credits paid for between two days (`YYYY-MM-DD`), and refunds'
853 /// money given back on those days, for the reconciliation.
854 pub(crate) async fn credit_effects(&self, since: &str, until: &str) -> Result<(Vec<(String, Draw)>, Vec<Refunded>)> {
855 let end = format!("{until}T23:59:59.999Z");
856 let grants = self
857 .db
858 .prepare("SELECT * FROM credit_grants WHERE created_at <= ?1 AND (closed_at IS NULL OR closed_at >= ?2)")
859 .bind(&[end.as_str().into(), day_start_before(since).into()])?
860 .all()
861 .await?
862 .results::<GrantRow>()?;
863 let mut workspaces: Vec<String> = grants.iter().map(|g| g.workspace.clone()).collect();
864 workspaces.sort();
865 workspaces.dedup();
866 let mut draws = vec![];
867 for workspace in workspaces {
868 let all = self.grants_of(&workspace).await?;
869 let (replay, _) = self.replay_of(&workspace, &all).await?;
870 draws.extend(
871 replay
872 .draws
873 .into_iter()
874 .filter(|d| d.at.as_str() >= since && d.at.as_str() <= end.as_str())
875 .map(|d| (workspace.clone(), d)),
876 );
877 }
878 let refunds = grants
879 .iter()
880 .filter(|g| g.kind() == CreditKind::Refund)
881 .map(|g| Refunded {
882 workspace: g.workspace.clone(),
883 day: refund_cash_day(g.refund_day.as_deref(), &g.created_at),
884 micros: (g.amount_micros - g.closed_micros).max(0),
885 })
886 .filter(|r| r.micros > 0 && r.day.as_str() >= since && r.day.as_str() <= until)
887 .collect();
888 Ok((draws, refunds))
889 }
890}
891
892/// The instant a reconciliation's first day starts, less the refund window:
893/// a grant closed before it paid for nothing in the days and refunds none.
894fn day_start_before(since: &str) -> String {
895 let ms = parse_rfc3339(&format!("{since}T00:00:00Z")).unwrap_or(0);
896 rfc3339(ms.saturating_sub(REFUND_DAYS_BACK * DAY_MS))
897}
898
899/// The owners' email: subject and first paragraph.
900pub(crate) fn credit_notice(
901 workspace: &str,
902 kind: CreditKind,
903 amount: i64,
904 note: &str,
905 refund_for: Option<&str>,
906 expires_at: Option<&str>,
907) -> (String, String) {
908 let amount = cents(amount);
909 let subject = match kind {
910 CreditKind::Refund => format!("g1t: a {amount} refund for {workspace}, as credit"),
911 _ => format!("g1t: {amount} of credit for {workspace}"),
912 };
913 let what = match (kind, refund_for) {
914 (CreditKind::Refund, Some(what)) => format!("g1t refunded {amount} to {workspace} as credit, for {}.", what.trim()),
915 _ => format!("g1t added {amount} of credit to {workspace}."),
916 };
917 let until = match expires_at {
918 Some(at) => format!(" Unused credit expires on {}.", &at[..at.len().min(10)]),
919 None => String::new(),
920 };
921 let intro = format!(
922 "{what} {}{}It pays for usage before anything paid in advance, and you can see what is left on the Billing page.{until}",
923 note.trim(),
924 if note.trim().ends_with(['.', '!', '?']) { " " } else { ". " },
925 );
926 (subject, intro)
927}
928
929#[cfg(test)]
930mod tests {
931 use super::*;
932
933 fn grant(id: &str, kind: CreditKind, expires: Option<&str>, created: &str) -> Grant {
934 Grant { id: id.into(), kind, expires_at: expires.map(Into::into), created_at: created.into(), closed_at: None, models_only: false }
935 }
936
937 fn line(reference: &str, kind: &str, amount: i64, at: &str) -> Line {
938 Line { reference: reference.into(), kind: kind.into(), amount_micros: amount, created_at: at.into(), task: Some("implement".into()) }
939 }
940
941 #[test]
942 fn credit_pays_for_usage_before_anything_paid_in_advance() {
943 // $50 paid in advance, then $25 of credit, then $10 of usage: the
944 // credit pays for all of it.
945 let grants = [grant("crd_a", CreditKind::Promotional, None, "2026-10-02T00:00:00Z")];
946 let lines = [
947 line("cs_paid", "top_up", 50_000_000, "2026-10-01T00:00:00Z"),
948 line("crd_a", "top_up", 25_000_000, "2026-10-02T00:00:00Z"),
949 line("run_1", "usage", -10_000_000, "2026-10-03T00:00:00Z"),
950 ];
951 let r = replay(0, &lines, &grants);
952 assert_eq!(r.used("crd_a"), 10_000_000);
953 assert_eq!(r.left["crd_a"], 15_000_000);
954 assert_eq!(r.draws.len(), 1);
955 assert_eq!(r.draws[0].reference, "run_1");
956 }
957
958 #[test]
959 fn the_soonest_expiring_credit_is_spent_first() {
960 let grants = [
961 grant("crd_never", CreditKind::Goodwill, None, "2026-10-01T00:00:00Z"),
962 grant("crd_late", CreditKind::Promotional, Some("2027-01-01T00:00:00Z"), "2026-10-01T00:00:01Z"),
963 grant("crd_soon", CreditKind::Promotional, Some("2026-11-01T00:00:00Z"), "2026-10-01T00:00:02Z"),
964 ];
965 let lines = [
966 line("crd_never", "top_up", 5_000_000, "2026-10-01T00:00:00Z"),
967 line("crd_late", "top_up", 5_000_000, "2026-10-01T00:00:01Z"),
968 line("crd_soon", "top_up", 5_000_000, "2026-10-01T00:00:02Z"),
969 line("run_1", "usage", -7_000_000, "2026-10-05T00:00:00Z"),
970 line("run_2", "usage", -6_000_000, "2026-10-06T00:00:00Z"),
971 ];
972 let r = replay(0, &lines, &grants);
973 assert_eq!((r.used("crd_soon"), r.used("crd_late"), r.used("crd_never")), (5_000_000, 5_000_000, 3_000_000));
974 assert_eq!(r.left["crd_never"], 2_000_000);
975 // Past its expiry, a grant pays for nothing more.
976 let lines = [
977 line("crd_soon", "top_up", 5_000_000, "2026-10-01T00:00:02Z"),
978 line("run_late", "usage", -1_000_000, "2026-11-02T00:00:00Z"),
979 ];
980 let r = replay(0, &lines, &grants);
981 assert_eq!(r.used("crd_soon"), 0);
982 assert_eq!(r.left["crd_soon"], 5_000_000);
983 }
984
985 #[test]
986 fn credit_for_models_pays_only_for_models_and_is_spent_first() {
987 let models = Grant { models_only: true, ..grant("pi_ai", CreditKind::Purchased, Some("2027-10-01T00:00:00Z"), "2026-10-01T00:00:01Z") };
988 let grants = [grant("crd_all", CreditKind::Promotional, Some("2026-11-01T00:00:00Z"), "2026-10-01T00:00:00Z"), models];
989 let mut sandbox = line("run_1/sandbox", "usage", -2_000_000, "2026-10-02T00:00:00Z");
990 sandbox.task = Some("sandbox".into());
991 let lines = [
992 line("crd_all", "top_up", 5_000_000, "2026-10-01T00:00:00Z"),
993 line("pi_ai", "top_up", 10_000_000, "2026-10-01T00:00:01Z"),
994 line("run_1", "usage", -3_000_000, "2026-10-02T00:00:00Z"),
995 sandbox,
996 ];
997 let r = replay(0, &lines, &grants);
998 // The run's model cost from the models credit, though the other
999 // expires sooner; the sandbox time only from credit for everything.
1000 assert_eq!((r.used("pi_ai"), r.used("crd_all")), (3_000_000, 2_000_000));
1001 assert!(is_model_usage(Some("implement")) && !is_model_usage(Some("sandbox")) && !is_model_usage(None));
1002 }
1003
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1004
1005 #[test]
1006 fn bought_ai_credit_is_spent_on_models_first_and_never_on_what_else_was_owed() {
1007 let ai = Grant { models_only: true, ..grant("cs_ai", CreditKind::Purchased, Some("2027-10-08T00:00:00Z"), "2026-10-08T00:00:00Z") };
1008 let promo = grant("crd_p", CreditKind::Promotional, Some("2026-11-01T00:00:00Z"), "2026-10-01T00:00:00Z");
1009 let mut sandbox = line("sbx_1", "usage", -3_000_000, "2026-10-05T00:00:00Z");
1010 sandbox.task = Some("sandbox".into());
1011 let lines = [
1012 line("crd_p", "top_up", 2_000_000, "2026-10-01T00:00:00Z"),
1013 // Owed for sandbox time when the AI credit is bought: it stays owed.
1014 sandbox,
1015 line("cs_ai", "top_up", 10_000_000, "2026-10-08T00:00:00Z"),
1016 line("run_1", "usage", -4_000_000, "2026-10-09T00:00:00Z"),
1017 line("run_1/agent", "usage", -500_000, "2026-10-09T00:00:01Z"),
1018 ];
1019 let r = replay(0, &lines, &[promo, ai]);
1020 // The run and its agent rate from the AI credit, though the other
1021 // credit expires sooner; the sandbox time from the credit for all.
1022 assert_eq!(r.used("cs_ai"), 4_500_000);
1023 assert_eq!(r.left["cs_ai"], 5_500_000);
1024 assert_eq!(r.used("crd_p"), 2_000_000);
1025 assert!(r.draws.iter().all(|d| d.grant != "cs_ai" || d.reference.starts_with("run_1")));
1026 }
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1027 #[test]
1028 fn credit_given_while_owing_pays_the_most_recent_usage_first() {
1029 let grants = [grant("crd_a", CreditKind::Goodwill, None, "2026-10-10T00:00:00Z")];
1030 let lines = [
1031 line("run_1", "usage", -20_000_000, "2026-10-02T00:00:00Z"),
1032 line("run_2", "usage", -10_000_000, "2026-10-05T00:00:00Z"),
1033 line("crd_a", "top_up", 25_000_000, "2026-10-10T00:00:00Z"),
1034 ];
1035 let r = replay(0, &lines, &grants);
1036 let paid: Vec<(&str, i64)> = r.draws.iter().map(|d| (d.reference.as_str(), d.micros)).collect();
1037 assert_eq!(paid, [("run_2", 10_000_000), ("run_1", 15_000_000)]);
1038 assert_eq!(r.left["crd_a"], 0);
1039 // Owed from before the lines read: on the grant's day.
1040 let r = replay(-4_000_000, &[line("crd_a", "top_up", 25_000_000, "2026-10-10T00:00:00Z")], &grants);
1041 assert_eq!(r.draws[0].reference, "crd_a");
1042 assert_eq!(r.draws[0].micros, 4_000_000);
1043 assert_eq!(r.left["crd_a"], 21_000_000);
1044 }
1045
1046 #[test]
1047 fn a_charge_that_comes_down_gives_back_to_the_credit_that_paid() {
1048 let grants = [grant("crd_a", CreditKind::Promotional, None, "2026-10-01T00:00:00Z")];
1049 let lines = [
1050 line("crd_a", "top_up", 10_000_000, "2026-10-01T00:00:00Z"),
1051 line("run_1", "usage", -3_000_000, "2026-10-02T00:00:00Z"),
1052 line("run_1/settled", "usage", 1_000_000, "2026-10-02T01:00:00Z"),
1053 ];
1054 let r = replay(0, &lines, &grants);
1055 assert_eq!(r.used("crd_a"), 2_000_000);
1056 assert_eq!(r.left["crd_a"], 8_000_000);
1057 assert_eq!(r.draws.last().unwrap().micros, -1_000_000);
1058 }
1059
1060 #[test]
1061 fn revoked_or_expired_credit_pays_for_nothing_more() {
1062 let mut revoked = grant("crd_a", CreditKind::Promotional, None, "2026-10-01T00:00:00Z");
1063 revoked.closed_at = Some("2026-10-03T00:00:00Z".into());
1064 let lines = [
1065 line("crd_a", "top_up", 10_000_000, "2026-10-01T00:00:00Z"),
1066 line("run_1", "usage", -3_000_000, "2026-10-02T00:00:00Z"),
1067 line("crd_a_revoked", "top_up", -7_000_000, "2026-10-03T00:00:00Z"),
1068 line("run_2", "usage", -3_000_000, "2026-10-04T00:00:00Z"),
1069 ];
1070 let r = replay(0, &lines, &[revoked]);
1071 assert_eq!(r.used("crd_a"), 3_000_000);
1072 assert_eq!(r.left["crd_a"], 0);
1073 assert_eq!(closed_grant("crd_a_revoked"), Some("crd_a"));
1074 assert_eq!(closed_grant("crd_a_expired"), Some("crd_a"));
1075 assert_eq!(closed_grant("run_1"), None);
1076 }
1077
1078 #[test]
1079 fn taking_credit_back_never_takes_the_balance_below_zero() {
1080 assert_eq!(take_back(12_400_000, 50_000_000), 12_400_000);
1081 // A refunded payment left less on the balance than the credit.
1082 assert_eq!(take_back(12_400_000, 5_000_000), 5_000_000);
1083 assert_eq!(take_back(12_400_000, -1), 0);
1084 assert_eq!(take_back(-5, 10), 0);
1085 }
1086
1087 #[test]
1088 fn a_grant_says_where_it_stands() {
1089 let now = "2026-10-07T12:00:00Z";
1090 assert_eq!(state(12_400_000, Some("2027-01-05T23:59:59Z"), None, now), ("open", 12_400_000));
1091 assert_eq!(state(0, None, None, now), ("used", 0));
1092 assert_eq!(state(5, Some("2026-10-01T00:00:00Z"), None, now), ("expired", 0));
1093 assert_eq!(state(5, None, Some("revoked"), now), ("revoked", 0));
1094 assert_eq!(state(0, Some("2026-10-01T00:00:00Z"), Some("expired"), now), ("expired", 0));
1095 }
1096
1097 #[test]
1098 fn a_credit_needs_a_kind_a_note_and_a_sensible_amount() {
1099 let now = parse_rfc3339("2026-10-07T12:00:00Z").unwrap();
1100 let ok = AdminCreditArgs {
1101 workspace: "acme".into(),
1102 amount_micros: 25_000_000,
1103 note: "Welcome to g1t".into(),
1104 by: "chase@g1t.sh".into(),
1105 kind: CreditKind::Promotional,
1106 expires_at: Some("2027-01-05T23:59:59Z".into()),
1107 refund_for: None,
1108 refund_day: None,
1109 };
1110 assert_eq!(invalid(&ok, now), None);
1111 assert!(invalid(&AdminCreditArgs { note: " ".into(), ..clone(&ok) }, now).is_some());
1112 assert!(invalid(&AdminCreditArgs { amount_micros: 0, ..clone(&ok) }, now).is_some());
1113 assert!(invalid(&AdminCreditArgs { amount_micros: MAX_CREDIT_MICROS + 1, ..clone(&ok) }, now).is_some());
1114 assert_eq!(invalid(&AdminCreditArgs { amount_micros: MAX_CREDIT_MICROS, ..clone(&ok) }, now), None);
1115 assert!(invalid(&AdminCreditArgs { expires_at: Some("2026-10-01T00:00:00Z".into()), ..clone(&ok) }, now).unwrap().contains("future"));
1116 assert!(invalid(&AdminCreditArgs { expires_at: Some("2040-01-01T00:00:00Z".into()), ..clone(&ok) }, now).is_some());
1117 // A refund says what for, never expires, and refunds a day that was.
1118 let refund = AdminCreditArgs { kind: CreditKind::Refund, expires_at: None, refund_for: Some("the failed runs on Oct 2".into()), refund_day: Some("2026-10-02".into()), ..clone(&ok) };
1119 assert_eq!(invalid(&refund, now), None);
1120 assert!(invalid(&AdminCreditArgs { expires_at: Some("2027-01-05T23:59:59Z".into()), ..clone(&refund) }, now).unwrap().contains("never expires"));
1121 assert!(invalid(&AdminCreditArgs { refund_for: None, ..clone(&refund) }, now).is_some());
1122 assert!(invalid(&AdminCreditArgs { refund_day: Some("2026-10-09".into()), ..clone(&refund) }, now).is_some());
1123 assert!(invalid(&AdminCreditArgs { refund_day: Some("2026-02-30".into()), ..clone(&refund) }, now).is_some());
1124 }
1125
1126 fn clone(a: &AdminCreditArgs) -> AdminCreditArgs {
1127 AdminCreditArgs {
1128 workspace: a.workspace.clone(),
1129 amount_micros: a.amount_micros,
1130 note: a.note.clone(),
1131 by: a.by.clone(),
1132 kind: a.kind,
1133 expires_at: a.expires_at.clone(),
1134 refund_for: a.refund_for.clone(),
1135 refund_day: a.refund_day.clone(),
1136 }
1137 }
1138
1139 #[test]
1140 fn a_refund_takes_money_off_the_day_it_refunds_within_the_window() {
1141 assert_eq!(refund_cash_day(Some("2026-10-02"), "2026-10-07T12:00:00Z"), "2026-10-02");
1142 // Further back than the reconciliation recomputes: its oldest day.
1143 assert_eq!(refund_cash_day(Some("2026-08-01"), "2026-10-07T12:00:00Z"), "2026-09-07");
1144 // None, or a day after it was given: the day it was given.
1145 assert_eq!(refund_cash_day(None, "2026-10-07T12:00:00Z"), "2026-10-07");
1146 assert_eq!(refund_cash_day(Some("2026-10-09"), "2026-10-07T12:00:00Z"), "2026-10-07");
1147 }
1148
1149 #[test]
1150 fn usage_is_keyed_as_the_reconciliation_keys_it() {
1151 assert_eq!(usage_key(Some("deployments"), "deploy/abc"), "builds");
1152 assert_eq!(usage_key(Some("deployments"), "requests/2026-10"), "deployments");
1153 assert_eq!(usage_key(Some("implement"), "run_1"), "implement");
Merge costs and margin review: gateway query, own spend, discount meters, superseded rises1154 // An agent's planning run, never the plan's payments.
1155 assert_eq!(usage_key(Some("plan"), "run_2"), "planning");
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1156 assert_eq!(usage_key(None, "crd_a"), "other");
1157 }
1158
1159 #[test]
1160 fn the_statement_and_the_email_say_what_the_credit_is() {
1161 assert_eq!(describe_grant(CreditKind::Promotional, "Welcome to g1t", None, Some("2027-01-05T23:59:59Z")), "Credit from g1t (promotional, until 2027-01-05): Welcome to g1t");
1162 assert_eq!(
1163 describe_grant(CreditKind::Refund, "Sorry about that", Some("the failed runs on Oct 2"), None),
1164 "Credit from g1t (refund for the failed runs on Oct 2): Sorry about that"
1165 );
1166 let (subject, intro) = credit_notice("acme", CreditKind::Promotional, 25_000_000, "Welcome to g1t", None, Some("2027-01-05T23:59:59Z"));
1167 assert_eq!(subject, "g1t: $25.00 of credit for acme");
1168 assert!(intro.starts_with("g1t added $25.00 of credit to acme. Welcome to g1t. It pays for usage"));
1169 assert!(intro.ends_with("Unused credit expires on 2027-01-05."));
1170 let (subject, intro) = credit_notice("acme", CreditKind::Refund, 12_000_000, "Sorry.", Some("the outage on Oct 2"), None);
1171 assert_eq!(subject, "g1t: a $12.00 refund for acme, as credit");
1172 assert!(intro.starts_with("g1t refunded $12.00 to acme as credit, for the outage on Oct 2. Sorry. It pays"));
1173 }
1174
1175 #[test]
1176 fn months_add_up_given_used_and_taken_back_by_kind() {
1177 let promo = GrantRow {
1178 id: "crd_a".into(),
1179 workspace: "acme".into(),
1180 kind: "promotional".into(),
1181 amount_micros: 25_000_000,
1182 created_at: "2026-09-20T00:00:00Z".into(),
1183 closed_at: Some("2026-10-20T00:00:00Z".into()),
1184 closed_reason: Some("expired".into()),
1185 closed_micros: 5_000_000,
1186 ..GrantRow::default()
1187 };
1188 let draws = [
1189 Draw { grant: "crd_a".into(), kind: CreditKind::Promotional, reference: "r1".into(), task: None, at: "2026-09-25T00:00:00Z".into(), micros: 15_000_000 },
1190 Draw { grant: "crd_a".into(), kind: CreditKind::Promotional, reference: "r2".into(), task: None, at: "2026-10-02T00:00:00Z".into(), micros: 5_000_000 },
1191 ];
1192 let months = fold_months(&[promo], &draws);
1193 assert_eq!(months.len(), 2);
1194 assert_eq!((months[0].month.as_str(), months[0].used_micros, months[0].expired_micros, months[0].given_micros), ("2026-10", 5_000_000, 5_000_000, 0));
1195 assert_eq!((months[1].month.as_str(), months[1].used_micros, months[1].given_micros, months[1].grants), ("2026-09", 15_000_000, 25_000_000, 1));
1196 }
1197}

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