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