flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/billing/src/credits.rs

645 lines26,658 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1//! What pays for usage before the workspace does.
2//!
3//! Every charge is worked out the same way: its cost plus the margin, then
4//! the account's terms. What is left is drawn down, in this order, from:
5//!
6//! 1. **The Team plan's credit** (`TEAM_INCLUDED_MICROS` a month), when
7//! the workspace has Team. Any usage draws on it. Unused credit does not
8//! roll over.
9//! 2. **The trial credit**: one grant per workspace
10//! (`TRIAL_WORKSPACE_MICROS`), made the first time it uses something,
11//! out of a pool for everyone that resets each calendar month
12//! (`TRIAL_MONTHLY_POOL_MICROS`). Never for deployments, which are never
13//! free.
14//! 3. **g1t's open-source pool** (`OSS_POOL_MICROS` a month, at most
15//! `OSS_REPO_MICROS` for any one repository): only sandbox time and
16//! model cost for work on a public repository.
17//!
18//! Whatever is left is charged. Each source is a fixed, capped budget that
19//! something pays for: the plan, or g1t. Nothing here is an open-ended
20//! allowance per workspace.
21//!
22//! Months are calendar months in UTC, the same as the limits'. Every draw
23//! is one D1 batch, which runs as a transaction, so two charges at once
24//! never take more than a budget holds.
25
26use g1t_contracts::billing::{Feature, MICROS_PER_DOLLAR, Pools, TermsKind, Trial, TrialArgs};
27use g1t_contracts::time::rfc3339;
28use g1t_kit::now_ms;
29use serde::Deserialize;
30use worker::{Env, Result};
31
32use crate::Billing;
33use crate::features::dollars;
34
35/// Every number of the plans and pools, from the billing service's
36/// variables, each with its default.
37#[derive(Clone, Debug)]
38pub(crate) struct Config {
39 /// `TEAM_MONTHLY_CENTS`: the Team plan's price, per workspace.
40 pub team_monthly_cents: u32,
41 /// `TEAM_INCLUDED_MICROS`: its usage credit each month.
42 pub team_included_micros: i64,
43 /// `OSS_POOL_MICROS`: g1t's open-source pool each month, in all.
44 pub oss_pool_micros: i64,
45 /// `OSS_REPO_MICROS`: any one public repository's share of it.
46 pub oss_repo_micros: i64,
47 /// `TRIAL_WORKSPACE_MICROS`: each new workspace's trial credit.
48 pub trial_workspace_micros: i64,
49 /// `TRIAL_MONTHLY_POOL_MICROS`: trial grants each month, in all.
50 pub trial_monthly_pool_micros: i64,
51 /// `MIN_CHARGE_MICROS`: no card is charged less; smaller amounts carry
52 /// over to the next invoice.
53 pub min_charge_micros: i64,
54 /// `DEPLOYMENTS_BUILD_SECONDS`: build time the Deployments plan
55 /// includes each month.
56 pub build_seconds: u32,
57 /// `FREE_PRIVATE_STORAGE_BYTES` and `TEAM_PRIVATE_STORAGE_BYTES`:
58 /// private repository storage before it is charged.
59 pub free_storage_bytes: i64,
60 pub team_storage_bytes: i64,
61 /// `AUDIT_RETENTION_DAYS` and `TEAM_AUDIT_RETENTION_DAYS`.
62 pub audit_days: u32,
63 pub team_audit_days: u32,
64}
65
66impl Default for Config {
67 fn default() -> Self {
68 Config {
69 team_monthly_cents: 2_000,
70 team_included_micros: 5_000_000,
71 oss_pool_micros: 10_000_000,
72 oss_repo_micros: 1_000_000,
73 trial_workspace_micros: 1_000_000,
74 trial_monthly_pool_micros: 40_000_000,
75 min_charge_micros: 5_000_000,
76 build_seconds: g1t_contracts::billing::deployments_allowance::BUILD_SECONDS,
77 free_storage_bytes: 1_000_000_000,
78 team_storage_bytes: 50_000_000_000,
79 audit_days: 30,
80 team_audit_days: 365,
81 }
82 }
83}
84
85impl Config {
86 pub(crate) fn from_env(env: &Env) -> Self {
87 let d = Config::default();
88 let number = |name: &str, default: i64| -> i64 {
89 env.var(name).ok().and_then(|v| v.to_string().trim().parse::<i64>().ok()).filter(|n| *n >= 0).unwrap_or(default)
90 };
91 Config {
92 team_monthly_cents: number("TEAM_MONTHLY_CENTS", d.team_monthly_cents.into()) as u32,
93 team_included_micros: number("TEAM_INCLUDED_MICROS", d.team_included_micros),
94 oss_pool_micros: number("OSS_POOL_MICROS", d.oss_pool_micros),
95 oss_repo_micros: number("OSS_REPO_MICROS", d.oss_repo_micros),
96 trial_workspace_micros: number("TRIAL_WORKSPACE_MICROS", d.trial_workspace_micros),
97 trial_monthly_pool_micros: number("TRIAL_MONTHLY_POOL_MICROS", d.trial_monthly_pool_micros),
98 min_charge_micros: number("MIN_CHARGE_MICROS", d.min_charge_micros),
99 build_seconds: number("DEPLOYMENTS_BUILD_SECONDS", d.build_seconds.into()) as u32,
100 free_storage_bytes: number("FREE_PRIVATE_STORAGE_BYTES", d.free_storage_bytes),
101 team_storage_bytes: number("TEAM_PRIVATE_STORAGE_BYTES", d.team_storage_bytes),
102 audit_days: number("AUDIT_RETENTION_DAYS", d.audit_days.into()) as u32,
103 team_audit_days: number("TEAM_AUDIT_RETENTION_DAYS", d.team_audit_days.into()) as u32,
104 }
105 }
106}
107
108/// What may pay for a charge besides the Team credit, which any usage may
109/// draw on.
110#[derive(Clone, Debug, Default)]
111pub(crate) struct Eligible {
112 /// The trial credit: everything but deployments.
113 pub trial: bool,
114 /// The open-source pool: sandbox time and model cost for work on this
115 /// repository (`owner/name`), if it is public.
116 pub repo: Option<String>,
117}
118
119/// What paid for a charge before the workspace did.
120#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
121pub(crate) struct Drawn {
122 pub credit: i64,
123 pub trial: i64,
124 pub oss: i64,
125}
126
127impl Drawn {
128 pub fn total(&self) -> i64 {
129 self.credit + self.trial + self.oss
130 }
131
132 /// For the statement: what paid for the entry, e.g. ` ($0.12 paid by
133 /// g1t's open-source pool)`. Empty when nothing did.
134 pub fn note(&self) -> String {
135 let parts: Vec<String> = [
136 (self.credit, "your Team plan's credit"),
137 (self.trial, "your trial credit"),
138 (self.oss, "g1t's open-source pool"),
139 ]
140 .iter()
141 .filter(|(micros, _)| *micros > 0)
142 .map(|(micros, by)| format!("{} paid by {by}", dollars(*micros)))
143 .collect();
144 if parts.is_empty() { String::new() } else { format!(" ({})", parts.join(", ")) }
145 }
146}
147
148/// How `gross` is paid for from sources with `available` left each, in
149/// order: each takes what it can of what is still unpaid. The rest is
150/// charged.
151pub(crate) fn split(gross: i64, available: &[i64]) -> Vec<i64> {
152 let mut left = gross.max(0);
153 available
154 .iter()
155 .map(|available| {
156 let take = left.min((*available).max(0));
157 left -= take;
158 take
159 })
160 .collect()
161}
162
163/// What a budget with `cap` and `used` so far has left.
164pub(crate) fn left(cap: i64, used: i64) -> i64 {
165 (cap - used).max(0)
166}
167
168/// `YYYY-MM` of an RFC 3339 time.
169pub(crate) fn month_of(timestamp: &str) -> String {
170 timestamp[..7].to_owned()
171}
172
173/// The first instant of the month after `month`: when this month's pools
174/// reset.
175pub(crate) fn next_month_start(month: &str) -> String {
176 let year: i32 = month[..4].parse().unwrap_or(1970);
177 let number: u32 = month[5..7].parse().unwrap_or(1);
178 if number == 12 {
179 format!("{}-01-01T00:00:00Z", year + 1)
180 } else {
181 format!("{year}-{:02}-01T00:00:00Z", number + 1)
182 }
183}
184
185/// The last second of `month`, for a charge that belongs to a month that
186/// is over.
187pub(crate) fn month_end(month: &str) -> String {
188 let year: i32 = month[..4].parse().unwrap_or(1970);
189 let number: u32 = month[5..7].parse().unwrap_or(1);
190 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
191 let days = match number {
192 2 if leap => 29,
193 2 => 28,
194 4 | 6 | 9 | 11 => 30,
195 _ => 31,
196 };
197 format!("{month}-{days:02}T23:59:59Z")
198}
199
200/// What a trial grant would be: the account's own amount from sudo, or the
201/// default.
202pub(crate) fn grant_size(config: &Config, staff: Option<i64>) -> i64 {
203 staff.unwrap_or(config.trial_workspace_micros).max(0)
204}
205
206/// Whether this month's pool can still make a grant of `amount`.
207pub(crate) fn pool_has_room(pool: i64, granted_this_month: i64, amount: i64) -> bool {
208 amount > 0 && granted_this_month + amount <= pool
209}
210
211#[derive(Deserialize)]
212struct Used {
213 used: Option<i64>,
214}
215
216#[derive(Deserialize)]
217pub(crate) struct Grant {
218 pub granted_micros: i64,
219 pub used_micros: i64,
220}
221
222impl Billing {
223 /// Whether the workspace has the Team plan now: paid for, comped, or
224 /// given by g1t in sudo.
225 pub(crate) async fn team_on(&self, workspace: &str) -> Result<bool> {
226 let account = self.account_of(workspace).await?;
227 if account.terms.kind == TermsKind::Comped || account.allowances.team {
228 return Ok(true);
229 }
230 if self.stripe.is_none() {
231 return Ok(false);
232 }
233 self.plan_on(workspace, Feature::Team).await
234 }
235
236 /// What one monthly allowance has used.
237 pub(crate) async fn allowance_used(&self, kind: &str, scope: &str, month: &str) -> Result<i64> {
238 Ok(self
239 .db
240 .prepare("SELECT used FROM allowance_use WHERE kind = ? AND scope = ? AND month = ?")
241 .bind(&[kind.into(), scope.into(), month.into()])?
242 .first::<Used>(None)
243 .await?
244 .and_then(|u| u.used)
245 .unwrap_or(0))
246 }
247
248 /// Takes up to `want` from a monthly allowance with `cap`, as one
249 /// transaction. Returns what it took.
250 pub(crate) async fn draw_allowance(&self, kind: &str, scope: &str, month: &str, want: i64, cap: i64) -> Result<i64> {
251 if want <= 0 || cap <= 0 {
252 return Ok(0);
253 }
254 let key = [kind.into(), scope.into(), month.into()];
255 let results = self
256 .db
257 .batch(vec![
258 self.db
259 .prepare("INSERT OR IGNORE INTO allowance_use (kind, scope, month, used) VALUES (?1, ?2, ?3, 0)")
260 .bind(&key)?,
261 self.db
262 .prepare("SELECT used FROM allowance_use WHERE kind = ?1 AND scope = ?2 AND month = ?3")
263 .bind(&key)?,
264 self.db
265 .prepare(
266 "UPDATE allowance_use SET used = MIN(?4, used + ?5)
267 WHERE kind = ?1 AND scope = ?2 AND month = ?3 AND used < ?4",
268 )
269 .bind(&[kind.into(), scope.into(), month.into(), (cap as f64).into(), (want as f64).into()])?,
270 self.db
271 .prepare("SELECT used FROM allowance_use WHERE kind = ?1 AND scope = ?2 AND month = ?3")
272 .bind(&key)?,
273 ])
274 .await?;
275 let read = |i: usize| -> Result<i64> {
276 Ok(results[i].results::<Used>()?.first().and_then(|u| u.used).unwrap_or(0))
277 };
278 Ok((read(3)? - read(1)?).max(0))
279 }
280
281 /// Gives back what was drawn and not used.
282 async fn return_allowance(&self, kind: &str, scope: &str, month: &str, amount: i64) -> Result<()> {
283 if amount > 0 {
284 self.db
285 .prepare("UPDATE allowance_use SET used = MAX(0, used - ?4) WHERE kind = ?1 AND scope = ?2 AND month = ?3")
286 .bind(&[kind.into(), scope.into(), month.into(), (amount as f64).into()])?
287 .run()
288 .await?;
289 }
290 Ok(())
291 }
292
293 // --- Trials -----------------------------------------------------------
294
295 pub(crate) async fn grant_of(&self, workspace: &str) -> Result<Option<Grant>> {
296 self.db
297 .prepare("SELECT granted_micros, used_micros FROM trial_grants WHERE workspace = ?")
298 .bind(&[workspace.into()])?
299 .first::<Grant>(None)
300 .await
301 }
302
303 /// Trial grants made this month, in all.
304 pub(crate) async fn trial_granted(&self, month: &str) -> Result<(i64, u32)> {
305 #[derive(Deserialize)]
306 struct Row {
307 micros: Option<i64>,
308 n: Option<u32>,
309 }
310 let row = self
311 .db
312 .prepare("SELECT SUM(granted_micros) AS micros, COUNT(*) AS n FROM trial_grants WHERE month = ?")
313 .bind(&[month.into()])?
314 .first::<Row>(None)
315 .await?;
316 Ok(row.map_or((0, 0), |r| (r.micros.unwrap_or(0), r.n.unwrap_or(0))))
317 }
318
319 /// The workspace's grant, made now out of this month's pool if it has
320 /// none and the pool has room. A grant g1t staff set comes from no pool.
321 async fn ensure_grant(&self, workspace: &str) -> Result<Option<Grant>> {
322 if let Some(grant) = self.grant_of(workspace).await? {
323 return Ok(Some(grant));
324 }
325 if !self.trials_on {
326 return Ok(None);
327 }
328 let staff = self.account_of(workspace).await?.allowances.trial_micros;
329 let amount = grant_size(&self.plans, staff);
330 if amount <= 0 {
331 return Ok(None);
332 }
333 let now = rfc3339(now_ms());
334 let month = if staff.is_some() { "staff".to_owned() } else { month_of(&now) };
335 // One statement: the pool is checked and the grant made together.
336 self.db
337 .prepare(
338 "INSERT INTO trial_grants (workspace, month, granted_micros, used_micros, created_at)
339 SELECT ?1, ?2, ?3, 0, ?4
340 WHERE ?2 = 'staff'
341 OR (SELECT COALESCE(SUM(granted_micros), 0) FROM trial_grants WHERE month = ?2) + ?3 <= ?5
342 ON CONFLICT (workspace) DO NOTHING",
343 )
344 .bind(&[
345 workspace.into(),
346 month.as_str().into(),
347 (amount as f64).into(),
348 now.as_str().into(),
349 (self.plans.trial_monthly_pool_micros as f64).into(),
350 ])?
351 .run()
352 .await?;
353 self.grant_of(workspace).await
354 }
355
356 /// Takes up to `want` from the workspace's trial credit.
357 async fn draw_trial(&self, workspace: &str, want: i64) -> Result<i64> {
358 if want <= 0 || self.ensure_grant(workspace).await?.is_none() {
359 return Ok(0);
360 }
361 #[derive(Deserialize)]
362 struct Row {
363 used_micros: i64,
364 }
365 let results = self
366 .db
367 .batch(vec![
368 self.db.prepare("SELECT used_micros FROM trial_grants WHERE workspace = ?1").bind(&[workspace.into()])?,
369 self.db
370 .prepare(
371 "UPDATE trial_grants SET used_micros = MIN(granted_micros, used_micros + ?2)
372 WHERE workspace = ?1 AND used_micros < granted_micros",
373 )
374 .bind(&[workspace.into(), (want as f64).into()])?,
375 self.db.prepare("SELECT used_micros FROM trial_grants WHERE workspace = ?1").bind(&[workspace.into()])?,
376 ])
377 .await?;
378 let read = |i: usize| -> Result<i64> { Ok(results[i].results::<Row>()?.first().map_or(0, |r| r.used_micros)) };
379 Ok((read(2)? - read(0)?).max(0))
380 }
381
382 /// `trial`: where the workspace's trial credit stands.
383 pub(crate) async fn trial(&self, a: TrialArgs) -> Result<Trial> {
384 let workspace = a.workspace.to_lowercase();
385 let closed = |reason: &str| Trial {
386 open: false,
387 used_micros: 0,
388 limit_micros: 0,
389 ends_at: None,
390 reason: Some(reason.to_owned()),
391 granted: false,
392 waits_until: None,
393 };
394 if let Some(grant) = self.grant_of(&workspace).await? {
395 let open = grant.used_micros < grant.granted_micros;
396 return Ok(Trial {
397 open,
398 used_micros: grant.used_micros,
399 limit_micros: grant.granted_micros,
400 ends_at: None,
401 reason: (!open).then(|| "used".to_owned()),
402 granted: true,
403 waits_until: None,
404 });
405 }
406 if !self.trials_on {
407 return Ok(closed("off"));
408 }
409 let staff = self.account_of(&workspace).await?.allowances.trial_micros;
410 let amount = grant_size(&self.plans, staff);
411 if amount <= 0 {
412 return Ok(closed("off"));
413 }
414 let month = month_of(&rfc3339(now_ms()));
415 let (granted, _) = self.trial_granted(&month).await?;
416 let room = staff.is_some() || pool_has_room(self.plans.trial_monthly_pool_micros, granted, amount);
417 Ok(Trial {
418 open: room,
419 used_micros: 0,
420 limit_micros: amount,
421 ends_at: None,
422 reason: (!room).then(|| "pool".to_owned()),
423 granted: false,
424 waits_until: (!room).then(|| next_month_start(&month)),
425 })
426 }
427
428 // --- The open-source pool ---------------------------------------------
429
430 /// Whether `repo` (`owner/name`) is public, asked of the repos service.
431 /// Unknown counts as private: the pool pays only for what is known to
432 /// be open.
433 async fn is_public(&self, repo: &str) -> bool {
434 let Some(repos) = &self.repos else { return false };
435 let found: Result<Vec<g1t_contracts::repos::RepoVisibility>> = g1t_kit::call(
436 repos,
437 "visibility",
438 &g1t_contracts::repos::VisibilityArgs { paths: vec![repo.to_owned()] },
439 )
440 .await;
441 match found {
442 Ok(list) => list.iter().any(|v| v.path.eq_ignore_ascii_case(repo) && !v.is_private),
443 Err(error) => {
444 worker::console_error!("could not ask whether {repo} is public: {error}");
445 false
446 }
447 }
448 }
449
450 /// A public repository's monthly cap on the pool: its account's own
451 /// from sudo, or `OSS_REPO_MICROS`.
452 async fn oss_repo_cap(&self, workspace: &str) -> Result<i64> {
453 Ok(self.account_of(workspace).await?.allowances.oss_repo_micros.unwrap_or(self.plans.oss_repo_micros))
454 }
455
456 /// Takes up to `want` from the open-source pool for `repo`, within the
457 /// pool's cap and the repository's.
458 async fn draw_oss(&self, workspace: &str, repo: &str, month: &str, want: i64) -> Result<i64> {
459 let repo = repo.to_lowercase();
460 let cap = self.oss_repo_cap(workspace).await?;
461 let room = left(cap, self.allowance_used("oss_repo", &repo, month).await?);
462 let from_pool = self.draw_allowance("oss_pool", "", month, want.min(room), self.plans.oss_pool_micros).await?;
463 let for_repo = self.draw_allowance("oss_repo", &repo, month, from_pool, cap).await?;
464 // The repository's cap filled up meanwhile: give the pool back the rest.
465 self.return_allowance("oss_pool", "", month, from_pool - for_repo).await?;
466 Ok(for_repo)
467 }
468
469 // --- Drawing down -----------------------------------------------------
470
471 /// Pays for a `gross` charge from the Team credit, the trial credit and
472 /// the open-source pool, in that order, for usage in `month`. Returns
473 /// what each paid; the rest is the workspace's to pay.
474 pub(crate) async fn draw(&self, workspace: &str, gross: i64, month: &str, eligible: &Eligible) -> Result<Drawn> {
475 if gross <= 0 {
476 return Ok(Drawn::default());
477 }
478 let team = self.team_on(workspace).await?;
479 let credit_left = if team {
480 left(self.plans.team_included_micros, self.allowance_used("team_credit", workspace, month).await?)
481 } else {
482 0
483 };
484 let trial_left = if eligible.trial {
485 match self.grant_of(workspace).await? {
486 Some(grant) => left(grant.granted_micros, grant.used_micros),
487 // Granted on first use, if the pool has room.
488 None => match self.trial(TrialArgs { workspace: workspace.to_owned(), exempt: vec![] }).await? {
489 trial if trial.open => trial.limit_micros,
490 _ => 0,
491 },
492 }
493 } else {
494 0
495 };
496 // Asked only when the rest has not paid for it all.
497 let public_repo = match &eligible.repo {
498 Some(repo) if gross > credit_left + trial_left && self.is_public(repo).await => Some(repo.clone()),
499 _ => None,
500 };
501 let oss_left = match &public_repo {
502 Some(repo) => left(self.plans.oss_pool_micros, self.allowance_used("oss_pool", "", month).await?)
503 .min(left(self.oss_repo_cap(workspace).await?, self.allowance_used("oss_repo", &repo.to_lowercase(), month).await?)),
504 None => 0,
505 };
506 let planned = split(gross, &[credit_left, trial_left, oss_left]);
507 let mut drawn = Drawn::default();
508 drawn.credit = self.draw_allowance("team_credit", workspace, month, planned[0], self.plans.team_included_micros).await?;
509 drawn.trial = self.draw_trial(workspace, planned[1]).await?;
510 if let Some(repo) = &public_repo {
511 drawn.oss = self.draw_oss(workspace, repo, month, planned[2]).await?;
512 }
513 Ok(drawn)
514 }
515
516 /// Writes down on a usage entry what paid for it.
517 pub(crate) async fn record_drawn(&self, reference: &str, drawn: &Drawn) -> Result<()> {
518 if drawn.total() == 0 {
519 return Ok(());
520 }
521 self.db
522 .prepare("UPDATE ledger SET credit_micros = ?, trial_micros = ?, oss_micros = ? WHERE reference = ?")
523 .bind(&[
524 (drawn.credit as f64).into(),
525 (drawn.trial as f64).into(),
526 (drawn.oss as f64).into(),
527 reference.into(),
528 ])?
529 .run()
530 .await?;
531 Ok(())
532 }
533
534 /// g1t's pools this month, for sudo.
535 pub(crate) async fn pools(&self) -> Result<Pools> {
536 let month = month_of(&rfc3339(now_ms()));
537 let (granted, grants) = self.trial_granted(&month).await?;
538 Ok(Pools {
539 oss_used_micros: self.allowance_used("oss_pool", "", &month).await?,
540 oss_pool_micros: self.plans.oss_pool_micros,
541 oss_repo_micros: self.plans.oss_repo_micros,
542 trial_granted_micros: granted,
543 trial_pool_micros: self.plans.trial_monthly_pool_micros,
544 trial_grants: grants,
545 month,
546 })
547 }
548}
549
550/// A charge in millionths of a dollar for `micros` of cost plus `margin`.
551pub(crate) fn with_margin(cost_micros: i64, margin_percent: u32) -> i64 {
552 crate::charge_micros(cost_micros.max(0) as f64 / MICROS_PER_DOLLAR as f64, margin_percent)
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 #[test]
560 fn team_credit_pays_first_then_the_trial_then_the_pool_then_the_workspace() {
561 // $0.50 of usage; $0.20 of Team credit, $1 of trial, $1 of pool.
562 assert_eq!(split(500_000, &[200_000, 1_000_000, 1_000_000]), [200_000, 300_000, 0]);
563 // No Team: the trial pays all of it.
564 assert_eq!(split(500_000, &[0, 1_000_000, 1_000_000]), [0, 500_000, 0]);
565 // Trial spent: the pool pays, where it applies.
566 assert_eq!(split(500_000, &[0, 0, 1_000_000]), [0, 0, 500_000]);
567 // Everything spent: the workspace pays all of it.
568 let planned = split(500_000, &[0, 0, 0]);
569 assert_eq!(planned, [0, 0, 0]);
570 assert_eq!(500_000 - planned.iter().sum::<i64>(), 500_000);
571 // Each pays what it can, and the rest is charged.
572 let planned = split(500_000, &[100_000, 150_000, 50_000]);
573 assert_eq!(planned, [100_000, 150_000, 50_000]);
574 assert_eq!(500_000 - planned.iter().sum::<i64>(), 200_000);
575 // Nothing is drawn for nothing, nor from a negative balance.
576 assert_eq!(split(0, &[1, 1, 1]), [0, 0, 0]);
577 assert_eq!(split(100, &[-5, 50, 100]), [0, 50, 50]);
578 }
579
580 #[test]
581 fn a_budget_never_gives_more_than_its_cap() {
582 assert_eq!(left(1_000_000, 400_000), 600_000);
583 assert_eq!(left(1_000_000, 1_000_000), 0);
584 assert_eq!(left(1_000_000, 1_200_000), 0);
585 // The open-source pool: the repository's share and the pool's both bound it.
586 let pool = left(10_000_000, 9_900_000);
587 let repo = left(1_000_000, 300_000);
588 assert_eq!(split(800_000, &[pool.min(repo)]), [100_000]);
589 }
590
591 #[test]
592 fn pools_reset_each_calendar_month() {
593 assert_eq!(month_of("2026-10-31T23:59:59Z"), "2026-10");
594 assert_eq!(month_of("2026-11-01T00:00:00Z"), "2026-11");
595 assert_eq!(next_month_start("2026-10"), "2026-11-01T00:00:00Z");
596 assert_eq!(next_month_start("2026-12"), "2027-01-01T00:00:00Z");
597 // A pool given out this month has room again next month, since
598 // grants count only against the month they were made in.
599 assert!(!pool_has_room(40_000_000, 40_000_000, 1_000_000));
600 assert!(!pool_has_room(40_000_000, 39_500_000, 1_000_000));
601 assert!(pool_has_room(40_000_000, 0, 1_000_000));
602 assert!(pool_has_room(40_000_000, 39_000_000, 1_000_000));
603 assert!(!pool_has_room(40_000_000, 0, 0));
604 }
605
606 #[test]
607 fn a_trial_grant_is_the_default_unless_staff_set_one() {
608 let config = Config::default();
609 assert_eq!(grant_size(&config, None), 1_000_000);
610 assert_eq!(grant_size(&config, Some(5_000_000)), 5_000_000);
611 assert_eq!(grant_size(&config, Some(-1)), 0);
612 }
613
614 #[test]
615 fn a_month_ends_on_its_last_day() {
616 assert_eq!(month_end("2026-10"), "2026-10-31T23:59:59Z");
617 assert_eq!(month_end("2026-09"), "2026-09-30T23:59:59Z");
618 assert_eq!(month_end("2028-02"), "2028-02-29T23:59:59Z");
619 assert_eq!(month_end("2027-02"), "2027-02-28T23:59:59Z");
620 }
621
622 #[test]
623 fn what_paid_is_said_on_the_statement() {
624 assert_eq!(Drawn::default().note(), "");
625 let drawn = Drawn { credit: 0, trial: 0, oss: 120_000 };
626 assert_eq!(drawn.note(), " ($0.12 paid by g1t's open-source pool)");
627 let drawn = Drawn { credit: 50_000, trial: 20_000, oss: 0 };
628 assert_eq!(drawn.note(), " ($0.05 paid by your Team plan's credit, $0.02 paid by your trial credit)");
629 assert_eq!(drawn.total(), 70_000);
630 }
631
632 #[test]
633 fn the_defaults_are_the_published_ones() {
634 let c = Config::default();
635 assert_eq!(c.team_monthly_cents, 2_000);
636 assert_eq!(c.team_included_micros, 5_000_000);
637 assert_eq!(c.oss_pool_micros, 10_000_000);
638 assert_eq!(c.oss_repo_micros, 1_000_000);
639 assert_eq!(c.trial_monthly_pool_micros, 40_000_000);
640 assert_eq!(c.min_charge_micros, 5_000_000);
641 assert_eq!(c.build_seconds, 12_000);
642 assert_eq!(c.free_storage_bytes, 1_000_000_000);
643 assert_eq!(c.team_storage_bytes, 50_000_000_000);
644 }
645}