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