1use std::collections::{HashMap, HashSet};
16use std::sync::atomic::{AtomicUsize, Ordering};
17
18use crate::types::{
19 AmountData, DirectiveData, DirectiveWrapper, MetaValueData, OpenData, PluginInput,
20 PluginOutput, PostingData, TransactionData,
21};
22
23use super::super::NativePlugin;
24
25pub struct EffectiveDatePlugin;
27
28fn default_holding_accounts() -> HashMap<String, (String, String)> {
30 let mut map = HashMap::new();
31 map.insert(
32 "Expenses".to_string(),
33 (
34 "Liabilities:Hold:Expenses".to_string(),
35 "Assets:Hold:Expenses".to_string(),
36 ),
37 );
38 map.insert(
39 "Income".to_string(),
40 (
41 "Assets:Hold:Income".to_string(),
42 "Liabilities:Hold:Income".to_string(),
43 ),
44 );
45 map
46}
47
48impl NativePlugin for EffectiveDatePlugin {
49 fn name(&self) -> &'static str {
50 "effective_date"
51 }
52
53 fn description(&self) -> &'static str {
54 "Move postings to their effective dates using holding accounts"
55 }
56
57 fn process(&self, input: PluginInput) -> PluginOutput {
58 let holding_accounts = match &input.config {
60 Some(config) => parse_config(config).unwrap_or_else(|_| default_holding_accounts()),
61 None => default_holding_accounts(),
62 };
63
64 let mut new_accounts: HashSet<String> = HashSet::new();
65 let mut earliest_date: Option<String> = None;
66
67 let mut interesting_entries = Vec::new();
69 let mut filtered_entries = Vec::new();
70
71 for directive in input.directives {
72 if directive.directive_type == "transaction"
73 && let DirectiveData::Transaction(ref txn) = directive.data
74 && has_effective_date_posting(txn)
75 {
76 interesting_entries.push(directive);
77 continue;
78 }
79
80 if earliest_date.is_none() || directive.date < *earliest_date.as_ref().unwrap() {
82 earliest_date = Some(directive.date.clone());
83 }
84 filtered_entries.push(directive);
85 }
86
87 let mut new_entries = Vec::new();
89
90 for mut directive in interesting_entries {
91 if earliest_date.is_none() || directive.date < *earliest_date.as_ref().unwrap() {
92 earliest_date = Some(directive.date.clone());
93 }
94
95 let link = generate_link(&directive.date);
97
98 if let DirectiveData::Transaction(ref mut txn) = directive.data {
99 if !txn.links.contains(&link) {
101 txn.links.push(link.clone());
102 }
103
104 let entry_date = directive.date.clone();
105 let mut modified_postings = Vec::new();
106
107 for posting in &txn.postings {
108 if let Some(effective_date) = get_effective_date(posting) {
109 let (hold_account, _is_later) = find_holding_account(
111 &posting.account,
112 &effective_date,
113 &entry_date,
114 &holding_accounts,
115 );
116
117 if let Some(hold_acct) = hold_account {
118 let new_account = posting.account.replace(
120 &find_account_prefix(&posting.account, &holding_accounts),
121 &hold_acct,
122 );
123 new_accounts.insert(new_account.clone());
124
125 let mut modified_posting = posting.clone();
126 modified_posting.account.clone_from(&new_account);
127 modified_posting
129 .metadata
130 .retain(|(k, _)| k != "effective_date");
131
132 let hold_posting = create_opposite_posting(&modified_posting);
134
135 modified_postings.push(modified_posting);
136
137 let mut cleaned_original = posting.clone();
139 cleaned_original
140 .metadata
141 .retain(|(k, _)| k != "effective_date");
142
143 let new_txn = TransactionData {
144 flag: txn.flag.clone(),
145 payee: txn.payee.clone(),
146 narration: txn.narration.clone(),
147 tags: txn.tags.clone(),
148 links: vec![link.clone()],
149 metadata: vec![(
150 "original_date".to_string(),
151 MetaValueData::Date(entry_date.clone()),
152 )],
153 postings: vec![hold_posting, cleaned_original],
154 };
155
156 new_entries.push(DirectiveWrapper {
157 directive_type: "transaction".to_string(),
158 date: effective_date,
159 filename: directive.filename.clone(),
160 lineno: directive.lineno,
161 data: DirectiveData::Transaction(new_txn),
162 });
163 } else {
164 modified_postings.push(posting.clone());
166 }
167 } else {
168 modified_postings.push(posting.clone());
170 }
171 }
172
173 txn.postings = modified_postings;
174 }
175
176 new_entries.push(directive);
177 }
178
179 let mut open_directives: Vec<DirectiveWrapper> = Vec::new();
181 if let Some(date) = &earliest_date {
182 for account in &new_accounts {
183 open_directives.push(DirectiveWrapper {
184 directive_type: "open".to_string(),
185 date: date.clone(),
186 filename: Some("<effective_date>".to_string()),
187 lineno: Some(0),
188 data: DirectiveData::Open(OpenData {
189 account: account.clone(),
190 currencies: vec![],
191 booking: None,
192 metadata: vec![],
193 }),
194 });
195 }
196 }
197
198 new_entries.sort_by(|a, b| a.date.cmp(&b.date));
200
201 let mut all_directives = open_directives;
203 all_directives.extend(new_entries);
204 all_directives.extend(filtered_entries);
205
206 PluginOutput {
207 directives: all_directives,
208 errors: Vec::new(),
209 }
210 }
211}
212
213fn has_effective_date_posting(txn: &TransactionData) -> bool {
215 txn.postings.iter().any(|p| {
216 p.metadata
217 .iter()
218 .any(|(k, v)| k == "effective_date" && matches!(v, MetaValueData::Date(_)))
219 })
220}
221
222fn get_effective_date(posting: &PostingData) -> Option<String> {
224 for (key, value) in &posting.metadata {
225 if key == "effective_date"
226 && let MetaValueData::Date(d) = value
227 {
228 return Some(d.clone());
229 }
230 }
231 None
232}
233
234fn find_holding_account(
236 account: &str,
237 effective_date: &str,
238 entry_date: &str,
239 holding_accounts: &HashMap<String, (String, String)>,
240) -> (Option<String>, bool) {
241 for (prefix, (earlier, later)) in holding_accounts {
242 if account.starts_with(prefix) {
243 let is_later = effective_date > entry_date;
244 let hold_acct = if is_later { later } else { earlier };
245 return (Some(hold_acct.clone()), is_later);
246 }
247 }
248 (None, false)
249}
250
251fn find_account_prefix(
253 account: &str,
254 holding_accounts: &HashMap<String, (String, String)>,
255) -> String {
256 for prefix in holding_accounts.keys() {
257 if account.starts_with(prefix) {
258 return prefix.clone();
259 }
260 }
261 String::new()
262}
263
264fn create_opposite_posting(posting: &PostingData) -> PostingData {
266 let mut opposite = posting.clone();
267 if let Some(ref units) = opposite.units {
268 let number = if units.number.starts_with('-') {
269 units.number[1..].to_string()
270 } else {
271 format!("-{}", units.number)
272 };
273 opposite.units = Some(AmountData {
274 number,
275 currency: units.currency.clone(),
276 });
277 }
278 opposite
279}
280
281static LINK_COUNTER: AtomicUsize = AtomicUsize::new(0);
283
284fn generate_link(date: &str) -> String {
286 let date_short = date.replace('-', "");
287 let date_short = if date_short.len() > 6 {
288 &date_short[2..]
289 } else {
290 &date_short
291 };
292 let counter = LINK_COUNTER.fetch_add(1, Ordering::Relaxed);
293 format!("edate-{}-{:03x}", date_short, counter % 4096)
294}
295
296fn parse_config(config: &str) -> Result<HashMap<String, (String, String)>, String> {
298 let mut result = HashMap::new();
299
300 let re = regex::Regex::new(
302 r"'([^']+)'\s*:\s*\{\s*'earlier'\s*:\s*'([^']+)'\s*,\s*'later'\s*:\s*'([^']+)'\s*\}",
303 )
304 .map_err(|e| e.to_string())?;
305
306 for cap in re.captures_iter(config) {
307 let prefix = cap[1].to_string();
308 let earlier = cap[2].to_string();
309 let later = cap[3].to_string();
310 result.insert(prefix, (earlier, later));
311 }
312
313 if result.is_empty() {
314 return Err("No holding accounts found in config".to_string());
315 }
316
317 Ok(result)
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use crate::types::*;
324
325 fn create_test_transaction_with_effective_date(
326 date: &str,
327 effective_date: &str,
328 ) -> DirectiveWrapper {
329 DirectiveWrapper {
330 directive_type: "transaction".to_string(),
331 date: date.to_string(),
332 filename: None,
333 lineno: None,
334 data: DirectiveData::Transaction(TransactionData {
335 flag: "*".to_string(),
336 payee: None,
337 narration: "Test with effective date".to_string(),
338 tags: vec![],
339 links: vec![],
340 metadata: vec![],
341 postings: vec![
342 PostingData {
343 account: "Assets:Cash".to_string(),
344 units: Some(AmountData {
345 number: "-100.00".to_string(),
346 currency: "USD".to_string(),
347 }),
348 cost: None,
349 price: None,
350 flag: None,
351 metadata: vec![],
352 },
353 PostingData {
354 account: "Expenses:Food".to_string(),
355 units: Some(AmountData {
356 number: "100.00".to_string(),
357 currency: "USD".to_string(),
358 }),
359 cost: None,
360 price: None,
361 flag: None,
362 metadata: vec![(
363 "effective_date".to_string(),
364 MetaValueData::Date(effective_date.to_string()),
365 )],
366 },
367 ],
368 }),
369 }
370 }
371
372 #[test]
373 fn test_effective_date_later() {
374 let plugin = EffectiveDatePlugin;
375
376 let input = PluginInput {
377 directives: vec![create_test_transaction_with_effective_date(
378 "2024-01-15",
379 "2024-02-01",
380 )],
381 options: PluginOptions {
382 operating_currencies: vec!["USD".to_string()],
383 title: None,
384 },
385 config: None,
386 };
387
388 let output = plugin.process(input);
389 assert_eq!(output.errors.len(), 0);
390
391 assert!(output.directives.len() >= 2);
393
394 let effective_txn_count = output
396 .directives
397 .iter()
398 .filter(|d| d.date == "2024-02-01" && d.directive_type == "transaction")
399 .count();
400 assert_eq!(effective_txn_count, 1);
401 }
402
403 #[test]
404 fn test_effective_date_earlier() {
405 let plugin = EffectiveDatePlugin;
406
407 let input = PluginInput {
408 directives: vec![create_test_transaction_with_effective_date(
409 "2024-02-01",
410 "2024-01-15",
411 )],
412 options: PluginOptions {
413 operating_currencies: vec!["USD".to_string()],
414 title: None,
415 },
416 config: None,
417 };
418
419 let output = plugin.process(input);
420 assert_eq!(output.errors.len(), 0);
421
422 let effective_txn_count = output
424 .directives
425 .iter()
426 .filter(|d| d.date == "2024-01-15" && d.directive_type == "transaction")
427 .count();
428 assert_eq!(effective_txn_count, 1);
429 }
430
431 #[test]
432 fn test_no_effective_date_unchanged() {
433 let plugin = EffectiveDatePlugin;
434
435 let input = PluginInput {
436 directives: vec![DirectiveWrapper {
437 directive_type: "transaction".to_string(),
438 date: "2024-01-15".to_string(),
439 filename: None,
440 lineno: None,
441 data: DirectiveData::Transaction(TransactionData {
442 flag: "*".to_string(),
443 payee: None,
444 narration: "Regular transaction".to_string(),
445 tags: vec![],
446 links: vec![],
447 metadata: vec![],
448 postings: vec![
449 PostingData {
450 account: "Assets:Cash".to_string(),
451 units: Some(AmountData {
452 number: "-100.00".to_string(),
453 currency: "USD".to_string(),
454 }),
455 cost: None,
456 price: None,
457 flag: None,
458 metadata: vec![],
459 },
460 PostingData {
461 account: "Expenses:Food".to_string(),
462 units: Some(AmountData {
463 number: "100.00".to_string(),
464 currency: "USD".to_string(),
465 }),
466 cost: None,
467 price: None,
468 flag: None,
469 metadata: vec![],
470 },
471 ],
472 }),
473 }],
474 options: PluginOptions {
475 operating_currencies: vec!["USD".to_string()],
476 title: None,
477 },
478 config: None,
479 };
480
481 let output = plugin.process(input);
482 assert_eq!(output.errors.len(), 0);
483 let txn_count = output
485 .directives
486 .iter()
487 .filter(|d| d.directive_type == "transaction")
488 .count();
489 assert_eq!(txn_count, 1);
490 }
491}