1use crate::types::{DirectiveData, DirectiveWrapper, PluginInput, PluginOp, PluginOutput};
4
5use super::super::{NativePlugin, RegularPlugin};
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)> {
152 let units = posting.units.as_ref()?;
153 let units_num = Decimal::from_str(&units.number).unwrap_or_default();
154 if let Some(cost) = &posting.cost {
155 let currency = cost
156 .currency
157 .clone()
158 .unwrap_or_else(|| units.currency.clone());
159 let amount = match &cost.number {
168 Some(rustledger_plugin_types::CostNumberData::PerUnit { value }) => {
169 let per = Decimal::from_str(value).unwrap_or_default();
170 units_num * per
171 }
172 Some(rustledger_plugin_types::CostNumberData::Total { value }) => {
173 let total = Decimal::from_str(value).unwrap_or_default();
174 if units_num.is_sign_negative() {
175 -total.abs()
176 } else {
177 total.abs()
178 }
179 }
180 Some(rustledger_plugin_types::CostNumberData::PerUnitFromTotal {
181 total,
182 ..
183 }) => {
184 let total = Decimal::from_str(total).unwrap_or_default();
185 if units_num.is_sign_negative() {
186 -total.abs()
187 } else {
188 total.abs()
189 }
190 }
191 None => units_num,
192 };
193 Some((amount, currency))
194 } else if let Some(price) = &posting.price {
195 let price_amount = price.amount.as_ref()?;
196 let price_num = Decimal::from_str(&price_amount.number).unwrap_or_default();
197 let currency = price_amount.currency.clone();
198 let amount = if price.is_total {
199 if units_num.is_sign_negative() {
200 -price_num.abs()
201 } else {
202 price_num.abs()
203 }
204 } else {
205 units_num * price_num
206 };
207 Some((amount, currency))
208 } else {
209 Some((units_num, units.currency.clone()))
210 }
211 };
212
213 let mut group_inv: BTreeMap<&String, BTreeMap<String, Decimal>> = BTreeMap::new();
215 for (group_key, posting_indices) in &curmap {
216 let inv = group_inv.entry(group_key).or_default();
217 for &idx in posting_indices {
218 if let Some((amount, currency)) = weight_of(&txn.postings[idx]) {
219 *inv.entry(currency).or_default() += amount;
220 }
221 }
222 inv.retain(|_, amount| !amount.is_zero());
223 }
224
225 let mut new_postings: Vec<PostingData> =
241 Vec::with_capacity(txn.postings.len() + curmap.len());
242 for posting in &txn.postings {
243 new_postings.push(posting.clone());
244 }
245
246 for (group_key, inv) in &group_inv {
249 if inv.len() != 1 {
254 continue;
255 }
256
257 let (weight_currency, weight_amount) = inv.iter().next().unwrap();
258 let account_name = format!("{base_account}:{group_key}");
259 created_accounts.insert(account_name.clone());
260
261 new_postings.push(PostingData {
262 account: account_name,
263 units: Some(AmountData {
264 number: (-*weight_amount).to_string(),
265 currency: weight_currency.clone(),
266 }),
267 cost: None,
268 price: None,
269 flag: None,
270 metadata: vec![],
271 span: None,
272 });
273 }
274
275 let mut modified_txn = txn.clone();
276 modified_txn.postings = new_postings;
277
278 ops.push(PluginOp::Modify(
279 i,
280 DirectiveWrapper {
281 directive_type: wrapper.directive_type.clone(),
282 date: wrapper.date.clone(),
283 filename: wrapper.filename.clone(),
284 lineno: wrapper.lineno,
285 data: DirectiveData::Transaction(modified_txn),
286 },
287 ));
288 }
289
290 let mut new_open_accounts: Vec<String> = created_accounts
292 .into_iter()
293 .filter(|account| !existing_opens.contains(account))
294 .collect();
295 new_open_accounts.sort();
296 for account in new_open_accounts {
297 ops.push(PluginOp::Insert(DirectiveWrapper {
298 directive_type: "open".to_string(),
299 date: earliest_date.clone(),
300 filename: Some("<currency_accounts>".to_string()),
301 lineno: None,
302 data: DirectiveData::Open(OpenData {
303 account,
304 currencies: vec![],
305 booking: None,
306 metadata: vec![],
307 }),
308 }));
309 }
310
311 PluginOutput {
312 ops,
313 errors: Vec::new(),
314 }
315 }
316}
317
318impl RegularPlugin for CurrencyAccountsPlugin {}
319
320#[cfg(test)]
321mod currency_accounts_tests {
322 use super::super::utils::materialize_ops;
323 use super::*;
324 use crate::types::*;
325
326 fn txn_wrapper(date: &str, narration: &str, postings: Vec<PostingData>) -> DirectiveWrapper {
327 DirectiveWrapper {
328 directive_type: "transaction".to_string(),
329 date: date.to_string(),
330 filename: None,
331 lineno: None,
332 data: DirectiveData::Transaction(TransactionData {
333 flag: "*".to_string(),
334 payee: None,
335 narration: narration.to_string(),
336 tags: vec![],
337 links: vec![],
338 metadata: vec![],
339 postings,
340 }),
341 }
342 }
343
344 fn posting(account: &str, number: &str, currency: &str) -> PostingData {
345 PostingData {
346 account: account.to_string(),
347 units: Some(AmountData {
348 number: number.to_string(),
349 currency: currency.to_string(),
350 }),
351 cost: None,
352 price: None,
353 flag: None,
354 metadata: vec![],
355 span: None,
356 }
357 }
358
359 fn price_usd(number: &str) -> PriceAnnotationData {
360 PriceAnnotationData {
361 is_total: false,
362 amount: Some(AmountData {
363 number: number.to_string(),
364 currency: "USD".to_string(),
365 }),
366 number: None,
367 currency: None,
368 }
369 }
370
371 fn default_options() -> PluginOptions {
372 PluginOptions {
373 operating_currencies: vec!["USD".to_string()],
374 title: None,
375 }
376 }
377
378 #[test]
383 fn test_issue_776_currency_exchange_with_price() {
384 let plugin = CurrencyAccountsPlugin::with_base_account("Equity:Currency".to_string());
385
386 let mut p1 = posting("Assets:Bank:EUR", "-100", "EUR");
387 p1.price = Some(price_usd("1.10"));
388
389 let input = PluginInput {
390 directives: vec![txn_wrapper(
391 "2026-03-17",
392 "Currency exchange",
393 vec![p1, posting("Assets:Bank:USD", "110", "USD")],
394 )],
395 options: default_options(),
396 config: None,
397 };
398
399 let input_dirs = input.directives.clone();
400 let output = plugin.process(input);
401 assert_eq!(output.errors.len(), 0);
402 let directives = materialize_ops(&input_dirs, &output);
403
404 assert_eq!(directives.len(), 3);
406
407 let mut opens: Vec<&str> = directives
408 .iter()
409 .filter_map(|d| {
410 if let DirectiveData::Open(o) = &d.data {
411 Some(o.account.as_str())
412 } else {
413 None
414 }
415 })
416 .collect();
417 opens.sort_unstable();
418 assert_eq!(opens, vec!["Equity:Currency:EUR", "Equity:Currency:USD"]);
419
420 let txn_dir = directives
421 .iter()
422 .find(|d| matches!(d.data, DirectiveData::Transaction(_)))
423 .expect("expected transaction");
424 let DirectiveData::Transaction(txn) = &txn_dir.data else {
425 unreachable!()
426 };
427 assert_eq!(txn.postings.len(), 4);
429 assert!(txn.postings[0].price.is_some()); assert!(txn.postings[1].price.is_none()); let eur_neut = txn
438 .postings
439 .iter()
440 .find(|p| p.account == "Equity:Currency:EUR")
441 .expect("missing EUR neutralizer");
442 assert_eq!(eur_neut.units.as_ref().unwrap().number, "110.00");
446 assert_eq!(eur_neut.units.as_ref().unwrap().currency, "USD");
447
448 let usd_neut = txn
450 .postings
451 .iter()
452 .find(|p| p.account == "Equity:Currency:USD")
453 .expect("missing USD neutralizer");
454 assert_eq!(usd_neut.units.as_ref().unwrap().number, "-110");
455 assert_eq!(usd_neut.units.as_ref().unwrap().currency, "USD");
456 }
457
458 #[test]
462 fn test_cost_only_no_price_skipped() {
463 let plugin = CurrencyAccountsPlugin::new();
464
465 let mut p1 = posting("Assets:Shares:RING", "9", "RING");
466 p1.cost = Some(CostData {
467 number: Some(rustledger_plugin_types::CostNumberData::PerUnit {
468 value: "68.55".to_string(),
469 }),
470 currency: Some("USD".to_string()),
471 date: None,
472 label: None,
473 merge: false,
474 });
475
476 let input = PluginInput {
477 directives: vec![txn_wrapper(
478 "2026-03-21",
479 "Buy RING",
480 vec![
481 p1,
482 posting("Expenses:Financial", "0.35", "USD"),
483 posting("Assets:Cash:USD", "-617.30", "USD"),
484 ],
485 )],
486 options: default_options(),
487 config: None,
488 };
489
490 let input_dirs = input.directives.clone();
491 let output = plugin.process(input);
492 assert_eq!(output.errors.len(), 0);
493 let directives = materialize_ops(&input_dirs, &output);
494 assert_eq!(directives.len(), 1);
495 let DirectiveData::Transaction(txn) = &directives[0].data else {
496 panic!("expected transaction");
497 };
498 assert_eq!(txn.postings.len(), 3);
499 }
500
501 #[test]
503 fn test_single_currency_unchanged() {
504 let plugin = CurrencyAccountsPlugin::new();
505 let input = PluginInput {
506 directives: vec![txn_wrapper(
507 "2024-01-15",
508 "Simple transfer",
509 vec![
510 posting("Assets:Bank", "-100", "USD"),
511 posting("Expenses:Food", "100", "USD"),
512 ],
513 )],
514 options: default_options(),
515 config: None,
516 };
517
518 let input_dirs = input.directives.clone();
519 let output = plugin.process(input);
520 let directives = materialize_ops(&input_dirs, &output);
521 assert_eq!(directives.len(), 1);
522 let DirectiveData::Transaction(txn) = &directives[0].data else {
523 panic!("expected transaction");
524 };
525 assert_eq!(txn.postings.len(), 2);
526 }
527
528 #[test]
530 fn test_custom_base_account() {
531 let plugin = CurrencyAccountsPlugin::new();
532
533 let mut p1 = posting("Assets:Bank:EUR", "-100", "EUR");
534 p1.price = Some(price_usd("1.10"));
535
536 let input = PluginInput {
537 directives: vec![txn_wrapper(
538 "2024-01-15",
539 "Exchange",
540 vec![p1, posting("Assets:Bank:USD", "110", "USD")],
541 )],
542 options: default_options(),
543 config: Some("Income:Trading".to_string()),
544 };
545
546 let input_dirs = input.directives.clone();
547 let output = plugin.process(input);
548 let directives = materialize_ops(&input_dirs, &output);
549 assert_eq!(directives.len(), 3);
550 assert!(directives.iter().any(|d| {
551 if let DirectiveData::Open(o) = &d.data {
552 o.account == "Income:Trading:EUR"
553 } else {
554 false
555 }
556 }));
557 assert!(directives.iter().any(|d| {
558 if let DirectiveData::Open(o) = &d.data {
559 o.account == "Income:Trading:USD"
560 } else {
561 false
562 }
563 }));
564 }
565
566 #[test]
569 fn test_skips_existing_open() {
570 let plugin = CurrencyAccountsPlugin::new();
571
572 let existing_open = DirectiveWrapper {
573 directive_type: "open".to_string(),
574 date: "2024-01-01".to_string(),
575 filename: None,
576 lineno: None,
577 data: DirectiveData::Open(OpenData {
578 account: "Equity:CurrencyAccounts:USD".to_string(),
579 currencies: vec![],
580 booking: None,
581 metadata: vec![],
582 }),
583 };
584
585 let mut p1 = posting("Assets:Bank:EUR", "-100", "EUR");
586 p1.price = Some(price_usd("1.10"));
587
588 let input = PluginInput {
589 directives: vec![
590 existing_open,
591 txn_wrapper(
592 "2024-01-15",
593 "Exchange",
594 vec![p1, posting("Assets:Bank:USD", "110", "USD")],
595 ),
596 ],
597 options: default_options(),
598 config: None,
599 };
600
601 let input_dirs = input.directives.clone();
602 let output = plugin.process(input);
603 let directives = materialize_ops(&input_dirs, &output);
604
605 let new_currency_opens: Vec<&str> = directives
609 .iter()
610 .filter_map(|d| {
611 if let DirectiveData::Open(o) = &d.data
612 && d.filename.as_deref() == Some("<currency_accounts>")
613 {
614 Some(o.account.as_str())
615 } else {
616 None
617 }
618 })
619 .collect();
620 assert_eq!(new_currency_opens, vec!["Equity:CurrencyAccounts:EUR"]);
621 }
622
623 #[test]
627 fn test_open_uses_earliest_date() {
628 let plugin = CurrencyAccountsPlugin::new();
629
630 let mut p_later = posting("Assets:Bank:EUR", "-100", "EUR");
631 p_later.price = Some(price_usd("1.10"));
632
633 let input = PluginInput {
634 directives: vec![
635 DirectiveWrapper {
636 directive_type: "open".to_string(),
637 date: "2024-01-01".to_string(),
638 filename: None,
639 lineno: None,
640 data: DirectiveData::Open(OpenData {
641 account: "Assets:Bank:EUR".to_string(),
642 currencies: vec![],
643 booking: None,
644 metadata: vec![],
645 }),
646 },
647 txn_wrapper(
648 "2026-03-17",
649 "Exchange",
650 vec![p_later, posting("Assets:Bank:USD", "110", "USD")],
651 ),
652 ],
653 options: default_options(),
654 config: None,
655 };
656
657 let input_dirs = input.directives.clone();
658 let output = plugin.process(input);
659 let directives = materialize_ops(&input_dirs, &output);
660 for wrapper in &directives {
661 if let DirectiveData::Open(o) = &wrapper.data
662 && o.account.starts_with("Equity:CurrencyAccounts:")
663 && wrapper.filename.as_deref() == Some("<currency_accounts>")
664 {
665 assert_eq!(
666 wrapper.date, "2024-01-01",
667 "plugin-created open should use earliest date"
668 );
669 }
670 }
671 }
672}