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