| 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 | |
| 21 | use std::collections::BTreeMap; |
| 22 | |
| 23 | use crate::costs::{CostLine, SOURCE_BILLABLE, days_between}; |
| 24 | |
| 25 | /// Where a line's cost came from. |
| 26 | pub(crate) const BASIS_CLOUDFLARE: &str = "cloudflare"; |
| 27 | pub(crate) const BASIS_LIST: &str = "list"; |
| 28 | pub(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. |
| 32 | pub(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)] |
| 36 | pub(crate) struct Cycle { |
| 37 | pub start: String, |
| 38 | pub end: String, |
| 39 | } |
| 40 | |
| 41 | impl 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 | |
| 57 | fn 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 | |
| 62 | fn 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 | |
| 71 | fn 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. |
| 77 | fn 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). |
| 84 | pub(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`. |
| 98 | pub(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. |
| 106 | pub(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)] |
| 117 | pub(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 | |
| 132 | impl 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 | |
| 151 | const 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 | |
| 155 | const 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. |
| 163 | pub(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. |
| 202 | pub(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. |
| 210 | pub(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. |
| 218 | pub(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). |
| 252 | pub(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. |
| 262 | pub(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)] |
| 276 | mod 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 | } |