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