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;
5
6use bumpalo::collections as bcc;
7use chrono::NaiveDate;
8
9use crate::{
10    load,
11    syntax::{self, decoration::AsUndecorated, tracked::Tracked},
12};
13
14use super::{
15    balance::{Balance, BalanceError},
16    context::{Account, ReportContext},
17    error::{self, ReportError},
18    eval::{Amount, EvalError, Evaluable, PostingAmount, SingleAmount},
19    intern::InternError,
20};
21
22/// Error related to transaction understanding.
23// TODO: Reconsider the error in details.
24#[derive(Debug, thiserror::Error, PartialEq, Eq)]
25pub enum BookKeepError {
26    #[error("failed to evaluate the expression: {0}")]
27    EvalFailure(#[from] EvalError),
28    #[error("failed to meet balance condition: {0}")]
29    BalanceFailure(#[from] BalanceError),
30    #[error("posting amount must be resolved as a simple value with commodity or zero")]
31    ComplexPostingAmount,
32    #[error("transaction cannot have multiple postings without amount")]
33    UndeduciblePostingAmount(Tracked<usize>, Tracked<usize>),
34    #[error("transaction cannot have unbalanced postings: {0}")]
35    UnbalancedPostings(String),
36    #[error("balance assertion failed: got {0} but expected {1}")]
37    BalanceAssertionFailure(String, String),
38    #[error("failed to register account: {0}")]
39    InvalidAccount(#[source] InternError),
40    #[error("failed to register commodity: {0}")]
41    InvalidCommodity(#[source] InternError),
42}
43
44/// Takes the loader, and gives back the all read transactions.
45/// Also returns the computed balance, as a side-artifact.
46/// Usually this needs to be reordered, so just returning a `Vec`.
47pub fn process<'ctx, L, F>(
48    ctx: &mut ReportContext<'ctx>,
49    loader: L,
50) -> Result<(Vec<Transaction<'ctx>>, Balance<'ctx>), ReportError>
51where
52    L: Borrow<load::Loader<F>>,
53    F: load::FileSystem,
54{
55    let mut accum = ProcessAccumulator::new();
56    loader.borrow().load(|path, pctx, entry| {
57        accum.process(ctx, entry).map_err(|berr| {
58            ReportError::BookKeep(
59                berr,
60                error::ErrorContext::new(
61                    loader.borrow().error_style().clone(),
62                    path.to_owned(),
63                    pctx,
64                ),
65            )
66        })
67    })?;
68    Ok((accum.txns, accum.balance))
69}
70
71struct ProcessAccumulator<'ctx> {
72    balance: Balance<'ctx>,
73    txns: Vec<Transaction<'ctx>>,
74}
75
76impl<'ctx> ProcessAccumulator<'ctx> {
77    fn new() -> Self {
78        let balance = Balance::default();
79        let txns: Vec<Transaction<'ctx>> = Vec::new();
80        Self { balance, txns }
81    }
82
83    fn process(
84        &mut self,
85        ctx: &mut ReportContext<'ctx>,
86        entry: &syntax::tracked::LedgerEntry,
87    ) -> Result<(), BookKeepError> {
88        match entry {
89            syntax::LedgerEntry::Txn(txn) => {
90                self.txns
91                    .push(add_transaction(ctx, &mut self.balance, txn)?);
92                Ok(())
93            }
94            syntax::LedgerEntry::Account(account) => {
95                let canonical = ctx
96                    .accounts
97                    .insert_canonical(&account.name)
98                    .map_err(BookKeepError::InvalidAccount)?;
99                for ad in &account.details {
100                    if let syntax::AccountDetail::Alias(alias) = ad {
101                        ctx.accounts
102                            .insert_alias(alias, canonical)
103                            .map_err(BookKeepError::InvalidAccount)?;
104                    }
105                }
106                Ok(())
107            }
108            syntax::LedgerEntry::Commodity(commodity) => {
109                let canonical = ctx
110                    .commodities
111                    .insert_canonical(&commodity.name)
112                    .map_err(BookKeepError::InvalidCommodity)?;
113                for cd in &commodity.details {
114                    match cd {
115                        syntax::CommodityDetail::Alias(alias) => {
116                            ctx.commodities
117                                .insert_alias(alias, canonical)
118                                .map_err(BookKeepError::InvalidCommodity)?;
119                        }
120                        syntax::CommodityDetail::Format(format_amount) => {
121                            ctx.commodities
122                                .set_format(canonical, format_amount.value.clone());
123                        }
124                        _ => {}
125                    }
126                }
127                Ok(())
128            }
129            _ => Ok(()),
130        }
131    }
132}
133
134/// Evaluated transaction, already processed to have right balance.
135// TODO: Rename it to EvaluatedTxn?
136#[derive(Debug, PartialEq, Eq)]
137pub struct Transaction<'ctx> {
138    pub date: NaiveDate,
139    // Posting in the transaction.
140    // Note this MUST be a Box instead of &[Posting],
141    // as Posting is a [Drop] and we can't skip calling Drop,
142    // otherwise we leave allocated memory for Amount HashMap.
143    pub postings: bumpalo::boxed::Box<'ctx, [Posting<'ctx>]>,
144}
145
146/// Evaluated posting of the transaction.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct Posting<'ctx> {
149    pub account: Account<'ctx>,
150    /// Note this Amount is not PostingAmount,
151    /// as deduced posting may have non-single commodity amount.
152    pub amount: Amount<'ctx>,
153}
154
155/// Adds a syntax transaction, and converts it into a processed Transaction.
156fn add_transaction<'ctx>(
157    ctx: &mut ReportContext<'ctx>,
158    bal: &mut Balance<'ctx>,
159    txn: &syntax::tracked::Transaction,
160) -> Result<Transaction<'ctx>, BookKeepError> {
161    let mut postings = bcc::Vec::with_capacity_in(txn.posts.len(), ctx.arena);
162    let mut unfilled: Option<Tracked<usize>> = None;
163    let mut balance = Amount::default();
164    for (i, posting) in txn.posts.iter().enumerate() {
165        let account = ctx.accounts.ensure(&posting.as_undecorated().account);
166        let (amount, balance_amount): (PostingAmount, PostingAmount) = match (
167            &posting.as_undecorated().amount,
168            &posting.as_undecorated().balance,
169        ) {
170            (None, None) => {
171                if let Some(first) = unfilled.replace(Tracked::new(i, posting.span())) {
172                    Err(BookKeepError::UndeduciblePostingAmount(
173                        first,
174                        Tracked::new(i, posting.span()),
175                    ))
176                } else {
177                    Ok((PostingAmount::zero(), PostingAmount::zero()))
178                }
179            }
180            (None, Some(balance_constraints)) => {
181                let current: PostingAmount =
182                    balance_constraints.as_undecorated().eval(ctx)?.try_into()?;
183                let prev: PostingAmount = bal.set_partial(account, current)?;
184                let amount = current.check_sub(prev)?;
185                Ok((amount, amount))
186            }
187            (Some(syntax_amount), balance_constraints) => {
188                let amount: PostingAmount = syntax_amount
189                    .amount
190                    .as_undecorated()
191                    .eval(ctx)?
192                    .try_into()?;
193                let expected_balance: Option<PostingAmount> = balance_constraints
194                    .as_ref()
195                    .map(|x| x.as_undecorated().eval(ctx))
196                    .transpose()?
197                    .map(|x| x.try_into())
198                    .transpose()?;
199                let current = bal.add_posting_amount(account, amount);
200                if let Some(expected) = expected_balance {
201                    if !current.is_consistent(expected) {
202                        return Err(BookKeepError::BalanceAssertionFailure(
203                            format!("{}", current.as_inline_display()),
204                            format!("{}", expected),
205                        ));
206                    }
207                }
208                let balance_amount = calculate_balance_amount(ctx, syntax_amount, amount)?;
209                Ok((amount, balance_amount))
210            }
211        }?;
212        balance += balance_amount;
213        postings.push(Posting {
214            account,
215            amount: amount.into(),
216        });
217    }
218    if let Some(u) = unfilled {
219        let u = *u.as_undecorated();
220        let deduced: Amount = balance.clone().negate();
221        postings[u].amount = deduced.clone();
222        bal.add_amount(postings[u].account, deduced);
223    } else {
224        check_balance(ctx, balance)?;
225    }
226    Ok(Transaction {
227        date: txn.date,
228        postings: postings.into_boxed_slice(),
229    })
230}
231
232fn check_balance<'ctx>(
233    ctx: &ReportContext<'ctx>,
234    mut balance: Amount<'ctx>,
235) -> Result<(), BookKeepError> {
236    balance.round(|commodity| ctx.commodities.get_decimal_point(commodity));
237    if balance.is_zero() {
238        return Ok(());
239    }
240    if let Some((a1, a2)) = balance.maybe_pair() {
241        log::info!("deduced price {} == {}", a1, a2);
242        return Ok(());
243    }
244    if !balance.is_zero() {
245        return Err(BookKeepError::UnbalancedPostings(format!(
246            "{}",
247            balance.as_inline_display()
248        )));
249    }
250    Ok(())
251}
252
253fn calculate_balance_amount<'ctx>(
254    ctx: &mut ReportContext<'ctx>,
255    posting_amount: &syntax::tracked::PostingAmount,
256    computed_amount: PostingAmount<'ctx>,
257) -> Result<PostingAmount<'ctx>, BookKeepError> {
258    let cost: Option<SingleAmount<'ctx>> = posting_amount
259        .cost
260        .as_ref()
261        .map(|x| calculate_exchanged_amount(ctx, x.as_undecorated(), computed_amount.try_into()?))
262        .transpose()?;
263    let lot: Option<SingleAmount<'ctx>> = posting_amount
264        .lot
265        .price
266        .as_ref()
267        .map(|x| calculate_exchanged_amount(ctx, x.as_undecorated(), computed_amount.try_into()?))
268        .transpose()?;
269    // Actually, there's no point to compute cost if lot price is provided.
270    // Example: if you sell a X with cost p Y lot q Y.
271    //   Broker          -a X {{q Y}} @@ p Y
272    //   Broker           p Y
273    //   Income   (-(p - q) Y)
274    //
275    // if you set the first posting amount t,
276    // t + p Y - (p - q) Y = 0
277    // t = -q Y
278    // so actually cost is pointless in this case.
279    Ok(lot.or(cost).map(Into::into).unwrap_or(computed_amount))
280}
281
282fn calculate_exchanged_amount<'ctx>(
283    ctx: &mut ReportContext<'ctx>,
284    cost: &syntax::Exchange,
285    amount: SingleAmount<'ctx>,
286) -> Result<SingleAmount<'ctx>, BookKeepError> {
287    let exchanged: Result<SingleAmount, EvalError> = match cost {
288        syntax::Exchange::Rate(x) => {
289            let rate: SingleAmount = x.eval(ctx)?.try_into()?;
290            Ok(rate * amount.value)
291        }
292        syntax::Exchange::Total(y) => {
293            let abs: SingleAmount = y.eval(ctx)?.try_into()?;
294            Ok(abs.with_sign_of(amount))
295        }
296    };
297    Ok(exchanged?)
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    use bumpalo::Bump;
305    use indoc::indoc;
306    use maplit::hashmap;
307    use pretty_assertions::assert_eq;
308    use rust_decimal_macros::dec;
309
310    use crate::{
311        parse::{self, testing::expect_parse_ok},
312        syntax::tracked::TrackedSpan,
313    };
314
315    fn parse_transaction(input: &str) -> syntax::tracked::Transaction {
316        let (_, ret) = expect_parse_ok(parse::transaction::transaction, input);
317        ret
318    }
319
320    #[test]
321    fn add_transaction_maintains_balance() {
322        let arena = Bump::new();
323        let mut ctx = ReportContext::new(&arena);
324        let mut bal = Balance::default();
325        bal.add_posting_amount(
326            ctx.accounts.ensure("Account 1"),
327            PostingAmount::from_value(dec!(1000), ctx.commodities.ensure("JPY")),
328        );
329        bal.add_posting_amount(
330            ctx.accounts.ensure("Account 1"),
331            PostingAmount::from_value(dec!(123), ctx.commodities.ensure("EUR")),
332        );
333        bal.add_posting_amount(
334            ctx.accounts.ensure("Account 4"),
335            PostingAmount::from_value(dec!(10), ctx.commodities.ensure("CHF")),
336        );
337        let input = indoc! {"
338            2024/08/01 Sample
339              Account 1      200 JPY = 1200 JPY
340              Account 2     -100 JPY = -100 JPY
341              Account 2     -100 JPY = -200 JPY
342              Account 3     2.00 CHF @ 150 JPY
343              Account 4              = -300 JPY
344        "};
345        let txn = parse_transaction(input);
346        let _ = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
347        let want_balance: Balance = hashmap! {
348            ctx.accounts.ensure("Account 1") =>
349                Amount::from_values([
350                    (dec!(1200), ctx.commodities.ensure("JPY")),
351                    (dec!(123), ctx.commodities.ensure("EUR")),
352                ]),
353            ctx.accounts.ensure("Account 2") =>
354                Amount::from_value(dec!(-200), ctx.commodities.ensure("JPY")),
355            ctx.accounts.ensure("Account 3") =>
356                Amount::from_value(dec!(2), ctx.commodities.ensure("CHF")),
357            ctx.accounts.ensure("Account 4") =>
358                Amount::from_values([
359                    (dec!(-300), ctx.commodities.ensure("JPY")),
360                    (dec!(10), ctx.commodities.ensure("CHF")),
361                ]),
362        }
363        .into_iter()
364        .collect();
365        assert_eq!(want_balance.into_vec(), bal.into_vec());
366    }
367
368    #[test]
369    fn add_transaction_emits_transaction_with_postings() {
370        let arena = Bump::new();
371        let mut ctx = ReportContext::new(&arena);
372        let mut bal = Balance::default();
373        let input = indoc! {"
374            2024/08/01 Sample
375              Account 1      200 JPY = 200 JPY
376              Account 2     -100 JPY = -100 JPY
377              Account 2     -100 JPY = -200 JPY
378        "};
379        let txn = parse_transaction(input);
380        let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
381        let want = Transaction {
382            date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
383            postings: bcc::Vec::from_iter_in(
384                [
385                    Posting {
386                        account: ctx.accounts.ensure("Account 1"),
387                        amount: Amount::from_value(dec!(200), ctx.commodities.ensure("JPY")),
388                    },
389                    Posting {
390                        account: ctx.accounts.ensure("Account 2"),
391                        amount: Amount::from_value(dec!(-100), ctx.commodities.ensure("JPY")),
392                    },
393                    Posting {
394                        account: ctx.accounts.ensure("Account 2"),
395                        amount: Amount::from_value(dec!(-100), ctx.commodities.ensure("JPY")),
396                    },
397                ],
398                &arena,
399            )
400            .into_boxed_slice(),
401        };
402        assert_eq!(want, got);
403    }
404
405    #[test]
406    fn add_transaction_emits_transaction_with_deduce_and_balance_concern() {
407        let arena = Bump::new();
408        let mut ctx = ReportContext::new(&arena);
409        let mut bal = Balance::default();
410        bal.add_posting_amount(
411            ctx.accounts.ensure("Account 1"),
412            PostingAmount::from_value(dec!(1000), ctx.commodities.ensure("JPY")),
413        );
414        bal.add_posting_amount(
415            ctx.accounts.ensure("Account 1"),
416            PostingAmount::from_value(dec!(123), ctx.commodities.ensure("USD")),
417        );
418        let input = indoc! {"
419            2024/08/01 Sample
420              Account 1              = 1200 JPY
421              Account 2
422        "};
423        let txn = parse_transaction(input);
424        let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
425        let want = Transaction {
426            date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
427            postings: bcc::Vec::from_iter_in(
428                [
429                    Posting {
430                        account: ctx.accounts.ensure("Account 1"),
431                        amount: Amount::from_value(dec!(200), ctx.commodities.ensure("JPY")),
432                    },
433                    Posting {
434                        account: ctx.accounts.ensure("Account 2"),
435                        amount: Amount::from_value(dec!(-200), ctx.commodities.ensure("JPY")),
436                    },
437                ],
438                &arena,
439            )
440            .into_boxed_slice(),
441        };
442        assert_eq!(want, got);
443    }
444
445    #[test]
446    fn add_transaction_deduced_amount_contains_multi_commodity() {
447        let arena = Bump::new();
448        let mut ctx = ReportContext::new(&arena);
449        let mut bal = Balance::default();
450        let input = indoc! {"
451            2024/08/01 Sample
452              Account 1         1200 JPY
453              Account 2         234 EUR
454              Account 3         34.56 CHF
455              Account 4
456        "};
457        let txn = parse_transaction(input);
458        let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
459        let want = Transaction {
460            date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
461            postings: bcc::Vec::from_iter_in(
462                [
463                    Posting {
464                        account: ctx.accounts.ensure("Account 1"),
465                        amount: Amount::from_value(dec!(1200), ctx.commodities.ensure("JPY")),
466                    },
467                    Posting {
468                        account: ctx.accounts.ensure("Account 2"),
469                        amount: Amount::from_value(dec!(234), ctx.commodities.ensure("EUR")),
470                    },
471                    Posting {
472                        account: ctx.accounts.ensure("Account 3"),
473                        amount: Amount::from_value(dec!(34.56), ctx.commodities.ensure("CHF")),
474                    },
475                    Posting {
476                        account: ctx.accounts.ensure("Account 4"),
477                        amount: Amount::from_values([
478                            (dec!(-1200), ctx.commodities.ensure("JPY")),
479                            (dec!(-234), ctx.commodities.ensure("EUR")),
480                            (dec!(-34.56), ctx.commodities.ensure("CHF")),
481                        ]),
482                    },
483                ],
484                &arena,
485            )
486            .into_boxed_slice(),
487        };
488        assert_eq!(want, got);
489    }
490
491    #[test]
492    fn add_transaction_fails_when_two_posting_does_not_have_amount() {
493        let input = indoc! {"
494            2024/08/01 Sample
495              Account 1 ; no amount
496              Account 2 ; no amount
497        "};
498        let txn = parse_transaction(input);
499        let arena = Bump::new();
500        let mut ctx = ReportContext::new(&arena);
501        let mut bal = Balance::default();
502        let got = add_transaction(&mut ctx, &mut bal, &txn).expect_err("must fail");
503        assert_eq!(
504            got,
505            BookKeepError::UndeduciblePostingAmount(
506                Tracked::new(0, TrackedSpan::new(20..42)),
507                Tracked::new(1, TrackedSpan::new(44..66))
508            )
509        );
510    }
511
512    #[test]
513    fn add_transaction_balances_with_lot() {
514        let arena = Bump::new();
515        let mut ctx = ReportContext::new(&arena);
516        let mut bal = Balance::default();
517        let input = indoc! {"
518            2024/08/01 Sample
519              Account 1             12 OKANE {100 JPY}
520              Account 2         -1,200 JPY
521        "};
522        let txn = parse_transaction(input);
523        let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
524        let want = Transaction {
525            date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
526            postings: bcc::Vec::from_iter_in(
527                [
528                    Posting {
529                        account: ctx.accounts.ensure("Account 1"),
530                        amount: Amount::from_value(dec!(12), ctx.commodities.ensure("OKANE")),
531                    },
532                    Posting {
533                        account: ctx.accounts.ensure("Account 2"),
534                        amount: Amount::from_value(dec!(-1200), ctx.commodities.ensure("JPY")),
535                    },
536                ],
537                &arena,
538            )
539            .into_boxed_slice(),
540        };
541        assert_eq!(want, got);
542    }
543
544    #[test]
545    fn add_transaction_balances_with_price() {
546        let arena = Bump::new();
547        let mut ctx = ReportContext::new(&arena);
548        let mut bal = Balance::default();
549        let input = indoc! {"
550            2024/08/01 Sample
551              Account 1             12 OKANE @ (1 * 100 JPY)
552              Account 2         -1,200 JPY
553        "};
554        let txn = parse_transaction(input);
555        let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
556        let want = Transaction {
557            date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
558            postings: bcc::Vec::from_iter_in(
559                [
560                    Posting {
561                        account: ctx.accounts.ensure("Account 1"),
562                        amount: Amount::from_value(dec!(12), ctx.commodities.ensure("OKANE")),
563                    },
564                    Posting {
565                        account: ctx.accounts.ensure("Account 2"),
566                        amount: Amount::from_value(dec!(-1200), ctx.commodities.ensure("JPY")),
567                    },
568                ],
569                &arena,
570            )
571            .into_boxed_slice(),
572        };
573        assert_eq!(want, got);
574    }
575
576    #[test]
577    fn add_transaction_balances_with_lot_and_price() {
578        let arena = Bump::new();
579        let mut ctx = ReportContext::new(&arena);
580        let mut bal = Balance::default();
581        let input = indoc! {"
582            2024/08/01 Sample
583              Account 1            -12 OKANE {100 JPY} @ 120 JPY
584              Account 2          1,440 JPY
585              Income              -240 JPY
586        "};
587        let txn = parse_transaction(input);
588        let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
589        let want = Transaction {
590            date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
591            postings: bcc::Vec::from_iter_in(
592                [
593                    Posting {
594                        account: ctx.accounts.ensure("Account 1"),
595                        amount: Amount::from_value(dec!(-12), ctx.commodities.ensure("OKANE")),
596                    },
597                    Posting {
598                        account: ctx.accounts.ensure("Account 2"),
599                        amount: Amount::from_value(dec!(1440), ctx.commodities.ensure("JPY")),
600                    },
601                    Posting {
602                        account: ctx.accounts.ensure("Income"),
603                        amount: Amount::from_value(dec!(-240), ctx.commodities.ensure("JPY")),
604                    },
605                ],
606                &arena,
607            )
608            .into_boxed_slice(),
609        };
610        assert_eq!(want, got);
611    }
612
613    #[test]
614    fn add_transaction_balances_minor_diff() {
615        let arena = Bump::new();
616        let mut ctx = ReportContext::new(&arena);
617        let chf = ctx.commodities.insert_canonical("CHF").unwrap();
618        ctx.commodities
619            .set_format(chf, "20,000.00".parse().unwrap());
620        let mut bal = Balance::default();
621        let input = indoc! {"
622            2024/08/01 Sample
623              Expenses               300 EUR @ (1 / 1.0538 CHF)
624              Liabilities        -284.68 CHF
625        "};
626        let txn = parse_transaction(input);
627        let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
628        let want = Transaction {
629            date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
630            postings: bcc::Vec::from_iter_in(
631                [
632                    Posting {
633                        account: ctx.accounts.ensure("Expenses"),
634                        amount: Amount::from_value(dec!(300), ctx.commodities.ensure("EUR")),
635                    },
636                    Posting {
637                        account: ctx.accounts.ensure("Liabilities"),
638                        amount: Amount::from_value(dec!(-284.68), ctx.commodities.ensure("CHF")),
639                    },
640                ],
641                &arena,
642            )
643            .into_boxed_slice(),
644        };
645        assert_eq!(want, got);
646    }
647}