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