1use crate::types::{DirectiveData, DirectiveWrapper, PluginInput, PluginOutput};
4
5use super::super::NativePlugin;
6
7pub struct CurrencyAccountsPlugin {
30 base_account: String,
32}
33
34impl CurrencyAccountsPlugin {
35 pub fn new() -> Self {
37 Self {
38 base_account: "Equity:CurrencyAccounts".to_string(),
39 }
40 }
41
42 pub const fn with_base_account(base_account: String) -> Self {
44 Self { base_account }
45 }
46}
47
48impl Default for CurrencyAccountsPlugin {
49 fn default() -> Self {
50 Self::new()
51 }
52}
53
54impl NativePlugin for CurrencyAccountsPlugin {
55 fn name(&self) -> &'static str {
56 "currency_accounts"
57 }
58
59 fn description(&self) -> &'static str {
60 "Auto-generate currency trading postings"
61 }
62
63 fn process(&self, input: PluginInput) -> PluginOutput {
64 use crate::types::{AmountData, OpenData, PostingData};
65 use rust_decimal::Decimal;
66 use std::collections::{BTreeMap, HashSet};
67 use std::str::FromStr;
68
69 let base_account = input
74 .config
75 .as_ref()
76 .map(|c| c.trim().to_string())
77 .filter(|s| !s.is_empty())
78 .unwrap_or_else(|| self.base_account.clone());
79
80 let mut existing_opens: HashSet<String> = HashSet::new();
82 let mut earliest_date: Option<&str> = None;
83 for wrapper in &input.directives {
84 match earliest_date {
85 None => earliest_date = Some(&wrapper.date),
86 Some(current) if wrapper.date.as_str() < current => {
87 earliest_date = Some(&wrapper.date);
88 }
89 _ => {}
90 }
91 if let DirectiveData::Open(open) = &wrapper.data {
92 existing_opens.insert(open.account.clone());
93 }
94 }
95 let earliest_date = earliest_date.unwrap_or("1970-01-01").to_string();
96
97 let mut new_directives: Vec<DirectiveWrapper> = Vec::with_capacity(input.directives.len());
98 let mut created_accounts: HashSet<String> = HashSet::new();
99
100 for wrapper in &input.directives {
101 let DirectiveData::Transaction(txn) = &wrapper.data else {
102 new_directives.push(wrapper.clone());
103 continue;
104 };
105
106 let mut curmap: BTreeMap<String, Vec<usize>> = BTreeMap::new();
111 let mut has_price = false;
112
113 for (i, posting) in txn.postings.iter().enumerate() {
114 let Some(units) = &posting.units else {
115 continue;
116 };
117
118 let key = if let Some(cost) = &posting.cost {
123 cost.currency
124 .clone()
125 .unwrap_or_else(|| units.currency.clone())
126 } else {
127 units.currency.clone()
128 };
129
130 if posting.price.is_some() {
131 has_price = true;
132 }
133
134 curmap.entry(key).or_default().push(i);
135 }
136
137 if !has_price || curmap.len() < 2 {
140 new_directives.push(wrapper.clone());
141 continue;
142 }
143
144 let weight_of = |posting: &PostingData| -> Option<(Decimal, String)> {
151 let units = posting.units.as_ref()?;
152 let units_num = Decimal::from_str(&units.number).unwrap_or_default();
153 if let Some(cost) = &posting.cost {
154 let currency = cost
155 .currency
156 .clone()
157 .unwrap_or_else(|| units.currency.clone());
158 let amount = if let Some(per) = &cost.number_per {
159 let per = Decimal::from_str(per).unwrap_or_default();
160 units_num * per
161 } else if let Some(total) = &cost.number_total {
162 let total = Decimal::from_str(total).unwrap_or_default();
163 if units_num.is_sign_negative() {
166 -total.abs()
167 } else {
168 total.abs()
169 }
170 } else {
171 units_num
172 };
173 Some((amount, currency))
174 } else if let Some(price) = &posting.price {
175 let price_amount = price.amount.as_ref()?;
176 let price_num = Decimal::from_str(&price_amount.number).unwrap_or_default();
177 let currency = price_amount.currency.clone();
178 let amount = if price.is_total {
179 if units_num.is_sign_negative() {
180 -price_num.abs()
181 } else {
182 price_num.abs()
183 }
184 } else {
185 units_num * price_num
186 };
187 Some((amount, currency))
188 } else {
189 Some((units_num, units.currency.clone()))
190 }
191 };
192
193 let mut group_inv: BTreeMap<&String, BTreeMap<String, Decimal>> = BTreeMap::new();
195 for (group_key, posting_indices) in &curmap {
196 let inv = group_inv.entry(group_key).or_default();
197 for &idx in posting_indices {
198 if let Some((amount, currency)) = weight_of(&txn.postings[idx]) {
199 *inv.entry(currency).or_default() += amount;
200 }
201 }
202 inv.retain(|_, amount| !amount.is_zero());
203 }
204
205 let mut new_postings: Vec<PostingData> =
215 Vec::with_capacity(txn.postings.len() + curmap.len());
216 for posting in &txn.postings {
217 new_postings.push(posting.clone());
218 }
219
220 for (group_key, inv) in &group_inv {
223 if inv.len() != 1 {
228 continue;
229 }
230
231 let (weight_currency, weight_amount) = inv.iter().next().unwrap();
232 let account_name = format!("{base_account}:{group_key}");
233 created_accounts.insert(account_name.clone());
234
235 new_postings.push(PostingData {
236 account: account_name,
237 units: Some(AmountData {
238 number: (-*weight_amount).to_string(),
239 currency: weight_currency.clone(),
240 }),
241 cost: None,
242 price: None,
243 flag: None,
244 metadata: vec![],
245 });
246 }
247
248 let mut modified_txn = txn.clone();
249 modified_txn.postings = new_postings;
250
251 new_directives.push(DirectiveWrapper {
252 directive_type: wrapper.directive_type.clone(),
253 date: wrapper.date.clone(),
254 filename: wrapper.filename.clone(),
255 lineno: wrapper.lineno,
256 data: DirectiveData::Transaction(modified_txn),
257 });
258 }
259
260 let mut open_directives: Vec<DirectiveWrapper> = created_accounts
262 .into_iter()
263 .filter(|account| !existing_opens.contains(account))
264 .map(|account| DirectiveWrapper {
265 directive_type: "open".to_string(),
266 date: earliest_date.clone(),
267 filename: Some("<currency_accounts>".to_string()),
268 lineno: None,
269 data: DirectiveData::Open(OpenData {
270 account,
271 currencies: vec![],
272 booking: None,
273 metadata: vec![],
274 }),
275 })
276 .collect();
277
278 open_directives.sort_by(|a, b| {
280 if let (DirectiveData::Open(oa), DirectiveData::Open(ob)) = (&a.data, &b.data) {
281 oa.account.cmp(&ob.account)
282 } else {
283 std::cmp::Ordering::Equal
284 }
285 });
286
287 open_directives.extend(new_directives);
290
291 PluginOutput {
292 directives: open_directives,
293 errors: Vec::new(),
294 }
295 }
296}
297
298#[cfg(test)]
299mod currency_accounts_tests {
300 use super::*;
301 use crate::types::*;
302
303 fn txn_wrapper(date: &str, narration: &str, postings: Vec<PostingData>) -> DirectiveWrapper {
304 DirectiveWrapper {
305 directive_type: "transaction".to_string(),
306 date: date.to_string(),
307 filename: None,
308 lineno: None,
309 data: DirectiveData::Transaction(TransactionData {
310 flag: "*".to_string(),
311 payee: None,
312 narration: narration.to_string(),
313 tags: vec![],
314 links: vec![],
315 metadata: vec![],
316 postings,
317 }),
318 }
319 }
320
321 fn posting(account: &str, number: &str, currency: &str) -> PostingData {
322 PostingData {
323 account: account.to_string(),
324 units: Some(AmountData {
325 number: number.to_string(),
326 currency: currency.to_string(),
327 }),
328 cost: None,
329 price: None,
330 flag: None,
331 metadata: vec![],
332 }
333 }
334
335 fn price_usd(number: &str) -> PriceAnnotationData {
336 PriceAnnotationData {
337 is_total: false,
338 amount: Some(AmountData {
339 number: number.to_string(),
340 currency: "USD".to_string(),
341 }),
342 number: None,
343 currency: None,
344 }
345 }
346
347 fn default_options() -> PluginOptions {
348 PluginOptions {
349 operating_currencies: vec!["USD".to_string()],
350 title: None,
351 }
352 }
353
354 #[test]
359 fn test_issue_776_currency_exchange_with_price() {
360 let plugin = CurrencyAccountsPlugin::with_base_account("Equity:Currency".to_string());
361
362 let mut p1 = posting("Assets:Bank:EUR", "-100", "EUR");
363 p1.price = Some(price_usd("1.10"));
364
365 let input = PluginInput {
366 directives: vec![txn_wrapper(
367 "2026-03-17",
368 "Currency exchange",
369 vec![p1, posting("Assets:Bank:USD", "110", "USD")],
370 )],
371 options: default_options(),
372 config: None,
373 };
374
375 let output = plugin.process(input);
376 assert_eq!(output.errors.len(), 0);
377
378 assert_eq!(output.directives.len(), 3);
380
381 let opens: Vec<&str> = output
382 .directives
383 .iter()
384 .filter_map(|d| {
385 if let DirectiveData::Open(o) = &d.data {
386 Some(o.account.as_str())
387 } else {
388 None
389 }
390 })
391 .collect();
392 assert_eq!(opens, vec!["Equity:Currency:EUR", "Equity:Currency:USD"]);
393
394 let DirectiveData::Transaction(txn) = &output.directives[2].data else {
395 panic!("expected transaction at index 2");
396 };
397 assert_eq!(txn.postings.len(), 4);
399 assert!(txn.postings[0].price.is_some()); assert!(txn.postings[1].price.is_none()); let eur_neut = txn
408 .postings
409 .iter()
410 .find(|p| p.account == "Equity:Currency:EUR")
411 .expect("missing EUR neutralizer");
412 assert_eq!(eur_neut.units.as_ref().unwrap().number, "110.00");
416 assert_eq!(eur_neut.units.as_ref().unwrap().currency, "USD");
417
418 let usd_neut = txn
420 .postings
421 .iter()
422 .find(|p| p.account == "Equity:Currency:USD")
423 .expect("missing USD neutralizer");
424 assert_eq!(usd_neut.units.as_ref().unwrap().number, "-110");
425 assert_eq!(usd_neut.units.as_ref().unwrap().currency, "USD");
426 }
427
428 #[test]
432 fn test_cost_only_no_price_skipped() {
433 let plugin = CurrencyAccountsPlugin::new();
434
435 let mut p1 = posting("Assets:Shares:RING", "9", "RING");
436 p1.cost = Some(CostData {
437 number_per: Some("68.55".to_string()),
438 number_total: None,
439 currency: Some("USD".to_string()),
440 date: None,
441 label: None,
442 merge: false,
443 });
444
445 let input = PluginInput {
446 directives: vec![txn_wrapper(
447 "2026-03-21",
448 "Buy RING",
449 vec![
450 p1,
451 posting("Expenses:Financial", "0.35", "USD"),
452 posting("Assets:Cash:USD", "-617.30", "USD"),
453 ],
454 )],
455 options: default_options(),
456 config: None,
457 };
458
459 let output = plugin.process(input);
460 assert_eq!(output.errors.len(), 0);
461 assert_eq!(output.directives.len(), 1);
462 let DirectiveData::Transaction(txn) = &output.directives[0].data else {
463 panic!("expected transaction");
464 };
465 assert_eq!(txn.postings.len(), 3);
466 }
467
468 #[test]
470 fn test_single_currency_unchanged() {
471 let plugin = CurrencyAccountsPlugin::new();
472 let input = PluginInput {
473 directives: vec![txn_wrapper(
474 "2024-01-15",
475 "Simple transfer",
476 vec![
477 posting("Assets:Bank", "-100", "USD"),
478 posting("Expenses:Food", "100", "USD"),
479 ],
480 )],
481 options: default_options(),
482 config: None,
483 };
484
485 let output = plugin.process(input);
486 assert_eq!(output.directives.len(), 1);
487 let DirectiveData::Transaction(txn) = &output.directives[0].data else {
488 panic!("expected transaction");
489 };
490 assert_eq!(txn.postings.len(), 2);
491 }
492
493 #[test]
495 fn test_custom_base_account() {
496 let plugin = CurrencyAccountsPlugin::new();
497
498 let mut p1 = posting("Assets:Bank:EUR", "-100", "EUR");
499 p1.price = Some(price_usd("1.10"));
500
501 let input = PluginInput {
502 directives: vec![txn_wrapper(
503 "2024-01-15",
504 "Exchange",
505 vec![p1, posting("Assets:Bank:USD", "110", "USD")],
506 )],
507 options: default_options(),
508 config: Some("Income:Trading".to_string()),
509 };
510
511 let output = plugin.process(input);
512 assert_eq!(output.directives.len(), 3);
513 assert!(output.directives.iter().any(|d| {
514 if let DirectiveData::Open(o) = &d.data {
515 o.account == "Income:Trading:EUR"
516 } else {
517 false
518 }
519 }));
520 assert!(output.directives.iter().any(|d| {
521 if let DirectiveData::Open(o) = &d.data {
522 o.account == "Income:Trading:USD"
523 } else {
524 false
525 }
526 }));
527 }
528
529 #[test]
532 fn test_skips_existing_open() {
533 let plugin = CurrencyAccountsPlugin::new();
534
535 let existing_open = DirectiveWrapper {
536 directive_type: "open".to_string(),
537 date: "2024-01-01".to_string(),
538 filename: None,
539 lineno: None,
540 data: DirectiveData::Open(OpenData {
541 account: "Equity:CurrencyAccounts:USD".to_string(),
542 currencies: vec![],
543 booking: None,
544 metadata: vec![],
545 }),
546 };
547
548 let mut p1 = posting("Assets:Bank:EUR", "-100", "EUR");
549 p1.price = Some(price_usd("1.10"));
550
551 let input = PluginInput {
552 directives: vec![
553 existing_open,
554 txn_wrapper(
555 "2024-01-15",
556 "Exchange",
557 vec![p1, posting("Assets:Bank:USD", "110", "USD")],
558 ),
559 ],
560 options: default_options(),
561 config: None,
562 };
563
564 let output = plugin.process(input);
565
566 let new_currency_opens: Vec<&str> = output
570 .directives
571 .iter()
572 .filter_map(|d| {
573 if let DirectiveData::Open(o) = &d.data
574 && d.filename.as_deref() == Some("<currency_accounts>")
575 {
576 Some(o.account.as_str())
577 } else {
578 None
579 }
580 })
581 .collect();
582 assert_eq!(new_currency_opens, vec!["Equity:CurrencyAccounts:EUR"]);
583 }
584
585 #[test]
589 fn test_open_uses_earliest_date() {
590 let plugin = CurrencyAccountsPlugin::new();
591
592 let mut p_later = posting("Assets:Bank:EUR", "-100", "EUR");
593 p_later.price = Some(price_usd("1.10"));
594
595 let input = PluginInput {
596 directives: vec![
597 DirectiveWrapper {
598 directive_type: "open".to_string(),
599 date: "2024-01-01".to_string(),
600 filename: None,
601 lineno: None,
602 data: DirectiveData::Open(OpenData {
603 account: "Assets:Bank:EUR".to_string(),
604 currencies: vec![],
605 booking: None,
606 metadata: vec![],
607 }),
608 },
609 txn_wrapper(
610 "2026-03-17",
611 "Exchange",
612 vec![p_later, posting("Assets:Bank:USD", "110", "USD")],
613 ),
614 ],
615 options: default_options(),
616 config: None,
617 };
618
619 let output = plugin.process(input);
620 for wrapper in &output.directives {
621 if let DirectiveData::Open(o) = &wrapper.data
622 && o.account.starts_with("Equity:CurrencyAccounts:")
623 && wrapper.filename.as_deref() == Some("<currency_accounts>")
624 {
625 assert_eq!(
626 wrapper.date, "2024-01-01",
627 "plugin-created open should use earliest date"
628 );
629 }
630 }
631 }
632}