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