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