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,
29    PluginOutput, PostingData, TransactionData,
30};
31
32use super::super::NativePlugin;
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
76        // Separate entries with effective_date postings from regular entries
77        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            // Track earliest date for Open directives
90            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        // Process entries with effective dates
97        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            // Generate a random link for this set of entries
105            let link = generate_link(&directive.date);
106
107            if let DirectiveData::Transaction(ref mut txn) = directive.data {
108                // Add link to original transaction
109                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                        // Find the holding account for this posting's account type
119                        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                            // Create modified posting with holding account
128                            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                            // Remove effective_date from metadata
137                            modified_posting
138                                .metadata
139                                .retain(|(k, _)| k != "effective_date");
140
141                            // Create hold posting (opposite of modified) before moving
142                            let hold_posting = create_opposite_posting(&modified_posting);
143
144                            modified_postings.push(modified_posting);
145
146                            // Create new entry at effective date
147                            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                            // No matching holding account, keep original
174                            modified_postings.push(posting.clone());
175                        }
176                    } else {
177                        // No effective_date, keep original
178                        modified_postings.push(posting.clone());
179                    }
180                }
181
182                txn.postings = modified_postings;
183            }
184
185            new_entries.push(directive);
186        }
187
188        // Create Open directives for new accounts
189        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        // Sort new entries by date
208        new_entries.sort_by(|a, b| a.date.cmp(&b.date));
209
210        // Combine all entries
211        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
222/// Check if a transaction has any posting with `effective_date` metadata.
223fn 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
231/// Get the `effective_date` from a posting's metadata.
232fn 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
243/// Find the appropriate holding account for a posting.
244fn 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
260/// Find the account prefix that matches the holding accounts config.
261fn 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
273/// Create a posting with the opposite amount.
274fn 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
290/// Counter for generating unique links.
291static LINK_COUNTER: AtomicUsize = AtomicUsize::new(0);
292
293/// Generate a unique link for effective date entries.
294fn 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
305/// Parse the configuration string.
306fn parse_config(config: &str) -> Result<HashMap<String, (String, String)>, String> {
307    let mut result = HashMap::new();
308
309    // Parse format: {'Prefix': {'earlier': 'Account1', 'later': 'Account2'}, ...}
310    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        // Should have: open directives + original modified + new at effective date
396        assert!(output.directives.len() >= 2);
397
398        // Check that we have a transaction at the effective date
399        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        // Check that we have a transaction at the earlier effective date
427        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        // Should have exactly 1 transaction (unchanged)
488        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}