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