use std::collections::BTreeMap;
use crate::plan::{Dollars, Issue, Plan, PlanDate};
const NAMESPACE: &str = "http://ssa.gov/osss/schemas/2.0";
const BIRTH_TAG: &str = "DateOfBirth";
const EARNINGS_TAG: &str = "Earnings";
const FICA_TAG: &str = "FicaEarnings";
const START_ATTRIBUTE: &str = "startYear=\"";
const END_ATTRIBUTE: &str = "endYear=\"";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Statement {
pub birth: PlanDate,
pub earnings: BTreeMap<i16, Dollars>,
pub grouped: Vec<(i16, i16)>,
}
impl Statement {
#[must_use]
pub fn spread_note(&self) -> Option<String> {
if self.grouped.is_empty() {
return None;
}
let ranges: Vec<String> = (self.grouped.iter())
.map(|(from, to)| format!("{from}-{to}"))
.collect();
Some(format!(
"earnings stated as one sum for {} were spread evenly over those years",
ranges.join(", ")
))
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum StatementError {
#[error("not a Social Security statement (expected the {NAMESPACE} schema)")]
NotAStatement,
#[error("the statement has no <{0}>")]
Missing(&'static str),
#[error("<{tag}> holds `{text}`, not a {expected}")]
Malformed {
tag: &'static str,
text: String,
expected: &'static str,
},
}
pub fn parse(xml: &str) -> Result<Statement, StatementError> {
if !xml.contains(NAMESPACE) {
return Err(StatementError::NotAStatement);
}
let birth_text = text_of(xml, BIRTH_TAG).ok_or(StatementError::Missing(BIRTH_TAG))?;
let birth = birth_text
.parse()
.map(PlanDate)
.map_err(|_| malformed(BIRTH_TAG, birth_text, "date"))?;
let mut earnings = BTreeMap::new();
let mut grouped = Vec::new();
let open = format!("<osss:{EARNINGS_TAG} ");
let close = format!("</osss:{EARNINGS_TAG}>");
for row in xml.split(&open).skip(1) {
let row = row.split_once(&close).map_or(row, |(row, _)| row);
let from = attribute(row, START_ATTRIBUTE)?;
let to = attribute(row, END_ATTRIBUTE)?;
let amount_text = text_of(row, FICA_TAG).ok_or(StatementError::Missing(FICA_TAG))?;
let amount: Dollars = amount_text
.parse()
.map_err(|_| malformed(FICA_TAG, amount_text, "whole number of dollars"))?;
let years = Dollars::from(to - from) + 1;
if years < 1 {
return Err(malformed(
EARNINGS_TAG,
&format!("{from}-{to}"),
"year range",
));
}
if years > 1 {
grouped.push((from, to));
}
let share = amount / years;
for year in from..to {
earnings.insert(year, share);
}
earnings.insert(to, amount - share * (years - 1));
}
Ok(Statement {
birth,
earnings,
grouped,
})
}
fn text_of<'a>(xml: &'a str, tag: &str) -> Option<&'a str> {
let open = format!("<osss:{tag}>");
let after = &xml[xml.find(&open)? + open.len()..];
after.split('<').next().map(str::trim)
}
fn attribute(row: &str, name: &'static str) -> Result<i16, StatementError> {
let text = row
.split(name)
.nth(1)
.and_then(|after| after.split('"').next())
.ok_or(StatementError::Missing(EARNINGS_TAG))?;
text.parse()
.map_err(|_| malformed(EARNINGS_TAG, text, "year"))
}
fn malformed(tag: &'static str, text: &str, expected: &'static str) -> StatementError {
StatementError::Malformed {
tag,
text: text.to_owned(),
expected,
}
}
impl Plan {
pub fn adopt_earnings(&mut self, person: &str, statement: &Statement) -> Result<(), Issue> {
let Some(index) = self
.household
.people
.iter()
.position(|candidate| candidate.id == person)
else {
return Err(Issue {
path: "household.people".into(),
message: format!("unknown person `{person}`"),
});
};
let holder = &mut self.household.people[index];
if holder.birth != statement.birth {
return Err(Issue {
path: format!("household.people[{index}].birth"),
message: format!(
"the statement is for someone born {}; `{person}` was born {}",
statement.birth.0, holder.birth.0
),
});
}
holder.earnings.clone_from(&statement.earnings);
Ok(())
}
}