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, PluginOp,
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 let mut existing_opens: HashSet<String> = HashSet::new();
81
82 for directive in &input.directives {
84 if earliest_date.is_none() || directive.date < *earliest_date.as_ref().unwrap() {
85 earliest_date = Some(directive.date.clone());
86 }
87 if let DirectiveData::Open(open) = &directive.data {
88 existing_opens.insert(open.account.clone());
89 }
90 }
91
92 let mut ops: Vec<PluginOp> = Vec::with_capacity(input.directives.len());
93 let mut inserted_txns: Vec<DirectiveWrapper> = Vec::new();
97
98 for (i, mut directive) in input.directives.into_iter().enumerate() {
99 let is_interesting = matches!(&directive.data, DirectiveData::Transaction(t) if has_effective_date_posting(t));
100 if !is_interesting {
101 ops.push(PluginOp::Keep(i));
102 continue;
103 }
104
105 let link = generate_link(&directive.date);
107
108 if let DirectiveData::Transaction(ref mut txn) = directive.data {
109 if !txn.links.contains(&link) {
111 txn.links.push(link.clone());
112 }
113
114 let entry_date = directive.date.clone();
115 let mut modified_postings = Vec::new();
116
117 for posting in &txn.postings {
118 if let Some(effective_date) = get_effective_date(posting) {
119 let (hold_account, _is_later) = find_holding_account(
121 &posting.account,
122 &effective_date,
123 &entry_date,
124 &holding_accounts,
125 );
126
127 if let Some(hold_acct) = hold_account {
128 let new_account = posting.account.replace(
130 &find_account_prefix(&posting.account, &holding_accounts),
131 &hold_acct,
132 );
133 new_accounts.insert(new_account.clone());
134
135 let mut modified_posting = posting.clone();
136 modified_posting.account.clone_from(&new_account);
137 modified_posting
139 .metadata
140 .retain(|(k, _)| k != "effective_date");
141
142 let hold_posting = create_opposite_posting(&modified_posting);
144
145 modified_postings.push(modified_posting);
146
147 let mut cleaned_original = posting.clone();
149 cleaned_original
150 .metadata
151 .retain(|(k, _)| k != "effective_date");
152
153 let new_txn = TransactionData {
154 flag: txn.flag.clone(),
155 payee: txn.payee.clone(),
156 narration: txn.narration.clone(),
157 tags: txn.tags.clone(),
158 links: vec![link.clone()],
159 metadata: vec![(
160 "original_date".to_string(),
161 MetaValueData::Date(entry_date.clone()),
162 )],
163 postings: vec![hold_posting, cleaned_original],
164 };
165
166 inserted_txns.push(DirectiveWrapper {
167 directive_type: "transaction".to_string(),
168 date: effective_date,
169 filename: directive.filename.clone(),
170 lineno: directive.lineno,
171 data: DirectiveData::Transaction(new_txn),
172 });
173 } else {
174 modified_postings.push(posting.clone());
176 }
177 } else {
178 modified_postings.push(posting.clone());
180 }
181 }
182
183 txn.postings = modified_postings;
184 }
185
186 ops.push(PluginOp::Modify(i, directive));
187 }
188
189 for w in inserted_txns {
191 ops.push(PluginOp::Insert(w));
192 }
193
194 if let Some(date) = &earliest_date {
197 for account in &new_accounts {
198 if existing_opens.contains(account) {
199 continue;
200 }
201 ops.push(PluginOp::Insert(DirectiveWrapper {
202 directive_type: "open".to_string(),
203 date: date.clone(),
204 filename: Some("<effective_date>".to_string()),
205 lineno: Some(0),
206 data: DirectiveData::Open(OpenData {
207 account: account.clone(),
208 currencies: vec![],
209 booking: None,
210 metadata: vec![],
211 }),
212 }));
213 }
214 }
215
216 PluginOutput {
217 ops,
218 errors: Vec::new(),
219 }
220 }
221}
222
223fn has_effective_date_posting(txn: &TransactionData) -> bool {
225 txn.postings.iter().any(|p| {
226 p.metadata
227 .iter()
228 .any(|(k, v)| k == "effective_date" && matches!(v, MetaValueData::Date(_)))
229 })
230}
231
232fn get_effective_date(posting: &PostingData) -> Option<String> {
234 for (key, value) in &posting.metadata {
235 if key == "effective_date"
236 && let MetaValueData::Date(d) = value
237 {
238 return Some(d.clone());
239 }
240 }
241 None
242}
243
244fn find_holding_account(
246 account: &str,
247 effective_date: &str,
248 entry_date: &str,
249 holding_accounts: &HashMap<String, (String, String)>,
250) -> (Option<String>, bool) {
251 for (prefix, (earlier, later)) in holding_accounts {
252 if account.starts_with(prefix) {
253 let is_later = effective_date > entry_date;
254 let hold_acct = if is_later { later } else { earlier };
255 return (Some(hold_acct.clone()), is_later);
256 }
257 }
258 (None, false)
259}
260
261fn find_account_prefix(
263 account: &str,
264 holding_accounts: &HashMap<String, (String, String)>,
265) -> String {
266 for prefix in holding_accounts.keys() {
267 if account.starts_with(prefix) {
268 return prefix.clone();
269 }
270 }
271 String::new()
272}
273
274fn create_opposite_posting(posting: &PostingData) -> PostingData {
276 let mut opposite = posting.clone();
277 if let Some(ref units) = opposite.units {
278 let number = if units.number.starts_with('-') {
279 units.number[1..].to_string()
280 } else {
281 format!("-{}", units.number)
282 };
283 opposite.units = Some(AmountData {
284 number,
285 currency: units.currency.clone(),
286 });
287 }
288 opposite
289}
290
291static LINK_COUNTER: AtomicUsize = AtomicUsize::new(0);
293
294fn generate_link(date: &str) -> String {
296 let date_short = date.replace('-', "");
297 let date_short = if date_short.len() > 6 {
298 &date_short[2..]
299 } else {
300 &date_short
301 };
302 let counter = LINK_COUNTER.fetch_add(1, Ordering::Relaxed);
303 format!("edate-{}-{:03x}", date_short, counter % 4096)
304}
305
306fn parse_config(config: &str) -> Result<HashMap<String, (String, String)>, String> {
308 let mut result = HashMap::new();
309
310 for cap in HOLDING_ACCOUNT_RE.captures_iter(config) {
312 let prefix = cap[1].to_string();
313 let earlier = cap[2].to_string();
314 let later = cap[3].to_string();
315 result.insert(prefix, (earlier, later));
316 }
317
318 if result.is_empty() {
319 return Err("No holding accounts found in config".to_string());
320 }
321
322 Ok(result)
323}
324
325#[cfg(test)]
326mod tests {
327 use super::super::utils::materialize_ops;
328 use super::*;
329 use crate::types::*;
330
331 fn create_test_transaction_with_effective_date(
332 date: &str,
333 effective_date: &str,
334 ) -> DirectiveWrapper {
335 DirectiveWrapper {
336 directive_type: "transaction".to_string(),
337 date: date.to_string(),
338 filename: None,
339 lineno: None,
340 data: DirectiveData::Transaction(TransactionData {
341 flag: "*".to_string(),
342 payee: None,
343 narration: "Test with effective date".to_string(),
344 tags: vec![],
345 links: vec![],
346 metadata: vec![],
347 postings: vec![
348 PostingData {
349 account: "Assets:Cash".to_string(),
350 units: Some(AmountData {
351 number: "-100.00".to_string(),
352 currency: "USD".to_string(),
353 }),
354 cost: None,
355 price: None,
356 flag: None,
357 metadata: vec![],
358 },
359 PostingData {
360 account: "Expenses:Food".to_string(),
361 units: Some(AmountData {
362 number: "100.00".to_string(),
363 currency: "USD".to_string(),
364 }),
365 cost: None,
366 price: None,
367 flag: None,
368 metadata: vec![(
369 "effective_date".to_string(),
370 MetaValueData::Date(effective_date.to_string()),
371 )],
372 },
373 ],
374 }),
375 }
376 }
377
378 #[test]
379 fn test_effective_date_later() {
380 let plugin = EffectiveDatePlugin;
381
382 let input = PluginInput {
383 directives: vec![create_test_transaction_with_effective_date(
384 "2024-01-15",
385 "2024-02-01",
386 )],
387 options: PluginOptions {
388 operating_currencies: vec!["USD".to_string()],
389 title: None,
390 },
391 config: None,
392 };
393
394 let input_dirs = input.directives.clone();
395 let output = plugin.process(input);
396 assert_eq!(output.errors.len(), 0);
397 let directives = materialize_ops(&input_dirs, &output);
398
399 assert!(directives.len() >= 2);
401
402 let effective_txn_count = directives
404 .iter()
405 .filter(|d| d.date == "2024-02-01" && matches!(d.data, DirectiveData::Transaction(_)))
406 .count();
407 assert_eq!(effective_txn_count, 1);
408 }
409
410 #[test]
411 fn test_effective_date_earlier() {
412 let plugin = EffectiveDatePlugin;
413
414 let input = PluginInput {
415 directives: vec![create_test_transaction_with_effective_date(
416 "2024-02-01",
417 "2024-01-15",
418 )],
419 options: PluginOptions {
420 operating_currencies: vec!["USD".to_string()],
421 title: None,
422 },
423 config: None,
424 };
425
426 let input_dirs = input.directives.clone();
427 let output = plugin.process(input);
428 assert_eq!(output.errors.len(), 0);
429 let directives = materialize_ops(&input_dirs, &output);
430
431 let effective_txn_count = directives
433 .iter()
434 .filter(|d| d.date == "2024-01-15" && matches!(d.data, DirectiveData::Transaction(_)))
435 .count();
436 assert_eq!(effective_txn_count, 1);
437 }
438
439 #[test]
440 fn test_no_effective_date_unchanged() {
441 let plugin = EffectiveDatePlugin;
442
443 let input = PluginInput {
444 directives: vec![DirectiveWrapper {
445 directive_type: "transaction".to_string(),
446 date: "2024-01-15".to_string(),
447 filename: None,
448 lineno: None,
449 data: DirectiveData::Transaction(TransactionData {
450 flag: "*".to_string(),
451 payee: None,
452 narration: "Regular transaction".to_string(),
453 tags: vec![],
454 links: vec![],
455 metadata: vec![],
456 postings: vec![
457 PostingData {
458 account: "Assets:Cash".to_string(),
459 units: Some(AmountData {
460 number: "-100.00".to_string(),
461 currency: "USD".to_string(),
462 }),
463 cost: None,
464 price: None,
465 flag: None,
466 metadata: vec![],
467 },
468 PostingData {
469 account: "Expenses:Food".to_string(),
470 units: Some(AmountData {
471 number: "100.00".to_string(),
472 currency: "USD".to_string(),
473 }),
474 cost: None,
475 price: None,
476 flag: None,
477 metadata: vec![],
478 },
479 ],
480 }),
481 }],
482 options: PluginOptions {
483 operating_currencies: vec!["USD".to_string()],
484 title: None,
485 },
486 config: None,
487 };
488
489 let input_dirs = input.directives.clone();
490 let output = plugin.process(input);
491 assert_eq!(output.errors.len(), 0);
492 let directives = materialize_ops(&input_dirs, &output);
493 let txn_count = directives
495 .iter()
496 .filter(|d| matches!(d.data, DirectiveData::Transaction(_)))
497 .count();
498 assert_eq!(txn_count, 1);
499 }
500}