feat(rust) : serveur réécrit en Rust, Node retiré ; Calendrier en tête du menu (#571)

- Serveur Rust (server-rs/, axum + sqlx) : les 148 adresses de l'API,
  l'interface, les migrations de démarrage et les tâches de fond ; même
  comportement que Node (banc : 2027 cas, 0 écart hors écarts déclarés
  USERS-02, RAPPRO-21, R4, R5).
- Node retiré : server/, dépendances serveur, image Docker sans Node
  (51 Mo), docker-compose à un seul service applicatif.
- R4 : écritures d'un autre site refusées (Sec-Fetch-Site / Origin).
- R5 : « aujourd'hui » et sauvegarde automatique à l'heure de Paris.
- R6 : mainteneur dans .github/CODEOWNERS.
- Menu latéral : Accueil puis Calendrier en tête.
- Plan et état : docs/PLAN-MIGRATION-RUST.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ke2qyS8rgacP3ap5iezDbM
This commit is contained in:
LogiFlowandClaude Opus 5.5 authored and GitHub committed 2026-10-15 20:31:14 +02:00
1 parent 6760f43e87
commit 600627c632
146 files changed
+31521 -20331

No files matched your search

+656
View File
@@ -0,0 +1,656 @@
// Échéancier des paiements fournisseurs : GET /api/payment-schedule et son export
// CSV, POST /api/payment-schedule/export (server/routes.ts), réservés à l'admin et
// au directeur (ses magasins seulement).
//
// La lecture complète les livraisons dans NocoDB (vérification des factures, avec
// son cache) : échéance absente → échéance et montant HT trouvés écrits en base ;
// montant TTC absent ou nul → montant HT (et échéance brute) réécrits.
//
// Défauts de Node reproduits :
// - une lecture (GET) écrit en base, une livraison à la fois (ECHE-01) ;
// - le repli « TTC » n'écrit jamais le TTC : il réécrit le HT et l'échéance de
// NocoDB, et recommence à chaque lecture ; la réponse garde l'échéance d'avant ;
// - l'export n'échappe pas les champs du CSV, et une erreur de validation des
// paramètres donne un 500 (message de ZodError).
use std::collections::HashMap;
use axum::extract::State;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Json, Router};
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, TimeZone, Utc};
use serde_json::{Value, json};
use sqlx::PgPool;
use crate::AppState;
use crate::auth::CurrentUser;
use crate::db;
use crate::error::{ApiResult, AppError};
use crate::http::express_etag;
use crate::js;
use crate::request::{JsQuery, JsonBody};
use crate::schema::{DeliveriesRow, GroupsRow};
use crate::services::invoice_verification::{self as verification, JsField, Verification, js_number_text};
use crate::services::nocodb::{self, truthy};
use crate::zod::Parsed;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/payment-schedule", get(schedule))
.route("/api/payment-schedule/export", post(export))
}
fn error_body(status: StatusCode, error: &str) -> AppError {
AppError::with_body(status, json!({ "error": error }))
}
fn admin_or_directeur(user: &CurrentUser) -> Result<(), AppError> {
if ["admin", "directeur"].contains(&user.role()) {
Ok(())
} else {
Err(error_body(
StatusCode::FORBIDDEN,
"Accès refusé - Admin ou Directeur uniquement",
))
}
}
/// Le directeur ne lit que ses magasins
fn check_directeur_store(user: &CurrentUser, group_id: f64) -> Result<(), AppError> {
if user.role() == "directeur" && !user.group_ids().iter().any(|id| f64::from(*id) == group_id) {
return Err(error_body(
StatusCode::FORBIDDEN,
"Accès refusé - Vous ne pouvez accéder qu'aux données de votre groupe",
));
}
Ok(())
}
// ---------- Validation (zod, schémas isolés : chemin vide) ----------
/// Type d'une valeur selon zod (getParsedType)
fn received(value: Option<&Value>) -> &'static str {
match value {
None => "undefined",
Some(Value::Null) => "null",
Some(Value::Bool(_)) => "boolean",
Some(Value::Number(_)) => "number",
Some(Value::String(_)) => "string",
Some(Value::Array(_)) => "array",
Some(Value::Object(_)) => "object",
}
}
/// invalid_type de zod : « Required » pour une valeur absente
fn invalid_type(expected: &str, value: Option<&Value>, path: Value) -> Value {
let received = received(value);
let message = if value.is_none() {
"Required".to_string()
} else {
format!("Expected {expected}, received {received}")
};
json!({ "code": "invalid_type", "expected": expected, "received": received, "path": path, "message": message })
}
/// z.coerce.number().int().positive() : Number(valeur), entier strictement positif
fn coerce_positive_int(value: Option<&Value>) -> Result<f64, Vec<Value>> {
let number = value.map_or(f64::NAN, js::to_number);
if number.is_nan() {
return Err(vec![json!({
"code": "invalid_type", "expected": "number", "received": "nan", "path": [],
"message": "Expected number, received nan",
})]);
}
let mut issues = Vec::new();
if number.fract() != 0.0 || !number.is_finite() {
issues.push(json!({
"code": "invalid_type", "expected": "integer", "received": "float",
"message": "Expected integer, received float", "path": [],
}));
}
if number <= 0.0 {
issues.push(json!({
"code": "too_small", "minimum": 0, "type": "number", "inclusive": false, "exact": false,
"message": "Number must be greater than 0", "path": [],
}));
}
if issues.is_empty() {
Ok(number)
} else {
Err(issues)
}
}
/// z.string().regex(/^\d{4}-\d{2}$/)
fn month_string(value: Option<&Value>) -> Result<String, Vec<Value>> {
let Some(Value::String(text)) = value else {
return Err(vec![invalid_type("string", value, json!([]))]);
};
let bytes = text.as_bytes();
let matches = bytes.len() == 7
&& bytes[4] == b'-'
&& bytes
.iter()
.enumerate()
.all(|(i, b)| i == 4 || b.is_ascii_digit());
if matches {
Ok(text.clone())
} else {
Err(vec![
json!({ "validation": "regex", "code": "invalid_string", "message": "Invalid", "path": [] }),
])
}
}
/// z.array(z.string()).min(1) : longueur contrôlée avant les éléments
fn payment_methods(value: Option<&Value>) -> Result<Vec<String>, Vec<Value>> {
let Some(Value::Array(items)) = value else {
return Err(vec![invalid_type("array", value, json!([]))]);
};
let mut issues = Vec::new();
if items.is_empty() {
issues.push(json!({
"code": "too_small", "minimum": 1, "type": "array", "inclusive": true, "exact": false,
"message": "Array must contain at least 1 element(s)", "path": [],
}));
}
let mut methods = Vec::new();
for (index, item) in items.iter().enumerate() {
match item.as_str() {
Some(text) => methods.push(text.to_string()),
None => issues.push(invalid_type("string", Some(item), json!([index]))),
}
}
if issues.is_empty() {
Ok(methods)
} else {
Err(issues)
}
}
// ---------- Livraisons lues (storage.getDeliveries([groupId])) ----------
/// Date JavaScript : valide (à la milliseconde) ou « Invalid Date » (vraie, écrite null)
#[derive(Debug, Clone, Copy, PartialEq)]
enum JsDate {
Valid(DateTime<Utc>),
Invalid,
}
impl JsDate {
fn of(date: Option<DateTime<Utc>>) -> Self {
date.map_or(JsDate::Invalid, JsDate::Valid)
}
/// Colonne timestamp lue par node-pg : Date à la milliseconde
fn from_column(value: NaiveDateTime) -> Self {
let millis = Utc.from_utc_datetime(&value).timestamp_millis();
JsDate::of(Utc.timestamp_millis_opt(millis).single())
}
fn to_json(self) -> Value {
match self {
JsDate::Valid(date) => Value::String(js::iso(&date)),
JsDate::Invalid => Value::Null,
}
}
}
#[derive(Debug, sqlx::FromRow)]
struct DeliveryRow {
id: i32,
supplier_id: i32,
invoice_reference: Option<String>,
/// Montants en texte, tels que node-pg les lit ("1250.50")
invoice_amount: Option<String>,
invoice_amount_ttc: Option<String>,
due_date: Option<NaiveDateTime>,
reconciled: Option<bool>,
}
/// Livraison telle que le gestionnaire Node la garde en mémoire et la complète
struct Line {
id: i32,
supplier_id: i32,
invoice_reference: String,
invoice_amount: Option<String>,
invoice_amount_ttc: Option<String>,
due_date: Option<JsDate>,
reconciled: bool,
}
/// Livraisons du magasin avec une référence de facture, par date de création
/// décroissante (mêmes jointures que Node)
async fn invoiced_deliveries(db: &PgPool, group_id: i32) -> Result<Vec<Line>, sqlx::Error> {
let rows: Vec<DeliveryRow> = sqlx::query_as(
"SELECT d.id, d.supplier_id, d.invoice_reference, d.invoice_amount::text AS invoice_amount,
d.invoice_amount_ttc::text AS invoice_amount_ttc, d.due_date, d.reconciled
FROM deliveries d
LEFT JOIN suppliers s ON d.supplier_id = s.id
LEFT JOIN groups g ON d.group_id = g.id
LEFT JOIN users u ON d.created_by = u.id
WHERE d.group_id = $1
ORDER BY d.created_at DESC",
)
.bind(group_id)
.fetch_all(db)
.await?;
Ok(rows
.into_iter()
.filter_map(|row| {
let reference = row.invoice_reference.filter(|reference| !reference.is_empty())?;
Some(Line {
id: row.id,
supplier_id: row.supplier_id,
invoice_reference: reference,
invoice_amount: row.invoice_amount,
invoice_amount_ttc: row.invoice_amount_ttc,
due_date: row.due_date.map(JsDate::from_column),
reconciled: row.reconciled == Some(true),
})
})
.collect())
}
/// Nom et mode de paiement des fournisseurs (storage.getSuppliers)
async fn suppliers(db: &PgPool) -> Result<HashMap<i32, (String, Option<String>)>, sqlx::Error> {
let rows: Vec<(i32, String, Option<String>)> =
sqlx::query_as("SELECT id, name, payment_method FROM suppliers ORDER BY name")
.fetch_all(db)
.await?;
Ok(rows
.into_iter()
.map(|(id, name, method)| (id, (name, method)))
.collect())
}
/// Texte d'une valeur vraie (`valeur || repli`)
fn or_default(value: &Option<String>) -> Option<&str> {
value.as_deref().filter(|text| !text.is_empty())
}
/// `montant ? parseFloat(montant) : 0`
fn amount(value: &Option<String>) -> f64 {
or_default(value).map_or(0.0, js::parse_float)
}
/// Montant trouvé « vrai » (ni absent, ni 0, ni NaN)
fn found_amount(result: &Verification) -> Option<f64> {
result
.invoice_amount
.get()
.copied()
.filter(|amount| *amount != 0.0 && !amount.is_nan())
}
/// storage.updateDelivery : erreur journalisée, comme le catch de Node
async fn update_delivery(db: &PgPool, id: i32, data: &Parsed) -> bool {
match db::update_by_id::<DeliveriesRow>(db, id, data, true).await {
Ok(_) => true,
Err(error) => {
tracing::error!(%error, id, "échéancier : mise à jour de la livraison");
false
}
}
}
/// Échéance absente : échéance (normalisée) et montant HT manquant pris dans NocoDB
async fn complete_due_date(state: &AppState, group_id: i32, line: &mut Line) {
let result =
verification::verify_invoice(state, &line.invoice_reference, group_id, false, line.reconciled).await;
let mut data = Parsed::new();
// new Date(échéance invalide) : la mise à jour échoue (toISOString), la
// livraison garde en mémoire sa date invalide et son montant
let mut writable = true;
if result.exists {
if let Some(normalized) = result.normalized_due_date() {
let date = JsDate::of(crate::services::dates::parse_js_date(&normalized));
match date {
JsDate::Valid(date) => {
data.insert("dueDate".into(), json!(js::iso(&date)));
}
JsDate::Invalid => writable = false,
}
line.due_date = Some(date);
}
}
if let Some(found) = found_amount(&result).filter(|_| result.exists) {
if or_default(&line.invoice_amount).is_none() {
let text = js_number_text(found);
data.insert("invoiceAmount".into(), json!(text));
line.invoice_amount = Some(text);
}
}
if !data.is_empty() && writable {
update_delivery(&state.db, line.id, &data).await;
}
}
/// Montant TTC absent ou nul : réécrit le HT (et l'échéance brute) de NocoDB, sans
/// jamais écrire le TTC
async fn complete_amount(state: &AppState, group_id: i32, line: &mut Line) {
let result =
verification::verify_invoice(state, &line.invoice_reference, group_id, false, line.reconciled).await;
let Some(found) = found_amount(&result).filter(|_| result.exists) else {
return;
};
let text = js_number_text(found);
let mut data = Parsed::new();
data.insert("invoiceAmount".into(), json!(text));
if let JsField::Set(due) = &result.due_date {
if truthy(due) {
match crate::services::dates::js_date(due) {
Some(date) => {
data.insert("dueDate".into(), json!(js::iso(&date)));
}
// new Date(échéance) invalide : toISOString lève avant la requête
None => return,
}
}
}
if update_delivery(&state.db, line.id, &data).await {
line.invoice_amount = Some(text);
}
}
async fn schedule(State(state): State<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
let server_error = |details: String| {
AppError::with_body(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "Erreur serveur", "details": details }),
)
};
admin_or_directeur(&user)?;
let raw = query.get("groupId").map(|text| Value::String(text.to_string()));
let Ok(group_id) = coerce_positive_int(raw.as_ref()) else {
return Err(error_body(
StatusCode::BAD_REQUEST,
"groupId invalide - doit être un entier positif",
));
};
check_directeur_store(&user, group_id)?;
let Some(group) = load_group(&state.db, group_id).await.map_err(server_error)? else {
return Ok(Json(json!({ "schedules": [], "message": "Groupe non trouvé" })).into_response());
};
let lines = invoiced_deliveries(&state.db, group.id)
.await
.map_err(|error| server_error(nocodb::database_message(&error)))?;
let (mut dated, mut undated): (Vec<Line>, Vec<Line>) =
lines.into_iter().partition(|line| line.due_date.is_some());
for line in &mut undated {
complete_due_date(&state, group.id, line).await;
}
dated.extend(undated.into_iter().filter(|line| line.due_date.is_some()));
for line in dated
.iter_mut()
.filter(|line| or_default(&line.invoice_amount_ttc).is_none_or(|ttc| js::parse_float(ttc) == 0.0))
{
complete_amount(&state, group.id, line).await;
}
let suppliers = suppliers(&state.db)
.await
.map_err(|error| server_error(nocodb::database_message(&error)))?;
let schedules: Vec<Value> = dated
.iter()
.map(|line| {
let supplier = suppliers.get(&line.supplier_id);
json!({
"id": line.id,
"invoiceReference": line.invoice_reference,
"dueDate": line.due_date.map_or(Value::Null, JsDate::to_json),
"amount": js::number_value(amount(&line.invoice_amount)),
"amountTTC": js::number_value(amount(&line.invoice_amount_ttc)),
"supplierName": supplier
.map(|(name, _)| name.as_str())
.filter(|name| !name.is_empty())
.unwrap_or("Fournisseur inconnu"),
"paymentMethod": supplier.and_then(|(_, method)| or_default(method)),
"groupId": group.id,
"groupName": group.name,
})
})
.collect();
Ok(Json(json!({ "schedules": schedules })).into_response())
}
/// storage.getGroup avec l'identifiant validé, envoyé en texte comme node-pg (un
/// entier hors limites fait échouer la requête)
async fn load_group(db: &PgPool, group_id: f64) -> Result<Option<GroupsRow>, String> {
nocodb::group_by_text_id(db, Some(&js::number_to_string(group_id))).await
}
// ---------- Export CSV ----------
/// Number.prototype.toFixed(2) : arrondi de la valeur binaire exacte, les égalités
/// parfaites (x,xx5 exactement représentable) vers le haut
fn to_fixed_2(number: f64) -> String {
if number.is_nan() {
return "NaN".into();
}
if number.abs() >= 1e21 {
return js::number_string(number);
}
if number < 0.0 {
return format!("-{}", to_fixed_2(-number));
}
// -0 n'est pas négatif pour toFixed
let number = number.abs();
let eighths = number * 8.0;
if eighths.fract() == 0.0 && eighths % 2.0 == 1.0 {
// x = k/8 (k impair) : x × 100 se termine exactement par ,5
let cents = (number * 100.0 + 0.5).floor() as u128;
return format!("{}.{:02}", cents / 100, cents % 100);
}
format!("{number:.2}")
}
/// Premier et dernier instant du mois (parseISO puis endOfMonth de date-fns, en
/// heure locale : UTC), None pour un mois invalide (Invalid Date : aucune ligne)
fn month_bounds(month: &str) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
let year: i32 = month[..4].parse().ok()?;
let number: u32 = month[5..].parse().ok()?;
let first = NaiveDate::from_ymd_opt(year, number, 1)?;
let next = if number == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1)?
} else {
NaiveDate::from_ymd_opt(year, number + 1, 1)?
};
let start = Utc.from_utc_datetime(&first.and_hms_opt(0, 0, 0)?);
let end = Utc.from_utc_datetime(&next.and_hms_opt(0, 0, 0)?) - Duration::milliseconds(1);
Some((start, end))
}
struct ExportLine {
due_date: DateTime<Utc>,
supplier_name: String,
invoice_reference: String,
payment_method: String,
amount_ht: f64,
amount_ttc: f64,
}
async fn export(State(state): State<AppState>, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult {
let failed = |details: String| {
AppError::with_body(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "Erreur lors de l'export", "details": details }),
)
};
let zod_failed = |issues: Vec<Value>| failed(serde_json::to_string_pretty(&issues).unwrap_or_default());
admin_or_directeur(&user)?;
let group_id = coerce_positive_int(body.get("groupId")).map_err(zod_failed)?;
let month = month_string(body.get("month")).map_err(zod_failed)?;
let methods = payment_methods(body.get("paymentMethods")).map_err(zod_failed)?;
let include_ht = body.get("includeHT").is_some_and(truthy);
let include_ttc = body.get("includeTTC").is_some_and(truthy);
check_directeur_store(&user, group_id)?;
let Some(group) = load_group(&state.db, group_id).await.map_err(failed)? else {
return Err(error_body(StatusCode::NOT_FOUND, "Groupe non trouvé"));
};
let lines = invoiced_deliveries(&state.db, group.id)
.await
.map_err(|error| failed(nocodb::database_message(&error)))?;
let bounds = month_bounds(&month);
let suppliers = suppliers(&state.db)
.await
.map_err(|error| failed(nocodb::database_message(&error)))?;
let mut schedules: Vec<ExportLine> = lines
.iter()
.filter_map(|line| match (line.due_date, bounds) {
(Some(JsDate::Valid(due)), Some((start, end))) if start <= due && due <= end => Some((line, due)),
_ => None,
})
.map(|(line, due_date)| {
let supplier = suppliers.get(&line.supplier_id);
ExportLine {
due_date,
supplier_name: supplier
.map(|(name, _)| name.clone())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "Fournisseur inconnu".into()),
invoice_reference: line.invoice_reference.clone(),
payment_method: supplier
.and_then(|(_, method)| or_default(method))
.unwrap_or("Non défini")
.to_string(),
amount_ht: amount(&line.invoice_amount),
amount_ttc: amount(&line.invoice_amount_ttc),
}
})
.filter(|line| methods.contains(&line.payment_method))
.collect();
// Tri stable par échéance
schedules.sort_by_key(|line| line.due_date);
// CSV au point-virgule (Excel en français), champs non échappés
let mut headers = vec!["Date d'échéance", "Fournisseur", "Facture", "Mode de paiement"];
if include_ht {
headers.push("Montant HT");
}
if include_ttc {
headers.push("Montant TTC");
}
let mut csv_lines = vec![headers.join(";")];
for line in &schedules {
let mut row = vec![
format!(
"{:02}/{:02}/{:04}",
line.due_date.day(),
line.due_date.month(),
line.due_date.year()
),
line.supplier_name.clone(),
line.invoice_reference.clone(),
line.payment_method.clone(),
];
if include_ht {
row.push(to_fixed_2(line.amount_ht));
}
if include_ttc {
row.push(to_fixed_2(line.amount_ttc));
}
csv_lines.push(row.join(";"));
}
let mut total = vec![String::new(), String::new(), String::new(), "TOTAL".to_string()];
if include_ht {
total.push(to_fixed_2(
schedules.iter().fold(0.0, |sum, line| sum + line.amount_ht),
));
}
if include_ttc {
total.push(to_fixed_2(
schedules.iter().fold(0.0, |sum, line| sum + line.amount_ttc),
));
}
csv_lines.push(total.join(";"));
// BOM UTF-8 pour Excel
let content = format!("\u{FEFF}{}", csv_lines.join("\n"));
tracing::info!(
count = schedules.len(),
group = group.name,
"export CSV de l'échéancier"
);
// res.send : ETag calculé sur le corps
let etag = express_etag(content.as_bytes());
let mut response = content.into_response();
let response_headers = response.headers_mut();
response_headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/csv; charset=utf-8"),
);
if let Ok(value) = HeaderValue::from_str(&format!("attachment; filename=\"echeancier_{month}.csv\"")) {
response_headers.insert(header::CONTENT_DISPOSITION, value);
}
if let Ok(value) = HeaderValue::from_str(&etag) {
response_headers.insert(header::ETAG, value);
}
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_fixed_comme_javascript() {
// Valeurs de Number.prototype.toFixed(2) dans Node
assert_eq!(to_fixed_2(0.125), "0.13");
assert_eq!(to_fixed_2(0.375), "0.38");
assert_eq!(to_fixed_2(2.5), "2.50");
assert_eq!(to_fixed_2(1.005), "1.00");
assert_eq!(to_fixed_2(0.1 + 0.2), "0.30");
assert_eq!(to_fixed_2(1250.5), "1250.50");
assert_eq!(to_fixed_2(-45.9), "-45.90");
assert_eq!(to_fixed_2(-0.001), "-0.00");
assert_eq!(to_fixed_2(-0.0), "0.00");
assert_eq!(to_fixed_2(10.125), "10.13");
}
#[test]
fn identifiant_de_magasin_comme_zod() {
assert_eq!(coerce_positive_int(Some(&json!(" 2 "))), Ok(2.0));
assert_eq!(coerce_positive_int(Some(&json!(true))), Ok(1.0));
let issues = coerce_positive_int(Some(&json!("-1.5"))).unwrap_err();
assert_eq!(issues.len(), 2);
assert_eq!(
serde_json::to_string(&coerce_positive_int(None).unwrap_err()).unwrap(),
r#"[{"code":"invalid_type","expected":"number","received":"nan","path":[],"message":"Expected number, received nan"}]"#
);
}
#[test]
fn parametres_d_export_comme_zod() {
assert_eq!(month_string(Some(&json!("2026-10"))), Ok("2026-10".to_string()));
assert_eq!(month_string(None).unwrap_err()[0]["message"], json!("Required"));
assert_eq!(
month_string(Some(&json!("2026-1"))).unwrap_err()[0]["validation"],
json!("regex")
);
let issues = payment_methods(Some(&json!(["a", 5, null]))).unwrap_err();
assert_eq!(issues[0]["path"], json!([1]));
assert_eq!(issues[1]["message"], json!("Expected string, received null"));
assert_eq!(
payment_methods(Some(&json!([]))).unwrap_err()[0]["code"],
json!("too_small")
);
}
#[test]
fn bornes_du_mois() {
let (start, end) = month_bounds("2026-02").unwrap();
assert_eq!(js::iso(&start), "2026-02-01T00:00:00.000Z");
assert_eq!(js::iso(&end), "2026-02-28T23:59:59.999Z");
assert_eq!(
js::iso(&month_bounds("2026-12").unwrap().1),
"2026-12-31T23:59:59.999Z"
);
assert!(month_bounds("2026-13").is_none());
assert!(month_bounds("2026-00").is_none());
}
}