mod apply;
mod bank;
mod category;
mod csv;
mod ofx;
mod patterns;
mod payee;
mod place;
mod rules;
mod sort;
use std::collections::BTreeMap;
pub use apply::apply;
pub use bank::{BankError, CENTS_PER_DOLLAR, Cents, Row, merge, read};
pub use category::{Category, Family};
pub use patterns::Patterns;
pub use payee::normalize;
pub use place::Why;
pub use rules::{By, Outcome, PatternError};
pub use sort::{Of, Reach, Sorted, reach, sorted_records, spent_a_year};
use serde::{Deserialize, Serialize};
use crate::plan::{Dollars, Expense, Item, Plan, PlanDate};
use crate::project::inflation_factor;
use sort::{Sorter, Window};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct Estimate {
pub months: u8,
#[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
#[cfg_attr(feature = "ts", ts(type = "string | null"))]
#[serde(with = "crate::plan::optional_text")]
pub through: Option<PlanDate>,
pub proposals: Vec<Proposal>,
pub unknown: Vec<Unknown>,
pub income: Dollars,
pub transfers: Dollars,
pub ignored: Dollars,
pub ignored_rows: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct Proposal {
pub id: String,
pub is_new: bool,
pub name: String,
pub is_bucket: bool,
pub now: Option<Dollars>,
pub amount: Dollars,
pub total: Dollars,
pub rows: usize,
pub categories: Vec<Gathered>,
pub unknown: Dollars,
pub payees: Vec<(String, Dollars)>,
#[cfg_attr(feature = "schemars", schemars(with = "String"))]
#[cfg_attr(feature = "ts", ts(type = "string"))]
#[serde(with = "crate::plan::text")]
pub last: PlanDate,
pub one_time: bool,
pub phases: Phases,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct Gathered {
pub category: Category,
pub amount: Dollars,
pub why: Why,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "kebab-case")]
pub enum Phases {
#[default]
This,
Scaled,
All,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct Unknown {
pub payee: String,
pub amount: Dollars,
pub rows: usize,
}
#[derive(Default)]
struct Tally {
total: f64,
rows: usize,
payees: BTreeMap<String, f64>,
last: Option<PlanDate>,
}
impl Tally {
fn absorb(&mut self, other: Self) {
self.total += other.total;
self.rows += other.rows;
for (payee, dollars) in other.payees {
*self.payees.entry(payee).or_default() += dollars;
}
self.last = self.last.max(other.last);
}
}
#[derive(Default)]
struct Group {
tally: Tally,
categories: Vec<(Category, f64, Why)>,
unknown: f64,
}
#[must_use]
pub fn estimate(plan: &Plan, rows: &[Row], patterns: &Patterns) -> Estimate {
let (Some(window), Some(through)) = (Window::of(rows), rows.iter().map(|row| row.on).max())
else {
return Estimate::default();
};
let in_window = rows.iter().filter(|row| window.holds(row));
let Tallied {
mut by_category,
unknown,
ignored,
} = tally(plan, in_window, patterns);
let mut listed: Vec<Unknown> = unknown
.iter()
.map(|(payee, tally)| Unknown {
payee: payee.clone(),
amount: window.annual(-tally.total),
rows: tally.rows,
})
.collect();
let income = by_category.remove(&Category::Income);
let transfers = by_category.remove(&Category::Transfer);
let (groups, bucket) = grouped(plan, by_category, unknown);
let mut proposals: Vec<Proposal> = groups
.into_iter()
.map(|(target, group)| proposal(plan, (target, target == bucket), group, window))
.collect();
proposals.sort_by_key(|proposal| proposal.is_bucket);
listed
.sort_by(|a, b| (b.amount.abs().cmp(&a.amount.abs())).then_with(|| a.payee.cmp(&b.payee)));
let tallied = |tally: Option<Tally>| tally.map_or(0, |t| window.annual(t.total));
Estimate {
months: window.months() as u8,
through: Some(through),
proposals,
unknown: listed,
income: tallied(income),
transfers: tallied(transfers),
ignored: window.annual(-ignored.total),
ignored_rows: ignored.rows,
}
}
fn grouped(
plan: &Plan,
by_category: BTreeMap<Category, Tally>,
unknown: BTreeMap<String, Tally>,
) -> (BTreeMap<place::Target, Group>, place::Target) {
let spent = |tally: &Tally| (-tally.total).max(0.0);
let spending = by_category
.values()
.chain(unknown.values())
.map(spent)
.sum();
let placer = place::Placer::new(plan, spending);
let mut groups: BTreeMap<place::Target, Group> = BTreeMap::new();
for (category, tally) in by_category.into_iter().filter(|(_, t)| t.total < 0.0) {
let (target, why) = placer.place(category, spent(&tally));
let group = groups.entry(target).or_default();
group.categories.push((category, -tally.total, why));
group.tally.absorb(tally);
}
for tally in unknown.into_values().filter(|t| t.total < 0.0) {
let group = groups.entry(placer.bucket()).or_default();
group.unknown -= tally.total;
group.tally.absorb(tally);
}
(groups, placer.bucket())
}
#[derive(Default)]
struct Tallied {
by_category: BTreeMap<Category, Tally>,
unknown: BTreeMap<String, Tally>,
ignored: Tally,
}
fn tally<'a>(plan: &Plan, rows: impl Iterator<Item = &'a Row>, patterns: &Patterns) -> Tallied {
let mut tallied = Tallied::default();
let mut sorter = Sorter::new(patterns);
for row in rows {
let (payee, decided) = sorter.sort(row);
let (payee, outcome) = (payee.to_owned(), decided.map(|decided| decided.outcome));
let deflated = row.amount.dollars() / inflation_factor(plan, row.on.year());
let tally = match outcome {
Some(Outcome::Category(category)) => tallied.by_category.entry(category).or_default(),
Some(Outcome::Ignore) => &mut tallied.ignored,
None | Some(Outcome::Bucket) => tallied.unknown.entry(payee.clone()).or_default(),
};
tally.total += deflated;
tally.rows += 1;
*tally.payees.entry(payee).or_default() += deflated;
tally.last = tally.last.max(Some(row.on));
}
tallied
}
fn largest_first<K: Ord>(a: &(K, Dollars), b: &(K, Dollars)) -> std::cmp::Ordering {
b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))
}
fn proposal(
plan: &Plan,
(target, is_bucket): (place::Target, bool),
group: Group,
window: Window,
) -> Proposal {
let mut payees: Vec<(String, Dollars)> = group
.tally
.payees
.into_iter()
.map(|(payee, dollars)| (payee, window.annual(-dollars)))
.collect();
payees.sort_by(largest_first);
let mut categories: Vec<(Category, Dollars, Why)> = group
.categories
.into_iter()
.map(|(category, dollars, why)| (category, window.annual(dollars), why))
.collect();
categories.sort_by(|a, b| largest_first(&(a.0, a.1), &(b.0, b.1)));
let (id, now, name) = identified(plan, target, window.end);
Proposal {
id,
is_new: now.is_none(),
name,
is_bucket,
now,
amount: window.annual(-group.tally.total),
total: (-group.tally.total).round() as Dollars,
rows: group.tally.rows,
categories: categories
.into_iter()
.map(|(category, amount, why)| Gathered {
category,
amount,
why,
})
.collect(),
unknown: window.annual(group.unknown),
payees,
last: group.tally.last.expect("a group holds a row"),
one_time: false,
phases: Phases::default(),
}
}
fn identified(plan: &Plan, target: place::Target, end: i32) -> (String, Option<Dollars>, String) {
match target {
place::Target::Expense(at) => {
let stated = &plan.expenses[at];
let now = apply::in_force_amount(plan, stated, end);
(
stated.id.clone(),
Some(now),
stated.display_name().to_owned(),
)
}
place::Target::New(category) => (category.slug().to_owned(), None, category.title()),
place::Target::NewBucket => (
place::NEW_BUCKET_ID.to_owned(),
None,
place::NEW_BUCKET_NAME.to_owned(),
),
}
}
#[must_use]
pub fn bucket(plan: &Plan) -> Option<&Expense> {
place::bucket_position(plan).map(|at| &plan.expenses[at])
}