From 2b76dd186fdce764dc38b484b832430e4c6d0fc4 Mon Sep 17 00:00:00 2001 From: Peter Nirschl Date: Mon, 24 Jun 2024 22:37:41 +0200 Subject: [PATCH] refactor config queries into ImporterConfig - move matching methods into ImporterConfig - try to avoid code duplication between importers Signed-off-by: Peter Nirschl --- src/config.rs | 136 +++++++++++++ src/importers/cardcomplete.rs | 85 +++----- src/importers/erste.rs | 371 +++++++++++----------------------- 3 files changed, 281 insertions(+), 311 deletions(-) diff --git a/src/config.rs b/src/config.rs index 38253c8..1bf628e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,7 @@ use crate::{ importers::revolut::RevolutConfig, }; use homedir::get_my_home; +use regex::RegexBuilder; use serde::Deserialize; use std::str::FromStr; @@ -59,6 +60,129 @@ impl ImporterConfig { Err(_) => Err(ImportError::ConfigRead(path)), } } + + pub fn identify_iban_opt(&self, iban: &Option) -> Option { + match iban { + Some(iban) => self.identify_iban(iban), + None => None, + } + } + + pub fn identify_iban(&self, iban: &str) -> Option { + self.ibans + .iter() + .find(|rule| rule.iban == iban) + .map(|rule| ImporterConfigTarget { + account: rule.account.clone(), + note: rule.note.clone(), + }) + } + + pub fn identify_card_opt(&self, card_number: &Option) -> Option { + match card_number { + Some(card_number) => self.identify_card(card_number), + None => None, + } + } + + pub fn identify_card(&self, card_number: &str) -> Option { + self.cards + .iter() + .find(|rule| rule.card == card_number) + .map(|rule| ImporterConfigTarget { + account: rule.account.clone(), + note: rule.note.clone(), + }) + } + + pub fn match_category(&self, category: &str) -> Option { + self.categories + .iter() + .find(|rule| category.contains(&rule.pattern)) + .map(|rule| ImporterConfigTarget { + account: rule.account.clone(), + note: rule.note.clone(), + }) + } + + pub fn match_sepa_creditor_opt( + &self, + sepa_creditor_id: &Option, + ) -> Option { + match sepa_creditor_id { + Some(sepa_creditor_id) => self.match_sepa_creditor(sepa_creditor_id), + None => None, + } + } + + pub fn match_sepa_creditor(&self, sepa_creditor_id: &str) -> Option { + self.sepa + .creditors + .iter() + .find(|rule| rule.creditor_id == sepa_creditor_id) + .map(|rule| ImporterConfigTarget { + account: rule.account.clone(), + note: rule.note.clone(), + }) + } + + pub fn match_sepa_mandate_opt( + &self, + sepa_mandate_id: &Option, + ) -> Option { + match sepa_mandate_id { + Some(sepa_mandate_id) => self.match_sepa_mandate(sepa_mandate_id), + None => None, + } + } + + pub fn match_sepa_mandate(&self, sepa_mandate_id: &str) -> Option { + self.sepa + .mandates + .iter() + .find(|rule| rule.mandate_id == sepa_mandate_id) + .map(|rule| ImporterConfigTarget { + account: rule.account.clone(), + note: rule.note.clone(), + }) + } + + pub fn match_mapping_opt( + &self, + field: &Option, + ) -> Result> { + match field { + Some(field) => self.match_mapping(field), + None => Ok(None), + } + } + + pub fn match_mapping(&self, field: &str) -> Result> { + for rule in &self.mapping { + if rule.matches(field)? { + return Ok(Some(ImporterConfigTarget { + account: rule.account.clone(), + note: rule.note.clone(), + })); + } + } + Ok(None) + } + + pub fn fallback(&self) -> Option { + self.fallback_account + .as_ref() + .map(|fallback| ImporterConfigTarget { + account: fallback.clone(), + note: None, + }) + } +} + +#[derive(Debug)] +pub struct ImporterConfigTarget { + pub account: String, + pub note: Option, } #[derive(Debug, Deserialize, PartialEq, Eq)] @@ -130,6 +254,18 @@ pub struct SimpleMapping { pub note: Option, } +impl SimpleMapping { + pub fn matches(&self, field: &str) -> Result { + let regex = RegexBuilder::new(&self.search) + .case_insensitive(true) + .build(); + match regex { + Ok(regex) => Ok(!field.is_empty() && regex.is_match(field)), + Err(e) => Err(ImportError::Regex(e.to_string())), + } + } +} + /// Represents a more complex mapping that enables the importer to post to different accounts, /// depending on the given transaction #[derive(Debug, Deserialize, PartialEq, Eq)] diff --git a/src/importers/cardcomplete.rs b/src/importers/cardcomplete.rs index 722d783..d270456 100644 --- a/src/importers/cardcomplete.rs +++ b/src/importers/cardcomplete.rs @@ -4,7 +4,6 @@ use bigdecimal::BigDecimal; use chrono::NaiveDate; use fast_xml::de::from_reader; use fast_xml::DeError; -use regex::RegexBuilder; use serde::Deserialize; use crate::config::ImporterConfig; @@ -102,83 +101,47 @@ struct CCTransaction { impl CCTransaction { pub fn into_hledger(self, config: &ImporterConfig) -> Result { + let mut note = None; + let mut postings = Vec::new(); + let posting_date = self.posting_date()?; - let postings = self.postings(config)?; let tags = self.tags()?; let state = self.state(); - Ok(Transaction { - date: posting_date, - code: None, - payee: self.merchant_name, - note: None, - state, - comment: None, - tags, - postings, - }) - } - - pub fn postings(&self, config: &ImporterConfig) -> Result> { - let mut postings = Vec::new(); - - let account = if let Some(card_number) = &self.card_number { - config - .cards - .iter() - .find(|card| &card.card == card_number) - .map(|mapping| mapping.account.clone()) - } else { - None - }; - - if let Some(account) = account { - let amount = self.amount()?; + let own_target = config.identify_card_opt(&self.card_number); + if let Some(own_target) = own_target { + note.clone_from(&own_target.note); postings.push(Posting { - account, - amount: Some(amount), + account: own_target.account, + amount: Some(self.amount()?), comment: None, tags: Vec::new(), }); } - let mut other_account = None; - - for rule in &config.mapping { - let regex = RegexBuilder::new(&rule.search) - .case_insensitive(true) - .build(); - match regex { - Ok(regex) => { - if regex.is_match(&self.merchant_name) { - other_account = Some(rule.account.clone()); - break; - } - } - Err(e) => return Err(ImportError::Regex(e.to_string())), - } - } - - if other_account.is_none() { - other_account = config - .categories - .iter() - .find(|rule| self.category.contains(&rule.pattern)) - .map(|rule| rule.account.clone()); - } - - // TODO these queries need refactoring... - - if let Some(account) = other_account { + let other_target = config + .match_mapping(&self.merchant_name)? + .or(config.match_category(&self.category)); + if let Some(other_target) = other_target { + note.clone_from(&other_target.note); postings.push(Posting { - account, + account: other_target.account, amount: None, comment: None, tags: Vec::new(), }); } - Ok(postings) + Ok(Transaction { + date: posting_date, + code: None, + payee: self.merchant_name, + note, + state, + comment: None, + tags, + postings, + }) } pub fn tags(&self) -> Result> { diff --git a/src/importers/erste.rs b/src/importers/erste.rs index 80ede70..fd969b5 100644 --- a/src/importers/erste.rs +++ b/src/importers/erste.rs @@ -4,15 +4,10 @@ use bigdecimal::BigDecimal; use bigdecimal::FromPrimitive; use chrono::Days; use chrono::NaiveDate; -use regex::RegexBuilder; use serde::Deserialize; -use crate::config::CardMapping; -use crate::config::IbanMapping; use crate::config::ImporterConfig; -use crate::config::SepaCreditorMapping; -use crate::config::SepaMandateMapping; -use crate::config::SimpleMapping; +use crate::config::ImporterConfigTarget; use crate::error::ImportError; use crate::error::Result; use crate::hledger::output::*; @@ -47,10 +42,7 @@ impl HledgerImporter for HledgerErsteJsonImporter { .into_iter() .filter(|t| !known_codes.contains(&t.reference_number)) .map(|t| t.into_hledger(config)) - .collect::>>()? - .into_iter() - .flatten() - .collect(); + .collect::>>()?; Ok(result) } Err(e) => Err(ImportError::InputParse(e.to_string())), @@ -87,18 +79,57 @@ struct ErsteTransaction { } impl ErsteTransaction { - fn into_hledger(self, config: &ImporterConfig) -> Result> { - let matching_config = MatchingConfigItems::match_config(&self, config)?; - + fn into_hledger(self, config: &ImporterConfig) -> Result { + let mut postings = Vec::new(); + let mut note = None; let date = self.booking_date()?; + let tags = self.tags(); - let tags = self.derive_tags(); - let postings = self.derive_postings(&matching_config, config)?; - let note = self - .note - .or(matching_config.sepa_creditor.and_then(|c| c.note.clone())) - .or(matching_config.sepa_mandate.and_then(|m| m.note.clone())) - .or(matching_config.simple_mapping.and_then(|s| s.note.clone())); + let own_target = config + .identify_iban_opt(&self.owner_account_number) + .or(config.identify_card("Erste")); + + if let Some(own_target) = own_target { + note = own_target.note; + postings.push(Posting { + account: own_target.account, + amount: Some(self.amount.clone().try_into()?), + comment: None, + tags: Vec::new(), + }); + } + + let is_bank_transfer = match &self.partner_account { + Some(partner_account) => config.identify_iban_opt(&partner_account.iban).is_some(), + None => false, + }; + + if is_bank_transfer { + postings.push(Posting { + account: config.transfer_accounts.bank.clone(), + amount: None, + comment: None, + tags: Vec::new(), + }); + } else { + let other_target = config + .match_sepa_mandate_opt(&self.sepa_mandate_id) + .or(config.match_sepa_creditor_opt(&self.sepa_creditor_id)) + .or(self.match_creditor_debitor_mapping(config)?) + .or(config.match_mapping_opt(&self.partner_name)?) + .or(config.match_mapping_opt(&self.reference)?) + .or(config.fallback()); + + if let Some(other_target) = other_target { + note.clone_from(&other_target.note); + postings.push(Posting { + account: other_target.account.clone(), + amount: None, + comment: None, + tags: Vec::new(), + }); + } + } let mut payee = self .partner_name @@ -111,7 +142,11 @@ impl ErsteTransaction { } }); - Ok(vec![Transaction { + if let Some(trx_note) = &self.note { + note = Some(trx_note.clone()); + } + + Ok(Transaction { date, code: Some(self.reference_number), state: TransactionState::Cleared, @@ -120,10 +155,10 @@ impl ErsteTransaction { note, tags, postings, - }]) + }) } - fn derive_tags(&self) -> Vec { + fn tags(&self) -> Vec { let mut tags = Vec::new(); let valuation = &self.valuation; if valuation.len() >= 10 { @@ -177,73 +212,6 @@ impl ErsteTransaction { tags } - fn derive_postings( - &self, - config_items: &MatchingConfigItems, - config: &ImporterConfig, - ) -> Result> { - let mut result = Vec::new(); - - // posting on main bank account - let own_account = config_items - .iban - .map(|iban| iban.account.clone()) - .or_else(|| config_items.card.map(|card| card.account.clone())); - - if let Some(own_account) = own_account { - result.push(Posting { - account: own_account, - amount: Some(self.amount.clone().try_into()?), - comment: None, - tags: Vec::new(), - }); - } - - // postings agains another bank account owned by the person results in a bank transfer posting - if config_items.posting_against_own_iban { - result.push(Posting { - account: config.transfer_accounts.bank.clone(), - amount: None, - comment: None, - tags: Vec::new(), - }); - return Ok(result); - } - - // posting on P/L account or transfer account - let other_account = config_items - .sepa_mandate - .map(|mandate| mandate.account.clone()) - .or(config_items - .sepa_creditor - .map(|creditor| creditor.account.clone())) - .or(config_items.creditor_debitor_mapping_account.clone()) - .or(config_items - .simple_mapping - .map(|simple| simple.account.clone())); - - if let Some(other_account) = other_account { - result.push(Posting { - account: other_account, - amount: None, - comment: None, - tags: Vec::new(), - }); - } else if let Some(fallback_account) = &config.fallback_account { - result.push(Posting { - account: fallback_account.clone(), - amount: None, - comment: None, - tags: vec![Tag { - name: "todo".to_owned(), - value: None, - }], - }); - } - - Ok(result) - } - fn booking_date(&self) -> Result { if self.booking.len() >= 10 { match NaiveDate::parse_from_str(&self.booking[..10], "%Y-%m-%d") { @@ -257,6 +225,69 @@ impl ErsteTransaction { ))) } } + + fn match_creditor_debitor_mapping( + &self, + config: &ImporterConfig, + ) -> Result> { + match &self.partner_name { + Some(partner_name) => { + let mut search_amount: AmountAndCommodity = self.amount.clone().try_into()?; + search_amount.amount *= -1; + + for rule in &config.creditor_and_debitor_mapping { + if !partner_name.contains(&rule.payee) { + continue; + } + + let begin = match rule.days_difference { + Some(delta) => self + .booking_date()? + .checked_sub_days(Days::new(delta as u64)), + None => None, + }; + let end = match rule.days_difference { + Some(delta) => self + .booking_date()? + .checked_add_days(Days::new(delta as u64 + 1)), + None => None, + }; + + let hledger_transactions = query_hledger_by_payee_and_account( + &config.hledger, + &rule.payee, + &rule.account, + begin, + end, + )?; + let matching_cred_or_deb_trx = hledger_transactions.iter().any(|t| { + t.tpostings.iter().any(|p| { + p.paccount == rule.account + && p.pamount + .clone() + .into_iter() + .filter_map(|a| a.try_into().ok()) + .any(|a: AmountAndCommodity| a == search_amount) + }) + }); + + if matching_cred_or_deb_trx { + return Ok(Some(ImporterConfigTarget { + account: rule.account.clone(), + note: None, + })); + } else if let Some(default_pl_account) = &rule.default_pl_account { + return Ok(Some(ImporterConfigTarget { + account: default_pl_account.clone(), + note: None, + })); + } + } + Ok(None) + } + None => Ok(None), + } + } } #[derive(Deserialize)] @@ -292,166 +323,6 @@ impl TryFrom for AmountAndCommodity { } } -struct MatchingConfigItems<'a> { - pub sepa_creditor: Option<&'a SepaCreditorMapping>, - pub sepa_mandate: Option<&'a SepaMandateMapping>, - pub iban: Option<&'a IbanMapping>, - pub card: Option<&'a CardMapping>, - pub simple_mapping: Option<&'a SimpleMapping>, - pub creditor_debitor_mapping_account: Option, - - /// this flag is set to true, if the partner IBAN is found in the configuration - pub posting_against_own_iban: bool, -} - -impl<'a> MatchingConfigItems<'a> { - pub fn match_config( - transaction: &ErsteTransaction, - config: &'a ImporterConfig, - ) -> Result { - let mut iban = None; - if let Some(own_account_nr) = &transaction.owner_account_number { - if !own_account_nr.is_empty() { - // bank account (identified by its IBAN) - let iban_mapping = config.ibans.iter().find(|i| &i.iban == own_account_nr); - if let Some(iban_mapping) = iban_mapping { - iban = Some(iban_mapping); - } - } - } - - let mut card = None; - let card_mapping = config.cards.iter().find(|c| c.card == "Erste"); - if let Some(card_mapping) = card_mapping { - card = Some(card_mapping); - } - - let mut sepa_creditor = None; - if let Some(creditor_id) = &transaction.sepa_creditor_id { - if !creditor_id.is_empty() { - let sepa_creditor_mapping = config - .sepa - .creditors - .iter() - .find(|item| item.creditor_id == *creditor_id); - if let Some(sepa_creditor_mapping) = sepa_creditor_mapping { - sepa_creditor = Some(sepa_creditor_mapping); - } - } - } - - let mut sepa_mandate = None; - if let Some(mandate_id) = &transaction.sepa_mandate_id { - if !mandate_id.is_empty() { - let sepa_mandate_mapping = config - .sepa - .mandates - .iter() - .find(|item| item.mandate_id == *mandate_id); - if let Some(sepa_mandate_mapping) = sepa_mandate_mapping { - sepa_mandate = Some(sepa_mandate_mapping); - } - } - } - - let mut simple_mapping = None; - for rule in &config.mapping { - let regex = RegexBuilder::new(&rule.search) - .case_insensitive(true) - .build(); - match regex { - Ok(regex) => { - if let Some(partner_name) = &transaction.partner_name { - if !partner_name.is_empty() && regex.is_match(partner_name) { - simple_mapping = Some(rule); - break; - } - } - - if let Some(reference) = &transaction.reference { - if !reference.is_empty() && regex.is_match(reference) { - simple_mapping = Some(rule); - break; - } - } - } - Err(e) => return Err(ImportError::Regex(e.to_string())), - }; - } - - let mut creditor_debitor_mapping_account = None; - if let Some(partner_name) = &transaction.partner_name { - let mut search_amount: AmountAndCommodity = transaction.amount.clone().try_into()?; - search_amount.amount *= -1; - - for rule in &config.creditor_and_debitor_mapping { - if !partner_name.contains(&rule.payee) { - continue; - } - - let begin = match rule.days_difference { - Some(delta) => transaction - .booking_date()? - .checked_sub_days(Days::new(delta as u64)), - None => None, - }; - let end = match rule.days_difference { - Some(delta) => transaction - .booking_date()? - .checked_add_days(Days::new(delta as u64 + 1)), - None => None, - }; - - let hledger_transactions = query_hledger_by_payee_and_account( - &config.hledger, - &rule.payee, - &rule.account, - begin, - end, - )?; - let matching_cred_or_deb_trx = hledger_transactions.iter().any(|t| { - t.tpostings.iter().any(|p| { - p.paccount == rule.account - && p.pamount - .clone() - .into_iter() - .filter_map(|a| a.try_into().ok()) - .any(|a: AmountAndCommodity| a == search_amount) - }) - }); - - if matching_cred_or_deb_trx { - creditor_debitor_mapping_account = Some(rule.account.clone()); - } else { - creditor_debitor_mapping_account.clone_from(&rule.default_pl_account); - } - break; - } - } - - let posting_against_own_iban = match &transaction.partner_account { - Some(partner_account) => match &partner_account.iban { - Some(iban) => config - .ibans - .iter() - .any(|iban_mapping| iban_mapping.iban == *iban), - None => false, - }, - None => false, - }; - - Ok(Self { - iban, - card, - sepa_creditor, - sepa_mandate, - simple_mapping, - creditor_debitor_mapping_account, - posting_against_own_iban, - }) - } -} - #[cfg(test)] mod tests { use chrono::NaiveDate;