Skip to main content

rustledger_plugin/native/plugins/
forecast.rs

1//! Forecast plugin - generate recurring transactions.
2//!
3//! This plugin generates recurring transactions from template transactions
4//! marked with the "#" flag. The periodicity is specified in the narration.
5//!
6//! Example:
7//! ```beancount
8//! 2014-03-08 # "Electricity bill [MONTHLY]"
9//!   Expenses:Electricity  50.10 USD
10//!   Assets:Checking      -50.10 USD
11//! ```
12//!
13//! Supported patterns:
14//! - `[MONTHLY]` - Repeat monthly until end of current year
15//! - `[WEEKLY]` - Repeat weekly until end of current year
16//! - `[DAILY]` - Repeat daily until end of current year
17//! - `[YEARLY]` - Repeat yearly until end of current year
18//! - `[MONTHLY REPEAT 3 TIMES]` - Repeat 3 times
19//! - `[MONTHLY UNTIL 2020-12-31]` - Repeat until specified date
20//! - `[MONTHLY SKIP 1 TIME]` - Skip every other month
21
22use regex::Regex;
23use rustledger_core::NaiveDate;
24use std::sync::LazyLock;
25
26use crate::types::{DirectiveData, PluginInput, PluginOutput};
27
28use super::super::NativePlugin;
29
30/// Regex for parsing forecast patterns in narrations.
31/// Matches: `[MONTHLY]`, `[WEEKLY SKIP 2 TIMES]`, `[MONTHLY UNTIL 2025-12-31]`, etc.
32static FORECAST_PATTERN_RE: LazyLock<Regex> = LazyLock::new(|| {
33    Regex::new(
34        r"(?x)
35        (^.*?)                             # narration prefix
36        \[
37        (MONTHLY|YEARLY|WEEKLY|DAILY)     # interval type
38        (?:\s+SKIP\s+(\d+)\s+TIMES?)?     # optional SKIP n TIMES
39        (?:\s+REPEAT\s+(\d+)\s+TIMES?)?   # optional REPEAT n TIMES
40        (?:\s+UNTIL\s+(\d{4}-\d{2}-\d{2}))? # optional UNTIL date
41        \]
42    ",
43    )
44    .expect("FORECAST_PATTERN_RE: invalid regex pattern")
45});
46
47/// Plugin for generating recurring forecast transactions.
48pub struct ForecastPlugin;
49
50#[derive(Debug, Clone, Copy, PartialEq)]
51enum Interval {
52    Daily,
53    Weekly,
54    Monthly,
55    Yearly,
56}
57
58impl NativePlugin for ForecastPlugin {
59    fn name(&self) -> &'static str {
60        "forecast"
61    }
62
63    fn description(&self) -> &'static str {
64        "Generate recurring forecast transactions"
65    }
66
67    fn process(&self, input: PluginInput) -> PluginOutput {
68        let mut forecast_entries = Vec::new();
69        let mut filtered_entries = Vec::new();
70
71        // Separate forecast entries from regular entries
72        for directive in input.directives {
73            if directive.directive_type == "transaction"
74                && let DirectiveData::Transaction(ref txn) = directive.data
75                && txn.flag == "#"
76            {
77                forecast_entries.push(directive);
78            } else {
79                filtered_entries.push(directive);
80            }
81        }
82
83        // Get current year end as default until date
84        let today = jiff::Zoned::now().date();
85        let default_until = rustledger_core::naive_date(i32::from(today.year()), 12, 31).unwrap();
86
87        // Generate recurring transactions
88        let mut new_entries = Vec::new();
89
90        for directive in forecast_entries {
91            if let DirectiveData::Transaction(ref txn) = directive.data {
92                if let Some(caps) = FORECAST_PATTERN_RE.captures(&txn.narration) {
93                    let narration_prefix = caps.get(1).map_or("", |m| m.as_str().trim());
94                    let interval_str = caps.get(2).map_or("MONTHLY", |m| m.as_str());
95                    let skip_count: usize = caps
96                        .get(3)
97                        .and_then(|m| m.as_str().parse().ok())
98                        .unwrap_or(0);
99                    let repeat_count: Option<usize> =
100                        caps.get(4).and_then(|m| m.as_str().parse().ok());
101                    let until_date: Option<NaiveDate> = caps
102                        .get(5)
103                        .and_then(|m| m.as_str().parse::<NaiveDate>().ok());
104
105                    let interval = match interval_str {
106                        "DAILY" => Interval::Daily,
107                        "WEEKLY" => Interval::Weekly,
108                        "YEARLY" => Interval::Yearly,
109                        _ => Interval::Monthly,
110                    };
111
112                    // Parse start date
113                    let start_date = if let Ok(date) = directive.date.parse::<NaiveDate>() {
114                        date
115                    } else {
116                        // Skip if date is unparsable
117                        new_entries.push(directive);
118                        continue;
119                    };
120
121                    // Determine end condition
122                    let until = until_date.unwrap_or(default_until);
123
124                    // Generate dates
125                    let dates =
126                        generate_dates(start_date, interval, skip_count, repeat_count, until);
127
128                    // Create a transaction for each date
129                    for date in dates {
130                        let mut new_directive = directive.clone();
131                        new_directive.date = date.to_string();
132
133                        if let DirectiveData::Transaction(ref mut new_txn) = new_directive.data {
134                            new_txn.narration = narration_prefix.to_string();
135                        }
136
137                        new_entries.push(new_directive);
138                    }
139                } else {
140                    // No pattern match, keep original
141                    new_entries.push(directive);
142                }
143            }
144        }
145
146        // Sort new entries by date
147        new_entries.sort_by(|a, b| a.date.cmp(&b.date));
148
149        // Combine filtered entries with new entries
150        filtered_entries.extend(new_entries);
151
152        PluginOutput {
153            directives: filtered_entries,
154            errors: Vec::new(),
155        }
156    }
157}
158
159/// Generate dates according to the specified interval and constraints.
160fn generate_dates(
161    start: NaiveDate,
162    interval: Interval,
163    skip: usize,
164    repeat: Option<usize>,
165    until: NaiveDate,
166) -> Vec<NaiveDate> {
167    let mut dates = Vec::new();
168    let mut current = start;
169    let step = skip + 1; // Skip means interval multiplier
170
171    loop {
172        dates.push(current);
173
174        // Check repeat count
175        if let Some(max_count) = repeat
176            && dates.len() >= max_count
177        {
178            break;
179        }
180
181        // Advance to next date
182        current = match interval {
183            Interval::Daily => current
184                .checked_add(jiff::ToSpan::days(step as i64))
185                .unwrap_or(current),
186            Interval::Weekly => current
187                .checked_add(jiff::ToSpan::weeks(step as i64))
188                .unwrap_or(current),
189            Interval::Monthly => current
190                .checked_add(jiff::ToSpan::months(step as i64))
191                .unwrap_or(current),
192            Interval::Yearly => current
193                .checked_add(jiff::ToSpan::years(step as i64))
194                .unwrap_or(current),
195        };
196
197        // Check until date
198        if current > until {
199            break;
200        }
201
202        // Safety limit
203        if dates.len() > 1000 {
204            break;
205        }
206    }
207
208    dates
209}
210
211#[cfg(test)]
212/// Add months to a date, handling month-end overflow.
213fn add_months(date: NaiveDate, months: i32) -> NaiveDate {
214    date.checked_add(jiff::ToSpan::months(i64::from(months)))
215        .unwrap_or(date)
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::types::*;
222
223    fn create_forecast_transaction(date: &str, narration: &str) -> DirectiveWrapper {
224        DirectiveWrapper {
225            directive_type: "transaction".to_string(),
226            date: date.to_string(),
227            filename: None,
228            lineno: None,
229            data: DirectiveData::Transaction(TransactionData {
230                flag: "#".to_string(),
231                payee: None,
232                narration: narration.to_string(),
233                tags: vec![],
234                links: vec![],
235                metadata: vec![],
236                postings: vec![
237                    PostingData {
238                        account: "Expenses:Test".to_string(),
239                        units: Some(AmountData {
240                            number: "100.00".to_string(),
241                            currency: "USD".to_string(),
242                        }),
243                        cost: None,
244                        price: None,
245                        flag: None,
246                        metadata: vec![],
247                    },
248                    PostingData {
249                        account: "Assets:Cash".to_string(),
250                        units: Some(AmountData {
251                            number: "-100.00".to_string(),
252                            currency: "USD".to_string(),
253                        }),
254                        cost: None,
255                        price: None,
256                        flag: None,
257                        metadata: vec![],
258                    },
259                ],
260            }),
261        }
262    }
263
264    #[test]
265    fn test_forecast_monthly_repeat() {
266        let plugin = ForecastPlugin;
267
268        let input = PluginInput {
269            directives: vec![create_forecast_transaction(
270                "2024-01-15",
271                "Electric bill [MONTHLY REPEAT 3 TIMES]",
272            )],
273            options: PluginOptions {
274                operating_currencies: vec!["USD".to_string()],
275                title: None,
276            },
277            config: None,
278        };
279
280        let output = plugin.process(input);
281        assert_eq!(output.errors.len(), 0);
282        assert_eq!(output.directives.len(), 3);
283
284        // Check dates
285        assert_eq!(output.directives[0].date, "2024-01-15");
286        assert_eq!(output.directives[1].date, "2024-02-15");
287        assert_eq!(output.directives[2].date, "2024-03-15");
288
289        // Check narration is cleaned
290        if let DirectiveData::Transaction(txn) = &output.directives[0].data {
291            assert_eq!(txn.narration, "Electric bill");
292        }
293    }
294
295    #[test]
296    fn test_forecast_weekly_repeat() {
297        let plugin = ForecastPlugin;
298
299        let input = PluginInput {
300            directives: vec![create_forecast_transaction(
301                "2024-01-01",
302                "Groceries [WEEKLY REPEAT 4 TIMES]",
303            )],
304            options: PluginOptions {
305                operating_currencies: vec!["USD".to_string()],
306                title: None,
307            },
308            config: None,
309        };
310
311        let output = plugin.process(input);
312        assert_eq!(output.directives.len(), 4);
313
314        assert_eq!(output.directives[0].date, "2024-01-01");
315        assert_eq!(output.directives[1].date, "2024-01-08");
316        assert_eq!(output.directives[2].date, "2024-01-15");
317        assert_eq!(output.directives[3].date, "2024-01-22");
318    }
319
320    #[test]
321    fn test_forecast_until_date() {
322        let plugin = ForecastPlugin;
323
324        let input = PluginInput {
325            directives: vec![create_forecast_transaction(
326                "2024-01-15",
327                "Rent [MONTHLY UNTIL 2024-03-15]",
328            )],
329            options: PluginOptions {
330                operating_currencies: vec!["USD".to_string()],
331                title: None,
332            },
333            config: None,
334        };
335
336        let output = plugin.process(input);
337        assert_eq!(output.directives.len(), 3);
338
339        assert_eq!(output.directives[0].date, "2024-01-15");
340        assert_eq!(output.directives[1].date, "2024-02-15");
341        assert_eq!(output.directives[2].date, "2024-03-15");
342    }
343
344    #[test]
345    fn test_forecast_skip() {
346        let plugin = ForecastPlugin;
347
348        let input = PluginInput {
349            directives: vec![create_forecast_transaction(
350                "2024-01-01",
351                "Insurance [MONTHLY SKIP 1 TIME REPEAT 3 TIMES]",
352            )],
353            options: PluginOptions {
354                operating_currencies: vec!["USD".to_string()],
355                title: None,
356            },
357            config: None,
358        };
359
360        let output = plugin.process(input);
361        assert_eq!(output.directives.len(), 3);
362
363        // With SKIP 1 TIME, it should skip every other month (bi-monthly)
364        assert_eq!(output.directives[0].date, "2024-01-01");
365        assert_eq!(output.directives[1].date, "2024-03-01");
366        assert_eq!(output.directives[2].date, "2024-05-01");
367    }
368
369    #[test]
370    fn test_forecast_preserves_non_forecast_transactions() {
371        let plugin = ForecastPlugin;
372
373        let mut regular_txn = create_forecast_transaction("2024-01-15", "Regular purchase");
374        if let DirectiveData::Transaction(ref mut txn) = regular_txn.data {
375            txn.flag = "*".to_string(); // Regular transaction, not forecast
376        }
377
378        let input = PluginInput {
379            directives: vec![regular_txn],
380            options: PluginOptions {
381                operating_currencies: vec!["USD".to_string()],
382                title: None,
383            },
384            config: None,
385        };
386
387        let output = plugin.process(input);
388        assert_eq!(output.directives.len(), 1);
389
390        if let DirectiveData::Transaction(txn) = &output.directives[0].data {
391            assert_eq!(txn.flag, "*");
392            assert_eq!(txn.narration, "Regular purchase");
393        }
394    }
395
396    #[test]
397    fn test_add_months() {
398        // Regular case
399        assert_eq!(
400            add_months(rustledger_core::naive_date(2024, 1, 15).unwrap(), 1),
401            rustledger_core::naive_date(2024, 2, 15).unwrap()
402        );
403
404        // Month-end overflow (Jan 31 -> Feb 28/29)
405        assert_eq!(
406            add_months(rustledger_core::naive_date(2024, 1, 31).unwrap(), 1),
407            rustledger_core::naive_date(2024, 2, 29).unwrap() // 2024 is leap year
408        );
409
410        // Year overflow
411        assert_eq!(
412            add_months(rustledger_core::naive_date(2024, 11, 15).unwrap(), 3),
413            rustledger_core::naive_date(2025, 2, 15).unwrap()
414        );
415    }
416}