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