Skip to main content

eorscope/
lib.rs

1//! Dated statutory employer-contribution changes by country, read from
2//! official sources, as published by EOR Scope at <https://eorscope.com/data/>.
3//!
4//! The dataset is embedded in the crate (no network access) and parsed once,
5//! on first use. Each [`Change`] carries its effective date, the new value
6//! (rate, annual cap, weekly threshold or annual floor), its legal status and
7//! the source it was read from, with a verbatim quote and the date it was read.
8//!
9//! Cost comparison data, not legal or tax advice.
10//!
11//! # Example
12//!
13//! ```
14//! // United States: Social Security wage base for 2027.
15//! let us = eorscope::changes_for_after("US", "2026-12-31");
16//! let ss = us
17//!     .iter()
18//!     .find(|c| c.contribution_id.as_deref() == Some("social_security"))
19//!     .expect("US Social Security 2027 change");
20//! assert_eq!(ss.effective_from, "2027-01-01");
21//! assert_eq!(ss.change.cap_annual_local, Some(192_300));
22//! println!("{} from {}: {}", ss.country, ss.effective_from, ss.source.url);
23//! ```
24
25use serde::Deserialize;
26use std::collections::BTreeMap;
27use std::sync::OnceLock;
28
29/// Raw JSON bytes of the embedded dataset, byte-identical to the file
30/// published with the site.
31pub const DATA_JSON: &str = include_str!("../data/scheduled_changes.json");
32
33/// The whole embedded dataset.
34#[derive(Debug, Clone, Deserialize)]
35pub struct Dataset {
36    /// Where the snapshot was copied from in the EOR Scope repository.
37    pub copied_from: String,
38    /// Date of the snapshot (yyyy-mm-dd).
39    pub snapshot_date: String,
40    /// Commit of the snapshot in the EOR Scope repository.
41    pub source_commit: String,
42    /// Schedule per ISO 3166-1 alpha-2 country code.
43    pub scheduled: BTreeMap<String, CountrySchedule>,
44}
45
46/// Research state and scheduled changes for one country.
47#[derive(Debug, Clone, Deserialize)]
48pub struct CountrySchedule {
49    /// Date the country was last researched (yyyy-mm-dd).
50    pub researched_on: String,
51    /// Coverage of the research (for example `"partial"`).
52    pub coverage: String,
53    /// Changes announced but not acquired (awaited, bill, proposal...).
54    pub pending: Vec<Pending>,
55    /// Changes with an effective date.
56    pub scheduled_changes: Vec<Change>,
57}
58
59/// A change that is not acquired: it must be shown as pending, never priced.
60#[derive(Debug, Clone, Deserialize)]
61pub struct Pending {
62    /// `"awaited"`, `"bill"`, `"proposal"`, `"consultation"`, `"decided_unchanged"`...
63    pub status: String,
64    /// What is pending, as read from the source.
65    pub description: String,
66    /// Contribution concerned, when one is identified.
67    pub contribution_id: Option<String>,
68    /// Source of the information.
69    pub source: PendingSource,
70}
71
72/// Source of a [`Pending`] item.
73#[derive(Debug, Clone, Deserialize)]
74pub struct PendingSource {
75    /// Source URL.
76    pub url: String,
77}
78
79/// A dated change to a statutory employer contribution.
80#[derive(Debug, Clone, Deserialize)]
81pub struct Change {
82    /// ISO 3166-1 alpha-2 country code (filled from the dataset key).
83    #[serde(skip_deserializing, default)]
84    pub country: String,
85    /// Contribution concerned, when one is identified.
86    pub contribution_id: Option<String>,
87    /// Effective date (yyyy-mm-dd).
88    pub effective_from: String,
89    /// How the date was obtained: `"stated"`, `"derived"` or `"inferred"`.
90    pub date_basis: String,
91    /// The new values; `None` fields are not changed.
92    pub change: Delta,
93    /// Legal status: `"enacted"`, `"budget_announced"`...
94    pub status: String,
95    /// Explanation written by EOR Scope Research.
96    pub note: String,
97    /// Source the change was read from.
98    pub source: Source,
99}
100
101/// New values of a contribution; a `None` field is not changed.
102#[derive(Debug, Clone, Deserialize)]
103pub struct Delta {
104    /// New rate, as a fraction (`0.062` = 6.2 %).
105    pub rate: Option<f64>,
106    /// New annual cap, in local currency.
107    pub cap_annual_local: Option<i64>,
108    /// New weekly threshold, in local currency.
109    pub threshold_weekly_local: Option<i64>,
110    /// New annual floor, in local currency.
111    pub floor_annual_local: Option<i64>,
112}
113
114/// Source of a [`Change`].
115#[derive(Debug, Clone, Deserialize)]
116pub struct Source {
117    /// Source URL.
118    pub url: String,
119    /// Verbatim quote from the source.
120    pub quote: String,
121    /// Date the source was read (yyyy-mm-dd).
122    pub read_on: String,
123    /// Kind of source (for example `"official"`).
124    pub kind: String,
125}
126
127/// The parsed dataset, parsed once on first call.
128pub fn dataset() -> &'static Dataset {
129    static DATA: OnceLock<Dataset> = OnceLock::new();
130    DATA.get_or_init(|| {
131        let mut d: Dataset =
132            serde_json::from_str(DATA_JSON).expect("embedded dataset is valid JSON");
133        for (iso, s) in d.scheduled.iter_mut() {
134            for c in s.scheduled_changes.iter_mut() {
135                c.country = iso.clone();
136            }
137        }
138        d
139    })
140}
141
142/// All scheduled changes, ordered by country code then as published.
143pub fn changes() -> Vec<&'static Change> {
144    dataset()
145        .scheduled
146        .values()
147        .flat_map(|s| s.scheduled_changes.iter())
148        .collect()
149}
150
151/// Scheduled changes for one country (ISO alpha-2 code, case-insensitive).
152pub fn changes_for(iso: &str) -> Vec<&'static Change> {
153    changes()
154        .into_iter()
155        .filter(|c| c.country.eq_ignore_ascii_case(iso))
156        .collect()
157}
158
159/// Scheduled changes effective strictly after `date` (yyyy-mm-dd, compared
160/// as a string).
161pub fn changes_after(date: &str) -> Vec<&'static Change> {
162    changes()
163        .into_iter()
164        .filter(|c| c.effective_from.as_str() > date)
165        .collect()
166}
167
168/// Scheduled changes for one country effective strictly after `date`.
169pub fn changes_for_after(iso: &str, date: &str) -> Vec<&'static Change> {
170    changes_for(iso)
171        .into_iter()
172        .filter(|c| c.effective_from.as_str() > date)
173        .collect()
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use sha2::{Digest, Sha256};
180
181    /// sha256 of `site/src/data/scheduled_changes.json` at main (ace7551).
182    const DATA_SHA256: &str = "0b3443dbc3b00e1884a124ce503745c7b392fe02e0f662563c04ea3565e75576";
183
184    #[test]
185    fn embedded_copy_is_byte_identical() {
186        let hex: String = Sha256::digest(DATA_JSON.as_bytes())
187            .iter()
188            .map(|b| format!("{b:02x}"))
189            .collect();
190        assert_eq!(hex, DATA_SHA256);
191    }
192
193    #[test]
194    fn counts() {
195        assert_eq!(changes().len(), 66);
196        let mut iso: Vec<_> = changes().iter().map(|c| c.country.as_str()).collect();
197        iso.dedup();
198        assert_eq!(iso.len(), 28);
199        assert_eq!(dataset().scheduled.len(), 76);
200    }
201
202    #[test]
203    fn us_social_security_2027_cap() {
204        let c = changes_for("us")
205            .into_iter()
206            .find(|c| c.contribution_id.as_deref() == Some("social_security"))
207            .unwrap();
208        assert_eq!(c.effective_from, "2027-01-01");
209        assert_eq!(c.change.cap_annual_local, Some(192_300));
210        assert_eq!(c.status, "enacted");
211    }
212
213    #[test]
214    fn date_filters() {
215        assert!(changes_after("9999-12-31").is_empty());
216        assert_eq!(changes_after("0000-01-01").len(), changes().len());
217        assert!(changes_for_after("US", "2027-01-01")
218            .iter()
219            .all(|c| c.effective_from.as_str() > "2027-01-01"));
220        assert!(changes_for("XX").is_empty());
221    }
222}