pr_01m47d24b0e6n91zwymwxg0vpx/services/billing/src/storage.rs

377 lines17,261 bytesCodeBlame
1//! Usage other services meter through the month, charged once it is over:
2//! security scans, search embeddings, and two billing measures itself each
3//! day: private repository storage and git operations.
4//!
5//! Each reports what it cost g1t so far this month (`note_pending`), so the
6//! workspace's limit counts it as it happens. When the month is over,
7//! billing charges it once: at cost plus the margin, on the account's
8//! terms, after the plan's included usage and the trial credit (see
9//! `credits`), dated the month's last second so it falls in that month's
10//! statement and invoice.
11//!
12//! **Git operations.** Cloudflare Artifacts charges g1t $0.15 per 1,000
13//! operations (clones, fetches, pushes) from 2026-10-14. The repos service
14//! counts those through g1t's git endpoints. Every workspace has
15//! `GIT_OPERATIONS_INCLUDED` (10,000) a month, about twenty times what an
16//! active workspace uses; past it, the plan pays at cost plus the margin.
17//! A free workspace is never charged for them: past
18//! `GIT_OPERATIONS_FREE_CAP` (50,000) in a month, the repos service slows
19//! it down instead.
20//!
21//! **Storage.** The git store does not report a repository's size, so the
22//! repos service counts the packs pushed through g1t's git endpoints (see
23//! `g1t_contracts::repos::StorageArgs`): a lower bound. Each day billing
24//! records what each workspace's private repositories hold and what is
25//! free that day (`FREE_PRIVATE_STORAGE_BYTES`, 1 GB, or
26//! `PLAN_PRIVATE_STORAGE_BYTES`, 10 GB, on the plan). Like Cloudflare's own storage
27//! billing, a month's GB-months are the days' amounts past the free one,
28//! added up and divided by 30, and only the plan is charged for them. A
29//! free workspace is never charged for storage: pushes to its private
30//! repositories stop once they hold its free amount. Public repositories
31//! are never charged.
32
33use g1t_contracts::billing::PlanKind;
34use g1t_contracts::new_id;
35use g1t_contracts::repos::{GitOperationsArgs, StorageArgs, WorkspaceGitOperations, WorkspaceStorage};
36use g1t_contracts::time::rfc3339;
37use g1t_kit::now_ms;
38use serde::Deserialize;
39use worker::Result;
40
41use crate::credits::{self, Drawn, Eligible};
42use crate::{Billing, optional};
43
44/// Sources billing charges itself when the month is over.
45pub(crate) const CHARGED_HERE: [&str; 4] = ["security", "context", "storage", "git"];
46
47/// When Cloudflare starts charging for Artifacts operations: none before
48/// count.
49pub(crate) const GIT_BILLING_STARTS: &str = "2026-10-14T00";
50
51/// What git operations past what is included cost g1t, at
52/// `micros_per_thousand`: nothing up to the included amount.
53pub(crate) fn git_cost(operations: u64, included: u64, micros_per_thousand: f64) -> i64 {
54 let past = operations.saturating_sub(included);
55 (past as f64 * micros_per_thousand / 1000.0).ceil() as i64
56}
57
58/// The first hour of `month` to count git operations from.
59pub(crate) fn git_since(month: &str) -> String {
60 let start = format!("{month}-01T00");
61 if start.as_str() < GIT_BILLING_STARTS { GIT_BILLING_STARTS.to_owned() } else { start }
62}
63
64/// A gigabyte, as Cloudflare bills storage.
65pub(crate) const GB: f64 = 1_000_000_000.0;
66
67/// GB-months from a month's daily measures, each `(private, free)` bytes:
68/// what was past the free amount each day, over 30 days.
69pub(crate) fn storage_gb_months(days: &[(i64, i64)]) -> f64 {
70 days.iter().map(|(private, free)| (private - free).max(0) as f64).sum::<f64>() / GB / 30.0
71}
72
73/// What `gb_months` cost g1t at `micros_per_gb_month`, rounded up.
74pub(crate) fn storage_cost(gb_months: f64, micros_per_gb_month: f64) -> i64 {
75 (gb_months * micros_per_gb_month).ceil() as i64
76}
77
78/// What a source is called on the statement.
79pub(crate) fn title(source: &str) -> &'static str {
80 match source {
81 "security" => "Security scans",
82 "context" => "Search embeddings",
83 "storage" => "Private repository storage past the free amount",
84 "git" => "Git operations past the included amount",
85 _ => "Metered usage",
86 }
87}
88
89/// A usage entry to put on the ledger.
90pub(crate) struct UsageLine<'a> {
91 pub workspace: &'a str,
92 /// What the workspace is charged, after terms and what paid for it.
93 pub charged: i64,
94 pub description: &'a str,
95 pub repo: Option<&'a str>,
96 pub task: &'a str,
97 pub cost: i64,
98 pub reference: &'a str,
99 pub created_at: &'a str,
100 pub drawn: Drawn,
101}
102
103impl Billing {
104 /// Puts a usage entry on the ledger and takes it off the balance, as
105 /// one write.
106 pub(crate) async fn post_usage(&self, line: UsageLine<'_>) -> Result<()> {
107 self.db
108 .batch(vec![
109 self.db
110 .prepare(
111 "INSERT INTO ledger
112 (id, workspace, kind, amount_micros, description, repo, task, cost_micros, reference,
113 created_at, billed_to, credit_micros, trial_micros, oss_micros)
114 VALUES (?, ?, 'usage', ?, ?, ?, ?, ?, ?, ?, 'g1t', ?, ?, ?)",
115 )
116 .bind(&[
117 new_id("led", now_ms()).into(),
118 line.workspace.into(),
119 (-(line.charged as f64)).into(),
120 line.description.into(),
121 optional(line.repo),
122 line.task.into(),
123 (line.cost as f64).into(),
124 line.reference.into(),
125 line.created_at.into(),
126 (line.drawn.credit as f64).into(),
127 (line.drawn.trial as f64).into(),
128 (line.drawn.oss as f64).into(),
129 ])?,
130 self.db
131 .prepare(
132 "INSERT INTO accounts (workspace, balance_micros, created_at) VALUES (?1, ?2, ?3)
133 ON CONFLICT (workspace) DO UPDATE SET balance_micros = balance_micros + ?2",
134 )
135 .bind(&[line.workspace.into(), (-(line.charged as f64)).into(), rfc3339(now_ms()).into()])?,
136 ])
137 .await?;
138 Ok(())
139 }
140
141 /// Writes down what a source cost g1t so far in `month`, and what it
142 /// will be charged, replacing the last figure.
143 pub(crate) async fn set_pending(&self, workspace: &str, source: &str, month: &str, cost_micros: i64) -> Result<()> {
144 let charge = credits::with_margin(cost_micros, self.margin_percent);
145 self.db
146 .prepare(
147 "INSERT INTO pending_usage (workspace, source, month, charge_micros, cost_micros, updated_at)
148 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
149 ON CONFLICT (workspace, source, month) DO UPDATE SET charge_micros = ?4, cost_micros = ?5, updated_at = ?6",
150 )
151 .bind(&[
152 workspace.to_lowercase().into(),
153 source.into(),
154 month.into(),
155 (charge as f64).into(),
156 (cost_micros.max(0) as f64).into(),
157 rfc3339(now_ms()).into(),
158 ])?
159 .run()
160 .await?;
161 Ok(())
162 }
163
164 /// Charges every month that is over for the usage billing charges
165 /// itself, once each.
166 pub(crate) async fn charge_pending(&self) -> Result<()> {
167 if self.stripe.is_none() {
168 return Ok(());
169 }
170 let now = rfc3339(now_ms());
171 let current = credits::month_of(&now);
172 #[derive(Deserialize)]
173 struct Row {
174 workspace: String,
175 source: String,
176 month: String,
177 cost_micros: Option<i64>,
178 }
179 let marks = CHARGED_HERE.iter().map(|s| format!("'{s}'")).collect::<Vec<_>>().join(", ");
180 let due = self
181 .db
182 .prepare(format!(
183 "SELECT workspace, source, month, cost_micros FROM pending_usage
184 WHERE month < ? AND charged_at IS NULL AND source IN ({marks}) ORDER BY month LIMIT 50"
185 ))
186 .bind(&[current.as_str().into()])?
187 .all()
188 .await?
189 .results::<Row>()?;
190 for row in due {
191 // Claimed first, so two crons never charge it twice.
192 let claimed = self
193 .db
194 .prepare(
195 "UPDATE pending_usage SET charged_at = ?1
196 WHERE workspace = ?2 AND source = ?3 AND month = ?4 AND charged_at IS NULL RETURNING workspace",
197 )
198 .bind(&[now.as_str().into(), row.workspace.as_str().into(), row.source.as_str().into(), row.month.as_str().into()])?
199 .first::<serde_json::Value>(None)
200 .await?;
201 let cost = row.cost_micros.unwrap_or(0);
202 if claimed.is_none() || cost <= 0 {
203 continue;
204 }
205 let base = credits::with_margin(cost, self.margin_percent);
206 let (charge, terms_note) = self.charged(&row.workspace, base).await?;
207 let drawn = self.draw(&row.workspace, charge, &row.month, &Eligible { trial: true, repo: None, cover_rest: false }).await?;
208 let detail = if row.source == "storage" {
209 let gb_months = self.gb_months(&row.workspace, &row.month).await?;
210 format!(": {gb_months:.2} GB-months")
211 } else {
212 String::new()
213 };
214 let description = format!("{} in {}{detail}{terms_note}{}", title(&row.source), row.month, drawn.note());
215 let reference = format!("{}/{}/{}", row.source, row.workspace, row.month);
216 let created_at = credits::month_end(&row.month);
217 self.post_usage(UsageLine {
218 workspace: &row.workspace,
219 charged: charge - drawn.total(),
220 description: &description,
221 repo: None,
222 task: &row.source,
223 cost,
224 reference: &reference,
225 created_at: &created_at,
226 drawn,
227 })
228 .await?;
229 }
230 Ok(())
231 }
232
233 /// A workspace's private storage past the free amount in `month`.
234 async fn gb_months(&self, workspace: &str, month: &str) -> Result<f64> {
235 #[derive(Deserialize)]
236 struct Day {
237 private_bytes: i64,
238 free_bytes: i64,
239 }
240 let days = self
241 .db
242 .prepare("SELECT private_bytes, free_bytes FROM storage_days WHERE workspace = ? AND substr(day, 1, 7) = ?")
243 .bind(&[workspace.into(), month.into()])?
244 .all()
245 .await?
246 .results::<Day>()?;
247 Ok(storage_gb_months(&days.iter().map(|d| (d.private_bytes, d.free_bytes)).collect::<Vec<_>>()))
248 }
249
250 /// Once a day: what each workspace's private repositories hold, and
251 /// what this month's storage past the free amount comes to so far.
252 pub(crate) async fn measure_storage(&self) -> Result<()> {
253 let Some(repos) = &self.repos else { return Ok(()) };
254 let list: Vec<WorkspaceStorage> = g1t_kit::call(repos, "storage", &StorageArgs {}).await?;
255 let now = rfc3339(now_ms());
256 let (day, month) = (&now[..10], credits::month_of(&now));
257 let price = self.price("private_storage").await?.map_or(500_000.0, |(cost, _)| cost);
258 for workspace in list {
259 let slug = workspace.namespace.to_lowercase();
260 let plan = self.has_plan(&slug).await?;
261 let free = if plan { self.plans.plan_storage_bytes } else { self.plans.free_storage_bytes };
262 self.db
263 .prepare(
264 "INSERT INTO storage_days (workspace, day, private_bytes, free_bytes) VALUES (?1, ?2, ?3, ?4)
265 ON CONFLICT (workspace, day) DO UPDATE SET private_bytes = ?3, free_bytes = ?4",
266 )
267 .bind(&[slug.as_str().into(), day.into(), (workspace.private_bytes as f64).into(), (free as f64).into()])?
268 .run()
269 .await?;
270 // Only the plan pays for storage past its amount. A free
271 // workspace is never charged: the repos service stops its pushes
272 // to private repositories once it is full (see git_ops.rs there).
273 let gb_months = if plan { self.gb_months(&slug, &month).await? } else { 0.0 };
274 if gb_months > 0.0 {
275 self.set_pending(&slug, "storage", &month, storage_cost(gb_months, price)).await?;
276 }
277 }
278 Ok(())
279 }
280
281 /// Git operations this month for each workspace, from the repos
282 /// service: what is past the included amount goes to the month's
283 /// pending usage for workspaces on the plan. Free workspaces are never
284 /// charged for them.
285 pub(crate) async fn measure_git(&self) -> Result<()> {
286 let Some(repos) = &self.repos else { return Ok(()) };
287 let month = credits::month_of(&rfc3339(now_ms()));
288 let list: Vec<WorkspaceGitOperations> =
289 g1t_kit::call(repos, "git_operations", &GitOperationsArgs { since: Some(git_since(&month)), month: month.clone(), namespace: None }).await?;
290 let price = self.price("git_operations").await?.map_or(150_000.0, |(cost, _)| cost);
291 for workspace in list {
292 let slug = workspace.namespace.to_lowercase();
293 if self.plan_kind(&slug).await? == PlanKind::Free {
294 continue;
295 }
296 let cost = git_cost(workspace.operations, self.plans.git_included, price);
297 if cost > 0 {
298 self.set_pending(&slug, "git", &month, cost).await?;
299 }
300 }
301 Ok(())
302 }
303
304 /// The workspace's git operations this month, as last measured.
305 pub(crate) async fn git_operations_this_month(&self, workspace: &str) -> Result<u64> {
306 let Some(repos) = &self.repos else { return Ok(0) };
307 let month = credits::month_of(&rfc3339(now_ms()));
308 let list: Result<Vec<WorkspaceGitOperations>> =
309 g1t_kit::call(repos, "git_operations", &GitOperationsArgs { since: Some(format!("{month}-01T00")), month, namespace: Some(workspace.to_lowercase()) }).await;
310 Ok(list
311 .ok()
312 .and_then(|list| list.into_iter().find(|w| w.namespace.eq_ignore_ascii_case(workspace)))
313 .map_or(0, |w| w.operations))
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn storage_past_the_free_amount_is_counted_by_the_day() {
323 // 3 GB private with 1 GB free, every day of a 30-day month: 2 GB-months.
324 let month = vec![(3_000_000_000, 1_000_000_000); 30];
325 assert!((storage_gb_months(&month) - 2.0).abs() < 1e-9);
326 // Under the free amount: nothing.
327 assert_eq!(storage_gb_months(&[(500_000_000, 1_000_000_000); 30]), 0.0);
328 // The plan: 10 GB free.
329 assert_eq!(storage_gb_months(&[(8_000_000_000, 10_000_000_000); 30]), 0.0);
330 // 12 GB on the plan: 2 GB-months, $1.00 to g1t, $1.20 charged.
331 assert!((storage_gb_months(&[(12_000_000_000, 10_000_000_000); 30]) - 2.0).abs() < 1e-9);
332 // Ten days of 4 GB past it: a third of 4 GB-months.
333 let days = vec![(5_000_000_000, 1_000_000_000); 10];
334 assert!((storage_gb_months(&days) - 4.0 / 3.0).abs() < 1e-9);
335 }
336
337 #[test]
338 fn storage_is_priced_at_cloudflares_rate_plus_the_margin() {
339 // $0.50 a GB-month to g1t: 2 GB-months cost $1.00, charged $1.20.
340 let cost = storage_cost(2.0, 500_000.0);
341 assert_eq!(cost, 1_000_000);
342 assert_eq!(credits::with_margin(cost, 20), 1_200_000);
343 // A fraction of a millionth rounds up.
344 assert_eq!(storage_cost(0.000_000_001, 500_000.0), 1);
345 }
346
347 #[test]
348 fn embeddings_and_scans_are_charged_at_cost_plus_the_margin() {
349 // 10 million tokens at $0.067 a million: $0.67, charged $0.804.
350 assert_eq!(credits::with_margin(670_000, 20), 804_000);
351 assert_eq!(title("context"), "Search embeddings");
352 assert_eq!(title("security"), "Security scans");
353 assert!(CHARGED_HERE.contains(&"storage") && !CHARGED_HERE.contains(&"deployments"));
354 assert!(CHARGED_HERE.contains(&"git"));
355 }
356
357 #[test]
358 fn git_operations_are_charged_past_what_is_included_at_cloudflares_price() {
359 // $0.15 per 1,000 to g1t.
360 let per_thousand = 150_000.0;
361 assert_eq!(git_cost(9_000, 10_000, per_thousand), 0);
362 assert_eq!(git_cost(10_000, 10_000, per_thousand), 0);
363 // 30,000 operations: 20,000 past it, $3.00 to g1t, $3.60 charged.
364 assert_eq!(git_cost(30_000, 10_000, per_thousand), 3_000_000);
365 assert_eq!(credits::with_margin(git_cost(30_000, 10_000, per_thousand), 20), 3_600_000);
366 // One past it: a fraction of a cent, rounded up to a millionth.
367 assert_eq!(git_cost(10_001, 10_000, per_thousand), 150);
368 assert_eq!(title("git"), "Git operations past the included amount");
369 }
370
371 #[test]
372 fn git_operations_count_from_when_cloudflare_starts_charging() {
373 assert_eq!(git_since("2026-10"), "2026-10-14T00");
374 assert_eq!(git_since("2026-11"), "2026-11-01T00");
375 assert_eq!(git_since("2026-09"), "2026-10-14T00");
376 }
377}