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, RegularPlugin};
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
151impl RegularPlugin for ForecastPlugin {}
152
153/// Generate dates according to the specified interval and constraints.
154fn generate_dates(
155    start: NaiveDate,
156    interval: Interval,
157    skip: usize,
158    repeat: Option<usize>,
159    until: NaiveDate,
160) -> Vec<NaiveDate> {
161    let mut dates = Vec::new();
162    let mut current = start;
163    let step = skip + 1; // Skip means interval multiplier
164
165    loop {
166        dates.push(current);
167
168        // Check repeat count
169        if let Some(max_count) = repeat
170            && dates.len() >= max_count
171        {
172            break;
173        }
174
175        // Advance to next date
176        current = match interval {
177            Interval::Daily => current
178                .checked_add(jiff::ToSpan::days(step as i64))
179                .unwrap_or(current),
180            Interval::Weekly => current
181                .checked_add(jiff::ToSpan::weeks(step as i64))
182                .unwrap_or(current),
183            Interval::Monthly => current
184                .checked_add(jiff::ToSpan::months(step as i64))
185                .unwrap_or(current),
186            Interval::Yearly => current
187                .checked_add(jiff::ToSpan::years(step as i64))
188                .unwrap_or(current),
189        };
190
191        // Check until date
192        if current > until {
193            break;
194        }
195
196        // Safety limit
197        if dates.len() > 1000 {
198            break;
199        }
200    }
201
202    dates
203}
204
205#[cfg(test)]
206/// Add months to a date, handling month-end overflow.
207fn add_months(date: NaiveDate, months: i32) -> NaiveDate {
208    date.checked_add(jiff::ToSpan::months(i64::from(months)))
209        .unwrap_or(date)
210}
211
212#[cfg(test)]
213mod tests {
214    use super::super::utils::materialize_ops;
215    use super::*;
216    use crate::types::*;
217
218    fn create_forecast_transaction(date: &str, narration: &str) -> DirectiveWrapper {
219        DirectiveWrapper {
220            directive_type: "transaction".to_string(),
221            date: date.to_string(),
222            filename: None,
223            lineno: None,
224            data: DirectiveData::Transaction(TransactionData {
225                flag: "#".to_string(),
226                payee: None,
227                narration: narration.to_string(),
228                tags: vec![],
229                links: vec![],
230                metadata: vec![],
231                postings: vec![
232                    PostingData {
233                        account: "Expenses:Test".to_string(),
234                        units: Some(AmountData {
235                            number: "100.00".to_string(),
236                            currency: "USD".to_string(),
237                        }),
238                        cost: None,
239                        price: None,
240                        flag: None,
241                        metadata: vec![],
242                        span: None,
243                    },
244                    PostingData {
245                        account: "Assets:Cash".to_string(),
246                        units: Some(AmountData {
247                            number: "-100.00".to_string(),
248                            currency: "USD".to_string(),
249                        }),
250                        cost: None,
251                        price: None,
252                        flag: None,
253                        metadata: vec![],
254                        span: None,
255                    },
256                ],
257            }),
258        }
259    }
260
261    #[test]
262    fn test_forecast_monthly_repeat() {
263        let plugin = ForecastPlugin;
264
265        let input = PluginInput {
266            directives: vec![create_forecast_transaction(
267                "2024-01-15",
268                "Electric bill [MONTHLY REPEAT 3 TIMES]",
269            )],
270            options: PluginOptions {
271                operating_currencies: vec!["USD".to_string()],
272                title: None,
273            },
274            config: None,
275        };
276
277        let input_dirs = input.directives.clone();
278        let output = plugin.process(input);
279        assert_eq!(output.errors.len(), 0);
280        let directives = materialize_ops(&input_dirs, &output);
281        assert_eq!(directives.len(), 3);
282
283        // Check dates
284        assert_eq!(directives[0].date, "2024-01-15");
285        assert_eq!(directives[1].date, "2024-02-15");
286        assert_eq!(directives[2].date, "2024-03-15");
287
288        // Check narration is cleaned
289        if let DirectiveData::Transaction(txn) = &directives[0].data {
290            assert_eq!(txn.narration, "Electric bill");
291        }
292    }
293
294    #[test]
295    fn test_forecast_weekly_repeat() {
296        let plugin = ForecastPlugin;
297
298        let input = PluginInput {
299            directives: vec![create_forecast_transaction(
300                "2024-01-01",
301                "Groceries [WEEKLY REPEAT 4 TIMES]",
302            )],
303            options: PluginOptions {
304                operating_currencies: vec!["USD".to_string()],
305                title: None,
306            },
307            config: None,
308        };
309
310        let input_dirs = input.directives.clone();
311        let output = plugin.process(input);
312        let directives = materialize_ops(&input_dirs, &output);
313        assert_eq!(directives.len(), 4);
314
315        assert_eq!(directives[0].date, "2024-01-01");
316        assert_eq!(directives[1].date, "2024-01-08");
317        assert_eq!(directives[2].date, "2024-01-15");
318        assert_eq!(directives[3].date, "2024-01-22");
319    }
320
321    #[test]
322    fn test_forecast_until_date() {
323        let plugin = ForecastPlugin;
324
325        let input = PluginInput {
326            directives: vec![create_forecast_transaction(
327                "2024-01-15",
328                "Rent [MONTHLY UNTIL 2024-03-15]",
329            )],
330            options: PluginOptions {
331                operating_currencies: vec!["USD".to_string()],
332                title: None,
333            },
334            config: None,
335        };
336
337        let input_dirs = input.directives.clone();
338        let output = plugin.process(input);
339        let directives = materialize_ops(&input_dirs, &output);
340        assert_eq!(directives.len(), 3);
341
342        assert_eq!(directives[0].date, "2024-01-15");
343        assert_eq!(directives[1].date, "2024-02-15");
344        assert_eq!(directives[2].date, "2024-03-15");
345    }
346
347    #[test]
348    fn test_forecast_skip() {
349        let plugin = ForecastPlugin;
350
351        let input = PluginInput {
352            directives: vec![create_forecast_transaction(
353                "2024-01-01",
354                "Insurance [MONTHLY SKIP 1 TIME REPEAT 3 TIMES]",
355            )],
356            options: PluginOptions {
357                operating_currencies: vec!["USD".to_string()],
358                title: None,
359            },
360            config: None,
361        };
362
363        let input_dirs = input.directives.clone();
364        let output = plugin.process(input);
365        let directives = materialize_ops(&input_dirs, &output);
366        assert_eq!(directives.len(), 3);
367
368        // With SKIP 1 TIME, it should skip every other month (bi-monthly)
369        assert_eq!(directives[0].date, "2024-01-01");
370        assert_eq!(directives[1].date, "2024-03-01");
371        assert_eq!(directives[2].date, "2024-05-01");
372    }
373
374    #[test]
375    fn test_forecast_preserves_non_forecast_transactions() {
376        let plugin = ForecastPlugin;
377
378        let mut regular_txn = create_forecast_transaction("2024-01-15", "Regular purchase");
379        if let DirectiveData::Transaction(ref mut txn) = regular_txn.data {
380            txn.flag = "*".to_string(); // Regular transaction, not forecast
381        }
382
383        let input = PluginInput {
384            directives: vec![regular_txn],
385            options: PluginOptions {
386                operating_currencies: vec!["USD".to_string()],
387                title: None,
388            },
389            config: None,
390        };
391
392        let input_dirs = input.directives.clone();
393        let output = plugin.process(input);
394        let directives = materialize_ops(&input_dirs, &output);
395        assert_eq!(directives.len(), 1);
396
397        if let DirectiveData::Transaction(txn) = &directives[0].data {
398            assert_eq!(txn.flag, "*");
399            assert_eq!(txn.narration, "Regular purchase");
400        }
401    }
402
403    #[test]
404    fn test_add_months() {
405        // Regular case
406        assert_eq!(
407            add_months(rustledger_core::naive_date(2024, 1, 15).unwrap(), 1),
408            rustledger_core::naive_date(2024, 2, 15).unwrap()
409        );
410
411        // Month-end overflow (Jan 31 -> Feb 28/29)
412        assert_eq!(
413            add_months(rustledger_core::naive_date(2024, 1, 31).unwrap(), 1),
414            rustledger_core::naive_date(2024, 2, 29).unwrap() // 2024 is leap year
415        );
416
417        // Year overflow
418        assert_eq!(
419            add_months(rustledger_core::naive_date(2024, 11, 15).unwrap(), 3),
420            rustledger_core::naive_date(2025, 2, 15).unwrap()
421        );
422    }
423}