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, RegularPlugin};
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
223impl RegularPlugin for EffectiveDatePlugin {}
224
225fn has_effective_date_posting(txn: &TransactionData) -> bool {
227 txn.postings.iter().any(|p| {
228 p.metadata
229 .iter()
230 .any(|(k, v)| k == "effective_date" && matches!(v, MetaValueData::Date(_)))
231 })
232}
233
234fn get_effective_date(posting: &PostingData) -> Option<String> {
236 for (key, value) in &posting.metadata {
237 if key == "effective_date"
238 && let MetaValueData::Date(d) = value
239 {
240 return Some(d.clone());
241 }
242 }
243 None
244}
245
246fn find_holding_account(
248 account: &str,
249 effective_date: &str,
250 entry_date: &str,
251 holding_accounts: &HashMap<String, (String, String)>,
252) -> (Option<String>, bool) {
253 for (prefix, (earlier, later)) in holding_accounts {
254 if account.starts_with(prefix) {
255 let is_later = effective_date > entry_date;
256 let hold_acct = if is_later { later } else { earlier };
257 return (Some(hold_acct.clone()), is_later);
258 }
259 }
260 (None, false)
261}
262
263fn find_account_prefix(
265 account: &str,
266 holding_accounts: &HashMap<String, (String, String)>,
267) -> String {
268 for prefix in holding_accounts.keys() {
269 if account.starts_with(prefix) {
270 return prefix.clone();
271 }
272 }
273 String::new()
274}
275
276fn create_opposite_posting(posting: &PostingData) -> PostingData {
278 let mut opposite = posting.clone();
279 if let Some(ref units) = opposite.units {
280 let number = if units.number.starts_with('-') {
281 units.number[1..].to_string()
282 } else {
283 format!("-{}", units.number)
284 };
285 opposite.units = Some(AmountData {
286 number,
287 currency: units.currency.clone(),
288 });
289 }
290 opposite
291}
292
293static LINK_COUNTER: AtomicUsize = AtomicUsize::new(0);
295
296fn generate_link(date: &str) -> String {
298 let date_short = date.replace('-', "");
299 let date_short = if date_short.len() > 6 {
300 &date_short[2..]
301 } else {
302 &date_short
303 };
304 let counter = LINK_COUNTER.fetch_add(1, Ordering::Relaxed);
305 format!("edate-{}-{:03x}", date_short, counter % 4096)
306}
307
308fn parse_config(config: &str) -> Result<HashMap<String, (String, String)>, String> {
310 let mut result = HashMap::new();
311
312 for cap in HOLDING_ACCOUNT_RE.captures_iter(config) {
314 let prefix = cap[1].to_string();
315 let earlier = cap[2].to_string();
316 let later = cap[3].to_string();
317 result.insert(prefix, (earlier, later));
318 }
319
320 if result.is_empty() {
321 return Err("No holding accounts found in config".to_string());
322 }
323
324 Ok(result)
325}
326
327#[cfg(test)]
328mod tests {
329 use super::super::utils::materialize_ops;
330 use super::*;
331 use crate::types::*;
332
333 fn create_test_transaction_with_effective_date(
334 date: &str,
335 effective_date: &str,
336 ) -> DirectiveWrapper {
337 DirectiveWrapper {
338 directive_type: "transaction".to_string(),
339 date: date.to_string(),
340 filename: None,
341 lineno: None,
342 data: DirectiveData::Transaction(TransactionData {
343 flag: "*".to_string(),
344 payee: None,
345 narration: "Test with effective date".to_string(),
346 tags: vec![],
347 links: vec![],
348 metadata: vec![],
349 postings: vec![
350 PostingData {
351 account: "Assets:Cash".to_string(),
352 units: Some(AmountData {
353 number: "-100.00".to_string(),
354 currency: "USD".to_string(),
355 }),
356 cost: None,
357 price: None,
358 flag: None,
359 metadata: vec![],
360 span: None,
361 },
362 PostingData {
363 account: "Expenses:Food".to_string(),
364 units: Some(AmountData {
365 number: "100.00".to_string(),
366 currency: "USD".to_string(),
367 }),
368 cost: None,
369 price: None,
370 flag: None,
371 metadata: vec![(
372 "effective_date".to_string(),
373 MetaValueData::Date(effective_date.to_string()),
374 )],
375 span: None,
376 },
377 ],
378 }),
379 }
380 }
381
382 #[test]
383 fn test_effective_date_later() {
384 let plugin = EffectiveDatePlugin;
385
386 let input = PluginInput {
387 directives: vec![create_test_transaction_with_effective_date(
388 "2024-01-15",
389 "2024-02-01",
390 )],
391 options: PluginOptions {
392 operating_currencies: vec!["USD".to_string()],
393 title: None,
394 },
395 config: None,
396 };
397
398 let input_dirs = input.directives.clone();
399 let output = plugin.process(input);
400 assert_eq!(output.errors.len(), 0);
401 let directives = materialize_ops(&input_dirs, &output);
402
403 assert!(directives.len() >= 2);
405
406 let effective_txn_count = directives
408 .iter()
409 .filter(|d| d.date == "2024-02-01" && matches!(d.data, DirectiveData::Transaction(_)))
410 .count();
411 assert_eq!(effective_txn_count, 1);
412 }
413
414 #[test]
415 fn test_effective_date_earlier() {
416 let plugin = EffectiveDatePlugin;
417
418 let input = PluginInput {
419 directives: vec![create_test_transaction_with_effective_date(
420 "2024-02-01",
421 "2024-01-15",
422 )],
423 options: PluginOptions {
424 operating_currencies: vec!["USD".to_string()],
425 title: None,
426 },
427 config: None,
428 };
429
430 let input_dirs = input.directives.clone();
431 let output = plugin.process(input);
432 assert_eq!(output.errors.len(), 0);
433 let directives = materialize_ops(&input_dirs, &output);
434
435 let effective_txn_count = directives
437 .iter()
438 .filter(|d| d.date == "2024-01-15" && matches!(d.data, DirectiveData::Transaction(_)))
439 .count();
440 assert_eq!(effective_txn_count, 1);
441 }
442
443 #[test]
444 fn test_no_effective_date_unchanged() {
445 let plugin = EffectiveDatePlugin;
446
447 let input = PluginInput {
448 directives: vec![DirectiveWrapper {
449 directive_type: "transaction".to_string(),
450 date: "2024-01-15".to_string(),
451 filename: None,
452 lineno: None,
453 data: DirectiveData::Transaction(TransactionData {
454 flag: "*".to_string(),
455 payee: None,
456 narration: "Regular transaction".to_string(),
457 tags: vec![],
458 links: vec![],
459 metadata: vec![],
460 postings: vec![
461 PostingData {
462 account: "Assets:Cash".to_string(),
463 units: Some(AmountData {
464 number: "-100.00".to_string(),
465 currency: "USD".to_string(),
466 }),
467 cost: None,
468 price: None,
469 flag: None,
470 metadata: vec![],
471 span: None,
472 },
473 PostingData {
474 account: "Expenses:Food".to_string(),
475 units: Some(AmountData {
476 number: "100.00".to_string(),
477 currency: "USD".to_string(),
478 }),
479 cost: None,
480 price: None,
481 flag: None,
482 metadata: vec![],
483 span: None,
484 },
485 ],
486 }),
487 }],
488 options: PluginOptions {
489 operating_currencies: vec!["USD".to_string()],
490 title: None,
491 },
492 config: None,
493 };
494
495 let input_dirs = input.directives.clone();
496 let output = plugin.process(input);
497 assert_eq!(output.errors.len(), 0);
498 let directives = materialize_ops(&input_dirs, &output);
499 let txn_count = directives
501 .iter()
502 .filter(|d| matches!(d.data, DirectiveData::Transaction(_)))
503 .count();
504 assert_eq!(txn_count, 1);
505 }
506}