1use std::borrow::Borrow;
5
6use bumpalo::collections as bcc;
7use chrono::NaiveDate;
8
9use crate::{
10 load,
11 syntax::{self, decoration::AsUndecorated, tracked::Tracked},
12};
13
14use super::{
15 balance::{Balance, BalanceError},
16 context::{Account, ReportContext},
17 error::{self, ReportError},
18 eval::{Amount, EvalError, Evaluable, PostingAmount, SingleAmount},
19 intern::InternError,
20};
21
22#[derive(Debug, thiserror::Error, PartialEq, Eq)]
25pub enum BookKeepError {
26 #[error("failed to evaluate the expression: {0}")]
27 EvalFailure(#[from] EvalError),
28 #[error("failed to meet balance condition: {0}")]
29 BalanceFailure(#[from] BalanceError),
30 #[error("posting amount must be resolved as a simple value with commodity or zero")]
31 ComplexPostingAmount,
32 #[error("transaction cannot have multiple postings without amount")]
33 UndeduciblePostingAmount(Tracked<usize>, Tracked<usize>),
34 #[error("transaction cannot have unbalanced postings: {0}")]
35 UnbalancedPostings(String),
36 #[error("balance assertion failed: got {0} but expected {1}")]
37 BalanceAssertionFailure(String, String),
38 #[error("failed to register account: {0}")]
39 InvalidAccount(#[source] InternError),
40 #[error("failed to register commodity: {0}")]
41 InvalidCommodity(#[source] InternError),
42}
43
44pub fn process<'ctx, L, F>(
48 ctx: &mut ReportContext<'ctx>,
49 loader: L,
50) -> Result<(Vec<Transaction<'ctx>>, Balance<'ctx>), ReportError>
51where
52 L: Borrow<load::Loader<F>>,
53 F: load::FileSystem,
54{
55 let mut accum = ProcessAccumulator::new();
56 loader.borrow().load(|path, pctx, entry| {
57 accum.process(ctx, entry).map_err(|berr| {
58 ReportError::BookKeep(
59 berr,
60 error::ErrorContext::new(
61 loader.borrow().error_style().clone(),
62 path.to_owned(),
63 pctx,
64 ),
65 )
66 })
67 })?;
68 Ok((accum.txns, accum.balance))
69}
70
71struct ProcessAccumulator<'ctx> {
72 balance: Balance<'ctx>,
73 txns: Vec<Transaction<'ctx>>,
74}
75
76impl<'ctx> ProcessAccumulator<'ctx> {
77 fn new() -> Self {
78 let balance = Balance::default();
79 let txns: Vec<Transaction<'ctx>> = Vec::new();
80 Self { balance, txns }
81 }
82
83 fn process(
84 &mut self,
85 ctx: &mut ReportContext<'ctx>,
86 entry: &syntax::tracked::LedgerEntry,
87 ) -> Result<(), BookKeepError> {
88 match entry {
89 syntax::LedgerEntry::Txn(txn) => {
90 self.txns
91 .push(add_transaction(ctx, &mut self.balance, txn)?);
92 Ok(())
93 }
94 syntax::LedgerEntry::Account(account) => {
95 let canonical = ctx
96 .accounts
97 .insert_canonical(&account.name)
98 .map_err(BookKeepError::InvalidAccount)?;
99 for ad in &account.details {
100 if let syntax::AccountDetail::Alias(alias) = ad {
101 ctx.accounts
102 .insert_alias(alias, canonical)
103 .map_err(BookKeepError::InvalidAccount)?;
104 }
105 }
106 Ok(())
107 }
108 syntax::LedgerEntry::Commodity(commodity) => {
109 let canonical = ctx
110 .commodities
111 .insert_canonical(&commodity.name)
112 .map_err(BookKeepError::InvalidCommodity)?;
113 for cd in &commodity.details {
114 match cd {
115 syntax::CommodityDetail::Alias(alias) => {
116 ctx.commodities
117 .insert_alias(alias, canonical)
118 .map_err(BookKeepError::InvalidCommodity)?;
119 }
120 syntax::CommodityDetail::Format(format_amount) => {
121 ctx.commodities
122 .set_format(canonical, format_amount.value.clone());
123 }
124 _ => {}
125 }
126 }
127 Ok(())
128 }
129 _ => Ok(()),
130 }
131 }
132}
133
134#[derive(Debug, PartialEq, Eq)]
137pub struct Transaction<'ctx> {
138 pub date: NaiveDate,
139 pub postings: bumpalo::boxed::Box<'ctx, [Posting<'ctx>]>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct Posting<'ctx> {
149 pub account: Account<'ctx>,
150 pub amount: Amount<'ctx>,
153}
154
155fn add_transaction<'ctx>(
157 ctx: &mut ReportContext<'ctx>,
158 bal: &mut Balance<'ctx>,
159 txn: &syntax::tracked::Transaction,
160) -> Result<Transaction<'ctx>, BookKeepError> {
161 let mut postings = bcc::Vec::with_capacity_in(txn.posts.len(), ctx.arena);
162 let mut unfilled: Option<Tracked<usize>> = None;
163 let mut balance = Amount::default();
164 for (i, posting) in txn.posts.iter().enumerate() {
165 let account = ctx.accounts.ensure(&posting.as_undecorated().account);
166 let (amount, balance_amount): (PostingAmount, PostingAmount) = match (
167 &posting.as_undecorated().amount,
168 &posting.as_undecorated().balance,
169 ) {
170 (None, None) => {
171 if let Some(first) = unfilled.replace(Tracked::new(i, posting.span())) {
172 Err(BookKeepError::UndeduciblePostingAmount(
173 first,
174 Tracked::new(i, posting.span()),
175 ))
176 } else {
177 Ok((PostingAmount::zero(), PostingAmount::zero()))
178 }
179 }
180 (None, Some(balance_constraints)) => {
181 let current: PostingAmount =
182 balance_constraints.as_undecorated().eval(ctx)?.try_into()?;
183 let prev: PostingAmount = bal.set_partial(account, current)?;
184 let amount = current.check_sub(prev)?;
185 Ok((amount, amount))
186 }
187 (Some(syntax_amount), balance_constraints) => {
188 let amount: PostingAmount = syntax_amount
189 .amount
190 .as_undecorated()
191 .eval(ctx)?
192 .try_into()?;
193 let expected_balance: Option<PostingAmount> = balance_constraints
194 .as_ref()
195 .map(|x| x.as_undecorated().eval(ctx))
196 .transpose()?
197 .map(|x| x.try_into())
198 .transpose()?;
199 let current = bal.add_posting_amount(account, amount);
200 if let Some(expected) = expected_balance {
201 if !current.is_consistent(expected) {
202 return Err(BookKeepError::BalanceAssertionFailure(
203 format!("{}", current.as_inline_display()),
204 format!("{}", expected),
205 ));
206 }
207 }
208 let balance_amount = calculate_balance_amount(ctx, syntax_amount, amount)?;
209 Ok((amount, balance_amount))
210 }
211 }?;
212 balance += balance_amount;
213 postings.push(Posting {
214 account,
215 amount: amount.into(),
216 });
217 }
218 if let Some(u) = unfilled {
219 let u = *u.as_undecorated();
220 let deduced: Amount = balance.clone().negate();
221 postings[u].amount = deduced.clone();
222 bal.add_amount(postings[u].account, deduced);
223 } else {
224 check_balance(ctx, balance)?;
225 }
226 Ok(Transaction {
227 date: txn.date,
228 postings: postings.into_boxed_slice(),
229 })
230}
231
232fn check_balance<'ctx>(
233 ctx: &ReportContext<'ctx>,
234 mut balance: Amount<'ctx>,
235) -> Result<(), BookKeepError> {
236 balance.round(|commodity| ctx.commodities.get_decimal_point(commodity));
237 if balance.is_zero() {
238 return Ok(());
239 }
240 if let Some((a1, a2)) = balance.maybe_pair() {
241 log::info!("deduced price {} == {}", a1, a2);
242 return Ok(());
243 }
244 if !balance.is_zero() {
245 return Err(BookKeepError::UnbalancedPostings(format!(
246 "{}",
247 balance.as_inline_display()
248 )));
249 }
250 Ok(())
251}
252
253fn calculate_balance_amount<'ctx>(
254 ctx: &mut ReportContext<'ctx>,
255 posting_amount: &syntax::tracked::PostingAmount,
256 computed_amount: PostingAmount<'ctx>,
257) -> Result<PostingAmount<'ctx>, BookKeepError> {
258 let cost: Option<SingleAmount<'ctx>> = posting_amount
259 .cost
260 .as_ref()
261 .map(|x| calculate_exchanged_amount(ctx, x.as_undecorated(), computed_amount.try_into()?))
262 .transpose()?;
263 let lot: Option<SingleAmount<'ctx>> = posting_amount
264 .lot
265 .price
266 .as_ref()
267 .map(|x| calculate_exchanged_amount(ctx, x.as_undecorated(), computed_amount.try_into()?))
268 .transpose()?;
269 Ok(lot.or(cost).map(Into::into).unwrap_or(computed_amount))
280}
281
282fn calculate_exchanged_amount<'ctx>(
283 ctx: &mut ReportContext<'ctx>,
284 cost: &syntax::Exchange,
285 amount: SingleAmount<'ctx>,
286) -> Result<SingleAmount<'ctx>, BookKeepError> {
287 let exchanged: Result<SingleAmount, EvalError> = match cost {
288 syntax::Exchange::Rate(x) => {
289 let rate: SingleAmount = x.eval(ctx)?.try_into()?;
290 Ok(rate * amount.value)
291 }
292 syntax::Exchange::Total(y) => {
293 let abs: SingleAmount = y.eval(ctx)?.try_into()?;
294 Ok(abs.with_sign_of(amount))
295 }
296 };
297 Ok(exchanged?)
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 use bumpalo::Bump;
305 use indoc::indoc;
306 use maplit::hashmap;
307 use pretty_assertions::assert_eq;
308 use rust_decimal_macros::dec;
309
310 use crate::{
311 parse::{self, testing::expect_parse_ok},
312 syntax::tracked::TrackedSpan,
313 };
314
315 fn parse_transaction(input: &str) -> syntax::tracked::Transaction {
316 let (_, ret) = expect_parse_ok(parse::transaction::transaction, input);
317 ret
318 }
319
320 #[test]
321 fn add_transaction_maintains_balance() {
322 let arena = Bump::new();
323 let mut ctx = ReportContext::new(&arena);
324 let mut bal = Balance::default();
325 bal.add_posting_amount(
326 ctx.accounts.ensure("Account 1"),
327 PostingAmount::from_value(dec!(1000), ctx.commodities.ensure("JPY")),
328 );
329 bal.add_posting_amount(
330 ctx.accounts.ensure("Account 1"),
331 PostingAmount::from_value(dec!(123), ctx.commodities.ensure("EUR")),
332 );
333 bal.add_posting_amount(
334 ctx.accounts.ensure("Account 4"),
335 PostingAmount::from_value(dec!(10), ctx.commodities.ensure("CHF")),
336 );
337 let input = indoc! {"
338 2024/08/01 Sample
339 Account 1 200 JPY = 1200 JPY
340 Account 2 -100 JPY = -100 JPY
341 Account 2 -100 JPY = -200 JPY
342 Account 3 2.00 CHF @ 150 JPY
343 Account 4 = -300 JPY
344 "};
345 let txn = parse_transaction(input);
346 let _ = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
347 let want_balance: Balance = hashmap! {
348 ctx.accounts.ensure("Account 1") =>
349 Amount::from_values([
350 (dec!(1200), ctx.commodities.ensure("JPY")),
351 (dec!(123), ctx.commodities.ensure("EUR")),
352 ]),
353 ctx.accounts.ensure("Account 2") =>
354 Amount::from_value(dec!(-200), ctx.commodities.ensure("JPY")),
355 ctx.accounts.ensure("Account 3") =>
356 Amount::from_value(dec!(2), ctx.commodities.ensure("CHF")),
357 ctx.accounts.ensure("Account 4") =>
358 Amount::from_values([
359 (dec!(-300), ctx.commodities.ensure("JPY")),
360 (dec!(10), ctx.commodities.ensure("CHF")),
361 ]),
362 }
363 .into_iter()
364 .collect();
365 assert_eq!(want_balance.into_vec(), bal.into_vec());
366 }
367
368 #[test]
369 fn add_transaction_emits_transaction_with_postings() {
370 let arena = Bump::new();
371 let mut ctx = ReportContext::new(&arena);
372 let mut bal = Balance::default();
373 let input = indoc! {"
374 2024/08/01 Sample
375 Account 1 200 JPY = 200 JPY
376 Account 2 -100 JPY = -100 JPY
377 Account 2 -100 JPY = -200 JPY
378 "};
379 let txn = parse_transaction(input);
380 let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
381 let want = Transaction {
382 date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
383 postings: bcc::Vec::from_iter_in(
384 [
385 Posting {
386 account: ctx.accounts.ensure("Account 1"),
387 amount: Amount::from_value(dec!(200), ctx.commodities.ensure("JPY")),
388 },
389 Posting {
390 account: ctx.accounts.ensure("Account 2"),
391 amount: Amount::from_value(dec!(-100), ctx.commodities.ensure("JPY")),
392 },
393 Posting {
394 account: ctx.accounts.ensure("Account 2"),
395 amount: Amount::from_value(dec!(-100), ctx.commodities.ensure("JPY")),
396 },
397 ],
398 &arena,
399 )
400 .into_boxed_slice(),
401 };
402 assert_eq!(want, got);
403 }
404
405 #[test]
406 fn add_transaction_emits_transaction_with_deduce_and_balance_concern() {
407 let arena = Bump::new();
408 let mut ctx = ReportContext::new(&arena);
409 let mut bal = Balance::default();
410 bal.add_posting_amount(
411 ctx.accounts.ensure("Account 1"),
412 PostingAmount::from_value(dec!(1000), ctx.commodities.ensure("JPY")),
413 );
414 bal.add_posting_amount(
415 ctx.accounts.ensure("Account 1"),
416 PostingAmount::from_value(dec!(123), ctx.commodities.ensure("USD")),
417 );
418 let input = indoc! {"
419 2024/08/01 Sample
420 Account 1 = 1200 JPY
421 Account 2
422 "};
423 let txn = parse_transaction(input);
424 let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
425 let want = Transaction {
426 date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
427 postings: bcc::Vec::from_iter_in(
428 [
429 Posting {
430 account: ctx.accounts.ensure("Account 1"),
431 amount: Amount::from_value(dec!(200), ctx.commodities.ensure("JPY")),
432 },
433 Posting {
434 account: ctx.accounts.ensure("Account 2"),
435 amount: Amount::from_value(dec!(-200), ctx.commodities.ensure("JPY")),
436 },
437 ],
438 &arena,
439 )
440 .into_boxed_slice(),
441 };
442 assert_eq!(want, got);
443 }
444
445 #[test]
446 fn add_transaction_deduced_amount_contains_multi_commodity() {
447 let arena = Bump::new();
448 let mut ctx = ReportContext::new(&arena);
449 let mut bal = Balance::default();
450 let input = indoc! {"
451 2024/08/01 Sample
452 Account 1 1200 JPY
453 Account 2 234 EUR
454 Account 3 34.56 CHF
455 Account 4
456 "};
457 let txn = parse_transaction(input);
458 let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
459 let want = Transaction {
460 date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
461 postings: bcc::Vec::from_iter_in(
462 [
463 Posting {
464 account: ctx.accounts.ensure("Account 1"),
465 amount: Amount::from_value(dec!(1200), ctx.commodities.ensure("JPY")),
466 },
467 Posting {
468 account: ctx.accounts.ensure("Account 2"),
469 amount: Amount::from_value(dec!(234), ctx.commodities.ensure("EUR")),
470 },
471 Posting {
472 account: ctx.accounts.ensure("Account 3"),
473 amount: Amount::from_value(dec!(34.56), ctx.commodities.ensure("CHF")),
474 },
475 Posting {
476 account: ctx.accounts.ensure("Account 4"),
477 amount: Amount::from_values([
478 (dec!(-1200), ctx.commodities.ensure("JPY")),
479 (dec!(-234), ctx.commodities.ensure("EUR")),
480 (dec!(-34.56), ctx.commodities.ensure("CHF")),
481 ]),
482 },
483 ],
484 &arena,
485 )
486 .into_boxed_slice(),
487 };
488 assert_eq!(want, got);
489 }
490
491 #[test]
492 fn add_transaction_fails_when_two_posting_does_not_have_amount() {
493 let input = indoc! {"
494 2024/08/01 Sample
495 Account 1 ; no amount
496 Account 2 ; no amount
497 "};
498 let txn = parse_transaction(input);
499 let arena = Bump::new();
500 let mut ctx = ReportContext::new(&arena);
501 let mut bal = Balance::default();
502 let got = add_transaction(&mut ctx, &mut bal, &txn).expect_err("must fail");
503 assert_eq!(
504 got,
505 BookKeepError::UndeduciblePostingAmount(
506 Tracked::new(0, TrackedSpan::new(20..42)),
507 Tracked::new(1, TrackedSpan::new(44..66))
508 )
509 );
510 }
511
512 #[test]
513 fn add_transaction_balances_with_lot() {
514 let arena = Bump::new();
515 let mut ctx = ReportContext::new(&arena);
516 let mut bal = Balance::default();
517 let input = indoc! {"
518 2024/08/01 Sample
519 Account 1 12 OKANE {100 JPY}
520 Account 2 -1,200 JPY
521 "};
522 let txn = parse_transaction(input);
523 let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
524 let want = Transaction {
525 date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
526 postings: bcc::Vec::from_iter_in(
527 [
528 Posting {
529 account: ctx.accounts.ensure("Account 1"),
530 amount: Amount::from_value(dec!(12), ctx.commodities.ensure("OKANE")),
531 },
532 Posting {
533 account: ctx.accounts.ensure("Account 2"),
534 amount: Amount::from_value(dec!(-1200), ctx.commodities.ensure("JPY")),
535 },
536 ],
537 &arena,
538 )
539 .into_boxed_slice(),
540 };
541 assert_eq!(want, got);
542 }
543
544 #[test]
545 fn add_transaction_balances_with_price() {
546 let arena = Bump::new();
547 let mut ctx = ReportContext::new(&arena);
548 let mut bal = Balance::default();
549 let input = indoc! {"
550 2024/08/01 Sample
551 Account 1 12 OKANE @ (1 * 100 JPY)
552 Account 2 -1,200 JPY
553 "};
554 let txn = parse_transaction(input);
555 let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
556 let want = Transaction {
557 date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
558 postings: bcc::Vec::from_iter_in(
559 [
560 Posting {
561 account: ctx.accounts.ensure("Account 1"),
562 amount: Amount::from_value(dec!(12), ctx.commodities.ensure("OKANE")),
563 },
564 Posting {
565 account: ctx.accounts.ensure("Account 2"),
566 amount: Amount::from_value(dec!(-1200), ctx.commodities.ensure("JPY")),
567 },
568 ],
569 &arena,
570 )
571 .into_boxed_slice(),
572 };
573 assert_eq!(want, got);
574 }
575
576 #[test]
577 fn add_transaction_balances_with_lot_and_price() {
578 let arena = Bump::new();
579 let mut ctx = ReportContext::new(&arena);
580 let mut bal = Balance::default();
581 let input = indoc! {"
582 2024/08/01 Sample
583 Account 1 -12 OKANE {100 JPY} @ 120 JPY
584 Account 2 1,440 JPY
585 Income -240 JPY
586 "};
587 let txn = parse_transaction(input);
588 let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
589 let want = Transaction {
590 date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
591 postings: bcc::Vec::from_iter_in(
592 [
593 Posting {
594 account: ctx.accounts.ensure("Account 1"),
595 amount: Amount::from_value(dec!(-12), ctx.commodities.ensure("OKANE")),
596 },
597 Posting {
598 account: ctx.accounts.ensure("Account 2"),
599 amount: Amount::from_value(dec!(1440), ctx.commodities.ensure("JPY")),
600 },
601 Posting {
602 account: ctx.accounts.ensure("Income"),
603 amount: Amount::from_value(dec!(-240), ctx.commodities.ensure("JPY")),
604 },
605 ],
606 &arena,
607 )
608 .into_boxed_slice(),
609 };
610 assert_eq!(want, got);
611 }
612
613 #[test]
614 fn add_transaction_balances_minor_diff() {
615 let arena = Bump::new();
616 let mut ctx = ReportContext::new(&arena);
617 let chf = ctx.commodities.insert_canonical("CHF").unwrap();
618 ctx.commodities
619 .set_format(chf, "20,000.00".parse().unwrap());
620 let mut bal = Balance::default();
621 let input = indoc! {"
622 2024/08/01 Sample
623 Expenses 300 EUR @ (1 / 1.0538 CHF)
624 Liabilities -284.68 CHF
625 "};
626 let txn = parse_transaction(input);
627 let got = add_transaction(&mut ctx, &mut bal, &txn).expect("must succeed");
628 let want = Transaction {
629 date: NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
630 postings: bcc::Vec::from_iter_in(
631 [
632 Posting {
633 account: ctx.accounts.ensure("Expenses"),
634 amount: Amount::from_value(dec!(300), ctx.commodities.ensure("EUR")),
635 },
636 Posting {
637 account: ctx.accounts.ensure("Liabilities"),
638 amount: Amount::from_value(dec!(-284.68), ctx.commodities.ensure("CHF")),
639 },
640 ],
641 &arena,
642 )
643 .into_boxed_slice(),
644 };
645 assert_eq!(want, got);
646 }
647}