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, PluginOp, PluginOutput};
4
5use super::super::{NativePlugin, RegularPlugin};
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 ops: Vec<PluginOp> = Vec::new();
65
66        for (i, wrapper) in input.directives.iter().enumerate() {
67            ops.push(PluginOp::Keep(i));
68
69            if let DirectiveData::Close(data) = &wrapper.data {
70                // Only generate for balance sheet accounts (Assets,
71                // Liabilities, Equity). `account_type` classifies by the
72                // root component, so both `Assets:...` and a bare `Assets`
73                // land in the same arm (the old hand-written check needed
74                // six clauses for that).
75                let is_balance_sheet = matches!(
76                    rustledger_core::account_type(&data.account),
77                    "assets" | "liabilities" | "equity"
78                );
79
80                if !is_balance_sheet {
81                    continue;
82                }
83
84                // Get currencies for this account
85                if let Some(currencies) = account_currencies.get(&data.account) {
86                    // Calculate the day after close
87                    if let Some(next_date) = increment_date(&wrapper.date) {
88                        // Generate zero balance assertion for each currency
89                        let mut sorted_currencies: Vec<_> = currencies.iter().collect();
90                        sorted_currencies.sort(); // Consistent ordering
91
92                        for currency in sorted_currencies {
93                            ops.push(PluginOp::Insert(DirectiveWrapper {
94                                directive_type: "balance".to_string(),
95                                date: next_date.clone(),
96                                filename: None, // Plugin-generated
97                                lineno: None,
98                                data: DirectiveData::Balance(BalanceData {
99                                    account: data.account.clone(),
100                                    amount: AmountData {
101                                        number: "0".to_string(),
102                                        currency: currency.clone(),
103                                    },
104                                    tolerance: None,
105                                    metadata: vec![],
106                                }),
107                            }));
108                        }
109                    }
110                }
111            }
112        }
113
114        // Final ordering is the loader's responsibility — it re-sorts
115        // directives after the plugin pass.
116        PluginOutput {
117            ops,
118            errors: Vec::new(),
119        }
120    }
121}
122
123impl RegularPlugin for CheckDrainedPlugin {}
124
125#[cfg(test)]
126mod check_drained_tests {
127    use super::super::utils::materialize_ops;
128    use super::*;
129    use crate::types::*;
130
131    #[test]
132    fn test_check_drained_adds_balance_assertion() {
133        let plugin = CheckDrainedPlugin;
134
135        let input = PluginInput {
136            directives: vec![
137                DirectiveWrapper {
138                    directive_type: "open".to_string(),
139                    date: "2024-01-01".to_string(),
140                    filename: None,
141                    lineno: None,
142                    data: DirectiveData::Open(OpenData {
143                        account: "Assets:Bank".to_string(),
144                        currencies: vec!["USD".to_string()],
145                        booking: None,
146                        metadata: vec![],
147                    }),
148                },
149                DirectiveWrapper {
150                    directive_type: "transaction".to_string(),
151                    date: "2024-06-15".to_string(),
152                    filename: None,
153                    lineno: None,
154                    data: DirectiveData::Transaction(TransactionData {
155                        flag: "*".to_string(),
156                        payee: None,
157                        narration: "Deposit".to_string(),
158                        tags: vec![],
159                        links: vec![],
160                        metadata: vec![],
161                        postings: vec![PostingData {
162                            account: "Assets:Bank".to_string(),
163                            units: Some(AmountData {
164                                number: "100".to_string(),
165                                currency: "USD".to_string(),
166                            }),
167                            cost: None,
168                            price: None,
169                            flag: None,
170                            metadata: vec![],
171                            span: None,
172                        }],
173                    }),
174                },
175                DirectiveWrapper {
176                    directive_type: "close".to_string(),
177                    date: "2024-12-31".to_string(),
178                    filename: None,
179                    lineno: None,
180                    data: DirectiveData::Close(CloseData {
181                        account: "Assets:Bank".to_string(),
182                        metadata: vec![],
183                    }),
184                },
185            ],
186            options: PluginOptions {
187                operating_currencies: vec!["USD".to_string()],
188                title: None,
189            },
190            config: None,
191        };
192
193        let input_dirs = input.directives.clone();
194        let output = plugin.process(input);
195        assert_eq!(output.errors.len(), 0);
196
197        let directives = materialize_ops(&input_dirs, &output);
198        // Should have 4 directives: open, transaction, close, balance
199        assert_eq!(directives.len(), 4);
200
201        // Find the balance directive
202        let balance = directives
203            .iter()
204            .find(|d| matches!(d.data, DirectiveData::Balance(_)))
205            .expect("Should have balance directive");
206
207        assert_eq!(balance.date, "2025-01-01"); // Day after close
208        if let DirectiveData::Balance(b) = &balance.data {
209            assert_eq!(b.account, "Assets:Bank");
210            assert_eq!(b.amount.number, "0");
211            assert_eq!(b.amount.currency, "USD");
212        } else {
213            panic!("Expected Balance directive");
214        }
215    }
216
217    #[test]
218    fn test_check_drained_ignores_income_expense() {
219        let plugin = CheckDrainedPlugin;
220
221        let input = PluginInput {
222            directives: vec![
223                DirectiveWrapper {
224                    directive_type: "open".to_string(),
225                    date: "2024-01-01".to_string(),
226                    filename: None,
227                    lineno: None,
228                    data: DirectiveData::Open(OpenData {
229                        account: "Income:Salary".to_string(),
230                        currencies: vec!["USD".to_string()],
231                        booking: None,
232                        metadata: vec![],
233                    }),
234                },
235                DirectiveWrapper {
236                    directive_type: "close".to_string(),
237                    date: "2024-12-31".to_string(),
238                    filename: None,
239                    lineno: None,
240                    data: DirectiveData::Close(CloseData {
241                        account: "Income:Salary".to_string(),
242                        metadata: vec![],
243                    }),
244                },
245            ],
246            options: PluginOptions {
247                operating_currencies: vec!["USD".to_string()],
248                title: None,
249            },
250            config: None,
251        };
252
253        let input_dirs = input.directives.clone();
254        let output = plugin.process(input);
255        let directives = materialize_ops(&input_dirs, &output);
256        // Should not add balance assertions for income/expense accounts
257        assert_eq!(directives.len(), 2);
258        assert!(
259            !directives
260                .iter()
261                .any(|d| matches!(d.data, DirectiveData::Balance(_)))
262        );
263    }
264
265    #[test]
266    fn test_check_drained_multiple_currencies() {
267        let plugin = CheckDrainedPlugin;
268
269        let input = PluginInput {
270            directives: vec![
271                DirectiveWrapper {
272                    directive_type: "open".to_string(),
273                    date: "2024-01-01".to_string(),
274                    filename: None,
275                    lineno: None,
276                    data: DirectiveData::Open(OpenData {
277                        account: "Assets:Bank".to_string(),
278                        currencies: vec![],
279                        booking: None,
280                        metadata: vec![],
281                    }),
282                },
283                DirectiveWrapper {
284                    directive_type: "transaction".to_string(),
285                    date: "2024-06-15".to_string(),
286                    filename: None,
287                    lineno: None,
288                    data: DirectiveData::Transaction(TransactionData {
289                        flag: "*".to_string(),
290                        payee: None,
291                        narration: "USD Deposit".to_string(),
292                        tags: vec![],
293                        links: vec![],
294                        metadata: vec![],
295                        postings: vec![PostingData {
296                            account: "Assets:Bank".to_string(),
297                            units: Some(AmountData {
298                                number: "100".to_string(),
299                                currency: "USD".to_string(),
300                            }),
301                            cost: None,
302                            price: None,
303                            flag: None,
304                            metadata: vec![],
305                            span: None,
306                        }],
307                    }),
308                },
309                DirectiveWrapper {
310                    directive_type: "transaction".to_string(),
311                    date: "2024-07-15".to_string(),
312                    filename: None,
313                    lineno: None,
314                    data: DirectiveData::Transaction(TransactionData {
315                        flag: "*".to_string(),
316                        payee: None,
317                        narration: "EUR Deposit".to_string(),
318                        tags: vec![],
319                        links: vec![],
320                        metadata: vec![],
321                        postings: vec![PostingData {
322                            account: "Assets:Bank".to_string(),
323                            units: Some(AmountData {
324                                number: "50".to_string(),
325                                currency: "EUR".to_string(),
326                            }),
327                            cost: None,
328                            price: None,
329                            flag: None,
330                            metadata: vec![],
331                            span: None,
332                        }],
333                    }),
334                },
335                DirectiveWrapper {
336                    directive_type: "close".to_string(),
337                    date: "2024-12-31".to_string(),
338                    filename: None,
339                    lineno: None,
340                    data: DirectiveData::Close(CloseData {
341                        account: "Assets:Bank".to_string(),
342                        metadata: vec![],
343                    }),
344                },
345            ],
346            options: PluginOptions {
347                operating_currencies: vec!["USD".to_string()],
348                title: None,
349            },
350            config: None,
351        };
352
353        let input_dirs = input.directives.clone();
354        let output = plugin.process(input);
355        let directives = materialize_ops(&input_dirs, &output);
356        // Should have 6 directives: open, 2 transactions, close, 2 balance assertions
357        assert_eq!(directives.len(), 6);
358
359        let balances: Vec<_> = directives
360            .iter()
361            .filter(|d| matches!(d.data, DirectiveData::Balance(_)))
362            .collect();
363        assert_eq!(balances.len(), 2);
364
365        // Both should be dated 2025-01-01
366        for b in &balances {
367            assert_eq!(b.date, "2025-01-01");
368        }
369    }
370}