use std::{collections::BTreeMap, fmt::Display};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::{
ast::{self, AmountDetails, ValueExpr},
resolution,
};
#[derive(Debug, Serialize, Deserialize)]
pub struct Journal {
pub transactions: Vec<ResolvedTransaction>,
pub accounts: BTreeMap<String, AccountProperties>,
pub prices: Vec<HistoricalPrice>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HistoricalPrice {
pub date: i32,
pub time: Option<String>,
pub commodity: String,
pub price: Decimal,
pub price_commodity: Commodity,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct AccountProperties {
pub note: Option<String>,
}
#[derive(Default, Clone, Debug)]
struct AccountBalances {
commodity: BTreeMap<String, Decimal>,
}
#[derive(Default, Clone, Debug)]
struct RunningState {
account_balances: BTreeMap<String, AccountBalances>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ResolvedTransaction {
pub date: i32,
pub secondary_date: Option<i32>,
pub state: TransactionState,
pub code: Option<String>,
pub description: String,
pub tags: Vec<String>,
pub metadata: BTreeMap<String, String>,
pub postings: Vec<ResolvedPosting>,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct ResolvedPosting {
pub account: String,
pub payee: String,
pub amount: Amount,
pub state: TransactionState,
pub tags: Vec<String>,
pub metadata: BTreeMap<String, String>,
}
pub type Commodity = String;
#[derive(Default, Debug)]
pub struct Amount(pub BTreeMap<Commodity, Decimal>);
impl serde::Serialize for Amount {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let bytes_map: BTreeMap<&Commodity, [u8; 16]> =
self.0.iter().map(|(k, v)| (k, v.serialize())).collect();
bytes_map.serialize(s)
}
}
impl<'de> serde::Deserialize<'de> for Amount {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let bytes_map = BTreeMap::<Commodity, [u8; 16]>::deserialize(d)?;
Ok(Amount(
bytes_map
.into_iter()
.map(|(k, v)| (k, Decimal::deserialize(v)))
.collect(),
))
}
}
#[derive(Deserialize, Serialize, Debug)]
pub enum TransactionState {
Uncleared,
Pending,
Cleared,
}
impl From<ast::TransactionState> for TransactionState {
fn from(f: ast::TransactionState) -> TransactionState {
match f {
ast::TransactionState::Uncleared => TransactionState::Uncleared,
ast::TransactionState::Pending => TransactionState::Pending,
ast::TransactionState::Cleared => TransactionState::Cleared,
}
}
}
#[derive(Debug)]
pub enum ElaborationError {
AmountWithNoCommodity,
NonAmountWhereAmountExpected(ValueExpr),
EvaluationError(EvaluationError),
PostingBalanceAssertionFailed,
BalanceAssertionFailed {
account: String,
date: chrono::NaiveDate,
expected_amount: Decimal,
expected_commodity: String,
actual_amount: Decimal,
},
TooManyNullPostings,
TransactionDoesNotBalance(Amount),
}
#[derive(Debug)]
pub enum EvaluationError {
UnaryMultiplyOrDivide,
UnaryOnNonAmount(ValueExpr),
BinaryOperationTypeError((ValueExpr, ValueExpr, crate::ast::Op)),
NoSuchField(String),
FieldAccessTypeError(ValueExpr),
UnknownFunctionArgs((String, Vec<ValueExpr>)),
TypedCommodityToIncompatibleAmount((String, ValueExpr)),
InvalidFunctionArgs((String, ValueExpr)),
}
impl From<EvaluationError> for ElaborationError {
fn from(e: EvaluationError) -> ElaborationError {
ElaborationError::EvaluationError(e)
}
}
impl std::error::Error for ElaborationError {}
impl Display for ElaborationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ElaborationError::AmountWithNoCommodity => {
write!(f, "amount has no commodity and no default commodity is set")
}
ElaborationError::NonAmountWhereAmountExpected(expr) => {
write!(f, "expected an amount but got: {expr:?}")
}
ElaborationError::EvaluationError(e) => {
write!(f, "evaluation error: {e:?}")
}
ElaborationError::PostingBalanceAssertionFailed => {
write!(f, "posting balance assertion failed")
}
ElaborationError::BalanceAssertionFailed {
account,
date,
expected_amount,
expected_commodity,
actual_amount,
} => {
write!(
f,
"balance assertion failed for account {account} on {date}: \
expected {expected_amount} {expected_commodity}, \
actual {actual_amount} {expected_commodity}"
)
}
ElaborationError::TooManyNullPostings => {
write!(f, "transaction has more than one null posting")
}
ElaborationError::TransactionDoesNotBalance(_) => {
write!(f, "transaction does not balance")
}
}
}
}
impl TryFrom<resolution::HIR> for Journal {
type Error = ElaborationError;
fn try_from(value: resolution::HIR) -> Result<Self, Self::Error> {
let mut state = RunningState::default();
let mut transactions = vec![];
let mut accounts = BTreeMap::new();
for (name, properties) in value.global_context.account_properties {
accounts.insert(
name,
AccountProperties {
note: properties.note,
},
);
}
for entry in value.entries {
let entry_context = &value.contexts[entry.context_id];
match entry.data {
resolution::Entry::Assertion(assertion) => {
let (expected_amount, expected_commodity) =
evaluator::eval_and_normalize_amount(
assertion.amount,
entry_context,
&state,
)?;
let actual_amount = state
.account_balances
.get(&assertion.account)
.and_then(|ab| ab.commodity.get(&expected_commodity))
.copied()
.unwrap_or(Decimal::ZERO);
let _ = assertion.strict;
if actual_amount != expected_amount {
return Err(ElaborationError::BalanceAssertionFailed {
account: assertion.account,
date: assertion.date,
expected_amount,
expected_commodity,
actual_amount,
});
}
}
resolution::Entry::Transaction(mut transaction) => {
let mut transaction_state = Amount(BTreeMap::default());
let payee = transaction
.metadata
.remove("payee")
.unwrap_or_else(|| transaction.description.clone());
let mut null_postings = vec![];
let mut resolved_postings = vec![];
for mut posting in transaction.postings {
if let Some(amount) = posting.amount {
let account_name = entry_context
.account_aliases
.get(&posting.account)
.cloned()
.unwrap_or(posting.account);
let account_balance = state.account_balances.get(&account_name);
let (value, commodity, lot_pricing) = match amount {
AmountDetails::Amount {
value,
lot_pricing,
balance_assertion,
} => {
let (value, commodity) = evaluator::eval_and_normalize_amount(
value,
entry_context,
&state,
)?;
let lot_pricing = match lot_pricing {
Some(ast::LotPricing::Total(expr)) => {
let (mut v, c) = evaluator::eval_and_normalize_amount(
expr,
entry_context,
&state,
)?;
if value.is_sign_negative() {
v = -v;
}
Some((v, c))
}
Some(ast::LotPricing::Unit(expr)) => {
let (v, c) = evaluator::eval_and_normalize_amount(
expr,
entry_context,
&state,
)?;
Some((v * value, c))
}
None => None,
};
if let Some(balance_assertion) = balance_assertion {
let (baval, bacommodity) =
evaluator::eval_and_normalize_amount(
balance_assertion,
entry_context,
&state,
)?;
if !(bacommodity == commodity
&& account_balance
.and_then(|ab| ab.commodity.get(&commodity))
.unwrap_or(&Decimal::ZERO)
+ value
== baval)
{
Err(ElaborationError::PostingBalanceAssertionFailed)?;
}
}
(value, commodity, lot_pricing)
}
AmountDetails::BalanceAssignment(assignment) => {
let (newsum, commodity) = evaluator::eval_and_normalize_amount(
assignment,
entry_context,
&state,
)?;
let value = newsum
- account_balance
.and_then(|ab| ab.commodity.get(&commodity))
.unwrap_or(&Decimal::ZERO);
(value, commodity, None)
}
};
let payee = posting.metadata.remove("payee").unwrap_or(payee.clone());
if let Some((lot_total, lot_commodity)) = lot_pricing {
let dec = transaction_state.0.entry(lot_commodity).or_default();
*dec += lot_total;
} else {
let dec = transaction_state.0.entry(commodity.clone()).or_default();
*dec += value;
}
let amount = Amount(BTreeMap::from([(commodity, value)]));
resolved_postings.push(ResolvedPosting {
account: account_name,
payee,
amount,
state: posting.state.into(),
tags: posting.tags,
metadata: posting.metadata,
});
} else {
null_postings.push(posting);
}
}
if null_postings.len() > 1 {
return Err(ElaborationError::TooManyNullPostings);
}
if let Some(mut posting) = null_postings.pop() {
let account_name = entry_context
.account_aliases
.get(&posting.account)
.cloned()
.unwrap_or(posting.account);
let payee = posting.metadata.remove("payee").unwrap_or(payee.clone());
let amount = Amount(
transaction_state
.0
.iter()
.map(|(c, v)| (c.clone(), -v))
.collect(),
);
resolved_postings.push(ResolvedPosting {
account: account_name,
payee,
amount,
state: posting.state.into(),
tags: posting.tags,
metadata: posting.metadata,
});
} else {
if transaction_state.0.values().any(|value| !value.is_zero()) {
return Err(ElaborationError::TransactionDoesNotBalance(
transaction_state,
));
}
}
for posting in resolved_postings.iter() {
if !accounts.contains_key(&posting.account) {
accounts.insert(posting.account.clone(), Default::default());
}
let balances = state
.account_balances
.entry(posting.account.clone())
.or_default();
for (commodity, delta) in posting.amount.0.iter() {
*(balances.commodity.entry(commodity.clone()).or_default()) += delta;
}
}
transactions.push(ResolvedTransaction {
date: transaction.date.to_epoch_days(),
secondary_date: transaction.secondary_date.map(|d| d.to_epoch_days()),
state: transaction.state.into(),
code: transaction.code,
description: transaction.description,
tags: transaction.tags,
metadata: transaction.metadata,
postings: resolved_postings,
});
}
}
}
let final_context = value
.contexts
.last()
.expect("HIR always has at least one context");
let mut prices = vec![];
for hp in value.prices {
let (price, price_commodity) =
evaluator::eval_and_normalize_amount(hp.price, final_context, &state)?;
prices.push(HistoricalPrice {
date: hp.date.to_epoch_days(),
time: hp.time,
commodity: hp.commodity,
price,
price_commodity,
});
}
Ok(Journal {
transactions,
accounts,
prices,
})
}
}
mod evaluator {
use std::collections::BTreeMap;
use rust_decimal::Decimal;
use crate::{
ast::{self, ValueExpr},
resolution,
};
use super::{ElaborationError, EvaluationError, RunningState};
pub fn eval_and_normalize_amount(
val: ast::ValueExpr,
eval_context: &resolution::Context,
running_state: &RunningState,
) -> Result<(Decimal, String), ElaborationError> {
match eval(val, eval_context, running_state)? {
ast::ValueExpr::Amount { value, commodity } => {
let commodity = if let Some(commodity) = commodity {
eval_context
.commodity_aliases
.get(&commodity)
.unwrap_or(&commodity)
.clone()
} else {
eval_context
.default_commodity
.clone()
.ok_or(ElaborationError::AmountWithNoCommodity)?
};
Ok((value, commodity))
}
val => Err(ElaborationError::NonAmountWhereAmountExpected(val)),
}
}
fn eval(
val: ast::ValueExpr,
eval_context: &resolution::Context,
state: &RunningState,
) -> Result<ast::ValueExpr, EvaluationError> {
match val {
a @ ast::ValueExpr::Amount { .. } => Ok(a),
s @ ast::ValueExpr::Str(_) => Ok(s),
o @ ast::ValueExpr::Object(_) => Ok(o),
ast::ValueExpr::Unary { op, expr } => match eval(*expr, eval_context, state)? {
ast::ValueExpr::Amount { value, commodity } => match op {
ast::Op::Sub => Ok(ast::ValueExpr::Amount {
value: -value,
commodity,
}),
ast::Op::Add => Ok(ast::ValueExpr::Amount { value, commodity }),
_ => Err(EvaluationError::UnaryMultiplyOrDivide),
},
val => Err(EvaluationError::UnaryOnNonAmount(val)),
},
ast::ValueExpr::Binary { lhs, rhs, op } => {
match (
eval(*lhs, eval_context, state)?,
eval(*rhs, eval_context, state)?,
) {
(
ast::ValueExpr::Amount {
value: v1,
commodity: c,
},
ast::ValueExpr::Amount {
value: v2,
commodity: None,
},
)
| (
ast::ValueExpr::Amount {
value: v1,
commodity: None,
},
ast::ValueExpr::Amount {
value: v2,
commodity: c,
},
) => Ok(match op {
ast::Op::Add => ast::ValueExpr::Amount {
value: v1 + v2,
commodity: c,
},
ast::Op::Sub => ast::ValueExpr::Amount {
value: v1 - v2,
commodity: c,
},
ast::Op::Mul => ast::ValueExpr::Amount {
value: v1 * v2,
commodity: c,
},
ast::Op::Div => ast::ValueExpr::Amount {
value: v1 / v2,
commodity: c,
},
}),
(
ast::ValueExpr::Amount {
value: v1,
commodity: c,
},
ast::ValueExpr::Amount {
value: v2,
commodity: c2,
},
) if c == c2 => Ok(match op {
ast::Op::Add => ast::ValueExpr::Amount {
value: v1 + v2,
commodity: c,
},
ast::Op::Sub => ast::ValueExpr::Amount {
value: v1 - v2,
commodity: c,
},
ast::Op::Mul => ast::ValueExpr::Amount {
value: v1 * v2,
commodity: c,
},
ast::Op::Div => ast::ValueExpr::Amount {
value: v1 / v2,
commodity: c,
},
}),
(
ast::ValueExpr::Commodity(commodity),
ast::ValueExpr::Amount {
value,
commodity: None,
},
)
| (
ast::ValueExpr::Amount {
value,
commodity: None,
},
ast::ValueExpr::Commodity(commodity),
) => match op {
ast::Op::Sub => Ok(ast::ValueExpr::Amount {
value: -value,
commodity: Some(commodity),
}),
ast::Op::Add => Ok(ast::ValueExpr::Amount {
value,
commodity: Some(commodity),
}),
_ => Err(EvaluationError::UnaryMultiplyOrDivide),
},
(a, b) => Err(EvaluationError::BinaryOperationTypeError((a, b, op))),
}
}
ast::ValueExpr::Function { name, args } => match (name.as_str(), args.as_slice()) {
("scrub", [arg]) => eval(arg.clone(), eval_context, state),
("account", [account]) => {
if let ValueExpr::Str(account) = eval(account.clone(), eval_context, state)? {
let account = eval_context
.account_aliases
.get(&account)
.unwrap_or(&account);
let balance = state
.account_balances
.get(account)
.and_then(|ab| ab.commodity.get("$"))
.cloned()
.unwrap_or_default();
Ok(ast::ValueExpr::Object(BTreeMap::from([(
"total".into(),
ast::ValueExpr::Amount {
value: balance,
commodity: Some("$".into()),
},
)])))
} else {
Err(EvaluationError::InvalidFunctionArgs((
name,
account.clone(),
)))
}
}
_ => Err(EvaluationError::UnknownFunctionArgs((name, args))),
},
ast::ValueExpr::Commodity(ref name) => {
if let Some(defined_expr) = eval_context.defines.get(name.as_str()) {
eval(defined_expr.clone(), eval_context, state)
} else {
Ok(val)
}
}
ast::ValueExpr::Typed {
expr,
commodity: new_commodity,
} => match eval(*expr, eval_context, state)? {
ast::ValueExpr::Amount { value, commodity }
if commodity.is_none() || commodity.as_ref() == Some(&new_commodity) =>
{
Ok(ast::ValueExpr::Amount {
value,
commodity: Some(new_commodity),
})
}
a => Err(EvaluationError::TypedCommodityToIncompatibleAmount((
new_commodity,
a,
))),
},
ast::ValueExpr::Access { expr, field } => match eval(*expr, eval_context, state)? {
ast::ValueExpr::Object(map) => map
.get(&field)
.cloned()
.ok_or(EvaluationError::NoSuchField(field)),
val => Err(EvaluationError::FieldAccessTypeError(val)),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
#[test]
fn test_amount_serde_wire_format() {
let decimal = dec!(182.50);
let amount = Amount(BTreeMap::from([("$".to_string(), decimal)]));
let amount_bytes = postcard::to_allocvec(&amount).unwrap();
let raw_map: BTreeMap<&str, [u8; 16]> = BTreeMap::from([("$", decimal.serialize())]);
let raw_bytes = postcard::to_allocvec(&raw_map).unwrap();
assert_eq!(
amount_bytes, raw_bytes,
"Amount wire format must match [u8;16] map"
);
}
#[test]
fn test_amount_serde_roundtrip() {
let decimal = dec!(42.123456789);
let original = Amount(BTreeMap::from([
("USD".to_string(), decimal),
("$".to_string(), dec!(-1.5)),
]));
let bytes = postcard::to_allocvec(&original).unwrap();
let recovered: Amount = postcard::from_bytes(&bytes).unwrap();
assert_eq!(original.0.len(), recovered.0.len());
for (k, v) in &original.0 {
assert_eq!(recovered.0[k], *v);
}
}
#[test]
fn test_prices_wired_through_to_journal() {
use crate::{ast, resolution};
let price_ast = ast::HistoricalPrice {
date: ast::Date {
year: Some(2024),
month: 6,
date: 15,
},
time: Some("14:30:00".into()),
commodity: "AAPL".into(),
price: ast::ValueExpr::amount(rust_decimal::Decimal::from(182), "$".into()),
};
let journal_ast = ast::Journal {
entries: vec![ast::Entry::HistoricalPrice(price_ast)],
};
let hir = resolution::HIR::try_from(journal_ast).expect("resolution should succeed");
assert_eq!(hir.prices.len(), 1, "HIR should contain one price");
let journal = Journal::try_from(hir).expect("elaboration should succeed");
assert_eq!(journal.prices.len(), 1, "Journal should contain one price");
let price = &journal.prices[0];
let expected_days = chrono::NaiveDate::from_ymd_opt(2024, 6, 15)
.unwrap()
.to_epoch_days();
assert_eq!(price.date, expected_days);
assert_eq!(price.time.as_deref(), Some("14:30:00"));
assert_eq!(price.commodity, "AAPL");
assert_eq!(price.price, rust_decimal::Decimal::from(182));
assert_eq!(price.price_commodity, "$");
}
fn elaborate(input: &str) -> Journal {
use crate::{parser::parse_ledger, resolution::HIR};
let ast = parse_ledger(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
Journal::try_from(hir).expect("elaboration failed")
}
#[test]
fn test_define_simple_amount_alias() {
let input = "\
define monthly_rent = $1500.00
2024-01-01 Rent Payment
Expenses:Rent monthly_rent
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let rent_posting = tx
.postings
.iter()
.find(|p| p.account == "Expenses:Rent")
.unwrap();
assert_eq!(
rent_posting.amount.0.get("$").copied(),
Some(dec!(1500.00)),
"define alias should expand to $1500.00"
);
let checking_posting = tx
.postings
.iter()
.find(|p| p.account == "Assets:Checking")
.unwrap();
assert_eq!(
checking_posting.amount.0.get("$").copied(),
Some(dec!(-1500.00)),
"balancing posting should be -$1500.00"
);
}
#[test]
fn test_define_used_in_arithmetic_expression() {
let input = "\
define base_amount = 100 USD
2024-02-01 Double Amount
Expenses:Food 2 * base_amount
Assets:Cash
";
let journal = elaborate(input);
let tx = &journal.transactions[0];
let food = tx
.postings
.iter()
.find(|p| p.account == "Expenses:Food")
.unwrap();
assert_eq!(
food.amount.0.get("USD").copied(),
Some(dec!(200)),
"2 * define alias should expand to 200 USD"
);
}
#[test]
fn test_define_does_not_affect_earlier_transactions() {
use crate::{parser::parse_ledger, resolution::HIR};
let input = "\
2024-01-01 Before Define
Expenses:A $10.00
Assets:Cash
define myval = $99.00
2024-01-02 After Define
Expenses:B myval
Assets:Cash
";
let ast = parse_ledger(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
assert_eq!(hir.contexts.len(), 2);
assert_eq!(hir.entries[0].context_id, 0);
assert!(hir.contexts[0].defines.is_empty());
assert_eq!(hir.entries[1].context_id, 1);
assert!(hir.contexts[1].defines.contains_key("myval"));
let journal = Journal::try_from(hir).expect("elaboration failed");
let after_tx = &journal.transactions[1];
let b_posting = after_tx
.postings
.iter()
.find(|p| p.account == "Expenses:B")
.unwrap();
assert_eq!(b_posting.amount.0.get("$").copied(), Some(dec!(99.00)));
}
#[test]
fn test_transaction_state_preserved_through_pipeline() {
let input = "\
2024-01-01 * Cleared Transaction
Expenses:Food $10.00
Assets:Checking
2024-01-02 Uncleared Transaction
Expenses:Food $5.00
Assets:Checking
2024-01-03 ! Pending Transaction
Expenses:Food $3.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 3);
assert!(
matches!(journal.transactions[0].state, TransactionState::Cleared),
"first transaction should be Cleared"
);
assert!(
matches!(journal.transactions[1].state, TransactionState::Uncleared),
"second transaction should be Uncleared"
);
assert!(
matches!(journal.transactions[2].state, TransactionState::Pending),
"third transaction should be Pending"
);
}
#[test]
fn test_cleared_filter_mixed_transactions() {
let input = "\
2024-01-01 * Cleared Transaction
Expenses:Food $10.00
Assets:Checking
2024-01-02 Uncleared Transaction
Expenses:Food $5.00
Assets:Checking
2024-01-03 ! Pending Transaction
Expenses:Food $3.00
Assets:Checking
";
let journal = elaborate(input);
let cleared_total: rust_decimal::Decimal = journal
.transactions
.iter()
.filter(|txn| matches!(txn.state, TransactionState::Cleared))
.flat_map(|txn| txn.postings.iter())
.filter(|p| p.account == "Expenses:Food")
.filter_map(|p| p.amount.0.get("$").copied())
.sum();
assert_eq!(
cleared_total,
dec!(10.00),
"--cleared should include only the $10.00 cleared transaction"
);
}
#[test]
fn test_cleared_filter_no_cleared_transactions() {
let input = "\
2024-01-01 Uncleared One
Expenses:Food $10.00
Assets:Checking
2024-01-02 ! Pending One
Expenses:Food $5.00
Assets:Checking
";
let journal = elaborate(input);
let count = journal
.transactions
.iter()
.filter(|txn| matches!(txn.state, TransactionState::Cleared))
.count();
assert_eq!(count, 0, "no cleared transactions should be found");
}
#[test]
fn test_no_cleared_filter_includes_all_transactions() {
let input = "\
2024-01-01 * Cleared Transaction
Expenses:Food $10.00
Assets:Checking
2024-01-02 Uncleared Transaction
Expenses:Food $5.00
Assets:Checking
";
let journal = elaborate(input);
let total: rust_decimal::Decimal = journal
.transactions
.iter()
.flat_map(|txn| txn.postings.iter())
.filter(|p| p.account == "Expenses:Food")
.filter_map(|p| p.amount.0.get("$").copied())
.sum();
assert_eq!(
total,
dec!(15.00),
"without --cleared both transactions should contribute to the balance"
);
}
fn try_elaborate(input: &str) -> Result<Journal, ElaborationError> {
use crate::{parser::parse_ledger, resolution::HIR};
let ast = parse_ledger(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
Journal::try_from(hir)
}
#[test]
fn test_balance_assertion_succeeds_when_balance_matches() {
let input = "\
2024-01-01 Opening
Assets:Checking $1000.00
Equity:Opening
2024-01-01 = Assets:Checking $1000.00
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_balance_assertion_fails_when_balance_mismatches() {
let input = "\
2024-01-01 Opening
Assets:Checking $1000.00
Equity:Opening
2024-01-01 = Assets:Checking $500.00
";
let result = try_elaborate(input);
assert!(result.is_err(), "assertion should fail");
let err = result.unwrap_err();
match err {
ElaborationError::BalanceAssertionFailed {
ref account,
expected_amount,
actual_amount,
..
} => {
assert_eq!(account, "Assets:Checking");
assert_eq!(expected_amount, dec!(500.00));
assert_eq!(actual_amount, dec!(1000.00));
}
other => panic!("expected BalanceAssertionFailed, got: {other:?}"),
}
let msg = err.to_string();
assert!(
msg.contains("Assets:Checking"),
"error should name the account: {msg}"
);
assert!(
msg.contains("500"),
"error should show expected amount: {msg}"
);
assert!(
msg.contains("1000"),
"error should show actual amount: {msg}"
);
}
#[test]
fn test_balance_assertion_zero_balance_at_start() {
let input = "\
2024-01-01 = Assets:Checking $0.00
";
let journal = elaborate(input);
assert!(journal.transactions.is_empty());
}
#[test]
fn test_balance_assertion_nonzero_at_start_fails() {
let input = "\
2024-01-01 = Assets:Checking $100.00
";
let result = try_elaborate(input);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ElaborationError::BalanceAssertionFailed { .. }
));
}
#[test]
fn test_balance_assertion_after_multiple_transactions() {
let input = "\
2024-01-01 First deposit
Assets:Checking $500.00
Income:Salary
2024-01-15 Second deposit
Assets:Checking $300.00
Income:Salary
2024-01-31 = Assets:Checking $800.00
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 2);
}
#[test]
fn test_balance_assertion_with_expression() {
let input = "\
2024-01-01 Opening
Assets:Checking $1000.00
Equity:Opening
2024-01-01 = Assets:Checking $500.00 + $500.00
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_balance_assertion_weak_ignores_other_commodities() {
let input = "\
2024-01-01 USD deposit
Assets:Multi $1000.00
Equity:Opening
2024-01-02 EUR deposit
Assets:Multi 500.00 EUR
Equity:Opening
2024-01-02 = Assets:Multi $1000.00
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 2);
}
#[test]
fn test_balance_assertion_between_transactions() {
let input = "\
2024-01-01 First
Assets:Checking $100.00
Equity:Opening
2024-01-01 = Assets:Checking $100.00
2024-01-02 Second
Assets:Checking $50.00
Income:Salary
2024-01-02 = Assets:Checking $150.00
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 2);
}
#[test]
fn test_balance_assertion_strict_treated_as_weak() {
let input = "\
2024-01-01 Opening
Assets:Checking $1000.00
Equity:Opening
2024-01-01 == Assets:Checking $1000.00
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
}