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, RegularPlugin};
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 span: None,
168 });
169 }
170
171 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
212fn 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 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 let loss_postings: Vec<_> = t
297 .postings
298 .iter()
299 .filter(|p| p.account.ends_with(":Capital-Losses"))
300 .collect();
301
302 assert_eq!(loss_postings.len(), 2);
304
305 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 if let DirectiveData::Transaction(t) = &directives[0].data {
360 assert_eq!(t.postings.len(), 1);
361 }
362 }
363}