1use rust_decimal::Decimal;
26use rustledger_core::{
27 Amount, Directive, InternedStr, Inventory, NaiveDate, Pad, Position, Posting, Transaction,
28};
29use std::collections::HashMap;
30use std::ops::Neg;
31
32#[derive(Debug, Clone)]
34pub struct PadResult {
35 pub directives: Vec<Directive>,
37 pub padding_transactions: Vec<Transaction>,
39 pub errors: Vec<PadError>,
41}
42
43#[derive(Debug, Clone)]
45pub struct PadError {
46 pub date: NaiveDate,
48 pub message: String,
50 pub account: Option<InternedStr>,
52}
53
54impl PadError {
55 pub fn new(date: NaiveDate, message: impl Into<String>) -> Self {
57 Self {
58 date,
59 message: message.into(),
60 account: None,
61 }
62 }
63
64 pub fn with_account(mut self, account: impl Into<InternedStr>) -> Self {
66 self.account = Some(account.into());
67 self
68 }
69}
70
71#[derive(Debug, Clone)]
73struct PendingPad {
74 pad: Pad,
76 used: bool,
78 padded_currencies: std::collections::HashSet<InternedStr>,
80}
81
82pub fn process_pads(directives: &[Directive]) -> PadResult {
102 let num_directives = directives.len();
103 let mut inventories: HashMap<InternedStr, Inventory> =
104 HashMap::with_capacity(num_directives.min(16));
105 let mut pending_pads: HashMap<InternedStr, PendingPad> = HashMap::with_capacity(4);
106 let mut padding_transactions = Vec::with_capacity(num_directives.min(16));
107 let mut errors = Vec::with_capacity(4);
108
109 let mut sorted: Vec<&Directive> = directives.iter().collect();
111 sorted.sort_by_key(|d| d.date());
112
113 for directive in sorted {
114 match directive {
115 Directive::Open(open) => {
116 inventories.insert(open.account.clone(), Inventory::new());
117 }
118
119 Directive::Transaction(txn) => {
120 for posting in &txn.postings {
122 if let Some(units) = posting.amount()
123 && let Some(inv) = inventories.get_mut(&posting.account)
124 {
125 let position = if let Some(cost_spec) = &posting.cost {
126 if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
127 Position::with_cost(units.clone(), cost)
128 } else {
129 Position::simple(units.clone())
130 }
131 } else {
132 Position::simple(units.clone())
133 };
134 inv.add(position);
135 }
136 }
137 }
138
139 Directive::Pad(pad) => {
140 pending_pads.insert(
143 pad.account.clone(),
144 PendingPad {
145 pad: pad.clone(),
146 used: false,
147 padded_currencies: std::collections::HashSet::new(),
148 },
149 );
150 }
151
152 Directive::Balance(bal) => {
153 if let Some(pending) = pending_pads.get_mut(&bal.account) {
156 if pending.padded_currencies.contains(&bal.amount.currency) {
159 continue;
160 }
161
162 let current = inventories
164 .get(&bal.account)
165 .map_or(Decimal::ZERO, |inv| inv.units(&bal.amount.currency));
166
167 let difference = bal.amount.number - current;
168
169 if difference != Decimal::ZERO {
170 let pad_txn = create_padding_transaction(
172 pending.pad.date,
173 &pending.pad.account,
174 &pending.pad.source_account,
175 Amount::new(difference, &bal.amount.currency),
176 &bal.amount, );
178
179 if let Some(inv) = inventories.get_mut(&pending.pad.account) {
181 inv.add(Position::simple(Amount::new(
182 difference,
183 &bal.amount.currency,
184 )));
185 }
186 if let Some(inv) = inventories.get_mut(&pending.pad.source_account) {
187 inv.add(Position::simple(Amount::new(
188 -difference,
189 &bal.amount.currency,
190 )));
191 }
192
193 padding_transactions.push(pad_txn);
194 }
195
196 pending.used = true;
198 pending
199 .padded_currencies
200 .insert(bal.amount.currency.clone());
201 }
202 }
204
205 _ => {}
206 }
207 }
208
209 for (account, pending) in pending_pads {
211 if !pending.used {
212 errors.push(
213 PadError::new(
214 pending.pad.date,
215 format!(
216 "Pad directive for account {account} has no corresponding balance assertion"
217 ),
218 )
219 .with_account(account),
220 );
221 }
222 }
223
224 PadResult {
225 directives: directives.to_vec(),
226 padding_transactions,
227 errors,
228 }
229}
230
231fn create_padding_transaction(
236 date: NaiveDate,
237 target_account: &str,
238 source_account: &str,
239 difference: Amount,
240 balance: &Amount,
241) -> Transaction {
242 let narration = format!(
243 "(Padding inserted for Balance of {} {} for difference {} {})",
244 balance.number, balance.currency, difference.number, difference.currency
245 );
246 Transaction::new(date, &narration)
247 .with_flag('P')
248 .with_posting(Posting::new(target_account, difference.clone()))
249 .with_posting(Posting::new(source_account, difference.neg()))
250}
251
252pub fn expand_pads(directives: &[Directive]) -> Vec<Directive> {
264 let result = process_pads(directives);
265
266 let mut expanded: Vec<Directive> = Vec::new();
267
268 let mut sorted_originals: Vec<&Directive> = directives.iter().collect();
270 sorted_originals.sort_by_key(|d| d.date());
271
272 let mut pad_txns_by_date: HashMap<NaiveDate, Vec<&Transaction>> = HashMap::new();
274 for txn in &result.padding_transactions {
275 pad_txns_by_date.entry(txn.date).or_default().push(txn);
276 }
277
278 for directive in sorted_originals {
279 match directive {
280 Directive::Pad(pad) => {
281 if let Some(txns) = pad_txns_by_date.get(&pad.date) {
284 for txn in txns {
286 if txn.postings.iter().any(|p| p.account == pad.account) {
287 expanded.push(Directive::Transaction((*txn).clone()));
288 }
289 }
290 }
291 }
293 other => {
294 expanded.push(other.clone());
295 }
296 }
297 }
298
299 expanded
300}
301
302pub fn merge_with_padding(directives: &[Directive]) -> Vec<Directive> {
307 let result = process_pads(directives);
308
309 let mut merged: Vec<Directive> = directives.to_vec();
310
311 for txn in result.padding_transactions {
313 merged.push(Directive::Transaction(txn));
314 }
315
316 merged.sort_by_key(rustledger_core::Directive::date);
318
319 merged
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use rust_decimal_macros::dec;
326 use rustledger_core::{Balance, Open};
327
328 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
329 rustledger_core::naive_date(year, month, day).unwrap()
330 }
331
332 #[test]
333 fn test_process_pads_basic() {
334 let directives = vec![
335 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
336 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
337 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
338 Directive::Balance(Balance::new(
339 date(2024, 1, 2),
340 "Assets:Bank",
341 Amount::new(dec!(1000.00), "USD"),
342 )),
343 ];
344
345 let result = process_pads(&directives);
346
347 assert!(result.errors.is_empty());
348 assert_eq!(result.padding_transactions.len(), 1);
349
350 let txn = &result.padding_transactions[0];
351 assert_eq!(txn.date, date(2024, 1, 1));
352 assert_eq!(txn.postings.len(), 2);
353
354 assert_eq!(txn.postings[0].account, "Assets:Bank");
356 assert_eq!(
357 txn.postings[0].amount(),
358 Some(&Amount::new(dec!(1000.00), "USD"))
359 );
360
361 assert_eq!(txn.postings[1].account, "Equity:Opening");
363 assert_eq!(
364 txn.postings[1].amount(),
365 Some(&Amount::new(dec!(-1000.00), "USD"))
366 );
367 }
368
369 #[test]
370 fn test_process_pads_with_existing_balance() {
371 let directives = vec![
372 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
373 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
374 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
375 Directive::Transaction(
376 Transaction::new(date(2024, 1, 5), "Deposit")
377 .with_posting(Posting::new(
378 "Assets:Bank",
379 Amount::new(dec!(500.00), "USD"),
380 ))
381 .with_posting(Posting::new(
382 "Income:Salary",
383 Amount::new(dec!(-500.00), "USD"),
384 )),
385 ),
386 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
387 Directive::Balance(Balance::new(
388 date(2024, 1, 15),
389 "Assets:Bank",
390 Amount::new(dec!(1000.00), "USD"),
391 )),
392 ];
393
394 let result = process_pads(&directives);
395
396 assert!(result.errors.is_empty());
397 assert_eq!(result.padding_transactions.len(), 1);
398
399 let txn = &result.padding_transactions[0];
400 assert_eq!(
402 txn.postings[0].amount(),
403 Some(&Amount::new(dec!(500.00), "USD"))
404 );
405 }
406
407 #[test]
408 fn test_process_pads_negative_adjustment() {
409 let directives = vec![
410 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
411 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
412 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
413 Directive::Transaction(
414 Transaction::new(date(2024, 1, 5), "Big deposit")
415 .with_posting(Posting::new(
416 "Assets:Bank",
417 Amount::new(dec!(2000.00), "USD"),
418 ))
419 .with_posting(Posting::new(
420 "Income:Salary",
421 Amount::new(dec!(-2000.00), "USD"),
422 )),
423 ),
424 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
425 Directive::Balance(Balance::new(
426 date(2024, 1, 15),
427 "Assets:Bank",
428 Amount::new(dec!(1000.00), "USD"),
429 )),
430 ];
431
432 let result = process_pads(&directives);
433
434 assert!(result.errors.is_empty());
435 assert_eq!(result.padding_transactions.len(), 1);
436
437 let txn = &result.padding_transactions[0];
438 assert_eq!(
440 txn.postings[0].amount(),
441 Some(&Amount::new(dec!(-1000.00), "USD"))
442 );
443 }
444
445 #[test]
446 fn test_process_pads_no_difference() {
447 let directives = vec![
448 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
449 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
450 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
451 Directive::Transaction(
452 Transaction::new(date(2024, 1, 5), "Exact deposit")
453 .with_posting(Posting::new(
454 "Assets:Bank",
455 Amount::new(dec!(1000.00), "USD"),
456 ))
457 .with_posting(Posting::new(
458 "Income:Salary",
459 Amount::new(dec!(-1000.00), "USD"),
460 )),
461 ),
462 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
463 Directive::Balance(Balance::new(
464 date(2024, 1, 15),
465 "Assets:Bank",
466 Amount::new(dec!(1000.00), "USD"),
467 )),
468 ];
469
470 let result = process_pads(&directives);
471
472 assert!(result.errors.is_empty());
473 assert!(result.padding_transactions.is_empty());
475 }
476
477 #[test]
478 fn test_process_pads_unused_pad() {
479 let directives = vec![
480 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
481 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
482 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
484 ];
485
486 let result = process_pads(&directives);
487
488 assert_eq!(result.errors.len(), 1);
489 assert!(
490 result.errors[0]
491 .message
492 .contains("no corresponding balance")
493 );
494 }
495
496 #[test]
497 fn test_expand_pads() {
498 let directives = vec![
499 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
500 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
501 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
502 Directive::Balance(Balance::new(
503 date(2024, 1, 2),
504 "Assets:Bank",
505 Amount::new(dec!(1000.00), "USD"),
506 )),
507 ];
508
509 let expanded = expand_pads(&directives);
510
511 assert_eq!(expanded.len(), 4);
513
514 let has_pad = expanded.iter().any(|d| matches!(d, Directive::Pad(_)));
516 assert!(!has_pad, "Pad should be replaced");
517
518 let txn_count = expanded
520 .iter()
521 .filter(|d| matches!(d, Directive::Transaction(_)))
522 .count();
523 assert_eq!(txn_count, 1);
524 }
525
526 #[test]
527 fn test_merge_with_padding() {
528 let directives = vec![
529 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
530 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
531 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
532 Directive::Balance(Balance::new(
533 date(2024, 1, 2),
534 "Assets:Bank",
535 Amount::new(dec!(1000.00), "USD"),
536 )),
537 ];
538
539 let merged = merge_with_padding(&directives);
540
541 assert_eq!(merged.len(), 5);
543
544 let has_pad = merged.iter().any(|d| matches!(d, Directive::Pad(_)));
546 assert!(has_pad, "Pad should be preserved");
547
548 let txn_count = merged
550 .iter()
551 .filter(|d| matches!(d, Directive::Transaction(_)))
552 .count();
553 assert_eq!(txn_count, 1);
554 }
555
556 #[test]
557 fn test_padding_transaction_has_p_flag() {
558 let directives = vec![
559 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
560 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
561 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
562 Directive::Balance(Balance::new(
563 date(2024, 1, 2),
564 "Assets:Bank",
565 Amount::new(dec!(1000.00), "USD"),
566 )),
567 ];
568
569 let result = process_pads(&directives);
570
571 assert_eq!(result.padding_transactions.len(), 1);
572 assert_eq!(result.padding_transactions[0].flag, 'P');
573 }
574
575 #[test]
576 fn test_process_pads_multiple_currencies() {
577 let directives = vec![
584 Directive::Open(Open::new(date(2007, 1, 1), "Assets:Cash")),
585 Directive::Open(Open::new(date(2007, 1, 1), "Equity:Opening")),
586 Directive::Pad(Pad::new(
587 date(2007, 12, 30),
588 "Assets:Cash",
589 "Equity:Opening",
590 )),
591 Directive::Balance(Balance::new(
592 date(2007, 12, 31),
593 "Assets:Cash",
594 Amount::new(dec!(200), "CAD"),
595 )),
596 Directive::Balance(Balance::new(
597 date(2007, 12, 31),
598 "Assets:Cash",
599 Amount::new(dec!(300), "USD"),
600 )),
601 ];
602
603 let result = process_pads(&directives);
604
605 assert!(result.errors.is_empty(), "Should have no errors");
606 assert_eq!(
607 result.padding_transactions.len(),
608 2,
609 "Should generate TWO padding transactions (one per currency)"
610 );
611
612 let currencies: Vec<_> = result
614 .padding_transactions
615 .iter()
616 .filter_map(|txn| txn.postings.first())
617 .filter_map(|p| p.amount())
618 .map(|a| a.currency.as_str())
619 .collect();
620
621 assert!(currencies.contains(&"CAD"), "Should pad CAD");
622 assert!(currencies.contains(&"USD"), "Should pad USD");
623 }
624
625 #[test]
626 fn test_process_pads_transaction_after_balance_ends_pad() {
627 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::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
633 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
634 Directive::Balance(Balance::new(
635 date(2024, 1, 2),
636 "Assets:Bank",
637 Amount::new(dec!(1000), "USD"),
638 )),
639 Directive::Transaction(
641 Transaction::new(date(2024, 1, 3), "Spending")
642 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-100), "USD")))
643 .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(100), "USD"))),
644 ),
645 Directive::Balance(Balance::new(
647 date(2024, 1, 5),
648 "Assets:Bank",
649 Amount::new(dec!(900), "USD"),
650 )),
651 ];
652
653 let result = process_pads(&directives);
654
655 assert_eq!(result.padding_transactions.len(), 1);
657 assert_eq!(
658 result.padding_transactions[0]
659 .postings
660 .first()
661 .and_then(|p| p.amount())
662 .map(|a| a.number),
663 Some(dec!(1000))
664 );
665 }
666}