1use crate::types::{DirectiveData, DirectiveWrapper, PluginInput, PluginOutput};
4
5use super::super::NativePlugin;
6
7pub struct CurrencyAccountsPlugin {
14 base_account: String,
16}
17
18impl CurrencyAccountsPlugin {
19 pub fn new() -> Self {
21 Self {
22 base_account: "Equity:CurrencyAccounts".to_string(),
23 }
24 }
25
26 pub const fn with_base_account(base_account: String) -> Self {
28 Self { base_account }
29 }
30}
31
32impl Default for CurrencyAccountsPlugin {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl NativePlugin for CurrencyAccountsPlugin {
39 fn name(&self) -> &'static str {
40 "currency_accounts"
41 }
42
43 fn description(&self) -> &'static str {
44 "Auto-generate currency trading postings"
45 }
46
47 fn process(&self, input: PluginInput) -> PluginOutput {
48 use crate::types::{AmountData, OpenData, PostingData};
49 use rust_decimal::Decimal;
50 use std::collections::{HashMap, HashSet};
51 use std::str::FromStr;
52
53 let base_account = input
55 .config
56 .as_ref()
57 .map_or_else(|| self.base_account.clone(), |c| c.trim().to_string());
58
59 let mut new_directives: Vec<DirectiveWrapper> = Vec::with_capacity(input.directives.len());
61 let mut created_accounts: HashSet<String> = HashSet::new();
62
63 let mut existing_opens: HashSet<String> = HashSet::new();
65 let mut earliest_date: Option<&str> = None;
66 for wrapper in &input.directives {
67 match earliest_date {
69 None => earliest_date = Some(&wrapper.date),
70 Some(current) if wrapper.date.as_str() < current => {
71 earliest_date = Some(&wrapper.date);
72 }
73 _ => {}
74 }
75 if let DirectiveData::Open(open) = &wrapper.data {
77 existing_opens.insert(open.account.clone());
78 }
79 }
80 let earliest_date = earliest_date.unwrap_or("1970-01-01").to_string();
81
82 for wrapper in &input.directives {
83 if let DirectiveData::Transaction(txn) = &wrapper.data {
84 let mut currency_totals: HashMap<String, Decimal> = HashMap::new();
88
89 for posting in &txn.postings {
90 if let Some(units) = &posting.units {
91 let units_amount = Decimal::from_str(&units.number).unwrap_or_default();
92
93 let (currency, amount) = if let Some(cost) = &posting.cost {
97 if let Some(cost_currency) = &cost.currency {
98 let cost_amount = if let Some(num_per) = &cost.number_per {
100 let per_unit =
102 Decimal::from_str(num_per).unwrap_or(Decimal::ONE);
103 units_amount * per_unit
104 } else if let Some(num_total) = &cost.number_total {
105 Decimal::from_str(num_total).unwrap_or_default()
107 } else {
108 units_amount
110 };
111 (cost_currency.clone(), cost_amount)
112 } else {
113 (units.currency.clone(), units_amount)
115 }
116 } else if let Some(price) = &posting.price {
117 if let Some(price_amount) = &price.amount {
119 let price_currency = price_amount.currency.clone();
120 let price_num =
121 Decimal::from_str(&price_amount.number).unwrap_or(Decimal::ONE);
122 let weight = if price.is_total {
123 if units_amount < Decimal::ZERO {
126 -price_num
127 } else {
128 price_num
129 }
130 } else {
131 units_amount * price_num
133 };
134 (price_currency, weight)
135 } else {
136 (units.currency.clone(), units_amount)
138 }
139 } else {
140 (units.currency.clone(), units_amount)
142 };
143
144 *currency_totals.entry(currency).or_default() += amount;
145 }
146 }
147
148 let non_zero_currencies: Vec<_> = currency_totals
150 .iter()
151 .filter(|&(_, total)| *total != Decimal::ZERO)
152 .collect();
153
154 if non_zero_currencies.len() > 1 {
155 let mut modified_txn = txn.clone();
157
158 for &(currency, total) in &non_zero_currencies {
159 let account_name = format!("{base_account}:{currency}");
160 created_accounts.insert(account_name.clone());
162
163 modified_txn.postings.push(PostingData {
165 account: account_name,
166 units: Some(AmountData {
167 number: (-*total).to_string(),
168 currency: (*currency).clone(),
169 }),
170 cost: None,
171 price: None,
172 flag: None,
173 metadata: vec![],
174 });
175 }
176
177 new_directives.push(DirectiveWrapper {
178 directive_type: wrapper.directive_type.clone(),
179 date: wrapper.date.clone(),
180 filename: wrapper.filename.clone(), lineno: wrapper.lineno,
182 data: DirectiveData::Transaction(modified_txn),
183 });
184 } else {
185 new_directives.push(wrapper.clone());
187 }
188 } else {
189 new_directives.push(wrapper.clone());
190 }
191 }
192
193 let mut open_directives: Vec<DirectiveWrapper> = created_accounts
195 .into_iter()
196 .filter(|account| !existing_opens.contains(account))
197 .map(|account| DirectiveWrapper {
198 directive_type: "open".to_string(),
199 date: earliest_date.clone(),
200 filename: Some("<currency_accounts>".to_string()),
201 lineno: None,
202 data: DirectiveData::Open(OpenData {
203 account,
204 currencies: vec![],
205 booking: None,
206 metadata: vec![],
207 }),
208 })
209 .collect();
210
211 open_directives.sort_by(|a, b| {
213 if let (DirectiveData::Open(oa), DirectiveData::Open(ob)) = (&a.data, &b.data) {
214 oa.account.cmp(&ob.account)
215 } else {
216 std::cmp::Ordering::Equal
217 }
218 });
219
220 open_directives.extend(new_directives);
222
223 PluginOutput {
224 directives: open_directives,
225 errors: Vec::new(),
226 }
227 }
228}
229
230#[cfg(test)]
231mod currency_accounts_tests {
232 use super::*;
233 use crate::types::*;
234
235 #[test]
236 fn test_currency_accounts_adds_balancing_postings() {
237 let plugin = CurrencyAccountsPlugin::new();
238
239 let input = PluginInput {
240 directives: vec![DirectiveWrapper {
241 directive_type: "transaction".to_string(),
242 date: "2024-01-15".to_string(),
243 filename: None,
244 lineno: None,
245 data: DirectiveData::Transaction(TransactionData {
246 flag: "*".to_string(),
247 payee: None,
248 narration: "Currency exchange".to_string(),
249 tags: vec![],
250 links: vec![],
251 metadata: vec![],
252 postings: vec![
253 PostingData {
254 account: "Assets:Bank:USD".to_string(),
255 units: Some(AmountData {
256 number: "-100".to_string(),
257 currency: "USD".to_string(),
258 }),
259 cost: None,
260 price: None,
261 flag: None,
262 metadata: vec![],
263 },
264 PostingData {
265 account: "Assets:Bank:EUR".to_string(),
266 units: Some(AmountData {
267 number: "85".to_string(),
268 currency: "EUR".to_string(),
269 }),
270 cost: None,
271 price: None,
272 flag: None,
273 metadata: vec![],
274 },
275 ],
276 }),
277 }],
278 options: PluginOptions {
279 operating_currencies: vec!["USD".to_string()],
280 title: None,
281 },
282 config: None,
283 };
284
285 let output = plugin.process(input);
286 assert_eq!(output.errors.len(), 0);
287 assert_eq!(output.directives.len(), 3);
289
290 if let DirectiveData::Open(open) = &output.directives[0].data {
292 assert_eq!(open.account, "Equity:CurrencyAccounts:EUR");
293 assert_eq!(output.directives[0].date, "2024-01-15");
294 } else {
295 panic!("Expected Open directive at index 0");
296 }
297
298 if let DirectiveData::Open(open) = &output.directives[1].data {
299 assert_eq!(open.account, "Equity:CurrencyAccounts:USD");
300 assert_eq!(output.directives[1].date, "2024-01-15");
301 } else {
302 panic!("Expected Open directive at index 1");
303 }
304
305 if let DirectiveData::Transaction(txn) = &output.directives[2].data {
307 assert_eq!(txn.postings.len(), 4);
309
310 let usd_posting = txn
312 .postings
313 .iter()
314 .find(|p| p.account == "Equity:CurrencyAccounts:USD");
315 assert!(usd_posting.is_some());
316 let usd_posting = usd_posting.unwrap();
317 assert_eq!(usd_posting.units.as_ref().unwrap().number, "100");
319
320 let eur_posting = txn
321 .postings
322 .iter()
323 .find(|p| p.account == "Equity:CurrencyAccounts:EUR");
324 assert!(eur_posting.is_some());
325 let eur_posting = eur_posting.unwrap();
326 assert_eq!(eur_posting.units.as_ref().unwrap().number, "-85");
328 } else {
329 panic!("Expected Transaction directive at index 2");
330 }
331 }
332
333 #[test]
334 fn test_currency_accounts_single_currency_unchanged() {
335 let plugin = CurrencyAccountsPlugin::new();
336
337 let input = PluginInput {
338 directives: vec![DirectiveWrapper {
339 directive_type: "transaction".to_string(),
340 date: "2024-01-15".to_string(),
341 filename: None,
342 lineno: None,
343 data: DirectiveData::Transaction(TransactionData {
344 flag: "*".to_string(),
345 payee: None,
346 narration: "Simple transfer".to_string(),
347 tags: vec![],
348 links: vec![],
349 metadata: vec![],
350 postings: vec![
351 PostingData {
352 account: "Assets:Bank".to_string(),
353 units: Some(AmountData {
354 number: "-100".to_string(),
355 currency: "USD".to_string(),
356 }),
357 cost: None,
358 price: None,
359 flag: None,
360 metadata: vec![],
361 },
362 PostingData {
363 account: "Expenses:Food".to_string(),
364 units: Some(AmountData {
365 number: "100".to_string(),
366 currency: "USD".to_string(),
367 }),
368 cost: None,
369 price: None,
370 flag: None,
371 metadata: vec![],
372 },
373 ],
374 }),
375 }],
376 options: PluginOptions {
377 operating_currencies: vec!["USD".to_string()],
378 title: None,
379 },
380 config: None,
381 };
382
383 let output = plugin.process(input);
384 assert_eq!(output.errors.len(), 0);
385
386 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
388 assert_eq!(txn.postings.len(), 2);
389 }
390 }
391
392 #[test]
393 fn test_currency_accounts_custom_base_account() {
394 let plugin = CurrencyAccountsPlugin::new();
395
396 let input = PluginInput {
397 directives: vec![DirectiveWrapper {
398 directive_type: "transaction".to_string(),
399 date: "2024-01-15".to_string(),
400 filename: None,
401 lineno: None,
402 data: DirectiveData::Transaction(TransactionData {
403 flag: "*".to_string(),
404 payee: None,
405 narration: "Exchange".to_string(),
406 tags: vec![],
407 links: vec![],
408 metadata: vec![],
409 postings: vec![
410 PostingData {
411 account: "Assets:USD".to_string(),
412 units: Some(AmountData {
413 number: "-50".to_string(),
414 currency: "USD".to_string(),
415 }),
416 cost: None,
417 price: None,
418 flag: None,
419 metadata: vec![],
420 },
421 PostingData {
422 account: "Assets:EUR".to_string(),
423 units: Some(AmountData {
424 number: "42".to_string(),
425 currency: "EUR".to_string(),
426 }),
427 cost: None,
428 price: None,
429 flag: None,
430 metadata: vec![],
431 },
432 ],
433 }),
434 }],
435 options: PluginOptions {
436 operating_currencies: vec!["USD".to_string()],
437 title: None,
438 },
439 config: Some("Income:Trading".to_string()),
440 };
441
442 let output = plugin.process(input);
443 assert_eq!(output.directives.len(), 3);
445
446 assert!(output.directives.iter().any(|d| {
448 if let DirectiveData::Open(open) = &d.data {
449 open.account.starts_with("Income:Trading:")
450 } else {
451 false
452 }
453 }));
454
455 if let DirectiveData::Transaction(txn) = &output.directives[2].data {
457 assert!(
459 txn.postings
460 .iter()
461 .any(|p| p.account.starts_with("Income:Trading:"))
462 );
463 } else {
464 panic!("Expected Transaction directive at index 2");
465 }
466 }
467
468 #[test]
469 fn test_currency_accounts_open_directives_use_earliest_date() {
470 let plugin = CurrencyAccountsPlugin::new();
471
472 let input = PluginInput {
473 directives: vec![
474 DirectiveWrapper {
475 directive_type: "transaction".to_string(),
476 date: "2024-03-15".to_string(),
477 filename: None,
478 lineno: None,
479 data: DirectiveData::Transaction(TransactionData {
480 flag: "*".to_string(),
481 payee: None,
482 narration: "Later exchange".to_string(),
483 tags: vec![],
484 links: vec![],
485 metadata: vec![],
486 postings: vec![
487 PostingData {
488 account: "Assets:USD".to_string(),
489 units: Some(AmountData {
490 number: "-100".to_string(),
491 currency: "USD".to_string(),
492 }),
493 cost: None,
494 price: None,
495 flag: None,
496 metadata: vec![],
497 },
498 PostingData {
499 account: "Assets:EUR".to_string(),
500 units: Some(AmountData {
501 number: "85".to_string(),
502 currency: "EUR".to_string(),
503 }),
504 cost: None,
505 price: None,
506 flag: None,
507 metadata: vec![],
508 },
509 ],
510 }),
511 },
512 DirectiveWrapper {
513 directive_type: "transaction".to_string(),
514 date: "2024-01-01".to_string(), filename: None,
516 lineno: None,
517 data: DirectiveData::Transaction(TransactionData {
518 flag: "*".to_string(),
519 payee: None,
520 narration: "Earlier exchange".to_string(),
521 tags: vec![],
522 links: vec![],
523 metadata: vec![],
524 postings: vec![
525 PostingData {
526 account: "Assets:GBP".to_string(),
527 units: Some(AmountData {
528 number: "-50".to_string(),
529 currency: "GBP".to_string(),
530 }),
531 cost: None,
532 price: None,
533 flag: None,
534 metadata: vec![],
535 },
536 PostingData {
537 account: "Assets:JPY".to_string(),
538 units: Some(AmountData {
539 number: "7500".to_string(),
540 currency: "JPY".to_string(),
541 }),
542 cost: None,
543 price: None,
544 flag: None,
545 metadata: vec![],
546 },
547 ],
548 }),
549 },
550 ],
551 options: PluginOptions {
552 operating_currencies: vec!["USD".to_string()],
553 title: None,
554 },
555 config: None,
556 };
557
558 let output = plugin.process(input);
559 assert_eq!(output.directives.len(), 6);
561
562 for wrapper in &output.directives[..4] {
564 if let DirectiveData::Open(_) = &wrapper.data {
565 assert_eq!(
566 wrapper.date, "2024-01-01",
567 "Open directive should use earliest date"
568 );
569 }
570 }
571 }
572
573 #[test]
574 fn test_currency_accounts_uses_cost_currency() {
575 let plugin = CurrencyAccountsPlugin::new();
578
579 let input = PluginInput {
582 directives: vec![DirectiveWrapper {
583 directive_type: "transaction".to_string(),
584 date: "2026-03-21".to_string(),
585 filename: None,
586 lineno: None,
587 data: DirectiveData::Transaction(TransactionData {
588 flag: "*".to_string(),
589 payee: Some("Buy RING".to_string()),
590 narration: String::new(),
591 tags: vec![],
592 links: vec![],
593 metadata: vec![],
594 postings: vec![
595 PostingData {
596 account: "Assets:Shares:RING".to_string(),
597 units: Some(AmountData {
598 number: "9".to_string(),
599 currency: "RING".to_string(),
600 }),
601 cost: Some(CostData {
602 number_per: Some("68.55".to_string()),
603 number_total: None,
604 currency: Some("USD".to_string()),
605 date: None,
606 label: None,
607 merge: false,
608 }),
609 price: None,
610 flag: None,
611 metadata: vec![],
612 },
613 PostingData {
614 account: "Expenses:Financial".to_string(),
615 units: Some(AmountData {
616 number: "0.35".to_string(),
617 currency: "USD".to_string(),
618 }),
619 cost: None,
620 price: None,
621 flag: None,
622 metadata: vec![],
623 },
624 PostingData {
625 account: "Assets:Cash:USD".to_string(),
626 units: Some(AmountData {
627 number: "-617.30".to_string(),
628 currency: "USD".to_string(),
629 }),
630 cost: None,
631 price: None,
632 flag: None,
633 metadata: vec![],
634 },
635 ],
636 }),
637 }],
638 options: PluginOptions {
639 operating_currencies: vec!["USD".to_string()],
640 title: None,
641 },
642 config: None,
643 };
644
645 let output = plugin.process(input);
646 assert_eq!(output.errors.len(), 0);
647
648 assert_eq!(output.directives.len(), 1);
651
652 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
653 assert_eq!(txn.postings.len(), 3);
655 } else {
656 panic!("Expected Transaction directive");
657 }
658 }
659
660 #[test]
661 fn test_currency_accounts_uses_price_currency() {
662 let plugin = CurrencyAccountsPlugin::new();
664
665 let input = PluginInput {
668 directives: vec![DirectiveWrapper {
669 directive_type: "transaction".to_string(),
670 date: "2026-03-17".to_string(),
671 filename: None,
672 lineno: None,
673 data: DirectiveData::Transaction(TransactionData {
674 flag: "*".to_string(),
675 payee: None,
676 narration: "Currency exchange".to_string(),
677 tags: vec![],
678 links: vec![],
679 metadata: vec![],
680 postings: vec![
681 PostingData {
682 account: "Assets:Bank:EUR".to_string(),
683 units: Some(AmountData {
684 number: "-100".to_string(),
685 currency: "EUR".to_string(),
686 }),
687 cost: None,
688 price: Some(PriceAnnotationData {
689 is_total: false,
690 amount: Some(AmountData {
691 number: "1.10".to_string(),
692 currency: "USD".to_string(),
693 }),
694 number: None,
695 currency: None,
696 }),
697 flag: None,
698 metadata: vec![],
699 },
700 PostingData {
701 account: "Assets:Bank:USD".to_string(),
702 units: Some(AmountData {
703 number: "110".to_string(),
704 currency: "USD".to_string(),
705 }),
706 cost: None,
707 price: None,
708 flag: None,
709 metadata: vec![],
710 },
711 ],
712 }),
713 }],
714 options: PluginOptions {
715 operating_currencies: vec!["USD".to_string()],
716 title: None,
717 },
718 config: None,
719 };
720
721 let output = plugin.process(input);
722 assert_eq!(output.errors.len(), 0);
723
724 assert_eq!(output.directives.len(), 1);
729
730 if let DirectiveData::Transaction(txn) = &output.directives[0].data {
731 assert_eq!(txn.postings.len(), 2);
733 } else {
734 panic!("Expected Transaction directive");
735 }
736 }
737
738 #[test]
739 fn test_currency_accounts_skips_existing_open() {
740 let plugin = CurrencyAccountsPlugin::new();
743
744 let input = PluginInput {
745 directives: vec![
746 DirectiveWrapper {
748 directive_type: "open".to_string(),
749 date: "2024-01-01".to_string(),
750 filename: None,
751 lineno: None,
752 data: DirectiveData::Open(OpenData {
753 account: "Equity:CurrencyAccounts:USD".to_string(),
754 currencies: vec![],
755 booking: None,
756 metadata: vec![],
757 }),
758 },
759 DirectiveWrapper {
760 directive_type: "open".to_string(),
761 date: "2024-01-01".to_string(),
762 filename: None,
763 lineno: None,
764 data: DirectiveData::Open(OpenData {
765 account: "Assets:Bank:EUR".to_string(),
766 currencies: vec![],
767 booking: None,
768 metadata: vec![],
769 }),
770 },
771 DirectiveWrapper {
772 directive_type: "open".to_string(),
773 date: "2024-01-01".to_string(),
774 filename: None,
775 lineno: None,
776 data: DirectiveData::Open(OpenData {
777 account: "Assets:Bank:USD".to_string(),
778 currencies: vec![],
779 booking: None,
780 metadata: vec![],
781 }),
782 },
783 DirectiveWrapper {
785 directive_type: "transaction".to_string(),
786 date: "2024-01-15".to_string(),
787 filename: None,
788 lineno: None,
789 data: DirectiveData::Transaction(TransactionData {
790 flag: "*".to_string(),
791 payee: None,
792 narration: "Currency exchange".to_string(),
793 tags: vec![],
794 links: vec![],
795 metadata: vec![],
796 postings: vec![
797 PostingData {
798 account: "Assets:Bank:USD".to_string(),
799 units: Some(AmountData {
800 number: "-100".to_string(),
801 currency: "USD".to_string(),
802 }),
803 cost: None,
804 price: None,
805 flag: None,
806 metadata: vec![],
807 },
808 PostingData {
809 account: "Assets:Bank:EUR".to_string(),
810 units: Some(AmountData {
811 number: "85".to_string(),
812 currency: "EUR".to_string(),
813 }),
814 cost: None,
815 price: None,
816 flag: None,
817 metadata: vec![],
818 },
819 ],
820 }),
821 },
822 ],
823 options: PluginOptions {
824 operating_currencies: vec!["USD".to_string()],
825 title: None,
826 },
827 config: None,
828 };
829
830 let output = plugin.process(input);
831 assert_eq!(output.errors.len(), 0);
832
833 assert_eq!(output.directives.len(), 5);
838
839 let currency_account_opens: Vec<_> = output
841 .directives
842 .iter()
843 .filter_map(|d| {
844 if let DirectiveData::Open(open) = &d.data {
845 if open.account.starts_with("Equity:CurrencyAccounts:") {
846 Some(open.account.clone())
847 } else {
848 None
849 }
850 } else {
851 None
852 }
853 })
854 .collect();
855
856 assert_eq!(currency_account_opens.len(), 2);
858 assert!(currency_account_opens.contains(&"Equity:CurrencyAccounts:USD".to_string()));
859 assert!(currency_account_opens.contains(&"Equity:CurrencyAccounts:EUR".to_string()));
860 }
861}