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