initial messy commit

This commit is contained in:
Evan Peterson 2025-01-23 19:13:28 -05:00
commit 423bbd8eb4
Signed by: petersonev
GPG Key ID: 26BC6134519C4FC6
28 changed files with 3242 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
/Cargo.lock
/data/

15
Cargo.toml Normal file
View File

@ -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"

526
notes.md Normal file
View File

@ -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)
```

4
rustfmt.toml Normal file
View File

@ -0,0 +1,4 @@
# [format]
# max_width = 120
struct_lit_width = 50
# use_small_heuristics = "Max"

63
src/core/account.rs Normal file
View File

@ -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<NaiveDate>,
close_date: Option<NaiveDate>,
}
impl Account {
pub fn new(name: String, open_date: Option<NaiveDate>) -> Result<Account, CoreError> {
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<NaiveDate> {
self.open_date
}
pub fn get_close_date(&self) -> Option<NaiveDate> {
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);
}
}

93
src/core/amounts.rs Normal file
View File

@ -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<String>,
// }
#[derive(Debug, Clone)]
pub struct UnitSymbol {
pub symbol: String,
pub is_prefix: bool,
}
#[derive(Debug)]
pub struct Unit {
id: u32,
symbols: Vec<UnitSymbol>,
scale: Option<u32>,
}
impl Amount {
pub fn at_opt_price(&self, price: Option<Amount>) -> 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<Unit, CoreError> {
Self::new_multi_symbol(vec![symbol], None)
}
pub fn new_multi_symbol(
symbols: Vec<UnitSymbol>,
scale: Option<u32>,
) -> Result<Unit, CoreError> {
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<u32> {
self.scale
}
pub fn get_symbols(&self) -> &Vec<UnitSymbol> {
&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<Item = Amount>) -> Vec<Amount> {
let mut output_amounts = HashMap::<u32, Decimal>::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()
}

7
src/core/common.rs Normal file
View File

@ -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)
}

37
src/core/errors.rs Normal file
View File

@ -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<String> 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)
}
}

178
src/core/ledger.rs Normal file
View File

@ -0,0 +1,178 @@
use rust_decimal_macros::dec;
use super::{Account, Amount, CoreError, Transaction, Unit};
#[derive(Debug)]
pub struct Ledger {
accounts: Vec<Account>,
units: Vec<Unit>,
transactions: Vec<Transaction>,
}
impl Ledger {
pub fn new() -> Ledger {
Ledger {
accounts: Vec::new(),
units: Vec::new(),
transactions: Vec::new(),
}
}
pub fn get_accounts(&self) -> &Vec<Account> {
&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<Unit> {
&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<Transaction> {
&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<Amount> {
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);
}
}

12
src/core/mod.rs Normal file
View File

@ -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::*;

227
src/core/transaction.rs Normal file
View File

@ -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<String>,
narration: Option<String>,
postings: Vec<Posting>,
}
///////////////
// 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<Amount>,
price: Option<Amount>,
}
/////////////////////
// Implementations //
/////////////////////
impl Transaction {
pub fn new(
date: NaiveDate,
flag: TransactionFlag,
mut payee: Option<String>,
mut narration: Option<String>,
postings: Vec<Posting>,
) -> Result<Transaction, CoreError> {
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<String> {
&self.payee
}
pub fn get_narration(&self) -> &Option<String> {
&self.narration
}
pub fn get_postings(&self) -> &Vec<Posting> {
&self.postings
}
pub fn remaining_balance(&self, by_cost: bool) -> Vec<Amount> {
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<char> for TransactionFlag {
type Error = CoreError;
fn try_from(c: char) -> Result<Self, Self::Error> {
TRANSACTION_FLAGS
.iter()
.find(|(_, flag_c)| c == *flag_c)
.map(|(flag, _)| flag.clone())
.ok_or(CoreError::from("Invalid flag character"))
}
}
impl TryFrom<Option<char>> for TransactionFlag {
type Error = CoreError;
fn try_from(c: Option<char>) -> Result<Self, Self::Error> {
c.map(|c| TransactionFlag::try_from(c))
.unwrap_or(Ok(TransactionFlag::Complete))
}
}
impl From<TransactionFlag> 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<Amount>,
price: Option<Amount>,
) -> Result<Posting, CoreError> {
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<Amount> {
&self.cost
}
pub fn get_price(&self) -> &Option<Amount> {
&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);
// }
// }

View File

@ -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<IncludeDirective>,
pub commodities: Vec<CommodityDirective>,
pub transactions: Vec<TransactionDirective>,
pub balances: Vec<BalanceDirective>,
}
////////////////
// Directives //
////////////////
#[derive(Debug, Clone)]
pub struct IncludeDirective {
pub path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct CommodityDirective {
pub date: Option<NaiveDate>,
pub name: Option<String>,
pub symbols: Vec<UnitSymbol>,
pub precision: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct TransactionDirective {
pub date: NaiveDate,
pub flag: Option<char>,
pub payee: Option<String>,
pub narration: Option<String>,
pub postings: Vec<DirectivePosting>,
}
#[derive(Debug, Clone)]
pub struct BalanceDirective {
pub date: NaiveDate,
pub account: String,
pub amounts: Vec<DirectiveAmount>,
}
///////////////
// Sub-types //
///////////////
#[derive(Debug, PartialEq, Clone)]
pub struct DirectivePosting {
pub account: String,
pub amount: Option<DirectiveAmount>,
pub cost: Option<DirectiveAmount>,
pub price: Option<DirectiveAmount>,
}
#[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());
}
}

210
src/document/ledger.rs Normal file
View File

@ -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<Amount>,
cost: Option<Amount>,
price: Option<Amount>,
}
fn create_amount(ledger: &mut Ledger, amount: &DirectiveAmount) -> Result<Amount, CoreError> {
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<u32, CoreError> {
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<IncompletePosting>,
) -> Result<Vec<Posting>, 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)
}

86
src/document/mod.rs Normal file
View File

@ -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<Self, CoreError> {
let mut document = Document { directives: Directives::new() };
document.add_directives_recursive(file_path)?;
Ok(document)
}
pub fn generate_ledger(&self) -> Result<Ledger, CoreError> {
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<Self, CoreError> {
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 })
}
}

View File

@ -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
}
);
}
}

View File

@ -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<NaiveDate>,
pub directive_name: &'a str,
pub lines: Vec<&'a str>,
}
// TODO: clean up
pub fn base_directives(input: &str) -> IResult<&str, Vec<BaseDirective>> {
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");
}
}

View File

@ -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<BaseDirective<'a>, 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<BaseDirective, IncludeDirective> {
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<BaseDirective, CommodityDirective> {
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::<u32>().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<BaseDirective, BalanceDirective> {
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);
}
}

View File

@ -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<Directives, CoreError> {
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)
}

View File

@ -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<BaseDirective<'a>, 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);
}
}

119
src/lib.rs Normal file
View File

@ -0,0 +1,119 @@
// pub struct Asset {
// name: String,
// smallest_fraction: Option<f32>,
// asset_type: AssetType,
// // namespace?
// }
// pub enum AssetType {
// Currency(Currency),
// Security(Security),
// Commodity(Commodity),
// }
// pub struct Currency {
// // TODO
// code: String,
// symbol: Option<String>,
// smallest_fraction: Option<f32>,
// }
// pub struct Commodity {
// symbol: String,
// }
// pub struct Security {
// symbol: String,
// code: Option<String>
// }
// struct Asset {
// name: Option<String>,
// symbol: String,
// short_symbol: Option<String>,
// smallest_fraction: Option<f32>,
// // 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<String, String>; // TODO
// pub struct CommoditySymbol {
// symbol: String,
// is_prefix: bool,
// }
// pub struct Commodity {
// symbols: Vec<CommoditySymbol>,
// smallest_fraction: Option<f32>,
// // date?
// meta: Metadata,
// }
// pub struct Amount {
// // TODO
// }
// pub struct Cost {
// number: f32,
// currency: String,
// date: u32, // TODO
// label: Option<String>,
// }
// pub struct Flag {
// // TODO
// }
// pub struct Posting {
// account: String, // code?
// value: Amount,
// cost: Cost,
// is_cost_total: bool,
// price: Amount,
// flag: Option<Flag>,
// meta: Metadata,
// }
// pub struct Transaction {
// date: u32, // TODO
// flag: Flag,
// payee: Option<String>,
// narration: Option<String>,
// // tags/links?
// postings: Vec<Posting>,
// 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");
// }

130
src/main.rs Normal file
View File

@ -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<dyn std::error::Error>> {
// 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);
}

19
src/output/amounts.rs Normal file
View File

@ -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)
}
}
}
}

277
src/output/cli/balance.rs Normal file
View File

@ -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<BalanceTree>,
amounts: Option<Vec<Amount>>,
}
struct AccountInfo<'a> {
account_path: Vec<&'a str>,
amounts: Vec<Amount>,
}
fn construct_tree(ledger: &Ledger, account_balances: &HashMap<u32, Vec<Amount>>) -> BalanceTree {
let account_info: Vec<AccountInfo> = 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<AccountInfo>, 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<BalanceTree> {
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<u32, Vec<Amount>>) -> 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<AccountInfo> = 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<AccountInfo> = 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<AccountInfo>) {
// 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);
// }
// }

4
src/output/cli/mod.rs Normal file
View File

@ -0,0 +1,4 @@
mod balance;
pub mod tui_to_ansi;
pub use balance::*;

View File

@ -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::<Vec<_>>()
.join("")
})
.collect::<Vec<_>>()
.join("/n")
}

2
src/output/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod cli;
mod amounts;

44
src/queries/balance.rs Normal file
View File

@ -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<u32, Vec<Amount>> {
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()
}

3
src/queries/mod.rs Normal file
View File

@ -0,0 +1,3 @@
mod balance;
pub use balance::*;