Skip to content

g1t/services/billing/src/grants.rs

1,184 lines53,861 bytesCodeBlame

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
286/// it: builds apart from a deployment's requests.
287pub(crate) fn usage_key(task: Option<&str>, reference: &str) -> String {
288 match task {
289 Some("deployments") if reference.starts_with("deploy/") => "builds".to_owned(),
290 Some(task) => task.to_owned(),
291 None => "other".to_owned(),
292 }
293}
294
295/// The day a refund takes money off: the day it refunds, but no further
296/// back than the reconciliation recomputes from the day it was given.
297pub(crate) fn refund_cash_day(refund_day: Option<&str>, granted_at: &str) -> String {
298 let granted = &granted_at[..10];
299 let oldest = rfc3339(parse_rfc3339(&format!("{granted}T00:00:00Z")).unwrap_or(0).saturating_sub(REFUND_DAYS_BACK * DAY_MS))[..10].to_owned();
300 match refund_day {
301 Some(day) if day.len() == 10 && day <= granted => day.max(oldest.as_str()).to_owned(),
302 _ => granted.to_owned(),
303 }
304}
305
306/// A `YYYY-MM-DD` that is a real day.
307fn is_day(day: &str) -> bool {
308 day.len() == 10 && parse_rfc3339(&format!("{day}T00:00:00Z")).is_some_and(|ms| rfc3339(ms).starts_with(day))
309}
310
311/// What is wrong with a credit as asked for, if anything.
312pub(crate) fn invalid(a: &AdminCreditArgs, now_ms: u64) -> Option<&'static str> {
313 if a.workspace.trim().is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() {
314 return Some("A credit needs a workspace, a note and who gave it.");
315 }
316 if a.note.trim().chars().count() > 500 {
317 return Some("Keep the note under 500 characters.");
318 }
319 if a.kind == CreditKind::Purchased {
320 return Some("Staff give promotional, goodwill or refund credit; purchased credit is bought by the workspace.");
321 }
322 if a.amount_micros <= 0 || a.amount_micros > MAX_CREDIT_MICROS {
323 return Some("A credit is more than $0 and at most $10,000.");
324 }
325 if let Some(expires) = &a.expires_at {
326 if a.kind == CreditKind::Refund {
327 return Some("A refund never expires: it is money the workspace already paid.");
328 }
329 match parse_rfc3339(expires) {
330 Some(at) if at > now_ms && at <= now_ms + MAX_EXPIRY_DAYS * DAY_MS => {}
331 Some(at) if at <= now_ms => return Some("The expiry has to be in the future."),
332 _ => return Some("The expiry is a date within five years."),
333 }
334 }
335 if a.kind == CreditKind::Refund {
336 if a.refund_for.as_deref().is_none_or(|f| f.trim().is_empty()) {
337 return Some("Say what the refund is for, such as the failed runs on Oct 2.");
338 }
339 if a.refund_for.as_deref().is_some_and(|f| f.trim().chars().count() > 200) {
340 return Some("Keep what the refund is for under 200 characters.");
341 }
342 if let Some(day) = &a.refund_day
343 && (!is_day(day) || day.as_str() > &rfc3339(now_ms)[..10])
344 {
345 return Some("The day refunded is a date, today or before.");
346 }
347 }
348 None
349}
350
351/// The line on the statement: `Credit from g1t (promotional): Welcome to g1t`.
352pub(crate) fn describe_grant(kind: CreditKind, note: &str, refund_for: Option<&str>, expires_at: Option<&str>) -> String {
353 let what = match (kind, refund_for) {
354 (CreditKind::Refund, Some(what)) => format!("refund for {}", what.trim()),
355 (kind, _) => kind.as_str().to_owned(),
356 };
357 let until = expires_at.map(|at| format!(", until {}", &at[..at.len().min(10)])).unwrap_or_default();
358 format!("Credit from g1t ({what}{until}): {}", note.trim())
359}
360
361/// A month's credits by kind, from the grants (given, taken back) and what
362/// they paid for.
363pub(crate) fn fold_months(grants: &[GrantRow], draws: &[Draw]) -> Vec<CreditMonth> {
364 fn at<'a>(months: &'a mut BTreeMap<(String, CreditKind), CreditMonth>, month: &str, kind: CreditKind) -> &'a mut CreditMonth {
365 months.entry((month.to_owned(), kind)).or_insert_with(|| CreditMonth { month: month.to_owned(), kind, ..CreditMonth::default() })
366 }
367 let mut months: BTreeMap<(String, CreditKind), CreditMonth> = BTreeMap::new();
368 for grant in grants {
369 let kind = grant.kind();
370 let m = at(&mut months, &grant.created_at[..7], kind);
371 m.given_micros += grant.amount_micros;
372 m.grants += 1;
373 if let Some(closed) = &grant.closed_at {
374 let m = at(&mut months, &closed[..7], kind);
375 if grant.closed_reason.as_deref() == Some("revoked") {
376 m.revoked_micros += grant.closed_micros;
377 } else {
378 m.expired_micros += grant.closed_micros;
379 }
380 }
381 }
382 for draw in draws {
383 at(&mut months, &draw.at[..7], draw.kind).used_micros += draw.micros;
384 }
385 let mut out: Vec<CreditMonth> = months.into_values().collect();
386 out.sort_by(|a, b| b.month.cmp(&a.month).then(a.kind.cmp(&b.kind)));
387 out
388}
389
390// ---------------------------------------------------------------------
391// The database.
392// ---------------------------------------------------------------------
393
394#[derive(Clone, Debug, Default, Deserialize)]
395pub(crate) struct GrantRow {
396 pub id: String,
397 pub workspace: String,
398 pub kind: String,
399 pub amount_micros: i64,
400 pub note: String,
401 pub refund_for: Option<String>,
402 pub refund_day: Option<String>,
403 pub expires_at: Option<String>,
404 pub created_by: String,
405 pub created_at: String,
406 pub closed_at: Option<String>,
407 pub closed_reason: Option<String>,
408 pub closed_note: Option<String>,
409 pub closed_by: Option<String>,
410 #[serde(default)]
411 pub closed_micros: i64,
412 /// `all` or `models`.
413 #[serde(default)]
414 pub scope: Option<String>,
415 /// `staff`, `purchase` or `promo_code`.
416 #[serde(default)]
417 pub source: Option<String>,
418}
419
420impl GrantRow {
421 pub fn kind(&self) -> CreditKind {
422 CreditKind::parse(&self.kind).unwrap_or_default()
423 }
424
425 fn facts(&self) -> Grant {
426 Grant {
427 id: self.id.clone(),
428 kind: self.kind(),
429 expires_at: self.expires_at.clone(),
430 created_at: self.created_at.clone(),
431 closed_at: self.closed_at.clone(),
432 models_only: self.scope.as_deref() == Some("models"),
433 }
434 }
435
436 fn view(&self, replay: &Replay, now: &str) -> CreditGrant {
437 let used = replay.used(&self.id);
438 let left = replay.left.get(&self.id).copied().unwrap_or(0);
439 let (state, left) = state(left, self.expires_at.as_deref(), self.closed_reason.as_deref(), now);
440 CreditGrant {
441 id: self.id.clone(),
442 workspace: self.workspace.clone(),
443 kind: self.kind(),
444 amount_micros: self.amount_micros,
445 used_micros: used,
446 left_micros: left,
447 note: self.note.clone(),
448 refund_for: self.refund_for.clone(),
449 refund_day: self.refund_day.clone(),
450 expires_at: self.expires_at.clone(),
451 created_by: self.created_by.clone(),
452 created_at: self.created_at.clone(),
453 state: state.to_owned(),
454 closed_at: self.closed_at.clone(),
455 closed_note: self.closed_note.clone(),
456 closed_by: self.closed_by.clone(),
457 closed_micros: self.closed_micros,
458 scope: self.scope.clone().unwrap_or_else(|| "all".to_owned()),
459 source: self.source.clone().unwrap_or_else(|| "staff".to_owned()),
460 }
461 }
462}
463
464/// A refund's money given back, on the day it comes off.
465#[derive(Clone, Debug, PartialEq)]
466pub(crate) struct Refunded {
467 pub workspace: String,
468 pub day: String,
469 pub micros: i64,
470}
471
472impl Billing {
473 async fn grants_of(&self, workspace: &str) -> Result<Vec<GrantRow>> {
474 self.db
475 .prepare("SELECT * FROM credit_grants WHERE workspace = ? ORDER BY created_at DESC")
476 .bind(&[workspace.into()])?
477 .all()
478 .await?
479 .results::<GrantRow>()
480 }
481
482 /// The workspace's ledger from the month before its first grant, and
483 /// the balance before it, replayed against its grants.
484 async fn replay_of(&self, workspace: &str, grants: &[GrantRow]) -> Result<(Replay, i64)> {
485 let Some(first) = grants.iter().map(|g| g.created_at.as_str()).min() else {
486 return Ok((Replay::default(), 0));
487 };
488 let since = format!("{}-01", crate::limits::previous_month(&first[..7]));
489 let lines = self
490 .db
491 .prepare(
492 "SELECT reference, kind, amount_micros, created_at, task FROM ledger
493 WHERE workspace = ? AND created_at >= ? ORDER BY created_at, id",
494 )
495 .bind(&[workspace.into(), since.into()])?
496 .all()
497 .await?
498 .results::<Line>()?;
499 let balance = self.row(workspace).await?.map_or(0, |row| row.balance_micros);
500 let opening = balance - lines.iter().map(|l| l.amount_micros).sum::<i64>();
501 let facts: Vec<Grant> = grants.iter().map(GrantRow::facts).collect();
502 Ok((replay(opening, &lines, &facts), balance))
503 }
504
505 /// `credits`: a workspace's credits from g1t, for its members.
506 pub(crate) async fn credits(&self, a: g1t_contracts::billing::AccountArgs) -> Result<Outcome<Credits>> {
507 let workspace = a.workspace.to_lowercase();
508 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
509 return Ok(crate::members_only());
510 }
511 Ok(Outcome::Ok(self.credits_of(&workspace).await?))
512 }
513
Usage, Billing settings and prepaid AI credit; fixes from the UX audit514 /// What was left of credit scoped to models (AI credit) just before
515 /// `before`, from the ledger up to then: what a month's close must not
516 /// count as money for anything else.
517 pub(crate) async fn models_left_before(&self, workspace: &str, before: &str) -> Result<i64> {
518 let grants: Vec<GrantRow> = self.grants_of(workspace).await?.into_iter().filter(|g| g.created_at.as_str() < before).collect();
519 if !grants.iter().any(|g| g.scope.as_deref() == Some("models")) {
520 return Ok(0);
521 }
522 let first = grants.iter().map(|g| g.created_at.as_str()).min().unwrap_or(before);
523 let since = format!("{}-01", crate::limits::previous_month(&first[..7]));
524 #[derive(Deserialize)]
525 struct Opening {
526 micros: Option<f64>,
527 }
528 let opening = self
529 .db
530 .prepare("SELECT SUM(amount_micros) AS micros FROM ledger WHERE workspace = ? AND created_at < ?")
531 .bind(&[workspace.into(), since.as_str().into()])?
532 .first::<Opening>(None)
533 .await?
534 .and_then(|o| o.micros)
535 .unwrap_or(0.0) as i64;
536 let lines = self
537 .db
538 .prepare(
539 "SELECT reference, kind, amount_micros, created_at, task FROM ledger
540 WHERE workspace = ? AND created_at >= ? AND created_at < ? ORDER BY created_at, id",
541 )
542 .bind(&[workspace.into(), since.into(), before.into()])?
543 .all()
544 .await?
545 .results::<Line>()?;
546 let facts: Vec<Grant> = grants.iter().map(GrantRow::facts).collect();
547 let replay = replay(opening, &lines, &facts);
548 Ok(facts
549 .iter()
550 .filter(|g| g.models_only && open_at(g, before))
551 .map(|g| replay.left.get(&g.id).copied().unwrap_or(0).max(0))
552 .sum())
553 }
554
555 /// What credit (AI credit and credit from g1t) paid for usage entered
556 /// from `from` until before `until` (RFC 3339 or `YYYY-MM-DD`), and the
557 /// workspace's credits now.
558 pub(crate) async fn credit_paid_between(&self, workspace: &str, from: &str, until: &str) -> Result<(i64, Credits)> {
559 let grants = self.grants_of(workspace).await?;
560 let (replay, _) = self.replay_of(workspace, &grants).await?;
561 let paid = replay
562 .draws
563 .iter()
564 .filter(|d| d.reference != d.grant && d.at.as_str() >= from && d.at.as_str() < until)
565 .map(|d| d.micros)
566 .sum();
567 let now = rfc3339(now_ms());
568 let views: Vec<CreditGrant> = grants.iter().map(|g| g.view(&replay, &now)).collect();
569 Ok((paid, Credits { left_micros: views.iter().map(|g| g.left_micros).sum(), grants: views }))
570 }
571
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging572 pub(crate) async fn credits_of(&self, workspace: &str) -> Result<Credits> {
573 let grants = self.grants_of(workspace).await?;
574 let (replay, _) = self.replay_of(workspace, &grants).await?;
575 let now = rfc3339(now_ms());
576 let grants: Vec<CreditGrant> = grants.iter().map(|g| g.view(&replay, &now)).collect();
577 Ok(Credits { left_micros: grants.iter().map(|g| g.left_micros).sum(), grants })
578 }
579
580 /// Enters a grant: the ledger line and its `credit_grants` row. Returns
581 /// the grant's reference. `reference` names it, for a grant made
582 /// elsewhere (the Overages queue's goodwill).
583 #[allow(clippy::too_many_arguments)]
584 pub(crate) async fn grant_credit(
585 &self,
586 workspace: &str,
587 kind: CreditKind,
588 amount: i64,
589 note: &str,
590 by: &str,
591 expires_at: Option<&str>,
592 refund_for: Option<&str>,
593 refund_day: Option<&str>,
594 reference: Option<String>,
595 description: Option<String>,
596 ) -> Result<String> {
597 let now = now_ms();
598 let reference = reference.unwrap_or_else(|| new_id("crd", now));
599 let description = description.unwrap_or_else(|| describe_grant(kind, note, refund_for, expires_at));
600 let refund_day = (kind == CreditKind::Refund).then(|| refund_day.map_or_else(|| rfc3339(now)[..10].to_owned(), str::to_owned));
601 self.db
602 .prepare(
603 "INSERT INTO credit_grants (id, workspace, kind, amount_micros, note, refund_for, refund_day, expires_at, created_by, created_at)
604 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
605 )
606 .bind(&[
607 reference.as_str().into(),
608 workspace.into(),
609 kind.as_str().into(),
610 (amount as f64).into(),
611 note.trim().into(),
612 optional(refund_for.map(str::trim)),
613 optional(refund_day.as_deref()),
614 optional(expires_at),
615 by.into(),
616 rfc3339(now).into(),
617 ])?
618 .run()
619 .await?;
620 self.enter(workspace, EntryKind::TopUp, amount, &description, &reference, None, None, Some(by), None).await?;
621 self.mark_credit(&reference, kind).await?;
622 Ok(reference)
623 }
624
625 async fn mark_credit(&self, reference: &str, kind: CreditKind) -> Result<()> {
626 self.db
627 .prepare("UPDATE ledger SET credit_kind = ? WHERE reference = ?")
628 .bind(&[kind.as_str().into(), reference.into()])?
629 .run()
630 .await?;
631 Ok(())
632 }
633
634 /// `admin_credit`: credit for a workspace, from sudo.
635 pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> {
636 if let Some(why) = invalid(&a, now_ms()) {
637 return Ok(Outcome::fail(FailureCode::Invalid, why));
638 }
639 let workspace = a.workspace.trim().to_lowercase();
640 let reference = self
641 .grant_credit(
642 &workspace,
643 a.kind,
644 a.amount_micros,
645 a.note.trim(),
646 a.by.trim(),
647 a.expires_at.as_deref(),
648 a.refund_for.as_deref(),
649 a.refund_day.as_deref(),
650 None,
651 None,
652 )
653 .await?;
654 let account = self.account_of(&workspace).await?;
655 let until = a.expires_at.as_deref().map(|at| format!(", until {}", &at[..10])).unwrap_or_default();
656 let refund = a.refund_for.as_deref().filter(|_| a.kind == CreditKind::Refund).map(|f| format!(" for {}", f.trim())).unwrap_or_default();
657 self.audit(
658 &account.id,
659 "credit",
660 &format!("{} {} credit to {workspace}{refund}{until}: {}", cents(a.amount_micros), a.kind.as_str(), a.note.trim()),
661 a.by.trim(),
662 )
663 .await?;
664 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;
665 let row = self
666 .db
667 .prepare("SELECT * FROM ledger WHERE reference = ?")
668 .bind(&[reference.as_str().into()])?
669 .first::<LedgerRow>(None)
670 .await?;
671 Ok(match row {
672 Some(row) => Outcome::Ok(LedgerEntry::from(row)),
673 None => Outcome::fail(FailureCode::NotFound, "The credit was not saved."),
674 })
675 }
676
677 /// Emails the workspace's owners that credit was given. Never fails the
678 /// grant.
679 pub(crate) async fn tell_owners_of_credit(
680 &self,
681 workspace: &str,
682 kind: CreditKind,
683 amount: i64,
684 note: &str,
685 refund_for: Option<&str>,
686 expires_at: Option<&str>,
687 ) {
688 let Some(identity) = &self.identity else { return };
689 let (subject, intro) = credit_notice(workspace, kind, amount, note, refund_for, expires_at);
690 crate::limits::notify_with(
691 identity,
692 workspace,
693 &subject,
694 &intro,
695 "Open billing",
696 &format!("https://g1t.sh/{workspace}/-/billing#credits"),
697 "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",
698 )
699 .await;
700 }
701
702 /// Takes what is left of a grant off the balance: expired, or revoked
703 /// by staff. Returns what it took.
704 async fn close_grant(&self, grant: &GrantRow, reason: &str, note: Option<&str>, by: &str) -> Result<i64> {
705 let grants = self.grants_of(&grant.workspace).await?;
706 let (replay, balance) = self.replay_of(&grant.workspace, &grants).await?;
707 let left = replay.left.get(&grant.id).copied().unwrap_or(0);
708 let take = take_back(left, balance);
709 let now = rfc3339(now_ms());
710 // Closed first, so a second close finds it closed and takes nothing.
711 let claimed = self
712 .db
713 .prepare(
714 "UPDATE credit_grants SET closed_at = ?1, closed_reason = ?2, closed_note = ?3, closed_by = ?4, closed_micros = ?5
715 WHERE id = ?6 AND closed_at IS NULL RETURNING id",
716 )
717 .bind(&[now.as_str().into(), reason.into(), optional(note), by.into(), (take as f64).into(), grant.id.as_str().into()])?
718 .first::<crate::Touched>(None)
719 .await?;
720 if claimed.is_none() || take == 0 {
721 return Ok(0);
722 }
723 let reference = format!("{}_{reason}", grant.id);
724 let verb = if reason == "revoked" { "withdrawn" } else { "expired" };
725 let description = format!(
726 "Credit from g1t {verb}: {} unused of the {} given on {}",
727 cents(take),
728 cents(grant.amount_micros),
729 &grant.created_at[..10]
730 );
731 self.enter(&grant.workspace, EntryKind::TopUp, -take, &description, &reference, None, None, Some(by), None).await?;
732 self.mark_credit(&reference, grant.kind()).await?;
733 Ok(take)
734 }
735
736 async fn grant(&self, id: &str) -> Result<Option<GrantRow>> {
737 self.db.prepare("SELECT * FROM credit_grants WHERE id = ?").bind(&[id.into()])?.first::<GrantRow>(None).await
738 }
739
740 /// `admin_revoke_credit`: what is left of a grant, taken back.
741 pub(crate) async fn admin_revoke_credit(&self, a: AdminRevokeCreditArgs) -> Result<Outcome<CreditGrant>> {
742 let note = a.note.trim();
743 if note.is_empty() || a.by.trim().is_empty() {
744 return Ok(Outcome::fail(FailureCode::Invalid, "Say why, and who is revoking it."));
745 }
746 let Some(grant) = self.grant(a.id.trim()).await? else {
747 return Ok(Outcome::fail(FailureCode::NotFound, "No such credit."));
748 };
749 if grant.closed_at.is_some() {
750 return Ok(Outcome::fail(FailureCode::Conflict, "This credit is closed already."));
751 }
752 let taken = self.close_grant(&grant, "revoked", Some(note), a.by.trim()).await?;
753 let account = self.account_of(&grant.workspace).await?;
754 self.audit(
755 &account.id,
756 "credit_revoked",
757 &format!("{} unused of {}'s {} {} credit from {}: {note}", cents(taken), grant.workspace, cents(grant.amount_micros), grant.kind, &grant.created_at[..10]),
758 a.by.trim(),
759 )
760 .await?;
761 let credits = self.credits_of(&grant.workspace).await?;
762 Ok(match credits.grants.into_iter().find(|g| g.id == grant.id) {
763 Some(grant) => Outcome::Ok(grant),
764 None => Outcome::fail(FailureCode::NotFound, "The credit was not found again."),
765 })
766 }
767
768 /// The daily run: grants past their expiry, closed, and what was left
769 /// of each taken off the balance.
770 pub(crate) async fn expire_credits(&self) -> Result<u32> {
771 let now = rfc3339(now_ms());
772 let due = self
773 .db
774 .prepare("SELECT * FROM credit_grants WHERE closed_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ? LIMIT 200")
775 .bind(&[now.as_str().into()])?
776 .all()
777 .await?
778 .results::<GrantRow>()?;
779 let mut closed = 0;
780 for grant in due {
781 let taken = self.close_grant(&grant, "expired", None, "g1t").await?;
782 let account = self.account_of(&grant.workspace).await?;
783 self.audit(
784 &account.id,
785 "credit_expired",
786 &format!("{} unused of {}'s {} {} credit from {}", cents(taken), grant.workspace, cents(grant.amount_micros), grant.kind, &grant.created_at[..10]),
787 "g1t",
788 )
789 .await?;
790 closed += 1;
791 }
792 Ok(closed)
793 }
794
795 /// `admin_credits`: every grant, filtered, with each month's totals.
796 pub(crate) async fn admin_credits(&self, a: AdminCreditsArgs) -> Result<AdminCredits> {
797 // The last 12 months, and anything older still open.
798 let mut first = rfc3339(now_ms())[..7].to_owned();
799 for _ in 0..11 {
800 first = crate::limits::previous_month(&first);
801 }
802 #[derive(Deserialize)]
803 struct Workspace {
804 workspace: String,
805 }
806 let workspaces = self
807 .db
808 .prepare("SELECT DISTINCT workspace FROM credit_grants WHERE created_at >= ? OR closed_at >= ? OR closed_at IS NULL")
809 .bind(&[format!("{first}-01").into(), format!("{first}-01").into()])?
810 .all()
811 .await?
812 .results::<Workspace>()?;
813 let now = rfc3339(now_ms());
814 let mut rows: Vec<GrantRow> = vec![];
815 let mut views: Vec<CreditGrant> = vec![];
816 let mut draws: Vec<Draw> = vec![];
817 for Workspace { workspace } in workspaces {
818 let grants = self.grants_of(&workspace).await?;
819 let (replay, _) = self.replay_of(&workspace, &grants).await?;
820 views.extend(grants.iter().map(|g| g.view(&replay, &now)));
821 draws.extend(replay.draws);
822 rows.extend(grants);
823 }
824 views.sort_by(|a, b| b.created_at.cmp(&a.created_at));
825 let months = fold_months(&rows, &draws).into_iter().filter(|m| m.month >= first).collect();
826 let mut staff: Vec<String> = views.iter().map(|g| g.created_by.clone()).collect();
827 staff.sort();
828 staff.dedup();
829 let workspace = a.workspace.as_deref().map(|w| w.trim().to_lowercase()).filter(|w| !w.is_empty());
830 let grants = views
831 .into_iter()
832 .filter(|g| workspace.as_deref().is_none_or(|w| g.workspace == w))
833 .filter(|g| a.kind.is_none_or(|k| g.kind == k))
834 .filter(|g| a.month.as_deref().is_none_or(|m| g.created_at.starts_with(m)))
835 .filter(|g| a.by.as_deref().is_none_or(|by| g.created_by == by))
836 .take(200)
837 .collect();
838 Ok(AdminCredits { grants, months, staff })
839 }
840
841 /// What credits paid for between two days (`YYYY-MM-DD`), and refunds'
842 /// money given back on those days, for the reconciliation.
843 pub(crate) async fn credit_effects(&self, since: &str, until: &str) -> Result<(Vec<(String, Draw)>, Vec<Refunded>)> {
844 let end = format!("{until}T23:59:59.999Z");
845 let grants = self
846 .db
847 .prepare("SELECT * FROM credit_grants WHERE created_at <= ?1 AND (closed_at IS NULL OR closed_at >= ?2)")
848 .bind(&[end.as_str().into(), day_start_before(since).into()])?
849 .all()
850 .await?
851 .results::<GrantRow>()?;
852 let mut workspaces: Vec<String> = grants.iter().map(|g| g.workspace.clone()).collect();
853 workspaces.sort();
854 workspaces.dedup();
855 let mut draws = vec![];
856 for workspace in workspaces {
857 let all = self.grants_of(&workspace).await?;
858 let (replay, _) = self.replay_of(&workspace, &all).await?;
859 draws.extend(
860 replay
861 .draws
862 .into_iter()
863 .filter(|d| d.at.as_str() >= since && d.at.as_str() <= end.as_str())
864 .map(|d| (workspace.clone(), d)),
865 );
866 }
867 let refunds = grants
868 .iter()
869 .filter(|g| g.kind() == CreditKind::Refund)
870 .map(|g| Refunded {
871 workspace: g.workspace.clone(),
872 day: refund_cash_day(g.refund_day.as_deref(), &g.created_at),
873 micros: (g.amount_micros - g.closed_micros).max(0),
874 })
875 .filter(|r| r.micros > 0 && r.day.as_str() >= since && r.day.as_str() <= until)
876 .collect();
877 Ok((draws, refunds))
878 }
879}
880
881/// The instant a reconciliation's first day starts, less the refund window:
882/// a grant closed before it paid for nothing in the days and refunds none.
883fn day_start_before(since: &str) -> String {
884 let ms = parse_rfc3339(&format!("{since}T00:00:00Z")).unwrap_or(0);
885 rfc3339(ms.saturating_sub(REFUND_DAYS_BACK * DAY_MS))
886}
887
888/// The owners' email: subject and first paragraph.
889pub(crate) fn credit_notice(
890 workspace: &str,
891 kind: CreditKind,
892 amount: i64,
893 note: &str,
894 refund_for: Option<&str>,
895 expires_at: Option<&str>,
896) -> (String, String) {
897 let amount = cents(amount);
898 let subject = match kind {
899 CreditKind::Refund => format!("g1t: a {amount} refund for {workspace}, as credit"),
900 _ => format!("g1t: {amount} of credit for {workspace}"),
901 };
902 let what = match (kind, refund_for) {
903 (CreditKind::Refund, Some(what)) => format!("g1t refunded {amount} to {workspace} as credit, for {}.", what.trim()),
904 _ => format!("g1t added {amount} of credit to {workspace}."),
905 };
906 let until = match expires_at {
907 Some(at) => format!(" Unused credit expires on {}.", &at[..at.len().min(10)]),
908 None => String::new(),
909 };
910 let intro = format!(
911 "{what} {}{}It pays for usage before anything paid in advance, and you can see what is left on the Billing page.{until}",
912 note.trim(),
913 if note.trim().ends_with(['.', '!', '?']) { " " } else { ". " },
914 );
915 (subject, intro)
916}
917
918#[cfg(test)]
919mod tests {
920 use super::*;
921
922 fn grant(id: &str, kind: CreditKind, expires: Option<&str>, created: &str) -> Grant {
923 Grant { id: id.into(), kind, expires_at: expires.map(Into::into), created_at: created.into(), closed_at: None, models_only: false }
924 }
925
926 fn line(reference: &str, kind: &str, amount: i64, at: &str) -> Line {
927 Line { reference: reference.into(), kind: kind.into(), amount_micros: amount, created_at: at.into(), task: Some("implement".into()) }
928 }
929
930 #[test]
931 fn credit_pays_for_usage_before_anything_paid_in_advance() {
932 // $50 paid in advance, then $25 of credit, then $10 of usage: the
933 // credit pays for all of it.
934 let grants = [grant("crd_a", CreditKind::Promotional, None, "2026-10-02T00:00:00Z")];
935 let lines = [
936 line("cs_paid", "top_up", 50_000_000, "2026-10-01T00:00:00Z"),
937 line("crd_a", "top_up", 25_000_000, "2026-10-02T00:00:00Z"),
938 line("run_1", "usage", -10_000_000, "2026-10-03T00:00:00Z"),
939 ];
940 let r = replay(0, &lines, &grants);
941 assert_eq!(r.used("crd_a"), 10_000_000);
942 assert_eq!(r.left["crd_a"], 15_000_000);
943 assert_eq!(r.draws.len(), 1);
944 assert_eq!(r.draws[0].reference, "run_1");
945 }
946
947 #[test]
948 fn the_soonest_expiring_credit_is_spent_first() {
949 let grants = [
950 grant("crd_never", CreditKind::Goodwill, None, "2026-10-01T00:00:00Z"),
951 grant("crd_late", CreditKind::Promotional, Some("2027-01-01T00:00:00Z"), "2026-10-01T00:00:01Z"),
952 grant("crd_soon", CreditKind::Promotional, Some("2026-11-01T00:00:00Z"), "2026-10-01T00:00:02Z"),
953 ];
954 let lines = [
955 line("crd_never", "top_up", 5_000_000, "2026-10-01T00:00:00Z"),
956 line("crd_late", "top_up", 5_000_000, "2026-10-01T00:00:01Z"),
957 line("crd_soon", "top_up", 5_000_000, "2026-10-01T00:00:02Z"),
958 line("run_1", "usage", -7_000_000, "2026-10-05T00:00:00Z"),
959 line("run_2", "usage", -6_000_000, "2026-10-06T00:00:00Z"),
960 ];
961 let r = replay(0, &lines, &grants);
962 assert_eq!((r.used("crd_soon"), r.used("crd_late"), r.used("crd_never")), (5_000_000, 5_000_000, 3_000_000));
963 assert_eq!(r.left["crd_never"], 2_000_000);
964 // Past its expiry, a grant pays for nothing more.
965 let lines = [
966 line("crd_soon", "top_up", 5_000_000, "2026-10-01T00:00:02Z"),
967 line("run_late", "usage", -1_000_000, "2026-11-02T00:00:00Z"),
968 ];
969 let r = replay(0, &lines, &grants);
970 assert_eq!(r.used("crd_soon"), 0);
971 assert_eq!(r.left["crd_soon"], 5_000_000);
972 }
973
974 #[test]
975 fn credit_for_models_pays_only_for_models_and_is_spent_first() {
976 let models = Grant { models_only: true, ..grant("pi_ai", CreditKind::Purchased, Some("2027-10-01T00:00:00Z"), "2026-10-01T00:00:01Z") };
977 let grants = [grant("crd_all", CreditKind::Promotional, Some("2026-11-01T00:00:00Z"), "2026-10-01T00:00:00Z"), models];
978 let mut sandbox = line("run_1/sandbox", "usage", -2_000_000, "2026-10-02T00:00:00Z");
979 sandbox.task = Some("sandbox".into());
980 let lines = [
981 line("crd_all", "top_up", 5_000_000, "2026-10-01T00:00:00Z"),
982 line("pi_ai", "top_up", 10_000_000, "2026-10-01T00:00:01Z"),
983 line("run_1", "usage", -3_000_000, "2026-10-02T00:00:00Z"),
984 sandbox,
985 ];
986 let r = replay(0, &lines, &grants);
987 // The run's model cost from the models credit, though the other
988 // expires sooner; the sandbox time only from credit for everything.
989 assert_eq!((r.used("pi_ai"), r.used("crd_all")), (3_000_000, 2_000_000));
990 assert!(is_model_usage(Some("implement")) && !is_model_usage(Some("sandbox")) && !is_model_usage(None));
991 }
992
Usage, Billing settings and prepaid AI credit; fixes from the UX audit993
994 #[test]
995 fn bought_ai_credit_is_spent_on_models_first_and_never_on_what_else_was_owed() {
996 let ai = Grant { models_only: true, ..grant("cs_ai", CreditKind::Purchased, Some("2027-10-08T00:00:00Z"), "2026-10-08T00:00:00Z") };
997 let promo = grant("crd_p", CreditKind::Promotional, Some("2026-11-01T00:00:00Z"), "2026-10-01T00:00:00Z");
998 let mut sandbox = line("sbx_1", "usage", -3_000_000, "2026-10-05T00:00:00Z");
999 sandbox.task = Some("sandbox".into());
1000 let lines = [
1001 line("crd_p", "top_up", 2_000_000, "2026-10-01T00:00:00Z"),
1002 // Owed for sandbox time when the AI credit is bought: it stays owed.
1003 sandbox,
1004 line("cs_ai", "top_up", 10_000_000, "2026-10-08T00:00:00Z"),
1005 line("run_1", "usage", -4_000_000, "2026-10-09T00:00:00Z"),
1006 line("run_1/agent", "usage", -500_000, "2026-10-09T00:00:01Z"),
1007 ];
1008 let r = replay(0, &lines, &[promo, ai]);
1009 // The run and its agent rate from the AI credit, though the other
1010 // credit expires sooner; the sandbox time from the credit for all.
1011 assert_eq!(r.used("cs_ai"), 4_500_000);
1012 assert_eq!(r.left["cs_ai"], 5_500_000);
1013 assert_eq!(r.used("crd_p"), 2_000_000);
1014 assert!(r.draws.iter().all(|d| d.grant != "cs_ai" || d.reference.starts_with("run_1")));
1015 }
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1016 #[test]
1017 fn credit_given_while_owing_pays_the_most_recent_usage_first() {
1018 let grants = [grant("crd_a", CreditKind::Goodwill, None, "2026-10-10T00:00:00Z")];
1019 let lines = [
1020 line("run_1", "usage", -20_000_000, "2026-10-02T00:00:00Z"),
1021 line("run_2", "usage", -10_000_000, "2026-10-05T00:00:00Z"),
1022 line("crd_a", "top_up", 25_000_000, "2026-10-10T00:00:00Z"),
1023 ];
1024 let r = replay(0, &lines, &grants);
1025 let paid: Vec<(&str, i64)> = r.draws.iter().map(|d| (d.reference.as_str(), d.micros)).collect();
1026 assert_eq!(paid, [("run_2", 10_000_000), ("run_1", 15_000_000)]);
1027 assert_eq!(r.left["crd_a"], 0);
1028 // Owed from before the lines read: on the grant's day.
1029 let r = replay(-4_000_000, &[line("crd_a", "top_up", 25_000_000, "2026-10-10T00:00:00Z")], &grants);
1030 assert_eq!(r.draws[0].reference, "crd_a");
1031 assert_eq!(r.draws[0].micros, 4_000_000);
1032 assert_eq!(r.left["crd_a"], 21_000_000);
1033 }
1034
1035 #[test]
1036 fn a_charge_that_comes_down_gives_back_to_the_credit_that_paid() {
1037 let grants = [grant("crd_a", CreditKind::Promotional, None, "2026-10-01T00:00:00Z")];
1038 let lines = [
1039 line("crd_a", "top_up", 10_000_000, "2026-10-01T00:00:00Z"),
1040 line("run_1", "usage", -3_000_000, "2026-10-02T00:00:00Z"),
1041 line("run_1/settled", "usage", 1_000_000, "2026-10-02T01:00:00Z"),
1042 ];
1043 let r = replay(0, &lines, &grants);
1044 assert_eq!(r.used("crd_a"), 2_000_000);
1045 assert_eq!(r.left["crd_a"], 8_000_000);
1046 assert_eq!(r.draws.last().unwrap().micros, -1_000_000);
1047 }
1048
1049 #[test]
1050 fn revoked_or_expired_credit_pays_for_nothing_more() {
1051 let mut revoked = grant("crd_a", CreditKind::Promotional, None, "2026-10-01T00:00:00Z");
1052 revoked.closed_at = Some("2026-10-03T00:00:00Z".into());
1053 let lines = [
1054 line("crd_a", "top_up", 10_000_000, "2026-10-01T00:00:00Z"),
1055 line("run_1", "usage", -3_000_000, "2026-10-02T00:00:00Z"),
1056 line("crd_a_revoked", "top_up", -7_000_000, "2026-10-03T00:00:00Z"),
1057 line("run_2", "usage", -3_000_000, "2026-10-04T00:00:00Z"),
1058 ];
1059 let r = replay(0, &lines, &[revoked]);
1060 assert_eq!(r.used("crd_a"), 3_000_000);
1061 assert_eq!(r.left["crd_a"], 0);
1062 assert_eq!(closed_grant("crd_a_revoked"), Some("crd_a"));
1063 assert_eq!(closed_grant("crd_a_expired"), Some("crd_a"));
1064 assert_eq!(closed_grant("run_1"), None);
1065 }
1066
1067 #[test]
1068 fn taking_credit_back_never_takes_the_balance_below_zero() {
1069 assert_eq!(take_back(12_400_000, 50_000_000), 12_400_000);
1070 // A refunded payment left less on the balance than the credit.
1071 assert_eq!(take_back(12_400_000, 5_000_000), 5_000_000);
1072 assert_eq!(take_back(12_400_000, -1), 0);
1073 assert_eq!(take_back(-5, 10), 0);
1074 }
1075
1076 #[test]
1077 fn a_grant_says_where_it_stands() {
1078 let now = "2026-10-07T12:00:00Z";
1079 assert_eq!(state(12_400_000, Some("2027-01-05T23:59:59Z"), None, now), ("open", 12_400_000));
1080 assert_eq!(state(0, None, None, now), ("used", 0));
1081 assert_eq!(state(5, Some("2026-10-01T00:00:00Z"), None, now), ("expired", 0));
1082 assert_eq!(state(5, None, Some("revoked"), now), ("revoked", 0));
1083 assert_eq!(state(0, Some("2026-10-01T00:00:00Z"), Some("expired"), now), ("expired", 0));
1084 }
1085
1086 #[test]
1087 fn a_credit_needs_a_kind_a_note_and_a_sensible_amount() {
1088 let now = parse_rfc3339("2026-10-07T12:00:00Z").unwrap();
1089 let ok = AdminCreditArgs {
1090 workspace: "acme".into(),
1091 amount_micros: 25_000_000,
1092 note: "Welcome to g1t".into(),
1093 by: "chase@g1t.sh".into(),
1094 kind: CreditKind::Promotional,
1095 expires_at: Some("2027-01-05T23:59:59Z".into()),
1096 refund_for: None,
1097 refund_day: None,
1098 };
1099 assert_eq!(invalid(&ok, now), None);
1100 assert!(invalid(&AdminCreditArgs { note: " ".into(), ..clone(&ok) }, now).is_some());
1101 assert!(invalid(&AdminCreditArgs { amount_micros: 0, ..clone(&ok) }, now).is_some());
1102 assert!(invalid(&AdminCreditArgs { amount_micros: MAX_CREDIT_MICROS + 1, ..clone(&ok) }, now).is_some());
1103 assert_eq!(invalid(&AdminCreditArgs { amount_micros: MAX_CREDIT_MICROS, ..clone(&ok) }, now), None);
1104 assert!(invalid(&AdminCreditArgs { expires_at: Some("2026-10-01T00:00:00Z".into()), ..clone(&ok) }, now).unwrap().contains("future"));
1105 assert!(invalid(&AdminCreditArgs { expires_at: Some("2040-01-01T00:00:00Z".into()), ..clone(&ok) }, now).is_some());
1106 // A refund says what for, never expires, and refunds a day that was.
1107 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) };
1108 assert_eq!(invalid(&refund, now), None);
1109 assert!(invalid(&AdminCreditArgs { expires_at: Some("2027-01-05T23:59:59Z".into()), ..clone(&refund) }, now).unwrap().contains("never expires"));
1110 assert!(invalid(&AdminCreditArgs { refund_for: None, ..clone(&refund) }, now).is_some());
1111 assert!(invalid(&AdminCreditArgs { refund_day: Some("2026-10-09".into()), ..clone(&refund) }, now).is_some());
1112 assert!(invalid(&AdminCreditArgs { refund_day: Some("2026-02-30".into()), ..clone(&refund) }, now).is_some());
1113 }
1114
1115 fn clone(a: &AdminCreditArgs) -> AdminCreditArgs {
1116 AdminCreditArgs {
1117 workspace: a.workspace.clone(),
1118 amount_micros: a.amount_micros,
1119 note: a.note.clone(),
1120 by: a.by.clone(),
1121 kind: a.kind,
1122 expires_at: a.expires_at.clone(),
1123 refund_for: a.refund_for.clone(),
1124 refund_day: a.refund_day.clone(),
1125 }
1126 }
1127
1128 #[test]
1129 fn a_refund_takes_money_off_the_day_it_refunds_within_the_window() {
1130 assert_eq!(refund_cash_day(Some("2026-10-02"), "2026-10-07T12:00:00Z"), "2026-10-02");
1131 // Further back than the reconciliation recomputes: its oldest day.
1132 assert_eq!(refund_cash_day(Some("2026-08-01"), "2026-10-07T12:00:00Z"), "2026-09-07");
1133 // None, or a day after it was given: the day it was given.
1134 assert_eq!(refund_cash_day(None, "2026-10-07T12:00:00Z"), "2026-10-07");
1135 assert_eq!(refund_cash_day(Some("2026-10-09"), "2026-10-07T12:00:00Z"), "2026-10-07");
1136 }
1137
1138 #[test]
1139 fn usage_is_keyed_as_the_reconciliation_keys_it() {
1140 assert_eq!(usage_key(Some("deployments"), "deploy/abc"), "builds");
1141 assert_eq!(usage_key(Some("deployments"), "requests/2026-10"), "deployments");
1142 assert_eq!(usage_key(Some("implement"), "run_1"), "implement");
1143 assert_eq!(usage_key(None, "crd_a"), "other");
1144 }
1145
1146 #[test]
1147 fn the_statement_and_the_email_say_what_the_credit_is() {
1148 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");
1149 assert_eq!(
1150 describe_grant(CreditKind::Refund, "Sorry about that", Some("the failed runs on Oct 2"), None),
1151 "Credit from g1t (refund for the failed runs on Oct 2): Sorry about that"
1152 );
1153 let (subject, intro) = credit_notice("acme", CreditKind::Promotional, 25_000_000, "Welcome to g1t", None, Some("2027-01-05T23:59:59Z"));
1154 assert_eq!(subject, "g1t: $25.00 of credit for acme");
1155 assert!(intro.starts_with("g1t added $25.00 of credit to acme. Welcome to g1t. It pays for usage"));
1156 assert!(intro.ends_with("Unused credit expires on 2027-01-05."));
1157 let (subject, intro) = credit_notice("acme", CreditKind::Refund, 12_000_000, "Sorry.", Some("the outage on Oct 2"), None);
1158 assert_eq!(subject, "g1t: a $12.00 refund for acme, as credit");
1159 assert!(intro.starts_with("g1t refunded $12.00 to acme as credit, for the outage on Oct 2. Sorry. It pays"));
1160 }
1161
1162 #[test]
1163 fn months_add_up_given_used_and_taken_back_by_kind() {
1164 let promo = GrantRow {
1165 id: "crd_a".into(),
1166 workspace: "acme".into(),
1167 kind: "promotional".into(),
1168 amount_micros: 25_000_000,
1169 created_at: "2026-09-20T00:00:00Z".into(),
1170 closed_at: Some("2026-10-20T00:00:00Z".into()),
1171 closed_reason: Some("expired".into()),
1172 closed_micros: 5_000_000,
1173 ..GrantRow::default()
1174 };
1175 let draws = [
1176 Draw { grant: "crd_a".into(), kind: CreditKind::Promotional, reference: "r1".into(), task: None, at: "2026-09-25T00:00:00Z".into(), micros: 15_000_000 },
1177 Draw { grant: "crd_a".into(), kind: CreditKind::Promotional, reference: "r2".into(), task: None, at: "2026-10-02T00:00:00Z".into(), micros: 5_000_000 },
1178 ];
1179 let months = fold_months(&[promo], &draws);
1180 assert_eq!(months.len(), 2);
1181 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));
1182 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));
1183 }
1184}