Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
503 linesCodeBlameRaw
1//! The Usage page: a workspace's usage over a range of days, at price, by
2//! product family, meter, project and day, with what paid for it.
3//!
4//! Every figure comes from the ledger's own lines, measured as the
5//! statement measures them (`statement::PRICE_SQL`: what was charged, plus
6//! what included usage, the trial, a pool or g1t paid, plus what a discount
7//! took off), so the Usage page and Billing show the same numbers. Usage metered through the month and charged
8//! when it closes (storage, git operations, scans, embeddings, domains,
9//! app traffic) is added from `pending_usage`, at cost plus the margin, and
10//! said to be pending.
11//!
12//! Lines from before g1t stopped running agents and selling model access
13//! (agent runs, the agent rate, AI Gateway requests), and any line on a
14//! task this page does not know, are kept on one meter, Other, in a
15//! product of their own: nothing charged is ever left out.
16
17use std::collections::{BTreeMap, BTreeSet};
18
19use futures_util::future::{try_join, try_join3};
20use g1t_contracts::billing::{
21 Allowance, MeterLine, PlanKind, ProductUsage, ProjectUsage, UsageDay, UsageReport, UsageReportArgs, UsageTotals, PRODUCTS,
22};
23use g1t_contracts::time::{parse_rfc3339, rfc3339};
24use g1t_contracts::{FailureCode, Outcome};
25use g1t_kit::now_ms;
26use serde::Deserialize;
27use worker::Result;
28
29use crate::charged::{charged, pending_split};
30use crate::statement::PRICE_SQL;
31use crate::{Billing, members_only};
32
33/// The longest range the page reads at once.
34pub(crate) const MAX_DAYS: u64 = 400;
35const DAY_MS: u64 = 86_400_000;
36
37/// Which meter a ledger line is on. One SQL expression, so every query
38/// agrees with `METERS`.
39pub(crate) const METER_KEY_SQL: &str = "CASE
40 WHEN task = 'sandbox' THEN 'sandbox'
41 WHEN task = 'self_hosted' THEN 'self_hosted'
42 WHEN task = 'deployments' AND reference LIKE 'deploy/%' THEN 'builds'
43 WHEN task = 'deployments' THEN 'requests'
44 WHEN task = 'domains' THEN 'domains'
45 WHEN task = 'git' THEN 'git'
46 WHEN task = 'storage' THEN 'storage'
47 WHEN task = 'cache' THEN 'cache'
48 WHEN task = 'package_storage' THEN 'packages'
49 WHEN task = 'security' THEN 'security'
50 WHEN task = 'context' THEN 'context'
51 ELSE 'other' END";
52
53/// Every meter: key, name, product family and the unit of its quantity.
54pub(crate) const METERS: [(&str, &str, &str, &str); 12] = [
55 ("sandbox", "Sandbox time", "sandboxes", "seconds"),
56 ("self_hosted", "Self-hosted runner time", "sandboxes", "seconds"),
57 ("builds", "Builds", "deployments", "entries"),
58 ("requests", "Requests & CPU", "deployments", "entries"),
59 ("domains", "Custom domains", "deployments", "entries"),
60 ("git", "Git operations", "git_storage", "operations"),
61 ("storage", "Private storage", "git_storage", "bytes"),
62 ("cache", "Actions cache", "git_storage", "entries"),
63 ("packages", "Package storage", "packages", "entries"),
64 ("security", "Security scans", "security", "entries"),
65 ("context", "Search embeddings", "search", "entries"),
66 // Kept apart so nothing is ever lost: a line whose meter is unknown,
67 // and agents' and models' usage from before they were retired.
68 ("other", "Other", OTHER, "entries"),
69];
70
71/// The product of the Other meter: not one of `PRODUCTS`, listed last and
72/// only when it has something.
73pub(crate) const OTHER: &str = "other";
74
75/// The meter month-end usage noted so far (`pending_usage.source`) is on.
76pub(crate) fn meter_of_pending(source: &str) -> &'static str {
77 match source {
78 "deployments" => "requests",
79 "domains" => "domains",
80 "storage" => "storage",
81 "git" => "git",
82 "cache" => "cache",
83 "package_storage" => "packages",
84 "security" => "security",
85 "context" => "context",
86 _ => "other",
87 }
88}
89
90/// Every day from `from` to `until`, both included, or why not.
91pub(crate) fn days_between(from: &str, until: &str) -> std::result::Result<Vec<String>, &'static str> {
92 let (Some(start), Some(end)) = (parse_rfc3339(&format!("{from}T00:00:00Z")), parse_rfc3339(&format!("{until}T00:00:00Z"))) else {
93 return Err("Give the range as days, YYYY-MM-DD.");
94 };
95 if end < start {
96 return Err("The range ends before it starts.");
97 }
98 if (end - start) / DAY_MS + 1 > MAX_DAYS {
99 return Err("Ask for at most 400 days at a time.");
100 }
101 Ok((0..=(end - start) / DAY_MS).map(|n| rfc3339(start + n * DAY_MS)[..10].to_owned()).collect())
102}
103
104/// One group of ledger lines: a day, a meter and a project.
105#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
106pub(crate) struct Cell {
107 pub day: String,
108 pub meter: String,
109 #[serde(default)]
110 pub project: String,
111 #[serde(default)]
112 pub price: Option<f64>,
113 #[serde(default)]
114 pub quantity: Option<f64>,
115 #[serde(default)]
116 pub entries: Option<f64>,
117 #[serde(default)]
118 pub discount: Option<f64>,
119 #[serde(default)]
120 pub covered: Option<f64>,
121 #[serde(default)]
122 pub cost: Option<f64>,
123}
124
125/// What is metered this month and charged when it closes, by meter: at
126/// price, with what g1t covers of it and what the discount takes off, as
127/// the close will enter it (`charged::pending_split`).
128#[derive(Clone, Debug, Default, PartialEq)]
129pub(crate) struct Pending {
130 pub meter: &'static str,
131 pub micros: i64,
132 pub covered: i64,
133 pub discount: i64,
134}
135
136/// Whether a range of days reaches into the current month, so the month's
137/// usage not yet closed (`pending_usage`, which has no day of its own) is
138/// counted in it. `today` is billing's own UTC day, so the top bar, Spend,
139/// Home and Usage, which all ask for the first of the month to today, get
140/// the same answer whatever `until` they send: a range need not reach
141/// month end to include what the month has metered so far.
142pub(crate) fn reaches_this_month(from: &str, until: &str, today: &str) -> bool {
143 from <= today && until >= &format!("{}-01", &today[..7])[..]
144}
145
146/// Shapes the groups into the page: products in order, each with its
147/// meters, each meter with every day of the range and its projects. Only
148/// the products and projects asked for (all, when none are).
149pub(crate) fn shape(
150 days: &[String],
151 cells: &[Cell],
152 pending: &[Pending],
153 products: &[String],
154 projects: &[String],
155) -> (Vec<ProductUsage>, Vec<UsageDay>, UsageTotals, Vec<String>) {
156 let wanted_product = |product: &str| products.is_empty() || products.iter().any(|p| p == product);
157 let wanted_project = |project: &str| projects.is_empty() || projects.iter().any(|p| p == project);
158 let index: BTreeMap<&str, usize> = days.iter().enumerate().map(|(i, d)| (d.as_str(), i)).collect();
159 let product_of = |meter: &str| METERS.iter().find(|m| m.0 == meter).map_or(OTHER, |m| m.2);
160 let mut all_projects = BTreeSet::new();
161 let mut lines: Vec<MeterLine> = METERS
162 .iter()
163 .map(|(key, label, product, unit)| MeterLine {
164 key: (*key).to_owned(),
165 label: (*label).to_owned(),
166 product: (*product).to_owned(),
167 unit: (*unit).to_owned(),
168 quantity: 0.0,
169 micros: 0,
170 pending_micros: 0,
171 daily: vec![0; days.len()],
172 allowance: None,
173 by_project: vec![],
174 note: None,
175 })
176 .collect();
177 let mut by_day: BTreeMap<(String, String), i64> = BTreeMap::new();
178 let mut totals = UsageTotals::default();
179 let mut projects_of: BTreeMap<(String, String), (i64, f64)> = BTreeMap::new();
180 for cell in cells {
181 if !cell.project.is_empty() {
182 all_projects.insert(cell.project.clone());
183 }
184 let product = product_of(&cell.meter);
185 if !wanted_product(product) || !wanted_project(&cell.project) {
186 continue;
187 }
188 // A meter this page does not know is Other's: never dropped.
189 let meter = if METERS.iter().any(|m| m.0 == cell.meter) { cell.meter.as_str() } else { "other" };
190 let Some(line) = lines.iter_mut().find(|l| l.key == meter) else { continue };
191 let price = cell.price.unwrap_or(0.0).round() as i64;
192 line.micros += price;
193 line.quantity += if line.unit == "entries" { cell.entries.unwrap_or(0.0) } else { cell.quantity.unwrap_or(0.0) };
194 if let Some(i) = index.get(cell.day.as_str()) {
195 line.daily[*i] += price;
196 }
197 *by_day.entry((cell.day.clone(), product.to_owned())).or_default() += price;
198 let part = projects_of.entry((meter.to_owned(), cell.project.clone())).or_default();
199 part.0 += price;
200 part.1 += if line.unit == "entries" { cell.entries.unwrap_or(0.0) } else { cell.quantity.unwrap_or(0.0) };
201 totals.price_micros += price;
202 totals.discount_micros += cell.discount.unwrap_or(0.0).round() as i64;
203 totals.included_micros += cell.covered.unwrap_or(0.0).round() as i64;
204 totals.cost_micros += cell.cost.unwrap_or(0.0).round() as i64;
205 }
206 // Pending usage is no one project's: only when all projects are shown.
207 if projects.is_empty() {
208 for p in pending {
209 let product = product_of(p.meter);
210 if !wanted_product(product) {
211 continue;
212 }
213 if let Some(line) = lines.iter_mut().find(|l| l.key == p.meter) {
214 line.micros += p.micros;
215 line.pending_micros += p.micros;
216 }
217 totals.price_micros += p.micros;
218 totals.pending_micros += p.micros;
219 totals.pending_charged_micros += p.micros - p.covered - p.discount;
220 totals.included_micros += p.covered;
221 totals.discount_micros += p.discount;
222 }
223 }
224 for ((meter, project), (micros, quantity)) in projects_of {
225 if let Some(line) = lines.iter_mut().find(|l| l.key == meter) {
226 line.by_project.push(ProjectUsage { project, micros, quantity });
227 }
228 }
229 for line in &mut lines {
230 line.by_project.sort_by(|a, b| b.micros.cmp(&a.micros).then(a.project.cmp(&b.project)));
231 }
232 // The products that still have meters, in order, then Other when it
233 // has something.
234 let other_used = lines.iter().any(|l| l.product == OTHER && (l.micros != 0 || l.quantity != 0.0));
235 let products_out = PRODUCTS
236 .iter()
237 .copied()
238 .filter(|(key, _)| METERS.iter().any(|m| m.2 == *key))
239 .chain(other_used.then_some((OTHER, "Other")))
240 .filter(|(key, _)| wanted_product(key))
241 .map(|(key, label)| {
242 let meters: Vec<MeterLine> = lines.iter().filter(|l| l.product == key).cloned().collect();
243 ProductUsage {
244 key: key.to_owned(),
245 label: label.to_owned(),
246 micros: meters.iter().map(|m| m.micros).sum(),
247 meters,
248 }
249 })
250 .collect();
251 let days_out = by_day.into_iter().filter(|(_, micros)| *micros != 0).map(|((day, product), micros)| UsageDay { day, product, micros }).collect();
252 (products_out, days_out, totals, all_projects.into_iter().collect())
253}
254
255impl Billing {
256 /// `usage_report`: members only.
257 pub(crate) async fn usage_report(&self, a: UsageReportArgs) -> Result<Outcome<UsageReport>> {
258 let workspace = a.workspace.to_lowercase();
259 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
260 return Ok(members_only());
261 }
262 let days = match days_between(&a.from, &a.until) {
263 Ok(days) => days,
264 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
265 };
266 let from = days[0].clone();
267 let until = days[days.len() - 1].clone();
268 // Lines entered on the last day count, whatever the hour.
269 let end = rfc3339(parse_rfc3339(&format!("{until}T00:00:00Z")).unwrap_or(0) + DAY_MS);
270 let measure = if self.free { "COALESCE(cost_micros, 0)" } else { PRICE_SQL };
271 let now = rfc3339(now_ms());
272 let month = now[..7].to_owned();
273 let this_month = reaches_this_month(&from, &until, &now[..10]);
274
275 let cells = async {
276 self.db
277 .prepare(format!(
278 "SELECT substr(created_at, 1, 10) AS day, {meter} AS meter, COALESCE(repo, '') AS project,
279 SUM({measure}) AS price, SUM(COALESCE(quantity, 0)) AS quantity, COUNT(*) AS entries,
280 SUM(COALESCE(discount_micros, 0)) AS discount,
281 SUM(COALESCE(credit_micros, 0) + COALESCE(trial_micros, 0) + COALESCE(oss_micros, 0) + COALESCE(given_micros, 0)) AS covered,
282 SUM(COALESCE(cost_micros, 0)) AS cost
283 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND created_at < ?3
284 GROUP BY 1, 2, 3 LIMIT 20000",
285 meter = METER_KEY_SQL
286 ))
287 .bind(&[workspace.as_str().into(), from.as_str().into(), end.as_str().into()])?
288 .all()
289 .await?
290 .results::<Cell>()
291 };
292 let pending = async {
293 if !this_month {
294 return Ok(vec![]);
295 }
296 self.pending_this_month(&[workspace.as_str().into()], &month).await
297 };
298 let credits = self.credit_paid_between(&workspace, &from, &end);
299 let ((cells, pending, (credits_paid, credits)), account) =
300 try_join(try_join3(cells, pending, credits), self.account_of(&workspace)).await?;
301 let percent = account.terms.percent_off();
302 // What is metered so far is entered on the account's terms when the
303 // month closes: its discount, and what g1t covers, come off it now.
304 let pending: Vec<Pending> = pending
305 .into_iter()
306 .map(|p| {
307 let split = pending_split(p.cost(), p.charge(), self.margin_percent, percent);
308 Pending { meter: meter_of_pending(&p.source), micros: split.price, covered: split.covered, discount: split.discount }
309 })
310 .filter(|p| p.micros > 0)
311 .collect();
312 let (mut products, days_out, mut totals, all_projects) = shape(&days, &cells, &pending, &a.products, &a.projects);
313 let filtered = !a.products.is_empty() || !a.projects.is_empty();
314 // Credit is the workspace's, not a product's or a project's: only
315 // without a filter is it taken off.
316 totals.credits_micros = if filtered { 0 } else { credits_paid.max(0) };
317 totals.charged_micros = charged(&totals);
318 let plan = self.plan_kind_for(&workspace, &account).await?;
319 // Allowances, for the range that includes this month.
320 let mut included = None;
321 let mut trial = None;
322 if this_month {
323 let (used, stored, operations) = futures_util::future::try_join3(
324 async {
325 if plan == PlanKind::Paid { self.allowance_used("plan_credit", &workspace, &month).await } else { Ok(0) }
326 },
327 self.private_storage(&workspace),
328 self.git_operations_this_month(&workspace),
329 )
330 .await?;
331 if plan == PlanKind::Paid {
332 included = Some(Allowance { used: used as f64, of: self.plans.plan_included_micros as f64, unit: "micros".to_owned() });
333 }
334 for product in &mut products {
335 for meter in &mut product.meters {
336 match meter.key.as_str() {
337 "storage" => {
338 meter.quantity = stored as f64;
339 meter.allowance = Some(Allowance { used: stored as f64, of: self.plans.free_storage_bytes as f64, unit: "bytes".to_owned() });
340 }
341 "git" => {
342 meter.quantity = operations as f64;
343 meter.allowance = Some(Allowance { used: operations as f64, of: self.plans.git_included as f64, unit: "operations".to_owned() });
344 }
345 _ => {}
346 }
347 }
348 }
349 if plan == PlanKind::Free
350 && let Some(grant) = self.grant_of(&workspace).await?
351 {
352 trial = Some(crate::credits::left(grant.granted_micros, grant.used_micros));
353 }
354 }
355 // AI credit bought before g1t stopped selling it: still the
356 // workspace's, and kept apart from credit for everything.
357 let ai: i64 = credits.grants.iter().filter(|g| g.scope == "models").map(|g| g.left_micros).sum();
358 Ok(Outcome::Ok(UsageReport {
359 from,
360 until,
361 totals,
362 days: days_out,
363 products,
364 projects: all_projects,
365 included,
366 discount_percent: (percent > 0).then_some(percent),
367 ai_credit_micros: ai,
368 credit_micros: credits.left_micros - ai,
369 trial_micros: trial,
370 plan,
371 free: self.free,
372 }))
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 fn cell(day: &str, meter: &str, project: &str, price: f64) -> Cell {
381 Cell { day: day.into(), meter: meter.into(), project: project.into(), price: Some(price), entries: Some(1.0), ..Cell::default() }
382 }
383
384 #[test]
385 fn a_range_is_every_day_in_it() {
386 let days = days_between("2026-10-01", "2026-10-03").unwrap();
387 assert_eq!(days, ["2026-10-01", "2026-10-02", "2026-10-03"]);
388 assert_eq!(days_between("2026-10-01", "2026-10-01").unwrap().len(), 1);
389 assert!(days_between("2026-10-03", "2026-10-01").is_err());
390 assert!(days_between("2025-01-01", "2026-10-01").is_err());
391 assert!(days_between("Oct 1", "2026-10-01").is_err());
392 }
393
394 #[test]
395 fn usage_is_shaped_into_products_meters_days_and_projects() {
396 let days = days_between("2026-10-01", "2026-10-03").unwrap();
397 let mut sandbox = cell("2026-10-02", "sandbox", "acme/web", 120_000.0);
398 sandbox.quantity = Some(600.0);
399 let cells = vec![
400 cell("2026-10-01", "requests", "acme/web", 2_000_000.0),
401 cell("2026-10-03", "requests", "acme/api", 1_000_000.0),
402 sandbox,
403 Cell { covered: Some(1_000_000.0), discount: Some(0.0), ..cell("2026-10-03", "storage", "", 30_000.0) },
404 ];
405 let pending = vec![Pending { meter: "git", micros: 40_000, ..Pending::default() }];
406 let (products, by_day, totals, projects) = shape(&days, &cells, &pending, &[], &[]);
407 // Every product family with meters, in order; no Other without usage on it.
408 assert_eq!(products.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(), ["sandboxes", "deployments", "git_storage", "packages", "security", "search"]);
409 let deployments = &products[1];
410 assert_eq!(deployments.micros, 3_000_000);
411 let requests = deployments.meters.iter().find(|m| m.key == "requests").unwrap();
412 assert_eq!(requests.daily, [2_000_000, 0, 1_000_000]);
413 assert_eq!(requests.by_project[0].project, "acme/web");
414 let sandbox = products[0].meters.iter().find(|m| m.key == "sandbox").unwrap();
415 assert_eq!((sandbox.micros, sandbox.quantity), (120_000, 600.0));
416 // Pending usage counts in the totals and its meter, not on a day.
417 let git = products[2].meters.iter().find(|m| m.key == "git").unwrap();
418 assert_eq!((git.micros, git.pending_micros), (40_000, 40_000));
419 assert_eq!(totals.price_micros, 3_000_000 + 120_000 + 30_000 + 40_000);
420 assert_eq!(totals.pending_micros, 40_000);
421 assert_eq!(totals.included_micros, 1_000_000);
422 assert_eq!(by_day.iter().filter(|d| d.product == "deployments").map(|d| d.micros).sum::<i64>(), 3_000_000);
423 assert_eq!(projects, ["acme/api", "acme/web"]);
424 }
425
426 #[test]
427 fn usage_from_retired_or_unknown_meters_is_other_and_never_lost() {
428 let days = days_between("2026-10-01", "2026-10-02").unwrap();
429 // Lines from when g1t ran agents, as the ledger still has them, and
430 // a meter nothing knows.
431 let cells = vec![
432 cell("2026-10-01", "other", "acme/web", 2_000_000.0),
433 cell("2026-10-01", "agent_rate", "acme/web", 500_000.0),
434 cell("2026-10-02", "gateway", "", 70_000.0),
435 cell("2026-10-02", "sandbox", "acme/web", 10_000.0),
436 ];
437 let (products, by_day, totals, _) = shape(&days, &cells, &[], &[], &[]);
438 let other = products.last().unwrap();
439 assert_eq!((other.key.as_str(), other.label.as_str(), other.micros), ("other", "Other", 2_570_000));
440 assert_eq!(other.meters.len(), 1);
441 assert_eq!(other.meters[0].daily, [2_500_000, 70_000]);
442 // Everything charged is in the totals and on its day.
443 assert_eq!(totals.price_micros, 2_580_000);
444 assert_eq!(products.iter().map(|p| p.micros).sum::<i64>(), totals.price_micros);
445 assert_eq!(by_day.iter().map(|d| d.micros).sum::<i64>(), totals.price_micros);
446 // Asked for by name, Other is shown alone.
447 let (only, _, totals, _) = shape(&days, &cells, &[], &["other".into()], &[]);
448 assert_eq!(only.len(), 1);
449 assert_eq!(totals.price_micros, 2_570_000);
450 }
451
452 #[test]
453 fn filters_keep_only_the_products_and_projects_asked_for() {
454 let days = days_between("2026-10-01", "2026-10-01").unwrap();
455 let cells = vec![cell("2026-10-01", "sandbox", "acme/web", 100.0), cell("2026-10-01", "sandbox", "acme/api", 50.0), cell("2026-10-01", "requests", "acme/web", 10.0)];
456 let pending = vec![Pending { meter: "git", micros: 7, ..Pending::default() }];
457 let (products, _, totals, projects) = shape(&days, &cells, &pending, &["sandboxes".into()], &["acme/web".into()]);
458 assert_eq!(products.len(), 1);
459 assert_eq!(totals.price_micros, 100);
460 // Every project stays listed, for the filter itself.
461 assert_eq!(projects, ["acme/api", "acme/web"]);
462 }
463
464 #[test]
465 fn pending_usage_counts_at_price_with_what_is_covered_and_discounted() {
466 let days = days_between("2026-10-01", "2026-10-02").unwrap();
467 // Storage on a 100% discount, and scans g1t covers on a free workspace.
468 let pending = [
469 Pending { meter: "storage", micros: 9_000, covered: 0, discount: 9_000 },
470 Pending { meter: "security", micros: 2_400, covered: 2_400, discount: 0 },
471 ];
472 let (_, _, totals, _) = shape(&days, &[], &pending, &[], &[]);
473 assert_eq!(totals.price_micros, 11_400);
474 assert_eq!(totals.pending_micros, 11_400);
475 assert_eq!(totals.pending_charged_micros, 11_400 - totals.included_micros - totals.discount_micros);
476 assert_eq!(totals.included_micros, 2_400);
477 assert_eq!(totals.discount_micros, 9_000);
478 assert_eq!(charged(&totals), 0);
479 // With a project chosen, pending usage (no project's) is left out.
480 let (_, _, filtered, _) = shape(&days, &[], &pending, &[], &["acme/api".to_owned()]);
481 assert_eq!(filtered.pending_micros, 0);
482 }
483
484 #[test]
485 fn pending_usage_is_on_its_meter() {
486 assert_eq!(meter_of_pending("package_storage"), "packages");
487 assert_eq!(meter_of_pending("nonsense"), "other");
488 }
489
490 #[test]
491 fn pending_usage_counts_in_any_range_that_reaches_into_this_month() {
492 // The first of the month to today, as every page asks: counted.
493 assert!(reaches_this_month("2026-10-01", "2026-10-10", "2026-10-10"));
494 // To month end, or only the last week: counted too.
495 assert!(reaches_this_month("2026-10-01", "2026-10-31", "2026-10-10"));
496 assert!(reaches_this_month("2026-10-04", "2026-10-10", "2026-10-10"));
497 // Last month, or a month that has not started: not this month's.
498 assert!(!reaches_this_month("2026-09-01", "2026-09-30", "2026-10-10"));
499 assert!(!reaches_this_month("2026-11-01", "2026-11-30", "2026-10-10"));
500 // The month has turned on billing's clock: September's range no longer has it.
501 assert!(!reaches_this_month("2026-09-01", "2026-09-30", "2026-10-01"));
502 }
503}