Skip to main content

rustledger_booking/
pad.rs

1//! Pad directive processing and transaction reconstruction.
2//!
3//! This module provides functionality to:
4//! - Process pad directives and calculate padding amounts
5//! - Generate synthetic transactions representing padding adjustments
6//!
7//! # Pad Processing
8//!
9//! A `pad` directive inserts a synthetic transaction between the `pad` date and
10//! the next `balance` assertion to make the balance match. The synthetic transaction
11//! transfers funds from the source account to the target account.
12//!
13//! ```beancount
14//! 2024-01-01 pad Assets:Bank Equity:Opening-Balances
15//! 2024-01-02 balance Assets:Bank 1000.00 USD
16//! ```
17//!
18//! This generates a synthetic transaction (matching Python beancount's format):
19//! ```beancount
20//! 2024-01-01 P "(Padding inserted for Balance of 1000.00 USD for difference 1000.00 USD)"
21//!   Assets:Bank             1000.00 USD
22//!   Equity:Opening-Balances -1000.00 USD
23//! ```
24
25use 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/// Result of processing pad directives.
33#[derive(Debug, Clone)]
34pub struct PadResult {
35    /// Original directives with pads removed.
36    pub directives: Vec<Directive>,
37    /// Synthetic padding transactions generated.
38    pub padding_transactions: Vec<Transaction>,
39    /// Any errors encountered during pad processing.
40    pub errors: Vec<PadError>,
41}
42
43/// Error during pad processing.
44#[derive(Debug, Clone)]
45pub struct PadError {
46    /// Date of the error.
47    pub date: NaiveDate,
48    /// Error message.
49    pub message: String,
50    /// Account involved.
51    pub account: Option<InternedStr>,
52}
53
54impl PadError {
55    /// Create a new pad error.
56    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    /// Add account context.
65    pub fn with_account(mut self, account: impl Into<InternedStr>) -> Self {
66        self.account = Some(account.into());
67        self
68    }
69}
70
71/// Pending pad information.
72#[derive(Debug, Clone)]
73struct PendingPad {
74    /// The pad directive.
75    pad: Pad,
76    /// Whether this pad has been used (has at least one balance assertion).
77    used: bool,
78    /// Currencies that have already been padded (each currency can only be padded once per pad).
79    padded_currencies: std::collections::HashSet<InternedStr>,
80}
81
82/// Process pad directives and generate synthetic transactions.
83///
84/// This function:
85/// 1. Tracks account inventories
86/// 2. When a pad is encountered, stores it as pending
87/// 3. When a balance assertion is encountered for an account with a pending pad,
88///    generates a synthetic transaction to make the balance match
89/// 4. Returns the directives with synthetic transactions inserted
90///
91/// # Arguments
92///
93/// * `directives` - The directives to process (should be sorted by date)
94///
95/// # Returns
96///
97/// A `PadResult` containing:
98/// - The original directives (with pads preserved for reference)
99/// - Synthetic padding transactions
100/// - Any errors encountered
101pub 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    // Sort directives by date for processing
110    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                // Update inventories
121                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                // Store pending pad (replaces any existing pad for this account)
141                // Reset padded_currencies when a new pad is encountered
142                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                // Check if there's a pending pad for this account
154                // Use get_mut instead of remove - a pad can apply to multiple currencies
155                if let Some(pending) = pending_pads.get_mut(&bal.account) {
156                    // Only pad if this currency hasn't been padded yet for this pad directive
157                    // (each currency can only be padded once per pad)
158                    if pending.padded_currencies.contains(&bal.amount.currency) {
159                        continue;
160                    }
161
162                    // Calculate padding amount
163                    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                        // Generate synthetic transaction
171                        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, // target balance for narration
177                        );
178
179                        // Apply to inventories
180                        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                    // Mark the pad as used and track that this currency has been padded
197                    pending.used = true;
198                    pending
199                        .padded_currencies
200                        .insert(bal.amount.currency.clone());
201                }
202                // If no pending pad, nothing to do (balance will be checked normally)
203            }
204
205            _ => {}
206        }
207    }
208
209    // Check for unused pads (pad without corresponding balance)
210    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
231/// Create a synthetic padding transaction.
232///
233/// The narration format matches Python beancount:
234/// `(Padding inserted for Balance of {balance} for difference {difference})`
235fn 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
252/// Expand a ledger by replacing pad directives with synthetic transactions.
253///
254/// This is useful for reports that need to show explicit padding transactions.
255///
256/// # Arguments
257///
258/// * `directives` - The original directives
259///
260/// # Returns
261///
262/// A new list of directives with pad directives replaced by synthetic transactions.
263pub fn expand_pads(directives: &[Directive]) -> Vec<Directive> {
264    let result = process_pads(directives);
265
266    let mut expanded: Vec<Directive> = Vec::new();
267
268    // Sort original directives by date
269    let mut sorted_originals: Vec<&Directive> = directives.iter().collect();
270    sorted_originals.sort_by_key(|d| d.date());
271
272    // Create a map of pad dates to padding transactions
273    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                // Replace pad with synthetic transactions if any were generated
282                // A single pad can generate multiple transactions (one per currency)
283                if let Some(txns) = pad_txns_by_date.get(&pad.date) {
284                    // Find ALL matching transactions for this pad (multiple currencies)
285                    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                // If no transaction was generated (difference was zero), omit the pad
292            }
293            other => {
294                expanded.push(other.clone());
295            }
296        }
297    }
298
299    expanded
300}
301
302/// Merge original directives with padding transactions, maintaining date order.
303///
304/// Unlike `expand_pads`, this keeps the original pad directives and adds
305/// the synthetic transactions alongside them.
306pub 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    // Add padding transactions
312    for txn in result.padding_transactions {
313        merged.push(Directive::Transaction(txn));
314    }
315
316    // Sort by date
317    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        // Check target posting
355        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        // Check source posting
362        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        // Should pad 500.00 (1000 target - 500 existing)
401        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        // Should pad -1000.00 (1000 target - 2000 existing)
439        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        // No padding transaction needed when balance already matches
474        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            // Pad without balance assertion
483            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        // Should have: 2 opens + 1 synthetic transaction + 1 balance = 4
512        assert_eq!(expanded.len(), 4);
513
514        // The pad should be replaced with a transaction
515        let has_pad = expanded.iter().any(|d| matches!(d, Directive::Pad(_)));
516        assert!(!has_pad, "Pad should be replaced");
517
518        // Should have the synthetic transaction
519        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        // Should have: 2 opens + 1 pad + 1 balance + 1 synthetic = 5
542        assert_eq!(merged.len(), 5);
543
544        // Pad should still be there
545        let has_pad = merged.iter().any(|d| matches!(d, Directive::Pad(_)));
546        assert!(has_pad, "Pad should be preserved");
547
548        // Should also have the synthetic transaction
549        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        // From basic.beancount:
578        // 2007-12-30 pad  Assets:Cash  Equity:Opening-Balances
579        // 2007-12-31 balance  Assets:Cash  200 CAD
580        // 2007-12-31 balance  Assets:Cash  300 USD
581        //
582        // A single pad should generate padding for BOTH currencies
583        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        // Check that we have both currencies padded
613        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        // Once a transaction affects the account after the balance assertions,
628        // the pad should no longer apply to later balance assertions
629        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            // Transaction after balance - this "consumes" the pad
640            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            // This balance should NOT use the pad (too late)
646            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        // Should only generate one padding transaction (for the first balance)
656        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}