1use std::{borrow::Cow, collections::HashSet};
4
5use bumpalo::collections as bcc;
6use chrono::NaiveDate;
7use lender::{FallibleLender, FallibleLending, check_covariance_fallible};
8
9use crate::{
10 parse,
11 report::{commodity::CommodityTag, eval::OwnedEvalError},
12 syntax,
13};
14
15use super::{
16 balance::Balance,
17 commodity::OwnedCommodity,
18 context::{Account, ReportContext},
19 eval::{Amount, EvalError, Evaluable},
20 price_db::{self, ConversionError, PriceRepository},
21 transaction::{Posting, Transaction},
22};
23
24#[derive(Debug)]
26pub struct Ledger<'ctx> {
27 pub(super) arena: &'ctx bumpalo::Bump,
30 pub(super) transactions: Vec<Transaction<'ctx>>,
31 pub(super) date_sorted_txns: Option<Vec<Transaction<'ctx>>>,
39 pub(super) raw_balance: Balance<'ctx>,
40 pub(super) price_repos: PriceRepository<'ctx>,
41}
42
43#[derive(Debug, thiserror::Error)]
47pub enum QueryError {
48 #[error("failed to parse the given value")]
49 ParseFailed(#[from] parse::ParseError),
50 #[error("failed to evaluate the expr")]
51 EvalFailed(#[from] OwnedEvalError),
52 #[error("commodity {0} not found")]
53 CommodityNotFound(OwnedCommodity),
54 #[error("cannot convert amount: {0}")]
55 CommodityConversionFailure(#[from] ConversionError),
56 #[error("unsupported conversion strategy for this query")]
57 UnsupportedConversionStrategy,
58}
59
60#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
62pub enum Sort {
63 #[default]
65 Original,
66 Date,
68}
69
70#[derive(Debug, Default)]
72pub struct RegisterQuery<'ctx> {
74 pub account: AccountFilter<'ctx>,
77 pub date_range: DateRange,
79 pub conversion: Option<Conversion<'ctx>>,
83 pub sort: Sort,
85}
86
87#[derive(Debug)]
93#[non_exhaustive]
94pub struct RegisterEntry<'lend, 'ctx> {
95 pub date: NaiveDate,
97 pub payee: &'ctx str,
100 pub account: Account<'ctx>,
102 pub amount: &'lend Amount<'ctx>,
105 pub total: &'lend Amount<'ctx>,
108}
109
110#[derive(Debug, PartialEq, Eq, Clone, Copy)]
112pub enum ConversionStrategy {
113 Historical,
115 UpToDate {
119 today: NaiveDate,
121 },
122}
123
124#[derive(Debug, Clone, Copy)]
126pub struct Conversion<'ctx> {
128 pub strategy: ConversionStrategy,
129 pub target: CommodityTag<'ctx>,
130}
131
132#[derive(Debug, Default, Clone, Copy)]
136pub struct DateRange {
137 pub start: Option<NaiveDate>,
139 pub end: Option<NaiveDate>,
141}
142
143impl DateRange {
144 fn is_bypass(&self) -> bool {
145 self.start.is_none() && self.end.is_none()
146 }
147
148 fn contains(&self, date: NaiveDate) -> bool {
149 match (self.start, self.end) {
150 (Some(start), _) if date < start => false,
151 (_, Some(end)) if end <= date => false,
152 _ => true,
153 }
154 }
155}
156
157#[derive(Debug, Default)]
159pub struct BalanceQuery<'ctx> {
161 pub account: AccountFilter<'ctx>,
164 pub conversion: Option<Conversion<'ctx>>,
165 pub date_range: DateRange,
166}
167
168impl BalanceQuery<'_> {
169 fn require_recompute(&self) -> bool {
170 if !self.date_range.is_bypass() {
171 return true;
172 }
173 if matches!(&self.conversion, Some(conv) if conv.strategy == ConversionStrategy::Historical)
174 {
175 return true;
176 }
177 false
179 }
180}
181
182#[derive(Debug)]
184pub struct EvalContext {
186 pub date: NaiveDate,
187 pub exchange: Option<String>,
188}
189
190impl<'ctx> Ledger<'ctx> {
191 pub fn transactions(&self) -> impl Iterator<Item = &Transaction<'ctx>> {
193 self.transactions.iter()
194 }
195
196 fn ensure_date_sorted_txns(&mut self) {
202 if self.date_sorted_txns.is_some() {
203 return;
204 }
205 let arena = self.arena;
206 let mut sorted: Vec<Transaction<'ctx>> = self
207 .transactions
208 .iter()
209 .map(|txn| {
210 let mut postings = bcc::Vec::with_capacity_in(txn.postings.len(), arena);
211 for p in txn.postings.iter() {
212 postings.push(p.clone());
213 }
214 Transaction {
215 date: txn.date,
216 postings: postings.into_boxed_slice(),
217 }
218 })
219 .collect();
220 sorted.sort_by_key(|t| t.date);
221 self.date_sorted_txns = Some(sorted);
222 }
223
224 pub fn register_entries<'a>(
240 &'a mut self,
241 ctx: &'a ReportContext<'ctx>,
242 query: &RegisterQuery<'ctx>,
243 ) -> Result<RegisterEntries<'a, 'ctx>, QueryError> {
244 let conversion = match query.conversion {
245 None => None,
246 Some(c) => match c.strategy {
247 ConversionStrategy::Historical => Some(c),
248 ConversionStrategy::UpToDate { .. } => {
249 return Err(QueryError::UnsupportedConversionStrategy);
250 }
251 },
252 };
253 let account_filter = query.account.clone();
254 let txns: TxnIter<'a, 'ctx> = if account_filter.is_exhaustive_empty() {
257 TxnIter::linear(&[])
258 } else {
259 match query.sort {
260 Sort::Original => TxnIter::linear(&self.transactions),
261 Sort::Date => {
267 self.ensure_date_sorted_txns();
268 date_range_iter(
269 self.date_sorted_txns
270 .as_deref()
271 .expect("just built by ensure_date_sorted_txns"),
272 query.date_range,
273 )
274 }
275 }
276 };
277 Ok(RegisterEntries {
278 ctx,
279 txns,
280 current: [].iter(),
281 account_filter,
282 date_range: query.date_range,
283 conversion,
284 price_repos: &mut self.price_repos,
285 current_date: NaiveDate::MIN,
286 current_amount: Cow::Owned(Amount::default()),
287 total: Amount::default(),
288 })
289 }
290
291 pub fn balance(
295 &mut self,
296 ctx: &ReportContext<'ctx>,
297 query: &BalanceQuery<'ctx>,
298 ) -> Result<Cow<'_, Balance<'ctx>>, QueryError> {
299 let balance = if !query.require_recompute() {
300 Cow::Borrowed(&self.raw_balance)
301 } else {
302 let txns = if query.date_range.is_bypass() {
308 TxnIter::linear(&self.transactions)
309 } else {
310 self.ensure_date_sorted_txns();
311 date_range_iter(
312 self.date_sorted_txns
313 .as_deref()
314 .expect("just built by ensure_date_sorted_txns"),
315 query.date_range,
316 )
317 };
318 Cow::Owned(compute_balance(
319 ctx,
320 &mut self.price_repos,
321 txns,
322 query.conversion,
323 )?)
324 };
325 let balance = match query.conversion {
326 None
327 | Some(Conversion {
328 strategy: ConversionStrategy::Historical,
329 ..
330 }) => balance,
331 Some(Conversion {
332 strategy: ConversionStrategy::UpToDate { today },
333 target,
334 }) => {
335 let mut converted = Balance::default();
336 for (account, original_amount) in balance.iter() {
337 converted.add_amount(
338 *account,
339 price_db::convert_amount(
340 ctx,
341 &mut self.price_repos,
342 original_amount,
343 target,
344 today,
345 )
346 .map_err(QueryError::CommodityConversionFailure)?,
347 );
348 }
349 converted.round(ctx);
350 Cow::Owned(converted)
351 }
352 };
353 match &query.account {
357 AccountFilter::All => Ok(balance),
358 filter => {
359 let mut filtered = Balance::default();
360 for (account, amount) in balance.iter() {
361 if filter.is_match(account) {
362 filtered.add_amount(*account, amount.clone());
363 }
364 }
365 Ok(Cow::Owned(filtered))
366 }
367 }
368 }
369
370 pub fn eval(
372 &mut self,
373 ctx: &ReportContext<'ctx>,
374 expression: &str,
375 eval_ctx: &EvalContext,
376 ) -> Result<Amount<'ctx>, QueryError> {
377 let exchange = eval_ctx
378 .exchange
379 .as_ref()
380 .map(|x| {
381 ctx.commodities.resolve(x).ok_or_else(|| {
382 QueryError::CommodityNotFound(OwnedCommodity::from_string(x.to_owned()))
383 })
384 })
385 .transpose()?;
386 let parsed: syntax::expr::ValueExpr =
387 expression.try_into().map_err(QueryError::ParseFailed)?;
388 let evaled: Amount<'ctx> = parsed
389 .eval(ctx)
390 .map_err(|e| e.into_owned(ctx))?
391 .try_into()
392 .map_err(|e: EvalError<'_>| e.into_owned(ctx))?;
393 let evaled = match exchange {
394 None => evaled,
395 Some(price_with) => price_db::convert_amount(
396 ctx,
397 &mut self.price_repos,
398 &evaled,
399 price_with,
400 eval_ctx.date,
401 )
402 .map_err(QueryError::CommodityConversionFailure)?,
403 };
404 Ok(evaled)
405 }
406}
407
408pub(super) struct TxnIter<'a, 'ctx> {
418 it: std::slice::Iter<'a, Transaction<'ctx>>,
419 end: Option<NaiveDate>,
422}
423
424impl<'a, 'ctx> TxnIter<'a, 'ctx> {
425 fn linear(slice: &'a [Transaction<'ctx>]) -> Self {
427 Self {
428 it: slice.iter(),
429 end: None,
430 }
431 }
432
433 fn sorted(slice: &'a [Transaction<'ctx>], end: Option<NaiveDate>) -> Self {
437 Self {
438 it: slice.iter(),
439 end,
440 }
441 }
442}
443
444impl<'a, 'ctx> Iterator for TxnIter<'a, 'ctx> {
445 type Item = &'a Transaction<'ctx>;
446 #[inline]
447 fn next(&mut self) -> Option<Self::Item> {
448 let txn = self.it.next()?;
449 if let Some(end) = self.end
450 && txn.date >= end
451 {
452 return None;
453 }
454 Some(txn)
455 }
456}
457
458pub struct RegisterEntries<'a, 'ctx> {
465 ctx: &'a ReportContext<'ctx>,
466 txns: TxnIter<'a, 'ctx>,
467 current: std::slice::Iter<'a, Posting<'ctx>>,
468 account_filter: AccountFilter<'ctx>,
469 date_range: DateRange,
470 conversion: Option<Conversion<'ctx>>,
471 price_repos: &'a mut PriceRepository<'ctx>,
472 current_date: NaiveDate,
473 current_amount: Cow<'a, Amount<'ctx>>,
477 total: Amount<'ctx>,
478}
479
480impl<'a, 'ctx> RegisterEntries<'a, 'ctx> {
481 fn advance_to_next_posting(&mut self) -> Option<&'a Posting<'ctx>> {
482 loop {
483 if let Some(posting) = self.current.next() {
484 if self.account_filter.is_match(&posting.account) {
485 return Some(posting);
486 }
487 continue;
488 }
489 let txn = self.txns.next()?;
490 if !self.date_range.contains(txn.date) {
491 continue;
492 }
493 self.current_date = txn.date;
494 self.current = txn.postings.iter();
495 }
496 }
497}
498
499impl<'a, 'ctx, 'lend> FallibleLending<'lend> for RegisterEntries<'a, 'ctx> {
500 type Lend = RegisterEntry<'lend, 'ctx>;
501}
502
503impl<'a, 'ctx> FallibleLender for RegisterEntries<'a, 'ctx> {
504 type Error = QueryError;
505
506 check_covariance_fallible!();
507
508 fn next(&mut self) -> Result<Option<lender::FallibleLend<'_, Self>>, Self::Error> {
509 let Some(posting) = self.advance_to_next_posting() else {
510 return Ok(None);
511 };
512 self.current_amount = match self.conversion {
513 None => Cow::Borrowed(&posting.amount),
514 Some(conv) => {
515 debug_assert!(matches!(conv.strategy, ConversionStrategy::Historical));
518 Cow::Owned(
519 price_db::convert_amount(
520 self.ctx,
521 self.price_repos,
522 &posting.amount,
523 conv.target,
524 self.current_date,
525 )
526 .map_err(QueryError::CommodityConversionFailure)?,
527 )
528 }
529 };
530 self.total += self.current_amount.as_ref();
531 Ok(Some(RegisterEntry {
532 date: self.current_date,
533 payee: posting.payee,
534 account: posting.account,
535 amount: &self.current_amount,
536 total: &self.total,
537 }))
538 }
539}
540
541fn date_range_iter<'a, 'ctx>(
549 sorted_txns: &'a [Transaction<'ctx>],
550 date_range: DateRange,
551) -> TxnIter<'a, 'ctx> {
552 let lo = match date_range.start {
553 None => 0,
554 Some(start) => sorted_txns.partition_point(|t| t.date < start),
555 };
556 TxnIter::sorted(&sorted_txns[lo..], date_range.end)
557}
558
559fn compute_balance<'a, 'ctx>(
566 ctx: &ReportContext<'ctx>,
567 price_repos: &mut PriceRepository<'ctx>,
568 txns: TxnIter<'a, 'ctx>,
569 conversion: Option<Conversion<'ctx>>,
570) -> Result<Balance<'ctx>, QueryError> {
571 let mut bal = Balance::default();
572 for txn in txns {
573 for posting in txn.postings.iter() {
574 let delta = match conversion {
575 Some(Conversion {
576 strategy: ConversionStrategy::Historical,
577 target,
578 }) => Cow::Owned(
579 price_db::convert_amount(ctx, price_repos, &posting.amount, target, txn.date)
580 .map_err(QueryError::CommodityConversionFailure)?,
581 ),
582 None
583 | Some(Conversion {
584 strategy: ConversionStrategy::UpToDate { .. },
585 ..
586 }) => Cow::Borrowed(&posting.amount),
587 };
588 bal.add_amount(posting.account, delta.into_owned());
589 }
590 }
591 bal.round(ctx);
592 Ok(bal)
593}
594
595#[derive(Debug, Clone, Default)]
597pub enum AccountFilter<'ctx> {
598 #[default]
600 All,
601 Exact(Account<'ctx>),
603 Set(HashSet<Account<'ctx>>),
605}
606
607impl<'ctx> AccountFilter<'ctx> {
608 pub fn single(account: Account<'ctx>) -> Self {
610 Self::Exact(account)
611 }
612
613 fn from_set(accounts: HashSet<Account<'ctx>>) -> Self {
617 if accounts.len() == 1 {
618 Self::Exact(accounts.into_iter().next().unwrap())
619 } else {
620 Self::Set(accounts)
621 }
622 }
623
624 pub fn from_exact_accounts(ctx: &ReportContext<'ctx>, accounts: &[impl AsRef<str>]) -> Self {
629 let mut matched = HashSet::new();
630 for name in accounts {
631 if let Some(account) = ctx.account(name.as_ref()) {
632 matched.insert(account);
633 }
634 }
635 Self::from_set(matched)
636 }
637
638 pub fn from_regex_patterns(
648 ctx: &ReportContext<'ctx>,
649 patterns: &[impl AsRef<str>],
650 ) -> Result<Self, regex::Error> {
651 let mut matched: HashSet<Account<'ctx>> = HashSet::new();
652 let set = regex::RegexSet::new(patterns.iter().map(AsRef::as_ref))?;
653 for account in ctx.all_accounts_unsorted() {
654 if set.is_match(account.as_str()) {
655 matched.insert(account);
656 }
657 }
658 Ok(Self::from_set(matched))
659 }
660
661 fn is_exhaustive_empty(&self) -> bool {
663 matches!(self, AccountFilter::Set(s) if s.is_empty())
664 }
665
666 fn is_match(&self, account: &Account<'ctx>) -> bool {
667 match self {
668 AccountFilter::All => true,
669 AccountFilter::Exact(target) => account == target,
670 AccountFilter::Set(targets) => targets.contains(account),
671 }
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 use std::path::PathBuf;
680
681 use assert_matches::assert_matches;
682 use bumpalo::Bump;
683 use indoc::indoc;
684 use maplit::{hashmap, hashset};
685 use pretty_assertions::assert_eq;
686 use rust_decimal_macros::dec;
687
688 use crate::{load, report, testing::recursive_print};
689
690 fn fake_loader() -> load::Loader<load::FakeFileSystem> {
691 let content = indoc! {"
692 commodity JPY
693 format 1,000 JPY
694
695 2023/12/31 rate
696 Equity 0.00 CHF @ 168.24 JPY
697 Equity 0.00 EUR @ 157.12 JPY
698
699 2024/01/01 Initial
700 Assets:J 銀行 1,000,000 JPY
701 Assets:CH Bank 2,000.00 CHF
702 Liabilities:EUR Card -300.00 EUR
703 Assets:Broker 5,000.0000 OKANE {80 JPY}
704 Equity
705
706 2024/01/05 Shopping
707 Expenses:Grocery 100.00 CHF @ 171.50 JPY
708 Assets:J 銀行 -17,150 JPY
709
710 2024/01/09 Buy Stock
711 Assets:Broker 23.0000 OKANE {120 JPY}
712 Assets:J 銀行 -2,760 JPY
713
714 2024/01/15 Sell Stock
715 Assets:Broker 12,300 JPY
716 Assets:Broker -100.0000 OKANE {80 JPY} @ 100 JPY
717 Assets:Broker -23.0000 OKANE {120 JPY} @ 100 JPY
718 Income:Capital Gain -1540 JPY
719
720 2024/01/20 Shopping
721 Expenses:Food 150.00 EUR @ 0.9464 CHF
722 Assets:CH Bank -141.96 CHF
723 "};
724 let fake = hashmap! {
725 PathBuf::from("path/to/file.ledger") => content.as_bytes().to_vec(),
726 };
727 load::Loader::new(PathBuf::from("path/to/file.ledger"), fake.into())
728 }
729
730 fn create_ledger(arena: &Bump) -> (ReportContext<'_>, Ledger<'_>) {
731 let mut ctx = ReportContext::new(arena);
732 let ledger =
733 match report::process(&mut ctx, fake_loader(), &report::ProcessOptions::default()) {
734 Ok(v) => v,
735 Err(err) => panic!(
736 "failed to create the testing Ledger: {}",
737 recursive_print(err),
738 ),
739 };
740 (ctx, ledger)
741 }
742
743 #[test]
744 fn balance_default() {
745 let arena = Bump::new();
746 let (ctx, mut ledger) = create_ledger(&arena);
747
748 log::info!("all_accounts: {:?}", ctx.all_accounts());
749 let chf = ctx.commodities.resolve("CHF").unwrap();
750 let eur = ctx.commodities.resolve("EUR").unwrap();
751 let jpy = ctx.commodities.resolve("JPY").unwrap();
752 let okane = ctx.commodities.resolve("OKANE").unwrap();
753
754 let got = ledger.balance(&ctx, &BalanceQuery::default()).unwrap();
755
756 let want: Balance = [
757 (
758 ctx.account("Assets:CH Bank").unwrap(),
759 Amount::from_value(chf, dec!(1858.04)),
760 ),
761 (
762 ctx.account("Assets:Broker").unwrap(),
763 Amount::from_iter([(okane, dec!(4900.0000)), (jpy, dec!(12300))]),
764 ),
765 (
766 ctx.account("Assets:J 銀行").unwrap(),
767 Amount::from_value(jpy, dec!(980090)),
768 ),
769 (
770 ctx.account("Liabilities:EUR Card").unwrap(),
771 Amount::from_value(eur, dec!(-300.00)),
772 ),
773 (
774 ctx.account("Income:Capital Gain").unwrap(),
775 Amount::from_value(jpy, dec!(-1540)),
776 ),
777 (
778 ctx.account("Expenses:Food").unwrap(),
779 Amount::from_value(eur, dec!(150.00)),
780 ),
781 (
782 ctx.account("Expenses:Grocery").unwrap(),
783 Amount::from_value(chf, dec!(100.00)),
784 ),
785 (
786 ctx.account("Equity").unwrap(),
787 Amount::from_iter([
788 (jpy, dec!(-1_400_000)),
789 (chf, dec!(-2000.00)),
790 (eur, dec!(300.00)),
791 ]),
792 ),
793 ]
794 .into_iter()
795 .collect();
796
797 assert_eq!(want.into_vec(), got.into_owned().into_vec());
798 }
799
800 #[test]
801 fn balance_conversion_historical() {
802 let arena = Bump::new();
807 let (ctx, mut ledger) = create_ledger(&arena);
808
809 let jpy = ctx.commodities.resolve("JPY").unwrap();
810
811 let got = ledger
812 .balance(
813 &ctx,
814 &BalanceQuery {
815 account: AccountFilter::All,
816 conversion: Some(Conversion {
817 strategy: ConversionStrategy::Historical,
818 target: jpy,
819 }),
820 date_range: DateRange::default(),
821 },
822 )
823 .unwrap();
824
825 let want: Balance = [
826 (
827 ctx.account("Assets:CH Bank").unwrap(),
828 Amount::from_value(jpy, dec!(312_134)),
830 ),
831 (
832 ctx.account("Assets:Broker").unwrap(),
833 Amount::from_iter([
838 (jpy, dec!(400_000)),
839 (jpy, dec!(2_760)),
840 (jpy, dec!(-10_000)),
841 (jpy, dec!(-2_300)),
842 (jpy, dec!(12_300)),
843 ]),
844 ),
845 (
846 ctx.account("Assets:J 銀行").unwrap(),
847 Amount::from_value(jpy, dec!(980090)),
848 ),
849 (
850 ctx.account("Liabilities:EUR Card").unwrap(),
851 Amount::from_value(jpy, dec!(-47_136)),
852 ),
853 (
854 ctx.account("Income:Capital Gain").unwrap(),
855 Amount::from_value(jpy, dec!(-1_540)),
856 ),
857 (
858 ctx.account("Expenses:Grocery").unwrap(),
859 Amount::from_value(jpy, dec!(17_150)),
860 ),
861 (
862 ctx.account("Expenses:Food").unwrap(),
863 Amount::from_value(jpy, dec!(23_568)),
864 ),
865 (
866 ctx.account("Equity").unwrap(),
867 Amount::from_iter([
868 (jpy, dec!(-1_400_000)),
869 (jpy, dec!(-336_480)),
871 (jpy, dec!(47_136)),
873 ]),
874 ),
875 ]
876 .into_iter()
877 .collect();
878
879 assert_eq!(want.into_vec(), got.into_owned().into_vec());
880 }
881
882 #[test]
883 fn balance_conversion_up_to_date() {
884 let arena = Bump::new();
885 let (ctx, mut ledger) = create_ledger(&arena);
886
887 let jpy = ctx.commodities.resolve("JPY").unwrap();
888
889 let got = ledger
890 .balance(
891 &ctx,
892 &BalanceQuery {
893 account: AccountFilter::All,
894 conversion: Some(Conversion {
895 strategy: ConversionStrategy::UpToDate {
896 today: NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
897 },
898 target: jpy,
899 }),
900 date_range: DateRange::default(),
901 },
902 )
903 .unwrap();
904
905 let want: Balance = [
906 (
907 ctx.account("Assets:CH Bank").unwrap(),
908 Amount::from_value(jpy, dec!(318_654)),
910 ),
911 (
912 ctx.account("Assets:Broker").unwrap(),
913 Amount::from_value(jpy, dec!(502_300)),
914 ),
915 (
916 ctx.account("Assets:J 銀行").unwrap(),
917 Amount::from_value(jpy, dec!(980090)),
918 ),
919 (
920 ctx.account("Liabilities:EUR Card").unwrap(),
921 Amount::from_value(jpy, dec!(-47_136)),
923 ),
924 (
925 ctx.account("Income:Capital Gain").unwrap(),
926 Amount::from_value(jpy, dec!(-1540)),
927 ),
928 (
929 ctx.account("Expenses:Food").unwrap(),
930 Amount::from_value(jpy, dec!(23_568)),
932 ),
933 (
934 ctx.account("Expenses:Grocery").unwrap(),
935 Amount::from_value(jpy, dec!(17_150)),
936 ),
937 (
938 ctx.account("Equity").unwrap(),
939 Amount::from_iter([
940 (jpy, dec!(-1_400_000)),
941 (jpy, dec!(-343_000)),
943 (jpy, dec!(47_136)),
945 ]),
946 ),
947 ]
948 .into_iter()
949 .collect();
950
951 assert_eq!(want.into_vec(), got.into_owned().into_vec());
952 }
953
954 #[test]
955 fn balance_date_range() {
956 let arena = Bump::new();
957 let (ctx, mut ledger) = create_ledger(&arena);
958
959 log::info!("all_accounts: {:?}", ctx.all_accounts());
960 let chf = ctx.commodities.resolve("CHF").unwrap();
961 let jpy = ctx.commodities.resolve("JPY").unwrap();
962
963 let got = ledger
964 .balance(
965 &ctx,
966 &BalanceQuery {
967 account: AccountFilter::All,
968 conversion: None,
969 date_range: DateRange {
970 start: Some(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()),
971 end: Some(NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()),
972 },
973 },
974 )
975 .unwrap();
976
977 let want: Balance = [
978 (
979 ctx.account("Assets:J 銀行").unwrap(),
980 Amount::from_value(jpy, dec!(-17150)),
981 ),
982 (
983 ctx.account("Expenses:Grocery").unwrap(),
984 Amount::from_value(chf, dec!(100.00)),
985 ),
986 ]
987 .into_iter()
988 .collect();
989
990 assert_eq!(want.into_vec(), got.into_owned().into_vec());
991 }
992
993 #[test]
994 fn eval_default_context() {
995 let arena = Bump::new();
996 let (ctx, mut ledger) = create_ledger(&arena);
997
998 let evaluated = ledger
999 .eval(
1000 &ctx,
1001 "1 JPY",
1002 &EvalContext {
1003 date: NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(),
1004 exchange: None,
1005 },
1006 )
1007 .unwrap();
1008
1009 assert_eq!(
1010 Amount::from_value(ctx.commodities.resolve("JPY").unwrap(), dec!(1)),
1011 evaluated
1012 );
1013 }
1014
1015 type Row<'ctx> = (NaiveDate, String, Account<'ctx>, Amount<'ctx>, Amount<'ctx>);
1018
1019 fn collect_register<'ctx>(
1022 mut entries: RegisterEntries<'_, 'ctx>,
1023 ) -> Result<Vec<Row<'ctx>>, QueryError> {
1024 let mut out = Vec::new();
1025 while let Some(entry) = entries.next()? {
1026 out.push((
1027 entry.date,
1028 entry.payee.to_owned(),
1029 entry.account,
1030 entry.amount.clone(),
1031 entry.total.clone(),
1032 ));
1033 }
1034 Ok(out)
1035 }
1036
1037 #[test]
1038 fn register_entries_filter_by_account() {
1039 let arena = Bump::new();
1040 let (ctx, mut ledger) = create_ledger(&arena);
1041 let jpy = ctx.commodities.resolve("JPY").unwrap();
1042
1043 let bank = ctx.account("Assets:J 銀行").unwrap();
1044 let got = collect_register(
1045 ledger
1046 .register_entries(
1047 &ctx,
1048 &RegisterQuery {
1049 account: AccountFilter::single(bank),
1050 date_range: DateRange::default(),
1051 conversion: None,
1052 sort: Sort::Original,
1053 },
1054 )
1055 .unwrap(),
1056 )
1057 .unwrap();
1058
1059 let want = vec![
1060 (
1061 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1062 "Initial".to_string(),
1063 bank,
1064 Amount::from_value(jpy, dec!(1000000)),
1065 Amount::from_value(jpy, dec!(1000000)),
1066 ),
1067 (
1068 NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1069 "Shopping".to_string(),
1070 bank,
1071 Amount::from_value(jpy, dec!(-17150)),
1072 Amount::from_value(jpy, dec!(982850)),
1073 ),
1074 (
1075 NaiveDate::from_ymd_opt(2024, 1, 9).unwrap(),
1076 "Buy Stock".to_string(),
1077 bank,
1078 Amount::from_value(jpy, dec!(-2760)),
1079 Amount::from_value(jpy, dec!(980090)),
1080 ),
1081 ];
1082 assert_eq!(want, got);
1083 }
1084
1085 #[test]
1086 fn register_entries_filter_by_date_range() {
1087 let arena = Bump::new();
1088 let (ctx, mut ledger) = create_ledger(&arena);
1089 let jpy = ctx.commodities.resolve("JPY").unwrap();
1090 let chf = ctx.commodities.resolve("CHF").unwrap();
1091
1092 let grocery = ctx.account("Expenses:Grocery").unwrap();
1093 let bank = ctx.account("Assets:J 銀行").unwrap();
1094 let got = collect_register(
1095 ledger
1096 .register_entries(
1097 &ctx,
1098 &RegisterQuery {
1099 account: AccountFilter::All,
1100 date_range: DateRange {
1101 start: Some(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()),
1102 end: Some(NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()),
1103 },
1104 conversion: None,
1105 sort: Sort::Original,
1106 },
1107 )
1108 .unwrap(),
1109 )
1110 .unwrap();
1111
1112 let want = vec![
1116 (
1117 NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1118 "Shopping".to_string(),
1119 grocery,
1120 Amount::from_value(chf, dec!(100.00)),
1121 Amount::from_value(chf, dec!(100.00)),
1122 ),
1123 (
1124 NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1125 "Shopping".to_string(),
1126 bank,
1127 Amount::from_value(jpy, dec!(-17150)),
1128 Amount::from_iter([(chf, dec!(100.00)), (jpy, dec!(-17150))]),
1129 ),
1130 ];
1131 assert_eq!(want, got);
1132 }
1133
1134 #[test]
1135 fn register_entries_unknown_account_is_empty() {
1136 let arena = Bump::new();
1137 let (ctx, mut ledger) = create_ledger(&arena);
1138
1139 let got = collect_register(
1140 ledger
1141 .register_entries(
1142 &ctx,
1143 &RegisterQuery {
1144 account: AccountFilter::from_exact_accounts(&ctx, &["Does:Not:Exist"]),
1145 date_range: DateRange::default(),
1146 conversion: None,
1147 sort: Sort::Original,
1148 },
1149 )
1150 .unwrap(),
1151 )
1152 .unwrap();
1153 assert!(got.is_empty(), "expected empty register, got {:?}", got);
1154 }
1155
1156 #[test]
1157 fn register_entries_historical_conversion() {
1158 let arena = Bump::new();
1159 let (ctx, mut ledger) = create_ledger(&arena);
1160 let jpy = ctx.commodities.resolve("JPY").unwrap();
1161 let grocery = ctx.account("Expenses:Grocery").unwrap();
1162 let bank = ctx.account("Assets:J 銀行").unwrap();
1163
1164 let got = collect_register(
1168 ledger
1169 .register_entries(
1170 &ctx,
1171 &RegisterQuery {
1172 account: AccountFilter::All,
1173 date_range: DateRange {
1174 start: Some(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()),
1175 end: Some(NaiveDate::from_ymd_opt(2024, 1, 6).unwrap()),
1176 },
1177 conversion: Some(Conversion {
1178 strategy: ConversionStrategy::Historical,
1179 target: jpy,
1180 }),
1181 sort: Sort::Original,
1182 },
1183 )
1184 .unwrap(),
1185 )
1186 .unwrap();
1187
1188 let want = vec![
1189 (
1190 NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1191 "Shopping".to_string(),
1192 grocery,
1193 Amount::from_value(jpy, dec!(17150.00)),
1195 Amount::from_value(jpy, dec!(17150.00)),
1196 ),
1197 (
1198 NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1199 "Shopping".to_string(),
1200 bank,
1201 Amount::from_value(jpy, dec!(-17150)),
1202 Amount::from_value(jpy, dec!(0.00)),
1203 ),
1204 ];
1205 assert_eq!(want, got);
1206 }
1207
1208 #[test]
1209 fn register_entries_rejects_up_to_date_conversion() {
1210 let arena = Bump::new();
1211 let (ctx, mut ledger) = create_ledger(&arena);
1212 let jpy = ctx.commodities.resolve("JPY").unwrap();
1213
1214 let err = ledger
1215 .register_entries(
1216 &ctx,
1217 &RegisterQuery {
1218 account: AccountFilter::All,
1219 date_range: DateRange::default(),
1220 conversion: Some(Conversion {
1221 strategy: ConversionStrategy::UpToDate {
1222 today: NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
1223 },
1224 target: jpy,
1225 }),
1226 sort: Sort::Original,
1227 },
1228 )
1229 .err()
1230 .expect("UpToDate is not supported yet");
1231 assert_matches!(
1232 err,
1233 QueryError::UnsupportedConversionStrategy,
1234 "unexpected error: {:?}",
1235 err
1236 );
1237 }
1238
1239 #[test]
1240 fn register_entries_running_total_accumulates_across_accounts() {
1241 let arena = Bump::new();
1248 let (ctx, mut ledger) = create_ledger(&arena);
1249
1250 let entries = ledger
1251 .register_entries(&ctx, &RegisterQuery::default())
1252 .unwrap();
1253 let rows = collect_register(entries).unwrap();
1254 assert!(!rows.is_empty());
1255
1256 let final_total = rows.last().map(|r| r.4.clone()).unwrap();
1257 let mut summed = Amount::default();
1258 for r in &rows {
1259 summed += &r.3;
1260 }
1261 assert_eq!(summed, final_total);
1262 }
1263
1264 fn unordered_loader() -> load::Loader<load::FakeFileSystem> {
1267 let content = indoc! {"
1270 2024/03/01 Third by file
1271 Assets:Bank 300 JPY
1272 Equity
1273
1274 2024/01/01 First by date
1275 Assets:Bank 100 JPY
1276 Equity
1277
1278 2024/02/01 Middle by date
1279 Assets:Bank 200 JPY
1280 Equity
1281 "};
1282 let fake = hashmap! {
1283 PathBuf::from("path/to/file.ledger") => content.as_bytes().to_vec(),
1284 };
1285 load::Loader::new(PathBuf::from("path/to/file.ledger"), fake.into())
1286 }
1287
1288 #[test]
1289 fn register_entries_sort_original_preserves_file_order() {
1290 let arena = Bump::new();
1291 let mut ctx = report::ReportContext::new(&arena);
1292 let mut ledger = report::process(
1293 &mut ctx,
1294 unordered_loader(),
1295 &report::ProcessOptions::default(),
1296 )
1297 .unwrap();
1298 let bank = ctx.account("Assets:Bank").unwrap();
1299 let rows = collect_register(
1300 ledger
1301 .register_entries(
1302 &ctx,
1303 &RegisterQuery {
1304 account: AccountFilter::single(bank),
1305 sort: Sort::Original,
1306 ..Default::default()
1307 },
1308 )
1309 .unwrap(),
1310 )
1311 .unwrap();
1312 let dates: Vec<NaiveDate> = rows.iter().map(|r| r.0).collect();
1313 assert_eq!(
1314 dates,
1315 vec![
1316 NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
1317 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1318 NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1319 ]
1320 );
1321 assert!(rows.iter().all(|r| r.2 == bank));
1323 }
1324
1325 #[test]
1326 fn register_entries_sort_date_yields_ascending_order() {
1327 let arena = Bump::new();
1328 let mut ctx = report::ReportContext::new(&arena);
1329 let mut ledger = report::process(
1330 &mut ctx,
1331 unordered_loader(),
1332 &report::ProcessOptions::default(),
1333 )
1334 .unwrap();
1335 let bank = ctx.account("Assets:Bank").unwrap();
1336 let rows = collect_register(
1337 ledger
1338 .register_entries(
1339 &ctx,
1340 &RegisterQuery {
1341 account: AccountFilter::single(bank),
1342 sort: Sort::Date,
1343 ..Default::default()
1344 },
1345 )
1346 .unwrap(),
1347 )
1348 .unwrap();
1349 let dates: Vec<NaiveDate> = rows.iter().map(|r| r.0).collect();
1350 assert_eq!(
1351 dates,
1352 vec![
1353 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1354 NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1355 NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
1356 ]
1357 );
1358 }
1359
1360 #[test]
1361 fn register_entries_sort_date_running_total_matches_chronology() {
1362 let arena = Bump::new();
1365 let mut ctx = report::ReportContext::new(&arena);
1366 let mut ledger = report::process(
1367 &mut ctx,
1368 unordered_loader(),
1369 &report::ProcessOptions::default(),
1370 )
1371 .unwrap();
1372 let jpy = ctx.commodities.resolve("JPY").unwrap();
1373 let bank = ctx.account("Assets:Bank").unwrap();
1374 let rows = collect_register(
1375 ledger
1376 .register_entries(
1377 &ctx,
1378 &RegisterQuery {
1379 account: AccountFilter::single(bank),
1380 sort: Sort::Date,
1381 ..Default::default()
1382 },
1383 )
1384 .unwrap(),
1385 )
1386 .unwrap();
1387 let totals: Vec<Amount> = rows.into_iter().map(|r| r.4).collect();
1388 assert_eq!(
1389 totals,
1390 vec![
1391 Amount::from_value(jpy, dec!(100)),
1392 Amount::from_value(jpy, dec!(300)),
1393 Amount::from_value(jpy, dec!(600)),
1394 ]
1395 );
1396 }
1397
1398 #[test]
1399 fn account_filter_from_regex_patterns_empty_matches_nothing() {
1400 let arena = Bump::new();
1401 let (ctx, _ledger) = create_ledger(&arena);
1402
1403 let filter = AccountFilter::from_regex_patterns(&ctx, &[] as &[&str]).unwrap();
1404 assert_matches!(filter, AccountFilter::Set(s) if s.is_empty());
1405 }
1406
1407 #[test]
1408 fn account_filter_from_regex_patterns_matches_substring() {
1409 let arena = Bump::new();
1410 let (ctx, _ledger) = create_ledger(&arena);
1411
1412 let filter = AccountFilter::from_regex_patterns(&ctx, &["Assets"]).unwrap();
1414 let matched = assert_matches!(filter, AccountFilter::Set(s) => s);
1415 assert_eq!(
1416 matched,
1417 HashSet::from([
1418 ctx.account("Assets:CH Bank").unwrap(),
1419 ctx.account("Assets:Broker").unwrap(),
1420 ctx.account("Assets:J 銀行").unwrap()
1421 ]),
1422 );
1423 }
1424
1425 #[test]
1426 fn account_filter_from_regex_patterns_multiple_patterns_are_unioned() {
1427 let arena = Bump::new();
1428 let (ctx, _ledger) = create_ledger(&arena);
1429
1430 let filter = AccountFilter::from_regex_patterns(&ctx, &["^Income", "Card$"]).unwrap();
1431 assert_matches!(filter, AccountFilter::Set(s) => {
1432 assert_eq!(s, hashset![
1433 ctx.account("Income:Capital Gain").unwrap(),
1434 ctx.account("Liabilities:EUR Card").unwrap(),
1435 ]);
1436 });
1437 }
1438
1439 #[test]
1440 fn account_filter_from_regex_patterns_single_match_collapses_to_exact() {
1441 let arena = Bump::new();
1442 let (ctx, _ledger) = create_ledger(&arena);
1443
1444 let filter = AccountFilter::from_regex_patterns(&ctx, &["Grocery"]).unwrap();
1445 let expected = ctx.account("Expenses:Grocery").unwrap();
1446 assert_matches!(filter, AccountFilter::Exact(a) if a == expected);
1447 }
1448
1449 #[test]
1450 fn account_filter_from_exact_accounts_matches_names_verbatim() {
1451 let arena = Bump::new();
1452 let (ctx, _ledger) = create_ledger(&arena);
1453
1454 let filter =
1458 AccountFilter::from_exact_accounts(&ctx, &["Assets:Broker", ".", "Does:Not:Exist"]);
1459 let expected = ctx.account("Assets:Broker").unwrap();
1460 assert_matches!(filter, AccountFilter::Exact(a) if a == expected);
1461 }
1462
1463 #[test]
1464 fn account_filter_from_exact_accounts_no_matches_is_empty_set() {
1465 let arena = Bump::new();
1466 let (ctx, _ledger) = create_ledger(&arena);
1467
1468 let filter = AccountFilter::from_exact_accounts(&ctx, &["Does:Not:Exist"]);
1469 assert_matches!(filter, AccountFilter::Set(s) if s.is_empty());
1470 }
1471
1472 #[test]
1473 fn account_filter_from_regex_patterns_invalid_regex_errors() {
1474 let arena = Bump::new();
1475 let (ctx, _ledger) = create_ledger(&arena);
1476
1477 let err = AccountFilter::from_regex_patterns(&ctx, &["("]);
1478 assert!(err.is_err(), "expected regex compile error, got {err:?}");
1479 }
1480
1481 #[test]
1482 fn balance_with_account_filter() {
1483 let arena = Bump::new();
1484 let (ctx, mut ledger) = create_ledger(&arena);
1485 let chf = ctx.commodities.resolve("CHF").unwrap();
1486 let jpy = ctx.commodities.resolve("JPY").unwrap();
1487 let okane = ctx.commodities.resolve("OKANE").unwrap();
1488
1489 let got = ledger
1490 .balance(
1491 &ctx,
1492 &BalanceQuery {
1493 account: AccountFilter::from_regex_patterns(&ctx, &["^Assets"]).unwrap(),
1494 conversion: None,
1495 date_range: DateRange::default(),
1496 },
1497 )
1498 .unwrap()
1499 .into_owned();
1500
1501 let want: Balance = [
1502 (
1503 ctx.account("Assets:CH Bank").unwrap(),
1504 Amount::from_value(chf, dec!(1858.04)),
1505 ),
1506 (
1507 ctx.account("Assets:Broker").unwrap(),
1508 Amount::from_iter([(okane, dec!(4900.0000)), (jpy, dec!(12300))]),
1509 ),
1510 (
1511 ctx.account("Assets:J 銀行").unwrap(),
1512 Amount::from_value(jpy, dec!(980090)),
1513 ),
1514 ]
1515 .into_iter()
1516 .collect();
1517 assert_eq!(want, got);
1518 }
1519}