This commit is contained in:
Evan Peterson 2026-01-05 14:19:32 -05:00
parent c163d4f8d3
commit d7bc50c267
Signed by: petersonev
GPG Key ID: 26BC6134519C4FC6
19 changed files with 1200 additions and 208 deletions

View File

@ -1,22 +1,31 @@
[package] [package]
name = "accounting-rust" name = "accounting-rust"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
chrono = "0.4.39" chrono = "0.4.39"
clap = { version = "4.5.53", features = ["derive"] }
crossterm = "0.29.0"
csv = "1.4.0"
inquire = "0.9.1"
nom = "7.1.3" nom = "7.1.3"
nom_locate = "4.2.0" nom_locate = "4.2.0"
nucleo-matcher = "0.3.1"
rand = "0.8.5" rand = "0.8.5"
ratatui = "0.29.0" ratatui = "0.29.0"
regex = "1.11.1" regex = "1.11.1"
rt-format = "0.3.1"
rust_decimal = "1.36.0" rust_decimal = "1.36.0"
rust_decimal_macros = "1.36.0" rust_decimal_macros = "1.36.0"
serde = { version="1.0", features = ["derive"] }
strsim = "0.11.1"
toml = "0.9.10"
[profile.release] [profile.release]
debug = 1 debug = 1
[rust] # [rust]
debuginfo-level = 1 # debuginfo-level = 1

29
src/cli/balance.rs Normal file
View File

@ -0,0 +1,29 @@
use super::commands::Balance;
use accounting_rust::{core::Ledger, output::cli::format_balance, parser, query::{self, PostingField}};
use chrono::Utc;
pub fn balance(ledger: &Ledger, b: Balance) {
let balance_query = if b.accounts.is_empty() {
None
} else {
let balance_query = b
.accounts
.iter()
.map(|a| format!("account.name ~ '{}'", a))
.collect::<Vec<String>>()
.join(" OR ");
let parsed_query = parser::query::<PostingField>(&balance_query).unwrap();
if parsed_query.0.trim().len() != 0 {
panic!("Full string not consumed")
}
Some(parsed_query.1)
};
let current_date = Utc::now().date_naive();
let balance = query::balance(&ledger, balance_query, Some(("$", current_date)));
format_balance(&ledger, &balance);
}

35
src/cli/commands.rs Normal file
View File

@ -0,0 +1,35 @@
use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
#[arg(short, long)]
pub check: bool,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Args, Debug)]
pub struct Balance {
/// Accounts to query
pub accounts: Vec<String>,
}
#[derive(Args, Debug)]
pub struct Import {
/// Import config name
pub config: String,
/// File path to import
pub file_path: String,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Shows balance of accounts
Balance(Balance),
/// Check transactions and balances
Check,
/// Import CSV
Import(Import),
}

62
src/cli/config.rs Normal file
View File

@ -0,0 +1,62 @@
use accounting_rust::core::CoreError;
use serde::Deserialize;
use std::{collections::HashMap, path::Path};
#[derive(Deserialize, Debug)]
pub struct Config {
pub ledger: LedgerConfig,
pub importers: HashMap<String, ImporterConfig>,
pub variables: Option<HashMap<String, String>>,
}
#[derive(Deserialize, Debug)]
pub struct LedgerConfig {
pub path: String,
}
#[derive(Deserialize, Debug)]
pub struct ImporterConfig {
pub template: String,
pub column_date: Option<u32>,
pub column_description: Option<u32>,
pub column_amount: Option<i32>,
pub column_unit: Option<u32>,
pub column_price_total: Option<i32>,
pub first_row: Option<u32>,
pub reverse_order: Option<bool>,
pub unit: Option<String>,
pub date_format: Option<String>,
pub mapping_file: Option<String>,
pub output_file: String,
}
pub fn read_config(file_path: &Path) -> Result<Config, CoreError> {
let config_file_data =
std::fs::read_to_string(file_path).map_err(|e| CoreError::from(e.to_string()))?;
let mut config: Result<Config, CoreError> =
toml::from_str(&config_file_data).map_err(|e| e.to_string().into());
if let Ok(config) = &mut config
&& let Some(variables) = &config.variables
{
println!("{variables:?}");
for variable in variables {
config.ledger.path = config
.ledger
.path
.replace(&format!("{{{}}}", variable.0), &variable.1);
for importer in &mut config.importers {
importer.1.mapping_file = importer
.1
.mapping_file
.as_ref()
.map(|v| v.replace(&format!("{{{}}}", variable.0), &variable.1));
importer.1.output_file = importer
.1
.output_file
.replace(&format!("{{{}}}", variable.0), &variable.1);
}
}
}
config
}

98
src/cli/fmt.rs Normal file
View File

@ -0,0 +1,98 @@
use core::fmt;
use chrono::NaiveDate;
use rt_format::{Format, FormatArgument, Specifier};
use rust_decimal::Decimal;
#[derive(Debug, PartialEq)]
#[allow(dead_code)]
pub enum Variant {
Int(i32),
Float(f64),
String(String),
Decimal(Decimal),
Date(NaiveDate),
}
impl FormatArgument for Variant {
fn supports_format(&self, spec: &Specifier) -> bool {
match self {
Self::Int(_) => true,
Self::Float(_) | Self::Decimal(_) => match spec.format {
Format::Display | Format::Debug | Format::LowerExp | Format::UpperExp => true,
_ => false,
},
Self::String(_) | Self::Date(_) => match spec.format {
Format::Display | Format::Debug => true,
_ => false,
},
}
}
fn fmt_display(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Int(val) => fmt::Display::fmt(&val, f),
Self::Float(val) => fmt::Display::fmt(&val, f),
Self::String(val) => fmt::Display::fmt(&val, f),
Self::Decimal(val) => fmt::Display::fmt(&val, f),
Self::Date(val) => fmt::Display::fmt(&val, f),
}
}
fn fmt_debug(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
fn fmt_octal(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Int(val) => fmt::Octal::fmt(&val, f),
_ => Err(fmt::Error),
}
}
fn fmt_lower_hex(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Int(val) => fmt::LowerHex::fmt(&val, f),
_ => Err(fmt::Error),
}
}
fn fmt_upper_hex(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Int(val) => fmt::UpperHex::fmt(&val, f),
_ => Err(fmt::Error),
}
}
fn fmt_binary(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Int(val) => fmt::Binary::fmt(&val, f),
_ => Err(fmt::Error),
}
}
fn fmt_lower_exp(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Int(val) => fmt::LowerExp::fmt(&val, f),
Self::Float(val) => fmt::LowerExp::fmt(&val, f),
Self::Decimal(val) => fmt::LowerExp::fmt(&val, f),
_ => Err(fmt::Error),
}
}
fn fmt_upper_exp(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Int(val) => fmt::UpperExp::fmt(&val, f),
Self::Float(val) => fmt::UpperExp::fmt(&val, f),
Self::Decimal(val) => fmt::UpperExp::fmt(&val, f),
_ => Err(fmt::Error),
}
}
fn to_usize(&self) -> Result<usize, ()> {
match self {
Variant::Int(val) => (*val).try_into().map_err(|_| ()),
_ => Err(()),
}
}
}

620
src/cli/importer.rs Normal file
View File

@ -0,0 +1,620 @@
use std::{
collections::{HashMap, HashSet},
fmt::format,
fs::{File, OpenOptions},
io::{Write, stdout},
path::Path,
};
use crate::cli::{config::ImporterConfig, fmt::Variant};
use super::{commands::Import, config::Config};
use accounting_rust::{
core::{Amount, CoreError, Ledger},
parser::decimal,
};
use chrono::NaiveDate;
use crossterm::{ExecutableCommand, cursor::MoveToPreviousLine, terminal};
use inquire::{
Autocomplete, Confirm, InquireError, Text, required, validator::{ErrorMessage, Validation}
};
use rt_format::ParsedFormat;
use rust_decimal::Decimal;
#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct Mapping {
original_description: String,
account: Option<String>,
payee: Option<String>,
narration: Option<String>,
}
#[derive(Clone)]
struct Accounts {
accounts: Vec<String>,
}
#[derive(Clone)]
struct Payees {
payees: Vec<String>,
}
#[derive(Clone)]
struct Narrations {
narrations: Vec<String>,
}
pub fn import(ledger: &Ledger, config: &Config, args: Import) -> Result<(), CoreError> {
let import_config = config
.importers
.get(&args.config)
.ok_or(CoreError::from("Config not found"))?;
let mut mappings = get_mappings(&import_config);
let file_to_read = Path::new(&args.file_path);
let mut reader = csv::ReaderBuilder::new()
.flexible(true)
.has_headers(false)
.from_path(file_to_read)
.unwrap();
let output_path = Path::new(&import_config.output_file);
let mut file: File = File::options()
.append(true)
.create(true)
.open(output_path)
.unwrap();
writeln!(&mut file, "\n").unwrap();
// Needed to add bottom space for prompts
// Extra line needed because enter key after prompt
print!("\n\n\n\n");
clear_lines(4);
let mut records: Vec<Result<csv::StringRecord, csv::Error>> = reader
.records()
.skip((import_config.first_row.unwrap_or(1) - 1) as usize)
.collect();
if import_config.reverse_order.unwrap_or(false) {
records.reverse();
}
for record in records {
let transaction = parse_record(
record.map_err(|e| CoreError::from(e.to_string()))?,
ledger,
import_config,
&mut mappings,
);
if let Some(transaction) = transaction {
let mut file: File = File::options()
.append(true)
.create(true)
.open(output_path)
.unwrap();
write!(&mut file, "{}", transaction).unwrap();
}
}
Ok(())
}
fn clear_lines(count: u16) {
let mut stdout = stdout();
stdout.execute(MoveToPreviousLine(count)).unwrap();
stdout
.execute(terminal::Clear(terminal::ClearType::FromCursorDown))
.unwrap();
}
fn parse_record(
record: csv::StringRecord,
ledger: &Ledger,
import_config: &ImporterConfig,
mappings: &mut Vec<Mapping>,
) -> Option<String> {
let mut lines_to_clear = 0;
let date = import_config
.column_date
.and_then(|col| record.get((col - 1) as usize))
.filter(|val| val.trim().len() != 0);
let description = import_config
.column_description
.and_then(|col| record.get((col - 1) as usize))
.unwrap_or("");
let amount = import_config
.column_amount
.and_then(|col| record.get((col.abs() - 1) as usize))
.filter(|val| val.trim().len() != 0);
let mut unit_symbol = import_config
.column_unit
.and_then(|col| record.get((col - 1) as usize).map(|c| c.to_string()))
.filter(|val| val.trim().len() != 0);
let mut price_total = import_config
.column_price_total
.and_then(|col| record.get((col.abs() - 1) as usize))
.filter(|val| val.trim().len() != 0)
.and_then(|val| {
decimal(val).ok().map(|val| {
if let Some(column_price_total) = import_config.column_price_total
&& column_price_total < 0
{
val.1 * Decimal::NEGATIVE_ONE
} else {
val.1
}
})
});
let top_match = find_top_mapping(&description, mappings);
let match_to_use = top_match.filter(|m| m.1 >= 0.8).map(|m| m.0);
println!(
"{} {:?} {}{}{}",
date.unwrap_or(""),
description,
amount.unwrap_or(""),
unit_symbol
.as_ref()
.and_then(|s| Some(format!(" ({s})")))
.unwrap_or("".to_string()),
price_total
.and_then(|t| Some(format!(" @@ {t}")))
.unwrap_or("".to_string()),
);
lines_to_clear += 1;
let Some(date) = date else {
Confirm::new("Skip row without date?")
.with_default(true)
.prompt()
.unwrap();
lines_to_clear += 1;
clear_lines(lines_to_clear);
return None;
};
let Ok(date) = NaiveDate::parse_from_str(
date,
import_config
.date_format
.as_ref()
.map(|v| v.as_str())
.unwrap_or("%Y-%m-%d"),
) else {
Confirm::new("Skip row with invalid date?")
.with_default(true)
.prompt()
.unwrap();
lines_to_clear += 1;
clear_lines(lines_to_clear);
return None;
};
let Some(amount) = amount else {
Confirm::new("Skip row without amount?")
.with_default(true)
.prompt()
.unwrap();
lines_to_clear += 1;
clear_lines(lines_to_clear);
return None;
};
let Ok(amount) = decimal(amount) else {
Confirm::new("Skip with invalid amount?")
.with_default(true)
.prompt()
.unwrap();
lines_to_clear += 1;
clear_lines(lines_to_clear);
return None;
};
let mut amount = amount.1;
if let Some(column_amount) = import_config.column_amount
&& column_amount < 0
{
amount *= Decimal::NEGATIVE_ONE;
}
if let Some(price_total_unwrap) = price_total {
if amount.is_zero() {
amount = price_total_unwrap;
unit_symbol = import_config.unit.clone();
price_total = None;
} else {
price_total = Some(price_total_unwrap.abs());
}
}
if let Some(match_to_use) = match_to_use {
if let Some(match_account) = &match_to_use.account {
let skip = loop {
let skip = Confirm::new(&format!("Enter {:.0}% match?", top_match.unwrap().1 * 100.))
.with_help_message(&format!(
"Account: {}\n Payee: {}\n Narration: {} ",
match_account,
match_to_use.payee.as_ref().unwrap_or(&"".to_string()),
match_to_use.narration.as_ref().unwrap_or(&"".to_string())
))
.with_default(true)
.prompt();
match skip {
Ok(s) => break s,
Err(InquireError::OperationCanceled) => {},
_ => panic!()
}
clear_lines(1);
};
// .unwrap_or(true);
lines_to_clear += 1;
if skip {
clear_lines(lines_to_clear);
return Some(
format_transaction(
ledger,
&import_config,
date,
amount,
unit_symbol,
price_total,
match_account.clone(),
match_to_use.payee.clone(),
match_to_use.narration.clone(),
)
.unwrap(),
);
}
} else {
let skip = loop {
let skip = Confirm::new(&format!("Skip {:.1}% match?", top_match.unwrap().1 * 100.))
.with_default(true)
.prompt();
match skip {
Ok(s) => break s,
Err(InquireError::OperationCanceled) => {},
_ => panic!()
}
clear_lines(1);
};
lines_to_clear += 1;
if skip {
clear_lines(lines_to_clear);
return None;
}
}
}
let mut account = Text::new("Account?");
if let Some(match_to_use) = match_to_use
&& let Some(match_account) = &match_to_use.account
{
account = account.with_default(&match_account);
}
let account = account
.with_autocomplete(Accounts::new(ledger))
.with_validator(|v: &str| {
if v.trim().len() == 0 {
Ok(Validation::Invalid(ErrorMessage::Custom(
"Account cannot be empty, press escape to skip row".into(),
)))
} else if v.contains(' ') {
Ok(Validation::Invalid(ErrorMessage::Custom(
"Account cannot contain spaces".into(),
)))
} else {
Ok(Validation::Valid)
}
})
.prompt_skippable()
.unwrap();
lines_to_clear += 1;
let Some(account) = account else {
clear_lines(lines_to_clear);
if let Some(top_match) = top_match
&& top_match.1 == 1.
{
} else {
add_mapping(
Mapping {
original_description: description.to_string(),
account: None,
payee: None,
narration: None,
},
mappings,
import_config,
);
}
return None;
};
let mut payee = Text::new("Payee?");
if let Some(match_to_use) = match_to_use
&& let Some(match_payee) = &match_to_use.payee
{
payee = payee.with_default(&match_payee);
}
let payee = payee
.with_autocomplete(Payees::new(ledger))
.with_validator(|p: &str| {
if p.contains('|') {
Ok(Validation::Invalid(ErrorMessage::Custom(
"Payee cannot contain '|'".into(),
)))
} else {
Ok(Validation::Valid)
}
})
.prompt_skippable()
.unwrap();
lines_to_clear += 1;
let payee = payee.filter(|v| v.trim().len() != 0);
let narration = Text::new("Narration?")
.with_autocomplete(Narrations::new(ledger))
.with_validator(|p: &str| {
if p.contains('|') {
Ok(Validation::Invalid(ErrorMessage::Custom(
"Payee cannot contain '|'".into(),
)))
} else {
Ok(Validation::Valid)
}
})
.prompt_skippable()
.unwrap();
lines_to_clear += 1;
let narration = narration.filter(|v| v.trim().len() != 0);
clear_lines(lines_to_clear);
if let Some(top_match) = top_match
&& top_match.1 == 1.
{
} else {
add_mapping(
Mapping {
original_description: description.to_string(),
account: Some(account.clone()),
payee: payee.clone(),
narration: narration.clone(),
},
mappings,
import_config,
);
}
Some(
format_transaction(
ledger,
&import_config,
date,
amount,
unit_symbol,
price_total,
account,
payee,
narration,
)
.unwrap(),
)
}
fn format_transaction(
ledger: &Ledger,
import_config: &ImporterConfig,
date: NaiveDate,
amount: Decimal,
unit_symbol: Option<String>,
price_total: Option<Decimal>,
account: String,
payee: Option<String>,
narration: Option<String>,
) -> Result<String, ()> {
let unit_symbol = unit_symbol.or(import_config.unit.clone()).ok_or(())?;
let unit = ledger.get_unit_by_symbol(&unit_symbol);
let amount = if let Some(unit) = unit {
// TODO: pad to decimal point
ledger
.format_amount(&Amount { unit_id: unit.get_id(), value: amount })
.unwrap()
.0
} else {
format!("{} {}", amount, unit_symbol)
};
let price_total = if let Some(price_total) = price_total {
let unit = ledger
.get_unit_by_symbol(import_config.unit.as_ref().unwrap())
.unwrap();
let price_total_str = ledger
.format_amount(&Amount { unit_id: unit.get_id(), value: price_total })
.unwrap()
.0;
format!("@@ {}", price_total_str)
} else {
"".to_string()
};
let args = HashMap::from([
("date", Variant::Date(date)),
("amount", Variant::String(amount)),
("price_total", Variant::String(price_total)),
("account", Variant::String(account)),
("payee", Variant::String(payee.unwrap_or("".into()))),
(
"narration",
Variant::String(narration.map(|v| format!("| {v}")).unwrap_or("".into())),
),
]);
Ok(ParsedFormat::parse(&import_config.template, &[], &args)
.or(Err(()))?
.to_string())
}
fn get_mappings(import_config: &ImporterConfig) -> Vec<Mapping> {
let file_to_read = Path::new(import_config.mapping_file.as_ref().unwrap());
let mut mappings_reader = csv::ReaderBuilder::new()
.flexible(true)
.has_headers(false)
.from_path(file_to_read)
.unwrap();
let mut mappings: Vec<_> = mappings_reader
.deserialize::<Mapping>()
.map(|v| v.unwrap())
.collect();
// Reverse to check newest first
mappings.reverse();
mappings
}
fn add_mapping(new_mapping: Mapping, mappings: &mut Vec<Mapping>, import_config: &ImporterConfig) {
let file_to_append = OpenOptions::new()
.append(true)
.create(true)
.open(Path::new(import_config.mapping_file.as_ref().unwrap()))
.unwrap();
let mut mappings_writer: csv::Writer<File> = csv::WriterBuilder::new()
.has_headers(false)
.from_writer(file_to_append);
mappings_writer.serialize(&new_mapping).unwrap();
// mappings_writer.write();
mappings.push(new_mapping);
}
fn find_top_mapping<'a>(
description: &str,
mappings: &'a Vec<Mapping>,
) -> Option<(&'a Mapping, f64)> {
let description = description.to_lowercase();
let mut best_mapping: Option<(&'a Mapping, f64)> = None;
for mapping in mappings {
let score =
strsim::jaro_winkler(&description, &mapping.original_description.to_lowercase());
if let Some(best_mapping) = best_mapping
&& best_mapping.1 >= score
{
} else {
best_mapping = Some((mapping, score));
}
}
best_mapping
}
impl Accounts {
fn new(ledger: &Ledger) -> Self {
let mut accounts: Vec<_> = ledger
.get_accounts()
.iter()
.map(|a| a.get_name().clone())
.collect();
accounts.sort();
Self { accounts }
}
}
impl Autocomplete for Accounts {
fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, inquire::CustomUserError> {
let input = input.to_lowercase();
Ok(self
.accounts
.iter()
.filter(|a| a.to_lowercase().find(&input).is_some())
.map(|t| t.clone())
.collect())
}
fn get_completion(
&mut self,
input: &str,
highlighted_suggestion: Option<String>,
) -> Result<inquire::autocompletion::Replacement, inquire::CustomUserError> {
let suggestions = self.get_suggestions(input);
if highlighted_suggestion.is_none() {
Ok(suggestions?.get(0).cloned())
} else {
Ok(highlighted_suggestion)
}
}
}
impl Payees {
fn new(ledger: &Ledger) -> Self {
let payees: HashSet<_> = ledger
.get_transactions()
.iter()
.filter_map(|t| t.get_payee().clone())
.collect();
let mut payees: Vec<_> = payees.into_iter().collect();
payees.sort();
Self { payees }
}
}
impl Autocomplete for Payees {
fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, inquire::CustomUserError> {
let input = input.to_lowercase();
Ok(self
.payees
.iter()
.filter(|t| t.to_lowercase().find(&input).is_some())
.map(|t| t.clone())
.collect())
}
fn get_completion(
&mut self,
input: &str,
highlighted_suggestion: Option<String>,
) -> Result<inquire::autocompletion::Replacement, inquire::CustomUserError> {
let suggestions = self.get_suggestions(input);
if highlighted_suggestion.is_none() {
Ok(suggestions?.get(0).cloned())
} else {
Ok(highlighted_suggestion)
}
}
}
impl Narrations {
fn new(ledger: &Ledger) -> Self {
let narrations: HashSet<_> = ledger
.get_transactions()
.iter()
.filter_map(|t| t.get_narration().clone())
.collect();
let mut narrations: Vec<_> = narrations.into_iter().collect();
narrations.sort();
Self { narrations }
}
}
impl Autocomplete for Narrations {
fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, inquire::CustomUserError> {
let input = input.to_lowercase();
Ok(self
.narrations
.iter()
.filter(|t| t.to_lowercase().find(&input).is_some())
.map(|t| t.clone())
.collect())
}
fn get_completion(
&mut self,
input: &str,
highlighted_suggestion: Option<String>,
) -> Result<inquire::autocompletion::Replacement, inquire::CustomUserError> {
let suggestions = self.get_suggestions(input);
if highlighted_suggestion.is_none() {
Ok(suggestions?.get(0).cloned())
} else {
Ok(highlighted_suggestion)
}
}
}

37
src/cli/mod.rs Normal file
View File

@ -0,0 +1,37 @@
use std::path::Path;
use clap::Parser;
use crate::cli::{balance::balance, commands::Commands, importer::import};
use accounting_rust::{core::CoreError, document::Document};
mod balance;
mod commands;
pub mod config;
mod importer;
pub mod fmt;
pub fn run() -> Result<(), CoreError> {
let cli = commands::Cli::parse();
let config_file =
Path::new("/Users/petersonev/Documents/Projects/accounting/ledger-data/config.toml");
let config = config::read_config(config_file)?;
let document = Document::new(Path::new(&config.ledger.path))?;
let balance_check = cli.check || matches!(cli.command, Commands::Check);
let ledger = document.generate_ledger(balance_check)?;
match cli.command {
Commands::Balance(b) => balance(&ledger, b),
Commands::Check => {
println!("{} transactions", ledger.get_transactions().len());
println!("{} prices", ledger.get_prices().len());
println!("{} balances checked", document.get_balance_count());
}
Commands::Import(args) => {
import(&ledger, &config, args)?;
}
}
Ok(())
}

View File

@ -77,7 +77,8 @@ impl Unit {
} }
pub fn get_scale(&self) -> Option<u32> { pub fn get_scale(&self) -> Option<u32> {
self.scale // TODO: probably don't hard code 8 here
self.scale.or(Some(8))
} }
pub fn get_symbols(&self) -> &Vec<UnitSymbol> { pub fn get_symbols(&self) -> &Vec<UnitSymbol> {

View File

@ -60,6 +60,10 @@ impl Ledger {
&self.transactions &self.transactions
} }
pub fn get_prices(&self) -> &Vec<Price> {
&self.prices
}
// Assume prices are sorted by date already // Assume prices are sorted by date already
// For now only trivial conversions, not multiple conversions // For now only trivial conversions, not multiple conversions
pub fn get_price_on_date( pub fn get_price_on_date(
@ -166,7 +170,6 @@ impl Ledger {
} }
pub fn sort_prices(&mut self) { pub fn sort_prices(&mut self) {
println!("price length = {:?}", self.prices.len());
self.prices.sort_by(|a, b| a.date.cmp(&b.date)); self.prices.sort_by(|a, b| a.date.cmp(&b.date));
} }
} }

View File

@ -98,7 +98,7 @@ impl Transaction {
} }
amounts amounts
.iter() .iter()
.filter(|(_, &value)| value != dec!(0)) .filter(|(_, value)| **value != dec!(0))
.map(|(&unit_id, &value)| Amount { value, unit_id }) .map(|(&unit_id, &value)| Amount { value, unit_id })
.collect() .collect()
} }

View File

@ -90,7 +90,7 @@ pub fn check_balance2(ledger: &Ledger, balance: &BalanceDirective) -> Result<(),
let t2 = Instant::now(); let t2 = Instant::now();
let accounts = query::balance(&ledger, Some(&balance.query), None); let accounts = query::balance(&ledger, Some(balance.query.clone()), None);
let t3 = Instant::now(); let t3 = Instant::now();
@ -233,6 +233,7 @@ pub fn check_balance2(ledger: &Ledger, balance: &BalanceDirective) -> Result<(),
// Ok(()) // Ok(())
// } // }
#[derive(Debug)]
struct IncompletePosting { struct IncompletePosting {
account_id: u32, account_id: u32,
amount: Option<Amount>, amount: Option<Amount>,
@ -300,7 +301,7 @@ fn complete_incomplete_postings(
} }
if let Some(account_id) = incomplete_posting_account_id { if let Some(account_id) = incomplete_posting_account_id {
for (&unit_id, &value) in amounts.iter().filter(|(_, &v)| v != dec!(0)) { for (&unit_id, &value) in amounts.iter().filter(|(_, v)| **v != dec!(0)) {
let mut value = -value; let mut value = -value;
if let Some(scale) = ledger.get_unit(unit_id).and_then(|u| u.get_scale()) { if let Some(scale) = ledger.get_unit(unit_id).and_then(|u| u.get_scale()) {
value = value.round_dp(scale); value = value.round_dp(scale);

View File

@ -4,7 +4,11 @@ mod ledger;
pub use directives::*; pub use directives::*;
use ledger::{add_transaction, check_balance2}; use ledger::{add_transaction, check_balance2};
use crate::{core::{CoreError, Ledger, Unit}, document::ledger::add_price, parser::parse_directives}; use crate::{
core::{CoreError, Ledger, Unit},
document::ledger::add_price,
parser::parse_directives,
};
use std::{path::Path, time::Instant}; use std::{path::Path, time::Instant};
#[derive(Debug)] #[derive(Debug)]
@ -26,7 +30,11 @@ impl Document {
Ok(document) Ok(document)
} }
pub fn generate_ledger(&self) -> Result<Ledger, CoreError> { pub fn get_balance_count(&self) -> usize {
self.directives.balances.len()
}
pub fn generate_ledger(&self, check_balance: bool) -> Result<Ledger, CoreError> {
let mut ledger = Ledger::new(); let mut ledger = Ledger::new();
for commodity in &self.directives.commodities { for commodity in &self.directives.commodities {
@ -45,11 +53,13 @@ impl Document {
ledger.sort_prices(); ledger.sort_prices();
let start = Instant::now(); let start = Instant::now();
if check_balance {
for balance in &self.directives.balances { for balance in &self.directives.balances {
check_balance2(&ledger, &balance)?; check_balance2(&ledger, &balance)?;
} }
}
let end = Instant::now(); let end = Instant::now();
println!("time to calculate balance: {:?}", end - start); // println!("time to calculate balance: {:?}", end - start);
Ok(ledger) Ok(ledger)
// for balance in self.directives.balances { // for balance in self.directives.balances {

187
src/main.backup.rs Normal file
View File

@ -0,0 +1,187 @@
use std::{
io::{self, Write},
path::Path,
time::Instant,
};
use accounting_rust::{
document::Document,
output::cli::{format_balance, tui_to_ansi::text_to_ansi},
parser::{self, query},
query::{self, PostingField},
// queries::{self, base::{self, DataValue, Query}, functions::{ComparisonFunction, LogicalFunction, SubAccountFunction}, transaction::{AccountField, PostingField, TransactionField}},
};
use chrono::{NaiveDate, Utc};
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 t1 = Instant::now();
let document = Document::new(Path::new("/Users/petersonev/Documents/Projects/accounting/ledger-data/main.ledger")).unwrap();
let t2 = Instant::now();
let ledger = document.generate_ledger().unwrap();
let t3 = Instant::now();
// let balance = queries::balance(
// &ledger,
// &[],
// );
// let balance = queries::balance2(
// &ledger,
// NaiveDate::from_ymd_opt(2100, 01, 01).unwrap(),
// Some("$")
// );
// let balance_query = "transaction.date < 2100-01-01";
let balance_query = "account.name ~ 'Assets' OR account.name ~ 'Liabilities'";
let parsed_query = parser::query::<PostingField>(balance_query).unwrap();
if parsed_query.0.trim().len() != 0 {
panic!("Full string not consumed")
}
let balance_query = parsed_query.1;
let current_date = Utc::now().date_naive();
let balance = query::balance(&ledger, Some(&balance_query), Some(("$", current_date)));
// let date_query = ComparisonFunction::new(
// "<=",
// Query::from_field(PostingField::Transaction(TransactionField::Date)),
// Query::from(DataValue::from(NaiveDate::from_ymd_opt(2100, 01, 01).unwrap())),
// ).unwrap();
// let account_query = SubAccountFunction::new(
// "Assets".into(),
// base::Query::from_field(PostingField::Account(AccountField::Name)),
// );
// let total_query = LogicalFunction::new(
// "and",
// base::Query::from_fn(date_query),
// base::Query::from_fn(account_query),
// ).unwrap();
// let balance = queries::balance3(
// &ledger,
// &base::Query::from_fn(total_query),
// );
let t4 = Instant::now();
format_balance(&ledger, &balance);
let t5 = Instant::now();
println!("{:?} - {:?} - {:?} - {:?}", t2-t1, t3-t2, t4-t3, t5-t4);
// 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);
return Ok(());
}

View File

@ -1,187 +1,70 @@
use std::{ // use std::path::PathBuf;
io::{self, Write},
path::Path, use crossterm::{
time::Instant, ExecutableCommand, QueueableCommand,
cursor::{self, MoveToPreviousLine},
style::{self, Stylize},
terminal,
}; };
use std::{collections::HashMap, default, io::stdout, path::Path};
use accounting_rust::{ use chrono::{Datelike, NaiveDate};
document::Document, use inquire::{CustomType, Text};
output::cli::{format_balance, tui_to_ansi::text_to_ansi}, use rt_format::ParsedFormat;
parser::{self, query},
query::{self, PostingField},
// queries::{self, base::{self, DataValue, Query}, functions::{ComparisonFunction, LogicalFunction, SubAccountFunction}, transaction::{AccountField, PostingField, TransactionField}},
};
use chrono::{NaiveDate, Utc};
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}; use crate::cli::fmt::Variant;
pub fn main() -> Result<(), Box<dyn std::error::Error>> { mod cli;
// 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(); fn main() {
cli::run().unwrap();
// let f = Frame:: // let skip = inquire::Confirm::new("Test?")
// .with_help_message("Account: abc\n Payee: def\n Narration: sdfsdf")
// .with_default(true)
// .prompt()
// .unwrap();
// println!( // let score = strsim::jaro_winkler(
// "{}{}", // &"KEYSIGHT TECHNOL DES:DIRECT DEP ID:XXXXX4194068UTV INDN:PETERSON,EVAN J CO ID:XXXXX11101 PPD".to_lowercase(),
// span_to_ansi(&Span::raw("Hello ")), // &"KEYSIGHT TECHNOL DES:DIRECT DEP ID:XXXXX6001478UTV INDN:PETERSON,EVAN J CO ID:XXXXX11101 PPD".to_lowercase(),
// span_to_ansi(&Span::styled(
// "World",
// Style::new().fg(Color::Green).bg(Color::White)
// ))
// ); // );
// // // let score = strsim::levenshtein("abc", "");
// println!("{score}");
// let stdout = io::stdout(); // // nucleo::Nucleo::new(nucleo::Config::DEFAULT, notify, num_threads, columns);
// let backend = CrosstermBackend::new(stdout); // let mut matcher = nucleo_matcher::Matcher::new(nucleo_matcher::Config::DEFAULT);
// let mut terminal = Terminal::new(backend)?;
// let line = Line::from(vec![ // let pattern = nucleo_matcher::pattern::Pattern::new(
// Span::raw("Hello "), // "Daniel Realty DES:RENTAL ID:XXXXX2645",
// Span::styled("Hello ", Style::new().fg(Color::Rgb(100, 200, 150))), // nucleo_matcher::pattern::CaseMatching::Ignore, // Ignore case for matching
// Span::styled("World", Style::new().fg(Color::Green).bg(Color::White)), // nucleo_matcher::pattern::Normalization::Smart, // Use smart normalization
// ]) // nucleo_matcher::pattern::AtomKind::Fuzzy // Use the fuzzy matching algorithm
// .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 t1 = Instant::now();
let document = Document::new(Path::new("data/full/main.ledger")).unwrap();
let t2 = Instant::now();
let ledger = document.generate_ledger().unwrap();
let t3 = Instant::now();
// let balance = queries::balance(
// &ledger,
// &[],
// ); // );
// let mut buf = Vec::new();
// let score = pattern.score(nucleo_matcher::Utf32Str::new("Daniel Realty DES:RENTAL ID:XXXXX8868", &mut buf), &mut matcher);
// println!("{score:?}");
// let balance = queries::balance2( // let matches = pattern.match_list(&items_to_match, &mut matcher);
// &ledger, // matcher
// NaiveDate::from_ymd_opt(2100, 01, 01).unwrap(),
// Some("$")
// );
// let balance_query = "transaction.date < 2100-01-01"; // let args = HashMap::from([("abc", Variant::String("aaa".into()))]);
let balance_query = "account.name ~ 'Assets' OR account.name ~ 'Liabilities'"; // let out = ParsedFormat::parse("{abc:>8} def", &[], &args).unwrap().to_string();
// println!("{out}");
let parsed_query = parser::query::<PostingField>(balance_query).unwrap(); // let a = "{abc} def".format(args);
if parsed_query.0.trim().len() != 0 {
panic!("Full string not consumed") // let mut stdout = stdout();
}
let balance_query = parsed_query.1; // let date: NaiveDate = CustomType::new("Date:")
// .with_error_message("Please type a valid date")
let current_date = Utc::now().date_naive(); // .with_default(NaiveDate::from_ymd_opt(2000, 01, 01).unwrap())
// .prompt()
let balance = query::balance(&ledger, Some(&balance_query), Some(("$", current_date))); // .unwrap();
// let date_query = ComparisonFunction::new( // stdout.execute(MoveToPreviousLine(1)).unwrap();
// "<=", // stdout.execute(terminal::Clear(terminal::ClearType::FromCursorDown)).unwrap();
// Query::from_field(PostingField::Transaction(TransactionField::Date)),
// Query::from(DataValue::from(NaiveDate::from_ymd_opt(2100, 01, 01).unwrap())), // // let name = Text::new("What is your name?").with_help_message("Help message").prompt();
// ).unwrap(); // println!("{:?}", date.year());
// let account_query = SubAccountFunction::new(
// "Assets".into(),
// base::Query::from_field(PostingField::Account(AccountField::Name)),
// );
// let total_query = LogicalFunction::new(
// "and",
// base::Query::from_fn(date_query),
// base::Query::from_fn(account_query),
// ).unwrap();
// let balance = queries::balance3(
// &ledger,
// &base::Query::from_fn(total_query),
// );
let t4 = Instant::now();
format_balance(&ledger, &balance);
let t5 = Instant::now();
println!("{:?} - {:?} - {:?} - {:?}", t2-t1, t3-t2, t4-t3, t5-t4);
// 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);
return Ok(());
} }

View File

@ -2,8 +2,8 @@ use crate::core::{Amount, Ledger};
impl Ledger { impl Ledger {
/// Returns formatted string and position of decimal point in string /// Returns formatted string and position of decimal point in string
pub fn format_amount(&self, amount: &Amount) -> (String, usize) { pub fn format_amount(&self, amount: &Amount) -> Result<(String, usize), ()> {
let unit = self.get_unit(amount.unit_id).unwrap(); let unit = self.get_unit(amount.unit_id).ok_or(())?;
let default_symbol = unit.default_symbol(); let default_symbol = unit.default_symbol();
let amount = self.round_amount(&amount); let amount = self.round_amount(&amount);
@ -16,13 +16,13 @@ impl Ledger {
let value = amount.value.abs().to_string(); let value = amount.value.abs().to_string();
let mut split = value.split("."); let mut split = value.split(".");
let mut value = split.next().unwrap() let mut value = split.next().ok_or(())?
.as_bytes() .as_bytes()
.rchunks(3) .rchunks(3)
.rev() .rev()
.map(std::str::from_utf8) .map(std::str::from_utf8)
.collect::<Result<Vec<&str>, _>>() .collect::<Result<Vec<&str>, _>>()
.unwrap() .or(Err(()))?
.join(","); .join(",");
let value_decimal_pos = value.len(); let value_decimal_pos = value.len();
@ -33,13 +33,13 @@ impl Ledger {
if default_symbol.is_prefix { if default_symbol.is_prefix {
let decimal_pos = sign.len() + default_symbol.symbol.len() + value_decimal_pos; let decimal_pos = sign.len() + default_symbol.symbol.len() + value_decimal_pos;
(format!("{}{}{}", sign, default_symbol.symbol, value), decimal_pos) Ok((format!("{}{}{}", sign, default_symbol.symbol, value), decimal_pos))
} else { } else {
let decimal_pos = sign.len() + value_decimal_pos; let decimal_pos = sign.len() + value_decimal_pos;
if default_symbol.symbol.len() == 1 { if default_symbol.symbol.len() == 1 {
(format!("{}{}{}", sign, value, default_symbol.symbol), decimal_pos) Ok((format!("{}{}{}", sign, value, default_symbol.symbol), decimal_pos))
} else { } else {
(format!("{}{} {}", sign, value, default_symbol.symbol), decimal_pos) Ok((format!("{}{} {}", sign, value, default_symbol.symbol), decimal_pos))
} }
} }
} }

View File

@ -231,7 +231,7 @@ fn tree_to_text(tree: &BalanceTreeStr, ledger: &Ledger, base_amount_pos: usize,
// } // }
fn balance_tree_to_str_tree(tree: BalanceTree, ledger: &Ledger) -> BalanceTreeStr { fn balance_tree_to_str_tree(tree: BalanceTree, ledger: &Ledger) -> BalanceTreeStr {
let amounts = tree.amounts.map(|v| v.iter().map(|a| ledger.format_amount(a)).collect()); let amounts = tree.amounts.map(|v| v.iter().map(|a| ledger.format_amount(a).unwrap()).collect());
let children = tree.children.into_iter().map(|c| balance_tree_to_str_tree(c, ledger)).collect(); let children = tree.children.into_iter().map(|c| balance_tree_to_str_tree(c, ledger)).collect();
BalanceTreeStr{amounts, name: tree.name, children} BalanceTreeStr{amounts, name: tree.name, children}

View File

@ -1,21 +1,26 @@
use nom::{ use nom::{
IResult, Parser,
branch::alt, branch::alt,
character::complete::{none_of, one_of, space0}, character::complete::{none_of, one_of, space0},
combinator::{opt, recognize}, combinator::{opt, recognize},
multi::many1, multi::many1,
sequence::{preceded, tuple}, IResult, Parser, sequence::{preceded, tuple},
}; };
use rust_decimal_macros::dec; use rust_decimal_macros::dec;
use super::{decimal, quoted_string};
use crate::core::RawAmount; use crate::core::RawAmount;
use super::decimal;
pub fn amount(input: &str) -> IResult<&str, RawAmount> { pub fn amount(input: &str) -> IResult<&str, RawAmount> {
alt((suffix_amount, prefix_amount)).parse(input) alt((suffix_amount, prefix_amount)).parse(input)
} }
pub fn unit(input: &str) -> IResult<&str, &str> { pub fn unit(input: &str) -> IResult<&str, &str> {
recognize(many1(none_of("0123456789,+-_()*/.{} \t"))).parse(input) alt((
quoted_string,
recognize(many1(none_of("0123456789,+-_()*/.{} \t"))),
))
.parse(input)
} }
/////////////// ///////////////

View File

@ -114,26 +114,38 @@ fn posting(input: &str) -> IResult<&str, DirectivePosting> {
amount = Some(v.0); amount = Some(v.0);
if let Some(c) = v.1.0 { if let Some(c) = v.1.0 {
if c.1 { if c.1 {
let amount = amount.as_ref().unwrap().value.abs();
if amount.is_zero() {
// TODO: this isn't really right
cost = None;
} else {
cost = Some(RawCost { cost = Some(RawCost {
amount: RawAmount { amount: RawAmount {
value: c.0.amount.value / amount.as_ref().unwrap().value.abs(), value: c.0.amount.value / amount,
unit_symbol: c.0.amount.unit_symbol, unit_symbol: c.0.amount.unit_symbol,
is_unit_prefix: c.0.amount.is_unit_prefix, is_unit_prefix: c.0.amount.is_unit_prefix,
}, },
date: c.0.date, date: c.0.date,
label: c.0.label, label: c.0.label,
}); });
}
} else { } else {
cost = Some(c.0); cost = Some(c.0);
} }
} }
if let Some(p) = v.1.1 { if let Some(p) = v.1.1 {
if p.1 { if p.1 {
let amount = amount.as_ref().unwrap().value.abs();
if amount.is_zero() {
// TODO: this isn't really right
price = None;
} else {
price = Some(RawAmount { price = Some(RawAmount {
value: p.0.value / amount.as_ref().unwrap().value.abs(), value: p.0.value / amount,
unit_symbol: p.0.unit_symbol, unit_symbol: p.0.unit_symbol,
is_unit_prefix: p.0.is_unit_prefix, is_unit_prefix: p.0.is_unit_prefix,
}); });
}
} else { } else {
price = Some(p.0); price = Some(p.0);
} }

View File

@ -10,7 +10,7 @@ use crate::core::{Amount, Ledger};
pub fn balance( pub fn balance(
ledger: &Ledger, ledger: &Ledger,
filter: Option<&Query<PostingField>>, filter: Option<Query<PostingField>>,
convert_to_unit: Option<(&str, NaiveDate)>, convert_to_unit: Option<(&str, NaiveDate)>,
) -> HashMap<u32, Vec<Amount>> { ) -> HashMap<u32, Vec<Amount>> {
let convert_to_unit = convert_to_unit.map(|u| (ledger.get_unit_by_symbol(u.0).unwrap(), u.1)); let convert_to_unit = convert_to_unit.map(|u| (ledger.get_unit_by_symbol(u.0).unwrap(), u.1));
@ -29,7 +29,7 @@ pub fn balance(
let filter = match filter { let filter = match filter {
Some(filter) => filter, Some(filter) => filter,
None => &Query::Value(true.into()), None => Query::Value(true.into()),
}; };
let filtered_postings = postings.filter(|data| { let filtered_postings = postings.filter(|data| {