1use rust_decimal::Decimal;
26use rustledger_core::{
27 Amount, Currency, Directive, Inventory, NaiveDate, Pad, Position, Posting, Spanned, Transaction,
28};
29use std::collections::HashMap;
30use std::ops::Neg;
31
32pub const SYNTH_PAD_NARRATION_PREFIX: &str = "(Padding inserted for Balance of ";
42
43#[must_use]
50pub fn is_synthesized_pad(txn: &Transaction) -> bool {
51 txn.flag == 'P'
52 && txn
53 .narration
54 .as_str()
55 .starts_with(SYNTH_PAD_NARRATION_PREFIX)
56}
57
58#[derive(Debug, Clone)]
68pub struct PadResult {
69 pub padding_transactions: Vec<Transaction>,
71 pub errors: Vec<PadError>,
73}
74
75#[derive(Debug, Clone)]
77pub struct PadError {
78 pub date: NaiveDate,
80 pub message: String,
82 pub account: Option<rustledger_core::Account>,
84}
85
86impl PadError {
87 pub fn new(date: NaiveDate, message: impl Into<String>) -> Self {
89 Self {
90 date,
91 message: message.into(),
92 account: None,
93 }
94 }
95
96 pub fn with_account(mut self, account: impl Into<rustledger_core::Account>) -> Self {
98 self.account = Some(account.into());
99 self
100 }
101}
102
103#[derive(Debug, Clone)]
105struct PendingPad {
106 pad: Pad,
108 used: bool,
110 padded_currencies: std::collections::HashSet<Currency>,
112}
113
114pub fn process_pads(directives: &[Directive]) -> PadResult {
138 let num_directives = directives.len();
139 let mut inventories: HashMap<rustledger_core::Account, Inventory> =
140 HashMap::with_capacity(num_directives.min(16));
141 let mut pending_pads: HashMap<rustledger_core::Account, PendingPad> = HashMap::with_capacity(4);
142 let mut padding_transactions = Vec::with_capacity(num_directives.min(16));
143 let mut errors = Vec::with_capacity(4);
144
145 let mut sorted: Vec<&Directive> = directives.iter().collect();
147 sorted.sort_by_key(|d| d.date());
148
149 for directive in sorted {
150 match directive {
151 Directive::Open(open) => {
152 inventories.insert(open.account.clone(), Inventory::new());
153 }
154
155 Directive::Transaction(txn) => {
156 for posting in &txn.postings {
158 if let Some(units) = posting.amount()
159 && let Some(inv) = inventories.get_mut(&posting.account)
160 {
161 let position =
162 Position::from_posting(units, posting.cost.as_ref(), txn.date);
163 inv.add(position);
164 }
165 }
166 }
167
168 Directive::Pad(pad) => {
169 pending_pads.insert(
172 pad.account.clone(),
173 PendingPad {
174 pad: pad.clone(),
175 used: false,
176 padded_currencies: std::collections::HashSet::new(),
177 },
178 );
179 }
180
181 Directive::Balance(bal) => {
182 if let Some(pending) = pending_pads.get_mut(&bal.account) {
185 if pending.padded_currencies.contains(&bal.amount.currency) {
188 continue;
189 }
190
191 let current = rustledger_core::sum_account_and_subaccounts(
198 inventories.iter(),
199 bal.account.as_str(),
200 &bal.amount.currency,
201 );
202
203 let difference = bal.amount.number - current;
204
205 if difference != Decimal::ZERO {
206 let pad_txn = create_padding_transaction(
208 pending.pad.date,
209 &pending.pad.account,
210 &pending.pad.source_account,
211 Amount::new(difference, &bal.amount.currency),
212 &bal.amount, );
214
215 if let Some(inv) = inventories.get_mut(&pending.pad.account) {
217 inv.add(Position::simple(Amount::new(
218 difference,
219 &bal.amount.currency,
220 )));
221 }
222 if let Some(inv) = inventories.get_mut(&pending.pad.source_account) {
223 inv.add(Position::simple(Amount::new(
224 -difference,
225 &bal.amount.currency,
226 )));
227 }
228
229 padding_transactions.push(pad_txn);
230 }
231
232 pending.used = true;
234 pending
235 .padded_currencies
236 .insert(bal.amount.currency.clone());
237 }
238 }
240
241 _ => {}
242 }
243 }
244
245 for (account, pending) in pending_pads {
247 if !pending.used {
248 errors.push(
249 PadError::new(
250 pending.pad.date,
251 format!(
252 "Pad directive for account {account} has no corresponding balance assertion"
253 ),
254 )
255 .with_account(account),
256 );
257 }
258 }
259
260 PadResult {
261 padding_transactions,
262 errors,
263 }
264}
265
266fn create_padding_transaction(
271 date: NaiveDate,
272 target_account: &str,
273 source_account: &str,
274 difference: Amount,
275 balance: &Amount,
276) -> Transaction {
277 let narration = format!(
278 "{prefix}{bal_num} {bal_cur} for difference {diff_num} {diff_cur})",
279 prefix = SYNTH_PAD_NARRATION_PREFIX,
280 bal_num = balance.number,
281 bal_cur = balance.currency,
282 diff_num = difference.number,
283 diff_cur = difference.currency,
284 );
285 Transaction::new(date, &narration)
286 .with_flag('P')
287 .with_synthesized_posting(Posting::new(target_account, difference.clone()))
288 .with_synthesized_posting(Posting::new(source_account, difference.neg()))
289}
290
291pub fn merge_with_padding(directives: &[Directive]) -> Vec<Directive> {
322 debug_assert!(
323 !directives
324 .iter()
325 .any(|d| matches!(d, Directive::Transaction(t) if is_synthesized_pad(t))),
326 "merge_with_padding called on input that already contains synth pad transactions; \
327 re-running would double-count pad effects",
328 );
329
330 let result = process_pads(directives);
331
332 let mut merged: Vec<Directive> =
338 Vec::with_capacity(directives.len() + result.padding_transactions.len());
339 for txn in result.padding_transactions {
340 merged.push(Directive::Transaction(txn));
341 }
342 merged.extend(directives.iter().cloned());
343
344 merged.sort_by_key(rustledger_core::Directive::date);
345
346 merged
347}
348
349#[must_use]
364pub fn merge_with_padding_spanned(directives: &[Spanned<Directive>]) -> Vec<Spanned<Directive>> {
365 let plain: Vec<Directive> = directives.iter().map(|s| s.value.clone()).collect();
366 debug_assert!(
367 !plain
368 .iter()
369 .any(|d| matches!(d, Directive::Transaction(t) if is_synthesized_pad(t))),
370 "merge_with_padding_spanned called on input that already contains synth pad transactions; \
371 re-running would double-count pad effects",
372 );
373
374 let result = process_pads(&plain);
375
376 let mut merged: Vec<Spanned<Directive>> =
379 Vec::with_capacity(directives.len() + result.padding_transactions.len());
380 for txn in result.padding_transactions {
381 merged.push(Spanned::synthesized(Directive::Transaction(txn)));
382 }
383 merged.extend(directives.iter().cloned());
384
385 merged.sort_by_key(|s| s.value.date());
386
387 merged
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use rust_decimal_macros::dec;
394 use rustledger_core::{Balance, Open};
395
396 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
397 rustledger_core::naive_date(year, month, day).unwrap()
398 }
399
400 #[test]
401 fn test_process_pads_basic() {
402 let directives = vec![
403 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
404 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
405 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
406 Directive::Balance(Balance::new(
407 date(2024, 1, 2),
408 "Assets:Bank",
409 Amount::new(dec!(1000.00), "USD"),
410 )),
411 ];
412
413 let result = process_pads(&directives);
414
415 assert!(result.errors.is_empty());
416 assert_eq!(result.padding_transactions.len(), 1);
417
418 let txn = &result.padding_transactions[0];
419 assert_eq!(txn.date, date(2024, 1, 1));
420 assert_eq!(txn.postings.len(), 2);
421
422 assert_eq!(txn.postings[0].account, "Assets:Bank");
424 assert_eq!(
425 txn.postings[0].amount(),
426 Some(&Amount::new(dec!(1000.00), "USD"))
427 );
428
429 assert_eq!(txn.postings[1].account, "Equity:Opening");
431 assert_eq!(
432 txn.postings[1].amount(),
433 Some(&Amount::new(dec!(-1000.00), "USD"))
434 );
435 }
436
437 #[test]
438 fn test_process_pads_with_existing_balance() {
439 let directives = vec![
440 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
441 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
442 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
443 Directive::Transaction(
444 Transaction::new(date(2024, 1, 5), "Deposit")
445 .with_synthesized_posting(Posting::new(
446 "Assets:Bank",
447 Amount::new(dec!(500.00), "USD"),
448 ))
449 .with_synthesized_posting(Posting::new(
450 "Income:Salary",
451 Amount::new(dec!(-500.00), "USD"),
452 )),
453 ),
454 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
455 Directive::Balance(Balance::new(
456 date(2024, 1, 15),
457 "Assets:Bank",
458 Amount::new(dec!(1000.00), "USD"),
459 )),
460 ];
461
462 let result = process_pads(&directives);
463
464 assert!(result.errors.is_empty());
465 assert_eq!(result.padding_transactions.len(), 1);
466
467 let txn = &result.padding_transactions[0];
468 assert_eq!(
470 txn.postings[0].amount(),
471 Some(&Amount::new(dec!(500.00), "USD"))
472 );
473 }
474
475 #[test]
476 fn test_process_pads_sums_subaccounts_for_nonleaf_target() {
477 let directives = vec![
484 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
485 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:Checking")),
486 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
487 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
488 Directive::Transaction(
489 Transaction::new(date(2024, 1, 5), "Deposit into sub-account")
490 .with_synthesized_posting(Posting::new(
491 "Assets:Bank:Checking",
492 Amount::new(dec!(50.00), "USD"),
493 ))
494 .with_synthesized_posting(Posting::new(
495 "Income:Salary",
496 Amount::new(dec!(-50.00), "USD"),
497 )),
498 ),
499 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
500 Directive::Balance(Balance::new(
501 date(2024, 1, 15),
502 "Assets:Bank",
503 Amount::new(dec!(100.00), "USD"),
504 )),
505 ];
506
507 let result = process_pads(&directives);
508
509 assert!(result.errors.is_empty());
510 assert_eq!(result.padding_transactions.len(), 1);
511 assert_eq!(
513 result.padding_transactions[0].postings[0].amount(),
514 Some(&Amount::new(dec!(50.00), "USD")),
515 "pad on a non-leaf account must sum sub-accounts (was leaf-only)"
516 );
517 }
518
519 #[test]
520 fn test_process_pads_negative_adjustment() {
521 let directives = vec![
522 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
523 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
524 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
525 Directive::Transaction(
526 Transaction::new(date(2024, 1, 5), "Big deposit")
527 .with_synthesized_posting(Posting::new(
528 "Assets:Bank",
529 Amount::new(dec!(2000.00), "USD"),
530 ))
531 .with_synthesized_posting(Posting::new(
532 "Income:Salary",
533 Amount::new(dec!(-2000.00), "USD"),
534 )),
535 ),
536 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
537 Directive::Balance(Balance::new(
538 date(2024, 1, 15),
539 "Assets:Bank",
540 Amount::new(dec!(1000.00), "USD"),
541 )),
542 ];
543
544 let result = process_pads(&directives);
545
546 assert!(result.errors.is_empty());
547 assert_eq!(result.padding_transactions.len(), 1);
548
549 let txn = &result.padding_transactions[0];
550 assert_eq!(
552 txn.postings[0].amount(),
553 Some(&Amount::new(dec!(-1000.00), "USD"))
554 );
555 }
556
557 #[test]
558 fn test_process_pads_no_difference() {
559 let directives = vec![
560 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
561 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
562 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
563 Directive::Transaction(
564 Transaction::new(date(2024, 1, 5), "Exact deposit")
565 .with_synthesized_posting(Posting::new(
566 "Assets:Bank",
567 Amount::new(dec!(1000.00), "USD"),
568 ))
569 .with_synthesized_posting(Posting::new(
570 "Income:Salary",
571 Amount::new(dec!(-1000.00), "USD"),
572 )),
573 ),
574 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
575 Directive::Balance(Balance::new(
576 date(2024, 1, 15),
577 "Assets:Bank",
578 Amount::new(dec!(1000.00), "USD"),
579 )),
580 ];
581
582 let result = process_pads(&directives);
583
584 assert!(result.errors.is_empty());
585 assert!(result.padding_transactions.is_empty());
587 }
588
589 #[test]
590 fn test_process_pads_unused_pad() {
591 let directives = vec![
592 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
593 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
594 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
596 ];
597
598 let result = process_pads(&directives);
599
600 assert_eq!(result.errors.len(), 1);
601 assert!(
602 result.errors[0]
603 .message
604 .contains("no corresponding balance")
605 );
606 }
607
608 #[test]
609 fn test_merge_with_padding() {
610 let directives = vec![
611 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
612 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
613 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
614 Directive::Balance(Balance::new(
615 date(2024, 1, 2),
616 "Assets:Bank",
617 Amount::new(dec!(1000.00), "USD"),
618 )),
619 ];
620
621 let merged = merge_with_padding(&directives);
622
623 assert_eq!(merged.len(), 5);
625
626 let has_pad = merged.iter().any(|d| matches!(d, Directive::Pad(_)));
628 assert!(has_pad, "Pad should be preserved");
629
630 let txn_count = merged
632 .iter()
633 .filter(|d| matches!(d, Directive::Transaction(_)))
634 .count();
635 assert_eq!(txn_count, 1);
636 }
637
638 #[test]
639 fn test_is_synthesized_pad_recognizes_synth() {
640 let directives = vec![
641 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
642 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
643 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
644 Directive::Balance(Balance::new(
645 date(2024, 1, 2),
646 "Assets:Bank",
647 Amount::new(dec!(1000), "USD"),
648 )),
649 ];
650 let result = process_pads(&directives);
651 let synth = result.padding_transactions.into_iter().next().unwrap();
652 assert!(
653 is_synthesized_pad(&synth),
654 "synth pad transaction must be detected by is_synthesized_pad",
655 );
656 }
657
658 #[test]
659 fn test_is_synthesized_pad_rejects_user_p_flag() {
660 let user_p = Transaction::new(date(2024, 1, 1), "user-authored P-flag txn")
664 .with_flag('P')
665 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")));
666 assert!(
667 !is_synthesized_pad(&user_p),
668 "user-written P-flag transaction must not be classified as synth",
669 );
670 }
671
672 #[test]
673 fn test_merge_with_padding_same_date_pad_balance_synth_comes_first() {
674 let directives = vec![
679 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
680 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
681 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")),
682 Directive::Balance(Balance::new(
683 date(2024, 1, 2),
684 "Assets:Bank",
685 Amount::new(dec!(1000), "USD"),
686 )),
687 ];
688
689 let merged = merge_with_padding(&directives);
690
691 let synth_idx = merged
693 .iter()
694 .position(|d| matches!(d, Directive::Transaction(t) if is_synthesized_pad(t)))
695 .expect("synth present");
696 let balance_idx = merged
697 .iter()
698 .position(|d| matches!(d, Directive::Balance(_)))
699 .expect("balance present");
700 assert!(
701 synth_idx < balance_idx,
702 "synth pad (idx {synth_idx}) must appear before Balance (idx {balance_idx}) on same date",
703 );
704 }
705
706 #[test]
707 #[should_panic(expected = "merge_with_padding called on input that already contains synth")]
708 fn test_merge_with_padding_double_apply_debug_asserts() {
709 let directives = vec![
715 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
716 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
717 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
718 Directive::Balance(Balance::new(
719 date(2024, 1, 2),
720 "Assets:Bank",
721 Amount::new(dec!(1000), "USD"),
722 )),
723 ];
724 let merged_once = merge_with_padding(&directives);
725 let _merged_twice = merge_with_padding(&merged_once); }
727
728 #[test]
729 fn test_padding_transaction_has_p_flag() {
730 let directives = vec![
731 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
732 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
733 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
734 Directive::Balance(Balance::new(
735 date(2024, 1, 2),
736 "Assets:Bank",
737 Amount::new(dec!(1000.00), "USD"),
738 )),
739 ];
740
741 let result = process_pads(&directives);
742
743 assert_eq!(result.padding_transactions.len(), 1);
744 assert_eq!(result.padding_transactions[0].flag, 'P');
745 }
746
747 #[test]
748 fn test_process_pads_multiple_currencies() {
749 let directives = vec![
756 Directive::Open(Open::new(date(2007, 1, 1), "Assets:Cash")),
757 Directive::Open(Open::new(date(2007, 1, 1), "Equity:Opening")),
758 Directive::Pad(Pad::new(
759 date(2007, 12, 30),
760 "Assets:Cash",
761 "Equity:Opening",
762 )),
763 Directive::Balance(Balance::new(
764 date(2007, 12, 31),
765 "Assets:Cash",
766 Amount::new(dec!(200), "CAD"),
767 )),
768 Directive::Balance(Balance::new(
769 date(2007, 12, 31),
770 "Assets:Cash",
771 Amount::new(dec!(300), "USD"),
772 )),
773 ];
774
775 let result = process_pads(&directives);
776
777 assert!(result.errors.is_empty(), "Should have no errors");
778 assert_eq!(
779 result.padding_transactions.len(),
780 2,
781 "Should generate TWO padding transactions (one per currency)"
782 );
783
784 let currencies: Vec<_> = result
786 .padding_transactions
787 .iter()
788 .filter_map(|txn| txn.postings.first())
789 .filter_map(|p| p.amount())
790 .map(|a| a.currency.as_str())
791 .collect();
792
793 assert!(currencies.contains(&"CAD"), "Should pad CAD");
794 assert!(currencies.contains(&"USD"), "Should pad USD");
795 }
796
797 #[test]
798 fn test_process_pads_transaction_after_balance_ends_pad() {
799 let directives = vec![
802 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
803 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
804 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
805 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
806 Directive::Balance(Balance::new(
807 date(2024, 1, 2),
808 "Assets:Bank",
809 Amount::new(dec!(1000), "USD"),
810 )),
811 Directive::Transaction(
813 Transaction::new(date(2024, 1, 3), "Spending")
814 .with_synthesized_posting(Posting::new(
815 "Assets:Bank",
816 Amount::new(dec!(-100), "USD"),
817 ))
818 .with_synthesized_posting(Posting::new(
819 "Expenses:Food",
820 Amount::new(dec!(100), "USD"),
821 )),
822 ),
823 Directive::Balance(Balance::new(
825 date(2024, 1, 5),
826 "Assets:Bank",
827 Amount::new(dec!(900), "USD"),
828 )),
829 ];
830
831 let result = process_pads(&directives);
832
833 assert_eq!(result.padding_transactions.len(), 1);
835 assert_eq!(
836 result.padding_transactions[0]
837 .postings
838 .first()
839 .and_then(|p| p.amount())
840 .map(|a| a.number),
841 Some(dec!(1000))
842 );
843 }
844}