1use crate::types::{DirectiveData, DirectiveWrapper, PluginInput, PluginOp, PluginOutput};
4
5use super::super::{NativePlugin, RegularPlugin};
6use super::utils::increment_date;
7
8pub 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 let mut account_currencies: HashMap<String, HashSet<String>> = HashMap::new();
30
31 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 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 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 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 if let Some(currencies) = account_currencies.get(&data.account) {
84 if let Some(next_date) = increment_date(&wrapper.date) {
86 let mut sorted_currencies: Vec<_> = currencies.iter().collect();
88 sorted_currencies.sort(); 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, 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 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 assert_eq!(directives.len(), 4);
198
199 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"); 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 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 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 for b in &balances {
365 assert_eq!(b.date, "2025-01-01");
366 }
367 }
368}