use serde::{Deserialize, Serialize};
use super::escalation::ColaSpec;
use super::triggers::Trigger;
use super::{Dollars, Issue, Plan, push_issue, validate};
use crate::tax;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Payer {
#[default]
Employee,
Employer,
AfterTax,
}
impl Payer {
pub const ALL: &'static [Self] = &[Self::Employee, Self::Employer, Self::AfterTax];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Employee => "employee",
Self::Employer => "employer",
Self::AfterTax => "after-tax",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Match {
pub rate: f64,
pub up_to: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Step {
pub add: f64,
pub up_to: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Contribution {
#[serde(default)]
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub to: String,
#[serde(default)]
pub by: Payer,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<Dollars>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rate: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub of: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub step: Option<Step>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub max: bool,
#[serde(rename = "match", skip_serializing_if = "Option::is_none")]
pub matching: Option<Match>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start: Option<Trigger>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<Trigger>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on: Option<Trigger>,
#[serde(default)]
pub cola: ColaSpec,
}
impl Contribution {
#[must_use]
pub fn rate_after(&self, years: i32) -> Option<f64> {
let rate = self.rate?;
let stepped = match self.step {
Some(step) => (rate + step.add * f64::from(years)).min(step.up_to),
None => rate,
};
Some((stepped * BASIS_POINTS).round() / BASIS_POINTS)
}
}
const BASIS_POINTS: f64 = 10_000.0;
const ONE_FORM: &str = "states exactly one of `amount`, `rate`, `max` and `match`";
const A_SHARE: &str = "must be a share between 0 and 1";
fn is_share(rate: f64) -> bool {
rate.is_finite() && (0.0..=1.0).contains(&rate)
}
fn check_form(path: &str, contribution: &Contribution, issues: &mut Vec<Issue>) {
let forms = [
contribution.amount.is_some(),
contribution.rate.is_some(),
contribution.max,
contribution.matching.is_some(),
];
if forms.iter().filter(|&&stated| stated).count() != 1 {
push_issue(issues, path, ONE_FORM);
}
if contribution.max && contribution.by != Payer::Employee {
push_issue(
issues,
format!("{path}.max"),
"only an employee amount has a maximum",
);
}
if contribution.matching.is_some() && contribution.by != Payer::Employer {
push_issue(issues, format!("{path}.match"), "only an employer matches");
}
if contribution.amount.is_some_and(|amount| amount < 0) {
push_issue(issues, format!("{path}.amount"), "must not be negative");
}
if let Some(matching) = contribution.matching {
if !is_share(matching.rate) {
push_issue(issues, format!("{path}.match.rate"), A_SHARE);
}
if !is_share(matching.up_to) {
push_issue(issues, format!("{path}.match.up_to"), A_SHARE);
}
}
}
fn check_share(path: &str, contribution: &Contribution, plan: &Plan, issues: &mut Vec<Issue>) {
let wants_income = contribution.rate.is_some() || contribution.matching.is_some();
if wants_income != contribution.of.is_some() {
push_issue(
issues,
format!("{path}.of"),
"`of` goes with `rate` and `match`",
);
}
if let Some(of) = &contribution.of
&& plan.income_source(of).is_none()
{
push_issue(
issues,
format!("{path}.of"),
format!("unknown income `{of}`"),
);
}
if contribution.rate.is_some_and(|rate| !is_share(rate)) {
push_issue(issues, format!("{path}.rate"), A_SHARE);
}
let Some(step) = contribution.step else {
return;
};
let Some(rate) = contribution.rate else {
push_issue(issues, format!("{path}.step"), "`step` goes with `rate`");
return;
};
if !step.add.is_finite() || step.add < 0.0 {
push_issue(issues, format!("{path}.step.add"), "must not be negative");
}
if !is_share(step.up_to) || step.up_to < rate {
push_issue(
issues,
format!("{path}.step.up_to"),
"must be a share at or above the rate",
);
}
}
pub(super) fn check_contributions(plan: &Plan, issues: &mut Vec<Issue>) {
for (i, contribution) in plan.contributions.iter().enumerate() {
let path = format!("contributions[{i}]");
check_form(&path, contribution, issues);
check_share(&path, contribution, plan, issues);
validate::check_span(issues, &path, contribution.span(), contribution.cola);
let Some(account) = plan.account(&contribution.to) else {
let unknown = format!("unknown account `{}`", contribution.to);
push_issue(issues, format!("{path}.to"), unknown);
continue;
};
let is_taken = match contribution.by {
Payer::Employee => account.kind.accepts_employee(),
Payer::Employer => account.kind.accepts_employer(),
Payer::AfterTax => account.kind.accepts_after_tax(),
};
if contribution.max && tax::limit_pool(account.kind).is_none() {
push_issue(
issues,
format!("{path}.max"),
"this account kind has no employee limit",
);
}
if !is_taken {
let refused = format!(
"this account kind takes no {} contributions",
contribution.by.as_str()
);
push_issue(issues, format!("{path}.by"), refused);
}
}
}