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                        span: None,
135                    },
136                    PostingData {
137                        account: "Expenses:Unknown".to_string(),
138                        units: None,
139                        cost: None,
140                        price: None,
141                        flag: None,
142                        metadata: vec![],
143                        span: None,
144                    },
145                ],
146            }),
147        }
148    }
149
150    #[test]
151    fn reconcile_matches() {
152        let directives = vec![
153            make_txn("2024-01-15", "Assets:Checking", "-50.00", "USD"),
154            make_txn("2024-01-16", "Assets:Checking", "-30.00", "USD"),
155            make_txn("2024-01-17", "Assets:Checking", "100.00", "USD"),
156        ];
157        let balance = StatementBalance {
158            date: "2024-01-31".to_string(),
159            account: "Assets:Checking".to_string(),
160            number: Decimal::new(102_000, 2), // 1020.00 (opening 1000 + 20 net)
161            currency: "USD".to_string(),
162        };
163        let result = reconcile(&directives, &balance, Some(Decimal::new(100_000, 2)));
164        assert!(result.matches);
165        assert_eq!(result.difference, Decimal::ZERO);
166    }
167
168    #[test]
169    fn reconcile_mismatch() {
170        let directives = vec![make_txn("2024-01-15", "Assets:Checking", "-50.00", "USD")];
171        let balance = StatementBalance {
172            date: "2024-01-31".to_string(),
173            account: "Assets:Checking".to_string(),
174            number: Decimal::new(100_000, 2), // 1000.00
175            currency: "USD".to_string(),
176        };
177        // Opening 1000, spent 50, should be 950 but statement says 1000
178        let result = reconcile(&directives, &balance, Some(Decimal::new(100_000, 2)));
179        assert!(!result.matches);
180        assert_eq!(result.difference, Decimal::new(5000, 2)); // 50.00
181    }
182
183    #[test]
184    fn reconcile_no_opening_balance() {
185        let directives = vec![
186            make_txn("2024-01-15", "Assets:Checking", "-50.00", "USD"),
187            make_txn("2024-01-16", "Assets:Checking", "100.00", "USD"),
188        ];
189        let balance = StatementBalance {
190            date: "2024-01-31".to_string(),
191            account: "Assets:Checking".to_string(),
192            number: Decimal::new(5000, 2), // 50.00
193            currency: "USD".to_string(),
194        };
195        let result = reconcile(&directives, &balance, None);
196        assert!(result.matches);
197    }
198
199    #[test]
200    fn reconcile_ignores_other_accounts() {
201        let directives = vec![
202            make_txn("2024-01-15", "Assets:Checking", "-50.00", "USD"),
203            make_txn("2024-01-15", "Assets:Savings", "50.00", "USD"),
204        ];
205        let balance = StatementBalance {
206            date: "2024-01-31".to_string(),
207            account: "Assets:Checking".to_string(),
208            number: Decimal::new(-5000, 2), // -50.00
209            currency: "USD".to_string(),
210        };
211        let result = reconcile(&directives, &balance, None);
212        assert!(result.matches);
213    }
214
215    #[test]
216    fn balance_directive_created() {
217        let balance = StatementBalance {
218            date: "2024-01-31".to_string(),
219            account: "Assets:Checking".to_string(),
220            number: Decimal::new(100_000, 2),
221            currency: "USD".to_string(),
222        };
223        let directive = create_balance_directive(&balance);
224        assert_eq!(directive.date, "2024-01-31");
225        if let DirectiveData::Balance(b) = &directive.data {
226            assert_eq!(b.account, "Assets:Checking");
227            assert_eq!(b.amount.number, "1000.00");
228            assert_eq!(b.amount.currency, "USD");
229        } else {
230            panic!("Expected Balance directive");
231        }
232    }
233
234    #[test]
235    fn balance_directive_has_metadata() {
236        let balance = StatementBalance {
237            date: "2024-01-31".to_string(),
238            account: "Assets:Checking".to_string(),
239            number: Decimal::new(100_000, 2),
240            currency: "USD".to_string(),
241        };
242        let directive = create_balance_directive(&balance);
243        if let DirectiveData::Balance(b) = &directive.data {
244            assert!(b.metadata.iter().any(|(k, _)| k == "import-reconcile"));
245        }
246    }
247}