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;
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
121#[cfg(test)]
122mod check_drained_tests {
123    use super::super::utils::materialize_ops;
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 input_dirs = input.directives.clone();
189        let output = plugin.process(input);
190        assert_eq!(output.errors.len(), 0);
191
192        let directives = materialize_ops(&input_dirs, &output);
193        // Should have 4 directives: open, transaction, close, balance
194        assert_eq!(directives.len(), 4);
195
196        // Find the balance directive
197        let balance = directives
198            .iter()
199            .find(|d| matches!(d.data, DirectiveData::Balance(_)))
200            .expect("Should have balance directive");
201
202        assert_eq!(balance.date, "2025-01-01"); // Day after close
203        if let DirectiveData::Balance(b) = &balance.data {
204            assert_eq!(b.account, "Assets:Bank");
205            assert_eq!(b.amount.number, "0");
206            assert_eq!(b.amount.currency, "USD");
207        } else {
208            panic!("Expected Balance directive");
209        }
210    }
211
212    #[test]
213    fn test_check_drained_ignores_income_expense() {
214        let plugin = CheckDrainedPlugin;
215
216        let input = PluginInput {
217            directives: vec![
218                DirectiveWrapper {
219                    directive_type: "open".to_string(),
220                    date: "2024-01-01".to_string(),
221                    filename: None,
222                    lineno: None,
223                    data: DirectiveData::Open(OpenData {
224                        account: "Income:Salary".to_string(),
225                        currencies: vec!["USD".to_string()],
226                        booking: None,
227                        metadata: vec![],
228                    }),
229                },
230                DirectiveWrapper {
231                    directive_type: "close".to_string(),
232                    date: "2024-12-31".to_string(),
233                    filename: None,
234                    lineno: None,
235                    data: DirectiveData::Close(CloseData {
236                        account: "Income:Salary".to_string(),
237                        metadata: vec![],
238                    }),
239                },
240            ],
241            options: PluginOptions {
242                operating_currencies: vec!["USD".to_string()],
243                title: None,
244            },
245            config: None,
246        };
247
248        let input_dirs = input.directives.clone();
249        let output = plugin.process(input);
250        let directives = materialize_ops(&input_dirs, &output);
251        // Should not add balance assertions for income/expense accounts
252        assert_eq!(directives.len(), 2);
253        assert!(
254            !directives
255                .iter()
256                .any(|d| matches!(d.data, DirectiveData::Balance(_)))
257        );
258    }
259
260    #[test]
261    fn test_check_drained_multiple_currencies() {
262        let plugin = CheckDrainedPlugin;
263
264        let input = PluginInput {
265            directives: vec![
266                DirectiveWrapper {
267                    directive_type: "open".to_string(),
268                    date: "2024-01-01".to_string(),
269                    filename: None,
270                    lineno: None,
271                    data: DirectiveData::Open(OpenData {
272                        account: "Assets:Bank".to_string(),
273                        currencies: vec![],
274                        booking: None,
275                        metadata: vec![],
276                    }),
277                },
278                DirectiveWrapper {
279                    directive_type: "transaction".to_string(),
280                    date: "2024-06-15".to_string(),
281                    filename: None,
282                    lineno: None,
283                    data: DirectiveData::Transaction(TransactionData {
284                        flag: "*".to_string(),
285                        payee: None,
286                        narration: "USD Deposit".to_string(),
287                        tags: vec![],
288                        links: vec![],
289                        metadata: vec![],
290                        postings: vec![PostingData {
291                            account: "Assets:Bank".to_string(),
292                            units: Some(AmountData {
293                                number: "100".to_string(),
294                                currency: "USD".to_string(),
295                            }),
296                            cost: None,
297                            price: None,
298                            flag: None,
299                            metadata: vec![],
300                        }],
301                    }),
302                },
303                DirectiveWrapper {
304                    directive_type: "transaction".to_string(),
305                    date: "2024-07-15".to_string(),
306                    filename: None,
307                    lineno: None,
308                    data: DirectiveData::Transaction(TransactionData {
309                        flag: "*".to_string(),
310                        payee: None,
311                        narration: "EUR Deposit".to_string(),
312                        tags: vec![],
313                        links: vec![],
314                        metadata: vec![],
315                        postings: vec![PostingData {
316                            account: "Assets:Bank".to_string(),
317                            units: Some(AmountData {
318                                number: "50".to_string(),
319                                currency: "EUR".to_string(),
320                            }),
321                            cost: None,
322                            price: None,
323                            flag: None,
324                            metadata: vec![],
325                        }],
326                    }),
327                },
328                DirectiveWrapper {
329                    directive_type: "close".to_string(),
330                    date: "2024-12-31".to_string(),
331                    filename: None,
332                    lineno: None,
333                    data: DirectiveData::Close(CloseData {
334                        account: "Assets:Bank".to_string(),
335                        metadata: vec![],
336                    }),
337                },
338            ],
339            options: PluginOptions {
340                operating_currencies: vec!["USD".to_string()],
341                title: None,
342            },
343            config: None,
344        };
345
346        let input_dirs = input.directives.clone();
347        let output = plugin.process(input);
348        let directives = materialize_ops(&input_dirs, &output);
349        // Should have 6 directives: open, 2 transactions, close, 2 balance assertions
350        assert_eq!(directives.len(), 6);
351
352        let balances: Vec<_> = directives
353            .iter()
354            .filter(|d| matches!(d.data, DirectiveData::Balance(_)))
355            .collect();
356        assert_eq!(balances.len(), 2);
357
358        // Both should be dated 2025-01-01
359        for b in &balances {
360            assert_eq!(b.date, "2025-01-01");
361        }
362    }
363}