Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index
Every signed-in page asks billing for usage, the limit and entitlements, and status.g1t.sh times its price book. Each awaited query after query: entitlements made about 40 D1 round trips, recomputing the plan three times and the account four, so pages waited about two seconds on billing and the status page opened and closed "Billing slow" all day on 2026-10-06. - prices: three reads at once; plans priced from the rows already read. - usage: the totals and the five breakdowns at once (6 round trips to 1). - limit: account and plan read once (plan_kind_for, limit_with), then the limits row, the month, pending, the balance before and charged months in one wave (about 12-19 to 4-8). - entitlements: account and plan once, then everything else at once; pause_reason and the alerts reuse what was read (about 40 to 6-10). - A plan whose period is over is asked about at Stripe at most once an hour, not on every read. - 0026: ledger (workspace, created_at), so a month's reads are ranges. - repos git_operations: the month is a range on hour, so the (namespace, hour) key is used instead of a scan.
| 909 | 909 | name = "g1t-billing" | |
| 910 | 910 | version = "0.1.0" | |
| 911 | 911 | dependencies = [ | |
| 912 | + | "futures-util", | |
| 912 | 913 | "g1t-contracts", | |
| 913 | 914 | "g1t-kit", | |
| 914 | 915 | "getrandom 0.2.17", |
| 14 | 14 | serde.workspace = true | |
| 15 | 15 | serde_json.workspace = true | |
| 16 | 16 | worker.workspace = true | |
| 17 | + | futures-util = { version = "0.3", default-features = false, features = ["alloc"] } | |
| 17 | 18 | getrandom = { version = "0.2", features = ["js"] } | |
| 18 | 19 | hex = "0.4" | |
| 19 | 20 | sha2 = "0.10" |
| 1 | + | -- The month's usage, the limit and the open-source pool read a workspace's | |
| 2 | + | -- ledger from a date on; by workspace and time those are ranges, not scans. | |
| 3 | + | CREATE INDEX IF NOT EXISTS ledger_by_workspace_time ON ledger (workspace, created_at); |
| 27 | 27 | //! stop. Runs already under way finish. g1t's own workspaces are watched | |
| 28 | 28 | //! but never paused. | |
| 29 | 29 | ||
| 30 | + | use futures_util::future::{try_join, try_join4, try_join5}; | |
| 30 | 31 | use g1t_contracts::billing::{ | |
| 31 | − | ComputeKind, ConfirmSpikeArgs, SetCapsArgs, Entitlements, EntitlementsArgs, LimitState, PaidBy, PlanKind, Reservation, | |
| 32 | + | BillingAccount, ComputeKind, ConfirmSpikeArgs, SetCapsArgs, Entitlements, EntitlementsArgs, LimitState, PaidBy, PlanKind, Reservation, | |
| 32 | 33 | ReserveArgs, SettleArgs, Spike, UNLIMITED_MICROS, UsageAlert, RESERVATION_HOURS, | |
| 33 | 34 | }; | |
| 34 | 35 | use g1t_contracts::time::rfc3339; | |
| 278 | 279 | /// The alerts a workspace has reached this month: its plan's included | |
| 279 | 280 | /// usage, its spend limit and g1t's ceiling, from 50%. | |
| 280 | 281 | pub(crate) async fn alerts_for(&self, workspace: &str) -> Result<Vec<UsageAlert>> { | |
| 281 | − | let limit = self.limit_of(workspace).await?; | |
| 282 | − | self.alerts_from(workspace, &limit).await | |
| 282 | + | let account = self.account_of(workspace).await?; | |
| 283 | + | let plan = self.plan_kind_for(workspace, &account).await?; | |
| 284 | + | let has_plan = plan != PlanKind::Free; | |
| 285 | + | let month = credits::month_of(&rfc3339(now_ms())); | |
| 286 | + | let (limit, used) = try_join(self.limit_with(workspace, &account, plan), async { | |
| 287 | + | if has_plan { self.allowance_used("plan_credit", workspace, &month).await } else { Ok(0) } | |
| 288 | + | }) | |
| 289 | + | .await?; | |
| 290 | + | Ok(alerts_from(workspace, &limit, has_plan, used, self.plans.plan_included_micros)) | |
| 283 | 291 | } | |
| 292 | + | } | |
| 284 | 293 | ||
| 285 | − | /// The same, from a limit already worked out. | |
| 286 | − | async fn alerts_from(&self, workspace: &str, limit: &g1t_contracts::billing::Limit) -> Result<Vec<UsageAlert>> { | |
| 287 | − | let month = credits::month_of(&rfc3339(now_ms())); | |
| 288 | − | let mut alerts = vec![]; | |
| 289 | − | if self.has_plan(workspace).await? && limit.trust != g1t_contracts::billing::Trust::Internal { | |
| 290 | − | let used = self.allowance_used("plan_credit", workspace, &month).await?; | |
| 291 | − | let included = self.plans.plan_included_micros; | |
| 292 | − | let level = alert_level(used, included); | |
| 293 | − | if level > 0 { | |
| 294 | − | alerts.push(UsageAlert { | |
| 295 | − | meter: "included".into(), | |
| 296 | − | level, | |
| 297 | − | used_micros: used, | |
| 298 | − | limit_micros: included, | |
| 299 | − | message: if level >= 100 { | |
| 300 | − | format!("{workspace} has used all {} of this month's included usage. Usage from here is charged at cost plus 20%, up to your spend limit.", dollars(included)) | |
| 301 | − | } else { | |
| 302 | − | format!("{workspace} has used {} of this month's {} included usage ({level}%). Past it, usage is charged at cost plus 20%, up to your spend limit.", dollars(used), dollars(included)) | |
| 303 | − | }, | |
| 304 | − | }); | |
| 305 | − | } | |
| 294 | + | /// The alerts reached, from a limit already worked out, whether the | |
| 295 | + | /// workspace has the plan, and what of its included usage it has used. | |
| 296 | + | fn alerts_from( | |
| 297 | + | workspace: &str, | |
| 298 | + | limit: &g1t_contracts::billing::Limit, | |
| 299 | + | has_plan: bool, | |
| 300 | + | used: i64, | |
| 301 | + | included: i64, | |
| 302 | + | ) -> Vec<UsageAlert> { | |
| 303 | + | let mut alerts = vec![]; | |
| 304 | + | if has_plan && limit.trust != g1t_contracts::billing::Trust::Internal { | |
| 305 | + | let level = alert_level(used, included); | |
| 306 | + | if level > 0 { | |
| 307 | + | alerts.push(UsageAlert { | |
| 308 | + | meter: "included".into(), | |
| 309 | + | level, | |
| 310 | + | used_micros: used, | |
| 311 | + | limit_micros: included, | |
| 312 | + | message: if level >= 100 { | |
| 313 | + | format!("{workspace} has used all {} of this month's included usage. Usage from here is charged at cost plus 20%, up to your spend limit.", dollars(included)) | |
| 314 | + | } else { | |
| 315 | + | format!("{workspace} has used {} of this month's {} included usage ({level}%). Past it, usage is charged at cost plus 20%, up to your spend limit.", dollars(used), dollars(included)) | |
| 316 | + | }, | |
| 317 | + | }); | |
| 306 | 318 | } | |
| 307 | − | if let Some(spend_limit) = limit.spend_limit_micros { | |
| 308 | − | let level = alert_level(limit.spent_micros, spend_limit); | |
| 309 | − | if level > 0 { | |
| 310 | − | alerts.push(UsageAlert { | |
| 311 | − | meter: "spend_limit".into(), | |
| 312 | − | level, | |
| 313 | − | used_micros: limit.spent_micros, | |
| 314 | − | limit_micros: spend_limit, | |
| 315 | − | message: format!( | |
| 316 | − | "{workspace} has spent {} of its {} monthly spend limit ({level}%). At the limit, new sandboxes, builds and agents stop until the month turns or an owner raises it.", | |
| 317 | − | dollars(limit.spent_micros), | |
| 318 | − | dollars(spend_limit) | |
| 319 | − | ), | |
| 320 | − | }); | |
| 321 | − | } | |
| 319 | + | } | |
| 320 | + | if let Some(spend_limit) = limit.spend_limit_micros { | |
| 321 | + | let level = alert_level(limit.spent_micros, spend_limit); | |
| 322 | + | if level > 0 { | |
| 323 | + | alerts.push(UsageAlert { | |
| 324 | + | meter: "spend_limit".into(), | |
| 325 | + | level, | |
| 326 | + | used_micros: limit.spent_micros, | |
| 327 | + | limit_micros: spend_limit, | |
| 328 | + | message: format!( | |
| 329 | + | "{workspace} has spent {} of its {} monthly spend limit ({level}%). At the limit, new sandboxes, builds and agents stop until the month turns or an owner raises it.", | |
| 330 | + | dollars(limit.spent_micros), | |
| 331 | + | dollars(spend_limit) | |
| 332 | + | ), | |
| 333 | + | }); | |
| 322 | 334 | } | |
| 323 | − | if let Some(ceiling) = limit.ceiling_micros.filter(|_| limit.trust != g1t_contracts::billing::Trust::New) { | |
| 324 | − | let level = alert_level(limit.exposure_micros, ceiling); | |
| 325 | − | if level > 0 { | |
| 326 | − | alerts.push(UsageAlert { | |
| 327 | − | meter: "ceiling".into(), | |
| 328 | − | level, | |
| 329 | − | used_micros: limit.exposure_micros, | |
| 330 | − | limit_micros: ceiling, | |
| 331 | − | message: format!( | |
| 332 | − | "{workspace} has {} of usage not yet paid for, of the {} g1t allows ({level}%). With a card on file g1t charges it as the limit nears; prepaying raises it at once.", | |
| 333 | − | dollars(limit.exposure_micros), | |
| 334 | − | dollars(ceiling) | |
| 335 | − | ), | |
| 336 | − | }); | |
| 337 | − | } | |
| 335 | + | } | |
| 336 | + | if let Some(ceiling) = limit.ceiling_micros.filter(|_| limit.trust != g1t_contracts::billing::Trust::New) { | |
| 337 | + | let level = alert_level(limit.exposure_micros, ceiling); | |
| 338 | + | if level > 0 { | |
| 339 | + | alerts.push(UsageAlert { | |
| 340 | + | meter: "ceiling".into(), | |
| 341 | + | level, | |
| 342 | + | used_micros: limit.exposure_micros, | |
| 343 | + | limit_micros: ceiling, | |
| 344 | + | message: format!( | |
| 345 | + | "{workspace} has {} of usage not yet paid for, of the {} g1t allows ({level}%). With a card on file g1t charges it as the limit nears; prepaying raises it at once.", | |
| 346 | + | dollars(limit.exposure_micros), | |
| 347 | + | dollars(ceiling) | |
| 348 | + | ), | |
| 349 | + | }); | |
| 338 | 350 | } | |
| 339 | − | Ok(alerts) | |
| 340 | 351 | } | |
| 352 | + | alerts | |
| 353 | + | } | |
| 341 | 354 | ||
| 355 | + | impl Billing { | |
| 342 | 356 | /// Whether a card check was done for the workspace. | |
| 343 | 357 | pub(crate) async fn card_checked(&self, workspace: &str) -> Result<bool> { | |
| 344 | 358 | Ok(self | |
| 380 | 394 | async fn pause_reason( | |
| 381 | 395 | &self, | |
| 382 | 396 | workspace: &str, | |
| 397 | + | account: &BillingAccount, | |
| 383 | 398 | plan: PlanKind, | |
| 384 | 399 | limit: Option<&g1t_contracts::billing::Limit>, | |
| 385 | 400 | ) -> Result<(Option<String>, Option<Spike>, Option<FailureCode>)> { | |
| 386 | − | let account = self.account_of(workspace).await?; | |
| 387 | 401 | if let Some(hold) = account.allowances.hold.as_deref().filter(|h| !h.trim().is_empty()) { | |
| 388 | 402 | return Ok((Some(format!("g1t staff put a hold on new compute ({}).", hold.trim())), None, Some(FailureCode::Paused))); | |
| 389 | 403 | } | |
| 390 | 404 | // A comped account past its monthly budget (`budget`). | |
| 391 | − | if let Some(why) = self.comped_stop(&account).await? { | |
| 405 | + | if let Some(why) = self.comped_stop(account).await? { | |
| 392 | 406 | return Ok((Some(why), None, Some(FailureCode::Paused))); | |
| 393 | 407 | } | |
| 394 | 408 | let spike = self.spike_pause(workspace, plan).await?; | |
| 428 | 442 | /// `entitlements`: what the workspace may do now. | |
| 429 | 443 | pub(crate) async fn entitlements(&self, a: EntitlementsArgs) -> Result<Entitlements> { | |
| 430 | 444 | let workspace = a.workspace.to_lowercase(); | |
| 431 | − | let plan = self.plan_kind(&workspace).await?; | |
| 432 | 445 | let account = self.account_of(&workspace).await?; | |
| 433 | − | let limit = self.limit_of(&workspace).await?; | |
| 446 | + | let plan = self.plan_kind_for(&workspace, &account).await?; | |
| 447 | + | let has_plan = plan != PlanKind::Free; | |
| 434 | 448 | let now = rfc3339(now_ms()); | |
| 435 | 449 | let month = credits::month_of(&now); | |
| 436 | − | let verified = matches!(plan, PlanKind::Internal | PlanKind::Enterprise) || self.card_checked(&workspace).await?; | |
| 437 | − | // A card checked while the month's pool was empty: granted once it | |
| 438 | − | // has room. | |
| 439 | − | let grant = match self.grant_of(&workspace).await? { | |
| 440 | − | Some(grant) => Some(grant), | |
| 441 | − | None if verified && plan == PlanKind::Free && self.trial_allowed(&workspace).await? => self.ensure_grant(&workspace).await?, | |
| 442 | − | None => None, | |
| 450 | + | // Every signed-in page asks for this, so what does not need another | |
| 451 | + | // answer is read at once: the limit (and the pause, from it), the | |
| 452 | + | // trial, and the month's counts. | |
| 453 | + | let standing = async { | |
| 454 | + | let limit = self.limit_with(&workspace, &account, plan).await?; | |
| 455 | + | let pause = self.pause_reason(&workspace, &account, plan, Some(&limit)).await?; | |
| 456 | + | Ok::<_, worker::Error>((limit, pause)) | |
| 457 | + | }; | |
| 458 | + | let trial = async { | |
| 459 | + | let (checked, grant) = try_join( | |
| 460 | + | async { | |
| 461 | + | if matches!(plan, PlanKind::Internal | PlanKind::Enterprise) { Ok(true) } else { self.card_checked(&workspace).await } | |
| 462 | + | }, | |
| 463 | + | self.grant_of(&workspace), | |
| 464 | + | ) | |
| 465 | + | .await?; | |
| 466 | + | // A card checked while the month's pool was empty: granted once | |
| 467 | + | // it has room. | |
| 468 | + | let grant = match grant { | |
| 469 | + | Some(grant) => Some(grant), | |
| 470 | + | None if checked && plan == PlanKind::Free && self.trial_allowed(&workspace).await? => self.ensure_grant(&workspace).await?, | |
| 471 | + | None => None, | |
| 472 | + | }; | |
| 473 | + | Ok::<_, worker::Error>((checked, grant)) | |
| 443 | 474 | }; | |
| 475 | + | let counts = try_join5( | |
| 476 | + | self.owner_caps(&workspace), | |
| 477 | + | self.private_storage(&workspace), | |
| 478 | + | self.oss_paid(&workspace, &month), | |
| 479 | + | self.held(&account.workspaces), | |
| 480 | + | async { if has_plan { self.allowance_used("plan_credit", &workspace, &month).await } else { Ok(0) } }, | |
| 481 | + | ); | |
| 482 | + | let more = try_join(self.allowance_used("build_seconds", &workspace, &month), self.git_operations_this_month(&workspace)); | |
| 483 | + | let ((limit, (paused, spike, _)), (verified, grant), (owners, stored, oss, held, included_used), (build_seconds, git_operations)) = | |
| 484 | + | try_join4(standing, trial, counts, more).await?; | |
| 444 | 485 | let trial_left = grant.as_ref().map_or(0, |g| left(g.granted_micros, g.used_micros)); | |
| 445 | 486 | let first_month = limit.first_month; | |
| 446 | 487 | let (max_agents, max_minutes) = caps(plan, first_month, trial_left > 0, account.allowances.max_concurrent_agents); | |
| 447 | − | let (paused, spike, _) = self.pause_reason(&workspace, plan, Some(&limit)).await?; | |
| 448 | 488 | let ceiling = match plan { | |
| 449 | 489 | PlanKind::Free => 0, | |
| 450 | 490 | PlanKind::Internal => UNLIMITED_MICROS, | |
| 451 | 491 | _ => limit.ceiling_micros.unwrap_or(UNLIMITED_MICROS), | |
| 452 | 492 | }; | |
| 453 | − | let has_plan = plan != PlanKind::Free; | |
| 454 | − | let owners = self.owner_caps(&workspace).await?; | |
| 455 | − | let stored = self.private_storage(&workspace).await?; | |
| 456 | − | let oss = self.oss_paid(&workspace, &month).await?; | |
| 493 | + | let alerts = alerts_from(&workspace, &limit, has_plan, included_used, self.plans.plan_included_micros); | |
| 457 | 494 | Ok(Entitlements { | |
| 458 | 495 | plan, | |
| 459 | 496 | compute: has_plan || trial_left > 0, | |
| 467 | 504 | ceiling_micros: ceiling, | |
| 468 | 505 | exposure_micros: limit.exposure_micros, | |
| 469 | 506 | paused, | |
| 470 | − | held_micros: self.held(&account.workspaces).await?, | |
| 507 | + | held_micros: held, | |
| 471 | 508 | prepaid_micros: limit.prepaid_micros, | |
| 472 | 509 | included_micros: if has_plan { self.plans.plan_included_micros } else { 0 }, | |
| 473 | − | included_used_micros: if has_plan { self.allowance_used("plan_credit", &workspace, &month).await? } else { 0 }, | |
| 510 | + | included_used_micros: included_used, | |
| 474 | 511 | audit_retention_days: self.plans.audit_days, | |
| 475 | 512 | free_private_storage_bytes: self.plans.free_storage_bytes, | |
| 476 | 513 | private_storage_bytes: stored, | |
| 477 | 514 | oss_paid_micros: oss, | |
| 478 | − | build_seconds_used: self.allowance_used("build_seconds", &workspace, &month).await?.max(0) as u32, | |
| 479 | − | git_operations: self.git_operations_this_month(&workspace).await?, | |
| 515 | + | build_seconds_used: build_seconds.max(0) as u32, | |
| 516 | + | git_operations, | |
| 480 | 517 | git_operations_included: self.plans.git_included, | |
| 481 | 518 | min_charge_micros: self.plans.min_charge_micros, | |
| 482 | 519 | spike, | |
| 483 | − | alerts: self.alerts_from(&workspace, &limit).await?, | |
| 520 | + | alerts, | |
| 484 | 521 | workspace, | |
| 485 | 522 | }) | |
| 486 | 523 | } | |
| 495 | 532 | if self.stripe.is_none() { | |
| 496 | 533 | return Ok(Outcome::Ok(Reservation { id: new_id("rsv", now), paid_by: PaidBy::OnDemand, held_micros: 0, expires_at })); | |
| 497 | 534 | } | |
| 498 | − | let plan = self.plan_kind(&workspace).await?; | |
| 499 | 535 | let account = self.account_of(&workspace).await?; | |
| 536 | + | let plan = self.plan_kind_for(&workspace, &account).await?; | |
| 500 | 537 | // g1t's own caps (`budget`), in their own words: a comped account's | |
| 501 | 538 | // monthly budget, and the daily breaker. | |
| 502 | 539 | if let Some(why) = self.comped_stop(&account).await? { | |
| 505 | 542 | if let Some(why) = self.breaker_refuses(plan, &account, a.kind, a.hosted_model).await? { | |
| 506 | 543 | return Ok(Outcome::fail(FailureCode::Paused, why)); | |
| 507 | 544 | } | |
| 508 | − | let limit = if plan == PlanKind::Internal { None } else { Some(self.limit_of(&workspace).await?) }; | |
| 509 | − | let (paused, _, code) = self.pause_reason(&workspace, plan, limit.as_ref()).await?; | |
| 545 | + | let limit = if plan == PlanKind::Internal { None } else { Some(self.limit_with(&workspace, &account, plan).await?) }; | |
| 546 | + | let (paused, _, code) = self.pause_reason(&workspace, &account, plan, limit.as_ref()).await?; | |
| 510 | 547 | if let (Some(why), Some(code)) = (paused, code) { | |
| 511 | 548 | return Ok(refusal(code, &workspace, a.kind, &why)); | |
| 512 | 549 | } | |
| 832 | 869 | mod tests { | |
| 833 | 870 | use super::*; | |
| 834 | 871 | ||
| 872 | + | fn limit(trust: g1t_contracts::billing::Trust, spent: i64, spend_limit: Option<i64>, exposure: i64, ceiling: Option<i64>) -> g1t_contracts::billing::Limit { | |
| 873 | + | g1t_contracts::billing::Limit { | |
| 874 | + | workspace: "acme".into(), | |
| 875 | + | account: "acc_acme".into(), | |
| 876 | + | account_name: "acme".into(), | |
| 877 | + | trust, | |
| 878 | + | exposure_micros: exposure, | |
| 879 | + | ceiling_micros: ceiling, | |
| 880 | + | trust_ceiling_micros: ceiling, | |
| 881 | + | spend_limit_micros: spend_limit, | |
| 882 | + | state: LimitState::Ok, | |
| 883 | + | message: None, | |
| 884 | + | spent_micros: spent, | |
| 885 | + | default_spend_limit: false, | |
| 886 | + | available_micros: None, | |
| 887 | + | growth: None, | |
| 888 | + | prepaid_micros: 0, | |
| 889 | + | max_ceiling_micros: None, | |
| 890 | + | raise_once_micros: None, | |
| 891 | + | raised_at: None, | |
| 892 | + | first_month: false, | |
| 893 | + | } | |
| 894 | + | } | |
| 895 | + | ||
| 896 | + | #[test] | |
| 897 | + | fn alerts_come_from_the_answers_already_read() { | |
| 898 | + | use g1t_contracts::billing::Trust; | |
| 899 | + | let meters = |alerts: Vec<UsageAlert>| alerts.into_iter().map(|a| (a.meter, a.level)).collect::<Vec<_>>(); | |
| 900 | + | // On the plan: included usage at 90%, the spend limit at 50%, the ceiling at 75%. | |
| 901 | + | let paid = limit(Trust::Paid, 100_000_000, Some(200_000_000), 75_000_000, Some(100_000_000)); | |
| 902 | + | assert_eq!( | |
| 903 | + | meters(alerts_from("acme", &paid, true, 9_000_000, 10_000_000)), | |
| 904 | + | vec![("included".to_owned(), 90), ("spend_limit".to_owned(), 50), ("ceiling".to_owned(), 75)] | |
| 905 | + | ); | |
| 906 | + | // Without the plan, what was used of the included usage is not an alert. | |
| 907 | + | assert_eq!(meters(alerts_from("acme", &paid, false, 9_000_000, 10_000_000)).len(), 2); | |
| 908 | + | // g1t's own workspaces have no included usage to warn of, and a new | |
| 909 | + | // workspace's ceiling is not one either. | |
| 910 | + | let internal = limit(Trust::Internal, 0, None, 0, None); | |
| 911 | + | assert!(alerts_from("acme", &internal, true, 10_000_000, 10_000_000).is_empty()); | |
| 912 | + | let new = limit(Trust::New, 0, None, 3_000_000, Some(3_000_000)); | |
| 913 | + | assert!(alerts_from("acme", &new, false, 0, 10_000_000).is_empty()); | |
| 914 | + | } | |
| 915 | + | ||
| 835 | 916 | fn paid(credit: i64, on_demand: i64) -> Room { | |
| 836 | 917 | Room { credit, trial: 0, oss: 0, on_demand: Some(on_demand) } | |
| 837 | 918 | } |
| 27 | 27 | //! is one D1 batch, which runs as a transaction, so two charges at once | |
| 28 | 28 | //! never take more than a budget holds. | |
| 29 | 29 | ||
| 30 | − | use g1t_contracts::billing::{ComputeKind, Feature, PlanKind, Pools, TermsKind, Trial, TrialArgs}; | |
| 30 | + | use g1t_contracts::billing::{BillingAccount, ComputeKind, Feature, PlanKind, Pools, TermsKind, Trial, TrialArgs}; | |
| 31 | 31 | use g1t_contracts::time::rfc3339; | |
| 32 | 32 | use g1t_kit::now_ms; | |
| 33 | 33 | use serde::Deserialize; | |
| 261 | 261 | /// does not charge has nothing to gate. | |
| 262 | 262 | pub(crate) async fn plan_kind(&self, workspace: &str) -> Result<PlanKind> { | |
| 263 | 263 | let account = self.account_of(workspace).await?; | |
| 264 | + | self.plan_kind_for(workspace, &account).await | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | /// The plan, from the account already read for the workspace. | |
| 268 | + | pub(crate) async fn plan_kind_for(&self, workspace: &str, account: &BillingAccount) -> Result<PlanKind> { | |
| 264 | 269 | if account.terms.kind == TermsKind::Comped { | |
| 265 | 270 | return Ok(PlanKind::Internal); | |
| 266 | 271 | } | |
| 270 | 275 | if self.stripe.is_none() || account.allowances.plan { | |
| 271 | 276 | return Ok(PlanKind::Paid); | |
| 272 | 277 | } | |
| 273 | − | if self.plan_on(workspace, Feature::Plan).await? || self.plan_on(workspace, Feature::Deployments).await? { | |
| 278 | + | // Both subscriptions are asked for at once; either one is the plan. | |
| 279 | + | let (plan, deployments) = futures_util::future::try_join( | |
| 280 | + | self.plan_on(workspace, Feature::Plan), | |
| 281 | + | self.plan_on(workspace, Feature::Deployments), | |
| 282 | + | ) | |
| 283 | + | .await?; | |
| 284 | + | if plan || deployments { | |
| 274 | 285 | return Ok(PlanKind::Paid); | |
| 275 | 286 | } | |
| 276 | 287 | Ok(PlanKind::Free) |
| 33 | 33 | period_end: Option<String>, | |
| 34 | 34 | started_by: String, | |
| 35 | 35 | started_at: String, | |
| 36 | + | updated_at: String, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// How long a plan's row is believed after it was last written, once its | |
| 40 | + | /// period is over, before the processor is asked again. | |
| 41 | + | const REFRESH_MS: u64 = 60 * 60 * 1000; | |
| 42 | + | ||
| 43 | + | /// Whether to ask the processor about a plan again: its period is over (or | |
| 44 | + | /// unknown) and it is not canceled, and it was not written in the last hour. | |
| 45 | + | /// Without the hour a plan the processor still shows as ended would be | |
| 46 | + | /// asked about on every page. | |
| 47 | + | fn needs_refresh(status: &str, period_end: Option<&str>, updated_at: &str, now_ms: u64) -> bool { | |
| 48 | + | let now = rfc3339(now_ms); | |
| 49 | + | let over = period_end.is_none_or(|end| end <= now.as_str()) && status != "canceled"; | |
| 50 | + | over && updated_at <= rfc3339(now_ms.saturating_sub(REFRESH_MS)).as_str() | |
| 36 | 51 | } | |
| 37 | 52 | ||
| 38 | 53 | #[derive(Deserialize)] | |
| 177 | 192 | async fn subscription_row(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> { | |
| 178 | 193 | self.db | |
| 179 | 194 | .prepare( | |
| 180 | − | "SELECT feature, subscription_id, status, period_end, started_by, started_at | |
| 195 | + | "SELECT feature, subscription_id, status, period_end, started_by, started_at, updated_at | |
| 181 | 196 | FROM subscriptions WHERE workspace = ? AND feature = ?", | |
| 182 | 197 | ) | |
| 183 | 198 | .bind(&[workspace.into(), feature.as_str().into()])? | |
| 220 | 235 | } | |
| 221 | 236 | ||
| 222 | 237 | /// A workspace's plan for a feature, asking the processor again once | |
| 223 | − | /// the period it last knew of is over. | |
| 238 | + | /// the period it last knew of is over, at most once an hour. | |
| 224 | 239 | async fn current(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> { | |
| 225 | 240 | let Some(row) = self.subscription_row(workspace, feature).await? else { | |
| 226 | 241 | return Ok(None); | |
| 227 | 242 | }; | |
| 228 | − | let stale = row.period_end.as_deref().is_none_or(|end| end <= rfc3339(now_ms()).as_str()) | |
| 229 | − | && row.status != "canceled"; | |
| 243 | + | let stale = needs_refresh(&row.status, row.period_end.as_deref(), &row.updated_at, now_ms()); | |
| 230 | 244 | if let (true, Some(stripe)) = (stale, &self.stripe) { | |
| 231 | 245 | match stripe.subscription(&row.subscription_id).await { | |
| 232 | 246 | Ok(subscription) => self.record(workspace, feature, &subscription, &row.started_by).await?, | |
| 615 | 629 | use super::*; | |
| 616 | 630 | ||
| 617 | 631 | #[test] | |
| 632 | + | fn an_ended_plan_is_asked_about_at_most_once_an_hour() { | |
| 633 | + | let now = 1_791_000_000_000; | |
| 634 | + | let at = |ago_ms: u64| rfc3339(now - ago_ms); | |
| 635 | + | let ended = at(24 * 60 * 60 * 1000); | |
| 636 | + | // Ended, and last written a day ago: ask. | |
| 637 | + | assert!(needs_refresh("active", Some(&ended), &ended, now)); | |
| 638 | + | // Ended, but written ten minutes ago: believe the row. | |
| 639 | + | assert!(!needs_refresh("active", Some(&ended), &at(10 * 60 * 1000), now)); | |
| 640 | + | // An hour on, ask again. | |
| 641 | + | assert!(needs_refresh("active", Some(&ended), &at(REFRESH_MS), now)); | |
| 642 | + | // No period known is the same as ended. | |
| 643 | + | assert!(needs_refresh("past_due", None, &ended, now)); | |
| 644 | + | // A period still running, or a canceled plan, is never asked about. | |
| 645 | + | assert!(!needs_refresh("active", Some(&rfc3339(now + 1000)), &ended, now)); | |
| 646 | + | assert!(!needs_refresh("canceled", Some(&ended), &ended, now)); | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | #[test] | |
| 618 | 650 | fn the_plan_text_quotes_a_build_minute_as_the_table_does() { | |
| 619 | 651 | // The price book's build second (16.44 millionths at cost, plus | |
| 620 | 652 | // 20%) is 19.73 millionths: a minute is 1,184 millionths, $0.0012, |
| 363 | 363 | ||
| 364 | 364 | impl Billing { | |
| 365 | 365 | pub(crate) async fn prices(&self) -> Result<PriceBook> { | |
| 366 | − | let prices = self | |
| 367 | − | .db | |
| 368 | − | .prepare("SELECT * FROM prices ORDER BY rowid") | |
| 369 | − | .all() | |
| 370 | − | .await? | |
| 371 | − | .results::<PriceRow>()?; | |
| 372 | − | let changes = self | |
| 373 | − | .db | |
| 374 | − | .prepare("SELECT * FROM price_changes ORDER BY created_at DESC LIMIT 20") | |
| 375 | − | .all() | |
| 376 | − | .await? | |
| 377 | − | .results::<ChangeRow>()?; | |
| 378 | − | let mut plans = Vec::new(); | |
| 379 | − | for feature in g1t_contracts::billing::Feature::ALL.iter() { | |
| 380 | − | plans.push(self.plan(*feature).await?); | |
| 381 | − | } | |
| 382 | − | // Changes still to come first, so a rise is seen before it is charged. | |
| 383 | − | let coming = self.coming_changes().await?; | |
| 366 | + | // Three reads at once; the plans are priced from the rows already | |
| 367 | + | // read, not one query per meter (status.g1t.sh times this call). | |
| 368 | + | let (prices, changes, coming) = futures_util::future::join3( | |
| 369 | + | async { self.db.prepare("SELECT * FROM prices ORDER BY rowid").all().await?.results::<PriceRow>() }, | |
| 370 | + | async { | |
| 371 | + | self.db | |
| 372 | + | .prepare("SELECT * FROM price_changes ORDER BY created_at DESC LIMIT 20") | |
| 373 | + | .all() | |
| 374 | + | .await? | |
| 375 | + | .results::<ChangeRow>() | |
| 376 | + | }, | |
| 377 | + | // Changes still to come first, so a rise is seen before it is charged. | |
| 378 | + | self.coming_changes(), | |
| 379 | + | ) | |
| 380 | + | .await; | |
| 381 | + | let (prices, changes, coming) = (prices?, changes?, coming?); | |
| 382 | + | let book: std::collections::BTreeMap<&str, f64> = prices | |
| 383 | + | .iter() | |
| 384 | + | .map(|row| (row.meter.as_str(), Price::price_for(row.cost_micros, row.markup_percent))) | |
| 385 | + | .collect(); | |
| 386 | + | let plans: Vec<_> = g1t_contracts::billing::Feature::ALL.iter().map(|_| self.plan_at(&book)).collect(); | |
| 384 | 387 | Ok(PriceBook { | |
| 385 | 388 | prices: prices | |
| 386 | 389 | .into_iter() |
| 48 | 48 | use sha2::{Digest, Sha256}; | |
| 49 | 49 | use worker::wasm_bindgen::JsValue; | |
| 50 | 50 | use worker::{Context, D1Database, Env, MessageBatch, MessageExt, Request, Response, Result, ScheduleContext, ScheduledEvent, event}; | |
| 51 | + | use futures_util::future::{try_join, try_join5}; | |
| 51 | 52 | ||
| 52 | 53 | use stripe::Stripe; | |
| 53 | 54 | ||
| 420 | 421 | runs: Option<u32>, | |
| 421 | 422 | added: Option<i64>, | |
| 422 | 423 | } | |
| 423 | − | let totals = self | |
| 424 | − | .db | |
| 425 | − | .prepare( | |
| 426 | − | "SELECT | |
| 427 | − | -SUM(CASE WHEN kind = 'usage' THEN amount_micros END) AS spent, | |
| 428 | − | SUM(CASE WHEN kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' THEN cost_micros END) AS cost, | |
| 429 | − | SUM(CASE WHEN kind = 'usage' AND billed_to = 'workspace' THEN cost_micros END) AS provider, | |
| 430 | − | SUM(CASE WHEN kind = 'usage' THEN 1 ELSE 0 END) AS runs, | |
| 431 | − | SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS added | |
| 432 | − | FROM ledger WHERE workspace = ?1 AND created_at >= ?2", | |
| 433 | − | ) | |
| 434 | − | .bind(&[workspace.as_str().into(), a.since.as_str().into()])? | |
| 435 | − | .first::<Totals>(None) | |
| 436 | − | .await?; | |
| 424 | + | let totals = async { | |
| 425 | + | self.db | |
| 426 | + | .prepare( | |
| 427 | + | "SELECT | |
| 428 | + | -SUM(CASE WHEN kind = 'usage' THEN amount_micros END) AS spent, | |
| 429 | + | SUM(CASE WHEN kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' THEN cost_micros END) AS cost, | |
| 430 | + | SUM(CASE WHEN kind = 'usage' AND billed_to = 'workspace' THEN cost_micros END) AS provider, | |
| 431 | + | SUM(CASE WHEN kind = 'usage' THEN 1 ELSE 0 END) AS runs, | |
| 432 | + | SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS added | |
| 433 | + | FROM ledger WHERE workspace = ?1 AND created_at >= ?2", | |
| 434 | + | ) | |
| 435 | + | .bind(&[workspace.as_str().into(), a.since.as_str().into()])? | |
| 436 | + | .first::<Totals>(None) | |
| 437 | + | .await | |
| 438 | + | }; | |
| 439 | + | // The totals and the five slices read the same rows independently, | |
| 440 | + | // so they go to D1 at once: one round trip of waiting, not six. | |
| 441 | + | let (totals, (by_day, by_task, by_repo, by_pull, by_model)) = try_join( | |
| 442 | + | totals, | |
| 443 | + | try_join5( | |
| 444 | + | query(slices("substr(created_at, 1, 10) || '/' || COALESCE(task, 'other')", 400)), | |
| 445 | + | query(slices("task", 20)), | |
| 446 | + | query(slices("repo", 20)), | |
| 447 | + | query(slices("repo || '#' || number", 10)), | |
| 448 | + | query(slices("model", 10)), | |
| 449 | + | ), | |
| 450 | + | ) | |
| 451 | + | .await?; | |
| 437 | 452 | let totals = totals.unwrap_or(Totals { | |
| 438 | 453 | spent: None, | |
| 439 | 454 | cost: None, | |
| 449 | 464 | free: self.free, | |
| 450 | 465 | runs: totals.runs.unwrap_or_default(), | |
| 451 | 466 | added_micros: totals.added.unwrap_or_default(), | |
| 452 | − | by_day: query(slices("substr(created_at, 1, 10) || '/' || COALESCE(task, 'other')", 400)).await?, | |
| 453 | − | by_task: query(slices("task", 20)).await?, | |
| 454 | − | by_repo: query(slices("repo", 20)).await?, | |
| 455 | − | by_pull: query(slices("repo || '#' || number", 10)).await?, | |
| 456 | − | by_model: query(slices("model", 10)).await?, | |
| 467 | + | by_day, | |
| 468 | + | by_task, | |
| 469 | + | by_repo, | |
| 470 | + | by_pull, | |
| 471 | + | by_model, | |
| 457 | 472 | since: a.since, | |
| 458 | 473 | })) | |
| 459 | 474 | } |
| 37 | 37 | //! Usage counts at what it cost g1t or what it is charged, whichever is | |
| 38 | 38 | //! more. Test-mode payments are not money, so they do not raise trust. | |
| 39 | 39 | ||
| 40 | + | use futures_util::future::{try_join, try_join5, try_join_all}; | |
| 40 | 41 | use g1t_contracts::billing::{ | |
| 41 | − | CheckLimitArgs, Limit, LimitArgs, LimitState, NotePendingArgs, PlanKind, SetSpendLimitArgs, TermsKind, Trust, | |
| 42 | + | BillingAccount, CheckLimitArgs, Limit, LimitArgs, LimitState, NotePendingArgs, PlanKind, SetSpendLimitArgs, TermsKind, Trust, | |
| 42 | 43 | }; | |
| 43 | 44 | use g1t_contracts::time::rfc3339; | |
| 44 | 45 | use g1t_contracts::{FailureCode, Outcome, Role}; | |
| 245 | 246 | pub(crate) async fn limit_of(&self, workspace: &str) -> Result<Limit> { | |
| 246 | 247 | let workspace = workspace.to_lowercase(); | |
| 247 | 248 | let account = self.account_of(&workspace).await?; | |
| 248 | − | let row = self | |
| 249 | − | .db | |
| 250 | − | .prepare( | |
| 251 | − | "SELECT spend_limit_micros, spend_limit_full, autopay_failed_at, autopay_error, | |
| 252 | − | max_ceiling_micros, granted_ceiling_micros, raised_at | |
| 253 | − | FROM limits WHERE workspace = ?", | |
| 254 | − | ) | |
| 255 | − | .bind(&[workspace.as_str().into()])? | |
| 256 | − | .first::<LimitRow>(None) | |
| 257 | − | .await?; | |
| 249 | + | let plan = self.plan_kind_for(&workspace, &account).await?; | |
| 250 | + | self.limit_with(&workspace, &account, plan).await | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// The workspace's limit, from the account and plan already read for | |
| 254 | + | /// it, so a caller that has them does not read them again. | |
| 255 | + | pub(crate) async fn limit_with(&self, workspace: &str, account: &BillingAccount, plan: PlanKind) -> Result<Limit> { | |
| 256 | + | let workspace = workspace.to_lowercase(); | |
| 258 | 257 | let now = rfc3339(now_ms()); | |
| 259 | 258 | let month_start = format!("{}-01", &now[..7]); | |
| 260 | 259 | let marks = vec!["?"; account.workspaces.len().max(1)].join(", "); | |
| 263 | 262 | } else { | |
| 264 | 263 | account.workspaces.iter().map(|w| JsValue::from(w.as_str())).collect() | |
| 265 | 264 | }; | |
| 265 | + | let row = async { | |
| 266 | + | self.db | |
| 267 | + | .prepare( | |
| 268 | + | "SELECT spend_limit_micros, spend_limit_full, autopay_failed_at, autopay_error, | |
| 269 | + | max_ceiling_micros, granted_ceiling_micros, raised_at | |
| 270 | + | FROM limits WHERE workspace = ?", | |
| 271 | + | ) | |
| 272 | + | .bind(&[workspace.as_str().into()])? | |
| 273 | + | .first::<LimitRow>(None) | |
| 274 | + | .await | |
| 275 | + | }; | |
| 266 | 276 | let mut with_month = members.clone(); | |
| 267 | 277 | with_month.push(month_start.as_str().into()); | |
| 268 | 278 | // Each usage entry at its cost to g1t or its charge, whichever is | |
| 270 | 280 | // What the plan's included usage, the trial, the open-source pool | |
| 271 | 281 | // or g1t itself paid for is not unpaid: those are budgets already | |
| 272 | 282 | // paid for. | |
| 273 | − | let month = self | |
| 274 | − | .db | |
| 275 | − | .prepare(format!( | |
| 276 | − | "SELECT | |
| 277 | − | SUM(CASE WHEN kind = 'usage' THEN | |
| 278 | − | CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t' | |
| 279 | − | THEN MAX(COALESCE(cost_micros, 0) - COALESCE(credit_micros, 0) | |
| 280 | − | - COALESCE(trial_micros, 0) - COALESCE(oss_micros, 0) | |
| 281 | − | - COALESCE(given_micros, 0), | |
| 282 | − | -amount_micros) | |
| 283 | − | ELSE -amount_micros END | |
| 284 | − | END) AS used, | |
| 285 | − | SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS paid | |
| 286 | − | FROM ledger WHERE workspace IN ({marks}) AND created_at >= ?" | |
| 287 | − | )) | |
| 288 | − | .bind(&with_month)? | |
| 289 | − | .first::<Month>(None) | |
| 290 | − | .await?; | |
| 291 | − | let (used, paid_month) = month.map_or((0, 0), |m| (m.used.unwrap_or(0), m.paid.unwrap_or(0))); | |
| 283 | + | let month = async { | |
| 284 | + | self.db | |
| 285 | + | .prepare(format!( | |
| 286 | + | "SELECT | |
| 287 | + | SUM(CASE WHEN kind = 'usage' THEN | |
| 288 | + | CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t' | |
| 289 | + | THEN MAX(COALESCE(cost_micros, 0) - COALESCE(credit_micros, 0) | |
| 290 | + | - COALESCE(trial_micros, 0) - COALESCE(oss_micros, 0) | |
| 291 | + | - COALESCE(given_micros, 0), | |
| 292 | + | -amount_micros) | |
| 293 | + | ELSE -amount_micros END | |
| 294 | + | END) AS used, | |
| 295 | + | SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS paid | |
| 296 | + | FROM ledger WHERE workspace IN ({marks}) AND created_at >= ?" | |
| 297 | + | )) | |
| 298 | + | .bind(&with_month)? | |
| 299 | + | .first::<Month>(None) | |
| 300 | + | .await | |
| 301 | + | }; | |
| 292 | 302 | // And what is metered but not charged until the month closes. | |
| 293 | 303 | let mut pending_args = members.clone(); | |
| 294 | 304 | pending_args.push(month_start[..7].into()); | |
| 295 | − | let pending = self | |
| 296 | − | .db | |
| 297 | − | .prepare(format!( | |
| 298 | − | "SELECT SUM(charge_micros) AS paid FROM pending_usage WHERE workspace IN ({marks}) AND month = ?" | |
| 299 | − | )) | |
| 300 | − | .bind(&pending_args)? | |
| 301 | − | .first::<Paid>(None) | |
| 302 | − | .await? | |
| 303 | − | .and_then(|row| row.paid) | |
| 304 | − | .unwrap_or(0); | |
| 305 | − | let used = used + pending; | |
| 305 | + | let pending = async { | |
| 306 | + | Ok::<i64, worker::Error>( | |
| 307 | + | self.db | |
| 308 | + | .prepare(format!( | |
| 309 | + | "SELECT SUM(charge_micros) AS paid FROM pending_usage WHERE workspace IN ({marks}) AND month = ?" | |
| 310 | + | )) | |
| 311 | + | .bind(&pending_args)? | |
| 312 | + | .first::<Paid>(None) | |
| 313 | + | .await? | |
| 314 | + | .and_then(|row| row.paid) | |
| 315 | + | .unwrap_or(0), | |
| 316 | + | ) | |
| 317 | + | }; | |
| 306 | 318 | // Test-mode payments are not money: they pay nothing off. | |
| 307 | 319 | let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live); | |
| 308 | 320 | // The balance the month started with: owed from before (so a new | |
| 311 | 323 | // do not. | |
| 312 | 324 | let mut before = members.clone(); | |
| 313 | 325 | before.push(month_start.as_str().into()); | |
| 314 | − | let balance_before = self | |
| 315 | − | .db | |
| 316 | − | .prepare(format!( | |
| 317 | − | "SELECT SUM(CASE WHEN kind = 'usage' THEN amount_micros | |
| 318 | − | WHEN kind = 'top_up' AND ({live} = 1 OR reference LIKE 'crd%') THEN amount_micros | |
| 319 | − | ELSE 0 END) AS paid | |
| 320 | − | FROM ledger WHERE workspace IN ({marks}) AND created_at < ?", | |
| 321 | − | live = u8::from(live) | |
| 322 | − | )) | |
| 323 | − | .bind(&before)? | |
| 324 | − | .first::<Paid>(None) | |
| 325 | − | .await? | |
| 326 | − | .and_then(|row| row.paid) | |
| 327 | − | .unwrap_or(0); | |
| 328 | − | let (exposure, prepaid) = exposure(used, if live { paid_month } else { 0 }, balance_before); | |
| 329 | − | ||
| 330 | − | let plan = self.plan_kind(&workspace).await?; | |
| 331 | − | let mut first_month = false; | |
| 332 | − | let (trust, trust_ceiling) = match account.terms.kind { | |
| 333 | − | TermsKind::Comped => (Trust::Internal, None), | |
| 334 | − | _ if account.terms.ceiling_micros.is_some() => (Trust::Reviewed, account.terms.ceiling_micros), | |
| 335 | − | _ => { | |
| 336 | − | let paid = self.live_paid(&members).await?; | |
| 337 | − | let established = if paid > 0 { self.established(&members).await? } else { None }; | |
| 338 | − | if plan == PlanKind::Free { | |
| 339 | − | // Nothing on demand: only what a free workspace can owe. | |
| 340 | − | (Trust::New, Some(self.ceilings.new)) | |
| 341 | − | } else { | |
| 342 | − | first_month = plan == PlanKind::Paid && self.first_month(&workspace).await?; | |
| 343 | − | let ceiling = paid_ceiling(&self.ceilings, self.plans.paid_start_micros, first_month, paid, established); | |
| 344 | − | (if established.is_some() { Trust::Established } else { Trust::Paid }, Some(ceiling)) | |
| 326 | + | let balance_before = async { | |
| 327 | + | Ok::<i64, worker::Error>( | |
| 328 | + | self.db | |
| 329 | + | .prepare(format!( | |
| 330 | + | "SELECT SUM(CASE WHEN kind = 'usage' THEN amount_micros | |
| 331 | + | WHEN kind = 'top_up' AND ({live} = 1 OR reference LIKE 'crd%') THEN amount_micros | |
| 332 | + | ELSE 0 END) AS paid | |
| 333 | + | FROM ledger WHERE workspace IN ({marks}) AND created_at < ?", | |
| 334 | + | live = u8::from(live) | |
| 335 | + | )) | |
| 336 | + | .bind(&before)? | |
| 337 | + | .first::<Paid>(None) | |
| 338 | + | .await? | |
| 339 | + | .and_then(|row| row.paid) | |
| 340 | + | .unwrap_or(0), | |
| 341 | + | ) | |
| 342 | + | }; | |
| 343 | + | // The trust ceiling, from what has been paid and how steadily. The | |
| 344 | + | // first month is asked for beside it, since neither needs the other. | |
| 345 | + | let trust = async { | |
| 346 | + | Ok::<_, worker::Error>(match account.terms.kind { | |
| 347 | + | TermsKind::Comped => (Trust::Internal, None, false), | |
| 348 | + | _ if account.terms.ceiling_micros.is_some() => (Trust::Reviewed, account.terms.ceiling_micros, false), | |
| 349 | + | _ => { | |
| 350 | + | let standing = async { | |
| 351 | + | let paid = self.live_paid(&members).await?; | |
| 352 | + | let established = if paid > 0 { self.established(&members).await? } else { None }; | |
| 353 | + | Ok::<_, worker::Error>((paid, established)) | |
| 354 | + | }; | |
| 355 | + | let first = async { | |
| 356 | + | if plan == PlanKind::Paid { self.first_month(&workspace).await } else { Ok(false) } | |
| 357 | + | }; | |
| 358 | + | let ((paid, established), first_month) = try_join(standing, first).await?; | |
| 359 | + | if plan == PlanKind::Free { | |
| 360 | + | // Nothing on demand: only what a free workspace can owe. | |
| 361 | + | (Trust::New, Some(self.ceilings.new), false) | |
| 362 | + | } else { | |
| 363 | + | let ceiling = paid_ceiling(&self.ceilings, self.plans.paid_start_micros, first_month, paid, established); | |
| 364 | + | (if established.is_some() { Trust::Established } else { Trust::Paid }, Some(ceiling), first_month) | |
| 365 | + | } | |
| 345 | 366 | } | |
| 346 | − | } | |
| 367 | + | }) | |
| 347 | 368 | }; | |
| 369 | + | // This month's charges, and last month's, for the spend limit. | |
| 370 | + | let charged = self.charged_months(&members, &month_start); | |
| 371 | + | // None of these reads needs another's answer, so they go to D1 at | |
| 372 | + | // once: the limit is on every signed-in page. | |
| 373 | + | let ((row, month, pending, balance_before, (spent, last_month)), (trust, trust_ceiling, first_month)) = | |
| 374 | + | try_join(try_join5(row, month, pending, balance_before, charged), trust).await?; | |
| 375 | + | let (used, paid_month) = month.map_or((0, 0), |m| (m.used.unwrap_or(0), m.paid.unwrap_or(0))); | |
| 376 | + | let used = used + pending; | |
| 377 | + | let (exposure, prepaid) = exposure(used, if live { paid_month } else { 0 }, balance_before); | |
| 378 | + | ||
| 348 | 379 | // A ceiling g1t granted is a floor under the trust ceiling. | |
| 349 | 380 | let granted = row.as_ref().and_then(|row| row.granted_ceiling_micros); | |
| 350 | 381 | let ceiling = trust_ceiling.map(|c| c.max(granted.unwrap_or(0))); | |
| 364 | 395 | .run() | |
| 365 | 396 | .await?; | |
| 366 | 397 | } | |
| 367 | − | // This month's charges, and last month's, for the spend limit. | |
| 368 | − | let (spent, last_month) = self.charged_months(&members, &month_start).await?; | |
| 369 | 398 | let spent = spent + pending; | |
| 370 | 399 | let raised_at = row.as_ref().and_then(|row| row.raised_at.clone()); | |
| 371 | 400 | let self_serve = matches!(trust, Trust::New | Trust::Paid | Trust::Established) && plan != PlanKind::Free; | |
| 451 | 480 | }; | |
| 452 | 481 | Ok(Limit { | |
| 453 | 482 | workspace, | |
| 454 | − | account: account.id, | |
| 455 | − | account_name: account.name, | |
| 483 | + | account: account.id.clone(), | |
| 484 | + | account_name: account.name.clone(), | |
| 456 | 485 | spent_micros: spent, | |
| 457 | 486 | default_spend_limit, | |
| 458 | 487 | available_micros: available, | |
| 547 | 576 | struct Count { | |
| 548 | 577 | n: Option<i64>, | |
| 549 | 578 | } | |
| 550 | − | let troubled = self | |
| 551 | − | .db | |
| 552 | − | .prepare(format!( | |
| 553 | − | "SELECT (SELECT COUNT(*) FROM ledger WHERE workspace IN ({marks}) AND disputed = 1) | |
| 554 | − | + (SELECT COUNT(*) FROM limits WHERE workspace IN ({marks}) AND autopay_failed_at >= '{since}') AS n", | |
| 555 | − | since = rfc3339(now_ms() - 90 * 24 * 60 * 60 * 1000) | |
| 556 | − | )) | |
| 557 | − | .bind(&[members, members].concat())? | |
| 558 | − | .first::<Count>(None) | |
| 559 | − | .await? | |
| 560 | − | .and_then(|c| c.n) | |
| 561 | − | .unwrap_or(0); | |
| 562 | − | if troubled > 0 { | |
| 563 | − | return Ok(None); | |
| 579 | + | let troubled = async { | |
| 580 | + | Ok::<i64, worker::Error>( | |
| 581 | + | self.db | |
| 582 | + | .prepare(format!( | |
| 583 | + | "SELECT (SELECT COUNT(*) FROM ledger WHERE workspace IN ({marks}) AND disputed = 1) | |
| 584 | + | + (SELECT COUNT(*) FROM limits WHERE workspace IN ({marks}) AND autopay_failed_at >= '{since}') AS n", | |
| 585 | + | since = rfc3339(now_ms() - 90 * 24 * 60 * 60 * 1000) | |
| 586 | + | )) | |
| 587 | + | .bind(&[members, members].concat())? | |
| 588 | + | .first::<Count>(None) | |
| 589 | + | .await? | |
| 590 | + | .and_then(|c| c.n) | |
| 591 | + | .unwrap_or(0), | |
| 592 | + | ) | |
| 593 | + | }; | |
| 594 | + | #[derive(Deserialize)] | |
| 595 | + | struct Month { | |
| 596 | + | charged: Option<i64>, | |
| 597 | + | unpaid: Option<i64>, | |
| 564 | 598 | } | |
| 565 | − | let mut charged = vec![]; | |
| 566 | − | for month in &months { | |
| 567 | − | #[derive(Deserialize)] | |
| 568 | − | struct Month { | |
| 569 | − | charged: Option<i64>, | |
| 570 | − | unpaid: Option<i64>, | |
| 571 | − | } | |
| 599 | + | let read_month = |month: &String| { | |
| 572 | 600 | let next = { | |
| 573 | 601 | let year: i32 = month[..4].parse().unwrap_or(1970); | |
| 574 | 602 | let number: u32 = month[5..7].parse().unwrap_or(1); | |
| 575 | 603 | if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) } | |
| 576 | 604 | }; | |
| 577 | − | let row = self | |
| 578 | − | .db | |
| 579 | − | .prepare(format!( | |
| 580 | − | "SELECT | |
| 581 | − | (SELECT -SUM(amount_micros) FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) | |
| 582 | − | AND created_at >= '{month}-01' AND created_at < '{next}-01') AS charged, | |
| 583 | − | (SELECT COUNT(*) FROM workspace_invoices WHERE workspace IN ({marks}) AND reason = 'month' | |
| 584 | − | AND period = '{month}' AND status <> 'paid') AS unpaid" | |
| 585 | − | )) | |
| 586 | − | .bind(&[members, members].concat())? | |
| 587 | − | .first::<Month>(None) | |
| 588 | − | .await?; | |
| 605 | + | let sql = format!( | |
| 606 | + | "SELECT | |
| 607 | + | (SELECT -SUM(amount_micros) FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) | |
| 608 | + | AND created_at >= '{month}-01' AND created_at < '{next}-01') AS charged, | |
| 609 | + | (SELECT COUNT(*) FROM workspace_invoices WHERE workspace IN ({marks}) AND reason = 'month' | |
| 610 | + | AND period = '{month}' AND status <> 'paid') AS unpaid" | |
| 611 | + | ); | |
| 612 | + | async move { self.db.prepare(sql).bind(&[members, members].concat())?.first::<Month>(None).await } | |
| 613 | + | }; | |
| 614 | + | // The check for trouble and the three months are read at once; the | |
| 615 | + | // answer is the one reading them in turn and stopping early gives. | |
| 616 | + | let (troubled, rows) = try_join(troubled, try_join_all(months.iter().map(read_month))).await?; | |
| 617 | + | if troubled > 0 { | |
| 618 | + | return Ok(None); | |
| 619 | + | } | |
| 620 | + | let mut charged = vec![]; | |
| 621 | + | for row in rows { | |
| 589 | 622 | let Some(row) = row else { return Ok(None) }; | |
| 590 | 623 | if row.unpaid.unwrap_or(0) > 0 { | |
| 591 | 624 | return Ok(None); |
| 30 | 30 | ||
| 31 | 31 | use g1t_contracts::repos::{GitService, WorkspaceGitOperations}; | |
| 32 | 32 | use serde::Deserialize; | |
| 33 | − | use worker::wasm_bindgen::JsValue; | |
| 34 | 33 | use worker::{D1Database, Env, Fetcher, Response, Result}; | |
| 35 | 34 | ||
| 36 | 35 | /// What a request to g1t's git endpoints asks the store. | |
| 197 | 196 | Ok(()) | |
| 198 | 197 | } | |
| 199 | 198 | ||
| 199 | + | /// The hours of `month` (`YYYY-MM`) from `since` on, as the range | |
| 200 | + | /// `[from, until)` of hour keys; `None` for a month that is not one. | |
| 201 | + | /// A range on `hour` is what the table's key can find; `substr` is not. | |
| 202 | + | pub fn month_hours(month: &str, since: Option<&str>) -> Option<(String, String)> { | |
| 203 | + | let year: u32 = month.get(..4)?.parse().ok()?; | |
| 204 | + | let number: u32 = month.get(5..7)?.parse().ok()?; | |
| 205 | + | if month.len() != 7 || &month[4..5] != "-" || !(1..=12).contains(&number) { | |
| 206 | + | return None; | |
| 207 | + | } | |
| 208 | + | let until = if number == 12 { format!("{}-01-01", year + 1) } else { format!("{year}-{:02}-01", number + 1) }; | |
| 209 | + | let start = format!("{month}-01"); | |
| 210 | + | let from = match since { | |
| 211 | + | Some(since) if since > start.as_str() => since.to_owned(), | |
| 212 | + | _ => start, | |
| 213 | + | }; | |
| 214 | + | Some((from, until)) | |
| 215 | + | } | |
| 216 | + | ||
| 200 | 217 | /// Each workspace's operations in `month`, from `since` (an hour) on. | |
| 201 | 218 | pub async fn totals(db: &D1Database, month: &str, since: Option<&str>, namespace: Option<&str>) -> Result<Vec<WorkspaceGitOperations>> { | |
| 202 | 219 | #[derive(Deserialize)] | |
| 204 | 221 | namespace: String, | |
| 205 | 222 | operations: Option<f64>, | |
| 206 | 223 | } | |
| 207 | − | Ok(db | |
| 208 | − | .prepare( | |
| 209 | − | "SELECT namespace, SUM(operations) AS operations FROM git_operations | |
| 210 | − | WHERE substr(hour, 1, 7) = ?1 AND hour >= COALESCE(?2, '') AND (?3 IS NULL OR namespace = ?3) | |
| 211 | − | GROUP BY namespace", | |
| 212 | − | ) | |
| 213 | − | .bind(&[month.into(), since.map_or(JsValue::NULL, JsValue::from), namespace.map_or(JsValue::NULL, JsValue::from)])? | |
| 224 | + | let Some((from, until)) = month_hours(month, since) else { return Ok(vec![]) }; | |
| 225 | + | // One workspace's is read by the table's key, namespace first; every | |
| 226 | + | // workspace's (billing's daily measure) reads the month's hours. | |
| 227 | + | let statement = match namespace { | |
| 228 | + | Some(namespace) => db | |
| 229 | + | .prepare( | |
| 230 | + | "SELECT namespace, SUM(operations) AS operations FROM git_operations | |
| 231 | + | WHERE namespace = ?1 AND hour >= ?2 AND hour < ?3 | |
| 232 | + | GROUP BY namespace", | |
| 233 | + | ) | |
| 234 | + | .bind(&[namespace.into(), from.as_str().into(), until.as_str().into()])?, | |
| 235 | + | None => db | |
| 236 | + | .prepare( | |
| 237 | + | "SELECT namespace, SUM(operations) AS operations FROM git_operations | |
| 238 | + | WHERE hour >= ?1 AND hour < ?2 | |
| 239 | + | GROUP BY namespace", | |
| 240 | + | ) | |
| 241 | + | .bind(&[from.as_str().into(), until.as_str().into()])?, | |
| 242 | + | }; | |
| 243 | + | Ok(statement | |
| 214 | 244 | .all() | |
| 215 | 245 | .await? | |
| 216 | 246 | .results::<Row>()? | |
| 297 | 327 | assert_eq!(hour_key("2026-10-14T09:59:59.000Z"), "2026-10-14T09"); | |
| 298 | 328 | } | |
| 299 | 329 | ||
| 330 | + | #[test] | |
| 331 | + | fn a_months_hours_are_a_range_on_the_key() { | |
| 332 | + | let range = |month: &str, since: Option<&str>| month_hours(month, since); | |
| 333 | + | assert_eq!(range("2026-10", None), Some(("2026-10-01".to_owned(), "2026-11-01".to_owned()))); | |
| 334 | + | assert_eq!(range("2026-12", None), Some(("2026-12-01".to_owned(), "2027-01-01".to_owned()))); | |
| 335 | + | // `since` narrows the start, never widens it past the month. | |
| 336 | + | assert_eq!(range("2026-10", Some("2026-10-14T00")).map(|r| r.0), Some("2026-10-14T00".to_owned())); | |
| 337 | + | assert_eq!(range("2026-10", Some("2026-09-30T23")).map(|r| r.0), Some("2026-10-01".to_owned())); | |
| 338 | + | assert_eq!(range("2026-10", Some("")).map(|r| r.0), Some("2026-10-01".to_owned())); | |
| 339 | + | // Every hour of the month is in it, and none of the next or last, | |
| 340 | + | // as `substr(hour, 1, 7) = month` had it. | |
| 341 | + | let (from, until) = range("2026-10", None).unwrap(); | |
| 342 | + | let inside = |hour: &str| hour >= from.as_str() && hour < until.as_str(); | |
| 343 | + | assert!(inside("2026-10-01T00") && inside("2026-10-31T23")); | |
| 344 | + | assert!(!inside("2026-09-30T23") && !inside("2026-11-01T00")); | |
| 345 | + | assert_eq!(range("2026-13", None), None); | |
| 346 | + | assert_eq!(range("2026-1", None), None); | |
| 347 | + | assert_eq!(range("", None), None); | |
| 348 | + | } | |
| 349 | + | ||
| 300 | 350 | fn pkt(payload: &str) -> Vec<u8> { | |
| 301 | 351 | format!("{:04x}{payload}", payload.len() + 4).into_bytes() | |
| 302 | 352 | } |