rustledger_plugin/native/plugins/
currency_accounts.rs1use crate::types::{DirectiveData, DirectiveWrapper, PluginInput, PluginOutput};
4
5use super::super::NativePlugin;
6
7pub struct CurrencyAccountsPlugin {
14 base_account: String,
16}
17
18impl CurrencyAccountsPlugin {
19 pub fn new() -> Self {
21 Self {
22 base_account: "Equity:CurrencyAccounts".to_string(),
23 }
24 }
25
26 pub const fn with_base_account(base_account: String) -> Self {
28 Self { base_account }
29 }
30}
31
32impl Default for CurrencyAccountsPlugin {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl NativePlugin for CurrencyAccountsPlugin {
39 fn name(&self) -> &'static str {
40 "currency_accounts"
41 }
42
43 fn description(&self) -> &'static str {
44 "Auto-generate currency trading postings"
45 }
46
47 fn process(&self, input: PluginInput) -> PluginOutput {
48 use crate::types::{AmountData, PostingData};
49 use rust_decimal::Decimal;
50 use std::collections::HashMap;
51 use std::str::FromStr;
52
53 let base_account = input
55 .config
56 .as_ref()
57 .map_or_else(|| self.base_account.clone(), |c| c.trim().to_string());
58
59 let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
60
61 for wrapper in &input.directives {
62 if let DirectiveData::Transaction(txn) = &wrapper.data {
63 let mut currency_totals: HashMap<String, Decimal> = HashMap::new();
66
67 for posting in &txn.postings {
68 if let Some(units) = &posting.units {
69 let amount = Decimal::from_str(&units.number).unwrap_or_default();
70 *currency_totals.entry(units.currency.clone()).or_default() += amount;
71 }
72 }
73
74 let non_zero_currencies: Vec<_> = currency_totals
76 .iter()
77 .filter(|&(_, total)| *total != Decimal::ZERO)
78 .collect();
79
80 if non_zero_currencies.len() > 1 {
81 let mut modified_txn = txn.clone();
83
84 for &(currency, total) in &non_zero_currencies {
85 modified_txn.postings.push(PostingData {
87 account: format!("{base_account}:{currency}"),
88 units: Some(AmountData {
89 number: (-*total).to_string(),
90 currency: (*currency).clone(),
91 }),
92 cost: None,
93 price: None,
94 flag: None,
95 metadata: vec![],
96 });
97 }
98
99 new_directives.push(DirectiveWrapper {
100 directive_type: wrapper.directive_type.clone(),
101 date: wrapper.date.clone(),
102 filename: wrapper.filename.clone(), lineno: wrapper.lineno,
104 data: DirectiveData::Transaction(modified_txn),
105 });
106 } else {
107 new_directives.push(wrapper.clone());
109 }
110 } else {
111 new_directives.push(wrapper.clone());
112 }
113 }
114
115 PluginOutput {
116 directives: new_directives,
117 errors: Vec::new(),
118 }
119 }
120}
121
122#[cfg(test)]
123mod currency_accounts_tests {
124 use super::*;
125 use crate::types::*;
126
127 #[test]
128 fn test_currency_accounts_adds_balancing_postings() {
129 let plugin = CurrencyAccountsPlugin::new();
130
131 let input = PluginInput {
132 directives: vec![DirectiveWrapper {
133 directive_type: "transaction".to_string(),
134 date: "2024-01-15".to_string(),
135 filename: None,
136 lineno: None,
137 data: DirectiveData::Transaction(TransactionData {
138 flag: "*".to_string(),
139 payee: None,
140 narration: "Currency exchange".to_string(),
141 tags: vec![],
142 links: vec![],
143 metadata: vec![],
144 postings: vec![
145 PostingData {
146 account: "Assets:Bank:USD".to_string(),
147 units: Some(AmountData {
148 number: "-100".to_string(),
149 currency: "USD".to_string(),
150 }),
151 cost: None,
152 price: None,
153 flag: None,
154 metadata: vec![],
155 },
156 PostingData {
157 account: "Assets:Bank:EUR".to_string(),
158 units: Some(AmountData {
159 number: "85".to_string(),
160 currency: "EUR".to_string(),
161 }),
162 cost: None,
163 price: None,
164 flag: None,
165 metadata: vec![],
166 },
167 ],
168 }),
169 }],
170 options: PluginOptions {
171 operating_currencies: vec!["USD".to_string()],
172 title: None,
173 },
174 config: None,
175 };
176
177 let output = plugin.process(input);
178 assert_eq!(output.errors.len(), 0);
179 assert_eq!(output.directives.len(), 1);
180
181 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
182 assert_eq!(txn.postings.len(), 4);
184
185 let usd_posting = txn
187 .postings
188 .iter()
189 .find(|p| p.account == "Equity:CurrencyAccounts:USD");
190 assert!(usd_posting.is_some());
191 let usd_posting = usd_posting.unwrap();
192 assert_eq!(usd_posting.units.as_ref().unwrap().number, "100");
194
195 let eur_posting = txn
196 .postings
197 .iter()
198 .find(|p| p.account == "Equity:CurrencyAccounts:EUR");
199 assert!(eur_posting.is_some());
200 let eur_posting = eur_posting.unwrap();
201 assert_eq!(eur_posting.units.as_ref().unwrap().number, "-85");
203 } else {
204 panic!("Expected Transaction directive");
205 }
206 }
207
208 #[test]
209 fn test_currency_accounts_single_currency_unchanged() {
210 let plugin = CurrencyAccountsPlugin::new();
211
212 let input = PluginInput {
213 directives: vec![DirectiveWrapper {
214 directive_type: "transaction".to_string(),
215 date: "2024-01-15".to_string(),
216 filename: None,
217 lineno: None,
218 data: DirectiveData::Transaction(TransactionData {
219 flag: "*".to_string(),
220 payee: None,
221 narration: "Simple transfer".to_string(),
222 tags: vec![],
223 links: vec![],
224 metadata: vec![],
225 postings: vec![
226 PostingData {
227 account: "Assets:Bank".to_string(),
228 units: Some(AmountData {
229 number: "-100".to_string(),
230 currency: "USD".to_string(),
231 }),
232 cost: None,
233 price: None,
234 flag: None,
235 metadata: vec![],
236 },
237 PostingData {
238 account: "Expenses:Food".to_string(),
239 units: Some(AmountData {
240 number: "100".to_string(),
241 currency: "USD".to_string(),
242 }),
243 cost: None,
244 price: None,
245 flag: None,
246 metadata: vec![],
247 },
248 ],
249 }),
250 }],
251 options: PluginOptions {
252 operating_currencies: vec!["USD".to_string()],
253 title: None,
254 },
255 config: None,
256 };
257
258 let output = plugin.process(input);
259 assert_eq!(output.errors.len(), 0);
260
261 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
263 assert_eq!(txn.postings.len(), 2);
264 }
265 }
266
267 #[test]
268 fn test_currency_accounts_custom_base_account() {
269 let plugin = CurrencyAccountsPlugin::new();
270
271 let input = PluginInput {
272 directives: vec![DirectiveWrapper {
273 directive_type: "transaction".to_string(),
274 date: "2024-01-15".to_string(),
275 filename: None,
276 lineno: None,
277 data: DirectiveData::Transaction(TransactionData {
278 flag: "*".to_string(),
279 payee: None,
280 narration: "Exchange".to_string(),
281 tags: vec![],
282 links: vec![],
283 metadata: vec![],
284 postings: vec![
285 PostingData {
286 account: "Assets:USD".to_string(),
287 units: Some(AmountData {
288 number: "-50".to_string(),
289 currency: "USD".to_string(),
290 }),
291 cost: None,
292 price: None,
293 flag: None,
294 metadata: vec![],
295 },
296 PostingData {
297 account: "Assets:EUR".to_string(),
298 units: Some(AmountData {
299 number: "42".to_string(),
300 currency: "EUR".to_string(),
301 }),
302 cost: None,
303 price: None,
304 flag: None,
305 metadata: vec![],
306 },
307 ],
308 }),
309 }],
310 options: PluginOptions {
311 operating_currencies: vec!["USD".to_string()],
312 title: None,
313 },
314 config: Some("Income:Trading".to_string()),
315 };
316
317 let output = plugin.process(input);
318 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
319 assert!(
321 txn.postings
322 .iter()
323 .any(|p| p.account.starts_with("Income:Trading:"))
324 );
325 }
326 }
327}