Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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);
    }
}