diff --git a/src/classifier.rs b/src/classifier.rs index c5cc3ba..6e0c75e 100644 --- a/src/classifier.rs +++ b/src/classifier.rs @@ -1,7 +1,7 @@ use crate::{Account, Transaction, TransactionDetails}; use chrono::{Datelike, NaiveDate}; -#[derive(Debug)] +#[derive(Debug, PartialEq, Hash, Eq, Clone)] pub enum Category { Zero815, Book, @@ -40,7 +40,7 @@ pub enum Category { Hardware(Hardware), } -#[derive(Debug)] +#[derive(Debug, PartialEq, Hash, Eq, Clone)] pub enum Hardware { Laptop, Remarkable, @@ -53,9 +53,9 @@ impl From for Category { } } -#[derive(Debug)] +#[derive(Debug, PartialEq, Hash, Eq, Clone)] pub enum Travel { - eSIM, + Esim, } impl From for Category { @@ -64,7 +64,7 @@ impl From for Category { } } -#[derive(Debug)] +#[derive(Debug, PartialEq, Hash, Eq, Clone)] pub enum Tech { LLM, MediaMarkt, @@ -80,7 +80,7 @@ impl From for Category { } } -#[derive(Debug)] +#[derive(Debug, PartialEq, Hash, Eq, Clone)] pub enum Restaurant { Proper, FastFood, @@ -94,7 +94,7 @@ impl From for Category { } } -#[derive(Debug)] +#[derive(Debug, PartialEq, Hash, Eq, Clone)] pub enum Sport { Squash, SportOkay, @@ -108,7 +108,7 @@ impl From for Category { } } -#[derive(Debug)] +#[derive(Debug, PartialEq, Hash, Eq, Clone)] pub enum Groceries { Hofer, Lidl, @@ -125,7 +125,7 @@ impl From for Category { } } -#[derive(Debug)] +#[derive(Debug, PartialEq, Hash, Eq, Clone)] pub enum Transport { LinzLinien, Nextbike, @@ -141,7 +141,7 @@ impl From for Category { } } -#[derive(Debug)] +#[derive(Debug, PartialEq, Hash, Eq, Clone)] pub enum Donation { RemarkableRCU, Signal, @@ -377,7 +377,7 @@ pub fn cat(details: &TransactionDetails) -> Category { return Category::Household("Schuhe".into()); } if details.desc == "AIRALO" { - return Travel::eSIM.into(); + return Travel::Esim.into(); } if details.desc == "Autobahnrasthaus Chiem" || details.desc == "Cafe+Co AT 311530" { return Category::CoffeeToGo; diff --git a/src/lib.rs b/src/lib.rs index 4aa4748..f579907 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,10 @@ pub mod classifier; +mod n26; +pub mod stat; use crate::classifier::Category; use chrono::NaiveDate; +use std::fmt::Display; #[derive(Debug, PartialEq)] pub enum Account { @@ -21,3 +24,22 @@ pub struct Transaction { pub details: TransactionDetails, pub cat: Category, } + +pub struct Overview { + transactions: Vec, +} + +impl Overview { + pub fn new() -> Self { + Self { + transactions: Vec::new(), + } + } +} + +impl Display for Overview { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!("{} transactions", self.transactions.len()))?; + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index b9d68dd..4071d73 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,13 @@ -mod n26; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; fn main() { - n26::Transaction::from_file("./data/n26-2025-10-16.csv"); + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .init(); + + let mut overview = fin::Overview::new(); + overview.n26("./data/n26-2025-10-16.csv"); + + overview.by_month(); + println!("{overview}"); } diff --git a/src/n26.rs b/src/n26.rs index 7e99361..c14c79d 100644 --- a/src/n26.rs +++ b/src/n26.rs @@ -1,12 +1,20 @@ +use crate::{Account, Overview, TransactionDetails}; use chrono::NaiveDate; use csv::Reader; -use fin::{Account, TransactionDetails}; use serde::Deserialize; use std::fs::File; use tracing::info; +impl Overview { + pub fn n26(&mut self, path: &str) { + let mut transactions = Transaction::from_file(path); + info!("Added {} transactions.", transactions.len()); + self.transactions.append(&mut transactions); + } +} + #[derive(Debug, Deserialize)] -pub struct Transaction { +struct Transaction { #[serde(rename = "Booking Date")] booking_date: String, #[serde(rename = "Value Date")] @@ -31,7 +39,7 @@ pub struct Transaction { exchange_rate: Option, } -impl From for fin::Transaction { +impl From for crate::Transaction { fn from(value: Transaction) -> Self { let date = if let Some(date) = value.value_date { // If value_date exist, use this as it's earlier and closer to real date... @@ -42,6 +50,7 @@ impl From for fin::Transaction { }; let date = NaiveDate::parse_from_str(&date, "%Y-%m-%d").unwrap(); let amount_in_cent = (value.amount_eur * 100.).round() as i64; + let amount_in_cent = amount_in_cent.abs(); let desc = if let Some(desc) = value.partner_name { desc.clone() } else { @@ -60,16 +69,14 @@ impl From for fin::Transaction { } impl Transaction { - pub fn from_file(path: &str) -> Vec { + fn from_file(path: &str) -> Vec { info!("Parsing {path}..."); let mut ret = Vec::new(); let file = File::open(path).unwrap(); let mut rdr = Reader::from_reader(file); - for (idx, result) in rdr.deserialize::().enumerate() { - println!("{idx}"); + for result in rdr.deserialize::() { let tx: Transaction = result.unwrap(); - println!("{tx:#?}"); ret.push(tx.into()); } diff --git a/src/stat/date.rs b/src/stat/date.rs new file mode 100644 index 0000000..e3520d0 --- /dev/null +++ b/src/stat/date.rs @@ -0,0 +1,64 @@ +use crate::{classifier::Category, Overview, Transaction}; +use chrono::Datelike; +use std::collections::HashMap; + +impl Overview { + pub fn by_month(&self) { + let min_date = self.get_first_transaction().details.date; + let max_date = self.get_last_transaction().details.date; + let mut min_date = min_date.with_day(1).unwrap(); + + while min_date < max_date { + let month = min_date.format("%Y-%m").to_string(); + print!("{month}: "); + let txs = self.get_expenses_for_month(&month); + print!("{} txs, ", txs.len()); + let sum: i64 = txs.iter().map(|t| t.details.amount_in_cent).sum::() / 100; + println!("{sum} €"); + for (cat, amount) in self.get_top_5_cats_for_month(&month) { + println!(" - {cat:?} ({})", amount / 100); + } + + println!(); + min_date = min_date + chrono::Months::new(1); + } + } + + fn get_top_5_cats_for_month(&self, month: &str) -> Vec<(Category, i64)> { + let mut ret = HashMap::new(); + let txs = self.get_expenses_for_month(month); + for tx in txs { + if let Some(c) = ret.get_mut(&tx.cat) { + *c += tx.details.amount_in_cent; + } else { + ret.insert(tx.cat.clone(), tx.details.amount_in_cent); + } + } + + let mut ret = ret.into_iter().collect::>(); + ret.sort_by_key(|(_, amount)| *amount); + ret.into_iter().rev().take(5).collect() + } + + fn get_expenses_for_month(&self, month: &str) -> Vec<&Transaction> { + self.transactions + .iter() + .filter(|t| { + t.details.date.format("%Y-%m").to_string() == month && t.cat != Category::Transfer + }) + .collect() + } + + fn get_first_transaction(&self) -> &Transaction { + self.transactions + .iter() + .min_by_key(|t| t.details.date) + .unwrap() + } + fn get_last_transaction(&self) -> &Transaction { + self.transactions + .iter() + .max_by_key(|t| t.details.date) + .unwrap() + } +} diff --git a/src/stat/mod.rs b/src/stat/mod.rs new file mode 100644 index 0000000..1cce2a3 --- /dev/null +++ b/src/stat/mod.rs @@ -0,0 +1 @@ +pub mod date;