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