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;
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
223/// Check if a transaction has any posting with `effective_date` metadata.
224fn 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
232/// Get the `effective_date` from a posting's metadata.
233fn 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
244/// Find the appropriate holding account for a posting.
245fn 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
261/// Find the account prefix that matches the holding accounts config.
262fn 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
274/// Create a posting with the opposite amount.
275fn 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
291/// Counter for generating unique links.
292static LINK_COUNTER: AtomicUsize = AtomicUsize::new(0);
293
294/// Generate a unique link for effective date entries.
295fn 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
306/// Parse the configuration string.
307fn parse_config(config: &str) -> Result<HashMap<String, (String, String)>, String> {
308    let mut result = HashMap::new();
309
310    // Parse format: {'Prefix': {'earlier': 'Account1', 'later': 'Account2'}, ...}
311    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        // Should have: open directives + original modified + new at effective date
400        assert!(directives.len() >= 2);
401
402        // Check that we have a transaction at the effective date
403        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        // Check that we have a transaction at the earlier effective date
432        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        // Should have exactly 1 transaction (unchanged)
494        let txn_count = directives
495            .iter()
496            .filter(|d| matches!(d.data, DirectiveData::Transaction(_)))
497            .count();
498        assert_eq!(txn_count, 1);
499    }
500}