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