Skip to main content

rustledger_plugin/native/plugins/
currency_accounts.rs

1//! Auto-generate currency trading account postings.
2
3use crate::types::{DirectiveData, DirectiveWrapper, PluginInput, PluginOutput};
4
5use super::super::NativePlugin;
6
7/// Plugin that auto-generates currency trading account postings.
8///
9/// For multi-currency transactions, this plugin adds neutralizing postings
10/// to equity accounts like `Equity:CurrencyAccounts:USD` to track currency
11/// conversion gains/losses. This enables proper reporting of currency
12/// trading activity.
13pub struct CurrencyAccountsPlugin {
14    /// Base account for currency tracking (default: "Equity:CurrencyAccounts").
15    base_account: String,
16}
17
18impl CurrencyAccountsPlugin {
19    /// Create with default base account.
20    pub fn new() -> Self {
21        Self {
22            base_account: "Equity:CurrencyAccounts".to_string(),
23        }
24    }
25
26    /// Create with custom base account.
27    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        // Get base account from config if provided
54        let base_account = input
55            .config
56            .as_ref()
57            .map_or_else(|| self.base_account.clone(), |c| c.trim().to_string());
58
59        // Pre-allocate with expected capacity
60        let mut new_directives: Vec<DirectiveWrapper> = Vec::with_capacity(input.directives.len());
61        let mut created_accounts: HashSet<String> = HashSet::new();
62
63        // Single pass: collect existing opens AND find earliest date
64        let mut existing_opens: HashSet<String> = HashSet::new();
65        let mut earliest_date: Option<&str> = None;
66        for wrapper in &input.directives {
67            // Track earliest date
68            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            // Collect existing Open accounts
76            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                // Calculate currency totals for this transaction
85                // Map from currency -> total amount in that currency
86                // Like Python beancount, we use the WEIGHT currency (cost currency if present)
87                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                        // Determine the weight currency and amount
94                        // If posting has a cost, use cost currency and calculate cost amount
95                        // Otherwise use units currency and amount
96                        let (currency, amount) = if let Some(cost) = &posting.cost {
97                            if let Some(cost_currency) = &cost.currency {
98                                // Calculate cost amount
99                                let cost_amount = if let Some(num_per) = &cost.number_per {
100                                    // Per-unit cost: units * cost_per_unit
101                                    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                                    // Total cost specified directly
106                                    Decimal::from_str(num_total).unwrap_or_default()
107                                } else {
108                                    // No cost number, fall back to units
109                                    units_amount
110                                };
111                                (cost_currency.clone(), cost_amount)
112                            } else {
113                                // Cost exists but no currency - fall back to units
114                                (units.currency.clone(), units_amount)
115                            }
116                        } else if let Some(price) = &posting.price {
117                            // Price annotation (@) - use price currency for weight
118                            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                                    // Total price (@@): weight is the price amount directly
124                                    // But sign follows units
125                                    if units_amount < Decimal::ZERO {
126                                        -price_num
127                                    } else {
128                                        price_num
129                                    }
130                                } else {
131                                    // Per-unit price (@): weight = units * price
132                                    units_amount * price_num
133                                };
134                                (price_currency, weight)
135                            } else {
136                                // Incomplete price - fall back to units
137                                (units.currency.clone(), units_amount)
138                            }
139                        } else {
140                            // No cost or price - use units directly
141                            (units.currency.clone(), units_amount)
142                        };
143
144                        *currency_totals.entry(currency).or_default() += amount;
145                    }
146                }
147
148                // If we have multiple currencies with non-zero totals, add balancing postings
149                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                    // Clone the transaction and add currency account postings
156                    let mut modified_txn = txn.clone();
157
158                    for &(currency, total) in &non_zero_currencies {
159                        let account_name = format!("{base_account}:{currency}");
160                        // Track the account for Open directive generation
161                        created_accounts.insert(account_name.clone());
162
163                        // Add posting to currency account to neutralize
164                        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(), // Preserve original location
181                        lineno: wrapper.lineno,
182                        data: DirectiveData::Transaction(modified_txn),
183                    });
184                } else {
185                    // Single currency or balanced - pass through
186                    new_directives.push(wrapper.clone());
187                }
188            } else {
189                new_directives.push(wrapper.clone());
190            }
191        }
192
193        // Generate Open directives for created currency accounts (skip existing ones)
194        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        // Sort for deterministic output
212        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        // Prepend Open directives to the output
221        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        // Should have 2 Open directives + 1 Transaction
288        assert_eq!(output.directives.len(), 3);
289
290        // First two should be Open directives (sorted alphabetically)
291        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        // Last should be the transaction
306        if let DirectiveData::Transaction(txn) = &output.directives[2].data {
307            // Should have original 2 postings + 2 currency account postings
308            assert_eq!(txn.postings.len(), 4);
309
310            // Check for currency account postings
311            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            // Should neutralize the -100 USD
318            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            // Should neutralize the 85 EUR
327            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        // Single currency balanced - should not add any postings
387        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        // Should have 2 Open directives + 1 Transaction
444        assert_eq!(output.directives.len(), 3);
445
446        // Check Open directives use custom base account
447        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        // Transaction is at index 2
456        if let DirectiveData::Transaction(txn) = &output.directives[2].data {
457            // Check for custom base account in postings
458            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(), // Earlier date
515                    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        // Should have 4 Open directives (EUR, GBP, JPY, USD) + 2 Transactions
560        assert_eq!(output.directives.len(), 6);
561
562        // All Open directives should use the earliest date (2024-01-01)
563        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        // Issue #521/#531: When a posting has a cost, use the cost currency
576        // for grouping, not the units currency
577        let plugin = CurrencyAccountsPlugin::new();
578
579        // Transaction: Buy 9 RING at 68.55 USD each
580        // All postings should be grouped under USD (the cost currency)
581        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        // All postings have cost/units in USD, so NO currency account postings should be added
649        // The transaction should pass through unchanged (just 1 directive)
650        assert_eq!(output.directives.len(), 1);
651
652        if let DirectiveData::Transaction(txn) = &output.directives[0].data {
653            // Should have the original 3 postings only
654            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        // When a posting has a price (@), use the price currency for grouping
663        let plugin = CurrencyAccountsPlugin::new();
664
665        // Transaction: -100 EUR @ 1.10 USD, +110 USD
666        // Both should be grouped under USD (price currency for first, units for second)
667        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        // Both postings have weight in USD:
725        // -100 EUR @ 1.10 USD = -110 USD weight
726        // +110 USD = +110 USD weight
727        // Total: 0 USD - balanced, NO currency account postings needed
728        assert_eq!(output.directives.len(), 1);
729
730        if let DirectiveData::Transaction(txn) = &output.directives[0].data {
731            // Should have the original 2 postings only
732            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        // When user already has Open directive for currency account,
741        // plugin should NOT create a duplicate (would cause E1002)
742        let plugin = CurrencyAccountsPlugin::new();
743
744        let input = PluginInput {
745            directives: vec![
746                // Pre-existing Open for the currency account
747                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                // Multi-currency transaction
784                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        // Should have:
834        // - 1 new Open for Equity:CurrencyAccounts:EUR (USD already exists)
835        // - 3 original Open directives
836        // - 1 modified Transaction
837        assert_eq!(output.directives.len(), 5);
838
839        // Count Open directives for currency accounts
840        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        // Should have exactly 2 currency account Opens (USD from input, EUR generated)
857        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}