Skip to main content

rustledger_plugin/native/plugins/
box_accrual.rs

1//! Box accrual plugin - splits capital losses across multiple years.
2//!
3//! This plugin looks for transactions with `synthetic_loan_expiry` metadata
4//! and splits Capital-Losses postings proportionally across years.
5//!
6//! Usage:
7//! ```text
8//! plugin "beancount_reds_plugins.box_accrual.box_accrual"
9//!
10//! 2024-01-15 * "Sell synthetic"
11//!   synthetic_loan_expiry: 2026-06-30
12//!   Assets:Broker        1000 USD
13//!   Income:Capital-Losses  -500 USD
14//! ```
15
16use rust_decimal::Decimal;
17use rust_decimal::prelude::*;
18use rustledger_core::NaiveDate;
19
20use crate::types::{
21    AmountData, DirectiveData, DirectiveWrapper, MetaValueData, PluginInput, PluginOp,
22    PluginOutput, PostingData, TransactionData,
23};
24
25use super::super::{NativePlugin, RegularPlugin};
26
27/// Plugin for splitting capital losses across multiple years.
28pub struct BoxAccrualPlugin;
29
30impl NativePlugin for BoxAccrualPlugin {
31    fn name(&self) -> &'static str {
32        "box_accrual"
33    }
34
35    fn description(&self) -> &'static str {
36        "Split capital losses across multiple years based on expiry date"
37    }
38
39    fn process(&self, input: PluginInput) -> PluginOutput {
40        let mut ops: Vec<PluginOp> = Vec::with_capacity(input.directives.len());
41
42        for (i, directive) in input.directives.into_iter().enumerate() {
43            if directive.directive_type != "transaction" {
44                ops.push(PluginOp::Keep(i));
45                continue;
46            }
47
48            if let DirectiveData::Transaction(txn) = &directive.data {
49                // Look for synthetic_loan_expiry in metadata
50                let expiry_date = txn
51                    .metadata
52                    .iter()
53                    .find(|(k, _)| k == "synthetic_loan_expiry")
54                    .and_then(|(_, v)| match v {
55                        MetaValueData::Date(d) => d.parse::<NaiveDate>().ok(),
56                        MetaValueData::String(s) => s.parse::<NaiveDate>().ok(),
57                        _ => None,
58                    });
59
60                let expiry_date = if let Some(d) = expiry_date {
61                    d
62                } else {
63                    ops.push(PluginOp::Keep(i));
64                    continue;
65                };
66
67                // Find Capital-Losses posting
68                let losses: Vec<&PostingData> = txn
69                    .postings
70                    .iter()
71                    .filter(|p| p.account.ends_with(":Capital-Losses"))
72                    .collect();
73
74                if losses.len() != 1 {
75                    ops.push(PluginOp::Keep(i));
76                    continue;
77                }
78
79                let loss_posting = losses[0];
80                let (total_loss, currency) = if let Some(units) = &loss_posting.units {
81                    let number = if let Ok(n) = Decimal::from_str(&units.number) {
82                        n
83                    } else {
84                        ops.push(PluginOp::Keep(i));
85                        continue;
86                    };
87                    (number, units.currency.clone())
88                } else {
89                    ops.push(PluginOp::Keep(i));
90                    continue;
91                };
92
93                let start_date = if let Ok(d) = directive.date.parse::<NaiveDate>() {
94                    d
95                } else {
96                    ops.push(PluginOp::Keep(i));
97                    continue;
98                };
99
100                // If same year, no splitting needed
101                if start_date.year() == expiry_date.year() {
102                    ops.push(PluginOp::Keep(i));
103                    continue;
104                }
105
106                // Calculate total days (inclusive)
107                let total_days =
108                    i64::from(expiry_date.since(start_date).unwrap_or_default().get_days()) + 1;
109                if total_days <= 0 {
110                    ops.push(PluginOp::Keep(i));
111                    continue;
112                }
113
114                // Build year splits
115                let mut fractions: Vec<(i32, i64, NaiveDate)> = Vec::new();
116                for year in i32::from(start_date.year())..=i32::from(expiry_date.year()) {
117                    let seg_start = if year == i32::from(start_date.year()) {
118                        start_date
119                    } else {
120                        rustledger_core::naive_date(year, 1, 1).unwrap()
121                    };
122                    let seg_end = if year == i32::from(expiry_date.year()) {
123                        expiry_date
124                    } else {
125                        rustledger_core::naive_date(year, 12, 31).unwrap()
126                    };
127                    let seg_days = i64::from(seg_end.since(seg_start).unwrap().get_days()) + 1;
128                    if seg_days > 0 {
129                        fractions.push((year, seg_days, seg_end));
130                    }
131                }
132
133                // Calculate and round each year's loss
134                let mut splits: Vec<PostingData> = Vec::new();
135                let mut rounded_sum = Decimal::ZERO;
136                let total_days_dec = Decimal::from(total_days);
137
138                for (i, (_year, seg_days, seg_end)) in fractions.iter().enumerate() {
139                    let frac = Decimal::from(*seg_days) / total_days_dec;
140                    let mut seg_amt = total_loss * frac;
141
142                    if i < fractions.len() - 1 {
143                        // Round to 2 decimal places
144                        seg_amt = seg_amt
145                            .round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero);
146                        rounded_sum += seg_amt;
147                    } else {
148                        // Final segment = remainder
149                        seg_amt = total_loss - rounded_sum;
150                        seg_amt = seg_amt
151                            .round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero);
152                    }
153
154                    splits.push(PostingData {
155                        account: loss_posting.account.clone(),
156                        units: Some(AmountData {
157                            number: format_decimal(seg_amt),
158                            currency: currency.clone(),
159                        }),
160                        cost: None,
161                        price: None,
162                        flag: None,
163                        metadata: vec![(
164                            "effective_date".to_string(),
165                            MetaValueData::Date(seg_end.to_string()),
166                        )],
167                        span: None,
168                    });
169                }
170
171                // Build new postings: all except the original loss posting + splits
172                let mut new_postings: Vec<PostingData> = txn
173                    .postings
174                    .iter()
175                    .filter(|p| !p.account.ends_with(":Capital-Losses"))
176                    .cloned()
177                    .collect();
178                new_postings.extend(splits);
179
180                ops.push(PluginOp::Modify(
181                    i,
182                    DirectiveWrapper {
183                        directive_type: "transaction".to_string(),
184                        date: directive.date.clone(),
185                        filename: directive.filename.clone(),
186                        lineno: directive.lineno,
187                        data: DirectiveData::Transaction(TransactionData {
188                            flag: txn.flag.clone(),
189                            payee: txn.payee.clone(),
190                            narration: txn.narration.clone(),
191                            tags: txn.tags.clone(),
192                            links: txn.links.clone(),
193                            metadata: txn.metadata.clone(),
194                            postings: new_postings,
195                        }),
196                    },
197                ));
198            } else {
199                ops.push(PluginOp::Keep(i));
200            }
201        }
202
203        PluginOutput {
204            ops,
205            errors: Vec::new(),
206        }
207    }
208}
209
210impl RegularPlugin for BoxAccrualPlugin {}
211
212/// Format a decimal number with 2 decimal places.
213fn format_decimal(d: Decimal) -> String {
214    let s = d.to_string();
215    if s.contains('.') {
216        s.trim_end_matches('0').trim_end_matches('.').to_string()
217    } else {
218        s
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::super::utils::materialize_ops;
225    use super::*;
226    use crate::types::*;
227
228    #[test]
229    fn test_box_accrual_splits_across_years() {
230        let plugin = BoxAccrualPlugin;
231
232        let input = PluginInput {
233            directives: vec![DirectiveWrapper {
234                directive_type: "transaction".to_string(),
235                date: "2024-07-01".to_string(),
236                filename: None,
237                lineno: None,
238                data: DirectiveData::Transaction(TransactionData {
239                    flag: "*".to_string(),
240                    payee: None,
241                    narration: "Sell synthetic".to_string(),
242                    tags: vec![],
243                    links: vec![],
244                    metadata: vec![(
245                        "synthetic_loan_expiry".to_string(),
246                        MetaValueData::Date("2025-06-30".to_string()),
247                    )],
248                    postings: vec![
249                        PostingData {
250                            account: "Assets:Broker".to_string(),
251                            units: Some(AmountData {
252                                number: "1000".to_string(),
253                                currency: "USD".to_string(),
254                            }),
255                            cost: None,
256                            price: None,
257                            flag: None,
258                            metadata: vec![],
259                            span: None,
260                        },
261                        PostingData {
262                            account: "Income:Capital-Losses".to_string(),
263                            units: Some(AmountData {
264                                number: "-365".to_string(),
265                                currency: "USD".to_string(),
266                            }),
267                            cost: None,
268                            price: None,
269                            flag: None,
270                            metadata: vec![],
271                            span: None,
272                        },
273                    ],
274                }),
275            }],
276            options: PluginOptions {
277                operating_currencies: vec!["USD".to_string()],
278                title: None,
279            },
280            config: None,
281        };
282
283        let input_dirs = input.directives.clone();
284        let output = plugin.process(input);
285        assert_eq!(output.errors.len(), 0);
286        let directives = materialize_ops(&input_dirs, &output);
287
288        // Find the transaction
289        let txn = directives
290            .iter()
291            .find(|d| matches!(d.data, DirectiveData::Transaction(_)));
292        assert!(txn.is_some());
293
294        if let DirectiveData::Transaction(t) = &txn.unwrap().data {
295            // Should have multiple Capital-Losses postings (split across years)
296            let loss_postings: Vec<_> = t
297                .postings
298                .iter()
299                .filter(|p| p.account.ends_with(":Capital-Losses"))
300                .collect();
301
302            // Should have 2 splits (2024 and 2025)
303            assert_eq!(loss_postings.len(), 2);
304
305            // Each should have effective_date metadata
306            for posting in &loss_postings {
307                assert!(posting.metadata.iter().any(|(k, _)| k == "effective_date"));
308            }
309        }
310    }
311
312    #[test]
313    fn test_box_accrual_same_year_unchanged() {
314        let plugin = BoxAccrualPlugin;
315
316        let input = PluginInput {
317            directives: vec![DirectiveWrapper {
318                directive_type: "transaction".to_string(),
319                date: "2024-01-01".to_string(),
320                filename: None,
321                lineno: None,
322                data: DirectiveData::Transaction(TransactionData {
323                    flag: "*".to_string(),
324                    payee: None,
325                    narration: "Sell".to_string(),
326                    tags: vec![],
327                    links: vec![],
328                    metadata: vec![(
329                        "synthetic_loan_expiry".to_string(),
330                        MetaValueData::Date("2024-12-31".to_string()),
331                    )],
332                    postings: vec![PostingData {
333                        account: "Income:Capital-Losses".to_string(),
334                        units: Some(AmountData {
335                            number: "-100".to_string(),
336                            currency: "USD".to_string(),
337                        }),
338                        cost: None,
339                        price: None,
340                        flag: None,
341                        metadata: vec![],
342                        span: None,
343                    }],
344                }),
345            }],
346            options: PluginOptions {
347                operating_currencies: vec!["USD".to_string()],
348                title: None,
349            },
350            config: None,
351        };
352
353        let input_dirs = input.directives.clone();
354        let output = plugin.process(input);
355        assert_eq!(output.errors.len(), 0);
356        let directives = materialize_ops(&input_dirs, &output);
357
358        // Should be unchanged (same year)
359        if let DirectiveData::Transaction(t) = &directives[0].data {
360            assert_eq!(t.postings.len(), 1);
361        }
362    }
363}