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