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, PluginOp, PluginOutput};
4
5use super::super::{NativePlugin, RegularPlugin};
6
7/// Plugin that auto-generates currency trading account postings.
8///
9/// Implements the currency trading accounts method as in Python beancount's
10/// `beancount.plugins.currency_accounts`. For transactions that mix multiple
11/// currencies and use price annotations, this plugin:
12///
13/// 1. Groups postings by `cost.currency` (if the posting has a cost) or
14///    `units.currency` (otherwise). **Price currency is never used as the
15///    group key** — this matches Python's `group_postings_by_weight_currency`.
16/// 2. If there is at least one price annotation in the transaction and
17///    there are two or more distinct group keys, inserts a neutralizing
18///    posting for each group. The neutralizing posting goes to
19///    `<base>:<group_key>` and carries the negated weight inventory of
20///    that group (denominated in the weight/cost currency, which may
21///    differ from the group key).
22/// 3. Unlike Python's plugin, does NOT strip `price` annotations from
23///    the original postings. Python strips them because its pipeline
24///    runs plugins before booking; rustledger runs booking first, so
25///    stripping prices would cause balance-check failures (E3001) in
26///    the post-plugin validator.
27/// 4. Emits `open` directives at the earliest transaction date for all
28///    newly created currency trading accounts.
29pub struct CurrencyAccountsPlugin {
30    /// Base account for currency tracking (default: "Equity:CurrencyAccounts").
31    base_account: String,
32}
33
34impl CurrencyAccountsPlugin {
35    /// Create with default base account.
36    pub fn new() -> Self {
37        Self {
38            base_account: "Equity:CurrencyAccounts".to_string(),
39        }
40    }
41
42    /// Create with custom base account.
43    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        // Get base account from config if provided. We only check for
70        // non-empty (Python's plugin additionally validates that it is a
71        // well-formed account name and falls back to the default when
72        // it isn't, but we skip that check for simplicity).
73        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        // Find earliest date and collect existing Open accounts in one pass.
81        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            // Group postings by key and track whether any posting has a price.
107            //
108            // Use BTreeMap for deterministic iteration so the order in which
109            // neutralizing postings are appended is stable across runs.
110            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                // Group key: cost.currency if the posting has a cost,
119                // otherwise units.currency. Matches Python's
120                // `group_postings_by_weight_currency` at
121                // currency_accounts.py:93-104.
122                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            // Only neutralize when there's at least one price AND more than
138            // one currency group. This is Python's gating condition.
139            if !has_price || curmap.len() < 2 {
140                ops.push(PluginOp::Keep(i));
141                continue;
142            }
143
144            // `weight(posting)` returns (amount, currency):
145            //   - Cost (PerUnit): (units * per_unit, cost.currency).
146            //   - Cost (Total / PerUnitFromTotal): preserved total
147            //     magnitude with sign following units.
148            //   - Price: (units * price, price.currency). For @@ (is_total),
149            //     weight magnitude is the total price, sign follows units.
150            //   - Else: (units.amount, units.currency)
151            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                    // Exhaustive variant match. The Total and
160                    // PerUnitFromTotal arms use the preserved total
161                    // (matching Python's `beancount.core.convert.get_cost`,
162                    // which uses the source total exactly and avoids
163                    // the division-then-multiplication precision
164                    // loss). PerUnit multiplies. Future variant
165                    // additions to `CostNumberData` will compile-fail
166                    // here, which is what we want.
167                    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            // Compute each group's weight inventory for neutralization.
214            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            // Re-insert ALL original postings in their original order
226            // (including any with units == None, which are auto-balanced
227            // postings that must not be dropped).
228            //
229            // Python's plugin strips price annotations here
230            // (currency_accounts.py:145) because its pipeline runs
231            // plugins BEFORE booking. rustledger also runs plugins
232            // before booking (since PR #1116), but we still keep prices
233            // because the appended neutralizing postings already make
234            // each currency group balanced on its own — booking then
235            // fills any elided posting from the per-currency residual
236            // and the extra prices are redundant rather than harmful.
237            // See `rustledger_validate::Phase` docs and CLAUDE.md's
238            // "Python Compatibility Policy" section for the broader
239            // ordering rationale.
240            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            // Append neutralizing postings (sorted by group key for
247            // deterministic output).
248            for (group_key, inv) in &group_inv {
249                // Python calls `inv.get_only_position()` and errors on
250                // multi-currency groups. We skip neutralization in that
251                // case rather than failing — it indicates a transaction
252                // shape the prototype plugin never handled.
253                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        // Insert Open directives for newly-created currency accounts (skip existing).
291        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    /// Regression test for #776. The canonical reproducer: a currency
379    /// exchange with a price annotation on one side. Python groups by
380    /// units currency, yielding EUR and USD groups, and emits two
381    /// neutralizing postings and two Open directives.
382    #[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        // 2 opens + 1 modified txn
405        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        // 2 originals + 2 neutralizers
428        assert_eq!(txn.postings.len(), 4);
429        // Original postings keep their price annotations (rustledger
430        // runs booking before plugins, so stripping prices would cause
431        // E3001 in the validator).
432        assert!(txn.postings[0].price.is_some()); // EUR posting has price
433        assert!(txn.postings[1].price.is_none()); // USD posting never had price
434
435        // EUR group weight is -110 USD → neutralizer +110 USD on Equity:Currency:EUR.
436        // Note the counter-intuitive currency mismatch — this is what Python emits.
437        let eur_neut = txn
438            .postings
439            .iter()
440            .find(|p| p.account == "Equity:Currency:EUR")
441            .expect("missing EUR neutralizer");
442        // rust_decimal preserves precision of operands: -100 * 1.10 = -110.00,
443        // so the negated weight string is "110.00" (two trailing zeros from
444        // the 1.10 factor). Python prints the same Decimal as "110.00".
445        assert_eq!(eur_neut.units.as_ref().unwrap().number, "110.00");
446        assert_eq!(eur_neut.units.as_ref().unwrap().currency, "USD");
447
448        // USD group weight is +110 USD → neutralizer -110 USD on Equity:Currency:USD.
449        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    /// Cost-only transaction: grouping key is cost.currency, and the plugin
459    /// only neutralizes when `has_price` is true. Without a price annotation,
460    /// the transaction passes through unchanged (no currency accounts created).
461    #[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    /// Single-currency transaction (no price, no cost): passed through.
502    #[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    /// Custom base account via config string.
529    #[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    /// Pre-existing Open for a currency account should not be duplicated
567    /// by the plugin (would cause E1002 in the validator).
568    #[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        // Only Equity:CurrencyAccounts:EUR should be a newly-created open
606        // (filename marker <currency_accounts>). The USD open passed
607        // through from the input.
608        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    /// Open directives for plugin-created accounts use the earliest date
624    /// observed in the input (matches Python `earliest_date = entries[0].date`
625    /// when entries are date-sorted upstream).
626    #[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}