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