1use 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#[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#[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#[derive(Debug)]
44pub struct PostingQuery {
45 pub account: Option<String>,
48}
49
50#[derive(Debug, PartialEq, Eq, Clone, Copy)]
52pub enum ConversionStrategy {
53 Historical,
55 UpToDate {
58 now: NaiveDate,
60 },
61}
62
63#[derive(Debug)]
65pub struct Conversion<'ctx> {
67 pub strategy: ConversionStrategy,
68 pub target: Commodity<'ctx>,
69}
70
71#[derive(Debug, Default)]
75pub struct DateRange {
76 pub start: Option<NaiveDate>,
78 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#[derive(Debug, Default)]
98pub 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 false
115 }
116}
117
118#[derive(Debug)]
120pub struct EvalContext {
122 pub date: NaiveDate,
123 pub exchange: Option<String>,
124}
125
126impl<'ctx> Ledger<'ctx> {
127 pub fn transactions(&self) -> impl Iterator<Item = &Transaction<'ctx>> {
129 self.transactions.iter()
130 }
131
132 pub fn postings<'a>(
134 &'a self,
135 ctx: &ReportContext<'ctx>,
136 query: &PostingQuery,
137 ) -> Vec<&'a Posting<'ctx>> {
138 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 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 .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 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 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 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 Amount::from_value(dec!(312_134), jpy),
438 ),
439 (
440 ctx.account("Assets:Broker").unwrap(),
441 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 (dec!(-336_480), jpy),
479 (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 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 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 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 (dec!(-343_000), jpy),
550 (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}