1use rust_decimal::Decimal;
26use rustledger_core::{
27 Amount, Currency, Directive, Inventory, NaiveDate, Pad, Position, Posting, 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 = if let Some(cost_spec) = &posting.cost {
162 if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
163 Position::with_cost(units.clone(), cost)
164 } else {
165 Position::simple(units.clone())
166 }
167 } else {
168 Position::simple(units.clone())
169 };
170 inv.add(position);
171 }
172 }
173 }
174
175 Directive::Pad(pad) => {
176 pending_pads.insert(
179 pad.account.clone(),
180 PendingPad {
181 pad: pad.clone(),
182 used: false,
183 padded_currencies: std::collections::HashSet::new(),
184 },
185 );
186 }
187
188 Directive::Balance(bal) => {
189 if let Some(pending) = pending_pads.get_mut(&bal.account) {
192 if pending.padded_currencies.contains(&bal.amount.currency) {
195 continue;
196 }
197
198 let current = inventories
200 .get(&bal.account)
201 .map_or(Decimal::ZERO, |inv| inv.units(&bal.amount.currency));
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#[cfg(test)]
350mod tests {
351 use super::*;
352 use rust_decimal_macros::dec;
353 use rustledger_core::{Balance, Open};
354
355 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
356 rustledger_core::naive_date(year, month, day).unwrap()
357 }
358
359 #[test]
360 fn test_process_pads_basic() {
361 let directives = vec![
362 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
363 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
364 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
365 Directive::Balance(Balance::new(
366 date(2024, 1, 2),
367 "Assets:Bank",
368 Amount::new(dec!(1000.00), "USD"),
369 )),
370 ];
371
372 let result = process_pads(&directives);
373
374 assert!(result.errors.is_empty());
375 assert_eq!(result.padding_transactions.len(), 1);
376
377 let txn = &result.padding_transactions[0];
378 assert_eq!(txn.date, date(2024, 1, 1));
379 assert_eq!(txn.postings.len(), 2);
380
381 assert_eq!(txn.postings[0].account, "Assets:Bank");
383 assert_eq!(
384 txn.postings[0].amount(),
385 Some(&Amount::new(dec!(1000.00), "USD"))
386 );
387
388 assert_eq!(txn.postings[1].account, "Equity:Opening");
390 assert_eq!(
391 txn.postings[1].amount(),
392 Some(&Amount::new(dec!(-1000.00), "USD"))
393 );
394 }
395
396 #[test]
397 fn test_process_pads_with_existing_balance() {
398 let directives = vec![
399 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
400 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
401 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
402 Directive::Transaction(
403 Transaction::new(date(2024, 1, 5), "Deposit")
404 .with_synthesized_posting(Posting::new(
405 "Assets:Bank",
406 Amount::new(dec!(500.00), "USD"),
407 ))
408 .with_synthesized_posting(Posting::new(
409 "Income:Salary",
410 Amount::new(dec!(-500.00), "USD"),
411 )),
412 ),
413 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
414 Directive::Balance(Balance::new(
415 date(2024, 1, 15),
416 "Assets:Bank",
417 Amount::new(dec!(1000.00), "USD"),
418 )),
419 ];
420
421 let result = process_pads(&directives);
422
423 assert!(result.errors.is_empty());
424 assert_eq!(result.padding_transactions.len(), 1);
425
426 let txn = &result.padding_transactions[0];
427 assert_eq!(
429 txn.postings[0].amount(),
430 Some(&Amount::new(dec!(500.00), "USD"))
431 );
432 }
433
434 #[test]
435 fn test_process_pads_negative_adjustment() {
436 let directives = vec![
437 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
438 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
439 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
440 Directive::Transaction(
441 Transaction::new(date(2024, 1, 5), "Big deposit")
442 .with_synthesized_posting(Posting::new(
443 "Assets:Bank",
444 Amount::new(dec!(2000.00), "USD"),
445 ))
446 .with_synthesized_posting(Posting::new(
447 "Income:Salary",
448 Amount::new(dec!(-2000.00), "USD"),
449 )),
450 ),
451 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
452 Directive::Balance(Balance::new(
453 date(2024, 1, 15),
454 "Assets:Bank",
455 Amount::new(dec!(1000.00), "USD"),
456 )),
457 ];
458
459 let result = process_pads(&directives);
460
461 assert!(result.errors.is_empty());
462 assert_eq!(result.padding_transactions.len(), 1);
463
464 let txn = &result.padding_transactions[0];
465 assert_eq!(
467 txn.postings[0].amount(),
468 Some(&Amount::new(dec!(-1000.00), "USD"))
469 );
470 }
471
472 #[test]
473 fn test_process_pads_no_difference() {
474 let directives = vec![
475 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
476 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
477 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
478 Directive::Transaction(
479 Transaction::new(date(2024, 1, 5), "Exact deposit")
480 .with_synthesized_posting(Posting::new(
481 "Assets:Bank",
482 Amount::new(dec!(1000.00), "USD"),
483 ))
484 .with_synthesized_posting(Posting::new(
485 "Income:Salary",
486 Amount::new(dec!(-1000.00), "USD"),
487 )),
488 ),
489 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
490 Directive::Balance(Balance::new(
491 date(2024, 1, 15),
492 "Assets:Bank",
493 Amount::new(dec!(1000.00), "USD"),
494 )),
495 ];
496
497 let result = process_pads(&directives);
498
499 assert!(result.errors.is_empty());
500 assert!(result.padding_transactions.is_empty());
502 }
503
504 #[test]
505 fn test_process_pads_unused_pad() {
506 let directives = vec![
507 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
508 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
509 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
511 ];
512
513 let result = process_pads(&directives);
514
515 assert_eq!(result.errors.len(), 1);
516 assert!(
517 result.errors[0]
518 .message
519 .contains("no corresponding balance")
520 );
521 }
522
523 #[test]
524 fn test_merge_with_padding() {
525 let directives = vec![
526 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
527 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
528 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
529 Directive::Balance(Balance::new(
530 date(2024, 1, 2),
531 "Assets:Bank",
532 Amount::new(dec!(1000.00), "USD"),
533 )),
534 ];
535
536 let merged = merge_with_padding(&directives);
537
538 assert_eq!(merged.len(), 5);
540
541 let has_pad = merged.iter().any(|d| matches!(d, Directive::Pad(_)));
543 assert!(has_pad, "Pad should be preserved");
544
545 let txn_count = merged
547 .iter()
548 .filter(|d| matches!(d, Directive::Transaction(_)))
549 .count();
550 assert_eq!(txn_count, 1);
551 }
552
553 #[test]
554 fn test_is_synthesized_pad_recognizes_synth() {
555 let directives = vec![
556 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
557 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
558 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
559 Directive::Balance(Balance::new(
560 date(2024, 1, 2),
561 "Assets:Bank",
562 Amount::new(dec!(1000), "USD"),
563 )),
564 ];
565 let result = process_pads(&directives);
566 let synth = result.padding_transactions.into_iter().next().unwrap();
567 assert!(
568 is_synthesized_pad(&synth),
569 "synth pad transaction must be detected by is_synthesized_pad",
570 );
571 }
572
573 #[test]
574 fn test_is_synthesized_pad_rejects_user_p_flag() {
575 let user_p = Transaction::new(date(2024, 1, 1), "user-authored P-flag txn")
579 .with_flag('P')
580 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")));
581 assert!(
582 !is_synthesized_pad(&user_p),
583 "user-written P-flag transaction must not be classified as synth",
584 );
585 }
586
587 #[test]
588 fn test_merge_with_padding_same_date_pad_balance_synth_comes_first() {
589 let directives = vec![
594 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
595 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
596 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")),
597 Directive::Balance(Balance::new(
598 date(2024, 1, 2),
599 "Assets:Bank",
600 Amount::new(dec!(1000), "USD"),
601 )),
602 ];
603
604 let merged = merge_with_padding(&directives);
605
606 let synth_idx = merged
608 .iter()
609 .position(|d| matches!(d, Directive::Transaction(t) if is_synthesized_pad(t)))
610 .expect("synth present");
611 let balance_idx = merged
612 .iter()
613 .position(|d| matches!(d, Directive::Balance(_)))
614 .expect("balance present");
615 assert!(
616 synth_idx < balance_idx,
617 "synth pad (idx {synth_idx}) must appear before Balance (idx {balance_idx}) on same date",
618 );
619 }
620
621 #[test]
622 #[should_panic(expected = "merge_with_padding called on input that already contains synth")]
623 fn test_merge_with_padding_double_apply_debug_asserts() {
624 let directives = vec![
630 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
631 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
632 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
633 Directive::Balance(Balance::new(
634 date(2024, 1, 2),
635 "Assets:Bank",
636 Amount::new(dec!(1000), "USD"),
637 )),
638 ];
639 let merged_once = merge_with_padding(&directives);
640 let _merged_twice = merge_with_padding(&merged_once); }
642
643 #[test]
644 fn test_padding_transaction_has_p_flag() {
645 let directives = vec![
646 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
647 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
648 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
649 Directive::Balance(Balance::new(
650 date(2024, 1, 2),
651 "Assets:Bank",
652 Amount::new(dec!(1000.00), "USD"),
653 )),
654 ];
655
656 let result = process_pads(&directives);
657
658 assert_eq!(result.padding_transactions.len(), 1);
659 assert_eq!(result.padding_transactions[0].flag, 'P');
660 }
661
662 #[test]
663 fn test_process_pads_multiple_currencies() {
664 let directives = vec![
671 Directive::Open(Open::new(date(2007, 1, 1), "Assets:Cash")),
672 Directive::Open(Open::new(date(2007, 1, 1), "Equity:Opening")),
673 Directive::Pad(Pad::new(
674 date(2007, 12, 30),
675 "Assets:Cash",
676 "Equity:Opening",
677 )),
678 Directive::Balance(Balance::new(
679 date(2007, 12, 31),
680 "Assets:Cash",
681 Amount::new(dec!(200), "CAD"),
682 )),
683 Directive::Balance(Balance::new(
684 date(2007, 12, 31),
685 "Assets:Cash",
686 Amount::new(dec!(300), "USD"),
687 )),
688 ];
689
690 let result = process_pads(&directives);
691
692 assert!(result.errors.is_empty(), "Should have no errors");
693 assert_eq!(
694 result.padding_transactions.len(),
695 2,
696 "Should generate TWO padding transactions (one per currency)"
697 );
698
699 let currencies: Vec<_> = result
701 .padding_transactions
702 .iter()
703 .filter_map(|txn| txn.postings.first())
704 .filter_map(|p| p.amount())
705 .map(|a| a.currency.as_str())
706 .collect();
707
708 assert!(currencies.contains(&"CAD"), "Should pad CAD");
709 assert!(currencies.contains(&"USD"), "Should pad USD");
710 }
711
712 #[test]
713 fn test_process_pads_transaction_after_balance_ends_pad() {
714 let directives = vec![
717 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
718 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
719 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
720 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
721 Directive::Balance(Balance::new(
722 date(2024, 1, 2),
723 "Assets:Bank",
724 Amount::new(dec!(1000), "USD"),
725 )),
726 Directive::Transaction(
728 Transaction::new(date(2024, 1, 3), "Spending")
729 .with_synthesized_posting(Posting::new(
730 "Assets:Bank",
731 Amount::new(dec!(-100), "USD"),
732 ))
733 .with_synthesized_posting(Posting::new(
734 "Expenses:Food",
735 Amount::new(dec!(100), "USD"),
736 )),
737 ),
738 Directive::Balance(Balance::new(
740 date(2024, 1, 5),
741 "Assets:Bank",
742 Amount::new(dec!(900), "USD"),
743 )),
744 ];
745
746 let result = process_pads(&directives);
747
748 assert_eq!(result.padding_transactions.len(), 1);
750 assert_eq!(
751 result.padding_transactions[0]
752 .postings
753 .first()
754 .and_then(|p| p.amount())
755 .map(|a| a.number),
756 Some(dec!(1000))
757 );
758 }
759}