1use crate::types::{DirectiveData, DirectiveWrapper, PluginInput, PluginOp, 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 ops: Vec<PluginOp> = Vec::with_capacity(input.directives.len());
98 let mut created_accounts: HashSet<String> = HashSet::new();
99
100 for (i, wrapper) in input.directives.iter().enumerate() {
101 let DirectiveData::Transaction(txn) = &wrapper.data else {
102 ops.push(PluginOp::Keep(i));
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 ops.push(PluginOp::Keep(i));
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> =
221 Vec::with_capacity(txn.postings.len() + curmap.len());
222 for posting in &txn.postings {
223 new_postings.push(posting.clone());
224 }
225
226 for (group_key, inv) in &group_inv {
229 if inv.len() != 1 {
234 continue;
235 }
236
237 let (weight_currency, weight_amount) = inv.iter().next().unwrap();
238 let account_name = format!("{base_account}:{group_key}");
239 created_accounts.insert(account_name.clone());
240
241 new_postings.push(PostingData {
242 account: account_name,
243 units: Some(AmountData {
244 number: (-*weight_amount).to_string(),
245 currency: weight_currency.clone(),
246 }),
247 cost: None,
248 price: None,
249 flag: None,
250 metadata: vec![],
251 });
252 }
253
254 let mut modified_txn = txn.clone();
255 modified_txn.postings = new_postings;
256
257 ops.push(PluginOp::Modify(
258 i,
259 DirectiveWrapper {
260 directive_type: wrapper.directive_type.clone(),
261 date: wrapper.date.clone(),
262 filename: wrapper.filename.clone(),
263 lineno: wrapper.lineno,
264 data: DirectiveData::Transaction(modified_txn),
265 },
266 ));
267 }
268
269 let mut new_open_accounts: Vec<String> = created_accounts
271 .into_iter()
272 .filter(|account| !existing_opens.contains(account))
273 .collect();
274 new_open_accounts.sort();
275 for account in new_open_accounts {
276 ops.push(PluginOp::Insert(DirectiveWrapper {
277 directive_type: "open".to_string(),
278 date: earliest_date.clone(),
279 filename: Some("<currency_accounts>".to_string()),
280 lineno: None,
281 data: DirectiveData::Open(OpenData {
282 account,
283 currencies: vec![],
284 booking: None,
285 metadata: vec![],
286 }),
287 }));
288 }
289
290 PluginOutput {
291 ops,
292 errors: Vec::new(),
293 }
294 }
295}
296
297#[cfg(test)]
298mod currency_accounts_tests {
299 use super::super::utils::materialize_ops;
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 input_dirs = input.directives.clone();
376 let output = plugin.process(input);
377 assert_eq!(output.errors.len(), 0);
378 let directives = materialize_ops(&input_dirs, &output);
379
380 assert_eq!(directives.len(), 3);
382
383 let mut opens: Vec<&str> = directives
384 .iter()
385 .filter_map(|d| {
386 if let DirectiveData::Open(o) = &d.data {
387 Some(o.account.as_str())
388 } else {
389 None
390 }
391 })
392 .collect();
393 opens.sort_unstable();
394 assert_eq!(opens, vec!["Equity:Currency:EUR", "Equity:Currency:USD"]);
395
396 let txn_dir = directives
397 .iter()
398 .find(|d| matches!(d.data, DirectiveData::Transaction(_)))
399 .expect("expected transaction");
400 let DirectiveData::Transaction(txn) = &txn_dir.data else {
401 unreachable!()
402 };
403 assert_eq!(txn.postings.len(), 4);
405 assert!(txn.postings[0].price.is_some()); assert!(txn.postings[1].price.is_none()); let eur_neut = txn
414 .postings
415 .iter()
416 .find(|p| p.account == "Equity:Currency:EUR")
417 .expect("missing EUR neutralizer");
418 assert_eq!(eur_neut.units.as_ref().unwrap().number, "110.00");
422 assert_eq!(eur_neut.units.as_ref().unwrap().currency, "USD");
423
424 let usd_neut = txn
426 .postings
427 .iter()
428 .find(|p| p.account == "Equity:Currency:USD")
429 .expect("missing USD neutralizer");
430 assert_eq!(usd_neut.units.as_ref().unwrap().number, "-110");
431 assert_eq!(usd_neut.units.as_ref().unwrap().currency, "USD");
432 }
433
434 #[test]
438 fn test_cost_only_no_price_skipped() {
439 let plugin = CurrencyAccountsPlugin::new();
440
441 let mut p1 = posting("Assets:Shares:RING", "9", "RING");
442 p1.cost = Some(CostData {
443 number_per: Some("68.55".to_string()),
444 number_total: None,
445 currency: Some("USD".to_string()),
446 date: None,
447 label: None,
448 merge: false,
449 });
450
451 let input = PluginInput {
452 directives: vec![txn_wrapper(
453 "2026-03-21",
454 "Buy RING",
455 vec![
456 p1,
457 posting("Expenses:Financial", "0.35", "USD"),
458 posting("Assets:Cash:USD", "-617.30", "USD"),
459 ],
460 )],
461 options: default_options(),
462 config: None,
463 };
464
465 let input_dirs = input.directives.clone();
466 let output = plugin.process(input);
467 assert_eq!(output.errors.len(), 0);
468 let directives = materialize_ops(&input_dirs, &output);
469 assert_eq!(directives.len(), 1);
470 let DirectiveData::Transaction(txn) = &directives[0].data else {
471 panic!("expected transaction");
472 };
473 assert_eq!(txn.postings.len(), 3);
474 }
475
476 #[test]
478 fn test_single_currency_unchanged() {
479 let plugin = CurrencyAccountsPlugin::new();
480 let input = PluginInput {
481 directives: vec![txn_wrapper(
482 "2024-01-15",
483 "Simple transfer",
484 vec![
485 posting("Assets:Bank", "-100", "USD"),
486 posting("Expenses:Food", "100", "USD"),
487 ],
488 )],
489 options: default_options(),
490 config: None,
491 };
492
493 let input_dirs = input.directives.clone();
494 let output = plugin.process(input);
495 let directives = materialize_ops(&input_dirs, &output);
496 assert_eq!(directives.len(), 1);
497 let DirectiveData::Transaction(txn) = &directives[0].data else {
498 panic!("expected transaction");
499 };
500 assert_eq!(txn.postings.len(), 2);
501 }
502
503 #[test]
505 fn test_custom_base_account() {
506 let plugin = CurrencyAccountsPlugin::new();
507
508 let mut p1 = posting("Assets:Bank:EUR", "-100", "EUR");
509 p1.price = Some(price_usd("1.10"));
510
511 let input = PluginInput {
512 directives: vec![txn_wrapper(
513 "2024-01-15",
514 "Exchange",
515 vec![p1, posting("Assets:Bank:USD", "110", "USD")],
516 )],
517 options: default_options(),
518 config: Some("Income:Trading".to_string()),
519 };
520
521 let input_dirs = input.directives.clone();
522 let output = plugin.process(input);
523 let directives = materialize_ops(&input_dirs, &output);
524 assert_eq!(directives.len(), 3);
525 assert!(directives.iter().any(|d| {
526 if let DirectiveData::Open(o) = &d.data {
527 o.account == "Income:Trading:EUR"
528 } else {
529 false
530 }
531 }));
532 assert!(directives.iter().any(|d| {
533 if let DirectiveData::Open(o) = &d.data {
534 o.account == "Income:Trading:USD"
535 } else {
536 false
537 }
538 }));
539 }
540
541 #[test]
544 fn test_skips_existing_open() {
545 let plugin = CurrencyAccountsPlugin::new();
546
547 let existing_open = DirectiveWrapper {
548 directive_type: "open".to_string(),
549 date: "2024-01-01".to_string(),
550 filename: None,
551 lineno: None,
552 data: DirectiveData::Open(OpenData {
553 account: "Equity:CurrencyAccounts:USD".to_string(),
554 currencies: vec![],
555 booking: None,
556 metadata: vec![],
557 }),
558 };
559
560 let mut p1 = posting("Assets:Bank:EUR", "-100", "EUR");
561 p1.price = Some(price_usd("1.10"));
562
563 let input = PluginInput {
564 directives: vec![
565 existing_open,
566 txn_wrapper(
567 "2024-01-15",
568 "Exchange",
569 vec![p1, posting("Assets:Bank:USD", "110", "USD")],
570 ),
571 ],
572 options: default_options(),
573 config: None,
574 };
575
576 let input_dirs = input.directives.clone();
577 let output = plugin.process(input);
578 let directives = materialize_ops(&input_dirs, &output);
579
580 let new_currency_opens: Vec<&str> = directives
584 .iter()
585 .filter_map(|d| {
586 if let DirectiveData::Open(o) = &d.data
587 && d.filename.as_deref() == Some("<currency_accounts>")
588 {
589 Some(o.account.as_str())
590 } else {
591 None
592 }
593 })
594 .collect();
595 assert_eq!(new_currency_opens, vec!["Equity:CurrencyAccounts:EUR"]);
596 }
597
598 #[test]
602 fn test_open_uses_earliest_date() {
603 let plugin = CurrencyAccountsPlugin::new();
604
605 let mut p_later = posting("Assets:Bank:EUR", "-100", "EUR");
606 p_later.price = Some(price_usd("1.10"));
607
608 let input = PluginInput {
609 directives: vec![
610 DirectiveWrapper {
611 directive_type: "open".to_string(),
612 date: "2024-01-01".to_string(),
613 filename: None,
614 lineno: None,
615 data: DirectiveData::Open(OpenData {
616 account: "Assets:Bank:EUR".to_string(),
617 currencies: vec![],
618 booking: None,
619 metadata: vec![],
620 }),
621 },
622 txn_wrapper(
623 "2026-03-17",
624 "Exchange",
625 vec![p_later, posting("Assets:Bank:USD", "110", "USD")],
626 ),
627 ],
628 options: default_options(),
629 config: None,
630 };
631
632 let input_dirs = input.directives.clone();
633 let output = plugin.process(input);
634 let directives = materialize_ops(&input_dirs, &output);
635 for wrapper in &directives {
636 if let DirectiveData::Open(o) = &wrapper.data
637 && o.account.starts_with("Equity:CurrencyAccounts:")
638 && wrapper.filename.as_deref() == Some("<currency_accounts>")
639 {
640 assert_eq!(
641 wrapper.date, "2024-01-01",
642 "plugin-created open should use earliest date"
643 );
644 }
645 }
646 }
647}