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, Currency, Directive, Inventory, NaiveDate, Pad, Position, Posting, Transaction,
28};
29use std::collections::HashMap;
30use std::ops::Neg;
31
32/// Prefix of the narration carried by every synth pad transaction
33/// produced by this crate (the format string used inside the
34/// private `create_padding_transaction` constructor).
35///
36/// Together with [`is_synthesized_pad`], lets consumers distinguish
37/// pad-synth transactions from user-written `P`-flag transactions
38/// (`P` is a valid user flag in beancount). The narration prefix
39/// matches Python beancount's format and is preserved end-to-end
40/// through the booking and merge steps.
41pub const SYNTH_PAD_NARRATION_PREFIX: &str = "(Padding inserted for Balance of ";
42
43/// Returns `true` iff `txn` is a pad-synth transaction produced by
44/// this crate.
45///
46/// Checks the `P` flag AND the [`SYNTH_PAD_NARRATION_PREFIX`].
47/// A bare flag check would conflate user-written `P`-flag
48/// transactions with synth pads.
49#[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/// Result of processing pad directives.
59///
60/// This holds only what `process_pads` *derives* from the input: the
61/// synthesized padding transactions and any errors. It deliberately
62/// does NOT echo the input directives back — the caller already owns
63/// that slice, so cloning it into the result was pure waste on every
64/// call (a full deep-clone of the directive stream the caller then
65/// discarded). Callers that want the source merged with the synth
66/// transactions for balance math should use [`merge_with_padding`].
67#[derive(Debug, Clone)]
68pub struct PadResult {
69    /// Synthetic padding transactions generated.
70    pub padding_transactions: Vec<Transaction>,
71    /// Any errors encountered during pad processing.
72    pub errors: Vec<PadError>,
73}
74
75/// Error during pad processing.
76#[derive(Debug, Clone)]
77pub struct PadError {
78    /// Date of the error.
79    pub date: NaiveDate,
80    /// Error message.
81    pub message: String,
82    /// Account involved.
83    pub account: Option<rustledger_core::Account>,
84}
85
86impl PadError {
87    /// Create a new pad error.
88    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    /// Add account context.
97    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/// Pending pad information.
104#[derive(Debug, Clone)]
105struct PendingPad {
106    /// The pad directive.
107    pad: Pad,
108    /// Whether this pad has been used (has at least one balance assertion).
109    used: bool,
110    /// Currencies that have already been padded (each currency can only be padded once per pad).
111    padded_currencies: std::collections::HashSet<Currency>,
112}
113
114/// Process pad directives and generate synthetic transactions.
115///
116/// This function:
117/// 1. Tracks account inventories
118/// 2. When a pad is encountered, stores it as pending
119/// 3. When a balance assertion is encountered for an account with a pending pad,
120///    generates a synthetic transaction to make the balance match
121///
122/// # Arguments
123///
124/// * `directives` - The directives to process. Order does not matter:
125///   `process_pads` sorts a view of them by date internally before
126///   applying pad math.
127///
128/// # Returns
129///
130/// A `PadResult` containing:
131/// - The synthetic padding transactions derived from the input
132/// - Any errors encountered
133///
134/// The input directives are NOT echoed back in the result; the caller
135/// already owns them. To get the source merged with the synth
136/// transactions, use [`merge_with_padding`].
137pub 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    // Sort directives by date for processing
146    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                // Update inventories
157                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                // Store pending pad (replaces any existing pad for this account)
177                // Reset padded_currencies when a new pad is encountered
178                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                // Check if there's a pending pad for this account
190                // Use get_mut instead of remove - a pad can apply to multiple currencies
191                if let Some(pending) = pending_pads.get_mut(&bal.account) {
192                    // Only pad if this currency hasn't been padded yet for this pad directive
193                    // (each currency can only be padded once per pad)
194                    if pending.padded_currencies.contains(&bal.amount.currency) {
195                        continue;
196                    }
197
198                    // Calculate padding amount
199                    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                        // Generate synthetic transaction
207                        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, // target balance for narration
213                        );
214
215                        // Apply to inventories
216                        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                    // Mark the pad as used and track that this currency has been padded
233                    pending.used = true;
234                    pending
235                        .padded_currencies
236                        .insert(bal.amount.currency.clone());
237                }
238                // If no pending pad, nothing to do (balance will be checked normally)
239            }
240
241            _ => {}
242        }
243    }
244
245    // Check for unused pads (pad without corresponding balance)
246    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
266/// Create a synthetic padding transaction.
267///
268/// The narration format matches Python beancount:
269/// `(Padding inserted for Balance of {balance} for difference {difference})`
270fn 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
291/// Merge original directives with padding transactions, maintaining date order.
292///
293/// Keeps the original pad directives and adds the synthesized
294/// transactions alongside them. Use this when downstream
295/// consumers want both views: `Pad` directives for source-faithful queries
296/// (e.g., BQL `WHERE type = 'pad'`) and the synth transactions for inventory
297/// math.
298///
299/// # Sort ordering on date ties
300///
301/// Synth transactions carry the pad's date, not the balance's date.
302/// On a same-date pad+balance pair (legal in beancount), the synth must
303/// appear BEFORE the balance so any consumer that checks balance assertions
304/// mid-stream sees the correct inventory. This is achieved by prepending
305/// the synth list to the original directives before the stable sort:
306/// synths land at the front of their date-group, originals follow.
307///
308/// # Errors are discarded
309///
310/// [`process_pads`] can emit `PadError`s (e.g., unused-pad warnings).
311/// `merge_with_padding` discards them by design: those diagnostics are the
312/// validator's responsibility (`E2003`). If you need them, call
313/// [`process_pads`] directly and inspect `result.errors`.
314///
315/// # Not idempotent
316///
317/// Re-running `merge_with_padding` on its own output double-counts pad
318/// effects because the original `Pad` directives survive and `process_pads`
319/// re-applies them against an inventory that already includes the prior
320/// synth. A `debug_assert!` guards against this in dev builds.
321pub 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    // Prepend synths so stable sort puts them BEFORE same-date originals.
333    // On a same-date pad+balance pair, the order is `[synth, pad, balance]`
334    // post-sort (synths start at the front of their date-group). This is
335    // important for any consumer that runs balance-assertion checks
336    // mid-stream against the merged view.
337    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        // Check target posting
382        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        // Check source posting
389        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        // Should pad 500.00 (1000 target - 500 existing)
428        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        // Should pad -1000.00 (1000 target - 2000 existing)
466        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        // No padding transaction needed when balance already matches
501        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            // Pad without balance assertion
510            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        // Should have: 2 opens + 1 pad + 1 balance + 1 synthetic = 5
539        assert_eq!(merged.len(), 5);
540
541        // Pad should still be there
542        let has_pad = merged.iter().any(|d| matches!(d, Directive::Pad(_)));
543        assert!(has_pad, "Pad should be preserved");
544
545        // Should also have the synthetic transaction
546        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        // A user-written `P`-flag transaction with arbitrary narration
576        // must NOT be classified as a synth pad. `P` is a valid user
577        // flag in beancount; bare flag-checking would conflate them.
578        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        // Pad and balance share the same date. The synth (which carries
590        // the pad's date) must appear BEFORE the Balance in the merged
591        // view so any mid-stream balance-assertion check sees the
592        // correct inventory.
593        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        // Find indices of the synth and the Balance.
607        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        // Calling merge_with_padding twice would double-count pad
625        // effects (original Pads survive in the output and would be
626        // re-applied against an inventory that already includes the
627        // prior synth). A debug_assert in dev builds guards against
628        // this caller mistake.
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::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); // should panic
641    }
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        // From basic.beancount:
665        // 2007-12-30 pad  Assets:Cash  Equity:Opening-Balances
666        // 2007-12-31 balance  Assets:Cash  200 CAD
667        // 2007-12-31 balance  Assets:Cash  300 USD
668        //
669        // A single pad should generate padding for BOTH currencies
670        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        // Check that we have both currencies padded
700        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        // Once a transaction affects the account after the balance assertions,
715        // the pad should no longer apply to later balance assertions
716        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            // Transaction after balance - this "consumes" the pad
727            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            // This balance should NOT use the pad (too late)
739            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        // Should only generate one padding transaction (for the first balance)
749        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}