crates/monetize-pay/src/time.rs
//! Calendar arithmetic the vendors need (bank-file dates, RFC 3339 timestamps),
//! dependency-free. Proleptic Gregorian, UTC.
/// Midnight UTC of a date as unix milliseconds — days-from-civil (Hinnant).
/// `None` for an impossible date or one before 1970.
pub fn ymd_to_unix_ms(y: i64, m: u32, d: u32) -> Option<u64> {
if !(1..=12).contains(&m) || d == 0 {
return None;
}
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let dim = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][(m - 1) as usize];
if d > dim {
return None;
}
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m as i64 + 9) % 12;
let doy = (153 * mp + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
if days < 0 {
return None;
}
Some(days as u64 * 86_400_000)
}
/// `YYYY-MM-DDThh:mm:ss[.fff...](Z|±hh:mm)` → unix milliseconds. The subset
/// Klarna and Stripe emit; anything else is `None`, never a guess.
pub fn parse_rfc3339_ms(s: &str) -> Option<u64> {
let b = s.as_bytes();
if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || (b[10] != b'T' && b[10] != b't' && b[10] != b' ') {
return None;
}
let num = |from: usize, to: usize| -> Option<u64> {
let part = s.get(from..to)?;
if !part.bytes().all(|c| c.is_ascii_digit()) {
return None;
}
part.parse().ok()
};
let y = num(0, 4)? as i64;
let m = num(5, 7)? as u32;
let d = num(8, 10)? as u32;
let hh = num(11, 13)?;
if b[13] != b':' || b[16] != b':' {
return None;
}
let mm = num(14, 16)?;
let ss = num(17, 19)?;
if hh > 23 || mm > 59 || ss > 60 {
return None;
}
let mut i = 19;
let mut millis = 0u64;
if b.get(i) == Some(&b'.') {
i += 1;
let start = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
let frac = &s[start..i];
if frac.is_empty() {
return None;
}
let padded: String = frac.chars().chain("000".chars()).take(3).collect();
millis = padded.parse().ok()?;
}
let offset_ms: i64 = match b.get(i) {
Some(b'Z') | Some(b'z') if i + 1 == b.len() => 0,
Some(sign @ (b'+' | b'-')) if i + 6 == b.len() && b[i + 3] == b':' => {
let oh = num(i + 1, i + 3)? as i64;
let om = num(i + 4, i + 6)? as i64;
let off = (oh * 60 + om) * 60_000;
if *sign == b'+' { off } else { -off }
}
_ => return None,
};
let day = ymd_to_unix_ms(y, m, d)? as i64;
let t = day + ((hh * 3600 + mm * 60 + ss) * 1000 + millis) as i64 - offset_ms;
if t < 0 {
None
} else {
Some(t as u64)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rfc3339_subset_round_trips_known_instants() {
assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(parse_rfc3339_ms("2024-02-29T00:00:00Z"), Some(1_709_164_800_000));
assert_eq!(parse_rfc3339_ms("2024-02-29T01:02:03.456Z"), Some(1_709_164_800_000 + 3_723_456));
assert_eq!(parse_rfc3339_ms("2024-02-29T02:00:00+02:00"), Some(1_709_164_800_000));
assert_eq!(parse_rfc3339_ms("2024-02-28T23:00:00-01:00"), Some(1_709_164_800_000));
assert_eq!(parse_rfc3339_ms("2024-02-29T00:00:00.1Z"), Some(1_709_164_800_100));
assert_eq!(parse_rfc3339_ms("2024-02-29T00:00:00.123456Z"), Some(1_709_164_800_123));
assert_eq!(parse_rfc3339_ms("2024-02-29T00:00:00"), None, "no zone");
assert_eq!(parse_rfc3339_ms("2024-02-30T00:00:00Z"), None);
assert_eq!(parse_rfc3339_ms("garbage"), None);
}
}