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::Compound {
181                            per_unit,
182                            total,
183                        }) => {
184                            // `{a # b}`: N*a (sign embedded in units) plus
185                            // the lump total signed with the posting.
186                            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            // Compute each group's weight inventory for neutralization.
229            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            // Re-insert ALL original postings in their original order
241            // (including any with units == None, which are auto-balanced
242            // postings that must not be dropped).
243            //
244            // Python's plugin strips price annotations here
245            // (currency_accounts.py:145) because its pipeline runs
246            // plugins BEFORE booking. rustledger also runs plugins
247            // before booking (since PR #1116), but we still keep prices
248            // because the appended neutralizing postings already make
249            // each currency group balanced on its own — booking then
250            // fills any elided posting from the per-currency residual
251            // and the extra prices are redundant rather than harmful.
252            // See `rustledger_validate::Phase` docs and CLAUDE.md's
253            // "Python Compatibility Policy" section for the broader
254            // ordering rationale.
255            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            // Append neutralizing postings (sorted by group key for
262            // deterministic output).
263            for (group_key, inv) in &group_inv {
264                // Python calls `inv.get_only_position()` and errors on
265                // multi-currency groups. We skip neutralization in that
266                // case rather than failing — it indicates a transaction
267                // shape the prototype plugin never handled.
268                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        // Insert Open directives for newly-created currency accounts (skip existing).
306        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    /// Regression test for #776. The canonical reproducer: a currency
394    /// exchange with a price annotation on one side. Python groups by
395    /// units currency, yielding EUR and USD groups, and emits two
396    /// neutralizing postings and two Open directives.
397    #[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        // 2 opens + 1 modified txn
420        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        // 2 originals + 2 neutralizers
443        assert_eq!(txn.postings.len(), 4);
444        // Original postings keep their price annotations (rustledger
445        // runs booking before plugins, so stripping prices would cause
446        // E3001 in the validator).
447        assert!(txn.postings[0].price.is_some()); // EUR posting has price
448        assert!(txn.postings[1].price.is_none()); // USD posting never had price
449
450        // EUR group weight is -110 USD → neutralizer +110 USD on Equity:Currency:EUR.
451        // Note the counter-intuitive currency mismatch — this is what Python emits.
452        let eur_neut = txn
453            .postings
454            .iter()
455            .find(|p| p.account == "Equity:Currency:EUR")
456            .expect("missing EUR neutralizer");
457        // rust_decimal preserves precision of operands: -100 * 1.10 = -110.00,
458        // so the negated weight string is "110.00" (two trailing zeros from
459        // the 1.10 factor). Python prints the same Decimal as "110.00".
460        assert_eq!(eur_neut.units.as_ref().unwrap().number, "110.00");
461        assert_eq!(eur_neut.units.as_ref().unwrap().currency, "USD");
462
463        // USD group weight is +110 USD → neutralizer -110 USD on Equity:Currency:USD.
464        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    /// Cost-only transaction: grouping key is cost.currency, and the plugin
474    /// only neutralizes when `has_price` is true. Without a price annotation,
475    /// the transaction passes through unchanged (no currency accounts created).
476    #[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    /// Single-currency transaction (no price, no cost): passed through.
517    #[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    /// Custom base account via config string.
544    #[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    /// Pre-existing Open for a currency account should not be duplicated
582    /// by the plugin (would cause E1002 in the validator).
583    #[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        // Only Equity:CurrencyAccounts:EUR should be a newly-created open
621        // (filename marker <currency_accounts>). The USD open passed
622        // through from the input.
623        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    /// Open directives for plugin-created accounts use the earliest date
639    /// observed in the input (matches Python `earliest_date = entries[0].date`
640    /// when entries are date-sorted upstream).
641    #[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}