Skip to content
1,188 linesCodeBlameRaw
1//! 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.
70const NOT_MODELS: [&str; 10] = [
71 "sandbox", "self_hosted", "deployments", "security", "context", "storage", "git", "cache", "domains", "package_storage",
72];
73
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 }
146 // Credit for models pays only what models owed.
147 if grant.models_only && !is_model_usage(lines[*i].task.as_deref()) {
148 continue;
149 }
150 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 }
166 if owed > 0 && !grant.models_only {
167 // 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, and an agent's planning
287/// run apart from the plan's payments (`margin::PLANNING_KEY`).
288pub(crate) fn usage_key(task: Option<&str>, reference: &str) -> String {
289 match task {
290 Some("deployments") if reference.starts_with("deploy/") => "builds".to_owned(),
291 Some("plan") => crate::margin::PLANNING_KEY.to_owned(),
292 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
516 /// What was left of credit scoped to models (AI credit) just before
517 /// `before`, from the ledger up to then: what a month's close must not
518 /// count as money for anything else.
519 pub(crate) async fn models_left_before(&self, workspace: &str, before: &str) -> Result<i64> {
520 let grants: Vec<GrantRow> = self.grants_of(workspace).await?.into_iter().filter(|g| g.created_at.as_str() < before).collect();
521 if !grants.iter().any(|g| g.scope.as_deref() == Some("models")) {
522 return Ok(0);
523 }
524 let first = grants.iter().map(|g| g.created_at.as_str()).min().unwrap_or(before);
525 let since = format!("{}-01", crate::limits::previous_month(&first[..7]));
526 #[derive(Deserialize)]
527 struct Opening {
528 micros: Option<f64>,
529 }
530 let opening = self
531 .db
532 .prepare("SELECT SUM(amount_micros) AS micros FROM ledger WHERE workspace = ? AND created_at < ?")
533 .bind(&[workspace.into(), since.as_str().into()])?
534 .first::<Opening>(None)
535 .await?
536 .and_then(|o| o.micros)
537 .unwrap_or(0.0) as i64;
538 let lines = self
539 .db
540 .prepare(
541 "SELECT reference, kind, amount_micros, created_at, task FROM ledger
542 WHERE workspace = ? AND created_at >= ? AND created_at < ? ORDER BY created_at, id",
543 )
544 .bind(&[workspace.into(), since.into(), before.into()])?
545 .all()
546 .await?
547 .results::<Line>()?;
548 let facts: Vec<Grant> = grants.iter().map(GrantRow::facts).collect();
549 let replay = replay(opening, &lines, &facts);
550 Ok(facts
551 .iter()
552 .filter(|g| g.models_only && open_at(g, before))
553 .map(|g| replay.left.get(&g.id).copied().unwrap_or(0).max(0))
554 .sum())
555 }
556
557 /// What credit (AI credit and credit from g1t) paid for usage entered
558 /// from `from` until before `until` (RFC 3339 or `YYYY-MM-DD`), and the
559 /// workspace's credits now.
560 pub(crate) async fn credit_paid_between(&self, workspace: &str, from: &str, until: &str) -> Result<(i64, Credits)> {
561 let grants = self.grants_of(workspace).await?;
562 let (replay, _) = self.replay_of(workspace, &grants).await?;
563 let paid = replay
564 .draws
565 .iter()
566 .filter(|d| d.reference != d.grant && d.at.as_str() >= from && d.at.as_str() < until)
567 .map(|d| d.micros)
568 .sum();
569 let now = rfc3339(now_ms());
570 let views: Vec<CreditGrant> = grants.iter().map(|g| g.view(&replay, &now)).collect();
571 Ok((paid, Credits { left_micros: views.iter().map(|g| g.left_micros).sum(), grants: views }))
572 }
573
574 pub(crate) async fn credits_of(&self, workspace: &str) -> Result<Credits> {
575 let grants = self.grants_of(workspace).await?;
576 let (replay, _) = self.replay_of(workspace, &grants).await?;
577 let now = rfc3339(now_ms());
578 let grants: Vec<CreditGrant> = grants.iter().map(|g| g.view(&replay, &now)).collect();
579 Ok(Credits { left_micros: grants.iter().map(|g| g.left_micros).sum(), grants })
580 }
581
582 /// Enters a grant: the ledger line and its `credit_grants` row. Returns
583 /// the grant's reference. `reference` names it, for a grant made
584 /// elsewhere (the Overages queue's goodwill).
585 #[allow(clippy::too_many_arguments)]
586 pub(crate) async fn grant_credit(
587 &self,
588 workspace: &str,
589 kind: CreditKind,
590 amount: i64,
591 note: &str,
592 by: &str,
593 expires_at: Option<&str>,
594 refund_for: Option<&str>,
595 refund_day: Option<&str>,
596 reference: Option<String>,
597 description: Option<String>,
598 ) -> Result<String> {
599 let now = now_ms();
600 let reference = reference.unwrap_or_else(|| new_id("crd", now));
601 let description = description.unwrap_or_else(|| describe_grant(kind, note, refund_for, expires_at));
602 let refund_day = (kind == CreditKind::Refund).then(|| refund_day.map_or_else(|| rfc3339(now)[..10].to_owned(), str::to_owned));
603 self.db
604 .prepare(
605 "INSERT INTO credit_grants (id, workspace, kind, amount_micros, note, refund_for, refund_day, expires_at, created_by, created_at)
606 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
607 )
608 .bind(&[
609 reference.as_str().into(),
610 workspace.into(),
611 kind.as_str().into(),
612 (amount as f64).into(),
613 note.trim().into(),
614 optional(refund_for.map(str::trim)),
615 optional(refund_day.as_deref()),
616 optional(expires_at),
617 by.into(),
618 rfc3339(now).into(),
619 ])?
620 .run()
621 .await?;
622 self.enter(workspace, EntryKind::TopUp, amount, &description, &reference, None, None, Some(by), None).await?;
623 self.mark_credit(&reference, kind).await?;
624 Ok(reference)
625 }
626
627 async fn mark_credit(&self, reference: &str, kind: CreditKind) -> Result<()> {
628 self.db
629 .prepare("UPDATE ledger SET credit_kind = ? WHERE reference = ?")
630 .bind(&[kind.as_str().into(), reference.into()])?
631 .run()
632 .await?;
633 Ok(())
634 }
635
636 /// `admin_credit`: credit for a workspace, from sudo.
637 pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> {
638 if let Some(why) = invalid(&a, now_ms()) {
639 return Ok(Outcome::fail(FailureCode::Invalid, why));
640 }
641 let workspace = a.workspace.trim().to_lowercase();
642 let reference = self
643 .grant_credit(
644 &workspace,
645 a.kind,
646 a.amount_micros,
647 a.note.trim(),
648 a.by.trim(),
649 a.expires_at.as_deref(),
650 a.refund_for.as_deref(),
651 a.refund_day.as_deref(),
652 None,
653 None,
654 )
655 .await?;
656 let account = self.account_of(&workspace).await?;
657 let until = a.expires_at.as_deref().map(|at| format!(", until {}", &at[..10])).unwrap_or_default();
658 let refund = a.refund_for.as_deref().filter(|_| a.kind == CreditKind::Refund).map(|f| format!(" for {}", f.trim())).unwrap_or_default();
659 self.audit(
660 &account.id,
661 "credit",
662 &format!("{} {} credit to {workspace}{refund}{until}: {}", cents(a.amount_micros), a.kind.as_str(), a.note.trim()),
663 a.by.trim(),
664 )
665 .await?;
666 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;
667 let row = self
668 .db
669 .prepare("SELECT * FROM ledger WHERE reference = ?")
670 .bind(&[reference.as_str().into()])?
671 .first::<LedgerRow>(None)
672 .await?;
673 Ok(match row {
674 Some(row) => Outcome::Ok(LedgerEntry::from(row)),
675 None => Outcome::fail(FailureCode::NotFound, "The credit was not saved."),
676 })
677 }
678
679 /// Emails the workspace's owners that credit was given. Never fails the
680 /// grant.
681 pub(crate) async fn tell_owners_of_credit(
682 &self,
683 workspace: &str,
684 kind: CreditKind,
685 amount: i64,
686 note: &str,
687 refund_for: Option<&str>,
688 expires_at: Option<&str>,
689 ) {
690 let Some(identity) = &self.identity else { return };
691 let (subject, intro) = credit_notice(workspace, kind, amount, note, refund_for, expires_at);
692 crate::limits::notify_with(
693 identity,
694 workspace,
695 &subject,
696 &intro,
697 "Open billing",
698 &format!("https://g1t.sh/{workspace}/-/billing#credits"),
699 "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",
700 )
701 .await;
702 }
703
704 /// Takes what is left of a grant off the balance: expired, or revoked
705 /// by staff. Returns what it took.
706 async fn close_grant(&self, grant: &GrantRow, reason: &str, note: Option<&str>, by: &str) -> Result<i64> {
707 let grants = self.grants_of(&grant.workspace).await?;
708 let (replay, balance) = self.replay_of(&grant.workspace, &grants).await?;
709 let left = replay.left.get(&grant.id).copied().unwrap_or(0);
710 let take = take_back(left, balance);
711 let now = rfc3339(now_ms());
712 // Closed first, so a second close finds it closed and takes nothing.
713 let claimed = self
714 .db
715 .prepare(
716 "UPDATE credit_grants SET closed_at = ?1, closed_reason = ?2, closed_note = ?3, closed_by = ?4, closed_micros = ?5
717 WHERE id = ?6 AND closed_at IS NULL RETURNING id",
718 )
719 .bind(&[now.as_str().into(), reason.into(), optional(note), by.into(), (take as f64).into(), grant.id.as_str().into()])?
720 .first::<crate::Touched>(None)
721 .await?;
722 if claimed.is_none() || take == 0 {
723 return Ok(0);
724 }
725 let reference = format!("{}_{reason}", grant.id);
726 let verb = if reason == "revoked" { "withdrawn" } else { "expired" };
727 let description = format!(
728 "Credit from g1t {verb}: {} unused of the {} given on {}",
729 cents(take),
730 cents(grant.amount_micros),
731 &grant.created_at[..10]
732 );
733 self.enter(&grant.workspace, EntryKind::TopUp, -take, &description, &reference, None, None, Some(by), None).await?;
734 self.mark_credit(&reference, grant.kind()).await?;
735 Ok(take)
736 }
737
738 async fn grant(&self, id: &str) -> Result<Option<GrantRow>> {
739 self.db.prepare("SELECT * FROM credit_grants WHERE id = ?").bind(&[id.into()])?.first::<GrantRow>(None).await
740 }
741
742 /// `admin_revoke_credit`: what is left of a grant, taken back.
743 pub(crate) async fn admin_revoke_credit(&self, a: AdminRevokeCreditArgs) -> Result<Outcome<CreditGrant>> {
744 let note = a.note.trim();
745 if note.is_empty() || a.by.trim().is_empty() {
746 return Ok(Outcome::fail(FailureCode::Invalid, "Say why, and who is revoking it."));
747 }
748 let Some(grant) = self.grant(a.id.trim()).await? else {
749 return Ok(Outcome::fail(FailureCode::NotFound, "No such credit."));
750 };
751 if grant.closed_at.is_some() {
752 return Ok(Outcome::fail(FailureCode::Conflict, "This credit is closed already."));
753 }
754 let taken = self.close_grant(&grant, "revoked", Some(note), a.by.trim()).await?;
755 let account = self.account_of(&grant.workspace).await?;
756 self.audit(
757 &account.id,
758 "credit_revoked",
759 &format!("{} unused of {}'s {} {} credit from {}: {note}", cents(taken), grant.workspace, cents(grant.amount_micros), grant.kind, &grant.created_at[..10]),
760 a.by.trim(),
761 )
762 .await?;
763 let credits = self.credits_of(&grant.workspace).await?;
764 Ok(match credits.grants.into_iter().find(|g| g.id == grant.id) {
765 Some(grant) => Outcome::Ok(grant),
766 None => Outcome::fail(FailureCode::NotFound, "The credit was not found again."),
767 })
768 }
769
770 /// The daily run: grants past their expiry, closed, and what was left
771 /// of each taken off the balance.
772 pub(crate) async fn expire_credits(&self) -> Result<u32> {
773 let now = rfc3339(now_ms());
774 let due = self
775 .db
776 .prepare("SELECT * FROM credit_grants WHERE closed_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ? LIMIT 200")
777 .bind(&[now.as_str().into()])?
778 .all()
779 .await?
780 .results::<GrantRow>()?;
781 let mut closed = 0;
782 for grant in due {
783 let taken = self.close_grant(&grant, "expired", None, "g1t").await?;
784 let account = self.account_of(&grant.workspace).await?;
785 self.audit(
786 &account.id,
787 "credit_expired",
788 &format!("{} unused of {}'s {} {} credit from {}", cents(taken), grant.workspace, cents(grant.amount_micros), grant.kind, &grant.created_at[..10]),
789 "g1t",
790 )
791 .await?;
792 closed += 1;
793 }
794 Ok(closed)
795 }
796
797 /// `admin_credits`: every grant, filtered, with each month's totals.
798 pub(crate) async fn admin_credits(&self, a: AdminCreditsArgs) -> Result<AdminCredits> {
799 // The last 12 months, and anything older still open.
800 let mut first = rfc3339(now_ms())[..7].to_owned();
801 for _ in 0..11 {
802 first = crate::limits::previous_month(&first);
803 }
804 #[derive(Deserialize)]
805 struct Workspace {
806 workspace: String,
807 }
808 let workspaces = self
809 .db
810 .prepare("SELECT DISTINCT workspace FROM credit_grants WHERE created_at >= ? OR closed_at >= ? OR closed_at IS NULL")
811 .bind(&[format!("{first}-01").into(), format!("{first}-01").into()])?
812 .all()
813 .await?
814 .results::<Workspace>()?;
815 let now = rfc3339(now_ms());
816 let mut rows: Vec<GrantRow> = vec![];
817 let mut views: Vec<CreditGrant> = vec![];
818 let mut draws: Vec<Draw> = vec![];
819 for Workspace { workspace } in workspaces {
820 let grants = self.grants_of(&workspace).await?;
821 let (replay, _) = self.replay_of(&workspace, &grants).await?;
822 views.extend(grants.iter().map(|g| g.view(&replay, &now)));
823 draws.extend(replay.draws);
824 rows.extend(grants);
825 }
826 views.sort_by(|a, b| b.created_at.cmp(&a.created_at));
827 let months = fold_months(&rows, &draws).into_iter().filter(|m| m.month >= first).collect();
828 let mut staff: Vec<String> = views.iter().map(|g| g.created_by.clone()).collect();
829 staff.sort();
830 staff.dedup();
831 let workspace = a.workspace.as_deref().map(|w| w.trim().to_lowercase()).filter(|w| !w.is_empty());
832 let grants = views
833 .into_iter()
834 .filter(|g| workspace.as_deref().is_none_or(|w| g.workspace == w))
835 .filter(|g| a.kind.is_none_or(|k| g.kind == k))
836 .filter(|g| a.month.as_deref().is_none_or(|m| g.created_at.starts_with(m)))
837 .filter(|g| a.by.as_deref().is_none_or(|by| g.created_by == by))
838 .take(200)
839 .collect();
840 Ok(AdminCredits { grants, months, staff })
841 }
842
843 /// What credits paid for between two days (`YYYY-MM-DD`), and refunds'
844 /// money given back on those days, for the reconciliation.
845 pub(crate) async fn credit_effects(&self, since: &str, until: &str) -> Result<(Vec<(String, Draw)>, Vec<Refunded>)> {
846 let end = format!("{until}T23:59:59.999Z");
847 let grants = self
848 .db
849 .prepare("SELECT * FROM credit_grants WHERE created_at <= ?1 AND (closed_at IS NULL OR closed_at >= ?2)")
850 .bind(&[end.as_str().into(), day_start_before(since).into()])?
851 .all()
852 .await?
853 .results::<GrantRow>()?;
854 let mut workspaces: Vec<String> = grants.iter().map(|g| g.workspace.clone()).collect();
855 workspaces.sort();
856 workspaces.dedup();
857 let mut draws = vec![];
858 for workspace in workspaces {
859 let all = self.grants_of(&workspace).await?;
860 let (replay, _) = self.replay_of(&workspace, &all).await?;
861 draws.extend(
862 replay
863 .draws
864 .into_iter()
865 .filter(|d| d.at.as_str() >= since && d.at.as_str() <= end.as_str())
866 .map(|d| (workspace.clone(), d)),
867 );
868 }
869 let refunds = grants
870 .iter()
871 .filter(|g| g.kind() == CreditKind::Refund)
872 .map(|g| Refunded {
873 workspace: g.workspace.clone(),
874 day: refund_cash_day(g.refund_day.as_deref(), &g.created_at),
875 micros: (g.amount_micros - g.closed_micros).max(0),
876 })
877 .filter(|r| r.micros > 0 && r.day.as_str() >= since && r.day.as_str() <= until)
878 .collect();
879 Ok((draws, refunds))
880 }
881}
882
883/// The instant a reconciliation's first day starts, less the refund window:
884/// a grant closed before it paid for nothing in the days and refunds none.
885fn day_start_before(since: &str) -> String {
886 let ms = parse_rfc3339(&format!("{since}T00:00:00Z")).unwrap_or(0);
887 rfc3339(ms.saturating_sub(REFUND_DAYS_BACK * DAY_MS))
888}
889
890/// The owners' email: subject and first paragraph.
891pub(crate) fn credit_notice(
892 workspace: &str,
893 kind: CreditKind,
894 amount: i64,
895 note: &str,
896 refund_for: Option<&str>,
897 expires_at: Option<&str>,
898) -> (String, String) {
899 let amount = cents(amount);
900 let subject = match kind {
901 CreditKind::Refund => format!("g1t: a {amount} refund for {workspace}, as credit"),
902 _ => format!("g1t: {amount} of credit for {workspace}"),
903 };
904 let what = match (kind, refund_for) {
905 (CreditKind::Refund, Some(what)) => format!("g1t refunded {amount} to {workspace} as credit, for {}.", what.trim()),
906 _ => format!("g1t added {amount} of credit to {workspace}."),
907 };
908 let until = match expires_at {
909 Some(at) => format!(" Unused credit expires on {}.", &at[..at.len().min(10)]),
910 None => String::new(),
911 };
912 let intro = format!(
913 "{what} {}{}It pays for usage before anything paid in advance, and you can see what is left on the Billing page.{until}",
914 note.trim(),
915 if note.trim().ends_with(['.', '!', '?']) { " " } else { ". " },
916 );
917 (subject, intro)
918}
919
920#[cfg(test)]
921mod tests {
922 use super::*;
923
924 fn grant(id: &str, kind: CreditKind, expires: Option<&str>, created: &str) -> Grant {
925 Grant { id: id.into(), kind, expires_at: expires.map(Into::into), created_at: created.into(), closed_at: None, models_only: false }
926 }
927
928 fn line(reference: &str, kind: &str, amount: i64, at: &str) -> Line {
929 Line { reference: reference.into(), kind: kind.into(), amount_micros: amount, created_at: at.into(), task: Some("implement".into()) }
930 }
931
932 #[test]
933 fn credit_pays_for_usage_before_anything_paid_in_advance() {
934 // $50 paid in advance, then $25 of credit, then $10 of usage: the
935 // credit pays for all of it.
936 let grants = [grant("crd_a", CreditKind::Promotional, None, "2026-10-02T00:00:00Z")];
937 let lines = [
938 line("cs_paid", "top_up", 50_000_000, "2026-10-01T00:00:00Z"),
939 line("crd_a", "top_up", 25_000_000, "2026-10-02T00:00:00Z"),
940 line("run_1", "usage", -10_000_000, "2026-10-03T00:00:00Z"),
941 ];
942 let r = replay(0, &lines, &grants);
943 assert_eq!(r.used("crd_a"), 10_000_000);
944 assert_eq!(r.left["crd_a"], 15_000_000);
945 assert_eq!(r.draws.len(), 1);
946 assert_eq!(r.draws[0].reference, "run_1");
947 }
948
949 #[test]
950 fn the_soonest_expiring_credit_is_spent_first() {
951 let grants = [
952 grant("crd_never", CreditKind::Goodwill, None, "2026-10-01T00:00:00Z"),
953 grant("crd_late", CreditKind::Promotional, Some("2027-01-01T00:00:00Z"), "2026-10-01T00:00:01Z"),
954 grant("crd_soon", CreditKind::Promotional, Some("2026-11-01T00:00:00Z"), "2026-10-01T00:00:02Z"),
955 ];
956 let lines = [
957 line("crd_never", "top_up", 5_000_000, "2026-10-01T00:00:00Z"),
958 line("crd_late", "top_up", 5_000_000, "2026-10-01T00:00:01Z"),
959 line("crd_soon", "top_up", 5_000_000, "2026-10-01T00:00:02Z"),
960 line("run_1", "usage", -7_000_000, "2026-10-05T00:00:00Z"),
961 line("run_2", "usage", -6_000_000, "2026-10-06T00:00:00Z"),
962 ];
963 let r = replay(0, &lines, &grants);
964 assert_eq!((r.used("crd_soon"), r.used("crd_late"), r.used("crd_never")), (5_000_000, 5_000_000, 3_000_000));
965 assert_eq!(r.left["crd_never"], 2_000_000);
966 // Past its expiry, a grant pays for nothing more.
967 let lines = [
968 line("crd_soon", "top_up", 5_000_000, "2026-10-01T00:00:02Z"),
969 line("run_late", "usage", -1_000_000, "2026-11-02T00:00:00Z"),
970 ];
971 let r = replay(0, &lines, &grants);
972 assert_eq!(r.used("crd_soon"), 0);
973 assert_eq!(r.left["crd_soon"], 5_000_000);
974 }
975
976 #[test]
977 fn credit_for_models_pays_only_for_models_and_is_spent_first() {
978 let models = Grant { models_only: true, ..grant("pi_ai", CreditKind::Purchased, Some("2027-10-01T00:00:00Z"), "2026-10-01T00:00:01Z") };
979 let grants = [grant("crd_all", CreditKind::Promotional, Some("2026-11-01T00:00:00Z"), "2026-10-01T00:00:00Z"), models];
980 let mut sandbox = line("run_1/sandbox", "usage", -2_000_000, "2026-10-02T00:00:00Z");
981 sandbox.task = Some("sandbox".into());
982 let lines = [
983 line("crd_all", "top_up", 5_000_000, "2026-10-01T00:00:00Z"),
984 line("pi_ai", "top_up", 10_000_000, "2026-10-01T00:00:01Z"),
985 line("run_1", "usage", -3_000_000, "2026-10-02T00:00:00Z"),
986 sandbox,
987 ];
988 let r = replay(0, &lines, &grants);
989 // The run's model cost from the models credit, though the other
990 // expires sooner; the sandbox time only from credit for everything.
991 assert_eq!((r.used("pi_ai"), r.used("crd_all")), (3_000_000, 2_000_000));
992 assert!(is_model_usage(Some("implement")) && !is_model_usage(Some("sandbox")) && !is_model_usage(None));
993 }
994
995
996 #[test]
997 fn bought_ai_credit_is_spent_on_models_first_and_never_on_what_else_was_owed() {
998 let ai = Grant { models_only: true, ..grant("cs_ai", CreditKind::Purchased, Some("2027-10-08T00:00:00Z"), "2026-10-08T00:00:00Z") };
999 let promo = grant("crd_p", CreditKind::Promotional, Some("2026-11-01T00:00:00Z"), "2026-10-01T00:00:00Z");
1000 let mut sandbox = line("sbx_1", "usage", -3_000_000, "2026-10-05T00:00:00Z");
1001 sandbox.task = Some("sandbox".into());
1002 let lines = [
1003 line("crd_p", "top_up", 2_000_000, "2026-10-01T00:00:00Z"),
1004 // Owed for sandbox time when the AI credit is bought: it stays owed.
1005 sandbox,
1006 line("cs_ai", "top_up", 10_000_000, "2026-10-08T00:00:00Z"),
1007 line("run_1", "usage", -4_000_000, "2026-10-09T00:00:00Z"),
1008 line("run_1/agent", "usage", -500_000, "2026-10-09T00:00:01Z"),
1009 ];
1010 let r = replay(0, &lines, &[promo, ai]);
1011 // The run and its agent rate from the AI credit, though the other
1012 // credit expires sooner; the sandbox time from the credit for all.
1013 assert_eq!(r.used("cs_ai"), 4_500_000);
1014 assert_eq!(r.left["cs_ai"], 5_500_000);
1015 assert_eq!(r.used("crd_p"), 2_000_000);
1016 assert!(r.draws.iter().all(|d| d.grant != "cs_ai" || d.reference.starts_with("run_1")));
1017 }
1018 #[test]
1019 fn credit_given_while_owing_pays_the_most_recent_usage_first() {
1020 let grants = [grant("crd_a", CreditKind::Goodwill, None, "2026-10-10T00:00:00Z")];
1021 let lines = [
1022 line("run_1", "usage", -20_000_000, "2026-10-02T00:00:00Z"),
1023 line("run_2", "usage", -10_000_000, "2026-10-05T00:00:00Z"),
1024 line("crd_a", "top_up", 25_000_000, "2026-10-10T00:00:00Z"),
1025 ];
1026 let r = replay(0, &lines, &grants);
1027 let paid: Vec<(&str, i64)> = r.draws.iter().map(|d| (d.reference.as_str(), d.micros)).collect();
1028 assert_eq!(paid, [("run_2", 10_000_000), ("run_1", 15_000_000)]);
1029 assert_eq!(r.left["crd_a"], 0);
1030 // Owed from before the lines read: on the grant's day.
1031 let r = replay(-4_000_000, &[line("crd_a", "top_up", 25_000_000, "2026-10-10T00:00:00Z")], &grants);
1032 assert_eq!(r.draws[0].reference, "crd_a");
1033 assert_eq!(r.draws[0].micros, 4_000_000);
1034 assert_eq!(r.left["crd_a"], 21_000_000);
1035 }
1036
1037 #[test]
1038 fn a_charge_that_comes_down_gives_back_to_the_credit_that_paid() {
1039 let grants = [grant("crd_a", CreditKind::Promotional, None, "2026-10-01T00:00:00Z")];
1040 let lines = [
1041 line("crd_a", "top_up", 10_000_000, "2026-10-01T00:00:00Z"),
1042 line("run_1", "usage", -3_000_000, "2026-10-02T00:00:00Z"),
1043 line("run_1/settled", "usage", 1_000_000, "2026-10-02T01:00:00Z"),
1044 ];
1045 let r = replay(0, &lines, &grants);
1046 assert_eq!(r.used("crd_a"), 2_000_000);
1047 assert_eq!(r.left["crd_a"], 8_000_000);
1048 assert_eq!(r.draws.last().unwrap().micros, -1_000_000);
1049 }
1050
1051 #[test]
1052 fn revoked_or_expired_credit_pays_for_nothing_more() {
1053 let mut revoked = grant("crd_a", CreditKind::Promotional, None, "2026-10-01T00:00:00Z");
1054 revoked.closed_at = Some("2026-10-03T00:00:00Z".into());
1055 let lines = [
1056 line("crd_a", "top_up", 10_000_000, "2026-10-01T00:00:00Z"),
1057 line("run_1", "usage", -3_000_000, "2026-10-02T00:00:00Z"),
1058 line("crd_a_revoked", "top_up", -7_000_000, "2026-10-03T00:00:00Z"),
1059 line("run_2", "usage", -3_000_000, "2026-10-04T00:00:00Z"),
1060 ];
1061 let r = replay(0, &lines, &[revoked]);
1062 assert_eq!(r.used("crd_a"), 3_000_000);
1063 assert_eq!(r.left["crd_a"], 0);
1064 assert_eq!(closed_grant("crd_a_revoked"), Some("crd_a"));
1065 assert_eq!(closed_grant("crd_a_expired"), Some("crd_a"));
1066 assert_eq!(closed_grant("run_1"), None);
1067 }
1068
1069 #[test]
1070 fn taking_credit_back_never_takes_the_balance_below_zero() {
1071 assert_eq!(take_back(12_400_000, 50_000_000), 12_400_000);
1072 // A refunded payment left less on the balance than the credit.
1073 assert_eq!(take_back(12_400_000, 5_000_000), 5_000_000);
1074 assert_eq!(take_back(12_400_000, -1), 0);
1075 assert_eq!(take_back(-5, 10), 0);
1076 }
1077
1078 #[test]
1079 fn a_grant_says_where_it_stands() {
1080 let now = "2026-10-07T12:00:00Z";
1081 assert_eq!(state(12_400_000, Some("2027-01-05T23:59:59Z"), None, now), ("open", 12_400_000));
1082 assert_eq!(state(0, None, None, now), ("used", 0));
1083 assert_eq!(state(5, Some("2026-10-01T00:00:00Z"), None, now), ("expired", 0));
1084 assert_eq!(state(5, None, Some("revoked"), now), ("revoked", 0));
1085 assert_eq!(state(0, Some("2026-10-01T00:00:00Z"), Some("expired"), now), ("expired", 0));
1086 }
1087
1088 #[test]
1089 fn a_credit_needs_a_kind_a_note_and_a_sensible_amount() {
1090 let now = parse_rfc3339("2026-10-07T12:00:00Z").unwrap();
1091 let ok = AdminCreditArgs {
1092 workspace: "acme".into(),
1093 amount_micros: 25_000_000,
1094 note: "Welcome to g1t".into(),
1095 by: "chase@g1t.sh".into(),
1096 kind: CreditKind::Promotional,
1097 expires_at: Some("2027-01-05T23:59:59Z".into()),
1098 refund_for: None,
1099 refund_day: None,
1100 };
1101 assert_eq!(invalid(&ok, now), None);
1102 assert!(invalid(&AdminCreditArgs { note: " ".into(), ..clone(&ok) }, now).is_some());
1103 assert!(invalid(&AdminCreditArgs { amount_micros: 0, ..clone(&ok) }, now).is_some());
1104 assert!(invalid(&AdminCreditArgs { amount_micros: MAX_CREDIT_MICROS + 1, ..clone(&ok) }, now).is_some());
1105 assert_eq!(invalid(&AdminCreditArgs { amount_micros: MAX_CREDIT_MICROS, ..clone(&ok) }, now), None);
1106 assert!(invalid(&AdminCreditArgs { expires_at: Some("2026-10-01T00:00:00Z".into()), ..clone(&ok) }, now).unwrap().contains("future"));
1107 assert!(invalid(&AdminCreditArgs { expires_at: Some("2040-01-01T00:00:00Z".into()), ..clone(&ok) }, now).is_some());
1108 // A refund says what for, never expires, and refunds a day that was.
1109 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) };
1110 assert_eq!(invalid(&refund, now), None);
1111 assert!(invalid(&AdminCreditArgs { expires_at: Some("2027-01-05T23:59:59Z".into()), ..clone(&refund) }, now).unwrap().contains("never expires"));
1112 assert!(invalid(&AdminCreditArgs { refund_for: None, ..clone(&refund) }, now).is_some());
1113 assert!(invalid(&AdminCreditArgs { refund_day: Some("2026-10-09".into()), ..clone(&refund) }, now).is_some());
1114 assert!(invalid(&AdminCreditArgs { refund_day: Some("2026-02-30".into()), ..clone(&refund) }, now).is_some());
1115 }
1116
1117 fn clone(a: &AdminCreditArgs) -> AdminCreditArgs {
1118 AdminCreditArgs {
1119 workspace: a.workspace.clone(),
1120 amount_micros: a.amount_micros,
1121 note: a.note.clone(),
1122 by: a.by.clone(),
1123 kind: a.kind,
1124 expires_at: a.expires_at.clone(),
1125 refund_for: a.refund_for.clone(),
1126 refund_day: a.refund_day.clone(),
1127 }
1128 }
1129
1130 #[test]
1131 fn a_refund_takes_money_off_the_day_it_refunds_within_the_window() {
1132 assert_eq!(refund_cash_day(Some("2026-10-02"), "2026-10-07T12:00:00Z"), "2026-10-02");
1133 // Further back than the reconciliation recomputes: its oldest day.
1134 assert_eq!(refund_cash_day(Some("2026-08-01"), "2026-10-07T12:00:00Z"), "2026-09-07");
1135 // None, or a day after it was given: the day it was given.
1136 assert_eq!(refund_cash_day(None, "2026-10-07T12:00:00Z"), "2026-10-07");
1137 assert_eq!(refund_cash_day(Some("2026-10-09"), "2026-10-07T12:00:00Z"), "2026-10-07");
1138 }
1139
1140 #[test]
1141 fn usage_is_keyed_as_the_reconciliation_keys_it() {
1142 assert_eq!(usage_key(Some("deployments"), "deploy/abc"), "builds");
1143 assert_eq!(usage_key(Some("deployments"), "requests/2026-10"), "deployments");
1144 assert_eq!(usage_key(Some("implement"), "run_1"), "implement");
1145 // An agent's planning run, never the plan's payments.
1146 assert_eq!(usage_key(Some("plan"), "run_2"), "planning");
1147 assert_eq!(usage_key(None, "crd_a"), "other");
1148 }
1149
1150 #[test]
1151 fn the_statement_and_the_email_say_what_the_credit_is() {
1152 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");
1153 assert_eq!(
1154 describe_grant(CreditKind::Refund, "Sorry about that", Some("the failed runs on Oct 2"), None),
1155 "Credit from g1t (refund for the failed runs on Oct 2): Sorry about that"
1156 );
1157 let (subject, intro) = credit_notice("acme", CreditKind::Promotional, 25_000_000, "Welcome to g1t", None, Some("2027-01-05T23:59:59Z"));
1158 assert_eq!(subject, "g1t: $25.00 of credit for acme");
1159 assert!(intro.starts_with("g1t added $25.00 of credit to acme. Welcome to g1t. It pays for usage"));
1160 assert!(intro.ends_with("Unused credit expires on 2027-01-05."));
1161 let (subject, intro) = credit_notice("acme", CreditKind::Refund, 12_000_000, "Sorry.", Some("the outage on Oct 2"), None);
1162 assert_eq!(subject, "g1t: a $12.00 refund for acme, as credit");
1163 assert!(intro.starts_with("g1t refunded $12.00 to acme as credit, for the outage on Oct 2. Sorry. It pays"));
1164 }
1165
1166 #[test]
1167 fn months_add_up_given_used_and_taken_back_by_kind() {
1168 let promo = GrantRow {
1169 id: "crd_a".into(),
1170 workspace: "acme".into(),
1171 kind: "promotional".into(),
1172 amount_micros: 25_000_000,
1173 created_at: "2026-09-20T00:00:00Z".into(),
1174 closed_at: Some("2026-10-20T00:00:00Z".into()),
1175 closed_reason: Some("expired".into()),
1176 closed_micros: 5_000_000,
1177 ..GrantRow::default()
1178 };
1179 let draws = [
1180 Draw { grant: "crd_a".into(), kind: CreditKind::Promotional, reference: "r1".into(), task: None, at: "2026-09-25T00:00:00Z".into(), micros: 15_000_000 },
1181 Draw { grant: "crd_a".into(), kind: CreditKind::Promotional, reference: "r2".into(), task: None, at: "2026-10-02T00:00:00Z".into(), micros: 5_000_000 },
1182 ];
1183 let months = fold_months(&[promo], &draws);
1184 assert_eq!(months.len(), 2);
1185 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));
1186 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));
1187 }
1188}