use std::collections::{BTreeMap, HashMap};
use std::ops::Sub;
use super::{Category, csv, ofx, payee};
use crate::plan::PlanDate;
pub const CENTS_PER_DOLLAR: i64 = 100;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Cents(pub i64);
impl Cents {
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
let text = text.trim();
let (is_negated, text) = match text.strip_prefix('(').and_then(|t| t.strip_suffix(')')) {
Some(inner) => (true, inner),
None => (false, text),
};
let cleaned: String = text
.chars()
.filter(|c| !matches!(c, '$' | ',' | ' '))
.collect();
let (is_minus, digits) = match cleaned.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, cleaned.strip_prefix('+').unwrap_or(&cleaned)),
};
let (whole, frac) = digits.split_once('.').unwrap_or((digits, ""));
if whole.is_empty() && frac.is_empty() || !frac.chars().all(|c| c.is_ascii_digit()) {
return None;
}
let whole: i64 = if whole.is_empty() {
0
} else {
whole.parse().ok()?
};
let mut frac_digits = frac.chars().map(|c| i64::from(c as u8 - b'0'));
let tens = frac_digits.next().unwrap_or(0);
let ones = frac_digits.next().unwrap_or(0);
let rounds_up = i64::from(frac_digits.next().is_some_and(|d| d >= 5));
let cents = whole * CENTS_PER_DOLLAR + tens * 10 + ones + rounds_up;
Some(Self(if is_minus ^ is_negated { -cents } else { cents }))
}
}
impl Cents {
#[must_use]
pub fn from_dollars(dollars: f64) -> Self {
Self((dollars * CENTS_PER_DOLLAR as f64).round() as i64)
}
#[must_use]
pub fn dollars(self) -> f64 {
self.0 as f64 / CENTS_PER_DOLLAR as f64
}
}
impl Sub for Cents {
type Output = Self;
fn sub(self, other: Self) -> Self {
Self(self.0 - other.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
pub on: PlanDate,
pub amount: Cents,
pub payee: String,
pub category: Option<String>,
pub kind: Option<Category>,
pub bank_id: Option<String>,
pub export: String,
}
impl Row {
#[must_use]
pub const fn spent(&self) -> Cents {
Cents(-self.amount.0)
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum BankError {
#[error("{0}: not a .csv, .ofx or .qfx file")]
Unknown(String),
#[error("{name}: cannot tell the columns apart from its first row `{header}`")]
Unreadable {
name: String,
header: String,
},
#[error("{0}: not an OFX file (no <OFX> and no <STMTTRN>)")]
NotOfx(String),
#[error("{name} row {row}: `{text}` is not a {expected}")]
Malformed {
name: String,
row: usize,
text: String,
expected: &'static str,
},
}
pub fn read(name: &str, text: &str) -> Result<Vec<Row>, BankError> {
let extension = name
.rsplit_once('.')
.map(|(_, ext)| ext.to_ascii_lowercase());
let mut rows = match extension.as_deref() {
Some("csv") => csv::rows(name, text),
Some("ofx" | "qfx") => ofx::rows(name, text),
_ => Err(BankError::Unknown(name.to_owned())),
}?;
for row in &mut rows {
name.clone_into(&mut row.export);
}
Ok(rows)
}
pub fn merge(read: &mut Vec<Row>, rows: Vec<Row>) {
let mut ids: HashMap<&str, usize> = HashMap::new();
let mut twins: BTreeMap<(PlanDate, Cents, String), usize> = BTreeMap::new();
for row in read.iter() {
match &row.bank_id {
Some(id) => *ids.entry(id).or_default() += 1,
None => *twins.entry(twin_key(row)).or_default() += 1,
}
}
let mut kept = Vec::new();
for row in rows {
let seen = match &row.bank_id {
Some(id) => ids.get_mut(id.as_str()),
None => twins.get_mut(&twin_key(&row)),
};
match seen {
Some(count) if *count > 0 => *count -= 1,
_ => kept.push(row),
}
}
read.extend(kept);
}
fn twin_key(row: &Row) -> (PlanDate, Cents, String) {
(row.on, row.amount, payee::normalize(&row.payee))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cents_read_every_bank_spelling() {
assert_eq!(Cents::parse("$1,234.56"), Some(Cents(123_456)));
assert_eq!(Cents::parse("(142.17)"), Some(Cents(-14217)));
assert_eq!(Cents::parse("-60.00"), Some(Cents(-6000)));
assert_eq!(Cents::parse(".5"), Some(Cents(50)));
assert_eq!(Cents::parse("12.345"), Some(Cents(1235)));
assert_eq!(Cents::parse("abc"), None);
}
#[test]
fn an_unknown_extension_is_refused_by_name() {
assert_eq!(
read("export.xlsx", ""),
Err(BankError::Unknown("export.xlsx".to_owned()))
);
}
}