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 if directives
327 .iter()
328 .any(|d| matches!(d, Directive::Transaction(t) if is_synthesized_pad(t)))
329 {
330 return directives.to_vec();
331 }
332
333 let result = process_pads(directives);
334
335 let mut merged: Vec<Directive> =
341 Vec::with_capacity(directives.len() + result.padding_transactions.len());
342 for txn in result.padding_transactions {
343 merged.push(Directive::Transaction(txn));
344 }
345 merged.extend(directives.iter().cloned());
346
347 merged.sort_by_key(rustledger_core::Directive::date);
348
349 merged
350}
351
352#[must_use]
367pub fn merge_with_padding_spanned(directives: &[Spanned<Directive>]) -> Vec<Spanned<Directive>> {
368 let plain: Vec<Directive> = directives.iter().map(|s| s.value.clone()).collect();
369 debug_assert!(
370 !plain
371 .iter()
372 .any(|d| matches!(d, Directive::Transaction(t) if is_synthesized_pad(t))),
373 "merge_with_padding_spanned called on input that already contains synth pad transactions; \
374 re-running would double-count pad effects",
375 );
376
377 let result = process_pads(&plain);
378
379 let mut merged: Vec<Spanned<Directive>> =
382 Vec::with_capacity(directives.len() + result.padding_transactions.len());
383 for txn in result.padding_transactions {
384 merged.push(Spanned::synthesized(Directive::Transaction(txn)));
385 }
386 merged.extend(directives.iter().cloned());
387
388 merged.sort_by_key(|s| s.value.date());
389
390 merged
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use rust_decimal_macros::dec;
397 use rustledger_core::{Balance, Open};
398
399 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
400 rustledger_core::naive_date(year, month, day).unwrap()
401 }
402
403 #[test]
404 fn test_process_pads_basic() {
405 let directives = vec![
406 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
407 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
408 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
409 Directive::Balance(Balance::new(
410 date(2024, 1, 2),
411 "Assets:Bank",
412 Amount::new(dec!(1000.00), "USD"),
413 )),
414 ];
415
416 let result = process_pads(&directives);
417
418 assert!(result.errors.is_empty());
419 assert_eq!(result.padding_transactions.len(), 1);
420
421 let txn = &result.padding_transactions[0];
422 assert_eq!(txn.date, date(2024, 1, 1));
423 assert_eq!(txn.postings.len(), 2);
424
425 assert_eq!(txn.postings[0].account, "Assets:Bank");
427 assert_eq!(
428 txn.postings[0].amount(),
429 Some(&Amount::new(dec!(1000.00), "USD"))
430 );
431
432 assert_eq!(txn.postings[1].account, "Equity:Opening");
434 assert_eq!(
435 txn.postings[1].amount(),
436 Some(&Amount::new(dec!(-1000.00), "USD"))
437 );
438 }
439
440 #[test]
441 fn test_process_pads_with_existing_balance() {
442 let directives = vec![
443 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
444 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
445 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
446 Directive::Transaction(
447 Transaction::new(date(2024, 1, 5), "Deposit")
448 .with_synthesized_posting(Posting::new(
449 "Assets:Bank",
450 Amount::new(dec!(500.00), "USD"),
451 ))
452 .with_synthesized_posting(Posting::new(
453 "Income:Salary",
454 Amount::new(dec!(-500.00), "USD"),
455 )),
456 ),
457 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
458 Directive::Balance(Balance::new(
459 date(2024, 1, 15),
460 "Assets:Bank",
461 Amount::new(dec!(1000.00), "USD"),
462 )),
463 ];
464
465 let result = process_pads(&directives);
466
467 assert!(result.errors.is_empty());
468 assert_eq!(result.padding_transactions.len(), 1);
469
470 let txn = &result.padding_transactions[0];
471 assert_eq!(
473 txn.postings[0].amount(),
474 Some(&Amount::new(dec!(500.00), "USD"))
475 );
476 }
477
478 #[test]
479 fn test_process_pads_sums_subaccounts_for_nonleaf_target() {
480 let directives = vec![
487 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
488 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:Checking")),
489 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
490 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
491 Directive::Transaction(
492 Transaction::new(date(2024, 1, 5), "Deposit into sub-account")
493 .with_synthesized_posting(Posting::new(
494 "Assets:Bank:Checking",
495 Amount::new(dec!(50.00), "USD"),
496 ))
497 .with_synthesized_posting(Posting::new(
498 "Income:Salary",
499 Amount::new(dec!(-50.00), "USD"),
500 )),
501 ),
502 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
503 Directive::Balance(Balance::new(
504 date(2024, 1, 15),
505 "Assets:Bank",
506 Amount::new(dec!(100.00), "USD"),
507 )),
508 ];
509
510 let result = process_pads(&directives);
511
512 assert!(result.errors.is_empty());
513 assert_eq!(result.padding_transactions.len(), 1);
514 assert_eq!(
516 result.padding_transactions[0].postings[0].amount(),
517 Some(&Amount::new(dec!(50.00), "USD")),
518 "pad on a non-leaf account must sum sub-accounts (was leaf-only)"
519 );
520 }
521
522 #[test]
523 fn test_process_pads_negative_adjustment() {
524 let directives = vec![
525 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
526 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
527 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
528 Directive::Transaction(
529 Transaction::new(date(2024, 1, 5), "Big deposit")
530 .with_synthesized_posting(Posting::new(
531 "Assets:Bank",
532 Amount::new(dec!(2000.00), "USD"),
533 ))
534 .with_synthesized_posting(Posting::new(
535 "Income:Salary",
536 Amount::new(dec!(-2000.00), "USD"),
537 )),
538 ),
539 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
540 Directive::Balance(Balance::new(
541 date(2024, 1, 15),
542 "Assets:Bank",
543 Amount::new(dec!(1000.00), "USD"),
544 )),
545 ];
546
547 let result = process_pads(&directives);
548
549 assert!(result.errors.is_empty());
550 assert_eq!(result.padding_transactions.len(), 1);
551
552 let txn = &result.padding_transactions[0];
553 assert_eq!(
555 txn.postings[0].amount(),
556 Some(&Amount::new(dec!(-1000.00), "USD"))
557 );
558 }
559
560 #[test]
561 fn test_process_pads_no_difference() {
562 let directives = vec![
563 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
564 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
565 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
566 Directive::Transaction(
567 Transaction::new(date(2024, 1, 5), "Exact deposit")
568 .with_synthesized_posting(Posting::new(
569 "Assets:Bank",
570 Amount::new(dec!(1000.00), "USD"),
571 ))
572 .with_synthesized_posting(Posting::new(
573 "Income:Salary",
574 Amount::new(dec!(-1000.00), "USD"),
575 )),
576 ),
577 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
578 Directive::Balance(Balance::new(
579 date(2024, 1, 15),
580 "Assets:Bank",
581 Amount::new(dec!(1000.00), "USD"),
582 )),
583 ];
584
585 let result = process_pads(&directives);
586
587 assert!(result.errors.is_empty());
588 assert!(result.padding_transactions.is_empty());
590 }
591
592 #[test]
593 fn test_process_pads_unused_pad() {
594 let directives = vec![
595 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
596 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
597 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
599 ];
600
601 let result = process_pads(&directives);
602
603 assert_eq!(result.errors.len(), 1);
604 assert!(
605 result.errors[0]
606 .message
607 .contains("no corresponding balance")
608 );
609 }
610
611 #[test]
612 fn test_merge_with_padding() {
613 let directives = vec![
614 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
615 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
616 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
617 Directive::Balance(Balance::new(
618 date(2024, 1, 2),
619 "Assets:Bank",
620 Amount::new(dec!(1000.00), "USD"),
621 )),
622 ];
623
624 let merged = merge_with_padding(&directives);
625
626 assert_eq!(merged.len(), 5);
628
629 let has_pad = merged.iter().any(|d| matches!(d, Directive::Pad(_)));
631 assert!(has_pad, "Pad should be preserved");
632
633 let txn_count = merged
635 .iter()
636 .filter(|d| matches!(d, Directive::Transaction(_)))
637 .count();
638 assert_eq!(txn_count, 1);
639 }
640
641 #[test]
642 fn test_is_synthesized_pad_recognizes_synth() {
643 let directives = vec![
644 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
645 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
646 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
647 Directive::Balance(Balance::new(
648 date(2024, 1, 2),
649 "Assets:Bank",
650 Amount::new(dec!(1000), "USD"),
651 )),
652 ];
653 let result = process_pads(&directives);
654 let synth = result.padding_transactions.into_iter().next().unwrap();
655 assert!(
656 is_synthesized_pad(&synth),
657 "synth pad transaction must be detected by is_synthesized_pad",
658 );
659 }
660
661 #[test]
662 fn test_is_synthesized_pad_rejects_user_p_flag() {
663 let user_p = Transaction::new(date(2024, 1, 1), "user-authored P-flag txn")
667 .with_flag('P')
668 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")));
669 assert!(
670 !is_synthesized_pad(&user_p),
671 "user-written P-flag transaction must not be classified as synth",
672 );
673 }
674
675 #[test]
676 fn test_merge_with_padding_same_date_pad_balance_synth_comes_first() {
677 let directives = vec![
682 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
683 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
684 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")),
685 Directive::Balance(Balance::new(
686 date(2024, 1, 2),
687 "Assets:Bank",
688 Amount::new(dec!(1000), "USD"),
689 )),
690 ];
691
692 let merged = merge_with_padding(&directives);
693
694 let synth_idx = merged
696 .iter()
697 .position(|d| matches!(d, Directive::Transaction(t) if is_synthesized_pad(t)))
698 .expect("synth present");
699 let balance_idx = merged
700 .iter()
701 .position(|d| matches!(d, Directive::Balance(_)))
702 .expect("balance present");
703 assert!(
704 synth_idx < balance_idx,
705 "synth pad (idx {synth_idx}) must appear before Balance (idx {balance_idx}) on same date",
706 );
707 }
708
709 #[test]
710 fn test_merge_with_padding_is_idempotent() {
711 let directives = vec![
716 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
717 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
718 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
719 Directive::Balance(Balance::new(
720 date(2024, 1, 2),
721 "Assets:Bank",
722 Amount::new(dec!(1000), "USD"),
723 )),
724 ];
725 let merged_once = merge_with_padding(&directives);
726 let merged_twice = merge_with_padding(&merged_once);
727 assert_eq!(merged_once.len(), merged_twice.len());
728 }
729
730 #[test]
731 fn test_padding_transaction_has_p_flag() {
732 let directives = vec![
733 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
734 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
735 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
736 Directive::Balance(Balance::new(
737 date(2024, 1, 2),
738 "Assets:Bank",
739 Amount::new(dec!(1000.00), "USD"),
740 )),
741 ];
742
743 let result = process_pads(&directives);
744
745 assert_eq!(result.padding_transactions.len(), 1);
746 assert_eq!(result.padding_transactions[0].flag, 'P');
747 }
748
749 #[test]
750 fn test_process_pads_multiple_currencies() {
751 let directives = vec![
758 Directive::Open(Open::new(date(2007, 1, 1), "Assets:Cash")),
759 Directive::Open(Open::new(date(2007, 1, 1), "Equity:Opening")),
760 Directive::Pad(Pad::new(
761 date(2007, 12, 30),
762 "Assets:Cash",
763 "Equity:Opening",
764 )),
765 Directive::Balance(Balance::new(
766 date(2007, 12, 31),
767 "Assets:Cash",
768 Amount::new(dec!(200), "CAD"),
769 )),
770 Directive::Balance(Balance::new(
771 date(2007, 12, 31),
772 "Assets:Cash",
773 Amount::new(dec!(300), "USD"),
774 )),
775 ];
776
777 let result = process_pads(&directives);
778
779 assert!(result.errors.is_empty(), "Should have no errors");
780 assert_eq!(
781 result.padding_transactions.len(),
782 2,
783 "Should generate TWO padding transactions (one per currency)"
784 );
785
786 let currencies: Vec<_> = result
788 .padding_transactions
789 .iter()
790 .filter_map(|txn| txn.postings.first())
791 .filter_map(|p| p.amount())
792 .map(|a| a.currency.as_str())
793 .collect();
794
795 assert!(currencies.contains(&"CAD"), "Should pad CAD");
796 assert!(currencies.contains(&"USD"), "Should pad USD");
797 }
798
799 #[test]
800 fn test_process_pads_transaction_after_balance_ends_pad() {
801 let directives = vec![
804 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
805 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
806 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
807 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
808 Directive::Balance(Balance::new(
809 date(2024, 1, 2),
810 "Assets:Bank",
811 Amount::new(dec!(1000), "USD"),
812 )),
813 Directive::Transaction(
815 Transaction::new(date(2024, 1, 3), "Spending")
816 .with_synthesized_posting(Posting::new(
817 "Assets:Bank",
818 Amount::new(dec!(-100), "USD"),
819 ))
820 .with_synthesized_posting(Posting::new(
821 "Expenses:Food",
822 Amount::new(dec!(100), "USD"),
823 )),
824 ),
825 Directive::Balance(Balance::new(
827 date(2024, 1, 5),
828 "Assets:Bank",
829 Amount::new(dec!(900), "USD"),
830 )),
831 ];
832
833 let result = process_pads(&directives);
834
835 assert_eq!(result.padding_transactions.len(), 1);
837 assert_eq!(
838 result.padding_transactions[0]
839 .postings
840 .first()
841 .and_then(|p| p.amount())
842 .map(|a| a.number),
843 Some(dec!(1000))
844 );
845 }
846}