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;
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                    });
168                }
169
170                // Build new postings: all except the original loss posting + splits
171                let mut new_postings: Vec<PostingData> = txn
172                    .postings
173                    .iter()
174                    .filter(|p| !p.account.ends_with(":Capital-Losses"))
175                    .cloned()
176                    .collect();
177                new_postings.extend(splits);
178
179                ops.push(PluginOp::Modify(
180                    i,
181                    DirectiveWrapper {
182                        directive_type: "transaction".to_string(),
183                        date: directive.date.clone(),
184                        filename: directive.filename.clone(),
185                        lineno: directive.lineno,
186                        data: DirectiveData::Transaction(TransactionData {
187                            flag: txn.flag.clone(),
188                            payee: txn.payee.clone(),
189                            narration: txn.narration.clone(),
190                            tags: txn.tags.clone(),
191                            links: txn.links.clone(),
192                            metadata: txn.metadata.clone(),
193                            postings: new_postings,
194                        }),
195                    },
196                ));
197            } else {
198                ops.push(PluginOp::Keep(i));
199            }
200        }
201
202        PluginOutput {
203            ops,
204            errors: Vec::new(),
205        }
206    }
207}
208
209/// Format a decimal number with 2 decimal places.
210fn format_decimal(d: Decimal) -> String {
211    let s = d.to_string();
212    if s.contains('.') {
213        s.trim_end_matches('0').trim_end_matches('.').to_string()
214    } else {
215        s
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::super::utils::materialize_ops;
222    use super::*;
223    use crate::types::*;
224
225    #[test]
226    fn test_box_accrual_splits_across_years() {
227        let plugin = BoxAccrualPlugin;
228
229        let input = PluginInput {
230            directives: vec![DirectiveWrapper {
231                directive_type: "transaction".to_string(),
232                date: "2024-07-01".to_string(),
233                filename: None,
234                lineno: None,
235                data: DirectiveData::Transaction(TransactionData {
236                    flag: "*".to_string(),
237                    payee: None,
238                    narration: "Sell synthetic".to_string(),
239                    tags: vec![],
240                    links: vec![],
241                    metadata: vec![(
242                        "synthetic_loan_expiry".to_string(),
243                        MetaValueData::Date("2025-06-30".to_string()),
244                    )],
245                    postings: vec![
246                        PostingData {
247                            account: "Assets:Broker".to_string(),
248                            units: Some(AmountData {
249                                number: "1000".to_string(),
250                                currency: "USD".to_string(),
251                            }),
252                            cost: None,
253                            price: None,
254                            flag: None,
255                            metadata: vec![],
256                        },
257                        PostingData {
258                            account: "Income:Capital-Losses".to_string(),
259                            units: Some(AmountData {
260                                number: "-365".to_string(),
261                                currency: "USD".to_string(),
262                            }),
263                            cost: None,
264                            price: None,
265                            flag: None,
266                            metadata: vec![],
267                        },
268                    ],
269                }),
270            }],
271            options: PluginOptions {
272                operating_currencies: vec!["USD".to_string()],
273                title: None,
274            },
275            config: None,
276        };
277
278        let input_dirs = input.directives.clone();
279        let output = plugin.process(input);
280        assert_eq!(output.errors.len(), 0);
281        let directives = materialize_ops(&input_dirs, &output);
282
283        // Find the transaction
284        let txn = directives
285            .iter()
286            .find(|d| matches!(d.data, DirectiveData::Transaction(_)));
287        assert!(txn.is_some());
288
289        if let DirectiveData::Transaction(t) = &txn.unwrap().data {
290            // Should have multiple Capital-Losses postings (split across years)
291            let loss_postings: Vec<_> = t
292                .postings
293                .iter()
294                .filter(|p| p.account.ends_with(":Capital-Losses"))
295                .collect();
296
297            // Should have 2 splits (2024 and 2025)
298            assert_eq!(loss_postings.len(), 2);
299
300            // Each should have effective_date metadata
301            for posting in &loss_postings {
302                assert!(posting.metadata.iter().any(|(k, _)| k == "effective_date"));
303            }
304        }
305    }
306
307    #[test]
308    fn test_box_accrual_same_year_unchanged() {
309        let plugin = BoxAccrualPlugin;
310
311        let input = PluginInput {
312            directives: vec![DirectiveWrapper {
313                directive_type: "transaction".to_string(),
314                date: "2024-01-01".to_string(),
315                filename: None,
316                lineno: None,
317                data: DirectiveData::Transaction(TransactionData {
318                    flag: "*".to_string(),
319                    payee: None,
320                    narration: "Sell".to_string(),
321                    tags: vec![],
322                    links: vec![],
323                    metadata: vec![(
324                        "synthetic_loan_expiry".to_string(),
325                        MetaValueData::Date("2024-12-31".to_string()),
326                    )],
327                    postings: vec![PostingData {
328                        account: "Income:Capital-Losses".to_string(),
329                        units: Some(AmountData {
330                            number: "-100".to_string(),
331                            currency: "USD".to_string(),
332                        }),
333                        cost: None,
334                        price: None,
335                        flag: None,
336                        metadata: vec![],
337                    }],
338                }),
339            }],
340            options: PluginOptions {
341                operating_currencies: vec!["USD".to_string()],
342                title: None,
343            },
344            config: None,
345        };
346
347        let input_dirs = input.directives.clone();
348        let output = plugin.process(input);
349        assert_eq!(output.errors.len(), 0);
350        let directives = materialize_ops(&input_dirs, &output);
351
352        // Should be unchanged (same year)
353        if let DirectiveData::Transaction(t) = &directives[0].data {
354            assert_eq!(t.postings.len(), 1);
355        }
356    }
357}