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