eorscope 0.1.0

Dated statutory employer-contribution changes by country, read from official sources, as published by EOR Scope.
Documentation
//! Dated statutory employer-contribution changes by country, read from
//! official sources, as published by EOR Scope at <https://eorscope.com/data/>.
//!
//! The dataset is embedded in the crate (no network access) and parsed once,
//! on first use. Each [`Change`] carries its effective date, the new value
//! (rate, annual cap, weekly threshold or annual floor), its legal status and
//! the source it was read from, with a verbatim quote and the date it was read.
//!
//! Cost comparison data, not legal or tax advice.
//!
//! # Example
//!
//! ```
//! // United States: Social Security wage base for 2027.
//! let us = eorscope::changes_for_after("US", "2026-12-31");
//! let ss = us
//!     .iter()
//!     .find(|c| c.contribution_id.as_deref() == Some("social_security"))
//!     .expect("US Social Security 2027 change");
//! assert_eq!(ss.effective_from, "2027-01-01");
//! assert_eq!(ss.change.cap_annual_local, Some(192_300));
//! println!("{} from {}: {}", ss.country, ss.effective_from, ss.source.url);
//! ```

use serde::Deserialize;
use std::collections::BTreeMap;
use std::sync::OnceLock;

/// Raw JSON bytes of the embedded dataset, byte-identical to the file
/// published with the site.
pub const DATA_JSON: &str = include_str!("../data/scheduled_changes.json");

/// The whole embedded dataset.
#[derive(Debug, Clone, Deserialize)]
pub struct Dataset {
    /// Where the snapshot was copied from in the EOR Scope repository.
    pub copied_from: String,
    /// Date of the snapshot (yyyy-mm-dd).
    pub snapshot_date: String,
    /// Commit of the snapshot in the EOR Scope repository.
    pub source_commit: String,
    /// Schedule per ISO 3166-1 alpha-2 country code.
    pub scheduled: BTreeMap<String, CountrySchedule>,
}

/// Research state and scheduled changes for one country.
#[derive(Debug, Clone, Deserialize)]
pub struct CountrySchedule {
    /// Date the country was last researched (yyyy-mm-dd).
    pub researched_on: String,
    /// Coverage of the research (for example `"partial"`).
    pub coverage: String,
    /// Changes announced but not acquired (awaited, bill, proposal...).
    pub pending: Vec<Pending>,
    /// Changes with an effective date.
    pub scheduled_changes: Vec<Change>,
}

/// A change that is not acquired: it must be shown as pending, never priced.
#[derive(Debug, Clone, Deserialize)]
pub struct Pending {
    /// `"awaited"`, `"bill"`, `"proposal"`, `"consultation"`, `"decided_unchanged"`...
    pub status: String,
    /// What is pending, as read from the source.
    pub description: String,
    /// Contribution concerned, when one is identified.
    pub contribution_id: Option<String>,
    /// Source of the information.
    pub source: PendingSource,
}

/// Source of a [`Pending`] item.
#[derive(Debug, Clone, Deserialize)]
pub struct PendingSource {
    /// Source URL.
    pub url: String,
}

/// A dated change to a statutory employer contribution.
#[derive(Debug, Clone, Deserialize)]
pub struct Change {
    /// ISO 3166-1 alpha-2 country code (filled from the dataset key).
    #[serde(skip_deserializing, default)]
    pub country: String,
    /// Contribution concerned, when one is identified.
    pub contribution_id: Option<String>,
    /// Effective date (yyyy-mm-dd).
    pub effective_from: String,
    /// How the date was obtained: `"stated"`, `"derived"` or `"inferred"`.
    pub date_basis: String,
    /// The new values; `None` fields are not changed.
    pub change: Delta,
    /// Legal status: `"enacted"`, `"budget_announced"`...
    pub status: String,
    /// Explanation written by EOR Scope Research.
    pub note: String,
    /// Source the change was read from.
    pub source: Source,
}

/// New values of a contribution; a `None` field is not changed.
#[derive(Debug, Clone, Deserialize)]
pub struct Delta {
    /// New rate, as a fraction (`0.062` = 6.2 %).
    pub rate: Option<f64>,
    /// New annual cap, in local currency.
    pub cap_annual_local: Option<i64>,
    /// New weekly threshold, in local currency.
    pub threshold_weekly_local: Option<i64>,
    /// New annual floor, in local currency.
    pub floor_annual_local: Option<i64>,
}

/// Source of a [`Change`].
#[derive(Debug, Clone, Deserialize)]
pub struct Source {
    /// Source URL.
    pub url: String,
    /// Verbatim quote from the source.
    pub quote: String,
    /// Date the source was read (yyyy-mm-dd).
    pub read_on: String,
    /// Kind of source (for example `"official"`).
    pub kind: String,
}

/// The parsed dataset, parsed once on first call.
pub fn dataset() -> &'static Dataset {
    static DATA: OnceLock<Dataset> = OnceLock::new();
    DATA.get_or_init(|| {
        let mut d: Dataset =
            serde_json::from_str(DATA_JSON).expect("embedded dataset is valid JSON");
        for (iso, s) in d.scheduled.iter_mut() {
            for c in s.scheduled_changes.iter_mut() {
                c.country = iso.clone();
            }
        }
        d
    })
}

/// All scheduled changes, ordered by country code then as published.
pub fn changes() -> Vec<&'static Change> {
    dataset()
        .scheduled
        .values()
        .flat_map(|s| s.scheduled_changes.iter())
        .collect()
}

/// Scheduled changes for one country (ISO alpha-2 code, case-insensitive).
pub fn changes_for(iso: &str) -> Vec<&'static Change> {
    changes()
        .into_iter()
        .filter(|c| c.country.eq_ignore_ascii_case(iso))
        .collect()
}

/// Scheduled changes effective strictly after `date` (yyyy-mm-dd, compared
/// as a string).
pub fn changes_after(date: &str) -> Vec<&'static Change> {
    changes()
        .into_iter()
        .filter(|c| c.effective_from.as_str() > date)
        .collect()
}

/// Scheduled changes for one country effective strictly after `date`.
pub fn changes_for_after(iso: &str, date: &str) -> Vec<&'static Change> {
    changes_for(iso)
        .into_iter()
        .filter(|c| c.effective_from.as_str() > date)
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use sha2::{Digest, Sha256};

    /// sha256 of `site/src/data/scheduled_changes.json` at main (ace7551).
    const DATA_SHA256: &str = "0b3443dbc3b00e1884a124ce503745c7b392fe02e0f662563c04ea3565e75576";

    #[test]
    fn embedded_copy_is_byte_identical() {
        let hex: String = Sha256::digest(DATA_JSON.as_bytes())
            .iter()
            .map(|b| format!("{b:02x}"))
            .collect();
        assert_eq!(hex, DATA_SHA256);
    }

    #[test]
    fn counts() {
        assert_eq!(changes().len(), 66);
        let mut iso: Vec<_> = changes().iter().map(|c| c.country.as_str()).collect();
        iso.dedup();
        assert_eq!(iso.len(), 28);
        assert_eq!(dataset().scheduled.len(), 76);
    }

    #[test]
    fn us_social_security_2027_cap() {
        let c = changes_for("us")
            .into_iter()
            .find(|c| c.contribution_id.as_deref() == Some("social_security"))
            .unwrap();
        assert_eq!(c.effective_from, "2027-01-01");
        assert_eq!(c.change.cap_annual_local, Some(192_300));
        assert_eq!(c.status, "enacted");
    }

    #[test]
    fn date_filters() {
        assert!(changes_after("9999-12-31").is_empty());
        assert_eq!(changes_after("0000-01-01").len(), changes().len());
        assert!(changes_for_after("US", "2027-01-01")
            .iter()
            .all(|c| c.effective_from.as_str() > "2027-01-01"));
        assert!(changes_for("XX").is_empty());
    }
}