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 chrono::{Datelike, NaiveDate};
17use rust_decimal::Decimal;
18use rust_decimal::prelude::*;
19
20use crate::types::{
21    AmountData, DirectiveData, DirectiveWrapper, MetaValueData, PluginInput, PluginOutput,
22    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 new_directives = Vec::new();
41
42        for directive in input.directives {
43            if directive.directive_type != "transaction" {
44                new_directives.push(directive);
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) => NaiveDate::parse_from_str(d, "%Y-%m-%d").ok(),
56                        MetaValueData::String(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d").ok(),
57                        _ => None,
58                    });
59
60                let expiry_date = if let Some(d) = expiry_date {
61                    d
62                } else {
63                    new_directives.push(directive);
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                    new_directives.push(directive);
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                        new_directives.push(directive);
85                        continue;
86                    };
87                    (number, units.currency.clone())
88                } else {
89                    new_directives.push(directive);
90                    continue;
91                };
92
93                let start_date =
94                    if let Ok(d) = NaiveDate::parse_from_str(&directive.date, "%Y-%m-%d") {
95                        d
96                    } else {
97                        new_directives.push(directive);
98                        continue;
99                    };
100
101                // If same year, no splitting needed
102                if start_date.year() == expiry_date.year() {
103                    new_directives.push(directive);
104                    continue;
105                }
106
107                // Calculate total days (inclusive)
108                let total_days = (expiry_date - start_date).num_days() + 1;
109                if total_days <= 0 {
110                    new_directives.push(directive);
111                    continue;
112                }
113
114                // Build year splits
115                let mut fractions: Vec<(i32, i64, NaiveDate)> = Vec::new();
116                for year in start_date.year()..=expiry_date.year() {
117                    let seg_start = if year == start_date.year() {
118                        start_date
119                    } else {
120                        NaiveDate::from_ymd_opt(year, 1, 1).unwrap()
121                    };
122                    let seg_end = if year == expiry_date.year() {
123                        expiry_date
124                    } else {
125                        NaiveDate::from_ymd_opt(year, 12, 31).unwrap()
126                    };
127                    let seg_days = (seg_end - seg_start).num_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.format("%Y-%m-%d").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                new_directives.push(DirectiveWrapper {
180                    directive_type: "transaction".to_string(),
181                    date: directive.date.clone(),
182                    filename: directive.filename.clone(),
183                    lineno: directive.lineno,
184                    data: DirectiveData::Transaction(TransactionData {
185                        flag: txn.flag.clone(),
186                        payee: txn.payee.clone(),
187                        narration: txn.narration.clone(),
188                        tags: txn.tags.clone(),
189                        links: txn.links.clone(),
190                        metadata: txn.metadata.clone(),
191                        postings: new_postings,
192                    }),
193                });
194            } else {
195                new_directives.push(directive);
196            }
197        }
198
199        PluginOutput {
200            directives: new_directives,
201            errors: Vec::new(),
202        }
203    }
204}
205
206/// Format a decimal number with 2 decimal places.
207fn format_decimal(d: Decimal) -> String {
208    let s = d.to_string();
209    if s.contains('.') {
210        s.trim_end_matches('0').trim_end_matches('.').to_string()
211    } else {
212        s
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::types::*;
220
221    #[test]
222    fn test_box_accrual_splits_across_years() {
223        let plugin = BoxAccrualPlugin;
224
225        let input = PluginInput {
226            directives: vec![DirectiveWrapper {
227                directive_type: "transaction".to_string(),
228                date: "2024-07-01".to_string(),
229                filename: None,
230                lineno: None,
231                data: DirectiveData::Transaction(TransactionData {
232                    flag: "*".to_string(),
233                    payee: None,
234                    narration: "Sell synthetic".to_string(),
235                    tags: vec![],
236                    links: vec![],
237                    metadata: vec![(
238                        "synthetic_loan_expiry".to_string(),
239                        MetaValueData::Date("2025-06-30".to_string()),
240                    )],
241                    postings: vec![
242                        PostingData {
243                            account: "Assets:Broker".to_string(),
244                            units: Some(AmountData {
245                                number: "1000".to_string(),
246                                currency: "USD".to_string(),
247                            }),
248                            cost: None,
249                            price: None,
250                            flag: None,
251                            metadata: vec![],
252                        },
253                        PostingData {
254                            account: "Income:Capital-Losses".to_string(),
255                            units: Some(AmountData {
256                                number: "-365".to_string(),
257                                currency: "USD".to_string(),
258                            }),
259                            cost: None,
260                            price: None,
261                            flag: None,
262                            metadata: vec![],
263                        },
264                    ],
265                }),
266            }],
267            options: PluginOptions {
268                operating_currencies: vec!["USD".to_string()],
269                title: None,
270            },
271            config: None,
272        };
273
274        let output = plugin.process(input);
275        assert_eq!(output.errors.len(), 0);
276
277        // Find the transaction
278        let txn = output
279            .directives
280            .iter()
281            .find(|d| d.directive_type == "transaction");
282        assert!(txn.is_some());
283
284        if let DirectiveData::Transaction(t) = &txn.unwrap().data {
285            // Should have multiple Capital-Losses postings (split across years)
286            let loss_postings: Vec<_> = t
287                .postings
288                .iter()
289                .filter(|p| p.account.ends_with(":Capital-Losses"))
290                .collect();
291
292            // Should have 2 splits (2024 and 2025)
293            assert_eq!(loss_postings.len(), 2);
294
295            // Each should have effective_date metadata
296            for posting in &loss_postings {
297                assert!(posting.metadata.iter().any(|(k, _)| k == "effective_date"));
298            }
299        }
300    }
301
302    #[test]
303    fn test_box_accrual_same_year_unchanged() {
304        let plugin = BoxAccrualPlugin;
305
306        let input = PluginInput {
307            directives: vec![DirectiveWrapper {
308                directive_type: "transaction".to_string(),
309                date: "2024-01-01".to_string(),
310                filename: None,
311                lineno: None,
312                data: DirectiveData::Transaction(TransactionData {
313                    flag: "*".to_string(),
314                    payee: None,
315                    narration: "Sell".to_string(),
316                    tags: vec![],
317                    links: vec![],
318                    metadata: vec![(
319                        "synthetic_loan_expiry".to_string(),
320                        MetaValueData::Date("2024-12-31".to_string()),
321                    )],
322                    postings: vec![PostingData {
323                        account: "Income:Capital-Losses".to_string(),
324                        units: Some(AmountData {
325                            number: "-100".to_string(),
326                            currency: "USD".to_string(),
327                        }),
328                        cost: None,
329                        price: None,
330                        flag: None,
331                        metadata: vec![],
332                    }],
333                }),
334            }],
335            options: PluginOptions {
336                operating_currencies: vec!["USD".to_string()],
337                title: None,
338            },
339            config: None,
340        };
341
342        let output = plugin.process(input);
343        assert_eq!(output.errors.len(), 0);
344
345        // Should be unchanged (same year)
346        if let DirectiveData::Transaction(t) = &output.directives[0].data {
347            assert_eq!(t.postings.len(), 1);
348        }
349    }
350}