Skip to main content

okane_core/report/
query.rs

1//! Provides query of transactions / balances on the processed [Ledger] instance.
2
3use std::{borrow::Cow, collections::HashSet};
4
5use chrono::NaiveDate;
6
7use crate::{
8    parse,
9    report::{commodity::CommodityTag, eval::OwnedEvalError},
10    syntax,
11};
12
13use super::{
14    balance::Balance,
15    commodity::OwnedCommodity,
16    context::{Account, ReportContext},
17    eval::{Amount, EvalError, Evaluable},
18    price_db::{self, PriceRepository},
19    transaction::{Posting, Transaction},
20};
21
22/// Contains processed transactions, so that users can query information.
23#[derive(Debug)]
24pub struct Ledger<'ctx> {
25    pub(super) transactions: Vec<Transaction<'ctx>>,
26    pub(super) raw_balance: Balance<'ctx>,
27    pub(super) price_repos: PriceRepository<'ctx>,
28}
29
30/// Error type for [`Ledger`] methods.
31// TODO: Organize errors.
32// TODO: non_exhaustive
33#[derive(Debug, thiserror::Error)]
34pub enum QueryError {
35    #[error("failed to parse the given value")]
36    ParseFailed(#[from] parse::ParseError),
37    #[error("failed to evaluate the expr")]
38    EvalFailed(#[from] OwnedEvalError),
39    #[error("commodity {0} not found")]
40    CommodityNotFound(OwnedCommodity),
41    #[error("cannot convert amount: {0}")]
42    CommodityConversionFailure(String),
43}
44
45/// Query to list postings matching the criteria.
46// TODO: non_exhaustive
47#[derive(Debug)]
48pub struct PostingQuery {
49    /// Select the specified account if specified.
50    /// Note this will be changed to list of regex eventually.
51    pub account: Option<String>,
52}
53
54/// Specifies the conversion strategy.
55#[derive(Debug, PartialEq, Eq, Clone, Copy)]
56pub enum ConversionStrategy {
57    /// Converts the amount on the date of transaction.
58    Historical,
59    /// Converts the amount on the given date's rate.
60    /// Not implemented for register report.
61    /// https://github.com/xkikeg/okane/issues/313
62    UpToDate {
63        /// Date for the conversion to be _up-to-date_.
64        today: NaiveDate,
65    },
66}
67
68/// Instruction about commodity conversion.
69#[derive(Debug)]
70// TODO: non_exhaustive
71pub struct Conversion<'ctx> {
72    pub strategy: ConversionStrategy,
73    pub target: CommodityTag<'ctx>,
74}
75
76/// Half-open range of the date for the query result.
77/// If any of `start` or `end` is set as [`None`],
78/// those are treated as -infinity, +infinity respectively.
79#[derive(Debug, Default)]
80pub struct DateRange {
81    /// Start of the range (inclusive), if exists.
82    pub start: Option<NaiveDate>,
83    /// End of the range (exclusive), if exists.
84    pub end: Option<NaiveDate>,
85}
86
87impl DateRange {
88    fn is_bypass(&self) -> bool {
89        self.start.is_none() && self.end.is_none()
90    }
91
92    fn contains(&self, date: NaiveDate) -> bool {
93        match (self.start, self.end) {
94            (Some(start), _) if date < start => false,
95            (_, Some(end)) if end <= date => false,
96            _ => true,
97        }
98    }
99}
100
101/// Query for [`Ledger::balance()`].
102#[derive(Debug, Default)]
103// TODO: non_exhaustive
104pub struct BalanceQuery<'ctx> {
105    pub conversion: Option<Conversion<'ctx>>,
106    pub date_range: DateRange,
107}
108
109impl BalanceQuery<'_> {
110    fn require_recompute(&self) -> bool {
111        if !self.date_range.is_bypass() {
112            return true;
113        }
114        if matches!(&self.conversion, Some(conv) if conv.strategy == ConversionStrategy::Historical)
115        {
116            return true;
117        }
118        // at this case, we can reuse the balance for the whole range data.
119        false
120    }
121}
122
123/// Context passed to [`Ledger::eval()`].
124#[derive(Debug)]
125// TODO: non_exhaustive
126pub struct EvalContext {
127    pub date: NaiveDate,
128    pub exchange: Option<String>,
129}
130
131impl<'ctx> Ledger<'ctx> {
132    /// Returns iterator for all transactions.
133    pub fn transactions(&self) -> impl Iterator<Item = &Transaction<'ctx>> {
134        self.transactions.iter()
135    }
136
137    /// Returns all postings following the queries.
138    // TODO: Support date range query.
139    // https://github.com/xkikeg/okane/issues/208
140    // TODO: Support currency conversion.
141    // https://github.com/xkikeg/okane/issues/313
142    pub fn postings<'a>(
143        &'a self,
144        ctx: &ReportContext<'ctx>,
145        query: &PostingQuery,
146    ) -> Vec<&'a Posting<'ctx>> {
147        // compile them into compiled query.
148        let af = AccountFilter::new(ctx, query.account.as_deref());
149        let af = match af {
150            None => return Vec::new(),
151            Some(af) => af,
152        };
153        self.transactions()
154            .flat_map(|txn| &*txn.postings)
155            .filter(|x| af.is_match(&x.account))
156            .collect()
157    }
158
159    /// Returns a balance matching the given query.
160    /// Note that currently we don't have the query,
161    /// that will be added soon.
162    pub fn balance(
163        &mut self,
164        ctx: &ReportContext<'ctx>,
165        query: &BalanceQuery<'ctx>,
166    ) -> Result<Cow<'_, Balance<'ctx>>, QueryError> {
167        let balance = if !query.require_recompute() {
168            Cow::Borrowed(&self.raw_balance)
169        } else {
170            let mut bal = Balance::default();
171            for (txn, posting) in self.transactions.iter().flat_map(|txn| {
172                txn.postings.iter().filter_map(move |posting| {
173                    if !query.date_range.contains(txn.date) {
174                        return None;
175                    }
176                    Some((txn, posting))
177                })
178            }) {
179                let delta = match query.conversion {
180                    Some(Conversion {
181                        strategy: ConversionStrategy::Historical,
182                        target,
183                    }) => Cow::Owned(
184                        price_db::convert_amount(
185                            ctx,
186                            &mut self.price_repos,
187                            &posting.amount,
188                            target,
189                            txn.date,
190                        )
191                        // TODO: do we need this round, or just at the end?
192                        // .map(|amount| amount.round(ctx))
193                        .map_err(|err| QueryError::CommodityConversionFailure(err.to_string()))?,
194                    ),
195                    None
196                    | Some(Conversion {
197                        strategy: ConversionStrategy::UpToDate { .. },
198                        ..
199                    }) => Cow::Borrowed(&posting.amount),
200                };
201                bal.add_amount(posting.account, delta.into_owned());
202            }
203            bal.round(ctx);
204            Cow::Owned(bal)
205        };
206        match query.conversion {
207            None
208            | Some(Conversion {
209                strategy: ConversionStrategy::Historical,
210                ..
211            }) => Ok(balance),
212            Some(Conversion {
213                strategy: ConversionStrategy::UpToDate { today },
214                target,
215            }) => {
216                let mut converted = Balance::default();
217                for (account, original_amount) in balance.iter() {
218                    converted.add_amount(
219                        *account,
220                        price_db::convert_amount(
221                            ctx,
222                            &mut self.price_repos,
223                            original_amount,
224                            target,
225                            today,
226                        )
227                        .map_err(|err| QueryError::CommodityConversionFailure(err.to_string()))?,
228                    );
229                }
230                converted.round(ctx);
231                Ok(Cow::Owned(converted))
232            }
233        }
234    }
235
236    /// Evals given `expression` with the given condition.
237    pub fn eval(
238        &mut self,
239        ctx: &ReportContext<'ctx>,
240        expression: &str,
241        eval_ctx: &EvalContext,
242    ) -> Result<Amount<'ctx>, QueryError> {
243        let exchange = eval_ctx
244            .exchange
245            .as_ref()
246            .map(|x| {
247                ctx.commodities.resolve(x).ok_or_else(|| {
248                    QueryError::CommodityNotFound(OwnedCommodity::from_string(x.to_owned()))
249                })
250            })
251            .transpose()?;
252        let parsed: syntax::expr::ValueExpr =
253            expression.try_into().map_err(QueryError::ParseFailed)?;
254        let evaled: Amount<'ctx> = parsed
255            .eval(ctx)
256            .map_err(|e| e.into_owned(ctx))?
257            .try_into()
258            .map_err(|e: EvalError<'_>| e.into_owned(ctx))?;
259        let evaled = match exchange {
260            None => evaled,
261            Some(price_with) => price_db::convert_amount(
262                ctx,
263                &mut self.price_repos,
264                &evaled,
265                price_with,
266                eval_ctx.date,
267            )
268            .map_err(|err| QueryError::CommodityConversionFailure(err.to_string()))?,
269        };
270        Ok(evaled)
271    }
272}
273
274enum AccountFilter<'ctx> {
275    Any,
276    Set(HashSet<Account<'ctx>>),
277}
278
279impl<'ctx> AccountFilter<'ctx> {
280    /// Creates a new instance, unless there's no matching account.
281    fn new(ctx: &ReportContext<'ctx>, filter: Option<&str>) -> Option<Self> {
282        let filter = match filter {
283            None => return Some(AccountFilter::Any),
284            Some(filter) => filter,
285        };
286        let targets: HashSet<_> = ctx
287            .all_accounts_unsorted()
288            .filter(|x| x.as_str() == filter)
289            .collect();
290        if targets.is_empty() {
291            return None;
292        }
293        Some(AccountFilter::Set(targets))
294    }
295
296    fn is_match(&self, account: &Account<'ctx>) -> bool {
297        match self {
298            AccountFilter::Any => true,
299            AccountFilter::Set(targets) => targets.contains(account),
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    use std::path::PathBuf;
309
310    use bumpalo::Bump;
311    use indoc::indoc;
312    use maplit::hashmap;
313    use pretty_assertions::assert_eq;
314    use rust_decimal_macros::dec;
315
316    use crate::{load, report, testing::recursive_print};
317
318    fn fake_loader() -> load::Loader<load::FakeFileSystem> {
319        let content = indoc! {"
320            commodity JPY
321                format 1,000 JPY
322
323            2023/12/31 rate
324                Equity                         0.00 CHF @ 168.24 JPY
325                Equity                         0.00 EUR @ 157.12 JPY
326
327            2024/01/01 Initial
328                Assets:J 銀行             1,000,000 JPY
329                Assets:CH Bank             2,000.00 CHF
330                Liabilities:EUR Card        -300.00 EUR
331                Assets:Broker            5,000.0000 OKANE {80 JPY}
332                Equity
333
334            2024/01/05 Shopping
335                Expenses:Grocery             100.00 CHF @ 171.50 JPY
336                Assets:J 銀行               -17,150 JPY
337
338            2024/01/09 Buy Stock
339                Assets:Broker               23.0000 OKANE {120 JPY}
340                Assets:J 銀行                -2,760 JPY
341
342            2024/01/15 Sell Stock
343                Assets:Broker                12,300 JPY
344                Assets:Broker             -100.0000 OKANE {80 JPY} @ 100 JPY
345                Assets:Broker              -23.0000 OKANE {120 JPY} @ 100 JPY
346                Income:Capital Gain           -1540 JPY
347
348            2024/01/20 Shopping
349                Expenses:Food                150.00 EUR @ 0.9464 CHF
350                Assets:CH Bank              -141.96 CHF
351        "};
352        let fake = hashmap! {
353            PathBuf::from("path/to/file.ledger") => content.as_bytes().to_vec(),
354        };
355        load::Loader::new(PathBuf::from("path/to/file.ledger"), fake.into())
356    }
357
358    fn create_ledger(arena: &Bump) -> (ReportContext<'_>, Ledger<'_>) {
359        let mut ctx = ReportContext::new(arena);
360        let ledger =
361            match report::process(&mut ctx, fake_loader(), &report::ProcessOptions::default()) {
362                Ok(v) => v,
363                Err(err) => panic!(
364                    "failed to create the testing Ledger: {}",
365                    recursive_print(err),
366                ),
367            };
368        (ctx, ledger)
369    }
370
371    #[test]
372    fn balance_default() {
373        let arena = Bump::new();
374        let (ctx, mut ledger) = create_ledger(&arena);
375
376        log::info!("all_accounts: {:?}", ctx.all_accounts());
377        let chf = ctx.commodities.resolve("CHF").unwrap();
378        let eur = ctx.commodities.resolve("EUR").unwrap();
379        let jpy = ctx.commodities.resolve("JPY").unwrap();
380        let okane = ctx.commodities.resolve("OKANE").unwrap();
381
382        let got = ledger.balance(&ctx, &BalanceQuery::default()).unwrap();
383
384        let want: Balance = [
385            (
386                ctx.account("Assets:CH Bank").unwrap(),
387                Amount::from_value(chf, dec!(1858.04)),
388            ),
389            (
390                ctx.account("Assets:Broker").unwrap(),
391                Amount::from_iter([(okane, dec!(4900.0000)), (jpy, dec!(12300))]),
392            ),
393            (
394                ctx.account("Assets:J 銀行").unwrap(),
395                Amount::from_value(jpy, dec!(980090)),
396            ),
397            (
398                ctx.account("Liabilities:EUR Card").unwrap(),
399                Amount::from_value(eur, dec!(-300.00)),
400            ),
401            (
402                ctx.account("Income:Capital Gain").unwrap(),
403                Amount::from_value(jpy, dec!(-1540)),
404            ),
405            (
406                ctx.account("Expenses:Food").unwrap(),
407                Amount::from_value(eur, dec!(150.00)),
408            ),
409            (
410                ctx.account("Expenses:Grocery").unwrap(),
411                Amount::from_value(chf, dec!(100.00)),
412            ),
413            (
414                ctx.account("Equity").unwrap(),
415                Amount::from_iter([
416                    (jpy, dec!(-1_400_000)),
417                    (chf, dec!(-2000.00)),
418                    (eur, dec!(300.00)),
419                ]),
420            ),
421        ]
422        .into_iter()
423        .collect();
424
425        assert_eq!(want.into_vec(), got.into_owned().into_vec());
426    }
427
428    #[test]
429    fn balance_conversion_historical() {
430        // actually it doesn't quite make sense to have balance with
431        // either --historical or --up-to-date,
432        // because up-to-date makes sense for Assets & Liabilities
433        // while historical does for Income / Expenses.
434        let arena = Bump::new();
435        let (ctx, mut ledger) = create_ledger(&arena);
436
437        let jpy = ctx.commodities.resolve("JPY").unwrap();
438
439        let got = ledger
440            .balance(
441                &ctx,
442                &BalanceQuery {
443                    conversion: Some(Conversion {
444                        strategy: ConversionStrategy::Historical,
445                        target: jpy,
446                    }),
447                    date_range: DateRange::default(),
448                },
449            )
450            .unwrap();
451
452        let want: Balance = [
453            (
454                ctx.account("Assets:CH Bank").unwrap(),
455                // 2000.00 * 168.24 - 141.96 * 171.50
456                Amount::from_value(jpy, dec!(312_134)),
457            ),
458            (
459                ctx.account("Assets:Broker").unwrap(),
460                // 5_000 * 80 = 400_000
461                // 23 * 120 = 2_760
462                // -100 * 100 = -10_000
463                // -23 * 100 = -2_300
464                Amount::from_iter([
465                    (jpy, dec!(400_000)),
466                    (jpy, dec!(2_760)),
467                    (jpy, dec!(-10_000)),
468                    (jpy, dec!(-2_300)),
469                    (jpy, dec!(12_300)),
470                ]),
471            ),
472            (
473                ctx.account("Assets:J 銀行").unwrap(),
474                Amount::from_value(jpy, dec!(980090)),
475            ),
476            (
477                ctx.account("Liabilities:EUR Card").unwrap(),
478                Amount::from_value(jpy, dec!(-47_136)),
479            ),
480            (
481                ctx.account("Income:Capital Gain").unwrap(),
482                Amount::from_value(jpy, dec!(-1_540)),
483            ),
484            (
485                ctx.account("Expenses:Grocery").unwrap(),
486                Amount::from_value(jpy, dec!(17_150)),
487            ),
488            (
489                ctx.account("Expenses:Food").unwrap(),
490                Amount::from_value(jpy, dec!(23_568)),
491            ),
492            (
493                ctx.account("Equity").unwrap(),
494                Amount::from_iter([
495                    (jpy, dec!(-1_400_000)),
496                    // -2000 * 168.24, historical rate.
497                    (jpy, dec!(-336_480)),
498                    // 300 * 157.12
499                    (jpy, dec!(47_136)),
500                ]),
501            ),
502        ]
503        .into_iter()
504        .collect();
505
506        assert_eq!(want.into_vec(), got.into_owned().into_vec());
507    }
508
509    #[test]
510    fn balance_conversion_up_to_date() {
511        let arena = Bump::new();
512        let (ctx, mut ledger) = create_ledger(&arena);
513
514        let jpy = ctx.commodities.resolve("JPY").unwrap();
515
516        let got = ledger
517            .balance(
518                &ctx,
519                &BalanceQuery {
520                    conversion: Some(Conversion {
521                        strategy: ConversionStrategy::UpToDate {
522                            today: NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
523                        },
524                        target: jpy,
525                    }),
526                    date_range: DateRange::default(),
527                },
528            )
529            .unwrap();
530
531        let want: Balance = [
532            (
533                ctx.account("Assets:CH Bank").unwrap(),
534                // 1858.04 * 171.50
535                Amount::from_value(jpy, dec!(318_654)),
536            ),
537            (
538                ctx.account("Assets:Broker").unwrap(),
539                Amount::from_value(jpy, dec!(502_300)),
540            ),
541            (
542                ctx.account("Assets:J 銀行").unwrap(),
543                Amount::from_value(jpy, dec!(980090)),
544            ),
545            (
546                ctx.account("Liabilities:EUR Card").unwrap(),
547                // -300 * 157.12, EUR/JPY rate won't use EUR/CHF CHF/JPY.
548                Amount::from_value(jpy, dec!(-47_136)),
549            ),
550            (
551                ctx.account("Income:Capital Gain").unwrap(),
552                Amount::from_value(jpy, dec!(-1540)),
553            ),
554            (
555                ctx.account("Expenses:Food").unwrap(),
556                // 150.00 EUR * 157.12
557                Amount::from_value(jpy, dec!(23_568)),
558            ),
559            (
560                ctx.account("Expenses:Grocery").unwrap(),
561                Amount::from_value(jpy, dec!(17_150)),
562            ),
563            (
564                ctx.account("Equity").unwrap(),
565                Amount::from_iter([
566                    (jpy, dec!(-1_400_000)),
567                    // -2000 CHF * 171.50
568                    (jpy, dec!(-343_000)),
569                    // 300 EUR * 157.12
570                    (jpy, dec!(47_136)),
571                ]),
572            ),
573        ]
574        .into_iter()
575        .collect();
576
577        assert_eq!(want.into_vec(), got.into_owned().into_vec());
578    }
579
580    #[test]
581    fn balance_date_range() {
582        let arena = Bump::new();
583        let (ctx, mut ledger) = create_ledger(&arena);
584
585        log::info!("all_accounts: {:?}", ctx.all_accounts());
586        let chf = ctx.commodities.resolve("CHF").unwrap();
587        let jpy = ctx.commodities.resolve("JPY").unwrap();
588
589        let got = ledger
590            .balance(
591                &ctx,
592                &BalanceQuery {
593                    conversion: None,
594                    date_range: DateRange {
595                        start: Some(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()),
596                        end: Some(NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()),
597                    },
598                },
599            )
600            .unwrap();
601
602        let want: Balance = [
603            (
604                ctx.account("Assets:J 銀行").unwrap(),
605                Amount::from_value(jpy, dec!(-17150)),
606            ),
607            (
608                ctx.account("Expenses:Grocery").unwrap(),
609                Amount::from_value(chf, dec!(100.00)),
610            ),
611        ]
612        .into_iter()
613        .collect();
614
615        assert_eq!(want.into_vec(), got.into_owned().into_vec());
616    }
617
618    #[test]
619    fn eval_default_context() {
620        let arena = Bump::new();
621        let (ctx, mut ledger) = create_ledger(&arena);
622
623        let evaluated = ledger
624            .eval(
625                &ctx,
626                "1 JPY",
627                &EvalContext {
628                    date: NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(),
629                    exchange: None,
630                },
631            )
632            .unwrap();
633
634        assert_eq!(
635            Amount::from_value(ctx.commodities.resolve("JPY").unwrap(), dec!(1)),
636            evaluated
637        );
638    }
639}