pub mod balance;
pub mod error;
pub use error::{BookError, BookErrorKind};
use std::collections::HashMap;
use std::sync::Arc;
use crate::decimal::Decimal;
use crate::parser::located::Located;
use crate::parser::posting::{Amount, Posting};
use crate::parser::transaction::Transaction;
pub fn book(
transactions: Vec<Located<Transaction>>,
) -> Result<Vec<Located<Transaction>>, BookError> {
let mut balances: HashMap<(String, String), Decimal> = HashMap::new();
let mut result = Vec::with_capacity(transactions.len());
for Located { file, line, mut value } in transactions {
let end_line = value
.postings
.iter()
.map(|p| p.line)
.max()
.unwrap_or(line);
for lp in &mut value.postings {
resolve_assignment(&mut lp.value, &balances);
}
balance::balance_tx(&mut value, &file, line, end_line)?;
for lp in &value.postings {
apply_and_check(&lp.value, &file, line, end_line, &mut balances)?;
}
result.push(Located { file, line, value });
}
Ok(result)
}
fn resolve_assignment(
posting: &mut Posting,
balances: &HashMap<(String, String), Decimal>,
) {
let (amount, assertion) = (&posting.amount, &posting.balance_assertion);
if amount.is_some() {
return;
}
let Some(target) = assertion else {
return;
};
let running = balances
.get(&(posting.account.clone(), target.commodity.clone()))
.copied()
.unwrap_or_else(Decimal::zero);
let diff = target.value - running;
posting.amount = Some(Amount {
commodity: target.commodity.clone(),
value: diff,
decimals: target.decimals,
});
}
fn apply_and_check(
posting: &Posting,
file: &Arc<str>,
start_line: usize,
end_line: usize,
balances: &mut HashMap<(String, String), Decimal>,
) -> Result<(), BookError> {
let Some(amt) = &posting.amount else {
return Ok(());
};
let key = (posting.account.clone(), amt.commodity.clone());
*balances.entry(key).or_insert_with(Decimal::zero) += amt.value;
if let Some(target) = &posting.balance_assertion {
let running = balances
.get(&(posting.account.clone(), target.commodity.clone()))
.copied()
.unwrap_or_else(Decimal::zero);
if running != target.value {
return Err(BookError::new(
file.clone(),
start_line,
end_line,
BookErrorKind::AssertionFailed {
account: posting.account.clone(),
expected: target.value,
got: running,
commodity: target.commodity.clone(),
decimals: target.decimals,
},
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser;
use crate::resolver;
fn pipeline(src: &str) -> Result<Vec<Located<Transaction>>, BookError> {
let entries = parser::parse(src).unwrap();
let resolved = resolver::resolve(entries).unwrap();
book(resolved.transactions)
}
#[test]
fn assignment_infers_amount_to_reach_target() {
let src = "2024-01-01 * Opening\n assets:bank = 100 USD\n equity:opening\n";
let out = pipeline(src).unwrap();
let bank = &out[0].value.postings[0].value;
assert_eq!(bank.amount.as_ref().unwrap().value, Decimal::from(100));
let equity = &out[0].value.postings[1].value;
assert_eq!(equity.amount.as_ref().unwrap().value, Decimal::from(-100));
}
#[test]
fn assignment_respects_prior_balance() {
let src = "2024-01-01 * Initial\n assets:bank 40 USD\n equity:opening -40 USD\n\
2024-01-05 * Adjust to target\n assets:bank = 100 USD\n equity:adjust\n";
let out = pipeline(src).unwrap();
let adjust = &out[1].value.postings[0].value;
assert_eq!(adjust.amount.as_ref().unwrap().value, Decimal::from(60));
}
#[test]
fn assignment_respects_inferred_prior_balance() {
let src = "2024-01-01 * A\n equity:opening -100 USD\n assets:bank\n\
2024-01-02 * B\n assets:bank = 100 USD\n equity:adjust\n";
let out = pipeline(src).unwrap();
let tx_b_bank = &out[1].value.postings[0].value;
assert_eq!(tx_b_bank.amount.as_ref().unwrap().value, Decimal::zero());
}
#[test]
fn assertion_passes_when_balance_matches() {
let src = "2024-01-01 * Deposit\n assets:bank 100 USD = 100 USD\n equity:opening -100 USD\n";
assert!(pipeline(src).is_ok());
}
#[test]
fn assertion_fails_on_mismatch() {
let src = "2024-01-01 * Deposit\n assets:bank 100 USD = 999 USD\n equity:opening -100 USD\n";
let err = pipeline(src).unwrap_err();
match err.kind {
BookErrorKind::AssertionFailed { ref account, .. } => {
assert_eq!(account, "assets:bank");
}
other => panic!("expected AssertionFailed, got {:?}", other),
}
}
#[test]
fn assertion_after_multiple_transactions() {
let src = "2024-01-01 * A\n assets:bank 50 USD\n equity:opening -50 USD\n\
2024-01-02 * B\n assets:bank 30 USD = 80 USD\n equity:other -30 USD\n";
assert!(pipeline(src).is_ok());
}
#[test]
fn independent_accounts_track_separately() {
let src = "2024-01-01 * A\n assets:bank 100 USD\n equity:opening -100 USD\n\
2024-01-02 * B\n assets:cash 50 USD = 50 USD\n equity:opening -50 USD\n";
assert!(pipeline(src).is_ok());
}
#[test]
fn commodity_tracked_separately_per_account() {
let src = "2024-01-01 * A\n assets:bank 100 USD\n equity:a -100 USD\n\
2024-01-02 * B\n assets:bank 50 EUR = 50 EUR\n equity:b -50 EUR\n";
assert!(pipeline(src).is_ok());
}
}