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, Spanned, 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 =
162                            Position::from_posting(units, posting.cost.as_ref(), txn.date);
163                        inv.add(position);
164                    }
165                }
166            }
167
168            Directive::Pad(pad) => {
169                // Store pending pad (replaces any existing pad for this account)
170                // Reset padded_currencies when a new pad is encountered
171                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                // Check if there's a pending pad for this account
183                // Use get_mut instead of remove - a pad can apply to multiple currencies
184                if let Some(pending) = pending_pads.get_mut(&bal.account) {
185                    // Only pad if this currency hasn't been padded yet for this pad directive
186                    // (each currency can only be padded once per pad)
187                    if pending.padded_currencies.contains(&bal.amount.currency) {
188                        continue;
189                    }
190
191                    // Calculate padding amount. The balance assertion this pad
192                    // targets sums the account AND its sub-accounts (beancount
193                    // semantic, verified against bean-check), so the pad
194                    // difference must be measured the same way — using only the
195                    // leaf account here under-/over-padded a non-leaf target and
196                    // then tripped the (sub-account-summing) Late validator.
197                    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                        // 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/// Span-preserving variant of [`merge_with_padding`].
350///
351/// Identical merge behavior, but the input/output keep each directive's
352/// [`Spanned`] wrapper so downstream consumers (e.g. BQL's `filename`/`lineno`
353/// columns) can resolve real source locations. Pad-synthesized transactions
354/// have no source representation, so they are wrapped with
355/// [`Spanned::synthesized`] ([`Span::ZERO`](rustledger_core::Span) +
356/// [`SYNTHESIZED_FILE_ID`](rustledger_core::SYNTHESIZED_FILE_ID)) — exactly how
357/// other synthesized directives (plugin output, etc.) are marked.
358///
359/// # Not idempotent
360///
361/// Same caveat as [`merge_with_padding`]: re-running on its own output
362/// double-counts pad effects.
363#[must_use]
364pub fn merge_with_padding_spanned(directives: &[Spanned<Directive>]) -> Vec<Spanned<Directive>> {
365    let plain: Vec<Directive> = directives.iter().map(|s| s.value.clone()).collect();
366    debug_assert!(
367        !plain
368            .iter()
369            .any(|d| matches!(d, Directive::Transaction(t) if is_synthesized_pad(t))),
370        "merge_with_padding_spanned called on input that already contains synth pad transactions; \
371         re-running would double-count pad effects",
372    );
373
374    let result = process_pads(&plain);
375
376    // Prepend synth transactions (same ordering rationale as the plain variant)
377    // and mark them as synthesized so they resolve to no source location.
378    let mut merged: Vec<Spanned<Directive>> =
379        Vec::with_capacity(directives.len() + result.padding_transactions.len());
380    for txn in result.padding_transactions {
381        merged.push(Spanned::synthesized(Directive::Transaction(txn)));
382    }
383    merged.extend(directives.iter().cloned());
384
385    merged.sort_by_key(|s| s.value.date());
386
387    merged
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use rust_decimal_macros::dec;
394    use rustledger_core::{Balance, Open};
395
396    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
397        rustledger_core::naive_date(year, month, day).unwrap()
398    }
399
400    #[test]
401    fn test_process_pads_basic() {
402        let directives = vec![
403            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
404            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
405            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
406            Directive::Balance(Balance::new(
407                date(2024, 1, 2),
408                "Assets:Bank",
409                Amount::new(dec!(1000.00), "USD"),
410            )),
411        ];
412
413        let result = process_pads(&directives);
414
415        assert!(result.errors.is_empty());
416        assert_eq!(result.padding_transactions.len(), 1);
417
418        let txn = &result.padding_transactions[0];
419        assert_eq!(txn.date, date(2024, 1, 1));
420        assert_eq!(txn.postings.len(), 2);
421
422        // Check target posting
423        assert_eq!(txn.postings[0].account, "Assets:Bank");
424        assert_eq!(
425            txn.postings[0].amount(),
426            Some(&Amount::new(dec!(1000.00), "USD"))
427        );
428
429        // Check source posting
430        assert_eq!(txn.postings[1].account, "Equity:Opening");
431        assert_eq!(
432            txn.postings[1].amount(),
433            Some(&Amount::new(dec!(-1000.00), "USD"))
434        );
435    }
436
437    #[test]
438    fn test_process_pads_with_existing_balance() {
439        let directives = vec![
440            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
441            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
442            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
443            Directive::Transaction(
444                Transaction::new(date(2024, 1, 5), "Deposit")
445                    .with_synthesized_posting(Posting::new(
446                        "Assets:Bank",
447                        Amount::new(dec!(500.00), "USD"),
448                    ))
449                    .with_synthesized_posting(Posting::new(
450                        "Income:Salary",
451                        Amount::new(dec!(-500.00), "USD"),
452                    )),
453            ),
454            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
455            Directive::Balance(Balance::new(
456                date(2024, 1, 15),
457                "Assets:Bank",
458                Amount::new(dec!(1000.00), "USD"),
459            )),
460        ];
461
462        let result = process_pads(&directives);
463
464        assert!(result.errors.is_empty());
465        assert_eq!(result.padding_transactions.len(), 1);
466
467        let txn = &result.padding_transactions[0];
468        // Should pad 500.00 (1000 target - 500 existing)
469        assert_eq!(
470            txn.postings[0].amount(),
471            Some(&Amount::new(dec!(500.00), "USD"))
472        );
473    }
474
475    #[test]
476    fn test_process_pads_sums_subaccounts_for_nonleaf_target() {
477        // A pad targeting a NON-LEAF account must measure the current balance the
478        // same way the balance assertion does — summing the account AND its
479        // sub-accounts (beancount semantic, verified against bean-check). Here the
480        // balance lives entirely in the sub-account `Assets:Bank:Checking`, so the
481        // pad to `Assets:Bank` must be 100 - 50 = 50, NOT 100 (the old leaf-only
482        // bug, which then tripped the sub-account-summing Late validator).
483        let directives = vec![
484            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
485            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:Checking")),
486            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
487            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
488            Directive::Transaction(
489                Transaction::new(date(2024, 1, 5), "Deposit into sub-account")
490                    .with_synthesized_posting(Posting::new(
491                        "Assets:Bank:Checking",
492                        Amount::new(dec!(50.00), "USD"),
493                    ))
494                    .with_synthesized_posting(Posting::new(
495                        "Income:Salary",
496                        Amount::new(dec!(-50.00), "USD"),
497                    )),
498            ),
499            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
500            Directive::Balance(Balance::new(
501                date(2024, 1, 15),
502                "Assets:Bank",
503                Amount::new(dec!(100.00), "USD"),
504            )),
505        ];
506
507        let result = process_pads(&directives);
508
509        assert!(result.errors.is_empty());
510        assert_eq!(result.padding_transactions.len(), 1);
511        // 100 target - 50 already held in the sub-account = 50.
512        assert_eq!(
513            result.padding_transactions[0].postings[0].amount(),
514            Some(&Amount::new(dec!(50.00), "USD")),
515            "pad on a non-leaf account must sum sub-accounts (was leaf-only)"
516        );
517    }
518
519    #[test]
520    fn test_process_pads_negative_adjustment() {
521        let directives = vec![
522            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
523            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
524            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
525            Directive::Transaction(
526                Transaction::new(date(2024, 1, 5), "Big deposit")
527                    .with_synthesized_posting(Posting::new(
528                        "Assets:Bank",
529                        Amount::new(dec!(2000.00), "USD"),
530                    ))
531                    .with_synthesized_posting(Posting::new(
532                        "Income:Salary",
533                        Amount::new(dec!(-2000.00), "USD"),
534                    )),
535            ),
536            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
537            Directive::Balance(Balance::new(
538                date(2024, 1, 15),
539                "Assets:Bank",
540                Amount::new(dec!(1000.00), "USD"),
541            )),
542        ];
543
544        let result = process_pads(&directives);
545
546        assert!(result.errors.is_empty());
547        assert_eq!(result.padding_transactions.len(), 1);
548
549        let txn = &result.padding_transactions[0];
550        // Should pad -1000.00 (1000 target - 2000 existing)
551        assert_eq!(
552            txn.postings[0].amount(),
553            Some(&Amount::new(dec!(-1000.00), "USD"))
554        );
555    }
556
557    #[test]
558    fn test_process_pads_no_difference() {
559        let directives = vec![
560            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
561            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
562            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
563            Directive::Transaction(
564                Transaction::new(date(2024, 1, 5), "Exact deposit")
565                    .with_synthesized_posting(Posting::new(
566                        "Assets:Bank",
567                        Amount::new(dec!(1000.00), "USD"),
568                    ))
569                    .with_synthesized_posting(Posting::new(
570                        "Income:Salary",
571                        Amount::new(dec!(-1000.00), "USD"),
572                    )),
573            ),
574            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
575            Directive::Balance(Balance::new(
576                date(2024, 1, 15),
577                "Assets:Bank",
578                Amount::new(dec!(1000.00), "USD"),
579            )),
580        ];
581
582        let result = process_pads(&directives);
583
584        assert!(result.errors.is_empty());
585        // No padding transaction needed when balance already matches
586        assert!(result.padding_transactions.is_empty());
587    }
588
589    #[test]
590    fn test_process_pads_unused_pad() {
591        let directives = vec![
592            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
593            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
594            // Pad without balance assertion
595            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
596        ];
597
598        let result = process_pads(&directives);
599
600        assert_eq!(result.errors.len(), 1);
601        assert!(
602            result.errors[0]
603                .message
604                .contains("no corresponding balance")
605        );
606    }
607
608    #[test]
609    fn test_merge_with_padding() {
610        let directives = vec![
611            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
612            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
613            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
614            Directive::Balance(Balance::new(
615                date(2024, 1, 2),
616                "Assets:Bank",
617                Amount::new(dec!(1000.00), "USD"),
618            )),
619        ];
620
621        let merged = merge_with_padding(&directives);
622
623        // Should have: 2 opens + 1 pad + 1 balance + 1 synthetic = 5
624        assert_eq!(merged.len(), 5);
625
626        // Pad should still be there
627        let has_pad = merged.iter().any(|d| matches!(d, Directive::Pad(_)));
628        assert!(has_pad, "Pad should be preserved");
629
630        // Should also have the synthetic transaction
631        let txn_count = merged
632            .iter()
633            .filter(|d| matches!(d, Directive::Transaction(_)))
634            .count();
635        assert_eq!(txn_count, 1);
636    }
637
638    #[test]
639    fn test_is_synthesized_pad_recognizes_synth() {
640        let directives = vec![
641            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
642            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
643            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
644            Directive::Balance(Balance::new(
645                date(2024, 1, 2),
646                "Assets:Bank",
647                Amount::new(dec!(1000), "USD"),
648            )),
649        ];
650        let result = process_pads(&directives);
651        let synth = result.padding_transactions.into_iter().next().unwrap();
652        assert!(
653            is_synthesized_pad(&synth),
654            "synth pad transaction must be detected by is_synthesized_pad",
655        );
656    }
657
658    #[test]
659    fn test_is_synthesized_pad_rejects_user_p_flag() {
660        // A user-written `P`-flag transaction with arbitrary narration
661        // must NOT be classified as a synth pad. `P` is a valid user
662        // flag in beancount; bare flag-checking would conflate them.
663        let user_p = Transaction::new(date(2024, 1, 1), "user-authored P-flag txn")
664            .with_flag('P')
665            .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")));
666        assert!(
667            !is_synthesized_pad(&user_p),
668            "user-written P-flag transaction must not be classified as synth",
669        );
670    }
671
672    #[test]
673    fn test_merge_with_padding_same_date_pad_balance_synth_comes_first() {
674        // Pad and balance share the same date. The synth (which carries
675        // the pad's date) must appear BEFORE the Balance in the merged
676        // view so any mid-stream balance-assertion check sees the
677        // correct inventory.
678        let directives = vec![
679            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
680            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
681            Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")),
682            Directive::Balance(Balance::new(
683                date(2024, 1, 2),
684                "Assets:Bank",
685                Amount::new(dec!(1000), "USD"),
686            )),
687        ];
688
689        let merged = merge_with_padding(&directives);
690
691        // Find indices of the synth and the Balance.
692        let synth_idx = merged
693            .iter()
694            .position(|d| matches!(d, Directive::Transaction(t) if is_synthesized_pad(t)))
695            .expect("synth present");
696        let balance_idx = merged
697            .iter()
698            .position(|d| matches!(d, Directive::Balance(_)))
699            .expect("balance present");
700        assert!(
701            synth_idx < balance_idx,
702            "synth pad (idx {synth_idx}) must appear before Balance (idx {balance_idx}) on same date",
703        );
704    }
705
706    #[test]
707    #[should_panic(expected = "merge_with_padding called on input that already contains synth")]
708    fn test_merge_with_padding_double_apply_debug_asserts() {
709        // Calling merge_with_padding twice would double-count pad
710        // effects (original Pads survive in the output and would be
711        // re-applied against an inventory that already includes the
712        // prior synth). A debug_assert in dev builds guards against
713        // this caller mistake.
714        let directives = vec![
715            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
716            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
717            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
718            Directive::Balance(Balance::new(
719                date(2024, 1, 2),
720                "Assets:Bank",
721                Amount::new(dec!(1000), "USD"),
722            )),
723        ];
724        let merged_once = merge_with_padding(&directives);
725        let _merged_twice = merge_with_padding(&merged_once); // should panic
726    }
727
728    #[test]
729    fn test_padding_transaction_has_p_flag() {
730        let directives = vec![
731            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
732            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
733            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
734            Directive::Balance(Balance::new(
735                date(2024, 1, 2),
736                "Assets:Bank",
737                Amount::new(dec!(1000.00), "USD"),
738            )),
739        ];
740
741        let result = process_pads(&directives);
742
743        assert_eq!(result.padding_transactions.len(), 1);
744        assert_eq!(result.padding_transactions[0].flag, 'P');
745    }
746
747    #[test]
748    fn test_process_pads_multiple_currencies() {
749        // From basic.beancount:
750        // 2007-12-30 pad  Assets:Cash  Equity:Opening-Balances
751        // 2007-12-31 balance  Assets:Cash  200 CAD
752        // 2007-12-31 balance  Assets:Cash  300 USD
753        //
754        // A single pad should generate padding for BOTH currencies
755        let directives = vec![
756            Directive::Open(Open::new(date(2007, 1, 1), "Assets:Cash")),
757            Directive::Open(Open::new(date(2007, 1, 1), "Equity:Opening")),
758            Directive::Pad(Pad::new(
759                date(2007, 12, 30),
760                "Assets:Cash",
761                "Equity:Opening",
762            )),
763            Directive::Balance(Balance::new(
764                date(2007, 12, 31),
765                "Assets:Cash",
766                Amount::new(dec!(200), "CAD"),
767            )),
768            Directive::Balance(Balance::new(
769                date(2007, 12, 31),
770                "Assets:Cash",
771                Amount::new(dec!(300), "USD"),
772            )),
773        ];
774
775        let result = process_pads(&directives);
776
777        assert!(result.errors.is_empty(), "Should have no errors");
778        assert_eq!(
779            result.padding_transactions.len(),
780            2,
781            "Should generate TWO padding transactions (one per currency)"
782        );
783
784        // Check that we have both currencies padded
785        let currencies: Vec<_> = result
786            .padding_transactions
787            .iter()
788            .filter_map(|txn| txn.postings.first())
789            .filter_map(|p| p.amount())
790            .map(|a| a.currency.as_str())
791            .collect();
792
793        assert!(currencies.contains(&"CAD"), "Should pad CAD");
794        assert!(currencies.contains(&"USD"), "Should pad USD");
795    }
796
797    #[test]
798    fn test_process_pads_transaction_after_balance_ends_pad() {
799        // Once a transaction affects the account after the balance assertions,
800        // the pad should no longer apply to later balance assertions
801        let directives = vec![
802            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
803            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
804            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
805            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
806            Directive::Balance(Balance::new(
807                date(2024, 1, 2),
808                "Assets:Bank",
809                Amount::new(dec!(1000), "USD"),
810            )),
811            // Transaction after balance - this "consumes" the pad
812            Directive::Transaction(
813                Transaction::new(date(2024, 1, 3), "Spending")
814                    .with_synthesized_posting(Posting::new(
815                        "Assets:Bank",
816                        Amount::new(dec!(-100), "USD"),
817                    ))
818                    .with_synthesized_posting(Posting::new(
819                        "Expenses:Food",
820                        Amount::new(dec!(100), "USD"),
821                    )),
822            ),
823            // This balance should NOT use the pad (too late)
824            Directive::Balance(Balance::new(
825                date(2024, 1, 5),
826                "Assets:Bank",
827                Amount::new(dec!(900), "USD"),
828            )),
829        ];
830
831        let result = process_pads(&directives);
832
833        // Should only generate one padding transaction (for the first balance)
834        assert_eq!(result.padding_transactions.len(), 1);
835        assert_eq!(
836            result.padding_transactions[0]
837                .postings
838                .first()
839                .and_then(|p| p.amount())
840                .map(|a| a.number),
841            Some(dec!(1000))
842        );
843    }
844}