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, PluginOp, 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        // Get current year end as default until date
69        let today = jiff::Zoned::now().date();
70        let default_until = rustledger_core::naive_date(i32::from(today.year()), 12, 31).unwrap();
71
72        let mut ops: Vec<PluginOp> = Vec::with_capacity(input.directives.len());
73
74        for (i, directive) in input.directives.into_iter().enumerate() {
75            // Only `#`-flagged transactions are forecast templates; all
76            // others pass through unchanged.
77            let is_forecast = matches!(&directive.data,
78                DirectiveData::Transaction(t) if t.flag == "#");
79            if !is_forecast {
80                ops.push(PluginOp::Keep(i));
81                continue;
82            }
83
84            if let DirectiveData::Transaction(ref txn) = directive.data {
85                if let Some(caps) = FORECAST_PATTERN_RE.captures(&txn.narration) {
86                    let narration_prefix = caps.get(1).map_or("", |m| m.as_str().trim());
87                    let interval_str = caps.get(2).map_or("MONTHLY", |m| m.as_str());
88                    let skip_count: usize = caps
89                        .get(3)
90                        .and_then(|m| m.as_str().parse().ok())
91                        .unwrap_or(0);
92                    let repeat_count: Option<usize> =
93                        caps.get(4).and_then(|m| m.as_str().parse().ok());
94                    let until_date: Option<NaiveDate> = caps
95                        .get(5)
96                        .and_then(|m| m.as_str().parse::<NaiveDate>().ok());
97
98                    let interval = match interval_str {
99                        "DAILY" => Interval::Daily,
100                        "WEEKLY" => Interval::Weekly,
101                        "YEARLY" => Interval::Yearly,
102                        _ => Interval::Monthly,
103                    };
104
105                    // Parse start date
106                    let start_date = if let Ok(date) = directive.date.parse::<NaiveDate>() {
107                        date
108                    } else {
109                        // Skip if date is unparsable — keep original template.
110                        ops.push(PluginOp::Keep(i));
111                        continue;
112                    };
113
114                    // Determine end condition
115                    let until = until_date.unwrap_or(default_until);
116
117                    // Generate dates
118                    let dates =
119                        generate_dates(start_date, interval, skip_count, repeat_count, until);
120
121                    // Drop the template (Delete) and Insert one transaction per date.
122                    ops.push(PluginOp::Delete(i));
123                    for date in dates {
124                        let mut new_directive = directive.clone();
125                        new_directive.date = date.to_string();
126                        new_directive.filename = None;
127                        new_directive.lineno = None;
128
129                        if let DirectiveData::Transaction(ref mut new_txn) = new_directive.data {
130                            new_txn.narration = narration_prefix.to_string();
131                        }
132
133                        ops.push(PluginOp::Insert(new_directive));
134                    }
135                } else {
136                    // No pattern match, keep original
137                    ops.push(PluginOp::Keep(i));
138                }
139            } else {
140                ops.push(PluginOp::Keep(i));
141            }
142        }
143
144        PluginOutput {
145            ops,
146            errors: Vec::new(),
147        }
148    }
149}
150
151/// Generate dates according to the specified interval and constraints.
152fn generate_dates(
153    start: NaiveDate,
154    interval: Interval,
155    skip: usize,
156    repeat: Option<usize>,
157    until: NaiveDate,
158) -> Vec<NaiveDate> {
159    let mut dates = Vec::new();
160    let mut current = start;
161    let step = skip + 1; // Skip means interval multiplier
162
163    loop {
164        dates.push(current);
165
166        // Check repeat count
167        if let Some(max_count) = repeat
168            && dates.len() >= max_count
169        {
170            break;
171        }
172
173        // Advance to next date
174        current = match interval {
175            Interval::Daily => current
176                .checked_add(jiff::ToSpan::days(step as i64))
177                .unwrap_or(current),
178            Interval::Weekly => current
179                .checked_add(jiff::ToSpan::weeks(step as i64))
180                .unwrap_or(current),
181            Interval::Monthly => current
182                .checked_add(jiff::ToSpan::months(step as i64))
183                .unwrap_or(current),
184            Interval::Yearly => current
185                .checked_add(jiff::ToSpan::years(step as i64))
186                .unwrap_or(current),
187        };
188
189        // Check until date
190        if current > until {
191            break;
192        }
193
194        // Safety limit
195        if dates.len() > 1000 {
196            break;
197        }
198    }
199
200    dates
201}
202
203#[cfg(test)]
204/// Add months to a date, handling month-end overflow.
205fn add_months(date: NaiveDate, months: i32) -> NaiveDate {
206    date.checked_add(jiff::ToSpan::months(i64::from(months)))
207        .unwrap_or(date)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::super::utils::materialize_ops;
213    use super::*;
214    use crate::types::*;
215
216    fn create_forecast_transaction(date: &str, narration: &str) -> DirectiveWrapper {
217        DirectiveWrapper {
218            directive_type: "transaction".to_string(),
219            date: date.to_string(),
220            filename: None,
221            lineno: None,
222            data: DirectiveData::Transaction(TransactionData {
223                flag: "#".to_string(),
224                payee: None,
225                narration: narration.to_string(),
226                tags: vec![],
227                links: vec![],
228                metadata: vec![],
229                postings: vec![
230                    PostingData {
231                        account: "Expenses:Test".to_string(),
232                        units: Some(AmountData {
233                            number: "100.00".to_string(),
234                            currency: "USD".to_string(),
235                        }),
236                        cost: None,
237                        price: None,
238                        flag: None,
239                        metadata: vec![],
240                    },
241                    PostingData {
242                        account: "Assets:Cash".to_string(),
243                        units: Some(AmountData {
244                            number: "-100.00".to_string(),
245                            currency: "USD".to_string(),
246                        }),
247                        cost: None,
248                        price: None,
249                        flag: None,
250                        metadata: vec![],
251                    },
252                ],
253            }),
254        }
255    }
256
257    #[test]
258    fn test_forecast_monthly_repeat() {
259        let plugin = ForecastPlugin;
260
261        let input = PluginInput {
262            directives: vec![create_forecast_transaction(
263                "2024-01-15",
264                "Electric bill [MONTHLY REPEAT 3 TIMES]",
265            )],
266            options: PluginOptions {
267                operating_currencies: vec!["USD".to_string()],
268                title: None,
269            },
270            config: None,
271        };
272
273        let input_dirs = input.directives.clone();
274        let output = plugin.process(input);
275        assert_eq!(output.errors.len(), 0);
276        let directives = materialize_ops(&input_dirs, &output);
277        assert_eq!(directives.len(), 3);
278
279        // Check dates
280        assert_eq!(directives[0].date, "2024-01-15");
281        assert_eq!(directives[1].date, "2024-02-15");
282        assert_eq!(directives[2].date, "2024-03-15");
283
284        // Check narration is cleaned
285        if let DirectiveData::Transaction(txn) = &directives[0].data {
286            assert_eq!(txn.narration, "Electric bill");
287        }
288    }
289
290    #[test]
291    fn test_forecast_weekly_repeat() {
292        let plugin = ForecastPlugin;
293
294        let input = PluginInput {
295            directives: vec![create_forecast_transaction(
296                "2024-01-01",
297                "Groceries [WEEKLY REPEAT 4 TIMES]",
298            )],
299            options: PluginOptions {
300                operating_currencies: vec!["USD".to_string()],
301                title: None,
302            },
303            config: None,
304        };
305
306        let input_dirs = input.directives.clone();
307        let output = plugin.process(input);
308        let directives = materialize_ops(&input_dirs, &output);
309        assert_eq!(directives.len(), 4);
310
311        assert_eq!(directives[0].date, "2024-01-01");
312        assert_eq!(directives[1].date, "2024-01-08");
313        assert_eq!(directives[2].date, "2024-01-15");
314        assert_eq!(directives[3].date, "2024-01-22");
315    }
316
317    #[test]
318    fn test_forecast_until_date() {
319        let plugin = ForecastPlugin;
320
321        let input = PluginInput {
322            directives: vec![create_forecast_transaction(
323                "2024-01-15",
324                "Rent [MONTHLY UNTIL 2024-03-15]",
325            )],
326            options: PluginOptions {
327                operating_currencies: vec!["USD".to_string()],
328                title: None,
329            },
330            config: None,
331        };
332
333        let input_dirs = input.directives.clone();
334        let output = plugin.process(input);
335        let directives = materialize_ops(&input_dirs, &output);
336        assert_eq!(directives.len(), 3);
337
338        assert_eq!(directives[0].date, "2024-01-15");
339        assert_eq!(directives[1].date, "2024-02-15");
340        assert_eq!(directives[2].date, "2024-03-15");
341    }
342
343    #[test]
344    fn test_forecast_skip() {
345        let plugin = ForecastPlugin;
346
347        let input = PluginInput {
348            directives: vec![create_forecast_transaction(
349                "2024-01-01",
350                "Insurance [MONTHLY SKIP 1 TIME REPEAT 3 TIMES]",
351            )],
352            options: PluginOptions {
353                operating_currencies: vec!["USD".to_string()],
354                title: None,
355            },
356            config: None,
357        };
358
359        let input_dirs = input.directives.clone();
360        let output = plugin.process(input);
361        let directives = materialize_ops(&input_dirs, &output);
362        assert_eq!(directives.len(), 3);
363
364        // With SKIP 1 TIME, it should skip every other month (bi-monthly)
365        assert_eq!(directives[0].date, "2024-01-01");
366        assert_eq!(directives[1].date, "2024-03-01");
367        assert_eq!(directives[2].date, "2024-05-01");
368    }
369
370    #[test]
371    fn test_forecast_preserves_non_forecast_transactions() {
372        let plugin = ForecastPlugin;
373
374        let mut regular_txn = create_forecast_transaction("2024-01-15", "Regular purchase");
375        if let DirectiveData::Transaction(ref mut txn) = regular_txn.data {
376            txn.flag = "*".to_string(); // Regular transaction, not forecast
377        }
378
379        let input = PluginInput {
380            directives: vec![regular_txn],
381            options: PluginOptions {
382                operating_currencies: vec!["USD".to_string()],
383                title: None,
384            },
385            config: None,
386        };
387
388        let input_dirs = input.directives.clone();
389        let output = plugin.process(input);
390        let directives = materialize_ops(&input_dirs, &output);
391        assert_eq!(directives.len(), 1);
392
393        if let DirectiveData::Transaction(txn) = &directives[0].data {
394            assert_eq!(txn.flag, "*");
395            assert_eq!(txn.narration, "Regular purchase");
396        }
397    }
398
399    #[test]
400    fn test_add_months() {
401        // Regular case
402        assert_eq!(
403            add_months(rustledger_core::naive_date(2024, 1, 15).unwrap(), 1),
404            rustledger_core::naive_date(2024, 2, 15).unwrap()
405        );
406
407        // Month-end overflow (Jan 31 -> Feb 28/29)
408        assert_eq!(
409            add_months(rustledger_core::naive_date(2024, 1, 31).unwrap(), 1),
410            rustledger_core::naive_date(2024, 2, 29).unwrap() // 2024 is leap year
411        );
412
413        // Year overflow
414        assert_eq!(
415            add_months(rustledger_core::naive_date(2024, 11, 15).unwrap(), 3),
416            rustledger_core::naive_date(2025, 2, 15).unwrap()
417        );
418    }
419}