Skip to main content

okane_core/report/
book_keeping.rs

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