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