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 std::collections::{HashMap, HashSet};
16use std::sync::atomic::{AtomicUsize, Ordering};
17
18use crate::types::{
19    AmountData, DirectiveData, DirectiveWrapper, MetaValueData, OpenData, PluginInput,
20    PluginOutput, PostingData, TransactionData,
21};
22
23use super::super::NativePlugin;
24
25/// Plugin for handling effective dates on postings.
26pub struct EffectiveDatePlugin;
27
28/// Default holding accounts configuration.
29fn default_holding_accounts() -> HashMap<String, (String, String)> {
30    let mut map = HashMap::new();
31    map.insert(
32        "Expenses".to_string(),
33        (
34            "Liabilities:Hold:Expenses".to_string(),
35            "Assets:Hold:Expenses".to_string(),
36        ),
37    );
38    map.insert(
39        "Income".to_string(),
40        (
41            "Assets:Hold:Income".to_string(),
42            "Liabilities:Hold:Income".to_string(),
43        ),
44    );
45    map
46}
47
48impl NativePlugin for EffectiveDatePlugin {
49    fn name(&self) -> &'static str {
50        "effective_date"
51    }
52
53    fn description(&self) -> &'static str {
54        "Move postings to their effective dates using holding accounts"
55    }
56
57    fn process(&self, input: PluginInput) -> PluginOutput {
58        // Parse configuration or use defaults
59        let holding_accounts = match &input.config {
60            Some(config) => parse_config(config).unwrap_or_else(|_| default_holding_accounts()),
61            None => default_holding_accounts(),
62        };
63
64        let mut new_accounts: HashSet<String> = HashSet::new();
65        let mut earliest_date: Option<String> = None;
66
67        // Separate entries with effective_date postings from regular entries
68        let mut interesting_entries = Vec::new();
69        let mut filtered_entries = Vec::new();
70
71        for directive in input.directives {
72            if directive.directive_type == "transaction"
73                && let DirectiveData::Transaction(ref txn) = directive.data
74                && has_effective_date_posting(txn)
75            {
76                interesting_entries.push(directive);
77                continue;
78            }
79
80            // Track earliest date for Open directives
81            if earliest_date.is_none() || directive.date < *earliest_date.as_ref().unwrap() {
82                earliest_date = Some(directive.date.clone());
83            }
84            filtered_entries.push(directive);
85        }
86
87        // Process entries with effective dates
88        let mut new_entries = Vec::new();
89
90        for mut directive in interesting_entries {
91            if earliest_date.is_none() || directive.date < *earliest_date.as_ref().unwrap() {
92                earliest_date = Some(directive.date.clone());
93            }
94
95            // Generate a random link for this set of entries
96            let link = generate_link(&directive.date);
97
98            if let DirectiveData::Transaction(ref mut txn) = directive.data {
99                // Add link to original transaction
100                if !txn.links.contains(&link) {
101                    txn.links.push(link.clone());
102                }
103
104                let entry_date = directive.date.clone();
105                let mut modified_postings = Vec::new();
106
107                for posting in &txn.postings {
108                    if let Some(effective_date) = get_effective_date(posting) {
109                        // Find the holding account for this posting's account type
110                        let (hold_account, _is_later) = find_holding_account(
111                            &posting.account,
112                            &effective_date,
113                            &entry_date,
114                            &holding_accounts,
115                        );
116
117                        if let Some(hold_acct) = hold_account {
118                            // Create modified posting with holding account
119                            let new_account = posting.account.replace(
120                                &find_account_prefix(&posting.account, &holding_accounts),
121                                &hold_acct,
122                            );
123                            new_accounts.insert(new_account.clone());
124
125                            let mut modified_posting = posting.clone();
126                            modified_posting.account.clone_from(&new_account);
127                            // Remove effective_date from metadata
128                            modified_posting
129                                .metadata
130                                .retain(|(k, _)| k != "effective_date");
131
132                            // Create hold posting (opposite of modified) before moving
133                            let hold_posting = create_opposite_posting(&modified_posting);
134
135                            modified_postings.push(modified_posting);
136
137                            // Create new entry at effective date
138                            let mut cleaned_original = posting.clone();
139                            cleaned_original
140                                .metadata
141                                .retain(|(k, _)| k != "effective_date");
142
143                            let new_txn = TransactionData {
144                                flag: txn.flag.clone(),
145                                payee: txn.payee.clone(),
146                                narration: txn.narration.clone(),
147                                tags: txn.tags.clone(),
148                                links: vec![link.clone()],
149                                metadata: vec![(
150                                    "original_date".to_string(),
151                                    MetaValueData::Date(entry_date.clone()),
152                                )],
153                                postings: vec![hold_posting, cleaned_original],
154                            };
155
156                            new_entries.push(DirectiveWrapper {
157                                directive_type: "transaction".to_string(),
158                                date: effective_date,
159                                filename: directive.filename.clone(),
160                                lineno: directive.lineno,
161                                data: DirectiveData::Transaction(new_txn),
162                            });
163                        } else {
164                            // No matching holding account, keep original
165                            modified_postings.push(posting.clone());
166                        }
167                    } else {
168                        // No effective_date, keep original
169                        modified_postings.push(posting.clone());
170                    }
171                }
172
173                txn.postings = modified_postings;
174            }
175
176            new_entries.push(directive);
177        }
178
179        // Create Open directives for new accounts
180        let mut open_directives: Vec<DirectiveWrapper> = Vec::new();
181        if let Some(date) = &earliest_date {
182            for account in &new_accounts {
183                open_directives.push(DirectiveWrapper {
184                    directive_type: "open".to_string(),
185                    date: date.clone(),
186                    filename: Some("<effective_date>".to_string()),
187                    lineno: Some(0),
188                    data: DirectiveData::Open(OpenData {
189                        account: account.clone(),
190                        currencies: vec![],
191                        booking: None,
192                        metadata: vec![],
193                    }),
194                });
195            }
196        }
197
198        // Sort new entries by date
199        new_entries.sort_by(|a, b| a.date.cmp(&b.date));
200
201        // Combine all entries
202        let mut all_directives = open_directives;
203        all_directives.extend(new_entries);
204        all_directives.extend(filtered_entries);
205
206        PluginOutput {
207            directives: all_directives,
208            errors: Vec::new(),
209        }
210    }
211}
212
213/// Check if a transaction has any posting with `effective_date` metadata.
214fn has_effective_date_posting(txn: &TransactionData) -> bool {
215    txn.postings.iter().any(|p| {
216        p.metadata
217            .iter()
218            .any(|(k, v)| k == "effective_date" && matches!(v, MetaValueData::Date(_)))
219    })
220}
221
222/// Get the `effective_date` from a posting's metadata.
223fn get_effective_date(posting: &PostingData) -> Option<String> {
224    for (key, value) in &posting.metadata {
225        if key == "effective_date"
226            && let MetaValueData::Date(d) = value
227        {
228            return Some(d.clone());
229        }
230    }
231    None
232}
233
234/// Find the appropriate holding account for a posting.
235fn find_holding_account(
236    account: &str,
237    effective_date: &str,
238    entry_date: &str,
239    holding_accounts: &HashMap<String, (String, String)>,
240) -> (Option<String>, bool) {
241    for (prefix, (earlier, later)) in holding_accounts {
242        if account.starts_with(prefix) {
243            let is_later = effective_date > entry_date;
244            let hold_acct = if is_later { later } else { earlier };
245            return (Some(hold_acct.clone()), is_later);
246        }
247    }
248    (None, false)
249}
250
251/// Find the account prefix that matches the holding accounts config.
252fn find_account_prefix(
253    account: &str,
254    holding_accounts: &HashMap<String, (String, String)>,
255) -> String {
256    for prefix in holding_accounts.keys() {
257        if account.starts_with(prefix) {
258            return prefix.clone();
259        }
260    }
261    String::new()
262}
263
264/// Create a posting with the opposite amount.
265fn create_opposite_posting(posting: &PostingData) -> PostingData {
266    let mut opposite = posting.clone();
267    if let Some(ref units) = opposite.units {
268        let number = if units.number.starts_with('-') {
269            units.number[1..].to_string()
270        } else {
271            format!("-{}", units.number)
272        };
273        opposite.units = Some(AmountData {
274            number,
275            currency: units.currency.clone(),
276        });
277    }
278    opposite
279}
280
281/// Counter for generating unique links.
282static LINK_COUNTER: AtomicUsize = AtomicUsize::new(0);
283
284/// Generate a unique link for effective date entries.
285fn generate_link(date: &str) -> String {
286    let date_short = date.replace('-', "");
287    let date_short = if date_short.len() > 6 {
288        &date_short[2..]
289    } else {
290        &date_short
291    };
292    let counter = LINK_COUNTER.fetch_add(1, Ordering::Relaxed);
293    format!("edate-{}-{:03x}", date_short, counter % 4096)
294}
295
296/// Parse the configuration string.
297fn parse_config(config: &str) -> Result<HashMap<String, (String, String)>, String> {
298    let mut result = HashMap::new();
299
300    // Parse format: {'Prefix': {'earlier': 'Account1', 'later': 'Account2'}, ...}
301    let re = regex::Regex::new(
302        r"'([^']+)'\s*:\s*\{\s*'earlier'\s*:\s*'([^']+)'\s*,\s*'later'\s*:\s*'([^']+)'\s*\}",
303    )
304    .map_err(|e| e.to_string())?;
305
306    for cap in re.captures_iter(config) {
307        let prefix = cap[1].to_string();
308        let earlier = cap[2].to_string();
309        let later = cap[3].to_string();
310        result.insert(prefix, (earlier, later));
311    }
312
313    if result.is_empty() {
314        return Err("No holding accounts found in config".to_string());
315    }
316
317    Ok(result)
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::types::*;
324
325    fn create_test_transaction_with_effective_date(
326        date: &str,
327        effective_date: &str,
328    ) -> DirectiveWrapper {
329        DirectiveWrapper {
330            directive_type: "transaction".to_string(),
331            date: date.to_string(),
332            filename: None,
333            lineno: None,
334            data: DirectiveData::Transaction(TransactionData {
335                flag: "*".to_string(),
336                payee: None,
337                narration: "Test with effective date".to_string(),
338                tags: vec![],
339                links: vec![],
340                metadata: vec![],
341                postings: vec![
342                    PostingData {
343                        account: "Assets:Cash".to_string(),
344                        units: Some(AmountData {
345                            number: "-100.00".to_string(),
346                            currency: "USD".to_string(),
347                        }),
348                        cost: None,
349                        price: None,
350                        flag: None,
351                        metadata: vec![],
352                    },
353                    PostingData {
354                        account: "Expenses:Food".to_string(),
355                        units: Some(AmountData {
356                            number: "100.00".to_string(),
357                            currency: "USD".to_string(),
358                        }),
359                        cost: None,
360                        price: None,
361                        flag: None,
362                        metadata: vec![(
363                            "effective_date".to_string(),
364                            MetaValueData::Date(effective_date.to_string()),
365                        )],
366                    },
367                ],
368            }),
369        }
370    }
371
372    #[test]
373    fn test_effective_date_later() {
374        let plugin = EffectiveDatePlugin;
375
376        let input = PluginInput {
377            directives: vec![create_test_transaction_with_effective_date(
378                "2024-01-15",
379                "2024-02-01",
380            )],
381            options: PluginOptions {
382                operating_currencies: vec!["USD".to_string()],
383                title: None,
384            },
385            config: None,
386        };
387
388        let output = plugin.process(input);
389        assert_eq!(output.errors.len(), 0);
390
391        // Should have: open directives + original modified + new at effective date
392        assert!(output.directives.len() >= 2);
393
394        // Check that we have a transaction at the effective date
395        let effective_txn_count = output
396            .directives
397            .iter()
398            .filter(|d| d.date == "2024-02-01" && d.directive_type == "transaction")
399            .count();
400        assert_eq!(effective_txn_count, 1);
401    }
402
403    #[test]
404    fn test_effective_date_earlier() {
405        let plugin = EffectiveDatePlugin;
406
407        let input = PluginInput {
408            directives: vec![create_test_transaction_with_effective_date(
409                "2024-02-01",
410                "2024-01-15",
411            )],
412            options: PluginOptions {
413                operating_currencies: vec!["USD".to_string()],
414                title: None,
415            },
416            config: None,
417        };
418
419        let output = plugin.process(input);
420        assert_eq!(output.errors.len(), 0);
421
422        // Check that we have a transaction at the earlier effective date
423        let effective_txn_count = output
424            .directives
425            .iter()
426            .filter(|d| d.date == "2024-01-15" && d.directive_type == "transaction")
427            .count();
428        assert_eq!(effective_txn_count, 1);
429    }
430
431    #[test]
432    fn test_no_effective_date_unchanged() {
433        let plugin = EffectiveDatePlugin;
434
435        let input = PluginInput {
436            directives: vec![DirectiveWrapper {
437                directive_type: "transaction".to_string(),
438                date: "2024-01-15".to_string(),
439                filename: None,
440                lineno: None,
441                data: DirectiveData::Transaction(TransactionData {
442                    flag: "*".to_string(),
443                    payee: None,
444                    narration: "Regular transaction".to_string(),
445                    tags: vec![],
446                    links: vec![],
447                    metadata: vec![],
448                    postings: vec![
449                        PostingData {
450                            account: "Assets:Cash".to_string(),
451                            units: Some(AmountData {
452                                number: "-100.00".to_string(),
453                                currency: "USD".to_string(),
454                            }),
455                            cost: None,
456                            price: None,
457                            flag: None,
458                            metadata: vec![],
459                        },
460                        PostingData {
461                            account: "Expenses:Food".to_string(),
462                            units: Some(AmountData {
463                                number: "100.00".to_string(),
464                                currency: "USD".to_string(),
465                            }),
466                            cost: None,
467                            price: None,
468                            flag: None,
469                            metadata: vec![],
470                        },
471                    ],
472                }),
473            }],
474            options: PluginOptions {
475                operating_currencies: vec!["USD".to_string()],
476                title: None,
477            },
478            config: None,
479        };
480
481        let output = plugin.process(input);
482        assert_eq!(output.errors.len(), 0);
483        // Should have exactly 1 transaction (unchanged)
484        let txn_count = output
485            .directives
486            .iter()
487            .filter(|d| d.directive_type == "transaction")
488            .count();
489        assert_eq!(txn_count, 1);
490    }
491}