rustledger_plugin/native/plugins/
box_accrual.rs1use 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
27pub 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 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 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 start_date.year() == expiry_date.year() {
102 ops.push(PluginOp::Keep(i));
103 continue;
104 }
105
106 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 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 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 seg_amt = seg_amt
145 .round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero);
146 rounded_sum += seg_amt;
147 } else {
148 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 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
209fn 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 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 let loss_postings: Vec<_> = t
292 .postings
293 .iter()
294 .filter(|p| p.account.ends_with(":Capital-Losses"))
295 .collect();
296
297 assert_eq!(loss_postings.len(), 2);
299
300 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 if let DirectiveData::Transaction(t) = &directives[0].data {
354 assert_eq!(t.postings.len(), 1);
355 }
356 }
357}