1use std::{borrow::Borrow, path::PathBuf};
5
6use annotate_snippets::{Annotation, AnnotationKind};
7use bumpalo::Bump;
8use bumpalo::collections as bcc;
9use chrono::NaiveDate;
10use rust_decimal::Decimal;
11
12use crate::parse::ParsedSpan;
13use crate::{
14 load,
15 report::eval::EvalError,
16 syntax::{
17 self,
18 decoration::AsUndecorated,
19 tracked::{Tracked, TrackedSpan},
20 },
21};
22
23use super::{
24 balance::{Balance, BalanceError},
25 context::ReportContext,
26 error::{self, ReportError},
27 eval::{Amount, Evaluable, OwnedEvalError, PostingAmount, SingleAmount},
28 price_db::{PriceEvent, PriceRepositoryBuilder, PriceSource},
29 query::Ledger,
30 transaction::{Posting, Transaction},
31};
32
33#[derive(Debug, thiserror::Error, PartialEq, Eq)]
35pub enum BookKeepError {
36 #[error("failed to evaluate the expression: {0}")]
37 EvalFailure(#[source] OwnedEvalError, TrackedSpan),
38 #[error("failed balance assertion: {source}")]
39 BalanceFailure {
40 #[source]
41 source: BalanceError,
42 account: TrackedSpan,
43 balance: TrackedSpan,
44 },
45 #[error("transaction cannot have multiple postings without constraints")]
46 UndeduciblePostingAmount(Tracked<usize>, Tracked<usize>),
47 #[error("transaction cannot have unbalanced postings: {0}")]
48 UnbalancedPostings(String),
49 #[error("balance assertion off by {diff}, computed balance is {computed}")]
50 BalanceAssertionFailure {
51 account_span: TrackedSpan,
52 balance_span: TrackedSpan,
53 computed: String,
54 diff: String,
55 },
56 #[error("already registered account alias: {0}")]
57 InvalidAccountAlias(String),
58 #[error("already registered commodity alias: {0}")]
59 InvalidCommodityAlias(String),
60 #[error("posting without commodity should not have exchange")]
61 ZeroAmountWithExchange(TrackedSpan),
62 #[error("cost or lot exchange must not be zero")]
63 ZeroExchangeRate(TrackedSpan),
64 #[error("cost or lot exchange must have different commodity from the amount commodity")]
65 ExchangeWithAmountCommodity {
66 posting_amount: TrackedSpan,
67 exchange: TrackedSpan,
68 },
69}
70
71impl BookKeepError {
72 pub(super) fn annotations<'arena>(
74 &self,
75 bump: &'arena Bump,
76 parsed_span: &ParsedSpan,
77 text: &str,
78 ) -> Vec<Annotation<'arena>> {
79 match self {
80 BookKeepError::EvalFailure(err, pos) => vec![
81 AnnotationKind::Primary
82 .span(parsed_span.resolve(pos))
83 .label(bumpalo::format!(in &bump, "{}", err).into_bump_str()),
84 ],
85 BookKeepError::BalanceFailure {
86 source: err,
87 account,
88 balance,
89 } => vec![
90 AnnotationKind::Primary
91 .span(parsed_span.resolve(balance))
92 .label(bumpalo::format!(in &bump, "{}", err).into_bump_str()),
93 AnnotationKind::Context
94 .span(parsed_span.resolve(account))
95 .label(bumpalo::format!(in &bump, "{}", err.note()).into_bump_str()),
96 ],
97 BookKeepError::UndeduciblePostingAmount(first, second) => vec![
98 AnnotationKind::Context
99 .span(parsed_span.resolve(&first.span()))
100 .label("first posting without constraints"),
101 AnnotationKind::Primary
102 .span(parsed_span.resolve(&second.span()))
103 .label("cannot deduce this posting"),
104 ],
105 BookKeepError::BalanceAssertionFailure {
106 balance_span,
107 account_span,
108 computed,
109 ..
110 } => {
111 let msg = bumpalo::format!(
112 in &bump,
113 "computed balance: {}", computed,
114 );
115 vec![
116 AnnotationKind::Primary
117 .span(parsed_span.resolve(balance_span))
118 .label("not match the computed balance"),
119 AnnotationKind::Context
120 .span(parsed_span.resolve(account_span))
121 .label(msg.into_bump_str()),
122 ]
123 }
124 BookKeepError::ZeroAmountWithExchange(exchange) => vec![
125 AnnotationKind::Primary
126 .span(parsed_span.resolve(exchange))
127 .label("absolute zero posting should not have exchange"),
128 ],
129 BookKeepError::ZeroExchangeRate(exchange) => vec![
130 AnnotationKind::Primary
131 .span(parsed_span.resolve(exchange))
132 .label("exchange with zero amount"),
133 ],
134 BookKeepError::ExchangeWithAmountCommodity {
135 posting_amount,
136 exchange,
137 } => vec![
138 AnnotationKind::Context
139 .span(parsed_span.resolve(posting_amount))
140 .label("posting amount"),
141 AnnotationKind::Primary
142 .span(parsed_span.resolve(exchange))
143 .label("exchange cannot have the same commodity with posting"),
144 ],
145 _ => {
146 vec![
150 AnnotationKind::Primary
151 .span(0..text.len())
152 .label("error occured"),
153 ]
154 }
155 }
156 }
157}
158
159#[derive(Debug, Default)]
161pub struct ProcessOptions {
163 pub price_db_path: Option<PathBuf>,
165}
166
167pub fn process<'ctx, L, F>(
171 ctx: &mut ReportContext<'ctx>,
172 loader: L,
173 options: &ProcessOptions,
174) -> Result<Ledger<'ctx>, ReportError>
175where
176 L: Borrow<load::Loader<F>>,
177 F: load::FileSystem,
178{
179 let mut accum = ProcessAccumulator::new();
180 loader.borrow().load(|path, pctx, entry| {
181 accum.process(ctx, entry).map_err(|berr| {
182 ReportError::BookKeep(
183 berr,
184 error::ErrorContext::new(
185 loader.borrow().error_style().clone(),
186 path.to_owned(),
187 pctx,
188 ),
189 )
190 })
191 })?;
192 if let Some(price_db_path) = options.price_db_path.as_deref() {
193 accum
194 .price_repos
195 .load_price_db(ctx, loader.borrow().filesystem(), price_db_path)?;
196 }
197 Ok(Ledger {
198 transactions: accum.txns,
199 raw_balance: accum.balance,
200 price_repos: accum.price_repos.build(),
201 })
202}
203
204struct ProcessAccumulator<'ctx> {
205 balance: Balance<'ctx>,
206 txns: Vec<Transaction<'ctx>>,
207 price_repos: PriceRepositoryBuilder<'ctx>,
208}
209
210impl<'ctx> ProcessAccumulator<'ctx> {
211 fn new() -> Self {
212 Self {
213 balance: Balance::default(),
214 txns: Vec::new(),
215 price_repos: PriceRepositoryBuilder::default(),
216 }
217 }
218
219 fn process(
220 &mut self,
221 ctx: &mut ReportContext<'ctx>,
222 entry: &syntax::tracked::LedgerEntry,
223 ) -> Result<(), BookKeepError> {
224 match entry {
225 syntax::LedgerEntry::Txn(txn) => {
226 self.txns.push(add_transaction(
227 ctx,
228 &mut self.price_repos,
229 &mut self.balance,
230 txn,
231 )?);
232 Ok(())
233 }
234 syntax::LedgerEntry::Account(account) => {
235 let canonical = ctx.accounts.ensure(&account.name);
236 for ad in &account.details {
237 if let syntax::AccountDetail::Alias(alias) = ad {
238 ctx.accounts
239 .insert_alias(alias, canonical)
240 .map_err(|_| BookKeepError::InvalidAccountAlias(alias.to_string()))?;
241 }
242 }
243 Ok(())
244 }
245 syntax::LedgerEntry::Commodity(commodity) => {
246 let canonical = ctx.commodities.ensure(&commodity.name);
247 for cd in &commodity.details {
248 match cd {
249 syntax::CommodityDetail::Alias(alias) => {
250 ctx.commodities
251 .insert_alias(alias, canonical)
252 .map_err(|_| {
253 BookKeepError::InvalidCommodityAlias(alias.to_string())
254 })?;
255 }
256 syntax::CommodityDetail::Format(format_amount) => {
257 ctx.commodities.set_format(canonical, format_amount.value);
258 }
259 _ => {}
260 }
261 }
262 Ok(())
263 }
264 _ => Ok(()),
265 }
266 }
267}
268fn add_transaction<'ctx>(
270 ctx: &mut ReportContext<'ctx>,
271 price_repos: &mut PriceRepositoryBuilder<'ctx>,
272 bal: &mut Balance<'ctx>,
273 txn: &syntax::tracked::Transaction,
274) -> Result<Transaction<'ctx>, BookKeepError> {
275 let mut postings = bcc::Vec::with_capacity_in(txn.posts.len(), ctx.arena);
278 let mut unfilled: Option<Tracked<usize>> = None;
279 let mut balance = Amount::default();
280 for (i, posting) in txn.posts.iter().enumerate() {
281 let posting = posting.as_undecorated();
282 let account_span = posting.account.span();
283 let account = ctx.accounts.ensure(posting.account.as_undecorated());
284 let (evaluated, price_event) = match process_posting(ctx, bal, txn.date, account, posting)?
285 {
286 (Some(x), y) => (x, y),
287 (None, y) => {
288 if let Some(first) = unfilled.replace(Tracked::new(i, account_span.clone())) {
289 return Err(BookKeepError::UndeduciblePostingAmount(
290 first,
291 Tracked::new(i, account_span.clone()),
292 ));
293 } else {
294 (
297 EvaluatedPosting {
298 amount: PostingAmount::zero(),
299 converted_amount: None,
300 balance_delta: PostingAmount::zero(),
301 },
302 y,
303 )
304 }
305 }
306 };
307 if let Some(event) = price_event {
308 price_repos.insert_price(PriceSource::Ledger, event);
309 }
310 balance += evaluated.balance_delta;
311 postings.push(Posting {
312 account,
313 amount: evaluated.amount.into(),
314 converted_amount: evaluated.converted_amount,
315 });
316 }
317 if let Some(u) = unfilled {
318 let u = *u.as_undecorated();
319 let deduced: Amount = balance.negate();
321 postings[u].amount = deduced.clone();
322 bal.add_amount(postings[u].account, deduced);
323 } else {
324 check_balance(ctx, price_repos, &mut postings, txn.date, balance)?;
325 }
326 Ok(Transaction {
327 date: txn.date,
328 postings: postings.into_boxed_slice(),
329 })
330}
331
332struct EvaluatedPosting<'ctx> {
334 amount: PostingAmount<'ctx>,
336
337 converted_amount: Option<SingleAmount<'ctx>>,
339
340 balance_delta: PostingAmount<'ctx>,
342}
343
344fn map_eval_err<'ctx>(
345 ctx: &mut ReportContext<'ctx>,
346 e: EvalError<'ctx>,
347 span: TrackedSpan,
348) -> BookKeepError {
349 BookKeepError::EvalFailure(e.into_owned(ctx), span)
350}
351
352fn process_posting<'ctx>(
353 ctx: &mut ReportContext<'ctx>,
354 bal: &mut Balance<'ctx>,
355 date: NaiveDate,
356 account: super::Account<'ctx>,
357 posting: &syntax::tracked::Posting,
358) -> Result<(Option<EvaluatedPosting<'ctx>>, Option<PriceEvent<'ctx>>), BookKeepError> {
359 match (&posting.amount, &posting.balance) {
360 (None, None) => Ok((None, None)),
362 (None, Some(balance_constraints)) => {
364 let balance_span = balance_constraints.span();
365 let current: PostingAmount = balance_constraints
366 .as_undecorated()
367 .eval_mut(ctx)
368 .and_then(|x| x.try_into())
369 .map_err(|e| map_eval_err(ctx, e, balance_span.clone()))?;
370 let prev: PostingAmount = bal.set_partial(ctx, account, current).map_err(|e| {
371 BookKeepError::BalanceFailure {
372 source: e,
373 account: posting.account.span(),
374 balance: balance_span.clone(),
375 }
376 })?;
377 let amount = current
378 .check_sub(prev)
379 .map_err(|e| map_eval_err(ctx, e, balance_span))?;
380 Ok((
381 Some(EvaluatedPosting {
382 amount,
383 converted_amount: None,
384 balance_delta: amount,
385 }),
386 None,
387 ))
388 }
389 (Some(syntax_amount), balance_constraints) => {
391 let computed = ComputedPosting::compute_from_syntax(ctx, syntax_amount)?;
392 let current = bal.add_posting_amount(account, computed.amount);
393 if let Some(balance_constraints) = balance_constraints.as_ref() {
394 let balance_span = balance_constraints.span();
395 let expected: PostingAmount = balance_constraints
396 .as_undecorated()
397 .eval_mut(ctx)
398 .and_then(|x| x.try_into())
399 .map_err(|e| map_eval_err(ctx, e, balance_span.clone()))?;
400 let diff = current.assert_balance(&expected);
401 if !diff.is_absolute_zero() {
402 return Err(BookKeepError::BalanceAssertionFailure {
403 account_span: posting.account.span(),
404 balance_span: balance_constraints.span(),
405 computed: format!("{}", current.as_inline_display(ctx)),
406 diff: format!("{}", diff.as_inline_display(ctx)),
407 });
408 }
409 }
410 let balance_delta = computed.calculate_balance_amount(ctx)?;
411 Ok((
412 Some(EvaluatedPosting {
413 amount: computed.amount,
414 converted_amount: computed.calculate_converted_amount(ctx)?,
415 balance_delta,
416 }),
417 posting_price_event(date, &computed)?,
418 ))
419 }
420 }
421}
422
423struct ComputedPosting<'ctx> {
425 amount_span: TrackedSpan,
426 amount: PostingAmount<'ctx>,
427 cost: Option<Exchange<'ctx>>,
428 lot: Option<Exchange<'ctx>>,
429}
430
431impl<'ctx> ComputedPosting<'ctx> {
432 fn compute_from_syntax(
434 ctx: &mut ReportContext<'ctx>,
435 syntax_amount: &syntax::tracked::PostingAmount<'_>,
436 ) -> Result<Self, BookKeepError> {
437 let amount_span = syntax_amount.amount.span();
438 let amount: PostingAmount = syntax_amount
439 .amount
440 .as_undecorated()
441 .eval_mut(ctx)
442 .and_then(|x| x.try_into())
443 .map_err(|e| map_eval_err(ctx, e, amount_span.clone()))?;
444 let cost = posting_cost_exchange(syntax_amount)
445 .map(|exchange| Exchange::try_from_syntax(ctx, syntax_amount, &amount, exchange))
446 .transpose()?;
447 let lot = posting_lot_exchange(syntax_amount)
448 .map(|exchange| Exchange::try_from_syntax(ctx, syntax_amount, &amount, exchange))
449 .transpose()?;
450 Ok(ComputedPosting {
451 amount_span,
452 amount,
453 cost,
454 lot,
455 })
456 }
457
458 fn calculate_converted_amount(
459 &self,
460 ctx: &mut ReportContext<'ctx>,
461 ) -> Result<Option<SingleAmount<'ctx>>, BookKeepError> {
462 let Some(rate) = self.cost.as_ref().or(self.lot.as_ref()) else {
463 return Ok(None);
464 };
465 let amount = self
466 .amount
467 .try_into()
468 .map_err(|e| map_eval_err(ctx, e, self.amount_span.clone()))?;
469 Ok(Some(rate.exchange(amount)))
470 }
471
472 fn calculate_balance_amount(
474 &self,
475 ctx: &mut ReportContext<'ctx>,
476 ) -> Result<PostingAmount<'ctx>, BookKeepError> {
477 match self.lot.as_ref().or(self.cost.as_ref()) {
488 Some(x) => {
489 let amount = self
490 .amount
491 .try_into()
492 .map_err(|e| map_eval_err(ctx, e, self.amount_span.clone()))?;
493 Ok(x.exchange(amount).into())
494 }
495 None => Ok(self.amount),
496 }
497 }
498}
499
500fn posting_price_event<'ctx>(
502 date: NaiveDate,
503 computed: &ComputedPosting<'ctx>,
504) -> Result<Option<PriceEvent<'ctx>>, BookKeepError> {
505 let exchange = match computed.cost.as_ref().or(computed.lot.as_ref()) {
506 None => return Ok(None),
507 Some(exchange) => exchange,
508 };
509 Ok(Some(match computed.amount {
510 PostingAmount::Zero => {
512 unreachable!("Given Exchange is set None, this must be SingleAmount.")
513 }
514 PostingAmount::Single(amount) => match exchange {
515 Exchange::Rate(rate) => PriceEvent {
516 price_x: SingleAmount::from_value(amount.commodity, Decimal::ONE),
517 price_y: *rate,
518 date,
519 },
520 Exchange::Total(total) => PriceEvent {
521 price_x: amount.abs(),
522 price_y: *total,
523 date,
524 },
525 },
526 }))
527}
528
529enum Exchange<'ctx> {
531 Total(SingleAmount<'ctx>),
532 Rate(SingleAmount<'ctx>),
533}
534
535impl<'ctx> Exchange<'ctx> {
536 fn is_zero(&self) -> bool {
537 match self {
538 Exchange::Total(x) => x.value.is_zero(),
539 Exchange::Rate(x) => x.value.is_zero(),
540 }
541 }
542
543 fn try_from_syntax<'a>(
544 ctx: &mut ReportContext<'ctx>,
545 syntax_amount: &syntax::tracked::PostingAmount<'a>,
546 posting_amount: &PostingAmount<'ctx>,
547 exchange: &syntax::tracked::Tracked<syntax::Exchange<'a>>,
548 ) -> Result<Exchange<'ctx>, BookKeepError> {
549 let (rate_commodity, rate) = match exchange.as_undecorated() {
550 syntax::Exchange::Rate(rate) => {
551 let rate: SingleAmount<'ctx> = rate
552 .eval_mut(ctx)
553 .and_then(|x| x.try_into())
554 .map_err(|e| map_eval_err(ctx, e, exchange.span()))?;
555 (rate.commodity, Exchange::Rate(rate))
556 }
557 syntax::Exchange::Total(rate) => {
558 let rate: SingleAmount<'ctx> = rate
559 .eval_mut(ctx)
560 .and_then(|x| x.try_into())
561 .map_err(|e| map_eval_err(ctx, e, exchange.span()))?;
562 (rate.commodity, Exchange::Total(rate))
563 }
564 };
565 if rate.is_zero() {
566 return Err(BookKeepError::ZeroExchangeRate(exchange.span()));
567 }
568 match posting_amount {
569 PostingAmount::Zero => Err(BookKeepError::ZeroAmountWithExchange(exchange.span())),
570 PostingAmount::Single(amount) => {
571 if amount.commodity == rate_commodity {
572 Err(BookKeepError::ExchangeWithAmountCommodity {
573 posting_amount: syntax_amount.amount.span(),
574 exchange: exchange.span(),
575 })
576 } else {
577 Ok(rate)
578 }
579 }
580 }
581 }
582
583 fn exchange(&self, amount: SingleAmount<'ctx>) -> SingleAmount<'ctx> {
586 match self {
587 Exchange::Rate(rate) => *rate * amount.value,
588 Exchange::Total(abs) => abs.with_sign_of(amount),
589 }
590 }
591}
592
593fn check_balance<'ctx>(
595 ctx: &ReportContext<'ctx>,
596 price_repos: &mut PriceRepositoryBuilder<'ctx>,
597 postings: &mut bcc::Vec<'ctx, Posting<'ctx>>,
598 date: NaiveDate,
599 balance: Amount<'ctx>,
600) -> Result<(), BookKeepError> {
601 log::trace!(
602 "balance before rounding in txn: {}",
603 balance.as_inline_display(ctx)
604 );
605 let balance = balance.round(ctx);
606 if balance.is_zero() {
607 return Ok(());
608 }
609 if let Some((a1, a2)) = balance.maybe_pair() {
610 for p in postings.iter_mut() {
612 let amount: Result<SingleAmount<'_>, _> = (&p.amount).try_into();
613 if let Ok(amount) = amount {
614 if a1.commodity == amount.commodity {
617 p.converted_amount = Some(SingleAmount::from_value(
618 a2.commodity,
619 (a2.value / a1.value).abs() * amount.value,
620 ));
621 } else if a2.commodity == amount.commodity {
622 p.converted_amount = Some(SingleAmount::from_value(
623 a1.commodity,
624 (a1.value / a2.value).abs() * amount.value,
625 ));
626 }
627 }
628 }
629 price_repos.insert_price(
631 PriceSource::Ledger,
632 PriceEvent {
633 date,
634 price_x: a1.abs(),
635 price_y: a2.abs(),
636 },
637 );
638 return Ok(());
639 }
640 if !balance.is_zero() {
641 return Err(BookKeepError::UnbalancedPostings(format!(
642 "{}",
643 balance.as_inline_display(ctx)
644 )));
645 }
646 Ok(())
647}
648
649#[inline]
651fn posting_cost_exchange<'a, 'ctx>(
652 posting_amount: &'a syntax::tracked::PostingAmount<'ctx>,
653) -> Option<&'a syntax::tracked::Tracked<syntax::Exchange<'ctx>>> {
654 posting_amount.cost.as_ref()
655}
656
657#[inline]
659fn posting_lot_exchange<'a, 'ctx>(
660 posting_amount: &'a syntax::tracked::PostingAmount<'ctx>,
661) -> Option<&'a syntax::tracked::Tracked<syntax::Exchange<'ctx>>> {
662 posting_amount.lot.price.as_ref()
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 use bumpalo::Bump;
670 use chrono::NaiveDate;
671 use indoc::indoc;
672 use maplit::hashmap;
673 use pretty_assertions::assert_eq;
674 use rust_decimal_macros::dec;
675
676 use crate::{
677 parse::{self, testing::expect_parse_ok},
678 syntax::tracked::TrackedSpan,
679 };
680
681 fn parse_transaction(input: &'_ str) -> syntax::tracked::Transaction<'_> {
682 let (_, ret) = expect_parse_ok(parse::transaction::transaction, input);
683 ret
684 }
685
686 #[test]
687 fn add_transaction_fails_with_inconsistent_balance() {
688 let arena = Bump::new();
689 let mut ctx = ReportContext::new(&arena);
690 let mut bal = Balance::default();
691 bal.add_posting_amount(
692 ctx.accounts.ensure("Account 1"),
693 PostingAmount::from_value(ctx.commodities.ensure("JPY"), dec!(1000)),
694 );
695 let input = indoc! {"
696 2024/08/01 Sample
697 Account 2
698 Account 1 200 JPY = 1300 JPY
699 "};
700 let txn = parse_transaction(input);
701 let mut price_repos = PriceRepositoryBuilder::default();
702
703 let got_err = add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).unwrap_err();
704
705 assert!(
706 matches!(got_err, BookKeepError::BalanceAssertionFailure { .. }),
707 "unexpected got_err: {:?}",
708 got_err
709 );
710 }
711
712 #[test]
713 fn add_transaction_fails_with_inconsistent_absolute_zero_balance() {
714 let arena = Bump::new();
715 let mut ctx = ReportContext::new(&arena);
716 let mut bal = Balance::default();
717 bal.add_posting_amount(
718 ctx.accounts.ensure("Account 1"),
719 PostingAmount::from_value(ctx.commodities.ensure("JPY"), dec!(1000)),
720 );
721 let input = indoc! {"
722 2024/08/01 Sample
723 Account 2
724 Account 1 0 CHF = 0 ; must fail because of 1000 JPY
725 "};
726 let txn = parse_transaction(input);
727 let mut price_repos = PriceRepositoryBuilder::default();
728
729 let got_err = add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).unwrap_err();
730
731 assert!(
732 matches!(got_err, BookKeepError::BalanceAssertionFailure { .. }),
733 "unexpected got_err: {:?}",
734 got_err
735 );
736 }
737
738 #[test]
739 fn add_transaction_maintains_balance() {
740 let arena = Bump::new();
741 let mut ctx = ReportContext::new(&arena);
742 let mut bal = Balance::default();
743 bal.add_posting_amount(
744 ctx.accounts.ensure("Account 1"),
745 PostingAmount::from_value(ctx.commodities.ensure("JPY"), dec!(1000)),
746 );
747 bal.add_posting_amount(
748 ctx.accounts.ensure("Account 1"),
749 PostingAmount::from_value(ctx.commodities.ensure("EUR"), dec!(123)),
750 );
751 bal.add_posting_amount(
752 ctx.accounts.ensure("Account 2"),
753 PostingAmount::from_value(ctx.commodities.ensure("EUR"), dec!(1)),
754 );
755 bal.add_posting_amount(
756 ctx.accounts.ensure("Account 4"),
757 PostingAmount::from_value(ctx.commodities.ensure("CHF"), dec!(10)),
758 );
759 let input = indoc! {"
760 2024/08/01 Sample
761 Account 1 200 JPY = 1200 JPY
762 Account 2 0 JPY = 0 JPY
763 Account 2 -100 JPY = -100 JPY
764 Account 2 -100 JPY = -200 JPY
765 Account 3 2.00 CHF @ 150 JPY
766 Account 4 = -300 JPY
767 "};
768 let txn = parse_transaction(input);
769 let mut price_repos = PriceRepositoryBuilder::default();
770
771 let _ = add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
772
773 let want_balance: Balance = hashmap! {
774 ctx.accounts.ensure("Account 1") =>
775 Amount::from_iter([
776 (ctx.commodities.ensure("JPY"), dec!(1200)),
777 (ctx.commodities.ensure("EUR"), dec!(123)),
778 ]),
779 ctx.accounts.ensure("Account 2") =>
780 Amount::from_iter([
781 (ctx.commodities.ensure("JPY"), dec!(-200)),
782 (ctx.commodities.ensure("EUR"), dec!(1)),
783 ]),
784 ctx.accounts.ensure("Account 3") =>
785 Amount::from_value(ctx.commodities.ensure("CHF"), dec!(2)),
786 ctx.accounts.ensure("Account 4") =>
787 Amount::from_iter([
788 (ctx.commodities.ensure("JPY"), dec!(-300)),
789 (ctx.commodities.ensure("CHF"), dec!(10)),
790 ]),
791 }
792 .into_iter()
793 .collect();
794 assert_eq!(want_balance.into_vec(), bal.into_vec());
795 }
796
797 #[test]
798 fn add_transaction_emits_transaction_with_postings() {
799 let arena = Bump::new();
800 let mut ctx = ReportContext::new(&arena);
801 let mut bal = Balance::default();
802 let input = indoc! {"
803 2024/08/01 Sample
804 Account 1 200 JPY = 200 JPY
805 Account 2 -100 JPY = -100 JPY
806 Account 2 -100 JPY = -200 JPY
807 "};
808 let txn = parse_transaction(input);
809 let mut price_repos = PriceRepositoryBuilder::default();
810
811 let got =
812 add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
813
814 let want = Transaction {
815 date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
816 postings: bcc::Vec::from_iter_in(
817 [
818 Posting {
819 account: ctx.accounts.ensure("Account 1"),
820 amount: Amount::from_value(ctx.commodities.ensure("JPY"), dec!(200)),
821 converted_amount: None,
822 },
823 Posting {
824 account: ctx.accounts.ensure("Account 2"),
825 amount: Amount::from_value(ctx.commodities.ensure("JPY"), dec!(-100)),
826 converted_amount: None,
827 },
828 Posting {
829 account: ctx.accounts.ensure("Account 2"),
830 amount: Amount::from_value(ctx.commodities.ensure("JPY"), dec!(-100)),
831 converted_amount: None,
832 },
833 ],
834 &arena,
835 )
836 .into_boxed_slice(),
837 };
838 assert_eq!(want, got);
839 }
840
841 #[test]
842 fn add_transaction_emits_transaction_with_deduce_and_balance_concern() {
843 let arena = Bump::new();
844 let mut ctx = ReportContext::new(&arena);
845 let mut bal = Balance::default();
846 let jpy = ctx.commodities.ensure("JPY");
847 let usd = ctx.commodities.ensure("USD");
848 bal.add_posting_amount(
849 ctx.accounts.ensure("Account 1"),
850 PostingAmount::from_value(jpy, dec!(1000)),
851 );
852 bal.add_posting_amount(
853 ctx.accounts.ensure("Account 1"),
854 PostingAmount::from_value(usd, dec!(123)),
855 );
856 bal.add_posting_amount(
857 ctx.accounts.ensure("Account 2"),
858 PostingAmount::from_value(jpy, dec!(-100)),
859 );
860 bal.add_posting_amount(
861 ctx.accounts.ensure("Account 2"),
862 PostingAmount::from_value(usd, dec!(-30)),
863 );
864 bal.add_posting_amount(
865 ctx.accounts.ensure("Account 3"),
866 PostingAmount::from_value(jpy, dec!(-150)),
867 );
868 let input = indoc! {"
869 2024/08/01 Sample
870 Account 1 = 1200 JPY
871 Account 2 = 0 JPY
872 Account 3 = 0
873 Account 4
874 "};
875 let txn = parse_transaction(input);
876 let mut price_repos = PriceRepositoryBuilder::default();
877
878 let got =
879 add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
880
881 let want = Transaction {
882 date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
883 postings: bcc::Vec::from_iter_in(
884 [
885 Posting {
886 account: ctx.accounts.ensure("Account 1"),
887 amount: Amount::from_value(jpy, dec!(200)),
888 converted_amount: None,
889 },
890 Posting {
891 account: ctx.accounts.ensure("Account 2"),
892 amount: Amount::from_value(jpy, dec!(100)),
893 converted_amount: None,
894 },
895 Posting {
896 account: ctx.accounts.ensure("Account 3"),
897 amount: Amount::from_value(jpy, dec!(150)),
898 converted_amount: None,
899 },
900 Posting {
901 account: ctx.accounts.ensure("Account 4"),
902 amount: Amount::from_value(jpy, dec!(-450)),
903 converted_amount: None,
904 },
905 ],
906 &arena,
907 )
908 .into_boxed_slice(),
909 };
910 assert_eq!(want, got);
911 }
912
913 #[test]
914 fn add_transaction_deduced_amount_contains_multi_commodity() {
915 let arena = Bump::new();
916 let mut ctx = ReportContext::new(&arena);
917 let mut bal = Balance::default();
918 let jpy = ctx.commodities.ensure("JPY");
919 let chf = ctx.commodities.ensure("CHF");
920 let eur = ctx.commodities.ensure("EUR");
921 let input = indoc! {"
922 2024/08/01 Sample
923 Account 1 1200 JPY
924 Account 2 234 EUR
925 Account 3 34.56 CHF
926 Account 4
927 "};
928 let txn = parse_transaction(input);
929 let mut price_repos = PriceRepositoryBuilder::default();
930 let got =
931 add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
932 let want = Transaction {
933 date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
934 postings: bcc::Vec::from_iter_in(
935 [
936 Posting {
937 account: ctx.accounts.ensure("Account 1"),
938 amount: Amount::from_value(jpy, dec!(1200)),
939 converted_amount: None,
940 },
941 Posting {
942 account: ctx.accounts.ensure("Account 2"),
943 amount: Amount::from_value(eur, dec!(234)),
944 converted_amount: None,
945 },
946 Posting {
947 account: ctx.accounts.ensure("Account 3"),
948 amount: Amount::from_value(chf, dec!(34.56)),
949 converted_amount: None,
950 },
951 Posting {
952 account: ctx.accounts.ensure("Account 4"),
953 amount: Amount::from_iter([
954 (jpy, dec!(-1200)),
955 (eur, dec!(-234)),
956 (chf, dec!(-34.56)),
957 ]),
958 converted_amount: None,
959 },
960 ],
961 &arena,
962 )
963 .into_boxed_slice(),
964 };
965 assert_eq!(want, got);
966 }
967
968 #[test]
969 fn add_transaction_fails_when_two_posting_does_not_have_amount() {
970 let input = indoc! {"
971 2024/08/01 Sample
972 Account 1 ; no amount
973 Account 2 ; no amount
974 "};
975 let txn = parse_transaction(input);
976 let arena = Bump::new();
977 let mut ctx = ReportContext::new(&arena);
978 let mut bal = Balance::default();
979 let mut price_repos = PriceRepositoryBuilder::default();
980
981 let got =
982 add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect_err("must fail");
983
984 assert_eq!(
985 got,
986 BookKeepError::UndeduciblePostingAmount(
987 Tracked::new(0, TrackedSpan::new(20..29)),
988 Tracked::new(1, TrackedSpan::new(44..53))
989 )
990 );
991 }
992 #[test]
993 fn add_transaction_fails_when_posting_has_zero_cost() {
994 let input = indoc! {"
995 2024/08/01 Sample
996 Account 1 1 AAPL @ 0 USD
997 Account 2 100 USD
998 "};
999 let txn = parse_transaction(input);
1000 let arena = Bump::new();
1001 let mut ctx = ReportContext::new(&arena);
1002 let mut bal = Balance::default();
1003 let mut price_repos = PriceRepositoryBuilder::default();
1004
1005 let got =
1006 add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect_err("must fail");
1007
1008 assert_eq!(
1009 got,
1010 BookKeepError::ZeroExchangeRate(TrackedSpan::new(48..55))
1011 );
1012 }
1013
1014 #[test]
1015 fn add_transaction_balances_with_lot() {
1016 let arena = Bump::new();
1017 let mut ctx = ReportContext::new(&arena);
1018 let mut bal = Balance::default();
1019 let input = indoc! {"
1020 2024/08/01 Sample
1021 Account 1 12 OKANE {100 JPY}
1022 Account 2 -1,200 JPY
1023 "};
1024 let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap();
1025 let txn = parse_transaction(input);
1026 let mut price_repos = PriceRepositoryBuilder::default();
1027
1028 let got =
1029 add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
1030
1031 let okane = ctx.commodities.resolve("OKANE").unwrap();
1032 let jpy = ctx.commodities.resolve("JPY").unwrap();
1033 let want = Transaction {
1034 date,
1035 postings: bcc::Vec::from_iter_in(
1036 [
1037 Posting {
1038 account: ctx.accounts.ensure("Account 1"),
1039 amount: Amount::from_value(okane, dec!(12)),
1040 converted_amount: Some(SingleAmount::from_value(jpy, dec!(1200))),
1041 },
1042 Posting {
1043 account: ctx.accounts.ensure("Account 2"),
1044 amount: Amount::from_value(jpy, dec!(-1200)),
1045 converted_amount: None,
1046 },
1047 ],
1048 &arena,
1049 )
1050 .into_boxed_slice(),
1051 };
1052 assert_eq!(want, got);
1053
1054 let want_prices = vec![
1055 PriceEvent {
1056 date,
1057 price_x: SingleAmount::from_value(okane, dec!(1)),
1058 price_y: SingleAmount::from_value(jpy, dec!(100)),
1059 },
1060 PriceEvent {
1061 date,
1062 price_x: SingleAmount::from_value(jpy, dec!(1)),
1063 price_y: SingleAmount::from_value(okane, dec!(1) / dec!(100)),
1064 },
1065 ];
1066 assert_eq!(want_prices, price_repos.to_events());
1067 }
1068
1069 #[test]
1070 fn add_transaction_balances_with_price() {
1071 let arena = Bump::new();
1072 let mut ctx = ReportContext::new(&arena);
1073 let mut bal = Balance::default();
1074 let input = indoc! {"
1075 2024/08/01 Sample
1076 Account 1 12 OKANE @@ (12 * 100 JPY)
1077 Account 2 -1,200 JPY
1078 "};
1079 let txn = parse_transaction(input);
1080 let mut price_repos = PriceRepositoryBuilder::default();
1081 let okane = ctx.commodities.ensure("OKANE");
1082 let jpy = ctx.commodities.ensure("JPY");
1083
1084 let got =
1085 add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
1086
1087 let want = Transaction {
1088 date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
1089 postings: bcc::Vec::from_iter_in(
1090 [
1091 Posting {
1092 account: ctx.accounts.ensure("Account 1"),
1093 amount: Amount::from_value(okane, dec!(12)),
1094 converted_amount: Some(SingleAmount::from_value(jpy, dec!(1200))),
1095 },
1096 Posting {
1097 account: ctx.accounts.ensure("Account 2"),
1098 amount: Amount::from_value(jpy, dec!(-1200)),
1099 converted_amount: None,
1100 },
1101 ],
1102 &arena,
1103 )
1104 .into_boxed_slice(),
1105 };
1106 assert_eq!(want, got);
1107 }
1108
1109 #[test]
1110 fn add_transaction_balances_with_lot_and_price() {
1111 let arena = Bump::new();
1112 let mut ctx = ReportContext::new(&arena);
1113 let mut bal = Balance::default();
1114 let input = indoc! {"
1115 2024/08/01 Sample
1116 Account 1 -12 OKANE {100 JPY} @ 120 JPY
1117 Account 2 1,440 JPY
1118 Income -240 JPY
1119 "};
1120 let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap();
1121 let txn = parse_transaction(input);
1122 let mut price_repos = PriceRepositoryBuilder::default();
1123
1124 let got =
1125 add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
1126
1127 let okane = ctx.commodities.resolve("OKANE").unwrap();
1128 let jpy = ctx.commodities.resolve("JPY").unwrap();
1129 let want = Transaction {
1130 date,
1131 postings: bcc::Vec::from_iter_in(
1132 [
1133 Posting {
1134 account: ctx.accounts.ensure("Account 1"),
1135 amount: Amount::from_value(okane, dec!(-12)),
1136 converted_amount: Some(SingleAmount::from_value(jpy, dec!(-1440))),
1137 },
1138 Posting {
1139 account: ctx.accounts.ensure("Account 2"),
1140 amount: Amount::from_value(jpy, dec!(1440)),
1141 converted_amount: None,
1142 },
1143 Posting {
1144 account: ctx.accounts.ensure("Income"),
1145 amount: Amount::from_value(jpy, dec!(-240)),
1146 converted_amount: None,
1147 },
1148 ],
1149 &arena,
1150 )
1151 .into_boxed_slice(),
1152 };
1153 assert_eq!(want, got);
1154
1155 let want_prices = vec![
1156 PriceEvent {
1157 date,
1158 price_x: SingleAmount::from_value(okane, dec!(1)),
1159 price_y: SingleAmount::from_value(jpy, dec!(120)),
1160 },
1161 PriceEvent {
1162 date,
1163 price_x: SingleAmount::from_value(jpy, dec!(1)),
1164 price_y: SingleAmount::from_value(okane, dec!(1) / dec!(120)),
1165 },
1166 ];
1167 assert_eq!(want_prices, price_repos.to_events());
1168 }
1169
1170 #[test]
1171 fn add_transaction_deduces_price_info() {
1172 let arena = Bump::new();
1173 let mut ctx = ReportContext::new(&arena);
1174 let mut bal = Balance::default();
1175 let input = indoc! {"
1176 2024/08/01 Sample
1177 Account 1 -12 OKANE
1178 Account 2 1,000 JPY
1179 Account 3 440 JPY
1180 "};
1181 let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap();
1182 let txn = parse_transaction(input);
1183 let mut price_repos = PriceRepositoryBuilder::default();
1184
1185 let got =
1186 add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
1187
1188 let okane = ctx.commodities.resolve("OKANE").unwrap();
1189 let jpy = ctx.commodities.resolve("JPY").unwrap();
1190 let want = Transaction {
1191 date,
1192 postings: bcc::Vec::from_iter_in(
1193 [
1194 Posting {
1195 account: ctx.accounts.ensure("Account 1"),
1196 amount: Amount::from_value(okane, dec!(-12)),
1197 converted_amount: Some(SingleAmount::from_value(jpy, dec!(-1440))),
1198 },
1199 Posting {
1200 account: ctx.accounts.ensure("Account 2"),
1201 amount: Amount::from_value(jpy, dec!(1000)),
1202 converted_amount: Some(SingleAmount::from_value(
1203 okane,
1204 dec!(8.333333333333333333333333300),
1205 )),
1206 },
1207 Posting {
1208 account: ctx.accounts.ensure("Account 3"),
1209 amount: Amount::from_value(jpy, dec!(440)),
1210 converted_amount: Some(SingleAmount::from_value(
1211 okane,
1212 dec!(3.6666666666666666666666666520),
1213 )),
1214 },
1215 ],
1216 &arena,
1217 )
1218 .into_boxed_slice(),
1219 };
1220 assert_eq!(want, got);
1221
1222 let want_prices = vec![
1223 PriceEvent {
1224 date,
1225 price_x: SingleAmount::from_value(okane, dec!(1)),
1226 price_y: SingleAmount::from_value(jpy, dec!(120)),
1227 },
1228 PriceEvent {
1229 date,
1230 price_x: SingleAmount::from_value(jpy, dec!(1)),
1231 price_y: SingleAmount::from_value(okane, dec!(1) / dec!(120)),
1232 },
1233 ];
1234 assert_eq!(want_prices, price_repos.to_events());
1235 }
1236
1237 #[test]
1238 fn add_transaction_balances_minor_diff() {
1239 let arena = Bump::new();
1240 let mut ctx = ReportContext::new(&arena);
1241 let chf = ctx.commodities.insert("CHF").unwrap();
1242 ctx.commodities
1243 .set_format(chf, "20,000.00".parse().unwrap());
1244 let mut bal = Balance::default();
1245 let input = indoc! {"
1246 2020/08/08 Petrol Station
1247 Expenses:Travel:Petrol 30.33 EUR @ 1.0902 CHF
1248 Expenses:Commissions 1.50 CHF ; Payee: Bank
1249 Expenses:Commissions 0.06 EUR @ 1.0902 CHF ; Payee: Bank
1250 Expenses:Commissions 0.07 CHF ; Payee: Bank
1251 Assets:Banks -34.70 CHF
1252 "};
1253 let date = NaiveDate::from_ymd_opt(2020, 8, 8).unwrap();
1254 let txn = parse_transaction(input);
1255 let mut price_repos = PriceRepositoryBuilder::default();
1256
1257 let got =
1258 add_transaction(&mut ctx, &mut price_repos, &mut bal, &txn).expect("must succeed");
1259 let eur = ctx.commodities.resolve("EUR").unwrap();
1260 let want = Transaction {
1261 date,
1262 postings: bcc::Vec::from_iter_in(
1263 [
1264 Posting {
1265 account: ctx.accounts.ensure("Expenses:Travel:Petrol"),
1266 amount: Amount::from_value(eur, dec!(30.33)),
1267 converted_amount: Some(SingleAmount::from_value(chf, dec!(33.065766))),
1268 },
1269 Posting {
1270 account: ctx.accounts.ensure("Expenses:Commissions"),
1271 amount: Amount::from_value(chf, dec!(1.50)),
1272 converted_amount: None,
1273 },
1274 Posting {
1275 account: ctx.accounts.ensure("Expenses:Commissions"),
1276 amount: Amount::from_value(eur, dec!(0.06)),
1277 converted_amount: Some(SingleAmount::from_value(chf, dec!(0.065412))),
1278 },
1279 Posting {
1280 account: ctx.accounts.ensure("Expenses:Commissions"),
1281 amount: Amount::from_value(chf, dec!(0.07)),
1282 converted_amount: None,
1283 },
1284 Posting {
1285 account: ctx.accounts.ensure("Assets:Banks"),
1286 amount: Amount::from_value(chf, dec!(-34.70)),
1287 converted_amount: None,
1288 },
1289 ],
1290 &arena,
1291 )
1292 .into_boxed_slice(),
1293 };
1294 assert_eq!(want, got);
1295
1296 let want_prices = vec![
1297 PriceEvent {
1298 date,
1299 price_x: SingleAmount::from_value(chf, dec!(1)),
1300 price_y: SingleAmount::from_value(eur, Decimal::ONE / dec!(1.0902)),
1301 },
1302 PriceEvent {
1303 date,
1304 price_x: SingleAmount::from_value(chf, dec!(1)),
1305 price_y: SingleAmount::from_value(eur, Decimal::ONE / dec!(1.0902)),
1306 },
1307 PriceEvent {
1308 date,
1309 price_x: SingleAmount::from_value(eur, dec!(1)),
1310 price_y: SingleAmount::from_value(chf, dec!(1.0902)),
1311 },
1312 PriceEvent {
1313 date,
1314 price_x: SingleAmount::from_value(eur, dec!(1)),
1315 price_y: SingleAmount::from_value(chf, dec!(1.0902)),
1316 },
1317 ];
1318 assert_eq!(want_prices, price_repos.to_events());
1319 }
1320}