Skip to main content

rustledger_plugin/native/plugins/
effective_date.rs

1//! Effective date plugin - move postings to their effective dates.
2//!
3//! When a posting has an `effective_date` metadata, this plugin:
4//! 1. Moves the original posting to a holding account on the transaction date
5//! 2. Creates a new transaction on the effective date
6//!
7//! Configuration (optional):
8//! ```text
9//! plugin "beancount_reds_plugins.effective_date.effective_date" "{
10//!   'Expenses': {'earlier': 'Liabilities:Hold:Expenses', 'later': 'Assets:Hold:Expenses'},
11//!   'Income': {'earlier': 'Assets:Hold:Income', 'later': 'Liabilities:Hold:Income'},
12//! }"
13//! ```
14
15use regex::Regex;
16use std::collections::{HashMap, HashSet};
17use std::sync::LazyLock;
18use std::sync::atomic::{AtomicUsize, Ordering};
19
20/// Regex for parsing holding account configuration entries.
21/// Format: `'Prefix': {'earlier': 'Account1', 'later': 'Account2'}`
22static 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
34/// Plugin for handling effective dates on postings.
35pub struct EffectiveDatePlugin;
36
37/// Default holding accounts configuration.
38fn 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        // Parse configuration or use defaults
68        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        // Accounts already opened by the user; suppress duplicate Opens
76        // for holding accounts the user has pre-declared (else Late
77        // validation emits E1002 AccountAlreadyOpen). Mirrors the
78        // pattern in `zerosum`, `currency_accounts`, `split_expenses`,
79        // and `capital_gains_classifier`.
80        let mut existing_opens: HashSet<String> = HashSet::new();
81
82        // Compute earliest date AND record existing opens in one pass.
83        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        // Inserted new transactions (one per posting with effective_date),
94        // accumulated into ops after the main loop so the Modify(i, ...)
95        // entries stay paired with their input indices in input-order.
96        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            // Generate a random link for this set of entries
106            let link = generate_link(&directive.date);
107
108            if let DirectiveData::Transaction(ref mut txn) = directive.data {
109                // Add link to original transaction
110                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                        // Find the holding account for this posting's account type
120                        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                            // Create modified posting with holding account
129                            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                            // Remove effective_date from metadata
138                            modified_posting
139                                .metadata
140                                .retain(|(k, _)| k != "effective_date");
141
142                            // Create hold posting (opposite of modified) before moving
143                            let hold_posting = create_opposite_posting(&modified_posting);
144
145                            modified_postings.push(modified_posting);
146
147                            // Create new entry at effective date
148                            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                            // No matching holding account, keep original
175                            modified_postings.push(posting.clone());
176                        }
177                    } else {
178                        // No effective_date, keep original
179                        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        // Append all inserted new-date transactions.
190        for w in inserted_txns {
191            ops.push(PluginOp::Insert(w));
192        }
193
194        // Insert Open directives for newly synthesized holding accounts
195        // the user hasn't already opened.
196        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
225/// Check if a transaction has any posting with `effective_date` metadata.
226fn 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
234/// Get the `effective_date` from a posting's metadata.
235fn 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
246/// Find the appropriate holding account for a posting.
247fn 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
263/// Find the account prefix that matches the holding accounts config.
264fn 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
276/// Create a posting with the opposite amount.
277fn 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
293/// Counter for generating unique links.
294static LINK_COUNTER: AtomicUsize = AtomicUsize::new(0);
295
296/// Generate a unique link for effective date entries.
297fn 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
308/// Parse the configuration string.
309fn parse_config(config: &str) -> Result<HashMap<String, (String, String)>, String> {
310    let mut result = HashMap::new();
311
312    // Parse format: {'Prefix': {'earlier': 'Account1', 'later': 'Account2'}, ...}
313    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        // Should have: open directives + original modified + new at effective date
404        assert!(directives.len() >= 2);
405
406        // Check that we have a transaction at the effective date
407        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        // Check that we have a transaction at the earlier effective date
436        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        // Should have exactly 1 transaction (unchanged)
500        let txn_count = directives
501            .iter()
502            .filter(|d| matches!(d.data, DirectiveData::Transaction(_)))
503            .count();
504        assert_eq!(txn_count, 1);
505    }
506}