1use crate::types::{DirectiveData, DirectiveWrapper, PluginInput, PluginOp, PluginOutput};
4
5use super::super::NativePlugin;
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
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 assert_eq!(directives.len(), 4);
195
196 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"); 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 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 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 for b in &balances {
360 assert_eq!(b.date, "2025-01-01");
361 }
362 }
363}