Skip to content

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

713 linesCodeBlameRaw
1//! 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 plan's included usage** (`PLAN_INCLUDED_MICROS` a month, $10),
7//! when the workspace has the g1t plan. Any usage draws on it. Unused
8//! included usage does not roll over.
9//! 2. **The trial credit**: one grant per workspace
10//! (`TRIAL_WORKSPACE_MICROS`, $5), made once its card is checked (see
11//! `cards`), out of a pool for everyone that resets each calendar month
12//! (`TRIAL_MONTHLY_POOL_MICROS`, $100). Never for deployments.
13//! 3. **g1t's open-source pool** (`OSS_POOL_MICROS` a month, $25, at most
14//! `OSS_REPO_MICROS`, $2, for any one repository): checks, workflows and
15//! the merge queue on a public repository.
16//!
17//! Whatever is left is charged: from what was paid in advance first, since
18//! a charge comes off the balance, and then owed. For a free workspace's
19//! compute, what is left past its trial is covered by g1t (`given`): a free
20//! workspace is never charged for compute, and `reserve` keeps that to the
21//! runs already in flight when the trial ran out.
22//!
23//! Each source is a fixed, capped budget that something pays for: the
24//! plan, or g1t. Nothing here is an open-ended allowance per workspace.
25//!
26//! Months are calendar months in UTC, the same as the limits'. Every draw
27//! is one D1 batch, which runs as a transaction, so two charges at once
28//! never take more than a budget holds.
29
30use g1t_contracts::billing::{BillingAccount, ComputeKind, Feature, PlanKind, Pools, Trial, TrialArgs};
31use g1t_contracts::time::rfc3339;
32use g1t_kit::now_ms;
33use serde::Deserialize;
34use worker::{Env, Result};
35
36use crate::Billing;
37use crate::features::dollars;
38
39/// Every number of the plan and the pools, from the billing service's
40/// variables, each with its default.
41#[derive(Clone, Debug)]
42pub(crate) struct Config {
43 /// `PLAN_MONTHLY_CENTS`: the plan's price, per workspace: $21, all in
44 /// before tax (Stripe's card fee is part of it, never a line of its own).
45 pub plan_monthly_cents: u32,
46 /// `PLAN_INCLUDED_MICROS`: its included usage each month: $10.
47 pub plan_included_micros: i64,
48 /// `OSS_POOL_MICROS`: g1t's open-source pool each month, in all.
49 pub oss_pool_micros: i64,
50 /// `OSS_REPO_MICROS`: any one public repository's share of it.
51 pub oss_repo_micros: i64,
52 /// `TRIAL_WORKSPACE_MICROS`: each new workspace's trial credit.
53 pub trial_workspace_micros: i64,
54 /// `TRIAL_MONTHLY_POOL_MICROS`: trial grants each month, in all.
55 pub trial_monthly_pool_micros: i64,
56 /// `MIN_CHARGE_MICROS`: a month's close charges no less; smaller
57 /// amounts carry over. Charges at a limit always go through.
58 pub min_charge_micros: i64,
59 /// `FREE_PRIVATE_STORAGE_BYTES`: private repository storage that is
60 /// free for every workspace. Past it, the plan pays at cost plus the
61 /// margin; a free workspace's pushes to private repositories stop.
62 pub free_storage_bytes: i64,
63 /// `PUBLIC_PACKAGES_FREE_BYTES` and `PRIVATE_PACKAGES_FREE_BYTES`: what
64 /// a workspace's public and private packages may hold for free (10 GB
65 /// and 500 MB). Past them a free workspace's pushes are refused (the
66 /// packages service asks); the plan pays at cost plus the margin.
67 pub public_package_free_bytes: i64,
68 pub private_package_free_bytes: i64,
69 /// `FREE_AUDIT_RETENTION_DAYS`: days of audit log a free workspace
70 /// keeps. `AUDIT_RETENTION_DAYS`: the plan's, g1t's own and an
71 /// enterprise's. `AUDIT_MAX_DAYS`: the most staff can set for an
72 /// account; the events service deletes everything older regardless.
73 pub free_audit_days: u32,
74 pub audit_days: u32,
75 pub audit_max_days: u32,
76 /// `LIMIT_PAID_START_MICROS`: a new paid workspace's ceiling in its
77 /// first month.
78 pub paid_start_micros: i64,
79 /// `SPIKE_FACTOR` and `SPIKE_FLOOR_MICROS`: an hour above this many
80 /// times the usual hour, and at least this much, is a spike.
81 pub spike_factor: i64,
82 pub spike_floor_micros: i64,
83 /// `OVERAGE_FORGIVE_COST_MICROS`: the most of an overage's real cost a
84 /// one-click goodwill credit covers.
85 pub forgive_cost_micros: i64,
86 /// `GIT_OPERATIONS_INCLUDED`: git operations a month that are free for
87 /// every workspace. Past it, the plan pays at cost plus the margin and
88 /// is never slowed; a free workspace is slowed down (the repos
89 /// service's `GIT_OPERATIONS_FREE_CAP`, the same number), never charged.
90 pub git_included: u64,
91}
92
93impl Default for Config {
94 fn default() -> Self {
95 Config {
96 plan_monthly_cents: 2_100,
97 plan_included_micros: 10_000_000,
98 oss_pool_micros: 25_000_000,
99 oss_repo_micros: 2_000_000,
100 trial_workspace_micros: 5_000_000,
101 trial_monthly_pool_micros: 100_000_000,
102 min_charge_micros: 5_000_000,
103 free_storage_bytes: 1_000_000_000,
104 public_package_free_bytes: 10_000_000_000,
105 private_package_free_bytes: 500_000_000,
106 free_audit_days: 7,
107 audit_days: 90,
108 audit_max_days: 400,
109 paid_start_micros: 100_000_000,
110 spike_factor: 5,
111 spike_floor_micros: 5_000_000,
112 forgive_cost_micros: 50_000_000,
113 git_included: 50_000,
114 }
115 }
116}
117
118impl Config {
119 pub(crate) fn from_env(env: &Env) -> Self {
120 let d = Config::default();
121 let number = |name: &str, default: i64| -> i64 {
122 env.var(name).ok().and_then(|v| v.to_string().trim().parse::<i64>().ok()).filter(|n| *n >= 0).unwrap_or(default)
123 };
124 Config {
125 plan_monthly_cents: number("PLAN_MONTHLY_CENTS", d.plan_monthly_cents.into()) as u32,
126 plan_included_micros: number("PLAN_INCLUDED_MICROS", d.plan_included_micros),
127 oss_pool_micros: number("OSS_POOL_MICROS", d.oss_pool_micros),
128 oss_repo_micros: number("OSS_REPO_MICROS", d.oss_repo_micros),
129 trial_workspace_micros: number("TRIAL_WORKSPACE_MICROS", d.trial_workspace_micros),
130 trial_monthly_pool_micros: number("TRIAL_MONTHLY_POOL_MICROS", d.trial_monthly_pool_micros),
131 min_charge_micros: number("MIN_CHARGE_MICROS", d.min_charge_micros),
132 free_storage_bytes: number("FREE_PRIVATE_STORAGE_BYTES", d.free_storage_bytes),
133 public_package_free_bytes: number("PUBLIC_PACKAGES_FREE_BYTES", d.public_package_free_bytes),
134 private_package_free_bytes: number("PRIVATE_PACKAGES_FREE_BYTES", d.private_package_free_bytes),
135 free_audit_days: number("FREE_AUDIT_RETENTION_DAYS", d.free_audit_days.into()).max(1) as u32,
136 audit_days: number("AUDIT_RETENTION_DAYS", d.audit_days.into()).max(1) as u32,
137 audit_max_days: number("AUDIT_MAX_DAYS", d.audit_max_days.into()).max(1) as u32,
138 paid_start_micros: number("LIMIT_PAID_START_MICROS", d.paid_start_micros),
139 spike_factor: number("SPIKE_FACTOR", d.spike_factor).max(1),
140 spike_floor_micros: number("SPIKE_FLOOR_MICROS", d.spike_floor_micros),
141 forgive_cost_micros: number("OVERAGE_FORGIVE_COST_MICROS", d.forgive_cost_micros),
142 git_included: number("GIT_OPERATIONS_INCLUDED", d.git_included as i64) as u64,
143 }
144 }
145}
146
147/// What may pay for a charge besides the plan's included usage, which any
148/// usage may draw on.
149#[derive(Clone, Debug, Default)]
150pub(crate) struct Eligible {
151 /// The trial credit: everything but deployments.
152 pub trial: bool,
153 /// The open-source pool: this repository (`owner/name`), if it is
154 /// public. Only checks, workflows and the merge queue name one.
155 pub repo: Option<String>,
156 /// g1t covers what is left, rather than charging it, when the workspace
157 /// has no plan: a free workspace's compute.
158 pub cover_rest: bool,
159}
160
161/// What paid for a charge before the workspace did.
162#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
163pub(crate) struct Drawn {
164 pub credit: i64,
165 /// What a complimentary allowance paid.
166 pub gift: i64,
167 pub trial: i64,
168 pub oss: i64,
169 /// What g1t covered itself.
170 pub given: i64,
171}
172
173impl Drawn {
174 pub fn total(&self) -> i64 {
175 self.credit + self.gift + self.trial + self.oss + self.given
176 }
177
178 /// For the statement: what paid for the entry, e.g. ` ($0.12 paid by
179 /// g1t's open-source pool)`. Empty when nothing did.
180 pub fn note(&self) -> String {
181 let parts: Vec<String> = [
182 (self.credit, "paid by your plan's included usage"),
183 (self.gift, "paid by your complimentary allowance"),
184 (self.trial, "paid by your trial credit"),
185 (self.oss, "paid by g1t's open-source pool"),
186 (self.given, "covered by g1t"),
187 ]
188 .iter()
189 .filter(|(micros, _)| *micros > 0)
190 .map(|(micros, by)| format!("{} {by}", dollars(*micros)))
191 .collect();
192 if parts.is_empty() { String::new() } else { format!(" ({})", parts.join(", ")) }
193 }
194}
195
196/// Draw from each source using what the preceding source actually gave,
197/// not a balance read before concurrent charges consumed it. On failure,
198/// return the partial allocation so the caller can return it.
199#[cfg(test)]
200async fn draw_in_order<F, Fut>(gross: i64, mut take: F) -> std::result::Result<Drawn, (worker::Error, Drawn)>
201where
202 F: FnMut(usize, i64) -> Fut,
203 Fut: std::future::Future<Output = Result<i64>>,
204{
205 let mut drawn = Drawn::default();
206 for source in 0..3 {
207 let remaining = (gross - drawn.total()).max(0);
208 if remaining == 0 {
209 break;
210 }
211 let amount = match take(source, remaining).await {
212 Ok(amount) => amount,
213 Err(error) => return Err((error, drawn)),
214 };
215 match source {
216 0 => drawn.credit = amount,
217 1 => drawn.trial = amount,
218 _ => drawn.oss = amount,
219 }
220 }
221 Ok(drawn)
222}
223
224/// What a budget with `cap` and `used` so far has left.
225pub(crate) fn left(cap: i64, used: i64) -> i64 {
226 (cap - used).max(0)
227}
228
229/// `YYYY-MM` of an RFC 3339 time.
230pub(crate) fn month_of(timestamp: &str) -> String {
231 timestamp[..7].to_owned()
232}
233
234/// The first instant of the month after `month`: when this month's pools
235/// reset.
236pub(crate) fn next_month_start(month: &str) -> String {
237 let year: i32 = month[..4].parse().unwrap_or(1970);
238 let number: u32 = month[5..7].parse().unwrap_or(1);
239 if number == 12 {
240 format!("{}-01-01T00:00:00Z", year + 1)
241 } else {
242 format!("{year}-{:02}-01T00:00:00Z", number + 1)
243 }
244}
245
246/// The last second of `month`, for a charge that belongs to a month that
247/// is over.
248pub(crate) fn month_end(month: &str) -> String {
249 format!("{month}-{:02}T23:59:59Z", days_in(month))
250}
251
252/// How many days `month` (`YYYY-MM`) has.
253pub(crate) fn days_in(month: &str) -> u32 {
254 let year: i32 = month.get(..4).and_then(|y| y.parse().ok()).unwrap_or(1970);
255 let number: u32 = month.get(5..7).and_then(|m| m.parse().ok()).unwrap_or(1);
256 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
257 match number {
258 2 if leap => 29,
259 2 => 28,
260 4 | 6 | 9 | 11 => 30,
261 _ => 31,
262 }
263}
264
265/// What a trial grant would be: the account's own amount from sudo, or the
266/// default.
267pub(crate) fn grant_size(config: &Config, staff: Option<i64>) -> i64 {
268 staff.unwrap_or(config.trial_workspace_micros).max(0)
269}
270
271/// Whether this month's pool can still make a grant of `amount`.
272pub(crate) fn pool_has_room(pool: i64, granted_this_month: i64, amount: i64) -> bool {
273 amount > 0 && granted_this_month + amount <= pool
274}
275
276#[derive(Deserialize)]
277struct Used {
278 used: Option<i64>,
279}
280
281#[derive(Deserialize)]
282pub(crate) struct Grant {
283 pub granted_micros: i64,
284 pub used_micros: i64,
285}
286
287impl Billing {
288 /// The workspace's plan: comped terms are internal, an enterprise's
289 /// workspaces are invoiced, and otherwise the plan is paid for (or
290 /// given by staff without its price) or not. A Deployments subscription
291 /// from before the plan counts as the plan until its period ends.
292 /// Without a card processor every workspace has the plan: a g1t that
293 /// does not charge has nothing to gate.
294 pub(crate) async fn plan_kind(&self, workspace: &str) -> Result<PlanKind> {
295 let account = self.account_of(workspace).await?;
296 self.plan_kind_for(workspace, &account).await
297 }
298
299 /// The plan, from the account already read for the workspace.
300 pub(crate) async fn plan_kind_for(&self, workspace: &str, account: &BillingAccount) -> Result<PlanKind> {
301 if account.terms.full_discount() {
302 return Ok(PlanKind::Internal);
303 }
304 if account.kind == g1t_contracts::billing::AccountKind::Enterprise {
305 return Ok(PlanKind::Enterprise);
306 }
307 if !self.charging {
308 return Ok(PlanKind::Paid);
309 }
310 // Enrolled by staff, once Stripe has its subscription.
311 if self.complimentary_active(workspace).await?.is_some() {
312 return Ok(PlanKind::Complimentary);
313 }
314 if account.allowances.plan {
315 return Ok(PlanKind::Paid);
316 }
317 // Both subscriptions are asked for at once; either one is the plan.
318 let (plan, deployments) = futures_util::future::try_join(
319 self.plan_on(workspace, Feature::Plan),
320 self.plan_on(workspace, Feature::Deployments),
321 )
322 .await?;
323 if plan || deployments {
324 return Ok(PlanKind::Paid);
325 }
326 Ok(PlanKind::Free)
327 }
328
329 /// Whether the workspace has the g1t plan now, whoever pays for it.
330 pub(crate) async fn has_plan(&self, workspace: &str) -> Result<bool> {
331 Ok(self.plan_kind(workspace).await? != PlanKind::Free)
332 }
333
334 /// `free_workspaces`: those of the workspaces on no paid plan. With no
335 /// card processor nothing is free (`plan_kind_for` says paid), so a
336 /// g1t without payments limits no one.
337 pub(crate) async fn free_workspaces(&self, a: g1t_contracts::billing::FreeWorkspacesArgs) -> Result<Vec<String>> {
338 let mut free = vec![];
339 // A person belongs to a bounded number of workspaces; this caps the
340 // reads all the same.
341 for workspace in a.workspaces.iter().take(100) {
342 let workspace = workspace.trim().to_lowercase();
343 if workspace.is_empty() || free.contains(&workspace) {
344 continue;
345 }
346 if self.plan_kind(&workspace).await? == PlanKind::Free {
347 free.push(workspace);
348 }
349 }
350 Ok(free)
351 }
352
353 /// What one monthly allowance has used.
354 pub(crate) async fn allowance_used(&self, kind: &str, scope: &str, month: &str) -> Result<i64> {
355 Ok(self
356 .db
357 .prepare("SELECT used FROM allowance_use WHERE kind = ? AND scope = ? AND month = ?")
358 .bind(&[kind.into(), scope.into(), month.into()])?
359 .first::<Used>(None)
360 .await?
361 .and_then(|u| u.used)
362 .unwrap_or(0))
363 }
364
365 /// Adds `amount` to a monthly count with no cap, such as the month's
366 /// build seconds, which the Billing page shows beside what they cost.
367 pub(crate) async fn tally(&self, kind: &str, scope: &str, month: &str, amount: i64) -> Result<()> {
368 if amount <= 0 {
369 return Ok(());
370 }
371 self.db
372 .prepare(
373 "INSERT INTO allowance_use (kind, scope, month, used) VALUES (?1, ?2, ?3, ?4)
374 ON CONFLICT (kind, scope, month) DO UPDATE SET used = used + ?4",
375 )
376 .bind(&[kind.into(), scope.into(), month.into(), (amount as f64).into()])?
377 .run()
378 .await?;
379 Ok(())
380 }
381
382 // --- Trials -----------------------------------------------------------
383
384 pub(crate) async fn grant_of(&self, workspace: &str) -> Result<Option<Grant>> {
385 self.db
386 .prepare("SELECT granted_micros, used_micros FROM trial_grants WHERE workspace = ?")
387 .bind(&[workspace.into()])?
388 .first::<Grant>(None)
389 .await
390 }
391
392 /// Trial grants made this month, in all.
393 pub(crate) async fn trial_granted(&self, month: &str) -> Result<(i64, u32)> {
394 #[derive(Deserialize)]
395 struct Row {
396 micros: Option<i64>,
397 n: Option<u32>,
398 }
399 let row = self
400 .db
401 .prepare("SELECT SUM(granted_micros) AS micros, COUNT(*) AS n FROM trial_grants WHERE month = ?")
402 .bind(&[month.into()])?
403 .first::<Row>(None)
404 .await?;
405 Ok(row.map_or((0, 0), |r| (r.micros.unwrap_or(0), r.n.unwrap_or(0))))
406 }
407
408 /// The workspace's grant, made now out of this month's pool if it has
409 /// none and the pool has room. Called once its card is checked, never
410 /// before: the trial needs a card check. A grant g1t staff set comes
411 /// from no pool.
412 pub(crate) async fn ensure_grant(&self, workspace: &str) -> Result<Option<Grant>> {
413 if let Some(grant) = self.grant_of(workspace).await? {
414 return Ok(Some(grant));
415 }
416 if !self.trials_on {
417 return Ok(None);
418 }
419 let staff = self.account_of(workspace).await?.allowances.trial_micros;
420 let amount = grant_size(&self.plans, staff);
421 if amount <= 0 {
422 return Ok(None);
423 }
424 let now = rfc3339(now_ms());
425 let month = if staff.is_some() { "staff".to_owned() } else { month_of(&now) };
426 // One statement: the pool is checked and the grant made together.
427 self.db
428 .prepare(
429 "INSERT INTO trial_grants (workspace, month, granted_micros, used_micros, created_at, fingerprint)
430 SELECT ?1, ?2, ?3, 0, ?4, c.fingerprint FROM card_checks c
431 WHERE c.workspace = ?1 AND c.fingerprint IS NOT NULL AND c.funding IN ('credit', 'debit')
432 AND (?2 = 'staff'
433 OR (SELECT COALESCE(SUM(granted_micros), 0) FROM trial_grants WHERE month = ?2) + ?3 <= ?5)
434 ON CONFLICT DO NOTHING",
435 )
436 .bind(&[
437 workspace.into(),
438 month.as_str().into(),
439 (amount as f64).into(),
440 now.as_str().into(),
441 (self.plans.trial_monthly_pool_micros as f64).into(),
442 ])?
443 .run()
444 .await?;
445 self.grant_of(workspace).await
446 }
447
448 /// `trial`: where the workspace's trial credit stands. Not granted yet,
449 /// it waits for a card check (`verify`), or for next month's pool
450 /// (`pool`).
451 pub(crate) async fn trial(&self, a: TrialArgs) -> Result<Trial> {
452 let workspace = a.workspace.to_lowercase();
453 let closed = |reason: &str| Trial {
454 open: false,
455 used_micros: 0,
456 limit_micros: 0,
457 ends_at: None,
458 reason: Some(reason.to_owned()),
459 granted: false,
460 waits_until: None,
461 };
462 if let Some(grant) = self.grant_of(&workspace).await? {
463 let open = grant.used_micros < grant.granted_micros;
464 return Ok(Trial {
465 open,
466 used_micros: grant.used_micros,
467 limit_micros: grant.granted_micros,
468 ends_at: None,
469 reason: (!open).then(|| "used".to_owned()),
470 granted: true,
471 waits_until: None,
472 });
473 }
474 if !self.trials_on {
475 return Ok(closed("off"));
476 }
477 let staff = self.account_of(&workspace).await?.allowances.trial_micros;
478 let amount = grant_size(&self.plans, staff);
479 if amount <= 0 {
480 return Ok(closed("off"));
481 }
482 let month = month_of(&rfc3339(now_ms()));
483 let (granted, _) = self.trial_granted(&month).await?;
484 let room = staff.is_some() || pool_has_room(self.plans.trial_monthly_pool_micros, granted, amount);
485 Ok(Trial {
486 open: false,
487 used_micros: 0,
488 limit_micros: amount,
489 ends_at: None,
490 reason: Some(if room { "verify" } else { "pool" }.to_owned()),
491 granted: false,
492 waits_until: (!room).then(|| next_month_start(&month)),
493 })
494 }
495
496 // --- The open-source pool ---------------------------------------------
497
498 /// Whether `repo` (`owner/name`) is public, asked of the repos service.
499 /// Unknown counts as private: the pool pays only for what is known to
500 /// be open.
501 pub(crate) async fn is_public(&self, repo: &str) -> bool {
502 let Some(repos) = &self.repos else { return false };
503 let found: Result<Vec<g1t_contracts::repos::RepoVisibility>> = g1t_kit::call(
504 repos,
505 "visibility",
506 &g1t_contracts::repos::VisibilityArgs { paths: vec![repo.to_owned()] },
507 )
508 .await;
509 match found {
510 Ok(list) => list.iter().any(|v| v.path.eq_ignore_ascii_case(repo) && !v.is_private),
511 Err(error) => {
512 worker::console_error!("could not ask whether {repo} is public: {error}");
513 false
514 }
515 }
516 }
517
518 /// A public repository's monthly cap on the pool: its account's own
519 /// from sudo, or `OSS_REPO_MICROS`.
520 pub(crate) async fn oss_repo_cap(&self, workspace: &str) -> Result<i64> {
521 Ok(self.account_of(workspace).await?.allowances.oss_repo_micros.unwrap_or(self.plans.oss_repo_micros))
522 }
523
524 /// What the open-source pool has left this month for `repo`: the
525 /// pool's and the repository's share, whichever is less.
526 pub(crate) async fn oss_left(&self, workspace: &str, repo: &str, month: &str) -> Result<i64> {
527 let pool = left(self.plans.oss_pool_micros, self.allowance_used("oss_pool", "", month).await?);
528 let share = left(self.oss_repo_cap(workspace).await?, self.allowance_used("oss_repo", &repo.to_lowercase(), month).await?);
529 Ok(pool.min(share))
530 }
531
532 /// g1t's pools this month, for sudo.
533 pub(crate) async fn pools(&self) -> Result<Pools> {
534 let month = month_of(&rfc3339(now_ms()));
535 let (granted, grants) = self.trial_granted(&month).await?;
536 Ok(Pools {
537 oss_used_micros: self.allowance_used("oss_pool", "", &month).await?,
538 oss_pool_micros: self.plans.oss_pool_micros,
539 oss_repo_micros: self.plans.oss_repo_micros,
540 trial_granted_micros: granted,
541 trial_pool_micros: self.plans.trial_monthly_pool_micros,
542 trial_grants: grants,
543 month,
544 })
545 }
546}
547
548/// What may pay for compute: the trial (never for deployments), the
549/// open-source pool for checks, workflows and the merge queue on `repo`,
550/// and g1t for a free workspace's overrun. Work whose kind is not known is
551/// taken as an agent's: never the pool.
552pub(crate) fn eligible_for(kind: Option<ComputeKind>, repo: Option<&str>) -> Eligible {
553 let kind = kind.unwrap_or(ComputeKind::Agent);
554 Eligible {
555 trial: kind != ComputeKind::Deploy,
556 repo: repo.filter(|_| kind.open_source_pool()).map(str::to_owned),
557 cover_rest: kind != ComputeKind::Deploy,
558 }
559}
560
561/// A charge in millionths of a dollar for `micros` of cost plus `margin`.
562pub(crate) fn with_margin(cost_micros: i64, margin_percent: u32) -> i64 {
563 crate::margin_on(cost_micros, margin_percent)
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use futures_util::FutureExt;
570
571 fn split(gross: i64, available: &[i64]) -> Vec<i64> {
572 let drawn = draw_in_order(gross, |source, want| {
573 std::future::ready(Ok(want.min(available.get(source).copied().unwrap_or(0).max(0))))
574 }).now_or_never().unwrap().unwrap();
575 [drawn.credit, drawn.trial, drawn.oss][..available.len()].to_vec()
576 }
577
578 #[test]
579 fn concurrent_consumption_of_included_usage_falls_through_to_trial() {
580 let mut requested = Vec::new();
581 let drawn = draw_in_order(500_000, |source, want| {
582 requested.push((source, want));
583 // Another charge has already taken the included balance that
584 // an earlier read would have said could pay for all of this.
585 std::future::ready(Ok(if source == 0 { 0 } else { want }))
586 }).now_or_never().unwrap().unwrap();
587 assert_eq!(requested, [(0, 500_000), (1, 500_000)]);
588 assert_eq!(drawn.credit, 0);
589 assert_eq!(drawn.trial, 500_000);
590 assert_eq!(drawn.total(), 500_000);
591 }
592
593 #[test]
594 fn a_failed_source_returns_the_actual_prior_draw_for_compensation() {
595 let failed = draw_in_order(500_000, |source, _| {
596 std::future::ready(if source == 0 { Ok(200_000) } else { Err(worker::Error::RustError("unavailable".into())) })
597 }).now_or_never().unwrap().unwrap_err();
598 assert_eq!(failed.1.credit, 200_000);
599 assert_eq!(failed.1.trial, 0);
600 assert_eq!(failed.1.total(), 200_000);
601 }
602
603 #[test]
604 fn included_usage_pays_first_then_the_trial_then_the_pool_then_the_workspace() {
605 // $0.50 of usage; $0.20 included, $1 of trial, $1 of pool.
606 assert_eq!(split(500_000, &[200_000, 1_000_000, 1_000_000]), [200_000, 300_000, 0]);
607 // No plan: the trial pays all of it.
608 assert_eq!(split(500_000, &[0, 1_000_000, 1_000_000]), [0, 500_000, 0]);
609 // Trial spent: the pool pays, where it applies.
610 assert_eq!(split(500_000, &[0, 0, 1_000_000]), [0, 0, 500_000]);
611 // Everything spent: the workspace pays all of it.
612 let planned = split(500_000, &[0, 0, 0]);
613 assert_eq!(planned, [0, 0, 0]);
614 assert_eq!(500_000 - planned.iter().sum::<i64>(), 500_000);
615 // Each pays what it can, and the rest is charged.
616 let planned = split(500_000, &[100_000, 150_000, 50_000]);
617 assert_eq!(planned, [100_000, 150_000, 50_000]);
618 assert_eq!(500_000 - planned.iter().sum::<i64>(), 200_000);
619 // Nothing is drawn for nothing, nor from a negative balance.
620 assert_eq!(split(0, &[1, 1, 1]), [0, 0, 0]);
621 assert_eq!(split(100, &[-5, 50, 100]), [0, 50, 50]);
622 }
623
624 #[test]
625 fn a_budget_never_gives_more_than_its_cap() {
626 assert_eq!(left(1_000_000, 400_000), 600_000);
627 assert_eq!(left(1_000_000, 1_000_000), 0);
628 assert_eq!(left(1_000_000, 1_200_000), 0);
629 // The open-source pool: the repository's share and the pool's both bound it.
630 let pool = left(25_000_000, 24_900_000);
631 let repo = left(2_000_000, 300_000);
632 assert_eq!(split(800_000, &[pool.min(repo)]), [100_000]);
633 // A repository past its $2 share gets nothing, however full the pool.
634 assert_eq!(split(800_000, &[left(25_000_000, 0).min(left(2_000_000, 2_000_000))]), [0]);
635 }
636
637 #[test]
638 fn the_open_source_pool_pays_only_for_checks_workflows_and_the_queue() {
639 assert_eq!(eligible_for(Some(ComputeKind::Check), Some("acme/web")).repo.as_deref(), Some("acme/web"));
640 assert_eq!(eligible_for(Some(ComputeKind::Queue), Some("acme/web")).repo.as_deref(), Some("acme/web"));
641 assert_eq!(eligible_for(Some(ComputeKind::Workflow), Some("acme/web")).repo.as_deref(), Some("acme/web"));
642 // An agent on a public repository pays as any agent does.
643 assert!(eligible_for(Some(ComputeKind::Agent), Some("acme/web")).repo.is_none());
644 // Unknown work is never the pool's.
645 assert!(eligible_for(None, Some("acme/web")).repo.is_none());
646 // Deployments are never the trial's, and never covered.
647 let deploy = eligible_for(Some(ComputeKind::Deploy), Some("acme/web"));
648 assert!(!deploy.trial && !deploy.cover_rest && deploy.repo.is_none());
649 assert!(eligible_for(Some(ComputeKind::Agent), None).trial);
650 }
651
652 #[test]
653 fn pools_reset_each_calendar_month() {
654 assert_eq!(month_of("2026-10-31T23:59:59Z"), "2026-10");
655 assert_eq!(month_of("2026-11-01T00:00:00Z"), "2026-11");
656 assert_eq!(next_month_start("2026-10"), "2026-11-01T00:00:00Z");
657 assert_eq!(next_month_start("2026-12"), "2027-01-01T00:00:00Z");
658 // $100 a month in $5 grants: twenty trials, then the next month.
659 assert!(pool_has_room(100_000_000, 95_000_000, 5_000_000));
660 assert!(!pool_has_room(100_000_000, 100_000_000, 5_000_000));
661 assert!(!pool_has_room(100_000_000, 97_500_000, 5_000_000));
662 assert!(pool_has_room(100_000_000, 0, 5_000_000));
663 assert!(!pool_has_room(100_000_000, 0, 0));
664 }
665
666 #[test]
667 fn a_trial_grant_is_the_default_unless_staff_set_one() {
668 let config = Config::default();
669 assert_eq!(grant_size(&config, None), 5_000_000);
670 assert_eq!(grant_size(&config, Some(20_000_000)), 20_000_000);
671 assert_eq!(grant_size(&config, Some(-1)), 0);
672 }
673
674 #[test]
675 fn a_month_ends_on_its_last_day() {
676 assert_eq!(month_end("2026-10"), "2026-10-31T23:59:59Z");
677 assert_eq!(month_end("2026-09"), "2026-09-30T23:59:59Z");
678 assert_eq!(month_end("2028-02"), "2028-02-29T23:59:59Z");
679 assert_eq!(month_end("2027-02"), "2027-02-28T23:59:59Z");
680 }
681
682 #[test]
683 fn what_paid_is_said_on_the_statement() {
684 assert_eq!(Drawn::default().note(), "");
685 let drawn = Drawn { oss: 120_000, ..Drawn::default() };
686 assert_eq!(drawn.note(), " ($0.12 paid by g1t's open-source pool)");
687 let drawn = Drawn { credit: 50_000, trial: 20_000, ..Drawn::default() };
688 assert_eq!(drawn.note(), " ($0.05 paid by your plan's included usage, $0.02 paid by your trial credit)");
689 assert_eq!(drawn.total(), 70_000);
690 let drawn = Drawn { trial: 300_000, given: 40_000, ..Drawn::default() };
691 assert_eq!(drawn.note(), " ($0.30 paid by your trial credit, $0.04 covered by g1t)");
692 assert_eq!(drawn.total(), 340_000);
693 }
694
695 #[test]
696 fn the_defaults_are_the_published_ones() {
697 let c = Config::default();
698 assert_eq!(c.plan_monthly_cents, 2_100);
699 assert_eq!(c.plan_included_micros, 10_000_000);
700 assert_eq!(c.oss_pool_micros, 25_000_000);
701 assert_eq!(c.oss_repo_micros, 2_000_000);
702 assert_eq!(c.trial_workspace_micros, 5_000_000);
703 assert_eq!(c.trial_monthly_pool_micros, 100_000_000);
704 assert_eq!(c.min_charge_micros, 5_000_000);
705 assert_eq!(c.free_storage_bytes, 1_000_000_000);
706 assert_eq!(c.free_audit_days, 7);
707 assert_eq!(c.audit_days, 90);
708 assert_eq!(c.audit_max_days, 400);
709 assert_eq!(c.paid_start_micros, 100_000_000);
710 assert_eq!(c.forgive_cost_micros, 50_000_000);
711 assert_eq!(c.git_included, 50_000);
712 }
713}