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.
| Projects: when each was last pushed, and how active it is lately | 1 | //! Usage other services meter through the month, charged once it is over: |
| Comments and the audit log stop speaking of g1t's agents as current. A workspace's runner settings send g1t's own work (checks, merge checks and the merge queue) to self-hosted runners, and the audit log now says "g1t's work on self-hosted runners" where it said "Agents on self-hosted runners"; the fields keep their names. The runner errors script names the sandboxes that run checks, workflow jobs and merge-queue builds, the reserved name waitlist is the retired waitlist's, and billing's context source is kept only so search embeddings noted before the context service was retired are still charged and closed. Checked: cargo test for g1t-contracts, g1t-billing (243) and g1t-actions (107), the wasm builds of billing and actions, and the ops scripts' tests. | 2 | //! security scans, the actions cache, and two billing measures itself each |
| 3 | //! day: private repository storage and git operations. `context` (search | |
| 4 | //! embeddings) is no longer reported, since the context service was | |
| 5 | //! retired; it stays a source so usage noted before is still charged and | |
| 6 | //! closed. | |
| Projects: when each was last pushed, and how active it is lately | 7 | //! |
| 8 | //! Each reports what it cost g1t so far this month (`note_pending`), so the | |
| 9 | //! workspace's limit counts it as it happens. When the month is over, | |
| 10 | //! billing charges it once: at cost plus the margin, on the account's | |
| 11 | //! terms, after the plan's included usage and the trial credit (see | |
| 12 | //! `credits`), dated the month's last second so it falls in that month's | |
| 13 | //! statement and invoice. | |
| 14 | //! | |
| 15 | //! The forge is free for every workspace up to the same amounts, on the | |
| 16 | //! plan or not; past them, a workspace on the plan pays at cost plus the | |
| 17 | //! margin and is never refused or slowed, and a free workspace is never | |
| 18 | //! charged but is held to them. | |
| 19 | //! | |
| 20 | //! **Git operations.** Cloudflare Artifacts charges g1t $0.15 per 1,000 | |
| 21 | //! operations (clones, fetches, pushes) from 2026-10-14. The repos service | |
| 22 | //! counts those through g1t's git endpoints. Every workspace has | |
| 23 | //! `GIT_OPERATIONS_INCLUDED` (50,000) a month free; past it, the plan pays | |
| 24 | //! at cost plus the margin, and a free workspace is slowed down instead | |
| 25 | //! (the repos service's `GIT_OPERATIONS_FREE_CAP`, the same number). | |
| 26 | //! | |
| 27 | //! **Actions cache.** The actions service reports what each workspace's | |
| 28 | //! `actions/cache` entries held each day (source `cache`), at what R2 | |
| 29 | //! charges g1t to store them ($0.015 a GB-month). Only the plan is charged | |
| 30 | //! for it, from the first byte; a free workspace is never charged, and its | |
| 31 | //! repositories are held to the cache's quota like everyone's. | |
| 32 | //! | |
| 33 | //! **Storage.** The git store does not report a repository's size, so the | |
| 34 | //! repos service counts the packs pushed through g1t's git endpoints (see | |
| 35 | //! `g1t_contracts::repos::StorageArgs`): a lower bound. Each day billing | |
| 36 | //! records what each workspace's private repositories hold and what is | |
| 37 | //! free that day (`FREE_PRIVATE_STORAGE_BYTES`, 1 GB, for everyone). Like | |
| 38 | //! Cloudflare's own storage billing, a month's GB-months are the days' | |
| 39 | //! amounts past the free one, added up and divided by 30, and only the | |
| 40 | //! plan is charged for them. A free workspace is never charged for | |
| 41 | //! storage: pushes to its private repositories stop once they hold its | |
| 42 | //! free amount. Public repositories are never charged. | |
| 43 | ||
| 44 | use g1t_contracts::billing::PlanKind; | |
| 45 | use g1t_contracts::new_id; | |
| 46 | use g1t_contracts::repos::{GitOperationsArgs, StorageArgs, WorkspaceGitOperations, WorkspaceStorage}; | |
| 47 | use g1t_contracts::time::rfc3339; | |
| 48 | use g1t_kit::now_ms; | |
| 49 | use serde::Deserialize; | |
| 50 | use worker::Result; | |
| 51 | ||
| 52 | use crate::credits::{self, Drawn, Eligible}; | |
| 53 | use crate::{Billing, optional}; | |
| 54 | ||
| 55 | /// Sources billing charges itself when the month is over. | |
| 56 | pub(crate) const CHARGED_HERE: [&str; 5] = ["security", "context", "storage", "git", "cache"]; | |
| 57 | ||
| 58 | /// Sources only the plan is charged for: a free workspace's are not kept. | |
| 59 | pub(crate) const PLAN_ONLY: [&str; 1] = ["cache"]; | |
| 60 | ||
| 61 | /// Sources g1t pays for itself on a free workspace: security scans and | |
| Comments and the audit log stop speaking of g1t's agents as current. A workspace's runner settings send g1t's own work (checks, merge checks and the merge queue) to self-hosted runners, and the audit log now says "g1t's work on self-hosted runners" where it said "Agents on self-hosted runners"; the fields keep their names. The runner errors script names the sandboxes that run checks, workflow jobs and merge-queue builds, the reserved name waitlist is the retired waitlist's, and billing's context source is kept only so search embeddings noted before the context service was retired are still charged and closed. Checked: cargo test for g1t-contracts, g1t-billing (243) and g1t-actions (107), the wasm builds of billing and actions, and the ops scripts' tests. | 62 | /// (from before the context service was retired) search embeddings. No security feature is held back for the plan, and |
| Projects: when each was last pushed, and how active it is lately | 63 | /// a free workspace never runs up a bill, so on a workspace without the |
| 64 | /// plan they are recorded at cost, covered by g1t, and charged nothing | |
| 65 | /// (not even from the trial). On the plan they are charged like any usage. | |
| 66 | pub(crate) const COVERED_FOR_FREE: [&str; 2] = ["security", "context"]; | |
| 67 | ||
| 68 | /// What pays for a month-end source's charge: the plan's included usage | |
| 69 | /// and the trial, and for a covered source on a free workspace, g1t. | |
| 70 | pub(crate) fn month_end_eligible(source: &str, plan: bool) -> Eligible { | |
| 71 | let covered = !plan && COVERED_FOR_FREE.contains(&source); | |
| 72 | Eligible { trial: !covered, repo: None, cover_rest: covered } | |
| 73 | } | |
| 74 | ||
| 75 | /// What Artifacts charges g1t, when the price book cannot be read: $0.50 | |
| 76 | /// a GB-month of storage, and $0.15 per 1,000 git operations. | |
| 77 | pub(crate) const STORAGE_MICROS_PER_GB_MONTH: i64 = 500_000; | |
| 78 | pub(crate) const GIT_MICROS_PER_THOUSAND: i64 = 150_000; | |
| 79 | ||
| 80 | /// When Cloudflare starts charging for Artifacts operations: none before | |
| 81 | /// count. | |
| 82 | pub(crate) const GIT_BILLING_STARTS: &str = "2026-10-14T00"; | |
| 83 | ||
| 84 | /// What git operations past the free amount cost g1t, at | |
| 85 | /// `micros_per_thousand`: nothing up to it. | |
| 86 | pub(crate) fn git_cost(operations: u64, included: u64, micros_per_thousand: f64) -> i64 { | |
| 87 | let past = operations.saturating_sub(included); | |
| 88 | (past as f64 * micros_per_thousand / 1000.0).ceil() as i64 | |
| 89 | } | |
| 90 | ||
| 91 | /// The first hour of `month` to count git operations from. | |
| 92 | pub(crate) fn git_since(month: &str) -> String { | |
| 93 | let start = format!("{month}-01T00"); | |
| 94 | if start.as_str() < GIT_BILLING_STARTS { GIT_BILLING_STARTS.to_owned() } else { start } | |
| 95 | } | |
| 96 | ||
| 97 | /// A gigabyte, as Cloudflare bills storage. | |
| 98 | pub(crate) const GB: f64 = 1_000_000_000.0; | |
| 99 | ||
| 100 | /// GB-months from a month's daily measures, each `(private, free)` bytes: | |
| 101 | /// what was past the free amount each day, over 30 days. | |
| 102 | pub(crate) fn storage_gb_months(days: &[(i64, i64)]) -> f64 { | |
| 103 | days.iter().map(|(private, free)| (private - free).max(0) as f64).sum::<f64>() / GB / 30.0 | |
| 104 | } | |
| 105 | ||
| 106 | /// GB-months of package storage past the free amounts from a month's | |
| 107 | /// daily measures, each `(public, private, public_free, private_free)`: | |
| 108 | /// public and private past their own amounts, over 30 days. | |
| 109 | pub(crate) fn package_gb_months(days: &[(i64, i64, i64, i64)]) -> f64 { | |
| 110 | days.iter() | |
| 111 | .map(|(public, private, public_free, private_free)| ((public - public_free).max(0) + (private - private_free).max(0)) as f64) | |
| 112 | .sum::<f64>() | |
| 113 | / GB | |
| 114 | / 30.0 | |
| 115 | } | |
| 116 | ||
| 117 | /// What R2 charges g1t a GB-month, when the price book cannot be read. | |
| 118 | pub(crate) const PACKAGE_MICROS_PER_GB_MONTH: i64 = 15_000; | |
| 119 | ||
| 120 | /// What `gb_months` cost g1t at `micros_per_gb_month`, rounded up. | |
| 121 | pub(crate) fn storage_cost(gb_months: f64, micros_per_gb_month: f64) -> i64 { | |
| 122 | (gb_months * micros_per_gb_month).ceil() as i64 | |
| 123 | } | |
| 124 | ||
| 125 | /// What a source is called on the statement. | |
| 126 | pub(crate) fn title(source: &str) -> &'static str { | |
| 127 | match source { | |
| 128 | "security" => "Security scans", | |
| 129 | "context" => "Search embeddings", | |
| 130 | "storage" => "Private repository storage past the free amount", | |
| 131 | "package_storage" => "Package storage past the free amounts", | |
| 132 | "git" => "Git operations past the free amount", | |
| 133 | "cache" => "Actions cache storage", | |
| 134 | "domains" => "Custom domains", | |
| 135 | _ => "Metered usage", | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | /// A usage entry to put on the ledger. | |
| 140 | pub(crate) struct UsageLine<'a> { | |
| 141 | pub workspace: &'a str, | |
| 142 | /// What the workspace is charged, after terms and what paid for it. | |
| 143 | pub charged: i64, | |
| 144 | pub description: &'a str, | |
| 145 | pub repo: Option<&'a str>, | |
| 146 | pub task: &'a str, | |
| 147 | pub cost: i64, | |
| 148 | pub reference: &'a str, | |
| 149 | pub created_at: &'a str, | |
| 150 | pub drawn: Drawn, | |
| 151 | } | |
| 152 | ||
| 153 | impl Billing { | |
| 154 | /// Puts a usage entry on the ledger and takes it off the balance, as | |
| 155 | /// one write. | |
| 156 | pub(crate) async fn post_usage(&self, line: UsageLine<'_>) -> Result<()> { | |
| 157 | // The price versions it was charged at (pricing.rs). | |
| 158 | let mut versions = Vec::new(); | |
| 159 | for meter in crate::pricing::meters_of(line.task) { | |
| 160 | versions.extend(self.version_now(meter).await?); | |
| 161 | } | |
| 162 | let price_version = versions.join(","); | |
| 163 | self.db | |
| 164 | .batch(vec![ | |
| 165 | self.db | |
| 166 | .prepare( | |
| 167 | "INSERT INTO ledger | |
| 168 | (id, workspace, kind, amount_micros, description, repo, task, cost_micros, reference, | |
| 169 | created_at, billed_to, credit_micros, trial_micros, oss_micros, price_version) | |
| 170 | VALUES (?, ?, 'usage', ?, ?, ?, ?, ?, ?, ?, 'g1t', ?, ?, ?, ?)", | |
| 171 | ) | |
| 172 | .bind(&[ | |
| 173 | new_id("led", now_ms()).into(), | |
| 174 | line.workspace.into(), | |
| 175 | (-(line.charged as f64)).into(), | |
| 176 | line.description.into(), | |
| 177 | optional(line.repo), | |
| 178 | line.task.into(), | |
| 179 | (line.cost as f64).into(), | |
| 180 | line.reference.into(), | |
| 181 | line.created_at.into(), | |
| 182 | (line.drawn.credit as f64).into(), | |
| 183 | (line.drawn.trial as f64).into(), | |
| 184 | (line.drawn.oss as f64).into(), | |
| 185 | optional(Some(price_version.as_str()).filter(|v| !v.is_empty())), | |
| 186 | ])?, | |
| 187 | self.db | |
| 188 | .prepare( | |
| 189 | "INSERT INTO accounts (workspace, balance_micros, created_at) VALUES (?1, ?2, ?3) | |
| 190 | ON CONFLICT (workspace) DO UPDATE SET balance_micros = balance_micros + ?2", | |
| 191 | ) | |
| 192 | .bind(&[line.workspace.into(), (-(line.charged as f64)).into(), rfc3339(now_ms()).into()])?, | |
| 193 | ]) | |
| 194 | .await?; | |
| 195 | Ok(()) | |
| 196 | } | |
| 197 | ||
| 198 | /// Writes down what a source cost g1t so far in `month`, what it will | |
| 199 | /// be charged, and how much of it there was (`detail`, for the Billing | |
| 200 | /// page), replacing the last figure. | |
| 201 | pub(crate) async fn set_pending( | |
| 202 | &self, | |
| 203 | workspace: &str, | |
| 204 | source: &str, | |
| 205 | month: &str, | |
| 206 | cost_micros: i64, | |
| 207 | detail: Option<&str>, | |
| 208 | ) -> Result<()> { | |
| 209 | // A covered source on a free workspace is never charged, so it does | |
| 210 | // not count toward the workspace's limit either. | |
| 211 | let covered = COVERED_FOR_FREE.contains(&source) && !self.has_plan(&workspace.to_lowercase()).await?; | |
| 212 | let charge = if covered { 0 } else { credits::with_margin(cost_micros, self.margin_percent) }; | |
| 213 | self.db | |
| 214 | .prepare( | |
| 215 | "INSERT INTO pending_usage (workspace, source, month, charge_micros, cost_micros, updated_at, detail) | |
| 216 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) | |
| 217 | ON CONFLICT (workspace, source, month) DO UPDATE SET | |
| 218 | charge_micros = ?4, cost_micros = ?5, updated_at = ?6, detail = COALESCE(?7, detail)", | |
| 219 | ) | |
| 220 | .bind(&[ | |
| 221 | workspace.to_lowercase().into(), | |
| 222 | source.into(), | |
| 223 | month.into(), | |
| 224 | (charge as f64).into(), | |
| 225 | (cost_micros.max(0) as f64).into(), | |
| 226 | rfc3339(now_ms()).into(), | |
| 227 | optional(detail), | |
| 228 | ])? | |
| 229 | .run() | |
| 230 | .await?; | |
| 231 | Ok(()) | |
| 232 | } | |
| 233 | ||
| 234 | /// Charges every month that is over for the usage billing charges | |
| 235 | /// itself, once each. | |
| 236 | pub(crate) async fn charge_pending(&self) -> Result<()> { | |
| 237 | if self.stripe.is_none() { | |
| 238 | return Ok(()); | |
| 239 | } | |
| 240 | let now = rfc3339(now_ms()); | |
| 241 | let current = credits::month_of(&now); | |
| 242 | #[derive(Deserialize)] | |
| 243 | struct Row { | |
| 244 | workspace: String, | |
| 245 | source: String, | |
| 246 | month: String, | |
| 247 | cost_micros: Option<i64>, | |
| 248 | } | |
| 249 | let marks = CHARGED_HERE.iter().map(|s| format!("'{s}'")).collect::<Vec<_>>().join(", "); | |
| 250 | let due = self | |
| 251 | .db | |
| 252 | .prepare(format!( | |
| 253 | "SELECT workspace, source, month, cost_micros FROM pending_usage | |
| 254 | WHERE month < ? AND charged_at IS NULL AND source IN ({marks}) ORDER BY month LIMIT 50" | |
| 255 | )) | |
| 256 | .bind(&[current.as_str().into()])? | |
| 257 | .all() | |
| 258 | .await? | |
| 259 | .results::<Row>()?; | |
| 260 | for row in due { | |
| 261 | // Claimed first, so two crons never charge it twice. | |
| 262 | let claimed = self | |
| 263 | .db | |
| 264 | .prepare( | |
| 265 | "UPDATE pending_usage SET charged_at = ?1 | |
| 266 | WHERE workspace = ?2 AND source = ?3 AND month = ?4 AND charged_at IS NULL RETURNING workspace", | |
| 267 | ) | |
| 268 | .bind(&[now.as_str().into(), row.workspace.as_str().into(), row.source.as_str().into(), row.month.as_str().into()])? | |
| 269 | .first::<serde_json::Value>(None) | |
| 270 | .await?; | |
| 271 | let cost = row.cost_micros.unwrap_or(0); | |
| 272 | if claimed.is_none() || cost <= 0 { | |
| 273 | continue; | |
| 274 | } | |
| 275 | let base = credits::with_margin(cost, self.margin_percent); | |
| 276 | let (charge, terms_note, discount) = self.charged(&row.workspace, base).await?; | |
| 277 | let plan = self.has_plan(&row.workspace).await?; | |
| 278 | let drawn = self.draw(&row.workspace, charge, &row.month, &month_end_eligible(&row.source, plan)).await?; | |
| 279 | let detail = if row.source == "storage" { | |
| 280 | let gb_months = self.gb_months(&row.workspace, &row.month).await?; | |
| 281 | format!(": {gb_months:.2} GB-months") | |
| 282 | } else { | |
| 283 | String::new() | |
| 284 | }; | |
| 285 | let description = format!("{} in {}{detail}{terms_note}{}", title(&row.source), row.month, drawn.note()); | |
| 286 | let reference = format!("{}/{}/{}", row.source, row.workspace, row.month); | |
| 287 | let created_at = credits::month_end(&row.month); | |
| 288 | self.post_usage(UsageLine { | |
| 289 | workspace: &row.workspace, | |
| 290 | charged: charge - drawn.total(), | |
| 291 | description: &description, | |
| 292 | repo: None, | |
| 293 | task: &row.source, | |
| 294 | cost, | |
| 295 | reference: &reference, | |
| 296 | created_at: &created_at, | |
| 297 | drawn, | |
| 298 | }) | |
| 299 | .await?; | |
| 300 | self.record_discount(&reference, discount).await?; | |
| 301 | } | |
| 302 | Ok(()) | |
| 303 | } | |
| 304 | ||
| 305 | /// A workspace's private storage past the free amount in `month`. | |
| 306 | async fn gb_months(&self, workspace: &str, month: &str) -> Result<f64> { | |
| 307 | #[derive(Deserialize)] | |
| 308 | struct Day { | |
| 309 | private_bytes: i64, | |
| 310 | free_bytes: i64, | |
| 311 | } | |
| 312 | let days = self | |
| 313 | .db | |
| 314 | .prepare("SELECT private_bytes, free_bytes FROM storage_days WHERE workspace = ? AND substr(day, 1, 7) = ?") | |
| 315 | .bind(&[workspace.into(), month.into()])? | |
| 316 | .all() | |
| 317 | .await? | |
| 318 | .results::<Day>()?; | |
| 319 | Ok(storage_gb_months(&days.iter().map(|d| (d.private_bytes, d.free_bytes)).collect::<Vec<_>>())) | |
| 320 | } | |
| 321 | ||
| 322 | /// Once a day: what each workspace's private repositories hold, and | |
| 323 | /// what this month's storage past the free amount comes to so far. | |
| 324 | pub(crate) async fn measure_storage(&self) -> Result<()> { | |
| 325 | let Some(repos) = &self.repos else { return Ok(()) }; | |
| 326 | let list: Vec<WorkspaceStorage> = g1t_kit::call(repos, "storage", &StorageArgs {}).await?; | |
| 327 | let now = rfc3339(now_ms()); | |
| 328 | let (day, month) = (&now[..10], credits::month_of(&now)); | |
| 329 | let price = self.price("private_storage").await?.map_or(STORAGE_MICROS_PER_GB_MONTH as f64, |(cost, _)| cost); | |
| 330 | for workspace in list { | |
| 331 | let slug = workspace.namespace.to_lowercase(); | |
| 332 | let plan = self.has_plan(&slug).await?; | |
| 333 | // The same free amount for everyone: the plan pays past it. | |
| 334 | let free = self.plans.free_storage_bytes; | |
| 335 | self.db | |
| 336 | .prepare( | |
| 337 | "INSERT INTO storage_days (workspace, day, private_bytes, free_bytes) VALUES (?1, ?2, ?3, ?4) | |
| 338 | ON CONFLICT (workspace, day) DO UPDATE SET private_bytes = ?3, free_bytes = ?4", | |
| 339 | ) | |
| 340 | .bind(&[slug.as_str().into(), day.into(), (workspace.private_bytes as f64).into(), (free as f64).into()])? | |
| 341 | .run() | |
| 342 | .await?; | |
| 343 | // Only the plan pays for storage past its amount. A free | |
| 344 | // workspace is never charged: the repos service stops its pushes | |
| 345 | // to private repositories once it is full (see git_ops.rs there). | |
| 346 | let gb_months = if plan { self.gb_months(&slug, &month).await? } else { 0.0 }; | |
| 347 | if gb_months > 0.0 { | |
| 348 | let detail = format!("{gb_months:.2} GB-months past the free {}", crate::features::bytes(free)); | |
| 349 | self.set_pending(&slug, "storage", &month, storage_cost(gb_months, price), Some(&detail)).await?; | |
| 350 | } | |
| 351 | } | |
| 352 | Ok(()) | |
| 353 | } | |
| 354 | ||
| 355 | /// Once a day: what each workspace's packages hold, public and private, | |
| 356 | /// from the packages service, and for the plan what this month's | |
| 357 | /// storage past the free amounts comes to so far. A free workspace is | |
| 358 | /// never charged: the packages service refuses its pushes past them. | |
| 359 | pub(crate) async fn measure_packages(&self) -> Result<()> { | |
| 360 | #[derive(Deserialize)] | |
| 361 | struct Held { | |
| 362 | workspace: String, | |
| 363 | public_bytes: i64, | |
| 364 | private_bytes: i64, | |
| 365 | } | |
| 366 | #[derive(Deserialize)] | |
| 367 | struct Day { | |
| 368 | public_bytes: i64, | |
| 369 | private_bytes: i64, | |
| 370 | public_free_bytes: i64, | |
| 371 | private_free_bytes: i64, | |
| 372 | } | |
| 373 | let Some(packages) = &self.packages else { return Ok(()) }; | |
| 374 | let list: Vec<Held> = g1t_kit::call(packages, "storage_all", &serde_json::json!({})).await?; | |
| 375 | let now = rfc3339(now_ms()); | |
| 376 | let (day, month) = (&now[..10], credits::month_of(&now)); | |
| 377 | let price = self.price("package_storage").await?.map_or(PACKAGE_MICROS_PER_GB_MONTH as f64, |(cost, _)| cost); | |
| 378 | let (public_free, private_free) = (self.plans.public_package_free_bytes, self.plans.private_package_free_bytes); | |
| 379 | for held in list { | |
| 380 | let slug = held.workspace.to_lowercase(); | |
| 381 | self.db | |
| 382 | .prepare( | |
| 383 | "INSERT INTO package_storage_days (workspace, day, public_bytes, private_bytes, public_free_bytes, private_free_bytes) | |
| 384 | VALUES (?1, ?2, ?3, ?4, ?5, ?6) | |
| 385 | ON CONFLICT (workspace, day) DO UPDATE SET public_bytes = ?3, private_bytes = ?4, public_free_bytes = ?5, private_free_bytes = ?6", | |
| 386 | ) | |
| 387 | .bind(&[ | |
| 388 | slug.as_str().into(), | |
| 389 | day.into(), | |
| 390 | (held.public_bytes as f64).into(), | |
| 391 | (held.private_bytes as f64).into(), | |
| 392 | (public_free as f64).into(), | |
| 393 | (private_free as f64).into(), | |
| 394 | ])? | |
| 395 | .run() | |
| 396 | .await?; | |
| 397 | if !self.has_plan(&slug).await? { | |
| 398 | continue; | |
| 399 | } | |
| 400 | let days = self | |
| 401 | .db | |
| 402 | .prepare( | |
| 403 | "SELECT public_bytes, private_bytes, public_free_bytes, private_free_bytes FROM package_storage_days | |
| 404 | WHERE workspace = ? AND substr(day, 1, 7) = ?", | |
| 405 | ) | |
| 406 | .bind(&[slug.as_str().into(), month.as_str().into()])? | |
| 407 | .all() | |
| 408 | .await? | |
| 409 | .results::<Day>()?; | |
| 410 | let gb_months = package_gb_months( | |
| 411 | &days.iter().map(|d| (d.public_bytes, d.private_bytes, d.public_free_bytes, d.private_free_bytes)).collect::<Vec<_>>(), | |
| 412 | ); | |
| 413 | if gb_months > 0.0 { | |
| 414 | let detail = format!( | |
| 415 | "{gb_months:.2} GB-months past the free {} public and {} private", | |
| 416 | crate::features::bytes(public_free), | |
| 417 | crate::features::bytes(private_free) | |
| 418 | ); | |
| 419 | self.set_pending(&slug, "package_storage", &month, storage_cost(gb_months, price), Some(&detail)).await?; | |
| 420 | } | |
| 421 | } | |
| 422 | Ok(()) | |
| 423 | } | |
| 424 | ||
| 425 | /// Git operations this month for each workspace, from the repos | |
| 426 | /// service: what is past the free amount goes to the month's pending | |
| 427 | /// usage for workspaces on the plan, which are never slowed or refused | |
| 428 | /// for them. Free workspaces are never charged for them. | |
| 429 | pub(crate) async fn measure_git(&self) -> Result<()> { | |
| 430 | let Some(repos) = &self.repos else { return Ok(()) }; | |
| 431 | let month = credits::month_of(&rfc3339(now_ms())); | |
| 432 | let list: Vec<WorkspaceGitOperations> = | |
| 433 | g1t_kit::call(repos, "git_operations", &GitOperationsArgs { since: Some(git_since(&month)), month: month.clone(), namespace: None }).await?; | |
| 434 | let price = self.price("git_operations").await?.map_or(GIT_MICROS_PER_THOUSAND as f64, |(cost, _)| cost); | |
| 435 | for workspace in list { | |
| 436 | let slug = workspace.namespace.to_lowercase(); | |
| 437 | if self.plan_kind(&slug).await? == PlanKind::Free { | |
| 438 | continue; | |
| 439 | } | |
| 440 | let cost = git_cost(workspace.operations, self.plans.git_included, price); | |
| 441 | if cost > 0 { | |
| 442 | let detail = format!( | |
| 443 | "{} operations, {} of them free", | |
| 444 | crate::features::thousands(workspace.operations), | |
| 445 | crate::features::thousands(self.plans.git_included) | |
| 446 | ); | |
| 447 | self.set_pending(&slug, "git", &month, cost, Some(&detail)).await?; | |
| 448 | } | |
| 449 | } | |
| 450 | Ok(()) | |
| 451 | } | |
| 452 | ||
| 453 | /// The workspace's git operations this month, as last measured. | |
| 454 | pub(crate) async fn git_operations_this_month(&self, workspace: &str) -> Result<u64> { | |
| 455 | let Some(repos) = &self.repos else { return Ok(0) }; | |
| 456 | let month = credits::month_of(&rfc3339(now_ms())); | |
| 457 | let list: Result<Vec<WorkspaceGitOperations>> = | |
| 458 | g1t_kit::call(repos, "git_operations", &GitOperationsArgs { since: Some(format!("{month}-01T00")), month, namespace: Some(workspace.to_lowercase()) }).await; | |
| 459 | Ok(list | |
| 460 | .ok() | |
| 461 | .and_then(|list| list.into_iter().find(|w| w.namespace.eq_ignore_ascii_case(workspace))) | |
| 462 | .map_or(0, |w| w.operations)) | |
| 463 | } | |
| 464 | } | |
| 465 | ||
| 466 | #[cfg(test)] | |
| 467 | mod tests { | |
| 468 | use super::*; | |
| 469 | ||
| 470 | #[test] | |
| 471 | fn scans_and_embeddings_on_a_free_workspace_are_covered_by_g1t() { | |
| 472 | // No plan: g1t pays, and the trial is left alone. | |
| 473 | let free = month_end_eligible("security", false); | |
| 474 | assert!(free.cover_rest && !free.trial); | |
| 475 | assert!(month_end_eligible("context", false).cover_rest); | |
| 476 | // On the plan: charged like any usage, from its included usage first. | |
| 477 | let plan = month_end_eligible("security", true); | |
| 478 | assert!(!plan.cover_rest && plan.trial); | |
| 479 | // Storage, git and the cache are never kept for a free workspace; | |
| 480 | // were one at the close, it would not be covered either. | |
| 481 | assert!(!month_end_eligible("storage", false).cover_rest); | |
| 482 | assert!(!month_end_eligible("git", false).cover_rest); | |
| 483 | // A covered source is still a source billing charges at the close. | |
| 484 | assert!(COVERED_FOR_FREE.iter().all(|s| CHARGED_HERE.contains(s))); | |
| 485 | } | |
| 486 | ||
| 487 | #[test] | |
| 488 | fn storage_past_the_free_amount_is_counted_by_the_day() { | |
| 489 | // 3 GB private with 1 GB free, every day of a 30-day month: 2 GB-months. | |
| 490 | let month = vec![(3_000_000_000, 1_000_000_000); 30]; | |
| 491 | assert!((storage_gb_months(&month) - 2.0).abs() < 1e-9); | |
| 492 | // Under the free amount: nothing. | |
| 493 | assert_eq!(storage_gb_months(&[(500_000_000, 1_000_000_000); 30]), 0.0); | |
| 494 | // The plan has the same 1 GB free: 11 GB on it all month is 10 | |
| 495 | // GB-months, $5.00 to g1t, $6.00 charged, from its included usage. | |
| 496 | let days = vec![(11_000_000_000, 1_000_000_000); 30]; | |
| 497 | assert!((storage_gb_months(&days) - 10.0).abs() < 1e-9); | |
| 498 | assert_eq!(credits::with_margin(storage_cost(storage_gb_months(&days), STORAGE_MICROS_PER_GB_MONTH as f64), 20), 6_000_000); | |
| 499 | // Ten days of 4 GB past it: a third of 4 GB-months. | |
| 500 | let days = vec![(5_000_000_000, 1_000_000_000); 10]; | |
| 501 | assert!((storage_gb_months(&days) - 4.0 / 3.0).abs() < 1e-9); | |
| 502 | } | |
| 503 | ||
| 504 | #[test] | |
| 505 | fn package_storage_counts_public_and_private_past_their_own_free_amounts() { | |
| 506 | let gb = GB as i64; | |
| 507 | // 12 GB public with 10 free, 0.5 GB private with 0.5 free: 2 GB past, for 15 days. | |
| 508 | let days = vec![(12 * gb, gb / 2, 10 * gb, gb / 2); 15]; | |
| 509 | assert!((package_gb_months(&days) - 1.0).abs() < 1e-9); | |
| 510 | // Under both amounts: nothing. | |
| 511 | assert_eq!(package_gb_months(&[(gb, gb / 4, 10 * gb, gb / 2)]), 0.0); | |
| 512 | // At R2's rate, a GB-month is $0.015. | |
| 513 | assert_eq!(storage_cost(1.0, PACKAGE_MICROS_PER_GB_MONTH as f64), 15_000); | |
| 514 | } | |
| 515 | ||
| 516 | #[test] | |
| 517 | fn storage_is_priced_at_cloudflares_rate_plus_the_margin() { | |
| 518 | // $0.50 a GB-month to g1t: 2 GB-months cost $1.00, charged $1.20. | |
| 519 | let cost = storage_cost(2.0, 500_000.0); | |
| 520 | assert_eq!(cost, 1_000_000); | |
| 521 | assert_eq!(credits::with_margin(cost, 20), 1_200_000); | |
| 522 | // A fraction of a millionth rounds up. | |
| 523 | assert_eq!(storage_cost(0.000_000_001, 500_000.0), 1); | |
| 524 | } | |
| 525 | ||
| 526 | #[test] | |
| 527 | fn embeddings_and_scans_are_charged_at_cost_plus_the_margin() { | |
| 528 | // 10 million tokens at $0.067 a million: $0.67, charged $0.804. | |
| 529 | assert_eq!(credits::with_margin(670_000, 20), 804_000); | |
| 530 | assert_eq!(title("context"), "Search embeddings"); | |
| 531 | assert_eq!(title("security"), "Security scans"); | |
| 532 | assert!(CHARGED_HERE.contains(&"storage") && !CHARGED_HERE.contains(&"deployments")); | |
| 533 | assert!(CHARGED_HERE.contains(&"git")); | |
| 534 | } | |
| 535 | ||
| 536 | #[test] | |
| 537 | fn git_operations_are_charged_past_the_free_amount_at_cloudflares_price() { | |
| 538 | // $0.15 per 1,000 to g1t; 50,000 a month free for everyone. | |
| 539 | let per_thousand = GIT_MICROS_PER_THOUSAND as f64; | |
| 540 | let free = crate::credits::Config::default().git_included; | |
| 541 | assert_eq!(free, 50_000); | |
| 542 | assert_eq!(git_cost(49_000, free, per_thousand), 0); | |
| 543 | assert_eq!(git_cost(50_000, free, per_thousand), 0); | |
| 544 | // A workspace on the plan pushes on past it, and pays: 70,000 | |
| 545 | // operations are 20,000 past it, $3.00 to g1t, $3.60 charged. | |
| 546 | assert_eq!(git_cost(70_000, free, per_thousand), 3_000_000); | |
| 547 | assert_eq!(credits::with_margin(git_cost(70_000, free, per_thousand), 20), 3_600_000); | |
| 548 | // A million in a month: no cap, $142.50 to g1t. | |
| 549 | assert_eq!(git_cost(1_000_000, free, per_thousand), 142_500_000); | |
| 550 | // One past it: a fraction of a cent, rounded up to a millionth. | |
| 551 | assert_eq!(git_cost(50_001, free, per_thousand), 150); | |
| 552 | assert_eq!(title("git"), "Git operations past the free amount"); | |
| 553 | } | |
| 554 | ||
| 555 | #[test] | |
| 556 | fn git_operations_count_from_when_cloudflare_starts_charging() { | |
| 557 | assert_eq!(git_since("2026-10"), "2026-10-14T00"); | |
| 558 | assert_eq!(git_since("2026-11"), "2026-11-01T00"); | |
| 559 | assert_eq!(git_since("2026-09"), "2026-10-14T00"); | |
| 560 | } | |
| 561 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.