From 423bbd8eb4a8cb095d8d8b8f3bd8bd37d206d830 Mon Sep 17 00:00:00 2001 From: Evan Peterson <77evan@gmail.com> Date: Thu, 23 Jan 2025 19:13:28 -0500 Subject: [PATCH] initial messy commit --- .gitignore | 3 + Cargo.toml | 15 + notes.md | 526 ++++++++++++++++++++++++++ rustfmt.toml | 4 + src/core/account.rs | 63 +++ src/core/amounts.rs | 93 +++++ src/core/common.rs | 7 + src/core/errors.rs | 37 ++ src/core/ledger.rs | 178 +++++++++ src/core/mod.rs | 12 + src/core/transaction.rs | 227 +++++++++++ src/document/directives.rs | 83 ++++ src/document/ledger.rs | 210 ++++++++++ src/document/mod.rs | 86 +++++ src/document/parser/amounts.rs | 156 ++++++++ src/document/parser/base_directive.rs | 245 ++++++++++++ src/document/parser/directives.rs | 194 ++++++++++ src/document/parser/mod.rs | 46 +++ src/document/parser/transaction.rs | 365 ++++++++++++++++++ src/lib.rs | 119 ++++++ src/main.rs | 130 +++++++ src/output/amounts.rs | 19 + src/output/cli/balance.rs | 277 ++++++++++++++ src/output/cli/mod.rs | 4 + src/output/cli/tui_to_ansi.rs | 94 +++++ src/output/mod.rs | 2 + src/queries/balance.rs | 44 +++ src/queries/mod.rs | 3 + 28 files changed, 3242 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 notes.md create mode 100644 rustfmt.toml create mode 100644 src/core/account.rs create mode 100644 src/core/amounts.rs create mode 100644 src/core/common.rs create mode 100644 src/core/errors.rs create mode 100644 src/core/ledger.rs create mode 100644 src/core/mod.rs create mode 100644 src/core/transaction.rs create mode 100644 src/document/directives.rs create mode 100644 src/document/ledger.rs create mode 100644 src/document/mod.rs create mode 100644 src/document/parser/amounts.rs create mode 100644 src/document/parser/base_directive.rs create mode 100644 src/document/parser/directives.rs create mode 100644 src/document/parser/mod.rs create mode 100644 src/document/parser/transaction.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/output/amounts.rs create mode 100644 src/output/cli/balance.rs create mode 100644 src/output/cli/mod.rs create mode 100644 src/output/cli/tui_to_ansi.rs create mode 100644 src/output/mod.rs create mode 100644 src/queries/balance.rs create mode 100644 src/queries/mod.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e3380b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/Cargo.lock +/data/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9e2df5e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "accounting-rust" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +chrono = "0.4.39" +nom = "7.1.3" +nom_locate = "4.2.0" +rand = "0.8.5" +ratatui = "0.29.0" +rust_decimal = "1.36.0" +rust_decimal_macros = "1.36.0" diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..78222a3 --- /dev/null +++ b/notes.md @@ -0,0 +1,526 @@ +## Transactions + +``` +; Simplest Transaction +2000-01-01 * + Account1 200 USD + Account2 -200 USD + +; With Payee +2023-01-01 * Payee + Account1 200 USD + Account2 -200 USD + +; With Payee and Narration +2023-01-01 * Payee | Narration + Account1 200 USD + Account2 -200 USD + +; With Narration only +2023-01-01 * | Narration + Account1 200 USD + Account2 -200 USD + +; Incomplete Transaction +2000-01-01 ! + Account1 200 USD + Account2 -200 USD + +; Partial incomplete transaction +2000-01-01 * + Account1 200 USD + ! Account2 -200 USD +``` + +# Conversion/inventories + +``` +; TODO: how to define currency conversion does not have cost basis + +; Currency conversion per unit +2000-01-01 * + Account1 10 USD @ 1.2 EUR + Account2 -12 EUR + +; Currency conversion total cost +2000-01-01 * + Account1 10 USD @@ 12 EUR + Account2 -12 EUR + +; Inventory with cost basis per unit +2000-01-01 * + Account1 10 AAPL @ 100 USD + Account2 -1000 USD + +; Inventory with total cost basis +2000-01-01 * + Account1 10 AAPL @@ 1000 USD + Account2 -1000 USD + +; Inventory with explicit unit cost basis + other properties +2000-01-01 * + Account1 10 AAPL {100 USD, 2000-01-02, "lot 1"} @ 100 USD + Account2 -1000 USD + +; Inventory with explicit total cost basis? +2000-01-01 * + ; TODO better syntax? + Account1 10 AAPL {{1000 USD, 2000-01-02, "lot 1"}} @@ 1000 USD + Account2 -1000 USD + +; Inventory specifying cost basis and current price +2000-01-01 * + ; Current market value conversion == $1100 + Account1 10 AAPL {100 USD} @ 110 USD + Account2 -1000 USD + +; Sell with price known +2000-01-01 * + ; Assuming lot existed of 10 AAPL @ 100 USD + ; Current price needs to match up with non-Income accounts + ; (-10 * $200 + $2000) == 0 + Account1 -10 AAPL @ 200 USD + Account2 2000 USD + Income:Gains -1000 USD + +; Sell with gains known +2000-01-01 * + ; Assuming lot existed of 10 AAPL @ 100 USD + ; Compute current price of -10 * x + 2000 USD == 0 -> x = 200 USD? + Account1 -10 AAPL + Account2 2000 USD + Income:Gains -1000 USD + + + + +; Sell with specific lot price +2000-01-01 * + Account1 -10 AAPL {100 USD} + ... + +; Sell with auto lot strategy +2000-01-01 * + Account1 -10 AAPL {} + ... + +; Transaction metadata +2000-01-01 * + key1: "value" + key2: 100 + key3: 2000-01-01 + key4: Account1:abc + key5: 100 USD + ... + +; Posting metadata +2000-01-01 * + Account1 200 USD + key1: "value" + ... + +; Postings with different dates +2000-01-01 * + Account1 200 USD + Account2 -200 USD [2000-01-02] + +; ability to specify time? (ISO 8601-ish) +; potentially support time as metadata? +; arithmetic in amounts: `( ) * / - +` +; should currency/commodity be required? +; payee per line? +; what if inventory doesn't match up perfectly with rounding? +; e.g. 4 @ $1.001 = $4.004 and other account has $4.00 +; ability to specify using average cost basis +; for account? for commodity per account? +; when selling check +; fixed lot prices? {=100 USD} +; note - storing unit value is always more accurate, no need to store total cost? +; auto import transactions +; be able to approve transactions + +; calculate net worth relative to inflation +; some way to change value of a dollar? +; explicit handling of stock splits to prevent historical price discontinuity? + + +; ISO 8601 dates +YY ~ Century +YYY ~ +YYYY ~ Year +YYYY-MM ~ Month +YYYY-MM-DD ~ Day +YYYY-Www ~ Week +YYYY-Www-D ~ Day of week +YYYY-DDD ~ Ordinal day? + +; how to represent intervals for projections? +xxxx ~ Every year +xxxx-MM-DD ~ Every year on MM-DD +xxxx-xx-xx ~ Every day +YYYY-xx-xx ~ Every day in YYYY +xxxx-Wxx ~ Every week +xxxx-Wxx-1 ~ Every monday +xxxx-xx ~ Every month +??? ~ Every quarter +??? ~ Every half + +R/P1Y ~ Every year? +R/YYYY/P1Y ~ Every year starting with YYYY +R/YYYY-MM-DD/P1Y ~ Every year on MM-DD starting with YYYY +; TODO: continue this + +R/YYYY-MM-DD/P1Y +R/YYYY-MM-DD/P1D +R/YYYY-MM-DD/P1D/YYYY-MM-DD +$.04 +; Ability to specify the weekday prior? + +; Buying a house that cost $500,000 with 20% down payment and 5% interest rate for 30 year +2000-01-01 * Bank | Take out loan + Assets -$100,000 + Equity:House 1 house @@ $500,000 + Liabilities:Mortgage -$400,000 + +; Amortization schedule: +; = P * ((1+r)^n)/((1+r)^n - 1) +; P = principal loan amounts +; r = monthly interest rate +; n = number of payments over loan lifetime + +; P = $400,000 +; r = 5%/12 = 0.0041666... +; n = 12*30 = 360 +; +; Monthly payment = $2147.29 + +2000-01-01 * Bank | Mortgage + Assets -$2147.29 + Expenses:Interest:Mortgage (Liabilities:Mortgage * 0.05/12) + Liabilities:Mortgage $2147.29 - (Liabilities:Mortgage * 0.05/12) + +2000-01-01 * Bank | Mortgage + Assets -$2147.29 + Expenses:Interest:Mortgage $1666.67 + Liabilities:Mortgage $480.62 + + +; Automated monthly forecast for mortgage payment +~ R360/2000-01-01/P1M * Bank + Assets -$2147.29 + Expenses:Interest:Mortgage (Liabilities:Mortgage * 0.05/12) + Liabilities:Mortgage + +; Automated interest from Fidelity - 5% per year +~ R/2000-01-01/P1M * Bank + Assets:Bank (Assets:Bank-$ * (1.05^(1/12)-1)) + Income:Interest + +; Automated prices??? +; TODO +~ 2000-01-01/P1Y price AAPL *= 1.05 +``` + +``` +forecast Bank | Mortgage + - start: 2000-01-01 + - repeat: 1 Month + - stop: 2001-01-01 + Assets -$2147.29 + Expenses:Interest:Mortgage (Liabilities:Mortgage * 0.05/12) + Liabilities:Mortgage $2147.29 - (Liabilities:Mortgage * 0.05/12) +``` + +* Assets + * Cash + * BofA + * Checking + * Savings + * Coinbase + * ESPP + * ETrade + * Brokerage + * RothIRA + * Fidelity + * 401k + * Match + * Roth + * Trad + * Brokerage + * Cash Management + * House +* Liabilities + * Credit + * BofA + * Chase + * Mortgage +* Expenses + * Taxes + * Federal + * Return + * Medicare + * SocialSecurity + * State + * California + * SDI + * Georgia + * Return +* Income +* Equity + * Opening Balance + * (CurrencyConversion) + * (Trading) + * (Transfer) + +``` +2000-01-01 * + Account1 10 AAPL @ 100 USD + Account2 -1000 USD + +2001-01-01 price AAPL 150 USD + +2002-01-01 * + Account1 -10 AAPL @ 200 USD + Account2 2000 USD + Income:Gains -1000 USD + +``` +* First: + * Account1 ~ 10 AAPL + * Account2 ~ -1000 USD +* Second: + * Account1 ~ 0 + * Account2 ~ 1000 USD + * Income:Gains ~ -1000 USD + +alternate: +* First: + * Account1 ~ 10 AAPL @ 100 USD + * Account2 ~ -1000 USD +* Second: + * Account1 ~ 0 + * Account2 ~ 1000 USD + * Income:Gains ~ -1000 USD + +with market value: +* First: + * Account1 ~ 1000 USD + * Account2 ~ -1000 USD +* (price): + * Account1 ~ 1500 USD + * Account2 ~ -1000 USD + * Equity:Unrealized Capital ~ -500 USD +* Second: + * Account1 ~ 0 + * Account2 ~ 1000 USD + * Income:Gains ~ -1000 USD + * Equity:Unrealized Capital ~ 0 + +``` +2000-01-01 * + Account1 10 AAPL @ 100 USD + Account2 -1000 USD + Equity:Trading -10 AAPL + Equity:Trading 1000 USD + +2001-01-01 price AAPL 150 USD + +2002-01-01 * + Account1 -10 AAPL @ 200 USD + Account2 2000 USD + Income:Gains -1000 USD + Equity:Trading 10 AAPL + Equity:Trading -1000 USD +``` + +market equivalent: +``` +2000-01-01 * + Account1 1000 USD + Account2 -1000 USD + Equity:Trading 0 +2001-01-01 * + Account1 500 USD + Equity:Trading -500 USD +2001-01-01 * + Account1 -2000 USD + Account2 2000 USD + Income:Gains -1000 USD + Equity:Trading 1000 USD +``` + + +* First: + * Account1 ~ 10 AAPL + * Account2 ~ -1000 USD + * Equity:Trading ~ 1000 USD - 10 AAPL +* Second: + * Account1 ~ 0 + * Account2 ~ 1000 USD + * Income:Gains ~ -1000 USD + * Equity:Trading ~ -1000 + +``` +2000-01-01 * + Account1: 12 EUR @@ 10 USD + Account2: -10 USD + Equity:CurrencyConversion -12 EUR + Equity:CurrencyConversion 10 USD + +2002-01-01 * + Account1: -10 EUR @@ 10 USD + Account2: 10 USD + Equity:CurrencyConversion -10 USD + Equity:CurrencyConversion 10 EUR +``` +* First + * Account1 ~ 12 EUR + * Account2 ~ -10 USD + * Equity:CurrencyConversion ~ 10 USD - 12 EUR +* Second + * Account1 ~ 2 EUR + * Account2 ~ 0 + * Equity:CurrencyConversion ~ -2 EUR + +``` +commodity USD + currency: "US Dollar" + code: "USD" + number: 840 + decimals: 2 + conversion-account: Equity:CurrencyConversion + aliases: ["$", "US$"] +``` + +``` +; Inventory specifying cost basis and current price +2000-01-01 * + ; Current market value conversion == $1100 + Assets1 10 AAPL {100 USD} @ 110 USD + Assets2 -1000 USD + +2000-02-01 * + Assets1 -10 AAPL {100 USD} @ 120 USD + Assets2 1200 USD + Income:CapitalGains -200 +``` + +Equivalent: +``` +2000-01-01 * + Assets1 10 AAPL {100 USD} @ 110 USD + Assets2 -1000 USD + Equity:Trading -10 AAPL + Equity:Trading 1000 USD + +2000-02-01 * + Assets1 -10 AAPL {100 USD} @ 120 USD + Assets2 1200 USD + Income:CapitalGains -200 USD + Equity:Trading 10 AAPL + Equity:Trading -1000 USD +``` + +Written transaction: +``` +2000-01-01 * + Account1: 12 EUR @@ 10 USD + Account2: -10 USD + +(account1 = 12 EUR, account2 = -10 USD) + +2002-01-01 * + Account1: -10 EUR @@ 10 USD + Account2: 10 USD + +(account1 = 2 EUR, account2 = 0 USD) +``` + + +Leave it in equity: +``` +2000-01-01 * + Account1: 12 EUR @ 1.2 USD + Account2: -10 USD + Equity:CurrencyConversion -12 EUR + Equity:CurrencyConversion 10 USD + +(account1 = 12 EUR, account2 = -10 USD, equity = 10 USD - 12 EUR) + + +2002-01-01 * + Account1: -10 EUR @ 1 USD + Account2: 10 USD + Equity:CurrencyConversion -10 USD + Equity:CurrencyConversion 10 EUR + +(account1 = 2 EUR, account2 = 0 USD, equity = 2 EUR) +``` + +``` +2000-01-01 * + Account1: 12 EUR @ 1.2 USD + Account2: -10 USD + Equity:CurrencyConversion -12 EUR + Equity:CurrencyConversion 10 USD + +(account1 = 12 EUR, account2 = -10 USD, equity = 10 USD - 12 EUR) + +2002-01-01 * + Account1: -10 EUR {1.2 USD} @ 1 USD + Account2: 10 USD + Income:CurrencyConversion -1.67 USD + Equity:CurrencyConversion -8.33 USD + Equity:CurrencyConversion 10 EUR + +(account1 = 2 EUR, account2 = 0 USD, income = -1.67 USD, equity = 1.67 USD - 2 EUR) +``` + + +Triple currency: +Written transaction: +``` +2000-01-01 * + Account2: 12 EUR @ 1.2 USD + Account1: -10 USD + +(account1 = -10 USD, account2 = 12 EUR, account2 = -10 USD) + +2002-01-01 * + Account3: 11 GBP + Account2: -10 EUR @ 1.1 GBP + +(account1 = -10 USD, account2 = 2 EUR account3 = 11 GBP) + +2002-01-01 * + Account1: 10 USD + Account3: -10 GBP @ 1 USD + +(account1 = 0 USD, account2 = 2 EUR account3 = 1 GBP) +``` + +``` +2000-01-01 * + Account2: 12 EUR @ 1.2 USD + Account1: -10 USD + Equity:CurrencyConversion -12 EUR {1.2 USD} + Equity:CurrencyConversion 10 USD + +(account1 = -10 USD, account2 = 12 EUR, account2 = -10 USD, equity = 10 USD - 12 EUR) + +2002-01-01 * + Account3: 11 GBP + Account2: -10 EUR @ 1.1 GBP + Income:CurrencyConversion 10 EUR {now} - 10 EUR {1.2 USD} + Income:CurrencyConversion -10 EUR {1.2 USD} + Equity:CurrencyConversion -11 GBP + Equity:CurrencyConversion 10 EUR {1.2 USD} + +(account1 = -10 USD, account2 = 2 EUR account3 = 11 GBP, equity = 10 USD - 2 EUR - 11 GBP, income = 10 EUR {now} - 10 EUR {1.2 USD}) + +2002-01-01 * + Account1: 10 USD + Account3: -10 GBP @ 1 USD + ... +(account1 = 0 USD, account2 = 2 EUR account3 = 1 GBP) +``` \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f240b6a --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,4 @@ +# [format] +# max_width = 120 +struct_lit_width = 50 +# use_small_heuristics = "Max" \ No newline at end of file diff --git a/src/core/account.rs b/src/core/account.rs new file mode 100644 index 0000000..3aee93e --- /dev/null +++ b/src/core/account.rs @@ -0,0 +1,63 @@ +use chrono::NaiveDate; + +use super::{common::generate_id, CoreError}; + +// const ACCOUNT_SEPERATOR: char = ':'; + +#[derive(Debug)] +pub struct Account { + id: u32, + name: String, + + open_date: Option, + close_date: Option, +} + +impl Account { + pub fn new(name: String, open_date: Option) -> Result { + let id = generate_id(); + return Ok(Account { id, name, open_date, close_date: None }); + } + + pub fn get_id(&self) -> u32 { + self.id + } + + pub fn get_name(&self) -> &String { + &self.name + } + + pub fn get_open_date(&self) -> Option { + self.open_date + } + + pub fn get_close_date(&self) -> Option { + self.close_date + } + + pub fn split_name_groups(&self) -> Vec<&str> { + self.name.split(":").collect() + } + + pub fn is_under_account(&self, account_name: &str) -> bool { + self.name + .strip_prefix(account_name) + .map(|n| n.is_empty() || n.starts_with(":")) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_is_under_account() { + let subaccount = Account::new("Account1:Sub".into(), None).unwrap(); + + assert_eq!(subaccount.is_under_account("Account1"), true); + assert_eq!(subaccount.is_under_account("Account1:Sub"), true); + assert_eq!(subaccount.is_under_account("Account"), false); + assert_eq!(subaccount.is_under_account("Account1:S"), false); + } +} diff --git a/src/core/amounts.rs b/src/core/amounts.rs new file mode 100644 index 0000000..124e3bf --- /dev/null +++ b/src/core/amounts.rs @@ -0,0 +1,93 @@ +use std::collections::HashMap; + +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +use super::{common::generate_id, CoreError}; + +#[derive(Debug, PartialEq, Clone, Copy)] +pub struct Amount { + pub value: Decimal, + pub unit_id: u32, +} + +// #[derive(Debug, PartialEq, Clone, Copy)] +// pub struct Cost { +// pub value: Decimal, +// pub unit_id: u32, +// pub date: NativeDate, +// pub label: Option, +// } + +#[derive(Debug, Clone)] +pub struct UnitSymbol { + pub symbol: String, + pub is_prefix: bool, +} + +#[derive(Debug)] +pub struct Unit { + id: u32, + symbols: Vec, + scale: Option, +} + +impl Amount { + pub fn at_opt_price(&self, price: Option) -> Amount { + if let Some(p) = price { + Amount { + value: self.value * p.value, + unit_id: p.unit_id, + } + } else { + *self + } + } +} + +impl Unit { + pub fn new(symbol: UnitSymbol) -> Result { + Self::new_multi_symbol(vec![symbol], None) + } + + pub fn new_multi_symbol( + symbols: Vec, + scale: Option, + ) -> Result { + if symbols.len() == 0 { + return Err("Unit must have at least one symbol".into()); + } + let id = generate_id(); + Ok(Unit { id, symbols, scale }) + } + + pub fn get_id(&self) -> u32 { + self.id + } + + pub fn get_scale(&self) -> Option { + self.scale + } + + pub fn get_symbols(&self) -> &Vec { + &self.symbols + } + + pub fn matches_symbol(&self, symbol: &str) -> bool { + self.symbols.iter().any(|s| s.symbol == symbol) + } + + pub fn default_symbol(&self) -> &UnitSymbol { + &self.symbols[0] + } +} + +pub fn combine_amounts(amounts: impl Iterator) -> Vec { + let mut output_amounts = HashMap::::new(); + + for amount in amounts { + *output_amounts.entry(amount.unit_id).or_insert(dec!(0)) += amount.value; + } + + output_amounts.iter().map(|(&unit_id, &value)| Amount {value, unit_id}).collect() +} \ No newline at end of file diff --git a/src/core/common.rs b/src/core/common.rs new file mode 100644 index 0000000..236d738 --- /dev/null +++ b/src/core/common.rs @@ -0,0 +1,7 @@ +use std::sync::atomic; + +pub fn generate_id() -> u32 { + static COUNTER: atomic::AtomicU32 = atomic::AtomicU32::new(1); + + COUNTER.fetch_add(1, atomic::Ordering::Relaxed) +} diff --git a/src/core/errors.rs b/src/core/errors.rs new file mode 100644 index 0000000..7b4a23a --- /dev/null +++ b/src/core/errors.rs @@ -0,0 +1,37 @@ +use core::fmt; + +pub struct CoreError { + text: StringData, +} + +enum StringData { + Static(&'static str), + Dynamic(String), +} + +impl From<&'static str> for CoreError { + fn from(s: &'static str) -> Self { + CoreError { text: StringData::Static(s) } + } +} + +impl From for CoreError { + fn from(s: String) -> Self { + CoreError { text: StringData::Dynamic(s) } + } +} + +impl fmt::Display for CoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.text { + StringData::Static(s) => f.write_str(s), + StringData::Dynamic(s) => f.write_str(&s), + } + } +} + +impl fmt::Debug for CoreError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} diff --git a/src/core/ledger.rs b/src/core/ledger.rs new file mode 100644 index 0000000..aa5b1b2 --- /dev/null +++ b/src/core/ledger.rs @@ -0,0 +1,178 @@ +use rust_decimal_macros::dec; + +use super::{Account, Amount, CoreError, Transaction, Unit}; + +#[derive(Debug)] +pub struct Ledger { + accounts: Vec, + units: Vec, + transactions: Vec, +} + +impl Ledger { + pub fn new() -> Ledger { + Ledger { + accounts: Vec::new(), + units: Vec::new(), + transactions: Vec::new(), + } + } + + pub fn get_accounts(&self) -> &Vec { + &self.accounts + } + + pub fn get_account(&self, id: u32) -> Option<&Account> { + self.accounts.iter().find(|account| account.get_id() == id) + } + + pub fn get_account_by_name(&self, name: &str) -> Option<&Account> { + self.accounts.iter().find(|account| account.get_name() == name) + } + + pub fn get_units(&self) -> &Vec { + &self.units + } + + pub fn get_unit(&self, id: u32) -> Option<&Unit> { + self.units.iter().find(|unit| unit.get_id() == id) + } + + pub fn get_unit_by_symbol(&self, unit_symbol: &str) -> Option<&Unit> { + self.units.iter().find(|unit| unit.matches_symbol(unit_symbol)) + } + + pub fn get_transactions(&self) -> &Vec { + &self.transactions + } + + pub fn round_amount(&self, amount: &Amount) -> Amount { + let mut new_amount = *amount; + let unit = self.get_unit(amount.unit_id); + + if let Some(scale) = unit.and_then(|u| u.get_scale()) { + new_amount.value = new_amount.value.round_dp(scale); + } else { + new_amount.value = new_amount.value.normalize() + } + + new_amount + } + + pub fn round_amounts(&self, amounts: &[Amount]) -> Vec { + amounts.iter().map(|a| self.round_amount(a)).filter(|a| a.value != dec!(0)).collect() + } + + pub fn add_account(&mut self, account: Account) -> Result<(), CoreError> { + if self + .accounts + .iter() + .any(|existing_account| existing_account.get_name() == account.get_name()) + { + return Err("Account with the same name already exists".into()); + } + + self.accounts.push(account); + + Ok(()) + } + + pub fn add_unit(&mut self, unit: Unit) -> Result<(), CoreError> { + if self.units.iter().any(|existing_unit| { + unit.get_symbols() + .iter() + .any(|new_symbol| existing_unit.matches_symbol(&new_symbol.symbol)) + }) { + return Err("Unit with the same symbol already exists".into()); + } + + self.units.push(unit); + + Ok(()) + } + + pub fn add_transaction(&mut self, transaction: Transaction) -> Result<(), CoreError> { + let balances = transaction.remaining_balance(true); + let balances = self.round_amounts(&balances); + + if balances.len() != 0 { + println!("{:?}", transaction); + println!("{:?}", balances); + return Err("Transaction is not balanced".into()); + } + self.transactions.push(transaction); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::core::UnitSymbol; + + use super::*; + + #[test] + fn add_unit_same_symbol() { + let mut ledger = Ledger::new(); + let unit1 = Unit::new_multi_symbol( + vec![UnitSymbol { symbol: "USD".to_string(), is_prefix: false }], + None, + ) + .unwrap(); + assert_eq!(ledger.add_unit(unit1).is_ok(), true); + + let unit2 = Unit::new_multi_symbol( + vec![UnitSymbol { symbol: "USD".to_string(), is_prefix: false }], + None, + ) + .unwrap(); + assert_eq!(ledger.add_unit(unit2).is_err(), true); + } + + #[test] + fn add_unit_partial_overlap() { + let mut ledger = Ledger::new(); + let unit1 = Unit::new_multi_symbol( + vec![ + UnitSymbol { symbol: "USD".to_string(), is_prefix: false }, + UnitSymbol { symbol: "EUR".to_string(), is_prefix: false }, + UnitSymbol { symbol: "JPY".to_string(), is_prefix: false }, + ], + None, + ) + .unwrap(); + assert_eq!(ledger.add_unit(unit1).is_ok(), true); + + let unit2 = Unit::new_multi_symbol( + vec![UnitSymbol { symbol: "JPY".to_string(), is_prefix: false }], + None, + ) + .unwrap(); + assert_eq!(ledger.add_unit(unit2).is_err(), true); + } + + #[test] + fn add_unit_no_overlap() { + let mut ledger = Ledger::new(); + let unit1 = Unit::new_multi_symbol( + vec![ + UnitSymbol { symbol: "USD".to_string(), is_prefix: false }, + UnitSymbol { symbol: "EUR".to_string(), is_prefix: false }, + ], + None, + ) + .unwrap(); + assert_eq!(ledger.add_unit(unit1).is_ok(), true); + + let unit2 = Unit::new_multi_symbol( + vec![ + UnitSymbol { symbol: "GBP".to_string(), is_prefix: false }, + UnitSymbol { symbol: "AUD".to_string(), is_prefix: false }, + ], + None, + ) + .unwrap(); + assert_eq!(ledger.add_unit(unit2).is_ok(), true); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..8e5e359 --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,12 @@ +mod account; +mod amounts; +mod errors; +mod ledger; +mod transaction; +mod common; + +pub use account::*; +pub use amounts::*; +pub use errors::*; +pub use ledger::*; +pub use transaction::*; diff --git a/src/core/transaction.rs b/src/core/transaction.rs new file mode 100644 index 0000000..0202933 --- /dev/null +++ b/src/core/transaction.rs @@ -0,0 +1,227 @@ +use chrono::NaiveDate; +use rust_decimal_macros::dec; +use std::collections::HashMap; + +use super::Amount; +use crate::core::CoreError; + +//////////////// +// Directives // +//////////////// + +#[derive(Debug)] +pub struct Transaction { + date: NaiveDate, + flag: TransactionFlag, + payee: Option, + narration: Option, + postings: Vec, +} + +/////////////// +// Sub types // +/////////////// + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TransactionFlag { + Complete, + Incomplete, +} + +#[derive(Debug, PartialEq)] +pub struct Posting { + account_id: u32, + amount: Amount, + cost: Option, + price: Option, +} + +///////////////////// +// Implementations // +///////////////////// + +impl Transaction { + pub fn new( + date: NaiveDate, + flag: TransactionFlag, + mut payee: Option, + mut narration: Option, + postings: Vec, + ) -> Result { + if let Some(p) = &payee { + if p.trim().is_empty() { + payee = None + } + } + if let Some(n) = &narration { + if n.trim().is_empty() { + narration = None + } + } + if postings.len() == 0 { + return Err("Transaction has no postings".into()); + } + + Ok(Transaction { date, flag, payee, narration, postings }) + } + + pub fn get_date(&self) -> NaiveDate { + self.date + } + + pub fn get_flag(&self) -> TransactionFlag { + self.flag + } + + pub fn get_payee(&self) -> &Option { + &self.payee + } + + pub fn get_narration(&self) -> &Option { + &self.narration + } + + pub fn get_postings(&self) -> &Vec { + &self.postings + } + + pub fn remaining_balance(&self, by_cost: bool) -> Vec { + let mut amounts = HashMap::new(); + for posting in &self.postings { + let cost = if by_cost { + posting.cost.or(posting.price) + } else { + None + }; + let amount = posting.amount.at_opt_price(cost); + *amounts.entry(amount.unit_id).or_insert(dec!(0)) += amount.value; + } + amounts + .iter() + .filter(|(_, &value)| value != dec!(0)) + .map(|(&unit_id, &value)| Amount { value, unit_id }) + .collect() + } + + // pub fn is_balanced(&self, by_cost: bool) -> bool { + // self.remaining_balance(by_cost).len() == 0 + // } +} + +const TRANSACTION_FLAGS: &[(TransactionFlag, char)] = &[ + (TransactionFlag::Complete, '*'), + (TransactionFlag::Incomplete, '!'), +]; + +impl TryFrom for TransactionFlag { + type Error = CoreError; + fn try_from(c: char) -> Result { + TRANSACTION_FLAGS + .iter() + .find(|(_, flag_c)| c == *flag_c) + .map(|(flag, _)| flag.clone()) + .ok_or(CoreError::from("Invalid flag character")) + } +} + +impl TryFrom> for TransactionFlag { + type Error = CoreError; + fn try_from(c: Option) -> Result { + c.map(|c| TransactionFlag::try_from(c)) + .unwrap_or(Ok(TransactionFlag::Complete)) + } +} + +impl From for char { + fn from(flag: TransactionFlag) -> Self { + TRANSACTION_FLAGS + .iter() + .find(|(f, _)| *f == flag) + .map(|(_, flag_c)| flag_c) + .unwrap() + .clone() + } +} + +impl Posting { + pub fn new( + account_id: u32, + amount: Amount, + cost: Option, + price: Option, + ) -> Result { + if cost.is_some() && cost.unwrap().value.is_sign_negative() { + return Err("Cost cannot be negative".into()); + } + if price.is_some() && price.unwrap().value.is_sign_negative() { + return Err("Price cannot be negative".into()); + } + Ok(Posting { account_id, amount, cost, price }) + } + + pub fn get_account_id(&self) -> u32 { + self.account_id + } + + pub fn get_amount(&self) -> &Amount { + &self.amount + } + + pub fn get_cost(&self) -> &Option { + &self.cost + } + + pub fn get_price(&self) -> &Option { + &self.price + } +} + +///////////// +// Tests // +///////////// + +// #[cfg(test)] +// mod tests { +// use super::*; + +// #[test] +// fn balanced_transaction_simple() { +// let txn = Transaction::new( +// NaiveDate::from_ymd_opt(2000, 01, 01).unwrap(), +// TransactionFlag::Complete, +// None, +// None, +// vec![ +// Posting::new(0, Amount { value: dec!(10), unit_id: 10 }, None, None).unwrap(), +// Posting::new(0, Amount { value: dec!(-10), unit_id: 10 }, None, None).unwrap(), +// ], +// ) +// .unwrap(); + +// assert_eq!(txn.is_balanced(true), true); +// } + +// #[test] +// fn balanced_transaction_cost() { +// let txn = Transaction::new( +// NaiveDate::from_ymd_opt(2000, 01, 01).unwrap(), +// TransactionFlag::Complete, +// None, +// None, +// vec![ +// Posting::new(0, Amount { value: dec!(10), unit_id: 10 }, None, None).unwrap(), +// Posting::new( +// 0, +// Amount { value: dec!(-2), unit_id: 11 }, +// Some(Amount { value: dec!(5), unit_id: 10 }), +// None, +// ) +// .unwrap(), +// ], +// ) +// .unwrap(); + +// assert_eq!(txn.is_balanced(true), true); +// assert_eq!(txn.is_balanced(false), false); +// } +// } diff --git a/src/document/directives.rs b/src/document/directives.rs new file mode 100644 index 0000000..36ee9e0 --- /dev/null +++ b/src/document/directives.rs @@ -0,0 +1,83 @@ +use std::path::PathBuf; + +use chrono::NaiveDate; +use rust_decimal::Decimal; + +use crate::core::UnitSymbol; + +#[derive(Debug)] +pub struct Directives { + pub includes: Vec, + pub commodities: Vec, + pub transactions: Vec, + pub balances: Vec, +} + +//////////////// +// Directives // +//////////////// + +#[derive(Debug, Clone)] +pub struct IncludeDirective { + pub path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct CommodityDirective { + pub date: Option, + pub name: Option, + pub symbols: Vec, + pub precision: Option, +} + +#[derive(Debug, Clone)] +pub struct TransactionDirective { + pub date: NaiveDate, + pub flag: Option, + pub payee: Option, + pub narration: Option, + pub postings: Vec, +} + +#[derive(Debug, Clone)] +pub struct BalanceDirective { + pub date: NaiveDate, + pub account: String, + pub amounts: Vec, +} + +/////////////// +// Sub-types // +/////////////// + +#[derive(Debug, PartialEq, Clone)] +pub struct DirectivePosting { + pub account: String, + pub amount: Option, + pub cost: Option, + pub price: Option, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct DirectiveAmount { + pub value: Decimal, + pub unit_symbol: String, + pub is_unit_prefix: bool, +} + +///////////////////// +// Implementations // +///////////////////// + +impl Directives { + pub fn new() -> Self { + Directives{includes: Vec::new(), commodities: Vec::new(), transactions: Vec::new(), balances: Vec::new()} + } + + pub fn add_directives(&mut self, other: &Directives) { + self.includes.extend(other.includes.clone()); + self.transactions.extend(other.transactions.clone()); + self.balances.extend(other.balances.clone()); + self.commodities.extend(other.commodities.clone()); + } +} \ No newline at end of file diff --git a/src/document/ledger.rs b/src/document/ledger.rs new file mode 100644 index 0000000..f654689 --- /dev/null +++ b/src/document/ledger.rs @@ -0,0 +1,210 @@ +use std::{cmp::max, collections::HashMap, hash::Hash, time::Instant}; + +use rust_decimal_macros::dec; + +use crate::{ + core::{ + Account, Amount, CoreError, Ledger, Posting, Transaction, TransactionFlag, Unit, UnitSymbol, + }, + queries::{self, Query}, +}; + +use super::{BalanceDirective, DirectiveAmount, TransactionDirective}; + +pub fn add_transaction( + ledger: &mut Ledger, + transaction: &TransactionDirective, +) -> Result<(), CoreError> { + let flag = TransactionFlag::try_from(transaction.flag)?; + + let mut incomplete_postings = Vec::with_capacity(transaction.postings.len()); + for p in &transaction.postings { + let account = ledger.get_account_by_name(&p.account); + + // TODO: don't create account + let account_id = if let Some(a) = account { + a.get_id() + } else { + let new_account = Account::new((&p.account).into(), None)?; + let new_account_id = new_account.get_id(); + ledger.add_account(new_account)?; + new_account_id + }; + + let amount = if let Some(a) = &p.amount { + Some(create_amount(ledger, a)?) + } else { + None + }; + let cost = if let Some(c) = &p.cost { + Some(create_amount(ledger, c)?) + } else { + None + }; + let price = if let Some(p) = &p.price { + Some(create_amount(ledger, p)?) + } else { + None + }; + + incomplete_postings.push(IncompletePosting { account_id, amount, cost, price }); + } + + let postings = complete_incomplete_postings(&ledger, &incomplete_postings)?; + + ledger.add_transaction(Transaction::new( + transaction.date, + flag, + transaction.payee.as_ref().map(|p| p.into()), + transaction.narration.as_ref().map(|n| n.into()), + postings, + )?)?; + + Ok(()) +} + +pub fn check_balance(ledger: &Ledger, balance: &BalanceDirective) -> Result<(), CoreError> { + let accounts = queries::balance(&ledger, &[Query::EndDate(balance.date)]); + + let accounts = accounts.iter().filter(|(&account_id, val)| { + let account = ledger.get_account(account_id).unwrap(); + account.is_under_account(&balance.account) + }); + + if accounts.clone().count() == 0 { + + } + + let mut total_amounts = HashMap::new(); + let mut account_count = 0; + + for (_, amounts) in accounts { + account_count += 1; + for amount in amounts { + *total_amounts.entry(amount.unit_id).or_insert(dec!(0)) += amount.value; + } + } + + if account_count == 0 { + return Err("No accounts match balance account".into()); + } + + // let balance_account = ledger + // .get_account_by_name(&balance.account) + // .ok_or("Account not found")?; + + // let amounts = accounts + // .get(&balance_account.get_id()) + // .map(|v| v.as_slice()) + // .unwrap_or(&[]); + + // if amounts.len() > balance.amounts.len() { + // return Err("".into()); + // } else if amounts.len() < balance.amounts.len() { + // return Err("".into()); + // } + + for balance_amount in &balance.amounts { + let unit = ledger + .get_unit_by_symbol(&balance_amount.unit_symbol) + .ok_or("Unit not found")?; + let value = total_amounts.get(&unit.get_id()).map(|v| *v).unwrap_or(dec!(0)); + + // let value = amounts + // .iter() + // .find(|a| a.unit_id == unit.get_id()) + // .map(|a| a.value) + // .unwrap_or(dec!(0)); + let max_scale = max(value.scale(), balance_amount.value.scale()); + + let value = value.round_dp(max_scale); + let balance_value = balance_amount.value.round_dp(max_scale); + + if value != balance_value { + return Err(format!( + "Balance amount for \"{}\" on {} does not match. Expected {} but got {}", + balance.account, balance.date, balance_value, value + ) + .into()); + } + } + + Ok(()) +} + +struct IncompletePosting { + account_id: u32, + amount: Option, + cost: Option, + price: Option, +} + +fn create_amount(ledger: &mut Ledger, amount: &DirectiveAmount) -> Result { + let unit_id = get_or_create_unit(ledger, &amount.unit_symbol, amount.is_unit_prefix)?; + + Ok(Amount { value: amount.value, unit_id }) +} + +fn get_or_create_unit( + ledger: &mut Ledger, + unit_symbol: &str, + is_prefix: bool, +) -> Result { + let unit_id = if let Some(u) = ledger.get_unit_by_symbol(unit_symbol) { + u.get_id() + } else { + let new_unit = Unit::new(UnitSymbol { symbol: unit_symbol.into(), is_prefix })?; + let new_unit_id = new_unit.get_id(); + ledger.add_unit(new_unit)?; + new_unit_id + }; + + Ok(unit_id) +} + +fn complete_incomplete_postings( + ledger: &Ledger, + postings: &Vec, +) -> Result, CoreError> { + let mut incomplete_postings_iter = postings.iter().filter(|p| p.amount.is_none()); + let incomplete_posting_account_id = incomplete_postings_iter.next().map(|p| p.account_id); + + if incomplete_postings_iter.count() > 0 { + return Err("Multiple incomplete posting amounts. Unable to infer value".into()); + } + + let mut amounts = HashMap::new(); + let mut complete_postings = Vec::with_capacity(postings.len()); + for posting in postings { + if let Some(amount) = posting.amount { + complete_postings.push(Posting::new( + posting.account_id, + amount, + posting.cost, + posting.price, + )?); + + let cost = posting.cost.or(posting.price); + let converted_amount = amount.at_opt_price(cost); + let converted_amount = ledger.round_amount(&converted_amount); + *amounts.entry(converted_amount.unit_id).or_insert(dec!(0)) += converted_amount.value; + } + } + + if let Some(account_id) = incomplete_posting_account_id { + for (&unit_id, &value) in amounts.iter().filter(|(_, &v)| v != dec!(0)) { + let mut value = -value; + if let Some(scale) = ledger.get_unit(unit_id).and_then(|u| u.get_scale()) { + value = value.round_dp(scale); + } + complete_postings.push(Posting::new( + account_id, + Amount { unit_id, value }, + None, + None, + )?); + } + } + + Ok(complete_postings) +} diff --git a/src/document/mod.rs b/src/document/mod.rs new file mode 100644 index 0000000..779b55d --- /dev/null +++ b/src/document/mod.rs @@ -0,0 +1,86 @@ +mod directives; +mod parser; +mod ledger; + +pub use directives::*; +use ledger::{add_transaction, check_balance}; +use parser::parse_directives; + +use crate::core::{CoreError, Ledger, Unit}; +use std::path::Path; + +#[derive(Debug)] +pub struct Document { + directives: Directives, +} + +#[derive(Debug)] +struct SingleDocument { + file_data: String, + directives: Directives, +} + +impl Document { + pub fn new(file_path: &Path) -> Result { + let mut document = Document { directives: Directives::new() }; + document.add_directives_recursive(file_path)?; + + Ok(document) + } + + pub fn generate_ledger(&self) -> Result { + let mut ledger = Ledger::new(); + + for commodity in &self.directives.commodities { + let unit = Unit::new_multi_symbol(commodity.symbols.clone(), commodity.precision)?; + ledger.add_unit(unit)?; + } + + for transaction in &self.directives.transactions { + add_transaction(&mut ledger, transaction)?; + } + + for balance in &self.directives.balances { + check_balance(&ledger, &balance)?; + } + + Ok(ledger) + // for balance in self.directives.balances { + // ledger.add + // } + } + + fn add_directives_recursive(&mut self, file_path: &Path) -> Result<(), CoreError> { + let document = SingleDocument::new(file_path)?; + + let includes: Vec<&IncludeDirective> = document + .directives + .includes + .iter() + .filter(|i1| !self.directives.includes.iter().any(|i2| i2.path == i1.path)) + .collect(); + self.directives.add_directives(&document.directives); + for include in includes { + let path = if include.path.is_relative() { + file_path + .parent() + .ok_or(CoreError::from("Unable to get parent path"))? + .join(&include.path) + } else { + include.path.clone() + }; + self.add_directives_recursive(&path)?; + } + + Ok(()) + } +} + +impl SingleDocument { + fn new(file_path: &Path) -> Result { + let file_data = std::fs::read_to_string(file_path) + .map_err(|e| format!("Failed to read file: {} - {}", file_path.display(), e))?; + let directives = parse_directives(&file_data)?; + Ok(SingleDocument { file_data, directives }) + } +} diff --git a/src/document/parser/amounts.rs b/src/document/parser/amounts.rs new file mode 100644 index 0000000..d3030b3 --- /dev/null +++ b/src/document/parser/amounts.rs @@ -0,0 +1,156 @@ +use nom::{ + branch::alt, + character::complete::{char, none_of, one_of, space0}, + combinator::{opt, recognize}, + error::{Error, ErrorKind}, + multi::{many0, many1}, + sequence::{preceded, terminated, tuple}, + Err, IResult, InputTakeAtPosition, Parser, +}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +use crate::document::DirectiveAmount; + +pub fn account(input: &str) -> IResult<&str, &str> { + input.split_at_position1_complete(|item| item == ' ' || item == '\t', ErrorKind::AlphaNumeric) +} + +pub fn amount(input: &str) -> IResult<&str, DirectiveAmount> { + alt((suffix_amount, prefix_amount)).parse(input) +} + +pub fn decimal(input: &str) -> IResult<&str, Decimal> { + let (new_input, decimal_str) = recognize(tuple(( + opt(one_of("+-")), + opt(number_int), + opt(char('.')), + opt(number_int), + ))) + .parse(input)?; + + if decimal_str.contains(',') { + match Decimal::from_str_exact(&decimal_str.replace(",", "")) { + Ok(decimal) => Ok((new_input, decimal)), + Err(_) => Err(Err::Error(Error::new(input, ErrorKind::Eof))), + } + } else { + match Decimal::from_str_exact(decimal_str) { + Ok(decimal) => Ok((new_input, decimal)), + Err(_) => Err(Err::Error(Error::new(input, ErrorKind::Eof))), + } + } +} + +/////////////// +// Private // +/////////////// + +fn prefix_amount(input: &str) -> IResult<&str, DirectiveAmount> { + tuple(( + opt(one_of("+-")), + unit, + preceded(space0, decimal), + )) + .map(|(sign, unit_symbol, mut value)| { + if let Some(s) = sign { + if s == '-' { + value = value * dec!(-1); + } + } + DirectiveAmount { + value, + unit_symbol: unit_symbol.to_string(), + is_unit_prefix: true, + } + }) + .parse(input) +} + +fn suffix_amount(input: &str) -> IResult<&str, DirectiveAmount> { + tuple(( + decimal, + preceded(space0, unit), + )) + .map(|(value, unit_symbol)| DirectiveAmount { + value, + unit_symbol: unit_symbol.to_string(), + is_unit_prefix: false, + }) + .parse(input) +} + +fn unit(input: &str) -> IResult<&str, &str> { + recognize(many1(none_of("0123456789,+-_()*/.{} \t"))).parse(input) +} + +fn number_int(input: &str) -> IResult<&str, &str> { + recognize(many1(terminated(one_of("0123456789"), many0(one_of("_,")))))(input) +} + + +#[cfg(test)] +mod tests { + use rust_decimal_macros::dec; + + use super::*; + + #[test] + fn parse_decimal_good() { + assert_eq!(decimal("1").unwrap().1, dec!(1)); + assert_eq!(decimal("+10").unwrap().1, dec!(10)); + assert_eq!(decimal("-10").unwrap().1, dec!(-10)); + assert_eq!(decimal("10.1").unwrap().1, dec!(10.1)); + assert_eq!(decimal("100_000.01").unwrap().1, dec!(100000.01)); + assert_eq!(decimal(".1").unwrap().1, dec!(0.1)); + assert_eq!(decimal("-.1").unwrap().1, dec!(-0.1)); + assert_eq!(decimal("2.").unwrap().1, dec!(2.)); + assert_eq!(decimal("1,000").unwrap().1, dec!(1000)); + } + + + #[test] + fn amount_good() { + assert_eq!( + amount("$10").unwrap().1, + DirectiveAmount { + value: dec!(10), + unit_symbol: "$".into(), + is_unit_prefix: true + } + ); + assert_eq!( + amount("10 USD").unwrap().1, + DirectiveAmount { + value: dec!(10), + unit_symbol: "USD".into(), + is_unit_prefix: false + } + ); + assert_eq!( + amount("-$10.01").unwrap().1, + DirectiveAmount { + value: dec!(-10.01), + unit_symbol: "$".into(), + is_unit_prefix: true + } + ); + assert_eq!( + amount("-10€").unwrap().1, + DirectiveAmount { + value: dec!(-10), + unit_symbol: "€".into(), + is_unit_prefix: false + } + ); + assert_eq!( + amount("-€10").unwrap().1, + DirectiveAmount { + value: dec!(-10), + unit_symbol: "€".into(), + is_unit_prefix: true + } + ); + } + +} \ No newline at end of file diff --git a/src/document/parser/base_directive.rs b/src/document/parser/base_directive.rs new file mode 100644 index 0000000..cb18bb7 --- /dev/null +++ b/src/document/parser/base_directive.rs @@ -0,0 +1,245 @@ +use chrono::NaiveDate; +use nom::{ + branch::alt, + bytes::complete::{tag, take_till, take_while_m_n}, + character::complete::{line_ending, multispace0, not_line_ending, one_of, space0, space1}, + combinator::{eof, opt}, + error::{Error, ErrorKind}, + multi::many0, + sequence::{pair, preceded, terminated, tuple}, + AsChar, FindToken, IResult, InputTakeAtPosition, Parser, +}; + +#[derive(Debug)] +pub struct BaseDirective<'a> { + pub date: Option, + pub directive_name: &'a str, + pub lines: Vec<&'a str>, +} + +// TODO: clean up +pub fn base_directives(input: &str) -> IResult<&str, Vec> { + terminated( + many0(preceded(empty_lines, base_directive)), + pair(empty_lines, eof), + ) + .parse(input) +} + +pub fn base_directive(input: &str) -> IResult<&str, BaseDirective> { + tuple(( + opt(terminated(parse_iso_date, space1)), + terminated(parse_directive_name, space0), + parse_line_not_comment, + many0(preceded( + many0(alt((preceded(space0, line_ending), parse_comment))), + preceded(space1, parse_line_not_comment), + )), + )) + .map(|(date, directive_name, line0, lines)| BaseDirective { + date: date, + directive_name, + lines: { + let mut l: Vec<&str> = lines + .iter() + .map(|v| *v) + .filter(|s| !s.trim().is_empty()) + .collect(); + l.insert(0, line0); + l + }, + }) + .parse(input) +} + +pub fn empty_lines(input: &str) -> IResult<&str, ()> { + many0(alt((preceded(space0, line_ending), parse_comment))) + .map(|_| {}) + .parse(input) +} + +/////////////// +// Private // +/////////////// + +fn take_n_digits(i: &str, n: usize) -> IResult<&str, u32> { + let (i, digits) = take_while_m_n(n, n, AsChar::is_dec_digit)(i)?; + + let res = digits.parse().expect("Invalid ASCII number"); + + Ok((i, res)) +} + +fn date_year(input: &str) -> IResult<&str, i32> { + take_n_digits(input, 4).map(|(str, year)| (str, i32::try_from(year).unwrap())) +} + +fn date_month(input: &str) -> IResult<&str, u32> { + take_n_digits(input, 2) +} + +fn date_day(input: &str) -> IResult<&str, u32> { + take_n_digits(input, 2) +} + +fn parse_iso_date(input: &str) -> IResult<&str, NaiveDate> { + let (new_input, (year, _, month, _, day)) = tuple(( + date_year, + opt(tag("-")), + date_month, + opt(tag("-")), + date_day, + )) + .parse(input)?; + + match NaiveDate::from_ymd_opt(year, month, day) { + Some(date) => Ok((new_input, date)), + None => Err(nom::Err::Error(Error::new(input, ErrorKind::Eof))), + } +} + +const COMMENT_CHARS: &str = ";#"; + +fn parse_comment(input: &str) -> IResult<&str, &str> { + preceded( + multispace0, + preceded(one_of(COMMENT_CHARS), not_line_ending), + ) + .parse(input) +} + +fn parse_line_not_comment(input: &str) -> IResult<&str, &str> { + not_line_ending + .and_then(take_till(|c| COMMENT_CHARS.find_token(c))) + .map(|val: &str| val.trim()) + .parse(input) +} + +fn parse_directive_name(input: &str) -> IResult<&str, &str> { + input.split_at_position1_complete( + |item| !item.is_alphanumeric() && !item.is_ascii_punctuation(), + ErrorKind::AlphaNumeric, + ) +} + +///////////// +// Tests // +///////////// + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn correct_date() { + assert_eq!( + parse_iso_date("2000-01-01"), + Ok(("", NaiveDate::from_ymd_opt(2000, 01, 01).unwrap())) + ); + assert_eq!( + parse_iso_date("20000101"), + Ok(("", NaiveDate::from_ymd_opt(2000, 01, 01).unwrap())) + ); + } + + #[test] + fn incomplete_date() { + assert_eq!(parse_iso_date("200-01-01").is_err(), true); + } + + #[test] + fn invalid_date() { + assert_eq!(parse_iso_date("2000-02-30").is_err(), true); + } + + #[test] + fn single_line_directive() { + let directive = "2000-01-01 directive value"; + let result = base_directive(directive).unwrap().1; + + assert_eq!(result.date.is_some(), true); + assert_eq!(result.directive_name, "directive"); + assert_eq!(result.lines[0], "value"); + } + + #[test] + fn single_line_directive_no_date() { + let directive = "directive value"; + let result = base_directive(directive).unwrap().1; + + assert_eq!(result.date.is_some(), false); + assert_eq!(result.directive_name, "directive"); + assert_eq!(result.lines[0], "value"); + } + + #[test] + fn multiple_directives() { + let directives = concat!( + "directive value\n", + "\n", + "2000-01-01 txn\n", + " Account1 200 USD\n", + " Account2 -200 USD\n", + "\n \n", + "2000-01-01 txn\n", + " Account1 200 USD\n", + " Account2 -200 USD\n", + ); + + let result = base_directives(directives).unwrap().1; + + assert_eq!(result.len(), 3); + assert_eq!(result[0].directive_name, "directive"); + assert_eq!(result[1].directive_name, "txn"); + assert_eq!(result[2].lines.len(), 3); + } + + #[test] + fn multiple_directives_extra_lines() { + let directives = concat!( + "\n", + "2000-01-01 txn\n", + " Account1 200 USD\n", + " \n", + " Account2 -200 USD\n", + "\n \n", + "2000-01-01 txn\n", + " Account1 200 USD\n", + "\n", + " Account2 -200 USD\n", + ); + let result = base_directives(directives).unwrap().1; + + assert_eq!(result.len(), 2); + assert_eq!(result[0].lines.len(), 3); + assert_eq!(result[1].lines.len(), 3); + } + + #[test] + fn multiple_directives_comments() { + let directives = concat!( + ";comment1\n", + "\n", + "2000-01-01 txn ;comment2\n", + " Account1 200 USD\n", + " \n", + " ; comment3\n", + " Account2 -200 USD\n", + "\n", + "2000-01-01 txn\n", + " Account1 200 USD ;comment 4\n", + "\n", + ";comment5\n", + " Account2 -200 USD\n", + "" + ); + + let result = base_directives(directives).unwrap().1; + + assert_eq!(result.len(), 2); + assert_eq!(result[0].lines.len(), 3); + assert_eq!(result[0].lines[0], ""); + assert_eq!(result[1].lines.len(), 3); + assert_eq!(result[1].lines[1], "Account1 200 USD"); + } +} diff --git a/src/document/parser/directives.rs b/src/document/parser/directives.rs new file mode 100644 index 0000000..97d1410 --- /dev/null +++ b/src/document/parser/directives.rs @@ -0,0 +1,194 @@ +use std::path::PathBuf; + +use nom::{ + bytes::complete::{is_not, tag}, + character::complete::{none_of, space1}, + combinator::{opt, rest}, + error::{Error, ErrorKind, ParseError}, + sequence::{preceded, terminated, tuple}, + IResult, Parser, +}; + +use crate::{core::UnitSymbol, document::{ + BalanceDirective, CommodityDirective, IncludeDirective, TransactionDirective, +}}; + +use super::{ + amounts::{account, amount}, + base_directive::BaseDirective, + transaction::transaction, +}; + +// use super::{ +// base::ParsedBaseDirective, +// shared::{parse_account, parse_amount}, +// transaction::parse_transaction, +// types::{ParseError, ParsedBalanceDirective, ParsedDirectives, ParsedIncludeDirective}, +// }; + +////////////// +// Public // +////////////// + +pub enum Directive { + Include(IncludeDirective), + Commodity(CommodityDirective), + Balance(BalanceDirective), + Transaction(TransactionDirective), +} + +pub fn specific_directive<'a>( + directive: BaseDirective<'a>, +) -> IResult, Directive> { + match directive.directive_name.to_lowercase().as_str() { + "txn" => transaction(directive).map(|(i, v)| (i, Directive::Transaction(v))), + // Assume transaction flag if length of one + n if (n.len() == 1) => transaction + .map(|v| Directive::Transaction(v)) + .parse(directive), + "include" => include_directive + .map(|v| Directive::Include(v)) + .parse(directive), + "commodity" => commodity_directive + .map(|v| Directive::Commodity(v)) + .parse(directive), + "balance" => balance_directive + .map(|v| Directive::Balance(v)) + .parse(directive), + _ => Err(nom::Err::Failure(Error { + input: directive, + code: ErrorKind::Fail, + })), + } +} + +//////// ////// +// Private // +/////////////// + +fn include_directive(directive: BaseDirective) -> IResult { + if directive.date.is_some() { + return Err(nom::Err::Failure(Error { + input: directive, + code: ErrorKind::Fail, + })); + } + if directive.lines.len() != 1 { + return Err(nom::Err::Failure(Error { + input: directive, + code: ErrorKind::Fail, + })); + } + + let path = PathBuf::from(directive.lines[0]); + + Ok((directive, IncludeDirective { path })) +} + +fn commodity_directive(directive: BaseDirective) -> IResult { + let date = directive.date; + let name = if directive.lines[0].trim().is_empty() { + None + } else { + Some(directive.lines[0].trim().into()) + }; + let mut symbols = Vec::new(); + let mut precision = None; + + for &line in directive.lines.get(1..).unwrap_or(&[]) { + let (key, value) = if let Ok(v) = + tuple((terminated(is_not::<_, _, Error<&str>>(":"), tag(":")), rest)).parse(line) + { + v.1 + } else { + return Err(nom::Err::Failure(Error { + input: directive, + code: ErrorKind::Fail, + })); + }; + let value = value.trim(); + if value.is_empty() { + return Err(nom::Err::Failure(Error { + input: directive, + code: ErrorKind::Fail, + })); + } + + match key { + "symbol_prefix" => symbols.push(UnitSymbol {symbol: value.into(), is_prefix: true}), + "symbol" => symbols.push(UnitSymbol {symbol: value.into(), is_prefix: false}), + "precision" => precision = Some(value.trim().parse::().unwrap()), // TODO: unwrap + _ => return Err(nom::Err::Failure(Error { + input: directive, + code: ErrorKind::Fail, + })), + } + } + + Ok(( + directive, + CommodityDirective { date, name, symbols, precision }, + )) +} + +fn balance_directive(directive: BaseDirective) -> IResult { + let date = if let Some(d) = directive.date { + d + } else { + return Err(nom::Err::Failure(Error { + input: directive, + code: ErrorKind::Fail, + })); + }; + + let (_, (account, bal_amount)) = if let Ok(v) = + tuple((account, opt(preceded(space1, amount)))).parse(directive.lines.get(0).unwrap_or(&"")) + { + v + } else { + return Err(nom::Err::Failure(Error { + input: directive, + code: ErrorKind::Eof, + })); + }; + let amounts = if let Some(a) = bal_amount { + vec![a] + } else { + let mut amounts = Vec::with_capacity(directive.lines.len()); + for &line in directive.lines.get(1..).unwrap_or(&[]) { + let bal_amount = if let Ok(a) = amount(line) { + a + } else { + return Err(nom::Err::Failure(Error { + input: directive, + code: ErrorKind::Fail, + })); + }; + amounts.push(bal_amount.1); + } + amounts + }; + + Ok(( + directive, + BalanceDirective { date, account: account.to_string(), amounts }, + )) +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + + use super::*; + + #[test] + fn parse_balance() { + let directive = BaseDirective { + date: NaiveDate::from_ymd_opt(2000, 01, 01), + directive_name: "txn", + lines: vec!["Account", "$10", "20 UNIT"], + }; + let balance = balance_directive(directive).unwrap(); + println!("{:?}", balance); + } +} diff --git a/src/document/parser/mod.rs b/src/document/parser/mod.rs new file mode 100644 index 0000000..4a6d13b --- /dev/null +++ b/src/document/parser/mod.rs @@ -0,0 +1,46 @@ +mod amounts; +mod base_directive; +mod directives; +mod transaction; + +use base_directive::{base_directive, empty_lines}; +use directives::{specific_directive, Directive}; +use nom::{ + combinator::eof, + error::{Error, ErrorKind}, + multi::many0, + sequence::{pair, preceded, terminated}, + Parser, +}; + +use crate::core::CoreError; + +use super::Directives; + +pub fn parse_directives(input: &str) -> Result { + let parsed_directives = terminated( + many0(preceded( + empty_lines, + base_directive.and_then(|d| { + specific_directive(d) + .map_err(|_| nom::Err::Failure(Error { input: "", code: ErrorKind::Fail })) + }), + )), + pair(empty_lines, eof), + ) + .parse(input) + .map_err(|e| CoreError::from(e.to_string()))? + .1; + + let mut directives = Directives::new(); + for parsed_directive in parsed_directives { + match parsed_directive { + Directive::Include(d) => directives.includes.push(d), + Directive::Balance(d) => directives.balances.push(d), + Directive::Transaction(d) => directives.transactions.push(d), + Directive::Commodity(d) => directives.commodities.push(d), + } + } + + Ok(directives) +} diff --git a/src/document/parser/transaction.rs b/src/document/parser/transaction.rs new file mode 100644 index 0000000..d206a04 --- /dev/null +++ b/src/document/parser/transaction.rs @@ -0,0 +1,365 @@ +use nom::{ + branch::alt, + bytes::complete::{is_not, tag}, + character::complete::space1, + combinator::{eof, opt, rest}, + error::{Error, ErrorKind}, + sequence::{delimited, preceded, tuple}, + Err, IResult, Parser, +}; + +use crate::document::{DirectiveAmount, DirectivePosting, TransactionDirective}; + +use super::{ + amounts::{account, amount}, + base_directive::BaseDirective, +}; + +// use super::{ +// base::ParsedBaseDirective, directives::BaseDirective, shared::{parse_account, amount}, types::{ParseError, DirectiveAmount, DirectivePosting, ParsedTransactionDirective} +// }; + +////////////// +// Public // +////////////// + +pub fn transaction<'a>( + directive: BaseDirective<'a>, +) -> IResult, TransactionDirective> { + let date = if let Some(d) = directive.date { + d + } else { + return Result::Err(Err::Failure(Error { + input: directive, + code: ErrorKind::Fail, + })); + }; + + let flag = if directive.directive_name.len() == 1 { + Some(directive.directive_name.chars().next().unwrap()) + } else { + None + }; + + let (_, (payee, narration)) = + if let Ok(v) = payee_narration(directive.lines.get(0).unwrap_or(&"")) { + v + } else { + return Result::Err(Err::Failure(Error { + input: directive, + code: ErrorKind::Fail, + })); + }; + + let mut postings = Vec::with_capacity(directive.lines.len()); + for &line in directive.lines.get(1..).unwrap_or(&[]) { + let posting = if let Ok(v) = posting(line) { + v + } else { + return Result::Err(Err::Failure(Error { + input: directive, + code: ErrorKind::Fail, + })); + }; + + postings.push(posting.1); + } + + Ok(( + directive, + TransactionDirective { + date, + flag, + payee: payee.map(|p| p.to_string()), + narration: narration.map(|n| n.to_string()), + postings, + }, + )) +} + +/////////////// +// Private // +/////////////// + +fn payee_narration(input: &str) -> IResult<&str, (Option<&str>, Option<&str>)> { + tuple((opt(is_not("|")), opt(preceded(tag("|"), rest)))) + .map(|(payee, narration): (Option<&str>, Option<&str>)| { + ( + payee.map(|p| p.trim()).filter(|p| !p.is_empty()), + narration.map(|n| n.trim()).filter(|n| !n.is_empty()), + ) + }) + .parse(input) +} + +fn posting(input: &str) -> IResult<&str, DirectivePosting> { + tuple(( + account, + opt(tuple(( + preceded(space1, amount), + opt(preceded(space1, parse_cost)), + opt(preceded(space1, parse_price)), + ))), + eof, + )) + .map(|(account, value, _)| { + let mut amount = None; + let mut cost = None; + let mut price = None; + if let Some(v) = value { + amount = Some(v.0); + if let Some(c) = v.1 { + if c.1 { + cost = Some(DirectiveAmount { + value: c.0.value / amount.as_ref().unwrap().value.abs(), + unit_symbol: c.0.unit_symbol, + is_unit_prefix: c.0.is_unit_prefix, + }); + } else { + cost = Some(c.0); + } + } + if let Some(p) = v.2 { + if p.1 { + price = Some(DirectiveAmount { + value: p.0.value / amount.as_ref().unwrap().value.abs(), + unit_symbol: p.0.unit_symbol, + is_unit_prefix: p.0.is_unit_prefix, + }); + } else { + price = Some(p.0); + } + } + } + DirectivePosting { account: account.to_string(), amount, cost, price } + }) + .parse(input) +} + +fn parse_cost(input: &str) -> IResult<&str, (DirectiveAmount, bool)> { + alt(( + delimited(tag("{"), amount, tag("}")).map(|amount| (amount, false)), + delimited(tag("{{"), amount, tag("}}")).map(|amount| (amount, true)), + )) + .parse(input) +} + +fn parse_price(input: &str) -> IResult<&str, (DirectiveAmount, bool)> { + alt(( + preceded(tuple((tag("@"), space1)), amount).map(|amount| (amount, false)), + preceded(tuple((tag("@@"), space1)), amount).map(|amount| (amount, true)), + )) + .parse(input) +} + +///////////// +// Tests // +///////////// + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + use rust_decimal_macros::dec; + + use super::*; + + #[test] + fn test_parse_payee_narration() { + assert_eq!(payee_narration("").unwrap().1, (None, None)); + assert_eq!(payee_narration("|").unwrap().1, (None, None)); + assert_eq!(payee_narration("aa").unwrap().1, (Some("aa"), None)); + assert_eq!(payee_narration("aa |").unwrap().1, (Some("aa"), None)); + assert_eq!( + payee_narration("aa | bb").unwrap().1, + (Some("aa"), Some("bb")) + ); + assert_eq!(payee_narration(" | bb").unwrap().1, (None, Some("bb"))); + assert_eq!(payee_narration("|bb ").unwrap().1, (None, Some("bb"))); + } + + #[test] + fn parse_transaction_empty() { + let directive = BaseDirective { + date: NaiveDate::from_ymd_opt(2000, 01, 01), + directive_name: "txn", + lines: vec!["payee | narration"], + }; + let transaction = transaction(directive).unwrap().1; + assert_eq!(transaction.payee, Some("payee".into())); + assert_eq!(transaction.narration, Some("narration".into())); + } + + #[test] + fn parse_posting_cost() { + assert_eq!( + posting("Account1 10 SHARE {$100}").unwrap().1, + DirectivePosting { + account: "Account1".into(), + amount: Some(DirectiveAmount { + value: dec!(10), + unit_symbol: "SHARE".into(), + is_unit_prefix: false + }), + cost: Some(DirectiveAmount { + value: dec!(100), + unit_symbol: "$".into(), + is_unit_prefix: true + }), + price: None, + } + ); + assert_eq!( + posting("Account1 10 SHARE {{1000 USD}}").unwrap().1, + DirectivePosting { + account: "Account1".into(), + amount: Some(DirectiveAmount { + value: dec!(10), + unit_symbol: "SHARE".into(), + is_unit_prefix: false + }), + cost: Some(DirectiveAmount { + value: dec!(100), + unit_symbol: "USD".into(), + is_unit_prefix: false + }), + price: None, + } + ); + } + + #[test] + fn parse_posting_price() { + assert_eq!( + posting("Account1 10 SHARE @ $100").unwrap().1, + DirectivePosting { + account: "Account1".into(), + amount: Some(DirectiveAmount { + value: dec!(10), + unit_symbol: "SHARE".into(), + is_unit_prefix: false + }), + cost: None, + price: Some(DirectiveAmount { + value: dec!(100), + unit_symbol: "$".into(), + is_unit_prefix: true + }), + } + ); + assert_eq!( + posting("Account1 10 SHARE @@ 1000 USD").unwrap().1, + DirectivePosting { + account: "Account1".into(), + amount: Some(DirectiveAmount { + value: dec!(10), + unit_symbol: "SHARE".into(), + is_unit_prefix: false + }), + cost: None, + price: Some(DirectiveAmount { + value: dec!(100), + unit_symbol: "USD".into(), + is_unit_prefix: false + }), + } + ); + } + + #[test] + fn parse_posting_cost_and_price() { + assert_eq!( + posting("Account1 10 SHARE {$100} @ $110").unwrap().1, + DirectivePosting { + account: "Account1".into(), + amount: Some(DirectiveAmount { + value: dec!(10), + unit_symbol: "SHARE".into(), + is_unit_prefix: false + }), + cost: Some(DirectiveAmount { + value: dec!(100), + unit_symbol: "$".into(), + is_unit_prefix: true + }), + price: Some(DirectiveAmount { + value: dec!(110), + unit_symbol: "$".into(), + is_unit_prefix: true + }), + } + ); + assert_eq!( + posting("Account1 10 SHARE {{1000 USD}} @@ 1100 USD") + .unwrap() + .1, + DirectivePosting { + account: "Account1".into(), + amount: Some(DirectiveAmount { + value: dec!(10), + unit_symbol: "SHARE".into(), + is_unit_prefix: false + }), + cost: Some(DirectiveAmount { + value: dec!(100), + unit_symbol: "USD".into(), + is_unit_prefix: false + }), + price: Some(DirectiveAmount { + value: dec!(110), + unit_symbol: "USD".into(), + is_unit_prefix: false + }), + } + ); + assert_eq!( + posting("Account1 10 SHARE {{1000 USD}} @ 110 USD") + .unwrap() + .1, + DirectivePosting { + account: "Account1".into(), + amount: Some(DirectiveAmount { + value: dec!(10), + unit_symbol: "SHARE".into(), + is_unit_prefix: false + }), + cost: Some(DirectiveAmount { + value: dec!(100), + unit_symbol: "USD".into(), + is_unit_prefix: false + }), + price: Some(DirectiveAmount { + value: dec!(110), + unit_symbol: "USD".into(), + is_unit_prefix: false + }), + } + ); + } + + #[test] + fn parse_transaction_postings() { + let directive = BaseDirective { + date: NaiveDate::from_ymd_opt(2000, 01, 01), + directive_name: "txn", + lines: vec![ + "payee | narration", + "Account1:Account2 $10.01", + "Account3", + ], + }; + + let transaction = transaction(directive).unwrap().1; + assert_eq!(transaction.postings.len(), 2); + assert_eq!(transaction.postings[0].account, "Account1:Account2"); + assert_eq!( + transaction.postings[0].amount, + Some(DirectiveAmount { + value: dec!(10.01), + unit_symbol: "$".into(), + is_unit_prefix: true + }) + ); + assert_eq!(transaction.postings[1].account, "Account3"); + assert_eq!(transaction.postings[1].amount, None); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a4dd631 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,119 @@ +// pub struct Asset { +// name: String, +// smallest_fraction: Option, +// asset_type: AssetType, +// // namespace? +// } + +// pub enum AssetType { +// Currency(Currency), +// Security(Security), +// Commodity(Commodity), +// } + +// pub struct Currency { +// // TODO +// code: String, +// symbol: Option, +// smallest_fraction: Option, +// } + +// pub struct Commodity { +// symbol: String, +// } + +// pub struct Security { +// symbol: String, +// code: Option +// } + +// struct Asset { +// name: Option, +// symbol: String, +// short_symbol: Option, +// smallest_fraction: Option, +// // namespace? +// } + +// struct Quantity {} + +pub mod core; +pub mod queries; +// pub mod parser; +// pub mod create_ledger; +pub mod document; +pub mod output; + +// pub struct Account { +// // TODO +// // type? +// } + +// pub fn add(left: usize, right: usize) -> usize { +// left + right +// } + +// pub type Metadata = HashMap; // TODO + +// pub struct CommoditySymbol { +// symbol: String, +// is_prefix: bool, +// } + +// pub struct Commodity { +// symbols: Vec, +// smallest_fraction: Option, +// // date? +// meta: Metadata, +// } + +// pub struct Amount { +// // TODO +// } + +// pub struct Cost { +// number: f32, +// currency: String, +// date: u32, // TODO +// label: Option, +// } + +// pub struct Flag { +// // TODO +// } + +// pub struct Posting { +// account: String, // code? +// value: Amount, +// cost: Cost, +// is_cost_total: bool, +// price: Amount, +// flag: Option, +// meta: Metadata, +// } + +// pub struct Transaction { +// date: u32, // TODO +// flag: Flag, +// payee: Option, +// narration: Option, +// // tags/links? +// postings: Vec, +// meta: Metadata, +// } + +// #[cfg(test)] +// mod tests { +// use super::*; + +// #[test] +// fn it_works() { +// let result = add(2, 2); +// assert_eq!(result, 4); +// } +// } + +// fn test() { + +// // let a = parse_directives("abc"); +// } diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7fe425c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,130 @@ +use std::{ + io::{self, Write}, + path::Path, + time::Instant, +}; + +use accounting_rust::{ + document::Document, + output::cli::{format_balance, tui_to_ansi::text_to_ansi}, + queries::{self, Query}, +}; +use chrono::NaiveDate; +use ratatui::{ + crossterm::{self, style::PrintStyledContent}, + layout::Rect, + prelude::CrosstermBackend, + style::{Color, Style, Stylize}, + text::{Line, Span, Text}, + widgets::Widget, + Frame, Terminal, +}; + +// use accounting_rust::{create_ledger::create_ledger, document::{SingleDocument, WholeDocument}, parser::parse_directives}; + +pub fn main() -> Result<(), Box> { + // let file_data = fs::read_to_string("data/2020-bofa-checking.ledger").unwrap(); + // let file_data = fs::read_to_string("data/2020-fidelity-401k.ledger").unwrap(); + // let directives = parse_directives(file_data.as_str()).unwrap(); + // println!("{:?}", directives.balances); + + // let document = Document::new(Path::new("data/2020-fidelity-401k.ledger")).unwrap(); + + // let f = Frame:: + + // println!( + // "{}{}", + // span_to_ansi(&Span::raw("Hello ")), + // span_to_ansi(&Span::styled( + // "World", + // Style::new().fg(Color::Green).bg(Color::White) + // )) + // ); + + let stdout = io::stdout(); + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let line = Line::from(vec![ + Span::raw("Hello "), + Span::styled("Hello ", Style::new().fg(Color::Rgb(100, 200, 150))), + Span::styled("World", Style::new().fg(Color::Green).bg(Color::White)), + ]) + .centered(); + let text = Text::from(line); + + println!("{}", text_to_ansi(&text)); + + // println!("{:?}", line.to_string()); + + // terminal.dra + + // crossterm::terminal::enable_raw_mode()?; + + terminal.draw(|f| { + let area = f.area(); + f.render_widget(text, area); + })?; + + // PrintStyledContent + + // let styled_text = Text::from(vec![ + // Line::from(vec![ + // Span::styled("Hello, ", Style::default().fg(Color::Green)), + // Span::styled("world! ", Style::default().fg(Color::Blue)), + // ]), + // Line::from(vec![ + // Span::styled("This is ", Style::default().fg(Color::Yellow)), + // Span::styled("styled text", Style::default().fg(Color::Red)), + // Span::raw("."), + // ]), + // ]); + + // // Convert the styled text to an ANSI-escaped string + // let ansi_string = styled_text.to_string(); + + // // Print the ANSI-styled string + // println!("{}", ansi_string); + + // println!("{}", text.render(area, buf);); + + return Ok(()); + + // let document = Document::new(Path::new("data/full/main.ledger")).unwrap(); + + // let ledger = document.generate_ledger().unwrap(); + + // let balance = queries::balance( + // &ledger, + // &[], + // ); + + // format_balance(&ledger, &balance); + + // return; + + // for (account_id, amounts) in balance { + // let account = ledger.get_account(account_id).unwrap(); + // if !account.get_name().starts_with("Assets") { + // continue; + // } + // print!("{} : ", account.get_name()); + // for (i, amount) in amounts.iter().enumerate() { + // if i != 0 { + // print!(", "); + // } + // print!("{}", ledger.format_amount(amount)); + // // let unit = ledger.get_unit(amount.unit_id).unwrap(); + // // let symbol = unit.default_symbol(); + // // if symbol.is_prefix { + // // print!("{}{}", symbol.symbol, amount.value); + // // } else { + // // print!("{} {}", amount.value, symbol.symbol); + // // } + // } + // println!(); + // } + + // let ledger = create_ledger(&file_data).unwrap(); + // println!("{:?}", val); +} diff --git a/src/output/amounts.rs b/src/output/amounts.rs new file mode 100644 index 0000000..f4d811b --- /dev/null +++ b/src/output/amounts.rs @@ -0,0 +1,19 @@ +use crate::core::{Amount, Ledger}; + +impl Ledger { + pub fn format_amount(&self, amount: &Amount) -> String { + let unit = self.get_unit(amount.unit_id).unwrap(); + let default_symbol = unit.default_symbol(); + let amount = self.round_amount(&amount); + + if default_symbol.is_prefix { + format!("{}{}", default_symbol.symbol, amount.value) + } else { + if default_symbol.symbol.len() == 1 { + format!("{}{}", amount.value, default_symbol.symbol) + } else { + format!("{} {}", amount.value, default_symbol.symbol) + } + } + } +} diff --git a/src/output/cli/balance.rs b/src/output/cli/balance.rs new file mode 100644 index 0000000..5e0c50a --- /dev/null +++ b/src/output/cli/balance.rs @@ -0,0 +1,277 @@ +use std::{ + cmp::{max, Ordering}, + collections::{HashMap, HashSet}, +}; + +use crate::core::{combine_amounts, Account, Amount, Ledger}; + +#[derive(Debug)] +struct BalanceTree { + name: String, + children: Vec, + amounts: Option>, +} + +struct AccountInfo<'a> { + account_path: Vec<&'a str>, + amounts: Vec, +} + +fn construct_tree(ledger: &Ledger, account_balances: &HashMap>) -> BalanceTree { + let account_info: Vec = account_balances + .iter() + .map(|(&account_id, amounts)| { + let account = ledger.get_account(account_id).unwrap(); + AccountInfo { + account_path: account.split_name_groups(), + amounts: amounts.clone(), + } + }) + .collect(); + + let root_children = construct_tree_children(account_info.iter().collect(), 0); + + BalanceTree { + name: "(Total)".into(), + children: root_children, + amounts: None, + } +} + +// fn construct_subtree(accounts: Vec, level: usize, parent: &BalanceTree) { +// for account in accounts +// .iter() +// .filter(|v| v.account_path[level] == parent.name) +// {} +// } + +fn construct_tree_children(accounts: Vec<&AccountInfo>, level: usize) -> Vec { + let mut sub_trees = Vec::new(); + + let names = HashSet::<&str>::from_iter(accounts.iter().map(|v| v.account_path[level])); + + for name in names { + let leaf_account = accounts + .iter() + .find(|v| v.account_path.len() - 1 == level && v.account_path[level] == name); + let sub_accounts: Vec<&AccountInfo> = accounts + .clone() + .into_iter() + .filter(|v| v.account_path.len() - 1 != level && v.account_path[level] == name) + .collect(); + + let mut sub_tree = BalanceTree { + name: name.into(), + children: Vec::new(), + amounts: None, + }; + + if sub_accounts.len() != 0 { + sub_tree.children = construct_tree_children(sub_accounts, level + 1); + if let Some(leaf_account) = leaf_account { + sub_tree.children.push(BalanceTree { + name: "...".into(), + children: Vec::new(), + amounts: Some(leaf_account.amounts.clone()), + }); + } + } else if let Some(leaf_account) = leaf_account { + sub_tree.amounts = Some(leaf_account.amounts.clone()); + } else { + panic!("No account"); + } + + sub_trees.push(sub_tree) + } + + sub_trees +} + +fn set_tree_totals(tree: &mut BalanceTree) { + if tree.amounts.is_some() { + return; + } + + for child in &mut tree.children { + set_tree_totals(child); + } + + let total_amounts = combine_amounts( + tree.children + .iter() + .map(|v| v.amounts.clone().unwrap().into_iter()) + .flatten(), + ); + tree.amounts = Some(total_amounts); +} + +fn print_tree(tree: &BalanceTree, ledger: &Ledger, level: usize, amount_pos: usize) { + let relative_amount_pos = amount_pos - (level*2 + tree.name.len()); + let main_line = format!("{}{} {}", " ".repeat(level), tree.name, "─".repeat(relative_amount_pos)); + let tree_amounts = tree.amounts.as_ref().unwrap().iter().filter(|v| !ledger.round_amount(v).value.is_zero()); + let tree_amounts_count = tree_amounts.clone().count(); + for (i, amount) in tree_amounts.enumerate() { + let mut line = String::new(); + if i == 0 { + line += &main_line; + if tree_amounts_count > 1 { + line += "┬" + } else { + line += "─" + } + } else { + line += &" ".repeat(amount_pos); + if i == tree_amounts_count - 1 { + line += " └" + } else { + line += " │" + } + } + + line += &format!(" {}", ledger.format_amount(amount)); + + println!("{}", line); + } + + // println!("{}{} {} {:?}", " ".repeat(level), tree.name, "-".repeat(relative_amount_pos), tree.amounts); + let mut children: Vec<&BalanceTree> = tree.children.iter().collect(); + children.sort_by(|a, b| a.name.cmp(&b.name)); + for child in children { + print_tree(&child, ledger, level + 1, amount_pos); + } +} + +fn calculate_max_account_len(tree: &BalanceTree, indent_amount: usize, indent_level: usize) -> usize { + let current_len = tree.name.len() + indent_amount * indent_level; + + let mut max_length = current_len; + for child in &tree.children { + let child_max_len = calculate_max_account_len(child, indent_amount, indent_level+1); + max_length = max(max_length, child_max_len); + } + + max_length +} + +pub fn format_balance(ledger: &Ledger, account_balances: &HashMap>) -> String { + let mut output = String::new(); + + let mut tree = construct_tree(ledger, account_balances); + set_tree_totals(&mut tree); + + let max_account_len = calculate_max_account_len(&tree, 2, 0); + + println!("{}", max_account_len); + print_tree(&tree, &ledger, 0, max_account_len + 5); + // println!("{:?}", tree); + + // let base_account_info: Vec = account_balances + // .iter() + // .map(|(&account_id, amounts)| { + // let account = ledger.get_account(account_id).unwrap(); + // AccountInfo { + // account_path: account.split_name_groups(), + // is_group: false, + // amounts: amounts.clone(), + // } + // }) + // .collect(); + + // let mut account_info: Vec = Vec::new(); + + // for base_account in &base_account_info {} + + // let mut group_account_info = Vec::new(); + // for account in &account_info { + // let mut account_path = account.account_path.clone(); + // loop { + // account_path.pop(); + // if account_path.len() == 0 { + // break; + // } + + // if !group_account_info + // .iter() + // .any(|v: &AccountInfo| v.account_path == account_path) + // { + // let amount_totals = combine_amounts( + // account_info + // .iter() + // .filter(|v| v.account_path.starts_with(&account_path)) + // .map(|v| v.amounts.iter()) + // .flatten() + // .map(|v| *v), + // ); + + // group_account_info.push(AccountInfo { + // account_path: account_path.clone(), + // is_group: true, + // amounts: amount_totals, + // }) + // } + // } + // } + // account_info.extend(group_account_info.into_iter()); + + // sort_account_info(&mut account_info); + + // for i in account_info { + // println!("{} {:?}", i.account_path.join(":"), i.amounts); + // } + + output +} + +// fn sort_account_info(account_info: &mut Vec) { +// account_info.sort_by(|a, b| { +// let ordering = a +// .account_path +// .iter() +// .zip(b.account_path.iter()) +// .map(|(&a, &b)| a.cmp(b)) +// .find(|val| *val != Ordering::Equal); + +// ordering.unwrap_or_else(|| a.account_path.len().cmp(&b.account_path.len())) +// }); +// } + +// ///////////// +// // Tests // +// ///////////// + +// #[cfg(test)] +// mod tests { +// use super::*; + +// #[test] +// fn test_sort_account_info() { +// let mut account_info = vec![ +// AccountInfo { +// account_path: vec!["a2", "b1"], +// is_group: false, +// amounts: vec![], +// }, +// AccountInfo { +// account_path: vec!["a1", "b2"], +// is_group: false, +// amounts: vec![], +// }, +// AccountInfo { +// account_path: vec!["a1", "b1"], +// is_group: false, +// amounts: vec![], +// }, +// AccountInfo { +// account_path: vec!["a1"], +// is_group: false, +// amounts: vec![], +// }, +// ]; +// sort_account_info(&mut account_info); + +// println!("{:?}", account_info[0].account_path); +// println!("{:?}", account_info[1].account_path); +// println!("{:?}", account_info[2].account_path); +// println!("{:?}", account_info[3].account_path); +// } +// } diff --git a/src/output/cli/mod.rs b/src/output/cli/mod.rs new file mode 100644 index 0000000..896c3e1 --- /dev/null +++ b/src/output/cli/mod.rs @@ -0,0 +1,4 @@ +mod balance; +pub mod tui_to_ansi; + +pub use balance::*; \ No newline at end of file diff --git a/src/output/cli/tui_to_ansi.rs b/src/output/cli/tui_to_ansi.rs new file mode 100644 index 0000000..b53f151 --- /dev/null +++ b/src/output/cli/tui_to_ansi.rs @@ -0,0 +1,94 @@ +use ratatui::{ + style::{Color, Modifier}, + text::{Span, Text}, +}; + +pub fn span_to_ansi(span: &Span) -> String { + let mut styled_string = String::new(); + + if let Some(color) = span.style.fg { + if let Color::Rgb(r, g, b) = color { + styled_string.push_str(&format!("\x1b[38;2;{};{};{}m", r, g, b)); + } else { + styled_string.push_str(&format!( + "\x1b[{}m", + match color { + Color::Black => "30", + Color::Red => "31", + Color::Green => "32", + Color::Yellow => "33", + Color::Blue => "34", + Color::Magenta => "35", + Color::Cyan => "36", + Color::Gray => "37", + Color::DarkGray => "90", + Color::LightRed => "91", + Color::LightGreen => "92", + Color::LightYellow => "93", + Color::LightBlue => "94", + Color::LightMagenta => "95", + Color::LightCyan => "96", + Color::White => "97", + _ => "39", + } + )); + } + } + + if let Some(color) = span.style.bg { + if let Color::Rgb(r, g, b) = color { + styled_string.push_str(&format!("\x1b[48;2;{};{};{}m", r, g, b)); + } else { + styled_string.push_str(&format!( + "\x1b[{}m", + match color { + Color::Black => "40", + Color::Red => "41", + Color::Green => "42", + Color::Yellow => "43", + Color::Blue => "44", + Color::Magenta => "45", + Color::Cyan => "46", + Color::Gray => "47", + Color::DarkGray => "100", + Color::LightRed => "101", + Color::LightGreen => "102", + Color::LightYellow => "103", + Color::LightBlue => "104", + Color::LightMagenta => "105", + Color::LightCyan => "106", + Color::White => "107", + _ => "49", + } + )); + } + } + + if span.style.add_modifier.contains(Modifier::BOLD) { + styled_string.push_str("\x1b[1m"); + } + if span.style.add_modifier.contains(Modifier::ITALIC) { + styled_string.push_str("\x1b[3m"); + } + if span.style.add_modifier.contains(Modifier::UNDERLINED) { + styled_string.push_str("\x1b[4m"); + } + + styled_string.push_str(&span.content); + styled_string.push_str("\x1b[0m"); // Reset style + styled_string +} + +pub fn text_to_ansi(text: &Text) -> String { + text.lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span_to_ansi(span)) + .collect::>() + .join("") + }) + .collect::>() + .join("/n") +} diff --git a/src/output/mod.rs b/src/output/mod.rs new file mode 100644 index 0000000..3827e9f --- /dev/null +++ b/src/output/mod.rs @@ -0,0 +1,2 @@ +pub mod cli; +mod amounts; diff --git a/src/queries/balance.rs b/src/queries/balance.rs new file mode 100644 index 0000000..b5295ca --- /dev/null +++ b/src/queries/balance.rs @@ -0,0 +1,44 @@ +use std::collections::HashMap; + +use crate::core::{Amount, Ledger}; +use chrono::NaiveDate; +use rust_decimal_macros::dec; + +pub enum Query { + StartDate(NaiveDate), + EndDate(NaiveDate), +} + +pub fn balance(ledger: &Ledger, query: &[Query]) -> HashMap> { + let relevant_transactions = ledger.get_transactions().iter().filter(|txn| { + query.iter().all(|q| match q { + Query::StartDate(date) => txn.get_date() >= *date, + Query::EndDate(date) => txn.get_date() <= *date, + }) + }); + + let mut accounts = HashMap::new(); + + for txn in relevant_transactions.clone() { + for posting in txn.get_postings() { + let amount = posting.get_amount(); + let account_vals = accounts + .entry(posting.get_account_id()) + .or_insert(HashMap::new()); + let a = account_vals.entry(amount.unit_id).or_insert(dec!(0)); + *a += amount.value; + } + } + + accounts + .iter() + .map(|(&k, v)| { + ( + k, + v.into_iter() + .map(|(&unit_id, &value)| Amount { value, unit_id }) + .collect(), + ) + }) + .collect() +} diff --git a/src/queries/mod.rs b/src/queries/mod.rs new file mode 100644 index 0000000..ae8db71 --- /dev/null +++ b/src/queries/mod.rs @@ -0,0 +1,3 @@ +mod balance; + +pub use balance::*;