Skip to main content

rustledger_plugin/native/plugins/
check_drained.rs

1//! Zero balance assertion on balance sheet account close.
2
3use crate::types::{DirectiveData, DirectiveWrapper, PluginInput, PluginOutput, sort_directives};
4
5use super::super::NativePlugin;
6use super::utils::increment_date;
7
8/// Plugin that inserts zero balance assertions when balance sheet accounts are closed.
9///
10/// When a Close directive is encountered for an account (Assets, Liabilities, or Equity),
11/// this plugin generates Balance directives with zero amounts for all currencies that
12/// were used in that account. The assertions are dated one day after the close date.
13pub struct CheckDrainedPlugin;
14
15impl NativePlugin for CheckDrainedPlugin {
16    fn name(&self) -> &'static str {
17        "check_drained"
18    }
19
20    fn description(&self) -> &'static str {
21        "Zero balance assertion on balance sheet account close"
22    }
23
24    fn process(&self, input: PluginInput) -> PluginOutput {
25        use crate::types::{AmountData, BalanceData};
26        use std::collections::{HashMap, HashSet};
27
28        // Track currencies used per account
29        let mut account_currencies: HashMap<String, HashSet<String>> = HashMap::new();
30
31        // First pass: collect all currencies used per account
32        for wrapper in &input.directives {
33            match &wrapper.data {
34                DirectiveData::Transaction(txn) => {
35                    for posting in &txn.postings {
36                        if let Some(units) = &posting.units {
37                            account_currencies
38                                .entry(posting.account.clone())
39                                .or_default()
40                                .insert(units.currency.clone());
41                        }
42                    }
43                }
44                DirectiveData::Balance(data) => {
45                    account_currencies
46                        .entry(data.account.clone())
47                        .or_default()
48                        .insert(data.amount.currency.clone());
49                }
50                DirectiveData::Open(data) => {
51                    // If Open has currencies, track them
52                    for currency in &data.currencies {
53                        account_currencies
54                            .entry(data.account.clone())
55                            .or_default()
56                            .insert(currency.clone());
57                    }
58                }
59                _ => {}
60            }
61        }
62
63        // Second pass: generate balance assertions for closed balance sheet accounts
64        let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
65
66        for wrapper in &input.directives {
67            new_directives.push(wrapper.clone());
68
69            if let DirectiveData::Close(data) = &wrapper.data {
70                // Only generate for balance sheet accounts (Assets, Liabilities, Equity)
71                let is_balance_sheet = data.account.starts_with("Assets:")
72                    || data.account.starts_with("Liabilities:")
73                    || data.account.starts_with("Equity:")
74                    || data.account == "Assets"
75                    || data.account == "Liabilities"
76                    || data.account == "Equity";
77
78                if !is_balance_sheet {
79                    continue;
80                }
81
82                // Get currencies for this account
83                if let Some(currencies) = account_currencies.get(&data.account) {
84                    // Calculate the day after close
85                    if let Some(next_date) = increment_date(&wrapper.date) {
86                        // Generate zero balance assertion for each currency
87                        let mut sorted_currencies: Vec<_> = currencies.iter().collect();
88                        sorted_currencies.sort(); // Consistent ordering
89
90                        for currency in sorted_currencies {
91                            new_directives.push(DirectiveWrapper {
92                                directive_type: "balance".to_string(),
93                                date: next_date.clone(),
94                                filename: None, // Plugin-generated
95                                lineno: None,
96                                data: DirectiveData::Balance(BalanceData {
97                                    account: data.account.clone(),
98                                    amount: AmountData {
99                                        number: "0".to_string(),
100                                        currency: currency.clone(),
101                                    },
102                                    tolerance: None,
103                                    metadata: vec![],
104                                }),
105                            });
106                        }
107                    }
108                }
109            }
110        }
111
112        // Sort using beancount's standard ordering
113        sort_directives(&mut new_directives);
114
115        PluginOutput {
116            directives: new_directives,
117            errors: Vec::new(),
118        }
119    }
120}
121
122#[cfg(test)]
123mod check_drained_tests {
124    use super::*;
125    use crate::types::*;
126
127    #[test]
128    fn test_check_drained_adds_balance_assertion() {
129        let plugin = CheckDrainedPlugin;
130
131        let input = PluginInput {
132            directives: vec![
133                DirectiveWrapper {
134                    directive_type: "open".to_string(),
135                    date: "2024-01-01".to_string(),
136                    filename: None,
137                    lineno: None,
138                    data: DirectiveData::Open(OpenData {
139                        account: "Assets:Bank".to_string(),
140                        currencies: vec!["USD".to_string()],
141                        booking: None,
142                        metadata: vec![],
143                    }),
144                },
145                DirectiveWrapper {
146                    directive_type: "transaction".to_string(),
147                    date: "2024-06-15".to_string(),
148                    filename: None,
149                    lineno: None,
150                    data: DirectiveData::Transaction(TransactionData {
151                        flag: "*".to_string(),
152                        payee: None,
153                        narration: "Deposit".to_string(),
154                        tags: vec![],
155                        links: vec![],
156                        metadata: vec![],
157                        postings: vec![PostingData {
158                            account: "Assets:Bank".to_string(),
159                            units: Some(AmountData {
160                                number: "100".to_string(),
161                                currency: "USD".to_string(),
162                            }),
163                            cost: None,
164                            price: None,
165                            flag: None,
166                            metadata: vec![],
167                        }],
168                    }),
169                },
170                DirectiveWrapper {
171                    directive_type: "close".to_string(),
172                    date: "2024-12-31".to_string(),
173                    filename: None,
174                    lineno: None,
175                    data: DirectiveData::Close(CloseData {
176                        account: "Assets:Bank".to_string(),
177                        metadata: vec![],
178                    }),
179                },
180            ],
181            options: PluginOptions {
182                operating_currencies: vec!["USD".to_string()],
183                title: None,
184            },
185            config: None,
186        };
187
188        let output = plugin.process(input);
189        assert_eq!(output.errors.len(), 0);
190
191        // Should have 4 directives: open, transaction, close, balance
192        assert_eq!(output.directives.len(), 4);
193
194        // Find the balance directive
195        let balance = output
196            .directives
197            .iter()
198            .find(|d| d.directive_type == "balance")
199            .expect("Should have balance directive");
200
201        assert_eq!(balance.date, "2025-01-01"); // Day after close
202        if let DirectiveData::Balance(b) = &balance.data {
203            assert_eq!(b.account, "Assets:Bank");
204            assert_eq!(b.amount.number, "0");
205            assert_eq!(b.amount.currency, "USD");
206        } else {
207            panic!("Expected Balance directive");
208        }
209    }
210
211    #[test]
212    fn test_check_drained_ignores_income_expense() {
213        let plugin = CheckDrainedPlugin;
214
215        let input = PluginInput {
216            directives: vec![
217                DirectiveWrapper {
218                    directive_type: "open".to_string(),
219                    date: "2024-01-01".to_string(),
220                    filename: None,
221                    lineno: None,
222                    data: DirectiveData::Open(OpenData {
223                        account: "Income:Salary".to_string(),
224                        currencies: vec!["USD".to_string()],
225                        booking: None,
226                        metadata: vec![],
227                    }),
228                },
229                DirectiveWrapper {
230                    directive_type: "close".to_string(),
231                    date: "2024-12-31".to_string(),
232                    filename: None,
233                    lineno: None,
234                    data: DirectiveData::Close(CloseData {
235                        account: "Income:Salary".to_string(),
236                        metadata: vec![],
237                    }),
238                },
239            ],
240            options: PluginOptions {
241                operating_currencies: vec!["USD".to_string()],
242                title: None,
243            },
244            config: None,
245        };
246
247        let output = plugin.process(input);
248        // Should not add balance assertions for income/expense accounts
249        assert_eq!(output.directives.len(), 2);
250        assert!(
251            !output
252                .directives
253                .iter()
254                .any(|d| d.directive_type == "balance")
255        );
256    }
257
258    #[test]
259    fn test_check_drained_multiple_currencies() {
260        let plugin = CheckDrainedPlugin;
261
262        let input = PluginInput {
263            directives: vec![
264                DirectiveWrapper {
265                    directive_type: "open".to_string(),
266                    date: "2024-01-01".to_string(),
267                    filename: None,
268                    lineno: None,
269                    data: DirectiveData::Open(OpenData {
270                        account: "Assets:Bank".to_string(),
271                        currencies: vec![],
272                        booking: None,
273                        metadata: vec![],
274                    }),
275                },
276                DirectiveWrapper {
277                    directive_type: "transaction".to_string(),
278                    date: "2024-06-15".to_string(),
279                    filename: None,
280                    lineno: None,
281                    data: DirectiveData::Transaction(TransactionData {
282                        flag: "*".to_string(),
283                        payee: None,
284                        narration: "USD Deposit".to_string(),
285                        tags: vec![],
286                        links: vec![],
287                        metadata: vec![],
288                        postings: vec![PostingData {
289                            account: "Assets:Bank".to_string(),
290                            units: Some(AmountData {
291                                number: "100".to_string(),
292                                currency: "USD".to_string(),
293                            }),
294                            cost: None,
295                            price: None,
296                            flag: None,
297                            metadata: vec![],
298                        }],
299                    }),
300                },
301                DirectiveWrapper {
302                    directive_type: "transaction".to_string(),
303                    date: "2024-07-15".to_string(),
304                    filename: None,
305                    lineno: None,
306                    data: DirectiveData::Transaction(TransactionData {
307                        flag: "*".to_string(),
308                        payee: None,
309                        narration: "EUR Deposit".to_string(),
310                        tags: vec![],
311                        links: vec![],
312                        metadata: vec![],
313                        postings: vec![PostingData {
314                            account: "Assets:Bank".to_string(),
315                            units: Some(AmountData {
316                                number: "50".to_string(),
317                                currency: "EUR".to_string(),
318                            }),
319                            cost: None,
320                            price: None,
321                            flag: None,
322                            metadata: vec![],
323                        }],
324                    }),
325                },
326                DirectiveWrapper {
327                    directive_type: "close".to_string(),
328                    date: "2024-12-31".to_string(),
329                    filename: None,
330                    lineno: None,
331                    data: DirectiveData::Close(CloseData {
332                        account: "Assets:Bank".to_string(),
333                        metadata: vec![],
334                    }),
335                },
336            ],
337            options: PluginOptions {
338                operating_currencies: vec!["USD".to_string()],
339                title: None,
340            },
341            config: None,
342        };
343
344        let output = plugin.process(input);
345        // Should have 6 directives: open, 2 transactions, close, 2 balance assertions
346        assert_eq!(output.directives.len(), 6);
347
348        let balances: Vec<_> = output
349            .directives
350            .iter()
351            .filter(|d| d.directive_type == "balance")
352            .collect();
353        assert_eq!(balances.len(), 2);
354
355        // Both should be dated 2025-01-01
356        for b in &balances {
357            assert_eq!(b.date, "2025-01-01");
358        }
359    }
360}