Skip to main content

rustledger_ops/
reconcile.rs

1//! Balance reconciliation.
2//!
3//! Compares imported transactions against a known statement ending balance
4//! to verify that all transactions were captured correctly. Generates
5//! balance assertion directives for the ledger.
6
7use rust_decimal::Decimal;
8use rustledger_plugin_types::{
9    AmountData, BalanceData, DirectiveData, DirectiveWrapper, MetaValueData,
10};
11use std::str::FromStr;
12
13/// A balance point extracted from a bank statement.
14#[derive(Debug, Clone)]
15pub struct StatementBalance {
16    /// Date of the balance (usually end of statement period).
17    pub date: String,
18    /// Account this balance applies to.
19    pub account: String,
20    /// The balance amount.
21    pub number: Decimal,
22    /// Currency.
23    pub currency: String,
24}
25
26/// Result of reconciling transactions against a statement balance.
27#[derive(Debug)]
28pub struct ReconciliationResult {
29    /// Whether the computed balance matches the statement balance.
30    pub matches: bool,
31    /// The expected balance (from the statement).
32    pub expected: Decimal,
33    /// The computed balance (sum of all transaction postings for the account).
34    pub computed: Decimal,
35    /// The difference (expected - computed).
36    pub difference: Decimal,
37    /// A balance assertion directive to add to the ledger.
38    pub balance_directive: DirectiveWrapper,
39}
40
41/// Reconcile imported transactions against a statement ending balance.
42///
43/// Computes the sum of all postings to the specified account and compares
44/// against the expected ending balance. Returns the result including a
45/// balance assertion directive that can be appended to the ledger.
46///
47/// `opening_balance` is the account balance before the imported transactions
48/// (if known). If `None`, only the transaction total is compared.
49#[must_use]
50pub fn reconcile(
51    directives: &[DirectiveWrapper],
52    ending_balance: &StatementBalance,
53    opening_balance: Option<Decimal>,
54) -> ReconciliationResult {
55    let mut total = opening_balance.unwrap_or(Decimal::ZERO);
56
57    for d in directives {
58        if let DirectiveData::Transaction(txn) = &d.data {
59            for posting in &txn.postings {
60                if posting.account == ending_balance.account
61                    && let Some(units) = &posting.units
62                    && units.currency == ending_balance.currency
63                    && let Ok(amount) = Decimal::from_str(&units.number)
64                {
65                    total += amount;
66                }
67            }
68        }
69    }
70
71    let difference = ending_balance.number - total;
72    let matches = difference.abs() < Decimal::new(1, 2); // Within 0.01
73
74    let balance_directive = create_balance_directive(ending_balance);
75
76    ReconciliationResult {
77        matches,
78        expected: ending_balance.number,
79        computed: total,
80        difference,
81        balance_directive,
82    }
83}
84
85/// Create a balance assertion directive from a statement balance.
86#[must_use]
87pub fn create_balance_directive(balance: &StatementBalance) -> DirectiveWrapper {
88    DirectiveWrapper {
89        directive_type: "balance".to_string(),
90        date: balance.date.clone(),
91        filename: Some("<import-reconcile>".to_string()),
92        lineno: None,
93        data: DirectiveData::Balance(BalanceData {
94            account: balance.account.clone(),
95            amount: AmountData {
96                number: balance.number.to_string(),
97                currency: balance.currency.clone(),
98            },
99            tolerance: None,
100            metadata: vec![("import-reconcile".to_string(), MetaValueData::Bool(true))],
101        }),
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use rustledger_plugin_types::{PostingData, TransactionData};
109
110    fn make_txn(date: &str, account: &str, amount: &str, currency: &str) -> DirectiveWrapper {
111        DirectiveWrapper {
112            directive_type: "transaction".to_string(),
113            date: date.to_string(),
114            filename: None,
115            lineno: None,
116            data: DirectiveData::Transaction(TransactionData {
117                flag: "*".to_string(),
118                payee: None,
119                narration: "Test".to_string(),
120                tags: vec![],
121                links: vec![],
122                metadata: vec![],
123                postings: vec![
124                    PostingData {
125                        account: account.to_string(),
126                        units: Some(AmountData {
127                            number: amount.to_string(),
128                            currency: currency.to_string(),
129                        }),
130                        cost: None,
131                        price: None,
132                        flag: None,
133                        metadata: vec![],
134                    },
135                    PostingData {
136                        account: "Expenses:Unknown".to_string(),
137                        units: None,
138                        cost: None,
139                        price: None,
140                        flag: None,
141                        metadata: vec![],
142                    },
143                ],
144            }),
145        }
146    }
147
148    #[test]
149    fn reconcile_matches() {
150        let directives = vec![
151            make_txn("2024-01-15", "Assets:Checking", "-50.00", "USD"),
152            make_txn("2024-01-16", "Assets:Checking", "-30.00", "USD"),
153            make_txn("2024-01-17", "Assets:Checking", "100.00", "USD"),
154        ];
155        let balance = StatementBalance {
156            date: "2024-01-31".to_string(),
157            account: "Assets:Checking".to_string(),
158            number: Decimal::new(102_000, 2), // 1020.00 (opening 1000 + 20 net)
159            currency: "USD".to_string(),
160        };
161        let result = reconcile(&directives, &balance, Some(Decimal::new(100_000, 2)));
162        assert!(result.matches);
163        assert_eq!(result.difference, Decimal::ZERO);
164    }
165
166    #[test]
167    fn reconcile_mismatch() {
168        let directives = vec![make_txn("2024-01-15", "Assets:Checking", "-50.00", "USD")];
169        let balance = StatementBalance {
170            date: "2024-01-31".to_string(),
171            account: "Assets:Checking".to_string(),
172            number: Decimal::new(100_000, 2), // 1000.00
173            currency: "USD".to_string(),
174        };
175        // Opening 1000, spent 50, should be 950 but statement says 1000
176        let result = reconcile(&directives, &balance, Some(Decimal::new(100_000, 2)));
177        assert!(!result.matches);
178        assert_eq!(result.difference, Decimal::new(5000, 2)); // 50.00
179    }
180
181    #[test]
182    fn reconcile_no_opening_balance() {
183        let directives = vec![
184            make_txn("2024-01-15", "Assets:Checking", "-50.00", "USD"),
185            make_txn("2024-01-16", "Assets:Checking", "100.00", "USD"),
186        ];
187        let balance = StatementBalance {
188            date: "2024-01-31".to_string(),
189            account: "Assets:Checking".to_string(),
190            number: Decimal::new(5000, 2), // 50.00
191            currency: "USD".to_string(),
192        };
193        let result = reconcile(&directives, &balance, None);
194        assert!(result.matches);
195    }
196
197    #[test]
198    fn reconcile_ignores_other_accounts() {
199        let directives = vec![
200            make_txn("2024-01-15", "Assets:Checking", "-50.00", "USD"),
201            make_txn("2024-01-15", "Assets:Savings", "50.00", "USD"),
202        ];
203        let balance = StatementBalance {
204            date: "2024-01-31".to_string(),
205            account: "Assets:Checking".to_string(),
206            number: Decimal::new(-5000, 2), // -50.00
207            currency: "USD".to_string(),
208        };
209        let result = reconcile(&directives, &balance, None);
210        assert!(result.matches);
211    }
212
213    #[test]
214    fn balance_directive_created() {
215        let balance = StatementBalance {
216            date: "2024-01-31".to_string(),
217            account: "Assets:Checking".to_string(),
218            number: Decimal::new(100_000, 2),
219            currency: "USD".to_string(),
220        };
221        let directive = create_balance_directive(&balance);
222        assert_eq!(directive.date, "2024-01-31");
223        if let DirectiveData::Balance(b) = &directive.data {
224            assert_eq!(b.account, "Assets:Checking");
225            assert_eq!(b.amount.number, "1000.00");
226            assert_eq!(b.amount.currency, "USD");
227        } else {
228            panic!("Expected Balance directive");
229        }
230    }
231
232    #[test]
233    fn balance_directive_has_metadata() {
234        let balance = StatementBalance {
235            date: "2024-01-31".to_string(),
236            account: "Assets:Checking".to_string(),
237            number: Decimal::new(100_000, 2),
238            currency: "USD".to_string(),
239        };
240        let directive = create_balance_directive(&balance);
241        if let DirectiveData::Balance(b) = &directive.data {
242            assert!(b.metadata.iter().any(|(k, _)| k == "import-reconcile"));
243        }
244    }
245}