rustledger_plugin/native/plugins/
box_accrual.rs1use 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
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 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 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 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 start_date.year() == expiry_date.year() {
103 new_directives.push(directive);
104 continue;
105 }
106
107 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 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 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.format("%Y-%m-%d").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 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
206fn 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 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 let loss_postings: Vec<_> = t
287 .postings
288 .iter()
289 .filter(|p| p.account.ends_with(":Capital-Losses"))
290 .collect();
291
292 assert_eq!(loss_postings.len(), 2);
294
295 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 if let DirectiveData::Transaction(t) = &output.directives[0].data {
347 assert_eq!(t.postings.len(), 1);
348 }
349 }
350}