Skip to content
435 linesCodeBlameRaw
1//! Cloudflare's billing cycle, and what g1t's usage costs over it.
2//!
3//! Cloudflare bills usage per billing cycle, a month from the day the
4//! account's subscription renews (for g1t's account, the 28th: Sep 28 to
5//! Oct 27), and each meter's included amount ("first 30M are included") is
6//! the account's, once per cycle, not per day or per product. So a day's
7//! cost depends on every day before it in the cycle: the cost lands on the
8//! day the cycle's running total passes the included amount, and on every
9//! day after, as on Cloudflare's own Billable usage page.
10//!
11//! Cloudflare's billable-usage lines carry no cost while a cycle is open
12//! (seen 2026-10-08: every line $0 while the dashboard showed $0.29), so
13//! the cost of each meter is the list price past the included amount,
14//! applied here over the cycle; where Cloudflare does put a cost on a
15//! meter's lines in a cycle, that cost is taken instead, as it is.
16//!
17//! Meters priced per million (requests, CPU ms, rows, operations) are billed
18//! in whole millions: 9.16M CPU ms past the included 30M is billed as 10M,
19//! $0.20, which is what Cloudflare's page showed on 2026-10-09.
20
21use std::collections::BTreeMap;
22
23use crate::costs::{CostLine, SOURCE_BILLABLE, days_between};
24
25/// Where a line's cost came from.
26pub(crate) const BASIS_CLOUDFLARE: &str = "cloudflare";
27pub(crate) const BASIS_LIST: &str = "list";
28pub(crate) const BASIS_NONE: &str = "none";
29
30/// g1t's account renews on the 28th; `CLOUDFLARE_BILLING_DAY` says so
31/// until Cloudflare's subscriptions have been read.
32pub(crate) const DEFAULT_ANCHOR: u32 = 1;
33
34/// A billing cycle: its first and last days, YYYY-MM-DD, inclusive.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub(crate) struct Cycle {
37 pub start: String,
38 pub end: String,
39}
40
41impl Cycle {
42 pub fn days(&self) -> u32 {
43 days_between(&self.start, &self.end).len() as u32
44 }
45
46 /// Days from its start to `day`, both included (at most all of them).
47 pub fn days_elapsed(&self, day: &str) -> u32 {
48 let until = if day > self.end.as_str() { self.end.as_str() } else { day };
49 days_between(&self.start, until).len() as u32
50 }
51
52 pub fn previous(&self, anchor: u32) -> Cycle {
53 cycle_of(&shift(&self.start, -1), anchor)
54 }
55}
56
57fn ymd(day: &str) -> Option<(i32, u32, u32)> {
58 let mut parts = day.get(..10)?.splitn(3, '-');
59 Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
60}
61
62fn days_in_month(year: i32, month: u32) -> u32 {
63 match month {
64 2 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 => 29,
65 2 => 28,
66 4 | 6 | 9 | 11 => 30,
67 _ => 31,
68 }
69}
70
71fn add_months(year: i32, month: u32, delta: i32) -> (i32, u32) {
72 let index = year * 12 + month as i32 - 1 + delta;
73 (index.div_euclid(12), index.rem_euclid(12) as u32 + 1)
74}
75
76/// `day` moved by `by` days.
77fn shift(day: &str, by: i64) -> String {
78 let ms = g1t_contracts::time::parse_rfc3339(&format!("{}T00:00:00Z", &day[..10.min(day.len())])).unwrap_or(0) as i64;
79 g1t_contracts::time::rfc3339((ms + by * crate::costs::DAY_MS as i64).max(0) as u64)[..10].to_owned()
80}
81
82/// The cycle `day` is in, for a cycle that starts on day `anchor` of each
83/// month (the month's last day where it has fewer).
84pub(crate) fn cycle_of(day: &str, anchor: u32) -> Cycle {
85 let anchor = anchor.clamp(1, 31);
86 let Some((year, month, date)) = ymd(day) else {
87 return Cycle { start: day.to_owned(), end: day.to_owned() };
88 };
89 let (sy, sm) = if date >= anchor.min(days_in_month(year, month)) { (year, month) } else { add_months(year, month, -1) };
90 let (ny, nm) = add_months(sy, sm, 1);
91 let start = format!("{sy:04}-{sm:02}-{:02}", anchor.min(days_in_month(sy, sm)));
92 let next = format!("{ny:04}-{nm:02}-{:02}", anchor.min(days_in_month(ny, nm)));
93 Cycle { start, end: shift(&next, -1) }
94}
95
96/// The day of the month a cycle starts on, from a subscription's
97/// `current_period_start`.
98pub(crate) fn anchor_of(period_start: &str) -> Option<u32> {
99 ymd(period_start).map(|(_, _, d)| d).filter(|d| (1..=31).contains(d))
100}
101
102/// The days to read the bill for: the current cycle, and the one before
103/// while it may still be restated (its first `restate_days` days) or when
104/// nothing has been read yet. Whole cycles, so the included amounts are
105/// applied to all of a cycle's usage.
106pub(crate) fn bill_since(today: &str, anchor: u32, restate_days: u32, first: bool) -> String {
107 let current = cycle_of(today, anchor);
108 if first || current.days_elapsed(today) <= restate_days {
109 current.previous(anchor).start
110 } else {
111 current.start
112 }
113}
114
115/// A meter's list price, with what each cycle includes.
116#[derive(Clone, Copy, Debug, PartialEq)]
117pub(crate) struct ListPrice {
118 /// Cloudflare's product and a meter prefix, as `costs::slug` makes them.
119 pub product: &'static str,
120 pub meter: &'static str,
121 /// Included each cycle (or each day, `daily`), in the meter's units.
122 pub included: f64,
123 /// Dollars per `per` units.
124 pub usd: f64,
125 pub per: f64,
126 /// Billed in whole blocks of `per` (the per-million meters).
127 pub whole: bool,
128 /// The included amount is a day's (Workers AI), not a cycle's.
129 pub daily: bool,
130}
131
132impl ListPrice {
133 /// What is past the included amount, of `used`.
134 pub fn over(&self, used: f64) -> f64 {
135 (used - self.included).max(0.0)
136 }
137
138 /// What `used` units cost in one cycle (or day).
139 pub fn cost(&self, used: f64) -> f64 {
140 let over = self.over(used);
141 if over <= 0.0 {
142 return 0.0;
143 }
144 let blocks = over / self.per;
145 // A float sum of a whole number of units must not round up a block.
146 let blocks = if self.whole { (blocks - 1e-9).ceil().max(1.0) } else { blocks };
147 blocks * self.usd
148 }
149}
150
151const fn price(product: &'static str, meter: &'static str, included: f64, usd: f64, per: f64, whole: bool) -> ListPrice {
152 ListPrice { product, meter, included, usd, per, whole, daily: false }
153}
154
155const M: f64 = 1_000_000.0;
156
157/// Cloudflare's list prices on Workers Paid, as published (checked
158/// 2026-10-09 against the Billable usage page: Workers CPU and Containers
159/// memory to the cent). A meter not here is costed at $0 and shown as
160/// having no list price, until Cloudflare's lines carry its cost or a price
161/// is added here. Containers: vCPU-seconds, GiB-seconds and GB-seconds;
162/// storage in GB-months.
163pub(crate) const LIST_PRICES: &[ListPrice] = &[
164 price("workers", "workers_standard_requests", 10.0 * M, 0.30, M, true),
165 price("workers", "workers_cpu_ms", 30.0 * M, 0.02, M, true),
166 price("workers", "workers_for_platforms_requests", 20.0 * M, 0.30, M, true),
167 price("workers", "workers_for_platforms_cpu_ms", 60.0 * M, 0.02, M, true),
168 price("workers", "workers_for_platforms_scripts", 1_000.0, 0.02, 1.0, false),
169 price("workers", "workers_logs", 20.0 * M, 0.60, M, true),
170 price("workers_for_platforms", "workers_for_platforms_requests", 20.0 * M, 0.30, M, true),
171 price("workers_for_platforms", "workers_for_platforms_cpu_ms", 60.0 * M, 0.02, M, true),
172 price("workers_for_platforms", "workers_for_platforms_scripts", 1_000.0, 0.02, 1.0, false),
173 price("d1", "d1_rows_read", 25_000.0 * M, 0.001, M, true),
174 price("d1", "d1_rows_written", 50.0 * M, 1.00, M, true),
175 price("d1", "d1_storage", 5.0, 0.75, 1.0, false),
176 price("workers_kv", "kv_read_operations", 10.0 * M, 0.50, M, true),
177 price("workers_kv", "kv_write_operations", M, 5.00, M, true),
178 price("workers_kv", "kv_list_operations", M, 5.00, M, true),
179 price("workers_kv", "kv_delete_operations", M, 5.00, M, true),
180 price("workers_kv", "kv_storage", 1.0, 0.50, 1.0, false),
181 price("r2", "r2_storage_class_a_operations", M, 4.50, M, true),
182 price("r2", "r2_storage_class_b_operations", 10.0 * M, 0.36, M, true),
183 price("durable_objects", "durable_objects_compute_requests", M, 0.15, M, true),
184 price("durable_objects", "durable_objects_compute_duration", 400_000.0, 12.50, M, false),
185 price("durable_objects", "durable_objects_storage_rows_read", 25_000.0 * M, 0.001, M, true),
186 price("durable_objects", "durable_objects_storage_rows_written", 50.0 * M, 1.00, M, true),
187 price("durable_objects", "durable_objects_sql_storage", 5.0, 0.20, 1.0, false),
188 price("queues", "queues_standard_operations", M, 0.40, M, true),
189 // 25 GiB-hours, 375 vCPU-minutes and 200 GB-hours a cycle.
190 price("containers", "container_memory", 90_000.0, 0.000_002_5, 1.0, false),
191 price("containers", "container_vcpu", 22_500.0, 0.000_020, 1.0, false),
192 price("containers", "container_disk", 720_000.0, 0.000_000_07, 1.0, false),
193 price("containers", "container_egress_north_america_europe", 1_000.0, 0.025, 1.0, false),
194 price("containers", "container_egress_everywhere_else", 500.0, 0.04, 1.0, false),
195 price("vectorize", "vectorize_queried", 50.0 * M, 0.01, M, false),
196 price("vectorize", "vectorize_stored", 10.0 * M, 0.05, 100.0 * M, false),
197 ListPrice { product: "workers_ai", meter: "workers_ai_neurons", included: 10_000.0, usd: 0.011, per: 1_000.0, whole: false, daily: true },
198];
199
200/// The list price for a line: its product's with the longest matching
201/// meter prefix.
202pub(crate) fn list_price(product: &str, meter: &str) -> Option<&'static ListPrice> {
203 LIST_PRICES.iter().filter(|p| p.product == product && meter.starts_with(p.meter)).max_by_key(|p| p.meter.len())
204}
205
206/// What `quantity` of a meter comes to at its list price before any
207/// included amount, and not in whole blocks: the price book costs every
208/// unit, so this, not what Cloudflare billed past the included amounts, is
209/// what it is checked against. None for a meter with no list price.
210pub(crate) fn list_cost(product: &str, meter: &str, quantity: f64) -> Option<f64> {
211 list_price(product, meter).map(|p| quantity.max(0.0) * p.usd / p.per)
212}
213
214/// Puts a cost on every billable-usage line, cycle by cycle (`anchor`):
215/// Cloudflare's own where it put one on any of the meter's lines in the
216/// cycle, else the list price past the included amount, landing on the
217/// days the cycle's running total passes it. Other lines are left alone.
218pub(crate) fn price_lines(lines: &mut [CostLine], anchor: u32) {
219 let mut groups: BTreeMap<(String, String, String), Vec<usize>> = BTreeMap::new();
220 for (index, line) in lines.iter().enumerate() {
221 if line.source != SOURCE_BILLABLE {
222 continue;
223 }
224 let period = match list_price(&line.product, &line.meter) {
225 Some(p) if p.daily => line.day.clone(),
226 _ => cycle_of(&line.day, anchor).start,
227 };
228 groups.entry((period, line.product.clone(), line.meter.clone())).or_default().push(index);
229 }
230 for ((_, product, meter), mut indexes) in groups {
231 indexes.sort_by(|a, b| lines[*a].day.cmp(&lines[*b].day));
232 let billed = indexes.iter().any(|i| lines[*i].billed_usd > 0.0);
233 let list = list_price(&product, &meter);
234 let mut used = 0.0;
235 for index in indexes {
236 let line = &mut lines[index];
237 let before = used;
238 used += line.quantity.max(0.0);
239 line.billable_quantity = list.map_or(0.0, |p| p.over(used) - p.over(before));
240 (line.cost_usd, line.basis) = match list {
241 _ if billed => (line.billed_usd, BASIS_CLOUDFLARE),
242 Some(p) => (p.cost(used) - p.cost(before), BASIS_LIST),
243 None => (0.0, BASIS_NONE),
244 };
245 }
246 }
247}
248
249/// A cycle's cost, from what it cost over the days elapsed: Cloudflare's
250/// projection on its Billable usage page ($0.29 over 12 days of a 30-day
251/// cycle is $0.73).
252pub(crate) fn project(cost_micros: i64, days_elapsed: u32, days: u32) -> i64 {
253 if days_elapsed == 0 {
254 return cost_micros;
255 }
256 (cost_micros as f64 * days as f64 / days_elapsed as f64).round() as i64
257}
258
259/// A monthly subscription over the days from `since` to `until`: each day
260/// its cycle's share (the month's price over the cycle's days), so a whole
261/// cycle comes to the month's price and any range to its days' part of it.
262pub(crate) fn accrued(monthly_micros: i64, since: &str, until: &str, anchor: u32) -> i64 {
263 let mut lengths: BTreeMap<String, u32> = BTreeMap::new();
264 let total: f64 = days_between(since, until)
265 .iter()
266 .map(|day| {
267 let cycle = cycle_of(day, anchor);
268 let days = *lengths.entry(cycle.start.clone()).or_insert_with(|| cycle.days());
269 monthly_micros as f64 / days.max(1) as f64
270 })
271 .sum();
272 total.round() as i64
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 fn line(day: &str, product: &str, meter: &str, quantity: f64, billed: f64) -> CostLine {
280 CostLine {
281 day: day.into(),
282 source: SOURCE_BILLABLE,
283 product: product.into(),
284 meter: meter.into(),
285 unit: "Count".into(),
286 quantity,
287 billed_usd: billed,
288 raw_name: meter.into(),
289 ..CostLine::default()
290 }
291 }
292
293 #[test]
294 fn a_cycle_runs_from_the_anchor_day_to_the_day_before_the_next() {
295 assert_eq!(cycle_of("2026-10-09", 28), Cycle { start: "2026-09-28".into(), end: "2026-10-27".into() });
296 assert_eq!(cycle_of("2026-09-28", 28).start, "2026-09-28");
297 assert_eq!(cycle_of("2026-09-27", 28), Cycle { start: "2026-08-28".into(), end: "2026-09-27".into() });
298 assert_eq!(cycle_of("2026-10-28", 28), Cycle { start: "2026-10-28".into(), end: "2026-11-27".into() });
299 assert_eq!(cycle_of("2026-10-09", 28).days(), 30);
300 assert_eq!(cycle_of("2026-10-09", 28).days_elapsed("2026-10-09"), 12);
301 // Across a year, and an anchor past a short month's end.
302 assert_eq!(cycle_of("2027-01-05", 28).start, "2026-12-28");
303 assert_eq!(cycle_of("2027-02-28", 31), Cycle { start: "2027-02-28".into(), end: "2027-03-30".into() });
304 assert_eq!(cycle_of("2026-10-09", 1), Cycle { start: "2026-10-01".into(), end: "2026-10-31".into() });
305 assert_eq!(anchor_of("2026-09-28T00:00:00Z"), Some(28));
306 assert_eq!(anchor_of("nonsense"), None);
307 }
308
309 #[test]
310 fn the_bill_is_read_for_whole_cycles() {
311 // Ten days in: the current cycle only.
312 assert_eq!(bill_since("2026-10-09", 28, 4, false), "2026-09-28");
313 // Its first days, or the first read: the cycle before too, which
314 // Cloudflare may still restate.
315 assert_eq!(bill_since("2026-09-30", 28, 4, false), "2026-08-28");
316 assert_eq!(bill_since("2026-10-09", 28, 4, true), "2026-08-28");
317 }
318
319 /// The owner's dashboard on 2026-10-09 (cycle Sep 28 to Oct 27): Workers
320 /// CPU 39.16M ms (9.16M billable) $0.20, Containers memory 126.87k
321 /// GiB-seconds (36.87k billable) $0.09, all of it on 10-07; total $0.29,
322 /// projected $0.73. Cloudflare's lines carried no cost.
323 #[test]
324 fn the_cycle_matches_cloudflares_billable_usage_page() {
325 let mut lines = vec![
326 line("2026-10-05", "workers", "workers_cpu_ms", 14_000_000.0, 0.0),
327 line("2026-10-06", "workers", "workers_cpu_ms", 14_000_000.0, 0.0),
328 line("2026-10-07", "workers", "workers_cpu_ms", 11_160_000.0, 0.0),
329 line("2026-10-05", "containers", "container_memory_per_gib_second", 20_000.0, 0.0),
330 line("2026-10-06", "containers", "container_memory_per_gib_second", 52_000.0, 0.0),
331 line("2026-10-07", "containers", "container_memory_per_gib_second", 54_870.0, 0.0),
332 line("2026-10-07", "containers", "container_vcpu", 12_000.0, 0.0),
333 line("2026-10-07", "containers", "container_disk_per_gb_second", 253_740.0, 0.0),
334 line("2026-10-07", "d1", "d1_rows_read", 61_820_000.0, 0.0),
335 line("2026-10-07", "workers", "workers_standard_requests", 845_650.0, 0.0),
336 line("2026-10-07", "email", "email_service_emails_sent", 7.0, 0.0),
337 ];
338 price_lines(&mut lines, 28);
339 let total: f64 = lines.iter().map(|l| l.cost_usd).sum();
340 assert!((total - 0.29).abs() < 0.01, "{total}");
341 let on = |day: &str| lines.iter().filter(|l| l.day == day).map(|l| l.cost_usd).sum::<f64>();
342 assert_eq!((on("2026-10-05"), on("2026-10-06")), (0.0, 0.0));
343 let cpu: f64 = lines.iter().filter(|l| l.meter == "workers_cpu_ms").map(|l| l.cost_usd).sum();
344 assert!((cpu - 0.20).abs() < 1e-9, "billed in whole millions: {cpu}");
345 let memory: Vec<&CostLine> = lines.iter().filter(|l| l.meter.starts_with("container_memory")).collect();
346 let billable: f64 = memory.iter().map(|l| l.billable_quantity).sum();
347 assert!((billable - 36_870.0).abs() < 1e-6);
348 assert!((memory.iter().map(|l| l.cost_usd).sum::<f64>() - 0.092_175).abs() < 1e-9);
349 assert!(lines.iter().filter(|l| l.product != "email").all(|l| l.basis == BASIS_LIST));
350 assert_eq!(lines.iter().find(|l| l.product == "email").unwrap().basis, BASIS_NONE);
351 let micros = (total * 1_000_000.0).round() as i64;
352 assert_eq!(project(micros, 12, 30), 730_438);
353 }
354
355 #[test]
356 fn included_amounts_are_the_cycles_not_the_days_or_the_month() {
357 // 20M CPU ms a day for three days across a cycle's end: the second
358 // day passes 30M in the old cycle; the third starts a new one.
359 let mut lines = vec![
360 line("2026-10-26", "workers", "workers_cpu_ms", 20_000_000.0, 0.0),
361 line("2026-10-27", "workers", "workers_cpu_ms", 20_000_000.0, 0.0),
362 line("2026-10-28", "workers", "workers_cpu_ms", 20_000_000.0, 0.0),
363 ];
364 price_lines(&mut lines, 28);
365 let costs: Vec<f64> = lines.iter().map(|l| (l.cost_usd * 100.0).round() / 100.0).collect();
366 assert_eq!(costs, vec![0.0, 0.20, 0.0]);
367 // Unsorted lines are priced in day order all the same.
368 let mut reversed = vec![lines[1].clone(), lines[0].clone()];
369 price_lines(&mut reversed, 28);
370 assert_eq!(reversed[0].cost_usd, lines[1].cost_usd);
371 }
372
373 #[test]
374 fn cloudflares_own_cost_wins_for_its_meter_and_cycle() {
375 let mut lines = vec![
376 line("2026-10-06", "workers", "workers_cpu_ms", 35_000_000.0, 0.0),
377 line("2026-10-07", "workers", "workers_cpu_ms", 5_000_000.0, 0.19),
378 line("2026-10-07", "containers", "container_memory_per_gib_second", 100_000.0, 0.0),
379 ];
380 price_lines(&mut lines, 28);
381 assert_eq!((lines[0].cost_usd, lines[0].basis), (0.0, BASIS_CLOUDFLARE));
382 assert_eq!((lines[1].cost_usd, lines[1].basis), (0.19, BASIS_CLOUDFLARE));
383 assert_eq!(lines[2].basis, BASIS_LIST);
384 // Not billable usage: left as it is.
385 let mut other = vec![CostLine { source: crate::costs::SOURCE_GATEWAY, cost_usd: 1.5, ..line("2026-10-07", "ai_gateway_requests", "m", 3.0, 0.0) }];
386 price_lines(&mut other, 28);
387 assert_eq!((other[0].cost_usd, other[0].basis), (1.5, ""));
388 }
389
390 #[test]
391 fn workers_ai_includes_a_day_not_a_cycle() {
392 let mut lines = vec![
393 line("2026-10-06", "workers_ai", "workers_ai_neurons", 9_000.0, 0.0),
394 line("2026-10-07", "workers_ai", "workers_ai_neurons", 12_000.0, 0.0),
395 ];
396 price_lines(&mut lines, 28);
397 assert_eq!(lines[0].cost_usd, 0.0);
398 assert!((lines[1].cost_usd - 0.022).abs() < 1e-12);
399 }
400
401 #[test]
402 fn the_longest_prefix_names_the_price() {
403 assert_eq!(list_price("workers", "workers_for_platforms_cpu_ms").unwrap().included, 60.0 * M);
404 assert_eq!(list_price("workers", "workers_cpu_ms").unwrap().included, 30.0 * M);
405 assert!(list_price("email", "email_service_emails_sent").is_none());
406 // The keeper prices sandboxes at the same published rates.
407 assert_eq!(list_price("containers", "container_memory_per_gib_second").unwrap().usd, 0.000_002_5);
408 }
409
410 #[test]
411 fn a_list_cost_is_every_unit_at_the_list_price() {
412 // 126.87k GiB-seconds is $0.32 at list, though only the 36.87k past
413 // the included 90k were billed ($0.09).
414 let memory = list_cost("containers", "container_memory_per_gib_second", 126_870.0).unwrap();
415 assert!((memory - 0.317_175).abs() < 1e-9);
416 // Per-million meters are not rounded up to a whole million.
417 assert!((list_cost("workers", "workers_cpu_ms", 11_160_000.0).unwrap() - 0.2232).abs() < 1e-9);
418 assert!(list_cost("email", "email_service_emails_sent", 7.0).is_none());
419 }
420
421 #[test]
422 fn subscriptions_accrue_by_the_cycles_days() {
423 // A whole cycle is the month's price, whatever its length.
424 assert_eq!(accrued(30_000_000, "2026-09-28", "2026-10-27", 28), 30_000_000);
425 assert_eq!(accrued(30_000_000, "2026-10-28", "2026-11-27", 28), 30_000_000);
426 // Twelve days of a 30-day cycle; October to the 9th.
427 assert_eq!(accrued(30_000_000, "2026-09-28", "2026-10-09", 28), 12_000_000);
428 assert_eq!(accrued(30_000_000, "2026-10-01", "2026-10-09", 28), 9_000_000);
429 // The statement's 30 days and the calendar month agree day for day.
430 let statement = accrued(30_000_000, "2026-09-10", "2026-10-09", 28);
431 let september = accrued(30_000_000, "2026-09-10", "2026-09-30", 28);
432 assert_eq!(statement, september + accrued(30_000_000, "2026-10-01", "2026-10-09", 28));
433 assert_eq!(accrued(30_000_000, "2026-10-09", "2026-10-01", 28), 0);
434 }
435}