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