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