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 account::{Account, AccountAggregate},
17 balance::Balance,
18 commodity::OwnedCommodity,
19 context::ReportContext,
20 eval::{Amount, EvalError, Evaluable},
21 price_db::{self, ConversionError, PriceRepository},
22 transaction::{Posting, Transaction},
23};
24
25#[derive(Debug)]
27pub struct Ledger<'ctx> {
28 pub(super) arena: &'ctx bumpalo::Bump,
31 pub(super) transactions: Vec<Transaction<'ctx>>,
32 pub(super) date_sorted_txns: Option<Vec<Transaction<'ctx>>>,
40 pub(super) raw_balance: Balance<'ctx>,
41 pub(super) price_repos: PriceRepository<'ctx>,
42}
43
44#[derive(Debug, thiserror::Error)]
48pub enum QueryError {
49 #[error("failed to parse the given value")]
50 ParseFailed(#[from] parse::ParseError),
51 #[error("failed to evaluate the expr")]
52 EvalFailed(#[from] OwnedEvalError),
53 #[error("commodity {0} not found")]
54 CommodityNotFound(OwnedCommodity),
55 #[error("cannot convert amount: {0}")]
56 CommodityConversionFailure(#[from] ConversionError),
57 #[error("unsupported conversion strategy for this query")]
58 UnsupportedConversionStrategy,
59 #[error("internal failure: {0}")]
60 Internal(String),
61}
62
63#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
65pub enum Sort {
66 #[default]
68 Original,
69 Date,
71}
72
73#[derive(Debug, Default)]
75pub struct RegisterQuery<'ctx> {
77 pub account: AccountFilter<'ctx>,
80 pub date_range: DateRange,
82 pub conversion: Option<Conversion<'ctx>>,
86 pub sort: Sort,
88}
89
90#[derive(Debug)]
96#[non_exhaustive]
97pub struct RegisterEntry<'lend, 'ctx> {
98 pub date: NaiveDate,
100 pub payee: &'ctx str,
103 pub account: Account<'ctx>,
105 pub amount: &'lend Amount<'ctx>,
108 pub total: &'lend Amount<'ctx>,
111}
112
113#[derive(Debug, PartialEq, Eq, Clone, Copy)]
115pub enum ConversionStrategy {
116 Historical,
118 UpToDate {
122 today: NaiveDate,
124 },
125}
126
127#[derive(Debug, Clone, Copy)]
129pub struct Conversion<'ctx> {
131 pub strategy: ConversionStrategy,
132 pub target: CommodityTag<'ctx>,
133}
134
135#[derive(Debug, Default, Clone, Copy)]
139pub struct DateRange {
140 pub start: Option<NaiveDate>,
142 pub end: Option<NaiveDate>,
144}
145
146impl DateRange {
147 fn is_bypass(&self) -> bool {
148 self.start.is_none() && self.end.is_none()
149 }
150
151 #[inline]
152 fn contains(&self, date: NaiveDate) -> bool {
153 match (self.start, self.end) {
154 (Some(start), _) if date < start => false,
155 (_, Some(end)) if end <= date => false,
156 _ => true,
157 }
158 }
159}
160
161#[derive(Debug, Default)]
163pub struct BalanceQuery<'ctx> {
165 pub account: AccountFilter<'ctx>,
168 pub conversion: Option<Conversion<'ctx>>,
169 pub date_range: DateRange,
170}
171
172impl BalanceQuery<'_> {
173 fn require_recompute(&self) -> bool {
174 if !self.date_range.is_bypass() {
175 return true;
176 }
177 if matches!(&self.conversion, Some(conv) if conv.strategy == ConversionStrategy::Historical)
178 {
179 return true;
180 }
181 false
183 }
184}
185
186#[derive(Debug)]
188pub struct EvalContext {
190 pub date: NaiveDate,
191 pub exchange: Option<String>,
192}
193
194impl<'ctx> Ledger<'ctx> {
195 pub fn empty(ctx: &ReportContext<'ctx>) -> Self {
199 Self {
200 arena: ctx.arena,
201 transactions: Vec::new(),
202 date_sorted_txns: None,
203 raw_balance: Balance::default(),
204 price_repos: price_db::PriceRepositoryBuilder::default().build(),
205 }
206 }
207
208 pub fn transactions(&self) -> impl Iterator<Item = &Transaction<'ctx>> {
210 self.transactions.iter()
211 }
212
213 fn ensure_date_sorted_txns(&mut self) {
219 if self.date_sorted_txns.is_some() {
220 return;
221 }
222 let arena = self.arena;
223 let mut sorted: Vec<Transaction<'ctx>> = self
224 .transactions
225 .iter()
226 .map(|txn| {
227 let mut postings = bcc::Vec::with_capacity_in(txn.postings.len(), arena);
228 for p in txn.postings.iter() {
229 postings.push(p.clone());
230 }
231 Transaction {
232 date: txn.date,
233 postings: postings.into_boxed_slice(),
234 }
235 })
236 .collect();
237 sorted.sort_by_key(|t| t.date);
238 self.date_sorted_txns = Some(sorted);
239 }
240
241 pub fn register_entries<'a>(
257 &'a mut self,
258 ctx: &'a ReportContext<'ctx>,
259 query: &RegisterQuery<'ctx>,
260 ) -> Result<RegisterEntries<'a, 'ctx>, QueryError> {
261 let conversion = match query.conversion {
262 None => None,
263 Some(c) => match c.strategy {
264 ConversionStrategy::Historical => Some(c),
265 ConversionStrategy::UpToDate { .. } => {
266 return Err(QueryError::UnsupportedConversionStrategy);
267 }
268 },
269 };
270 let account_filter = query.account.clone();
271 let txns: TxnIter<'a, 'ctx> = if account_filter.is_empty() {
274 TxnIter::linear(&[])
275 } else {
276 match query.sort {
277 Sort::Original => TxnIter::linear(&self.transactions),
278 Sort::Date => {
284 self.ensure_date_sorted_txns();
285 date_range_iter(
286 self.date_sorted_txns
287 .as_deref()
288 .expect("just built by ensure_date_sorted_txns"),
289 query.date_range,
290 )
291 }
292 }
293 };
294 Ok(RegisterEntries {
295 ctx,
296 txns,
297 current: [].iter(),
298 account_filter,
299 date_range: query.date_range,
300 conversion,
301 price_repos: &mut self.price_repos,
302 current_date: NaiveDate::MIN,
303 current_amount: Cow::Owned(Amount::default()),
304 total: Amount::default(),
305 })
306 }
307
308 pub fn balance(
312 &mut self,
313 ctx: &ReportContext<'ctx>,
314 query: &BalanceQuery<'ctx>,
315 ) -> Result<Cow<'_, Balance<'ctx>>, QueryError> {
316 let balance = if !query.require_recompute() {
317 Cow::Borrowed(&self.raw_balance)
318 } else {
319 let txns = if query.date_range.is_bypass() {
325 TxnIter::linear(&self.transactions)
326 } else {
327 self.ensure_date_sorted_txns();
328 date_range_iter(
329 self.date_sorted_txns
330 .as_deref()
331 .expect("just built by ensure_date_sorted_txns"),
332 query.date_range,
333 )
334 };
335 Cow::Owned(compute_balance(
336 ctx,
337 &mut self.price_repos,
338 txns,
339 query.conversion,
340 )?)
341 };
342 let balance = match query.conversion {
343 None
344 | Some(Conversion {
345 strategy: ConversionStrategy::Historical,
346 ..
347 }) => balance,
348 Some(Conversion {
349 strategy: ConversionStrategy::UpToDate { today },
350 target,
351 }) => {
352 let mut converted = Balance::default();
353 for (account, original_amount) in balance.iter() {
354 converted.add_amount(
355 *account,
356 price_db::convert_amount(
357 ctx,
358 &mut self.price_repos,
359 original_amount,
360 target,
361 today,
362 )
363 .map_err(QueryError::CommodityConversionFailure)?,
364 );
365 }
366 converted.round(ctx);
367 Cow::Owned(converted)
368 }
369 };
370 match &query.account {
374 AccountFilter::All => Ok(balance),
375 filter => {
376 let mut filtered = Balance::default();
377 for (account, amount) in balance.iter() {
378 if filter.is_match(account) {
379 filtered.add_amount(*account, amount.clone());
380 }
381 }
382 Ok(Cow::Owned(filtered))
383 }
384 }
385 }
386
387 pub fn eval(
389 &mut self,
390 ctx: &ReportContext<'ctx>,
391 expression: &str,
392 eval_ctx: &EvalContext,
393 ) -> Result<Amount<'ctx>, QueryError> {
394 let exchange = eval_ctx
395 .exchange
396 .as_ref()
397 .map(|x| {
398 ctx.commodities.resolve(x).ok_or_else(|| {
399 QueryError::CommodityNotFound(OwnedCommodity::from_string(x.to_owned()))
400 })
401 })
402 .transpose()?;
403 let parsed: syntax::expr::ValueExpr =
404 expression.try_into().map_err(QueryError::ParseFailed)?;
405 let evaled: Amount<'ctx> = parsed
406 .eval(ctx)
407 .map_err(|e| e.into_owned(ctx))?
408 .try_into()
409 .map_err(|e: EvalError<'_>| e.into_owned(ctx))?;
410 let evaled = match exchange {
411 None => evaled,
412 Some(price_with) => price_db::convert_amount(
413 ctx,
414 &mut self.price_repos,
415 &evaled,
416 price_with,
417 eval_ctx.date,
418 )
419 .map_err(QueryError::CommodityConversionFailure)?,
420 };
421 Ok(evaled)
422 }
423}
424
425pub(super) struct TxnIter<'a, 'ctx> {
435 it: std::slice::Iter<'a, Transaction<'ctx>>,
436 end: Option<NaiveDate>,
439}
440
441impl<'a, 'ctx> TxnIter<'a, 'ctx> {
442 fn linear(slice: &'a [Transaction<'ctx>]) -> Self {
444 Self {
445 it: slice.iter(),
446 end: None,
447 }
448 }
449
450 fn sorted(slice: &'a [Transaction<'ctx>], end: Option<NaiveDate>) -> Self {
454 Self {
455 it: slice.iter(),
456 end,
457 }
458 }
459}
460
461impl<'a, 'ctx> Iterator for TxnIter<'a, 'ctx> {
462 type Item = &'a Transaction<'ctx>;
463 #[inline]
464 fn next(&mut self) -> Option<Self::Item> {
465 let txn = self.it.next()?;
466 if let Some(end) = self.end
467 && txn.date >= end
468 {
469 return None;
470 }
471 Some(txn)
472 }
473}
474
475pub struct RegisterEntries<'a, 'ctx> {
482 ctx: &'a ReportContext<'ctx>,
483 txns: TxnIter<'a, 'ctx>,
484 current: std::slice::Iter<'a, Posting<'ctx>>,
485 account_filter: AccountFilter<'ctx>,
486 date_range: DateRange,
487 conversion: Option<Conversion<'ctx>>,
488 price_repos: &'a mut PriceRepository<'ctx>,
489 current_date: NaiveDate,
490 current_amount: Cow<'a, Amount<'ctx>>,
494 total: Amount<'ctx>,
495}
496
497impl<'a, 'ctx> RegisterEntries<'a, 'ctx> {
498 fn advance_to_next_posting(&mut self) -> Option<&'a Posting<'ctx>> {
499 loop {
500 if let Some(posting) = self.current.next() {
501 if self.account_filter.is_match(&posting.account) {
502 return Some(posting);
503 }
504 continue;
505 }
506 let txn = self.txns.next()?;
507 if !self.date_range.contains(txn.date) {
508 continue;
509 }
510 self.current_date = txn.date;
511 self.current = txn.postings.iter();
512 }
513 }
514}
515
516impl<'a, 'ctx, 'lend> FallibleLending<'lend> for RegisterEntries<'a, 'ctx> {
517 type Lend = RegisterEntry<'lend, 'ctx>;
518}
519
520impl<'a, 'ctx> FallibleLender for RegisterEntries<'a, 'ctx> {
521 type Error = QueryError;
522
523 check_covariance_fallible!();
524
525 fn next(&mut self) -> Result<Option<lender::FallibleLend<'_, Self>>, Self::Error> {
526 let Some(posting) = self.advance_to_next_posting() else {
527 return Ok(None);
528 };
529 self.current_amount = match self.conversion {
530 None => Cow::Borrowed(&posting.amount),
531 Some(conv) => {
532 debug_assert!(matches!(conv.strategy, ConversionStrategy::Historical));
535 Cow::Owned(
536 price_db::convert_amount(
537 self.ctx,
538 self.price_repos,
539 &posting.amount,
540 conv.target,
541 self.current_date,
542 )
543 .map_err(QueryError::CommodityConversionFailure)?,
544 )
545 }
546 };
547 self.total += self.current_amount.as_ref();
548 Ok(Some(RegisterEntry {
549 date: self.current_date,
550 payee: posting.payee,
551 account: posting.account,
552 amount: &self.current_amount,
553 total: &self.total,
554 }))
555 }
556}
557
558fn date_range_iter<'a, 'ctx>(
566 sorted_txns: &'a [Transaction<'ctx>],
567 date_range: DateRange,
568) -> TxnIter<'a, 'ctx> {
569 let lo = match date_range.start {
570 None => 0,
571 Some(start) => sorted_txns.partition_point(|t| t.date < start),
572 };
573 TxnIter::sorted(&sorted_txns[lo..], date_range.end)
574}
575
576fn compute_balance<'a, 'ctx>(
583 ctx: &ReportContext<'ctx>,
584 price_repos: &mut PriceRepository<'ctx>,
585 txns: TxnIter<'a, 'ctx>,
586 conversion: Option<Conversion<'ctx>>,
587) -> Result<Balance<'ctx>, QueryError> {
588 let mut bal = Balance::default();
589 for txn in txns {
590 for posting in txn.postings.iter() {
591 let delta = match conversion {
592 Some(Conversion {
593 strategy: ConversionStrategy::Historical,
594 target,
595 }) => Cow::Owned(
596 price_db::convert_amount(ctx, price_repos, &posting.amount, target, txn.date)
597 .map_err(QueryError::CommodityConversionFailure)?,
598 ),
599 None
600 | Some(Conversion {
601 strategy: ConversionStrategy::UpToDate { .. },
602 ..
603 }) => Cow::Borrowed(&posting.amount),
604 };
605 bal.add_amount(posting.account, delta.into_owned());
606 }
607 }
608 bal.round(ctx);
609 Ok(bal)
610}
611
612#[derive(Debug, Clone, Default)]
614pub enum AccountFilter<'ctx> {
615 #[default]
617 All,
618 Exact(Account<'ctx>),
620 Set(HashSet<Account<'ctx>>),
622}
623
624impl<'ctx> AccountFilter<'ctx> {
625 pub fn single(account: Account<'ctx>) -> Self {
627 Self::Exact(account)
628 }
629
630 fn from_set(accounts: HashSet<Account<'ctx>>) -> Self {
634 if accounts.len() == 1 {
635 Self::Exact(accounts.into_iter().next().unwrap())
636 } else {
637 Self::Set(accounts)
638 }
639 }
640
641 pub fn from_exact_accounts(ctx: &ReportContext<'ctx>, accounts: &[impl AsRef<str>]) -> Self {
646 let mut matched = HashSet::new();
647 for name in accounts {
648 if let Some(account) = ctx.account(name.as_ref()) {
649 matched.insert(account);
650 }
651 }
652 Self::from_set(matched)
653 }
654
655 pub fn from_regex_patterns(
665 ctx: &ReportContext<'ctx>,
666 patterns: &[impl AsRef<str>],
667 ) -> Result<Self, regex::Error> {
668 let mut matched: HashSet<Account<'ctx>> = HashSet::new();
669 let set = regex::RegexSet::new(patterns.iter().map(AsRef::as_ref))?;
670 for account in ctx.all_accounts_unsorted() {
671 if set.is_match(account.as_str()) {
672 matched.insert(account);
673 }
674 }
675 Ok(Self::from_set(matched))
676 }
677
678 pub fn descendants_of(ctx: &ReportContext<'ctx>, prefix: AccountAggregate<'ctx>) -> Self {
682 let mut matched: HashSet<Account<'ctx>> = HashSet::new();
683 let mut targets = vec![prefix];
684 while let Some(current) = targets.pop() {
685 if let Some(account) = current.as_account() {
686 matched.insert(account);
687 }
688 for child in ctx.account_tree.children(current).unwrap_or_default() {
691 targets.push(*child);
692 }
693 }
694 Self::from_set(matched)
695 }
696
697 fn is_empty(&self) -> bool {
699 matches!(self, AccountFilter::Set(s) if s.is_empty())
700 }
701
702 #[inline]
704 fn is_match(&self, account: &Account<'ctx>) -> bool {
705 match self {
706 AccountFilter::All => true,
707 AccountFilter::Exact(target) => account == target,
708 AccountFilter::Set(targets) => targets.contains(account),
709 }
710 }
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716
717 use std::path::PathBuf;
718
719 use assert_matches::assert_matches;
720 use bumpalo::Bump;
721 use indoc::indoc;
722 use maplit::{hashmap, hashset};
723 use pretty_assertions::assert_eq;
724 use rust_decimal_macros::dec;
725
726 use crate::{load, report, testing::recursive_print};
727
728 fn fake_loader() -> load::Loader<load::FakeFileSystem> {
729 let content = indoc! {"
730 commodity JPY
731 format 1,000 JPY
732
733 2023/12/31 rate
734 Equity 0.00 CHF @ 168.24 JPY
735 Equity 0.00 EUR @ 157.12 JPY
736
737 2024/01/01 Initial
738 Assets:J 銀行:普通 1,000,000 JPY
739 Assets:CH Bank 2,000.00 CHF
740 Liabilities:EUR Card -300.00 EUR
741 Assets:Broker 5,000.0000 OKANE {80 JPY}
742 Equity
743
744 2024/01/05 Shopping
745 Expenses:Grocery 100.00 CHF @ 171.50 JPY
746 Assets:J 銀行:普通 -17,150 JPY
747
748 2024/01/09 Buy Stock
749 Assets:Broker 23.0000 OKANE {120 JPY}
750 Assets:J 銀行:普通 -2,760 JPY
751
752 2024/01/15 Sell Stock
753 Assets:Broker 12,300 JPY
754 Assets:Broker -100.0000 OKANE {80 JPY} @ 100 JPY
755 Assets:Broker -23.0000 OKANE {120 JPY} @ 100 JPY
756 Income:Capital Gain -1540 JPY
757
758 2024/01/20 Restaurant
759 Expenses:Food 150.00 EUR @ 0.9464 CHF
760 Assets:CH Bank -141.96 CHF
761
762 2025/10/30 event
763 ; Expenses:Foodをprefixに持つけど関係ないアカウント
764 Expenses:Food Fight 1,000 JPY
765 Assets:J 銀行:普通
766
767 2026/01/30 Ramen
768 Expenses:Food:Ramen 650 JPY
769 Assets:CH Bank -3.25 CHF @ 200 JPY
770 "};
771 let fake = hashmap! {
772 PathBuf::from("path/to/file.ledger") => content.as_bytes().to_vec(),
773 };
774 load::Loader::new(PathBuf::from("path/to/file.ledger"), fake.into())
775 }
776
777 fn create_ledger(arena: &Bump) -> (ReportContext<'_>, Ledger<'_>) {
778 let mut ctx = ReportContext::new(arena);
779 let ledger =
780 match report::process(&mut ctx, fake_loader(), &report::ProcessOptions::default()) {
781 Ok(v) => v,
782 Err(err) => panic!(
783 "failed to create the testing Ledger: {}",
784 recursive_print(err),
785 ),
786 };
787 (ctx, ledger)
788 }
789
790 #[test]
791 fn balance_default() {
792 let arena = Bump::new();
793 let (ctx, mut ledger) = create_ledger(&arena);
794
795 log::info!("all_accounts: {:?}", ctx.all_accounts());
796 let chf = ctx.commodities.resolve("CHF").unwrap();
797 let eur = ctx.commodities.resolve("EUR").unwrap();
798 let jpy = ctx.commodities.resolve("JPY").unwrap();
799 let okane = ctx.commodities.resolve("OKANE").unwrap();
800
801 let got = ledger.balance(&ctx, &BalanceQuery::default()).unwrap();
802
803 let want: Balance = [
804 (
805 ctx.account("Assets:CH Bank").unwrap(),
806 Amount::from_value(chf, dec!(1854.79)),
807 ),
808 (
809 ctx.account("Assets:Broker").unwrap(),
810 Amount::from_iter([(okane, dec!(4900.0000)), (jpy, dec!(12300))]),
811 ),
812 (
813 ctx.account("Assets:J 銀行:普通").unwrap(),
814 Amount::from_value(jpy, dec!(979_090)),
815 ),
816 (
817 ctx.account("Liabilities:EUR Card").unwrap(),
818 Amount::from_value(eur, dec!(-300.00)),
819 ),
820 (
821 ctx.account("Income:Capital Gain").unwrap(),
822 Amount::from_value(jpy, dec!(-1540)),
823 ),
824 (
825 ctx.account("Expenses:Food").unwrap(),
826 Amount::from_value(eur, dec!(150.00)),
827 ),
828 (
829 ctx.account("Expenses:Food:Ramen").unwrap(),
830 Amount::from_value(jpy, dec!(650)),
831 ),
832 (
833 ctx.account("Expenses:Food Fight").unwrap(),
834 Amount::from_value(jpy, dec!(1_000)),
835 ),
836 (
837 ctx.account("Expenses:Grocery").unwrap(),
838 Amount::from_value(chf, dec!(100.00)),
839 ),
840 (
841 ctx.account("Equity").unwrap(),
842 Amount::from_iter([
843 (jpy, dec!(-1_400_000)),
844 (chf, dec!(-2000.00)),
845 (eur, dec!(300.00)),
846 ]),
847 ),
848 ]
849 .into_iter()
850 .collect();
851
852 assert_eq!(want.into_vec(), got.into_owned().into_vec());
853 }
854
855 #[test]
856 fn balance_conversion_historical() {
857 let arena = Bump::new();
862 let (ctx, mut ledger) = create_ledger(&arena);
863
864 let jpy = ctx.commodities.resolve("JPY").unwrap();
865
866 let got = ledger
867 .balance(
868 &ctx,
869 &BalanceQuery {
870 account: AccountFilter::All,
871 conversion: Some(Conversion {
872 strategy: ConversionStrategy::Historical,
873 target: jpy,
874 }),
875 date_range: DateRange::default(),
876 },
877 )
878 .unwrap();
879
880 let want: Balance = [
881 (
882 ctx.account("Assets:CH Bank").unwrap(),
883 Amount::from_value(jpy, dec!(311_484)),
885 ),
886 (
887 ctx.account("Assets:Broker").unwrap(),
888 Amount::from_iter([
893 (jpy, dec!(400_000)),
894 (jpy, dec!(2_760)),
895 (jpy, dec!(-10_000)),
896 (jpy, dec!(-2_300)),
897 (jpy, dec!(12_300)),
898 ]),
899 ),
900 (
901 ctx.account("Assets:J 銀行:普通").unwrap(),
902 Amount::from_value(jpy, dec!(979_090)),
903 ),
904 (
905 ctx.account("Liabilities:EUR Card").unwrap(),
906 Amount::from_value(jpy, dec!(-47_136)),
907 ),
908 (
909 ctx.account("Income:Capital Gain").unwrap(),
910 Amount::from_value(jpy, dec!(-1_540)),
911 ),
912 (
913 ctx.account("Expenses:Grocery").unwrap(),
914 Amount::from_value(jpy, dec!(17_150)),
915 ),
916 (
917 ctx.account("Expenses:Food").unwrap(),
918 Amount::from_value(jpy, dec!(23_568)),
919 ),
920 (
921 ctx.account("Expenses:Food:Ramen").unwrap(),
922 Amount::from_value(jpy, dec!(650)),
923 ),
924 (
925 ctx.account("Expenses:Food Fight").unwrap(),
926 Amount::from_value(jpy, dec!(1_000)),
927 ),
928 (
929 ctx.account("Equity").unwrap(),
930 Amount::from_iter([
931 (jpy, dec!(-1_400_000)),
932 (jpy, dec!(-336_480)),
934 (jpy, dec!(47_136)),
936 ]),
937 ),
938 ]
939 .into_iter()
940 .collect();
941
942 assert_eq!(want.into_vec(), got.into_owned().into_vec());
943 }
944
945 #[test]
946 fn balance_conversion_up_to_date() {
947 let arena = Bump::new();
948 let (ctx, mut ledger) = create_ledger(&arena);
949
950 let jpy = ctx.commodities.resolve("JPY").unwrap();
951
952 let got = ledger
953 .balance(
954 &ctx,
955 &BalanceQuery {
956 account: AccountFilter::All,
957 conversion: Some(Conversion {
958 strategy: ConversionStrategy::UpToDate {
959 today: NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
960 },
961 target: jpy,
962 }),
963 date_range: DateRange::default(),
964 },
965 )
966 .unwrap();
967
968 let want: Balance = [
969 (
970 ctx.account("Assets:CH Bank").unwrap(),
971 Amount::from_value(jpy, dec!(318_096)),
973 ),
974 (
975 ctx.account("Assets:Broker").unwrap(),
976 Amount::from_value(jpy, dec!(502_300)),
977 ),
978 (
979 ctx.account("Assets:J 銀行:普通").unwrap(),
980 Amount::from_value(jpy, dec!(979_090)),
981 ),
982 (
983 ctx.account("Liabilities:EUR Card").unwrap(),
984 Amount::from_value(jpy, dec!(-47_136)),
986 ),
987 (
988 ctx.account("Income:Capital Gain").unwrap(),
989 Amount::from_value(jpy, dec!(-1540)),
990 ),
991 (
992 ctx.account("Expenses:Food").unwrap(),
993 Amount::from_value(jpy, dec!(23_568)),
995 ),
996 (
997 ctx.account("Expenses:Food:Ramen").unwrap(),
998 Amount::from_value(jpy, dec!(650)),
999 ),
1000 (
1001 ctx.account("Expenses:Food Fight").unwrap(),
1002 Amount::from_value(jpy, dec!(1_000)),
1003 ),
1004 (
1005 ctx.account("Expenses:Grocery").unwrap(),
1006 Amount::from_value(jpy, dec!(17_150)),
1007 ),
1008 (
1009 ctx.account("Equity").unwrap(),
1010 Amount::from_iter([
1011 (jpy, dec!(-1_400_000)),
1012 (jpy, dec!(-343_000)),
1014 (jpy, dec!(47_136)),
1016 ]),
1017 ),
1018 ]
1019 .into_iter()
1020 .collect();
1021
1022 assert_eq!(want.into_vec(), got.into_owned().into_vec());
1023 }
1024
1025 #[test]
1026 fn balance_date_range() {
1027 let arena = Bump::new();
1028 let (ctx, mut ledger) = create_ledger(&arena);
1029
1030 log::info!("all_accounts: {:?}", ctx.all_accounts());
1031 let chf = ctx.commodities.resolve("CHF").unwrap();
1032 let jpy = ctx.commodities.resolve("JPY").unwrap();
1033
1034 let got = ledger
1035 .balance(
1036 &ctx,
1037 &BalanceQuery {
1038 account: AccountFilter::All,
1039 conversion: None,
1040 date_range: DateRange {
1041 start: Some(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()),
1042 end: Some(NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()),
1043 },
1044 },
1045 )
1046 .unwrap();
1047
1048 let want: Balance = [
1049 (
1050 ctx.account("Assets:J 銀行:普通").unwrap(),
1051 Amount::from_value(jpy, dec!(-17150)),
1052 ),
1053 (
1054 ctx.account("Expenses:Grocery").unwrap(),
1055 Amount::from_value(chf, dec!(100.00)),
1056 ),
1057 ]
1058 .into_iter()
1059 .collect();
1060
1061 assert_eq!(want.into_vec(), got.into_owned().into_vec());
1062 }
1063
1064 #[test]
1065 fn balance_with_account_filter() {
1066 let arena = Bump::new();
1067 let (ctx, mut ledger) = create_ledger(&arena);
1068 let chf = ctx.commodities.resolve("CHF").unwrap();
1069 let jpy = ctx.commodities.resolve("JPY").unwrap();
1070 let okane = ctx.commodities.resolve("OKANE").unwrap();
1071
1072 let got = ledger
1073 .balance(
1074 &ctx,
1075 &BalanceQuery {
1076 account: AccountFilter::from_regex_patterns(&ctx, &["^Assets"]).unwrap(),
1077 conversion: None,
1078 date_range: DateRange::default(),
1079 },
1080 )
1081 .unwrap()
1082 .into_owned();
1083
1084 let want: Balance = [
1085 (
1086 ctx.account("Assets:CH Bank").unwrap(),
1087 Amount::from_value(chf, dec!(1854.79)),
1088 ),
1089 (
1090 ctx.account("Assets:Broker").unwrap(),
1091 Amount::from_iter([(okane, dec!(4900.0000)), (jpy, dec!(12300))]),
1092 ),
1093 (
1094 ctx.account("Assets:J 銀行:普通").unwrap(),
1095 Amount::from_value(jpy, dec!(979_090)),
1096 ),
1097 ]
1098 .into_iter()
1099 .collect();
1100 assert_eq!(want, got);
1101 }
1102
1103 #[test]
1104 fn eval_default_context() {
1105 let arena = Bump::new();
1106 let (ctx, mut ledger) = create_ledger(&arena);
1107
1108 let evaluated = ledger
1109 .eval(
1110 &ctx,
1111 "1 JPY",
1112 &EvalContext {
1113 date: NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(),
1114 exchange: None,
1115 },
1116 )
1117 .unwrap();
1118
1119 assert_eq!(
1120 Amount::from_value(ctx.commodities.resolve("JPY").unwrap(), dec!(1)),
1121 evaluated
1122 );
1123 }
1124
1125 type Row<'ctx> = (NaiveDate, String, Account<'ctx>, Amount<'ctx>, Amount<'ctx>);
1128
1129 fn collect_register<'ctx>(
1132 mut entries: RegisterEntries<'_, 'ctx>,
1133 ) -> Result<Vec<Row<'ctx>>, QueryError> {
1134 let mut out = Vec::new();
1135 while let Some(entry) = entries.next()? {
1136 out.push((
1137 entry.date,
1138 entry.payee.to_owned(),
1139 entry.account,
1140 entry.amount.clone(),
1141 entry.total.clone(),
1142 ));
1143 }
1144 Ok(out)
1145 }
1146
1147 #[test]
1148 fn register_entries_filter_by_account() {
1149 let arena = Bump::new();
1150 let (ctx, mut ledger) = create_ledger(&arena);
1151 let jpy = ctx.commodities.resolve("JPY").unwrap();
1152
1153 let bank = ctx.account("Assets:J 銀行:普通").unwrap();
1154 let got = collect_register(
1155 ledger
1156 .register_entries(
1157 &ctx,
1158 &RegisterQuery {
1159 account: AccountFilter::single(bank),
1160 date_range: DateRange::default(),
1161 conversion: None,
1162 sort: Sort::Original,
1163 },
1164 )
1165 .unwrap(),
1166 )
1167 .unwrap();
1168
1169 let want = vec![
1170 (
1171 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1172 "Initial".to_string(),
1173 bank,
1174 Amount::from_value(jpy, dec!(1_000_000)),
1175 Amount::from_value(jpy, dec!(1_000_000)),
1176 ),
1177 (
1178 NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1179 "Shopping".to_string(),
1180 bank,
1181 Amount::from_value(jpy, dec!(-17_150)),
1182 Amount::from_value(jpy, dec!(982_850)),
1183 ),
1184 (
1185 NaiveDate::from_ymd_opt(2024, 1, 9).unwrap(),
1186 "Buy Stock".to_string(),
1187 bank,
1188 Amount::from_value(jpy, dec!(-2760)),
1189 Amount::from_value(jpy, dec!(980_090)),
1190 ),
1191 (
1192 NaiveDate::from_ymd_opt(2025, 10, 30).unwrap(),
1193 "event".to_string(),
1194 bank,
1195 Amount::from_value(jpy, dec!(-1000)),
1196 Amount::from_value(jpy, dec!(979_090)),
1197 ),
1198 ];
1199 assert_eq!(want, got);
1200 }
1201
1202 #[test]
1203 fn register_entries_filter_by_date_range() {
1204 let arena = Bump::new();
1205 let (ctx, mut ledger) = create_ledger(&arena);
1206 let jpy = ctx.commodities.resolve("JPY").unwrap();
1207 let chf = ctx.commodities.resolve("CHF").unwrap();
1208
1209 let grocery = ctx.account("Expenses:Grocery").unwrap();
1210 let bank = ctx.account("Assets:J 銀行:普通").unwrap();
1211 let got = collect_register(
1212 ledger
1213 .register_entries(
1214 &ctx,
1215 &RegisterQuery {
1216 account: AccountFilter::All,
1217 date_range: DateRange {
1218 start: Some(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()),
1219 end: Some(NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()),
1220 },
1221 conversion: None,
1222 sort: Sort::Original,
1223 },
1224 )
1225 .unwrap(),
1226 )
1227 .unwrap();
1228
1229 let want = vec![
1233 (
1234 NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1235 "Shopping".to_string(),
1236 grocery,
1237 Amount::from_value(chf, dec!(100.00)),
1238 Amount::from_value(chf, dec!(100.00)),
1239 ),
1240 (
1241 NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1242 "Shopping".to_string(),
1243 bank,
1244 Amount::from_value(jpy, dec!(-17150)),
1245 Amount::from_iter([(chf, dec!(100.00)), (jpy, dec!(-17150))]),
1246 ),
1247 ];
1248 assert_eq!(want, got);
1249 }
1250
1251 #[test]
1252 fn register_entries_unknown_account_is_empty() {
1253 let arena = Bump::new();
1254 let (ctx, mut ledger) = create_ledger(&arena);
1255
1256 let got = collect_register(
1257 ledger
1258 .register_entries(
1259 &ctx,
1260 &RegisterQuery {
1261 account: AccountFilter::from_exact_accounts(&ctx, &["Does:Not:Exist"]),
1262 date_range: DateRange::default(),
1263 conversion: None,
1264 sort: Sort::Original,
1265 },
1266 )
1267 .unwrap(),
1268 )
1269 .unwrap();
1270 assert!(got.is_empty(), "expected empty register, got {:?}", got);
1271 }
1272
1273 #[test]
1274 fn register_entries_historical_conversion() {
1275 let arena = Bump::new();
1276 let (ctx, mut ledger) = create_ledger(&arena);
1277 let jpy = ctx.commodities.resolve("JPY").unwrap();
1278 let grocery = ctx.account("Expenses:Grocery").unwrap();
1279 let bank = ctx.account("Assets:J 銀行:普通").unwrap();
1280
1281 let got = collect_register(
1285 ledger
1286 .register_entries(
1287 &ctx,
1288 &RegisterQuery {
1289 account: AccountFilter::All,
1290 date_range: DateRange {
1291 start: Some(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()),
1292 end: Some(NaiveDate::from_ymd_opt(2024, 1, 6).unwrap()),
1293 },
1294 conversion: Some(Conversion {
1295 strategy: ConversionStrategy::Historical,
1296 target: jpy,
1297 }),
1298 sort: Sort::Original,
1299 },
1300 )
1301 .unwrap(),
1302 )
1303 .unwrap();
1304
1305 let want = vec![
1306 (
1307 NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1308 "Shopping".to_string(),
1309 grocery,
1310 Amount::from_value(jpy, dec!(17150.00)),
1312 Amount::from_value(jpy, dec!(17150.00)),
1313 ),
1314 (
1315 NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1316 "Shopping".to_string(),
1317 bank,
1318 Amount::from_value(jpy, dec!(-17150)),
1319 Amount::from_value(jpy, dec!(0.00)),
1320 ),
1321 ];
1322 assert_eq!(want, got);
1323 }
1324
1325 #[test]
1326 fn register_entries_rejects_up_to_date_conversion() {
1327 let arena = Bump::new();
1328 let (ctx, mut ledger) = create_ledger(&arena);
1329 let jpy = ctx.commodities.resolve("JPY").unwrap();
1330
1331 let err = ledger
1332 .register_entries(
1333 &ctx,
1334 &RegisterQuery {
1335 account: AccountFilter::All,
1336 date_range: DateRange::default(),
1337 conversion: Some(Conversion {
1338 strategy: ConversionStrategy::UpToDate {
1339 today: NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
1340 },
1341 target: jpy,
1342 }),
1343 sort: Sort::Original,
1344 },
1345 )
1346 .err()
1347 .expect("UpToDate is not supported yet");
1348 assert_matches!(
1349 err,
1350 QueryError::UnsupportedConversionStrategy,
1351 "unexpected error: {:?}",
1352 err
1353 );
1354 }
1355
1356 #[test]
1357 fn register_entries_running_total_accumulates_across_accounts() {
1358 let arena = Bump::new();
1365 let (ctx, mut ledger) = create_ledger(&arena);
1366
1367 let entries = ledger
1368 .register_entries(&ctx, &RegisterQuery::default())
1369 .unwrap();
1370 let rows = collect_register(entries).unwrap();
1371 assert!(!rows.is_empty());
1372
1373 let final_total = rows.last().map(|r| r.4.clone()).unwrap();
1374 let mut summed = Amount::default();
1375 for r in &rows {
1376 summed += &r.3;
1377 }
1378 assert_eq!(summed, final_total);
1379 }
1380
1381 fn unordered_loader() -> load::Loader<load::FakeFileSystem> {
1384 let content = indoc! {"
1387 2024/03/01 Third by file
1388 Assets:Bank 300 JPY
1389 Equity
1390
1391 2024/01/01 First by date
1392 Assets:Bank 100 JPY
1393 Equity
1394
1395 2024/02/01 Middle by date
1396 Assets:Bank 200 JPY
1397 Equity
1398 "};
1399 let fake = hashmap! {
1400 PathBuf::from("path/to/file.ledger") => content.as_bytes().to_vec(),
1401 };
1402 load::Loader::new(PathBuf::from("path/to/file.ledger"), fake.into())
1403 }
1404
1405 #[test]
1406 fn register_entries_sort_original_preserves_file_order() {
1407 let arena = Bump::new();
1408 let mut ctx = report::ReportContext::new(&arena);
1409 let mut ledger = report::process(
1410 &mut ctx,
1411 unordered_loader(),
1412 &report::ProcessOptions::default(),
1413 )
1414 .unwrap();
1415 let bank = ctx.account("Assets:Bank").unwrap();
1416 let rows = collect_register(
1417 ledger
1418 .register_entries(
1419 &ctx,
1420 &RegisterQuery {
1421 account: AccountFilter::single(bank),
1422 sort: Sort::Original,
1423 ..Default::default()
1424 },
1425 )
1426 .unwrap(),
1427 )
1428 .unwrap();
1429 let dates: Vec<NaiveDate> = rows.iter().map(|r| r.0).collect();
1430 assert_eq!(
1431 dates,
1432 vec![
1433 NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
1434 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1435 NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1436 ]
1437 );
1438 assert!(rows.iter().all(|r| r.2 == bank));
1440 }
1441
1442 #[test]
1443 fn register_entries_sort_date_yields_ascending_order() {
1444 let arena = Bump::new();
1445 let mut ctx = report::ReportContext::new(&arena);
1446 let mut ledger = report::process(
1447 &mut ctx,
1448 unordered_loader(),
1449 &report::ProcessOptions::default(),
1450 )
1451 .unwrap();
1452 let bank = ctx.account("Assets:Bank").unwrap();
1453 let rows = collect_register(
1454 ledger
1455 .register_entries(
1456 &ctx,
1457 &RegisterQuery {
1458 account: AccountFilter::single(bank),
1459 sort: Sort::Date,
1460 ..Default::default()
1461 },
1462 )
1463 .unwrap(),
1464 )
1465 .unwrap();
1466 let dates: Vec<NaiveDate> = rows.iter().map(|r| r.0).collect();
1467 assert_eq!(
1468 dates,
1469 vec![
1470 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1471 NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1472 NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
1473 ]
1474 );
1475 }
1476
1477 #[test]
1478 fn register_entries_sort_date_running_total_matches_chronology() {
1479 let arena = Bump::new();
1482 let mut ctx = report::ReportContext::new(&arena);
1483 let mut ledger = report::process(
1484 &mut ctx,
1485 unordered_loader(),
1486 &report::ProcessOptions::default(),
1487 )
1488 .unwrap();
1489 let jpy = ctx.commodities.resolve("JPY").unwrap();
1490 let bank = ctx.account("Assets:Bank").unwrap();
1491 let rows = collect_register(
1492 ledger
1493 .register_entries(
1494 &ctx,
1495 &RegisterQuery {
1496 account: AccountFilter::single(bank),
1497 sort: Sort::Date,
1498 ..Default::default()
1499 },
1500 )
1501 .unwrap(),
1502 )
1503 .unwrap();
1504 let totals: Vec<Amount> = rows.into_iter().map(|r| r.4).collect();
1505 assert_eq!(
1506 totals,
1507 vec![
1508 Amount::from_value(jpy, dec!(100)),
1509 Amount::from_value(jpy, dec!(300)),
1510 Amount::from_value(jpy, dec!(600)),
1511 ]
1512 );
1513 }
1514
1515 mod account_filter {
1516 use super::assert_eq;
1517 use super::*;
1518
1519 #[test]
1520 fn from_regex_patterns_empty_matches_nothing() {
1521 let arena = Bump::new();
1522 let (ctx, _ledger) = create_ledger(&arena);
1523
1524 let filter = AccountFilter::from_regex_patterns(&ctx, &[] as &[&str]).unwrap();
1525 assert_matches!(filter, AccountFilter::Set(s) if s.is_empty());
1526 }
1527
1528 #[test]
1529 fn from_regex_patterns_matches_substring() {
1530 let arena = Bump::new();
1531 let (ctx, _ledger) = create_ledger(&arena);
1532
1533 let filter = AccountFilter::from_regex_patterns(&ctx, &["Assets"]).unwrap();
1535 let matched = assert_matches!(filter, AccountFilter::Set(s) => s);
1536 assert_eq!(
1537 matched,
1538 HashSet::from([
1539 ctx.account("Assets:CH Bank").unwrap(),
1540 ctx.account("Assets:Broker").unwrap(),
1541 ctx.account("Assets:J 銀行:普通").unwrap()
1542 ]),
1543 );
1544 }
1545
1546 #[test]
1547 fn from_regex_patterns_multiple_patterns_are_unioned() {
1548 let arena = Bump::new();
1549 let (ctx, _ledger) = create_ledger(&arena);
1550
1551 let filter = AccountFilter::from_regex_patterns(&ctx, &["^Income", "Card$"]).unwrap();
1552 assert_matches!(filter, AccountFilter::Set(s) => {
1553 assert_eq!(s, hashset![
1554 ctx.account("Income:Capital Gain").unwrap(),
1555 ctx.account("Liabilities:EUR Card").unwrap(),
1556 ]);
1557 });
1558 }
1559
1560 #[test]
1561 fn from_regex_patterns_single_match_collapses_to_exact() {
1562 let arena = Bump::new();
1563 let (ctx, _ledger) = create_ledger(&arena);
1564
1565 let filter = AccountFilter::from_regex_patterns(&ctx, &["Grocery"]).unwrap();
1566 let expected = ctx.account("Expenses:Grocery").unwrap();
1567 assert_matches!(filter, AccountFilter::Exact(a) if a == expected);
1568 }
1569
1570 #[test]
1571 fn from_regex_patterns_invalid_regex_errors() {
1572 let arena = Bump::new();
1573 let (ctx, _ledger) = create_ledger(&arena);
1574
1575 let err = AccountFilter::from_regex_patterns(&ctx, &["("]);
1576 assert!(err.is_err(), "expected regex compile error, got {err:?}");
1577 }
1578
1579 #[test]
1580 fn from_exact_accounts_matches_names_verbatim() {
1581 let arena = Bump::new();
1582 let (ctx, _ledger) = create_ledger(&arena);
1583
1584 let filter =
1588 AccountFilter::from_exact_accounts(&ctx, &["Assets:Broker", ".", "Does:Not:Exist"]);
1589 let expected = ctx.account("Assets:Broker").unwrap();
1590 assert_matches!(filter, AccountFilter::Exact(a) if a == expected);
1591 }
1592
1593 #[test]
1594 fn from_exact_accounts_no_matches_is_empty_set() {
1595 let arena = Bump::new();
1596 let (ctx, _ledger) = create_ledger(&arena);
1597
1598 let filter = AccountFilter::from_exact_accounts(&ctx, &["Does:Not:Exist"]);
1599 assert_matches!(filter, AccountFilter::Set(s) if s.is_empty());
1600 }
1601
1602 #[test]
1603 fn descendants_of_ancestor_prefix_matches_subtree() {
1604 let arena = Bump::new();
1605 let (ctx, _ledger) = create_ledger(&arena);
1606
1607 let assets =
1609 AccountAggregate::Ancestor(ctx.account_tree.resolve_ancestor("Assets").unwrap());
1610 let filter = AccountFilter::descendants_of(&ctx, assets);
1611 assert_matches!(filter, AccountFilter::Set(s) => {
1612 assert_eq!(s, hashset![
1613 ctx.account("Assets:CH Bank").unwrap(),
1614 ctx.account("Assets:Broker").unwrap(),
1615 ctx.account("Assets:J 銀行:普通").unwrap(),
1616 ]);
1617 });
1618 }
1619
1620 #[test]
1621 fn descendants_of_leaf_collapses_to_exact() {
1622 let arena = Bump::new();
1623 let (ctx, _ledger) = create_ledger(&arena);
1624
1625 let broker = ctx.account("Assets:Broker").unwrap();
1627 let filter = AccountFilter::descendants_of(&ctx, broker.into());
1628 assert_matches!(filter, AccountFilter::Exact(a) if a == broker);
1629 }
1630
1631 #[test]
1632 fn descendants_of_account_with_child() {
1633 let arena = Bump::new();
1634 let (ctx, _ledger) = create_ledger(&arena);
1635
1636 let food = ctx.account("Expenses:Food").unwrap();
1637 let filter = AccountFilter::descendants_of(&ctx, food.into());
1638 assert_matches!(filter, AccountFilter::Set(s) => {
1639 assert_eq!(s, hashset![
1640 ctx.account("Expenses:Food").unwrap(),
1641 ctx.account("Expenses:Food:Ramen").unwrap(),
1642 ]);
1644 });
1645 }
1646 }
1647}