use std::{borrow::Borrow, path::PathBuf};
use annotate_snippets::{Annotation, AnnotationKind};
use bumpalo::Bump;
use bumpalo::collections as bcc;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use crate::parse::ParsedSpan;
use crate::{
load,
report::eval::EvalError,
syntax::{
self,
decoration::AsUndecorated,
tracked::{Tracked, TrackedSpan},
},
};
use super::{
balance::{Balance, BalanceError},
context::ReportContext,
error::{self, ReportError},
eval::{Amount, Evaluable, OwnedEvalError, PostingAmount, SingleAmount},
price_db::{PriceEvent, PriceRepositoryBuilder, PriceSource},
query::Ledger,
transaction::{Posting, Transaction},
};
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum BookKeepError {
#[error("failed to evaluate the expression: {0}")]
EvalFailure(#[source] OwnedEvalError, TrackedSpan),
#[error("failed balance assertion: {source}")]
BalanceFailure {
#[source]
source: BalanceError,
account: TrackedSpan,
balance: TrackedSpan,
},
#[error("transaction cannot have multiple postings without constraints")]
UndeduciblePostingAmount(Tracked<usize>, Tracked<usize>),
#[error("transaction cannot have unbalanced postings: {0}")]
UnbalancedPostings(String),
#[error("balance assertion off by {diff}, computed balance is {computed}")]
BalanceAssertionFailure {
account_span: TrackedSpan,
balance_span: TrackedSpan,
computed: String,
diff: String,
},
#[error("already registered account alias: {0}")]
InvalidAccountAlias(String),
#[error("already registered commodity alias: {0}")]
InvalidCommodityAlias(String),
#[error("posting without commodity should not have exchange")]
ZeroAmountWithExchange(TrackedSpan),
#[error("cost or lot exchange must not be zero")]
ZeroExchangeRate(TrackedSpan),
#[error("cost or lot exchange must have different commodity from the amount commodity")]
ExchangeWithAmountCommodity {
posting_amount: TrackedSpan,
exchange: TrackedSpan,
},
}
impl BookKeepError {
pub(super) fn annotations<'arena>(
&self,
bump: &'arena Bump,
parsed_span: &ParsedSpan,
text: &str,
) -> Vec<Annotation<'arena>> {
match self {
BookKeepError::EvalFailure(err, pos) => vec![
AnnotationKind::Primary
.span(parsed_span.resolve(pos))
.label(bumpalo::format!(in &bump, "{}", err).into_bump_str()),
],
BookKeepError::BalanceFailure {
source: err,
account,
balance,
} => vec![
AnnotationKind::Primary
.span(parsed_span.resolve(balance))
.label(bumpalo::format!(in &bump, "{}", err).into_bump_str()),
AnnotationKind::Context
.span(parsed_span.resolve(account))
.label(bumpalo::format!(in &bump, "{}", err.note()).into_bump_str()),
],
BookKeepError::UndeduciblePostingAmount(first, second) => vec![
AnnotationKind::Context
.span(parsed_span.resolve(&first.span()))
.label("first posting without constraints"),
AnnotationKind::Primary
.span(parsed_span.resolve(&second.span()))
.label("cannot deduce this posting"),
],
BookKeepError::BalanceAssertionFailure {
balance_span,
account_span,
computed,
..
} => {
let msg = bumpalo::format!(
in &bump,
"computed balance: {}", computed,
);
vec![
AnnotationKind::Primary
.span(parsed_span.resolve(balance_span))
.label("not match the computed balance"),
AnnotationKind::Context
.span(parsed_span.resolve(account_span))
.label(msg.into_bump_str()),
]
}
BookKeepError::ZeroAmountWithExchange(exchange) => vec![
AnnotationKind::Primary
.span(parsed_span.resolve(exchange))
.label("absolute zero posting should not have exchange"),
],
BookKeepError::ZeroExchangeRate(exchange) => vec![
AnnotationKind::Primary
.span(parsed_span.resolve(exchange))
.label("exchange with zero amount"),
],
BookKeepError::ExchangeWithAmountCommodity {
posting_amount,
exchange,
} => vec![
AnnotationKind::Context
.span(parsed_span.resolve(posting_amount))
.label("posting amount"),
AnnotationKind::Primary
.span(parsed_span.resolve(exchange))
.label("exchange cannot have the same commodity with posting"),
],
_ => {
vec![
AnnotationKind::Primary
.span(0..text.len())
.label("error occured"),
]
}
}
}
}
#[derive(Debug, Default)]
pub struct ProcessOptions {
pub price_db_path: Option<PathBuf>,
}
pub fn process<'ctx, L, F>(
ctx: &mut ReportContext<'ctx>,
loader: L,
options: &ProcessOptions,
) -> Result<Ledger<'ctx>, ReportError>
where
L: Borrow<load::Loader<F>>,
F: load::FileSystem,
{
let mut accum = ProcessAccumulator::new();
loader.borrow().load(|path, pctx, entry| {
accum.process(ctx, entry).map_err(|berr| {
ReportError::BookKeep(
berr,
error::ErrorContext::new(
loader.borrow().error_style().clone(),
path.to_owned(),
pctx,
),
)
})
})?;
if let Some(price_db_path) = options.price_db_path.as_deref() {
accum
.price_repos
.load_price_db(ctx, loader.borrow().filesystem(), price_db_path)?;
}
Ok(Ledger {
transactions: accum.txns,
raw_balance: accum.balance,
price_repos: accum.price_repos.build(),
})
}
struct ProcessAccumulator<'ctx> {
balance: Balance<'ctx>,
txns: Vec<Transaction<'ctx>>,
price_repos: PriceRepositoryBuilder<'ctx>,
}
impl<'ctx> ProcessAccumulator<'ctx> {
fn new() -> Self {
Self {
balance: Balance::default(),
txns: Vec::new(),
price_repos: PriceRepositoryBuilder::default(),
}
}
fn process(
&mut self,
ctx: &mut ReportContext<'ctx>,
entry: &syntax::tracked::LedgerEntry,
) -> Result<(), BookKeepError> {
match entry {
syntax::LedgerEntry::Txn(txn) => {
self.txns.push(add_transaction(
ctx,
&mut self.price_repos,
&mut self.balance,
txn,
)?);
Ok(())
}
syntax::LedgerEntry::Account(account) => {
let canonical = ctx.accounts.ensure(&account.name);
for ad in &account.details {
if let syntax::AccountDetail::Alias(alias) = ad {
ctx.accounts
.insert_alias(alias, canonical)
.map_err(|_| BookKeepError::InvalidAccountAlias(alias.to_string()))?;
}
}
Ok(())
}
syntax::LedgerEntry::Commodity(commodity) => {
let canonical = ctx.commodities.ensure(&commodity.name);
for cd in &commodity.details {
match cd {
syntax::CommodityDetail::Alias(alias) => {
ctx.commodities
.insert_alias(alias, canonical)
.map_err(|_| {
BookKeepError::InvalidCommodityAlias(alias.to_string())
})?;
}
syntax::CommodityDetail::Format(format_amount) => {
ctx.commodities.set_format(canonical, format_amount.value);
}
_ => {}
}
}
Ok(())
}
_ => Ok(()),
}
}
}
fn add_transaction<'ctx>(
ctx: &mut ReportContext<'ctx>,
price_repos: &mut PriceRepositoryBuilder<'ctx>,
bal: &mut Balance<'ctx>,
txn: &syntax::tracked::Transaction,
) -> Result<Transaction<'ctx>, BookKeepError> {
let mut postings = bcc::Vec::with_capacity_in(txn.posts.len(), ctx.arena);
let mut unfilled: Option<Tracked<usize>> = None;
let mut balance = Amount::default();
for (i, posting) in txn.posts.iter().enumerate() {
let posting = posting.as_undecorated();
let account_span = posting.account.span();
let account = ctx.accounts.ensure(posting.account.as_undecorated());
let (evaluated, price_event) = match process_posting(ctx, bal, txn.date, account, posting)?
{
(Some(x), y) => (x, y),
(None, y) => {
if let Some(first) = unfilled.replace(Tracked::new(i, account_span.clone())) {
return Err(BookKeepError::UndeduciblePostingAmount(
first,
Tracked::new(i, account_span.clone()),
));
} else {
(
EvaluatedPosting {
amount: PostingAmount::zero(),
converted_amount: None,
balance_delta: PostingAmount::zero(),
},
y,
)
}
}
};
if let Some(event) = price_event {
price_repos.insert_price(PriceSource::Ledger, event);
}
balance += evaluated.balance_delta;
postings.push(Posting {
account,
amount: evaluated.amount.into(),
converted_amount: evaluated.converted_amount,
});
}
if let Some(u) = unfilled {
let u = *u.as_undecorated();
let deduced: Amount = balance.negate();
postings[u].amount = deduced.clone();
bal.add_amount(postings[u].account, deduced);
} else {
check_balance(ctx, price_repos, &mut postings, txn.date, balance)?;
}
Ok(Transaction {
date: txn.date,
postings: postings.into_boxed_slice(),
})
}
struct EvaluatedPosting<'ctx> {
amount: PostingAmount<'ctx>,
converted_amount: Option<SingleAmount<'ctx>>,
balance_delta: PostingAmount<'ctx>,
}
fn map_eval_err<'ctx>(
ctx: &mut ReportContext<'ctx>,
e: EvalError<'ctx>,
span: TrackedSpan,
) -> BookKeepError {
BookKeepError::EvalFailure(e.into_owned(ctx), span)
}
fn process_posting<'ctx>(
ctx: &mut ReportContext<'ctx>,
bal: &mut Balance<'ctx>,
date: NaiveDate,
account: super::Account<'ctx>,
posting: &syntax::tracked::Posting,
) -> Result<(Option<EvaluatedPosting<'ctx>>, Option<PriceEvent<'ctx>>), BookKeepError> {
match (&posting.amount, &posting.balance) {
(None, None) => Ok((None, None)),
(None, Some(balance_constraints)) => {
let balance_span = balance_constraints.span();
let current: PostingAmount = balance_constraints
.as_undecorated()
.eval_mut(ctx)
.and_then(|x| x.try_into())
.map_err(|e| map_eval_err(ctx, e, balance_span.clone()))?;
let prev: PostingAmount = bal.set_partial(ctx, account, current).map_err(|e| {
BookKeepError::BalanceFailure {
source: e,
account: posting.account.span(),
balance: balance_span.clone(),
}
})?;
let amount = current
.check_sub(prev)
.map_err(|e| map_eval_err(ctx, e, balance_span))?;
Ok((
Some(EvaluatedPosting {
amount,
converted_amount: None,
balance_delta: amount,
}),
None,
))
}
(Some(syntax_amount), balance_constraints) => {
let computed = ComputedPosting::compute_from_syntax(ctx, syntax_amount)?;
let current = bal.add_posting_amount(account, computed.amount);
if let Some(balance_constraints) = balance_constraints.as_ref() {
let balance_span = balance_constraints.span();
let expected: PostingAmount = balance_constraints
.as_undecorated()
.eval_mut(ctx)
.and_then(|x| x.try_into())
.map_err(|e| map_eval_err(ctx, e, balance_span.clone()))?;
let diff = current.assert_balance(&expected);
if !diff.is_absolute_zero() {
return Err(BookKeepError::BalanceAssertionFailure {
account_span: posting.account.span(),
balance_span: balance_constraints.span(),
computed: format!("{}", current.as_inline_display(ctx)),
diff: format!("{}", diff.as_inline_display(ctx)),
});
}
}
let balance_delta = computed.calculate_balance_amount(ctx)?;
Ok((
Some(EvaluatedPosting {
amount: computed.amount,
converted_amount: computed.calculate_converted_amount(ctx)?,
balance_delta,
}),
posting_price_event(date, &computed)?,
))
}
}
}
struct ComputedPosting<'ctx> {
amount_span: TrackedSpan,
amount: PostingAmount<'ctx>,
cost: Option<Exchange<'ctx>>,
lot: Option<Exchange<'ctx>>,
}
impl<'ctx> ComputedPosting<'ctx> {
fn compute_from_syntax(
ctx: &mut ReportContext<'ctx>,
syntax_amount: &syntax::tracked::PostingAmount<'_>,
) -> Result<Self, BookKeepError> {
let amount_span = syntax_amount.amount.span();
let amount: PostingAmount = syntax_amount
.amount
.as_undecorated()
.eval_mut(ctx)
.and_then(|x| x.try_into())
.map_err(|e| map_eval_err(ctx, e, amount_span.clone()))?;
let cost = posting_cost_exchange(syntax_amount)
.map(|exchange| Exchange::try_from_syntax(ctx, syntax_amount, &amount, exchange))
.transpose()?;
let lot = posting_lot_exchange(syntax_amount)
.map(|exchange| Exchange::try_from_syntax(ctx, syntax_amount, &amount, exchange))
.transpose()?;
Ok(ComputedPosting {
amount_span,
amount,
cost,
lot,
})
}
fn calculate_converted_amount(
&self,
ctx: &mut ReportContext<'ctx>,
) -> Result<Option<SingleAmount<'ctx>>, BookKeepError> {
let Some(rate) = self.cost.as_ref().or(self.lot.as_ref()) else {
return Ok(None);
};
let amount = self
.amount
.try_into()
.map_err(|e| map_eval_err(ctx, e, self.amount_span.clone()))?;
Ok(Some(rate.exchange(amount)))
}
fn calculate_balance_amount(
&self,
ctx: &mut ReportContext<'ctx>,
) -> Result<PostingAmount<'ctx>, BookKeepError> {
match self.lot.as_ref().or(self.cost.as_ref()) {
Some(x) => {
let amount = self
.amount
.try_into()
.map_err(|e| map_eval_err(ctx, e, self.amount_span.clone()))?;
Ok(x.exchange(amount).into())
}
None => Ok(self.amount),
}
}
}
fn posting_price_event<'ctx>(
date: NaiveDate,
computed: &ComputedPosting<'ctx>,
) -> Result<Option<PriceEvent<'ctx>>, BookKeepError> {
let exchange = match computed.cost.as_ref().or(computed.lot.as_ref()) {
None => return Ok(None),
Some(exchange) => exchange,
};
Ok(Some(match computed.amount {
PostingAmount::Zero => {
unreachable!("Given Exchange is set None, this must be SingleAmount.")
}
PostingAmount::Single(amount) => match exchange {
Exchange::Rate(rate) => PriceEvent {
price_x: SingleAmount::from_value(amount.commodity, Decimal::ONE),
price_y: *rate,
date,
},
Exchange::Total(total) => PriceEvent {
price_x: amount.abs(),
price_y: *total,
date,
},
},
}))
}
enum Exchange<'ctx> {
Total(SingleAmount<'ctx>),
Rate(SingleAmount<'ctx>),
}
impl<'ctx> Exchange<'ctx> {
fn is_zero(&self) -> bool {
match self {
Exchange::Total(x) => x.value.is_zero(),
Exchange::Rate(x) => x.value.is_zero(),
}
}
fn try_from_syntax<'a>(
ctx: &mut ReportContext<'ctx>,
syntax_amount: &syntax::tracked::PostingAmount<'a>,
posting_amount: &PostingAmount<'ctx>,
exchange: &syntax::tracked::Tracked<syntax::Exchange<'a>>,
) -> Result<Exchange<'ctx>, BookKeepError> {
let (rate_commodity, rate) = match exchange.as_undecorated() {
syntax::Exchange::Rate(rate) => {
let rate: SingleAmount<'ctx> = rate
.eval_mut(ctx)
.and_then(|x| x.try_into())
.map_err(|e| map_eval_err(ctx, e, exchange.span()))?;
(rate.commodity, Exchange::Rate(rate))
}
syntax::Exchange::Total(rate) => {
let rate: SingleAmount<'ctx> = rate
.eval_mut(ctx)
.and_then(|x| x.try_into())
.map_err(|e| map_eval_err(ctx, e, exchange.span()))?;
(rate.commodity, Exchange::Total(rate))
}
};
if rate.is_zero() {
return Err(BookKeepError::ZeroExchangeRate(exchange.span()));
}
match posting_amount {
PostingAmount::Zero => Err(BookKeepError::ZeroAmountWithExchange(exchange.span())),
PostingAmount::Single(amount) => {
if amount.commodity == rate_commodity {
Err(BookKeepError::ExchangeWithAmountCommodity {
posting_amount: syntax_amount.amount.span(),
exchange: exchange.span(),
})
} else {
Ok(rate)
}
}
}
}
fn exchange(&self, amount: SingleAmount<'ctx>) -> SingleAmount<'ctx> {
match self {
Exchange::Rate(rate) => *rate * amount.value,
Exchange::Total(abs) => abs.with_sign_of(amount),
}
}
}
fn check_balance<'ctx>(
ctx: &ReportContext<'ctx>,
price_repos: &mut PriceRepositoryBuilder<'ctx>,
postings: &mut bcc::Vec<'ctx, Posting<'ctx>>,
date: NaiveDate,
balance: Amount<'ctx>,
) -> Result<(), BookKeepError> {
log::trace!(
"balance before rounding in txn: {}",
balance.as_inline_display(ctx)
);
let balance = balance.round(ctx);
if balance.is_zero() {
return Ok(());
}
if let Some((a1, a2)) = balance.maybe_pair() {
for p in postings.iter_mut() {
let amount: Result<SingleAmount<'_>, _> = (&p.amount).try_into();
if let Ok(amount) = amount {
if a1.commodity == amount.commodity {
p.converted_amount = Some(SingleAmount::from_value(
a2.commodity,
(a2.value / a1.value).abs() * amount.value,
));
} else if a2.commodity == amount.commodity {
p.converted_amount = Some(SingleAmount::from_value(
a1.commodity,
(a1.value / a2.value).abs() * amount.value,
));
}
}
}
price_repos.insert_price(
PriceSource::Ledger,
PriceEvent {
date,
price_x: a1.abs(),
price_y: a2.abs(),
},
);
return Ok(());
}
if !balance.is_zero() {
return Err(BookKeepError::UnbalancedPostings(format!(
"{}",
balance.as_inline_display(ctx)
)));
}
Ok(())
}
#[inline]
fn posting_cost_exchange<'a, 'ctx>(
posting_amount: &'a syntax::tracked::PostingAmount<'ctx>,
) -> Option<&'a syntax::tracked::Tracked<syntax::Exchange<'ctx>>> {
posting_amount.cost.as_ref()
}
#[inline]
fn posting_lot_exchange<'a, 'ctx>(
posting_amount: &'a syntax::tracked::PostingAmount<'ctx>,
) -> Option<&'a syntax::tracked::Tracked<syntax::Exchange<'ctx>>> {
posting_amount.lot.price.as_ref()
}
#[cfg(test)]
mod tests {
use super::*;
use bumpalo::Bump;
use chrono::NaiveDate;
use indoc::indoc;
use maplit::hashmap;
use pretty_assertions::assert_eq;
use rust_decimal_macros::dec;
use crate::{
parse::{self, testing::expect_parse_ok},
syntax::tracked::TrackedSpan,
};
fn parse_transaction(input: &'_ str) -> syntax::tracked::Transaction<'_> {
let (_, ret) = expect_parse_ok(parse::transaction::transaction, input);
ret
}
#[test]
fn add_transaction_fails_with_inconsistent_balance() {
let arena = Bump::new();
let mut ctx = ReportContext::new(&arena);
let mut bal = Balance::default();
bal.add_posting_amount(
ctx.accounts.ensure("Account 1"),
PostingAmount::from_value(ctx.commodities.ensure("JPY"), dec!(1000)),
);
let input = indoc! {"
2024/08/01 Sample
Account 2
Account 1 200 JPY = 1300 JPY
"};
let txn = parse_transaction(input);
let mut price_repos = PriceRepositoryBuilder::default();
let got_err = add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).unwrap_err();
assert!(
matches!(got_err, BookKeepError::BalanceAssertionFailure { .. }),
"unexpected got_err: {:?}",
got_err
);
}
#[test]
fn add_transaction_fails_with_inconsistent_absolute_zero_balance() {
let arena = Bump::new();
let mut ctx = ReportContext::new(&arena);
let mut bal = Balance::default();
bal.add_posting_amount(
ctx.accounts.ensure("Account 1"),
PostingAmount::from_value(ctx.commodities.ensure("JPY"), dec!(1000)),
);
let input = indoc! {"
2024/08/01 Sample
Account 2
Account 1 0 CHF = 0 ; must fail because of 1000 JPY
"};
let txn = parse_transaction(input);
let mut price_repos = PriceRepositoryBuilder::default();
let got_err = add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).unwrap_err();
assert!(
matches!(got_err, BookKeepError::BalanceAssertionFailure { .. }),
"unexpected got_err: {:?}",
got_err
);
}
#[test]
fn add_transaction_maintains_balance() {
let arena = Bump::new();
let mut ctx = ReportContext::new(&arena);
let mut bal = Balance::default();
bal.add_posting_amount(
ctx.accounts.ensure("Account 1"),
PostingAmount::from_value(ctx.commodities.ensure("JPY"), dec!(1000)),
);
bal.add_posting_amount(
ctx.accounts.ensure("Account 1"),
PostingAmount::from_value(ctx.commodities.ensure("EUR"), dec!(123)),
);
bal.add_posting_amount(
ctx.accounts.ensure("Account 2"),
PostingAmount::from_value(ctx.commodities.ensure("EUR"), dec!(1)),
);
bal.add_posting_amount(
ctx.accounts.ensure("Account 4"),
PostingAmount::from_value(ctx.commodities.ensure("CHF"), dec!(10)),
);
let input = indoc! {"
2024/08/01 Sample
Account 1 200 JPY = 1200 JPY
Account 2 0 JPY = 0 JPY
Account 2 -100 JPY = -100 JPY
Account 2 -100 JPY = -200 JPY
Account 3 2.00 CHF @ 150 JPY
Account 4 = -300 JPY
"};
let txn = parse_transaction(input);
let mut price_repos = PriceRepositoryBuilder::default();
let _ = add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
let want_balance: Balance = hashmap! {
ctx.accounts.ensure("Account 1") =>
Amount::from_iter([
(ctx.commodities.ensure("JPY"), dec!(1200)),
(ctx.commodities.ensure("EUR"), dec!(123)),
]),
ctx.accounts.ensure("Account 2") =>
Amount::from_iter([
(ctx.commodities.ensure("JPY"), dec!(-200)),
(ctx.commodities.ensure("EUR"), dec!(1)),
]),
ctx.accounts.ensure("Account 3") =>
Amount::from_value(ctx.commodities.ensure("CHF"), dec!(2)),
ctx.accounts.ensure("Account 4") =>
Amount::from_iter([
(ctx.commodities.ensure("JPY"), dec!(-300)),
(ctx.commodities.ensure("CHF"), dec!(10)),
]),
}
.into_iter()
.collect();
assert_eq!(want_balance.into_vec(), bal.into_vec());
}
#[test]
fn add_transaction_emits_transaction_with_postings() {
let arena = Bump::new();
let mut ctx = ReportContext::new(&arena);
let mut bal = Balance::default();
let input = indoc! {"
2024/08/01 Sample
Account 1 200 JPY = 200 JPY
Account 2 -100 JPY = -100 JPY
Account 2 -100 JPY = -200 JPY
"};
let txn = parse_transaction(input);
let mut price_repos = PriceRepositoryBuilder::default();
let got =
add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
let want = Transaction {
date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
postings: bcc::Vec::from_iter_in(
[
Posting {
account: ctx.accounts.ensure("Account 1"),
amount: Amount::from_value(ctx.commodities.ensure("JPY"), dec!(200)),
converted_amount: None,
},
Posting {
account: ctx.accounts.ensure("Account 2"),
amount: Amount::from_value(ctx.commodities.ensure("JPY"), dec!(-100)),
converted_amount: None,
},
Posting {
account: ctx.accounts.ensure("Account 2"),
amount: Amount::from_value(ctx.commodities.ensure("JPY"), dec!(-100)),
converted_amount: None,
},
],
&arena,
)
.into_boxed_slice(),
};
assert_eq!(want, got);
}
#[test]
fn add_transaction_emits_transaction_with_deduce_and_balance_concern() {
let arena = Bump::new();
let mut ctx = ReportContext::new(&arena);
let mut bal = Balance::default();
let jpy = ctx.commodities.ensure("JPY");
let usd = ctx.commodities.ensure("USD");
bal.add_posting_amount(
ctx.accounts.ensure("Account 1"),
PostingAmount::from_value(jpy, dec!(1000)),
);
bal.add_posting_amount(
ctx.accounts.ensure("Account 1"),
PostingAmount::from_value(usd, dec!(123)),
);
bal.add_posting_amount(
ctx.accounts.ensure("Account 2"),
PostingAmount::from_value(jpy, dec!(-100)),
);
bal.add_posting_amount(
ctx.accounts.ensure("Account 2"),
PostingAmount::from_value(usd, dec!(-30)),
);
bal.add_posting_amount(
ctx.accounts.ensure("Account 3"),
PostingAmount::from_value(jpy, dec!(-150)),
);
let input = indoc! {"
2024/08/01 Sample
Account 1 = 1200 JPY
Account 2 = 0 JPY
Account 3 = 0
Account 4
"};
let txn = parse_transaction(input);
let mut price_repos = PriceRepositoryBuilder::default();
let got =
add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
let want = Transaction {
date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
postings: bcc::Vec::from_iter_in(
[
Posting {
account: ctx.accounts.ensure("Account 1"),
amount: Amount::from_value(jpy, dec!(200)),
converted_amount: None,
},
Posting {
account: ctx.accounts.ensure("Account 2"),
amount: Amount::from_value(jpy, dec!(100)),
converted_amount: None,
},
Posting {
account: ctx.accounts.ensure("Account 3"),
amount: Amount::from_value(jpy, dec!(150)),
converted_amount: None,
},
Posting {
account: ctx.accounts.ensure("Account 4"),
amount: Amount::from_value(jpy, dec!(-450)),
converted_amount: None,
},
],
&arena,
)
.into_boxed_slice(),
};
assert_eq!(want, got);
}
#[test]
fn add_transaction_deduced_amount_contains_multi_commodity() {
let arena = Bump::new();
let mut ctx = ReportContext::new(&arena);
let mut bal = Balance::default();
let jpy = ctx.commodities.ensure("JPY");
let chf = ctx.commodities.ensure("CHF");
let eur = ctx.commodities.ensure("EUR");
let input = indoc! {"
2024/08/01 Sample
Account 1 1200 JPY
Account 2 234 EUR
Account 3 34.56 CHF
Account 4
"};
let txn = parse_transaction(input);
let mut price_repos = PriceRepositoryBuilder::default();
let got =
add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
let want = Transaction {
date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
postings: bcc::Vec::from_iter_in(
[
Posting {
account: ctx.accounts.ensure("Account 1"),
amount: Amount::from_value(jpy, dec!(1200)),
converted_amount: None,
},
Posting {
account: ctx.accounts.ensure("Account 2"),
amount: Amount::from_value(eur, dec!(234)),
converted_amount: None,
},
Posting {
account: ctx.accounts.ensure("Account 3"),
amount: Amount::from_value(chf, dec!(34.56)),
converted_amount: None,
},
Posting {
account: ctx.accounts.ensure("Account 4"),
amount: Amount::from_iter([
(jpy, dec!(-1200)),
(eur, dec!(-234)),
(chf, dec!(-34.56)),
]),
converted_amount: None,
},
],
&arena,
)
.into_boxed_slice(),
};
assert_eq!(want, got);
}
#[test]
fn add_transaction_fails_when_two_posting_does_not_have_amount() {
let input = indoc! {"
2024/08/01 Sample
Account 1 ; no amount
Account 2 ; no amount
"};
let txn = parse_transaction(input);
let arena = Bump::new();
let mut ctx = ReportContext::new(&arena);
let mut bal = Balance::default();
let mut price_repos = PriceRepositoryBuilder::default();
let got =
add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect_err("must fail");
assert_eq!(
got,
BookKeepError::UndeduciblePostingAmount(
Tracked::new(0, TrackedSpan::new(20..29)),
Tracked::new(1, TrackedSpan::new(44..53))
)
);
}
#[test]
fn add_transaction_fails_when_posting_has_zero_cost() {
let input = indoc! {"
2024/08/01 Sample
Account 1 1 AAPL @ 0 USD
Account 2 100 USD
"};
let txn = parse_transaction(input);
let arena = Bump::new();
let mut ctx = ReportContext::new(&arena);
let mut bal = Balance::default();
let mut price_repos = PriceRepositoryBuilder::default();
let got =
add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect_err("must fail");
assert_eq!(
got,
BookKeepError::ZeroExchangeRate(TrackedSpan::new(48..55))
);
}
#[test]
fn add_transaction_balances_with_lot() {
let arena = Bump::new();
let mut ctx = ReportContext::new(&arena);
let mut bal = Balance::default();
let input = indoc! {"
2024/08/01 Sample
Account 1 12 OKANE {100 JPY}
Account 2 -1,200 JPY
"};
let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap();
let txn = parse_transaction(input);
let mut price_repos = PriceRepositoryBuilder::default();
let got =
add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
let okane = ctx.commodities.resolve("OKANE").unwrap();
let jpy = ctx.commodities.resolve("JPY").unwrap();
let want = Transaction {
date,
postings: bcc::Vec::from_iter_in(
[
Posting {
account: ctx.accounts.ensure("Account 1"),
amount: Amount::from_value(okane, dec!(12)),
converted_amount: Some(SingleAmount::from_value(jpy, dec!(1200))),
},
Posting {
account: ctx.accounts.ensure("Account 2"),
amount: Amount::from_value(jpy, dec!(-1200)),
converted_amount: None,
},
],
&arena,
)
.into_boxed_slice(),
};
assert_eq!(want, got);
let want_prices = vec![
PriceEvent {
date,
price_x: SingleAmount::from_value(okane, dec!(1)),
price_y: SingleAmount::from_value(jpy, dec!(100)),
},
PriceEvent {
date,
price_x: SingleAmount::from_value(jpy, dec!(1)),
price_y: SingleAmount::from_value(okane, dec!(1) / dec!(100)),
},
];
assert_eq!(want_prices, price_repos.to_events());
}
#[test]
fn add_transaction_balances_with_price() {
let arena = Bump::new();
let mut ctx = ReportContext::new(&arena);
let mut bal = Balance::default();
let input = indoc! {"
2024/08/01 Sample
Account 1 12 OKANE @@ (12 * 100 JPY)
Account 2 -1,200 JPY
"};
let txn = parse_transaction(input);
let mut price_repos = PriceRepositoryBuilder::default();
let okane = ctx.commodities.ensure("OKANE");
let jpy = ctx.commodities.ensure("JPY");
let got =
add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
let want = Transaction {
date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
postings: bcc::Vec::from_iter_in(
[
Posting {
account: ctx.accounts.ensure("Account 1"),
amount: Amount::from_value(okane, dec!(12)),
converted_amount: Some(SingleAmount::from_value(jpy, dec!(1200))),
},
Posting {
account: ctx.accounts.ensure("Account 2"),
amount: Amount::from_value(jpy, dec!(-1200)),
converted_amount: None,
},
],
&arena,
)
.into_boxed_slice(),
};
assert_eq!(want, got);
}
#[test]
fn add_transaction_balances_with_lot_and_price() {
let arena = Bump::new();
let mut ctx = ReportContext::new(&arena);
let mut bal = Balance::default();
let input = indoc! {"
2024/08/01 Sample
Account 1 -12 OKANE {100 JPY} @ 120 JPY
Account 2 1,440 JPY
Income -240 JPY
"};
let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap();
let txn = parse_transaction(input);
let mut price_repos = PriceRepositoryBuilder::default();
let got =
add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
let okane = ctx.commodities.resolve("OKANE").unwrap();
let jpy = ctx.commodities.resolve("JPY").unwrap();
let want = Transaction {
date,
postings: bcc::Vec::from_iter_in(
[
Posting {
account: ctx.accounts.ensure("Account 1"),
amount: Amount::from_value(okane, dec!(-12)),
converted_amount: Some(SingleAmount::from_value(jpy, dec!(-1440))),
},
Posting {
account: ctx.accounts.ensure("Account 2"),
amount: Amount::from_value(jpy, dec!(1440)),
converted_amount: None,
},
Posting {
account: ctx.accounts.ensure("Income"),
amount: Amount::from_value(jpy, dec!(-240)),
converted_amount: None,
},
],
&arena,
)
.into_boxed_slice(),
};
assert_eq!(want, got);
let want_prices = vec![
PriceEvent {
date,
price_x: SingleAmount::from_value(okane, dec!(1)),
price_y: SingleAmount::from_value(jpy, dec!(120)),
},
PriceEvent {
date,
price_x: SingleAmount::from_value(jpy, dec!(1)),
price_y: SingleAmount::from_value(okane, dec!(1) / dec!(120)),
},
];
assert_eq!(want_prices, price_repos.to_events());
}
#[test]
fn add_transaction_deduces_price_info() {
let arena = Bump::new();
let mut ctx = ReportContext::new(&arena);
let mut bal = Balance::default();
let input = indoc! {"
2024/08/01 Sample
Account 1 -12 OKANE
Account 2 1,000 JPY
Account 3 440 JPY
"};
let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap();
let txn = parse_transaction(input);
let mut price_repos = PriceRepositoryBuilder::default();
let got =
add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
let okane = ctx.commodities.resolve("OKANE").unwrap();
let jpy = ctx.commodities.resolve("JPY").unwrap();
let want = Transaction {
date,
postings: bcc::Vec::from_iter_in(
[
Posting {
account: ctx.accounts.ensure("Account 1"),
amount: Amount::from_value(okane, dec!(-12)),
converted_amount: Some(SingleAmount::from_value(jpy, dec!(-1440))),
},
Posting {
account: ctx.accounts.ensure("Account 2"),
amount: Amount::from_value(jpy, dec!(1000)),
converted_amount: Some(SingleAmount::from_value(
okane,
dec!(8.333333333333333333333333300),
)),
},
Posting {
account: ctx.accounts.ensure("Account 3"),
amount: Amount::from_value(jpy, dec!(440)),
converted_amount: Some(SingleAmount::from_value(
okane,
dec!(3.6666666666666666666666666520),
)),
},
],
&arena,
)
.into_boxed_slice(),
};
assert_eq!(want, got);
let want_prices = vec![
PriceEvent {
date,
price_x: SingleAmount::from_value(okane, dec!(1)),
price_y: SingleAmount::from_value(jpy, dec!(120)),
},
PriceEvent {
date,
price_x: SingleAmount::from_value(jpy, dec!(1)),
price_y: SingleAmount::from_value(okane, dec!(1) / dec!(120)),
},
];
assert_eq!(want_prices, price_repos.to_events());
}
#[test]
fn add_transaction_balances_minor_diff() {
let arena = Bump::new();
let mut ctx = ReportContext::new(&arena);
let chf = ctx.commodities.insert("CHF").unwrap();
ctx.commodities
.set_format(chf, "20,000.00".parse().unwrap());
let mut bal = Balance::default();
let input = indoc! {"
2020/08/08 Petrol Station
Expenses:Travel:Petrol 30.33 EUR @ 1.0902 CHF
Expenses:Commissions 1.50 CHF ; Payee: Bank
Expenses:Commissions 0.06 EUR @ 1.0902 CHF ; Payee: Bank
Expenses:Commissions 0.07 CHF ; Payee: Bank
Assets:Banks -34.70 CHF
"};
let date = NaiveDate::from_ymd_opt(2020, 8, 8).unwrap();
let txn = parse_transaction(input);
let mut price_repos = PriceRepositoryBuilder::default();
let got =
add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
let eur = ctx.commodities.resolve("EUR").unwrap();
let want = Transaction {
date,
postings: bcc::Vec::from_iter_in(
[
Posting {
account: ctx.accounts.ensure("Expenses:Travel:Petrol"),
amount: Amount::from_value(eur, dec!(30.33)),
converted_amount: Some(SingleAmount::from_value(chf, dec!(33.065766))),
},
Posting {
account: ctx.accounts.ensure("Expenses:Commissions"),
amount: Amount::from_value(chf, dec!(1.50)),
converted_amount: None,
},
Posting {
account: ctx.accounts.ensure("Expenses:Commissions"),
amount: Amount::from_value(eur, dec!(0.06)),
converted_amount: Some(SingleAmount::from_value(chf, dec!(0.065412))),
},
Posting {
account: ctx.accounts.ensure("Expenses:Commissions"),
amount: Amount::from_value(chf, dec!(0.07)),
converted_amount: None,
},
Posting {
account: ctx.accounts.ensure("Assets:Banks"),
amount: Amount::from_value(chf, dec!(-34.70)),
converted_amount: None,
},
],
&arena,
)
.into_boxed_slice(),
};
assert_eq!(want, got);
let want_prices = vec![
PriceEvent {
date,
price_x: SingleAmount::from_value(chf, dec!(1)),
price_y: SingleAmount::from_value(eur, Decimal::ONE / dec!(1.0902)),
},
PriceEvent {
date,
price_x: SingleAmount::from_value(chf, dec!(1)),
price_y: SingleAmount::from_value(eur, Decimal::ONE / dec!(1.0902)),
},
PriceEvent {
date,
price_x: SingleAmount::from_value(eur, dec!(1)),
price_y: SingleAmount::from_value(chf, dec!(1.0902)),
},
PriceEvent {
date,
price_x: SingleAmount::from_value(eur, dec!(1)),
price_y: SingleAmount::from_value(chf, dec!(1.0902)),
},
];
assert_eq!(want_prices, price_repos.to_events());
}
}