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