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), delegating the
145            // arithmetic to the booking crate's single-source weight ladder
146            // (`cost_number_weight` / `price_weight` — the exact rule the
147            // balance validator's residual uses), after parsing this DTO's
148            // string numbers. The `CostNumberData` → `CostNumber` mapping is
149            // an exhaustive match, so future variant additions still
150            // compile-fail here, which is what we want.
151            //   - Cost: canonical cost weight in cost.currency (preserved
152            //     totals — no division-then-multiplication precision loss).
153            //   - Price: canonical price weight in price.currency (@@ sign
154            //     follows units).
155            //   - Else: (units.amount, units.currency)
156            let weight_of = |posting: &PostingData| -> Option<(Decimal, String)> {
157                use rustledger_core::{BookedCost, CostNumber, PriceKind};
158                use rustledger_plugin_types::CostNumberData;
159                let units = posting.units.as_ref()?;
160                let units_num = Decimal::from_str(&units.number).unwrap_or_default();
161                let parse = |s: &str| Decimal::from_str(s).unwrap_or_default();
162                if let Some(cost) = &posting.cost {
163                    let currency = cost
164                        .currency
165                        .clone()
166                        .unwrap_or_else(|| units.currency.clone());
167                    let number = match &cost.number {
168                        Some(CostNumberData::PerUnit { value }) => Some(CostNumber::PerUnit {
169                            value: parse(value),
170                        }),
171                        Some(CostNumberData::Total { value }) => Some(CostNumber::Total {
172                            value: parse(value),
173                        }),
174                        Some(CostNumberData::Compound { per_unit, total }) => {
175                            Some(CostNumber::Compound {
176                                per_unit: parse(per_unit),
177                                total: parse(total),
178                            })
179                        }
180                        Some(CostNumberData::PerUnitFromTotal { per_unit, total }) => {
181                            // Struct literal, deliberately NOT
182                            // `BookedCost::try_new`: the weight arithmetic
183                            // reads only `.total` for this variant (see
184                            // `cost_number_weight`), so the
185                            // `per_unit x |units| == total` invariant is
186                            // irrelevant here — and enforcing it would need
187                            // an error channel this infallible closure
188                            // doesn't have. Consistency of wire-supplied
189                            // pairs is the ingress boundary's job
190                            // (ffi-wasi `input_entry_to_directive` rejects
191                            // inconsistent pairs via `try_new`); this DTO
192                            // arrives from the host's own booked data.
193                            Some(CostNumber::PerUnitFromTotal(BookedCost {
194                                per_unit: parse(per_unit),
195                                total: parse(total),
196                            }))
197                        }
198                        None => None,
199                    };
200                    let amount = match &number {
201                        Some(n) => rustledger_booking::cost_number_weight(units_num, n),
202                        // Empty `{}` — no determinable cost number; fall back
203                        // to the units magnitude (pre-existing behavior; the
204                        // spec is resolved by booking before plugins run).
205                        None => units_num,
206                    };
207                    Some((amount, currency))
208                } else if let Some(price) = &posting.price {
209                    let price_amount = price.amount.as_ref()?;
210                    let price_num = parse(&price_amount.number);
211                    let currency = price_amount.currency.clone();
212                    let kind = if price.is_total {
213                        PriceKind::Total
214                    } else {
215                        PriceKind::Unit
216                    };
217                    let amount = rustledger_booking::price_weight(units_num, price_num, kind);
218                    Some((amount, currency))
219                } else {
220                    Some((units_num, units.currency.clone()))
221                }
222            };
223
224            // Compute each group's weight inventory for neutralization.
225            let mut group_inv: BTreeMap<&String, BTreeMap<String, Decimal>> = BTreeMap::new();
226            for (group_key, posting_indices) in &curmap {
227                let inv = group_inv.entry(group_key).or_default();
228                for &idx in posting_indices {
229                    if let Some((amount, currency)) = weight_of(&txn.postings[idx]) {
230                        *inv.entry(currency).or_default() += amount;
231                    }
232                }
233                inv.retain(|_, amount| !amount.is_zero());
234            }
235
236            // Re-insert ALL original postings in their original order
237            // (including any with units == None, which are auto-balanced
238            // postings that must not be dropped).
239            //
240            // Python's plugin strips price annotations here
241            // (currency_accounts.py:145) because its pipeline runs
242            // plugins BEFORE booking. rustledger also runs plugins
243            // before booking (since PR #1116), but we still keep prices
244            // because the appended neutralizing postings already make
245            // each currency group balanced on its own — booking then
246            // fills any elided posting from the per-currency residual
247            // and the extra prices are redundant rather than harmful.
248            // See `rustledger_validate::Phase` docs and CLAUDE.md's
249            // "Python Compatibility Policy" section for the broader
250            // ordering rationale.
251            let mut new_postings: Vec<PostingData> =
252                Vec::with_capacity(txn.postings.len() + curmap.len());
253            for posting in &txn.postings {
254                new_postings.push(posting.clone());
255            }
256
257            // Append neutralizing postings (sorted by group key for
258            // deterministic output).
259            for (group_key, inv) in &group_inv {
260                // Python calls `inv.get_only_position()` and errors on
261                // multi-currency groups. We skip neutralization in that
262                // case rather than failing — it indicates a transaction
263                // shape the prototype plugin never handled.
264                if inv.len() != 1 {
265                    continue;
266                }
267
268                let (weight_currency, weight_amount) = inv.iter().next().unwrap();
269                let account_name = format!("{base_account}:{group_key}");
270                created_accounts.insert(account_name.clone());
271
272                new_postings.push(PostingData {
273                    account: account_name,
274                    units: Some(AmountData {
275                        number: (-*weight_amount).to_string(),
276                        currency: weight_currency.clone(),
277                    }),
278                    cost: None,
279                    price: None,
280                    flag: None,
281                    metadata: vec![],
282                    span: None,
283                });
284            }
285
286            let mut modified_txn = txn.clone();
287            modified_txn.postings = new_postings;
288
289            ops.push(PluginOp::Modify(
290                i,
291                DirectiveWrapper {
292                    directive_type: wrapper.directive_type.clone(),
293                    date: wrapper.date.clone(),
294                    filename: wrapper.filename.clone(),
295                    lineno: wrapper.lineno,
296                    data: DirectiveData::Transaction(modified_txn),
297                },
298            ));
299        }
300
301        // Insert Open directives for newly-created currency accounts (skip existing).
302        let mut new_open_accounts: Vec<String> = created_accounts
303            .into_iter()
304            .filter(|account| !existing_opens.contains(account))
305            .collect();
306        new_open_accounts.sort();
307        for account in new_open_accounts {
308            ops.push(PluginOp::Insert(DirectiveWrapper {
309                directive_type: "open".to_string(),
310                date: earliest_date.clone(),
311                filename: Some("<currency_accounts>".to_string()),
312                lineno: None,
313                data: DirectiveData::Open(OpenData {
314                    account,
315                    currencies: vec![],
316                    booking: None,
317                    metadata: vec![],
318                }),
319            }));
320        }
321
322        PluginOutput {
323            ops,
324            errors: Vec::new(),
325        }
326    }
327}
328
329impl RegularPlugin for CurrencyAccountsPlugin {}
330
331#[cfg(test)]
332mod currency_accounts_tests {
333    use super::super::utils::materialize_ops;
334    use super::*;
335    use crate::types::*;
336
337    fn txn_wrapper(date: &str, narration: &str, postings: Vec<PostingData>) -> DirectiveWrapper {
338        DirectiveWrapper {
339            directive_type: "transaction".to_string(),
340            date: date.to_string(),
341            filename: None,
342            lineno: None,
343            data: DirectiveData::Transaction(TransactionData {
344                flag: "*".to_string(),
345                payee: None,
346                narration: narration.to_string(),
347                tags: vec![],
348                links: vec![],
349                metadata: vec![],
350                postings,
351            }),
352        }
353    }
354
355    fn posting(account: &str, number: &str, currency: &str) -> PostingData {
356        PostingData {
357            account: account.to_string(),
358            units: Some(AmountData {
359                number: number.to_string(),
360                currency: currency.to_string(),
361            }),
362            cost: None,
363            price: None,
364            flag: None,
365            metadata: vec![],
366            span: None,
367        }
368    }
369
370    fn price_usd(number: &str) -> PriceAnnotationData {
371        PriceAnnotationData {
372            is_total: false,
373            amount: Some(AmountData {
374                number: number.to_string(),
375                currency: "USD".to_string(),
376            }),
377            number: None,
378            currency: None,
379        }
380    }
381
382    fn default_options() -> PluginOptions {
383        PluginOptions {
384            operating_currencies: vec!["USD".to_string()],
385            title: None,
386        }
387    }
388
389    /// Regression test for #776. The canonical reproducer: a currency
390    /// exchange with a price annotation on one side. Python groups by
391    /// units currency, yielding EUR and USD groups, and emits two
392    /// neutralizing postings and two Open directives.
393    #[test]
394    fn test_issue_776_currency_exchange_with_price() {
395        let plugin = CurrencyAccountsPlugin::with_base_account("Equity:Currency".to_string());
396
397        let mut p1 = posting("Assets:Bank:EUR", "-100", "EUR");
398        p1.price = Some(price_usd("1.10"));
399
400        let input = PluginInput {
401            directives: vec![txn_wrapper(
402                "2026-03-17",
403                "Currency exchange",
404                vec![p1, posting("Assets:Bank:USD", "110", "USD")],
405            )],
406            options: default_options(),
407            config: None,
408        };
409
410        let input_dirs = input.directives.clone();
411        let output = plugin.process(input);
412        assert_eq!(output.errors.len(), 0);
413        let directives = materialize_ops(&input_dirs, &output);
414
415        // 2 opens + 1 modified txn
416        assert_eq!(directives.len(), 3);
417
418        let mut opens: Vec<&str> = directives
419            .iter()
420            .filter_map(|d| {
421                if let DirectiveData::Open(o) = &d.data {
422                    Some(o.account.as_str())
423                } else {
424                    None
425                }
426            })
427            .collect();
428        opens.sort_unstable();
429        assert_eq!(opens, vec!["Equity:Currency:EUR", "Equity:Currency:USD"]);
430
431        let txn_dir = directives
432            .iter()
433            .find(|d| matches!(d.data, DirectiveData::Transaction(_)))
434            .expect("expected transaction");
435        let DirectiveData::Transaction(txn) = &txn_dir.data else {
436            unreachable!()
437        };
438        // 2 originals + 2 neutralizers
439        assert_eq!(txn.postings.len(), 4);
440        // Original postings keep their price annotations (rustledger
441        // runs booking before plugins, so stripping prices would cause
442        // E3001 in the validator).
443        assert!(txn.postings[0].price.is_some()); // EUR posting has price
444        assert!(txn.postings[1].price.is_none()); // USD posting never had price
445
446        // EUR group weight is -110 USD → neutralizer +110 USD on Equity:Currency:EUR.
447        // Note the counter-intuitive currency mismatch — this is what Python emits.
448        let eur_neut = txn
449            .postings
450            .iter()
451            .find(|p| p.account == "Equity:Currency:EUR")
452            .expect("missing EUR neutralizer");
453        // rust_decimal preserves precision of operands: -100 * 1.10 = -110.00,
454        // so the negated weight string is "110.00" (two trailing zeros from
455        // the 1.10 factor). Python prints the same Decimal as "110.00".
456        assert_eq!(eur_neut.units.as_ref().unwrap().number, "110.00");
457        assert_eq!(eur_neut.units.as_ref().unwrap().currency, "USD");
458
459        // USD group weight is +110 USD → neutralizer -110 USD on Equity:Currency:USD.
460        let usd_neut = txn
461            .postings
462            .iter()
463            .find(|p| p.account == "Equity:Currency:USD")
464            .expect("missing USD neutralizer");
465        assert_eq!(usd_neut.units.as_ref().unwrap().number, "-110");
466        assert_eq!(usd_neut.units.as_ref().unwrap().currency, "USD");
467    }
468
469    /// Cost-only transaction: grouping key is cost.currency, and the plugin
470    /// only neutralizes when `has_price` is true. Without a price annotation,
471    /// the transaction passes through unchanged (no currency accounts created).
472    #[test]
473    fn test_cost_only_no_price_skipped() {
474        let plugin = CurrencyAccountsPlugin::new();
475
476        let mut p1 = posting("Assets:Shares:RING", "9", "RING");
477        p1.cost = Some(CostData {
478            number: Some(rustledger_plugin_types::CostNumberData::PerUnit {
479                value: "68.55".to_string(),
480            }),
481            currency: Some("USD".to_string()),
482            date: None,
483            label: None,
484            merge: false,
485        });
486
487        let input = PluginInput {
488            directives: vec![txn_wrapper(
489                "2026-03-21",
490                "Buy RING",
491                vec![
492                    p1,
493                    posting("Expenses:Financial", "0.35", "USD"),
494                    posting("Assets:Cash:USD", "-617.30", "USD"),
495                ],
496            )],
497            options: default_options(),
498            config: None,
499        };
500
501        let input_dirs = input.directives.clone();
502        let output = plugin.process(input);
503        assert_eq!(output.errors.len(), 0);
504        let directives = materialize_ops(&input_dirs, &output);
505        assert_eq!(directives.len(), 1);
506        let DirectiveData::Transaction(txn) = &directives[0].data else {
507            panic!("expected transaction");
508        };
509        assert_eq!(txn.postings.len(), 3);
510    }
511
512    /// Single-currency transaction (no price, no cost): passed through.
513    #[test]
514    fn test_single_currency_unchanged() {
515        let plugin = CurrencyAccountsPlugin::new();
516        let input = PluginInput {
517            directives: vec![txn_wrapper(
518                "2024-01-15",
519                "Simple transfer",
520                vec![
521                    posting("Assets:Bank", "-100", "USD"),
522                    posting("Expenses:Food", "100", "USD"),
523                ],
524            )],
525            options: default_options(),
526            config: None,
527        };
528
529        let input_dirs = input.directives.clone();
530        let output = plugin.process(input);
531        let directives = materialize_ops(&input_dirs, &output);
532        assert_eq!(directives.len(), 1);
533        let DirectiveData::Transaction(txn) = &directives[0].data else {
534            panic!("expected transaction");
535        };
536        assert_eq!(txn.postings.len(), 2);
537    }
538
539    /// Custom base account via config string.
540    #[test]
541    fn test_custom_base_account() {
542        let plugin = CurrencyAccountsPlugin::new();
543
544        let mut p1 = posting("Assets:Bank:EUR", "-100", "EUR");
545        p1.price = Some(price_usd("1.10"));
546
547        let input = PluginInput {
548            directives: vec![txn_wrapper(
549                "2024-01-15",
550                "Exchange",
551                vec![p1, posting("Assets:Bank:USD", "110", "USD")],
552            )],
553            options: default_options(),
554            config: Some("Income:Trading".to_string()),
555        };
556
557        let input_dirs = input.directives.clone();
558        let output = plugin.process(input);
559        let directives = materialize_ops(&input_dirs, &output);
560        assert_eq!(directives.len(), 3);
561        assert!(directives.iter().any(|d| {
562            if let DirectiveData::Open(o) = &d.data {
563                o.account == "Income:Trading:EUR"
564            } else {
565                false
566            }
567        }));
568        assert!(directives.iter().any(|d| {
569            if let DirectiveData::Open(o) = &d.data {
570                o.account == "Income:Trading:USD"
571            } else {
572                false
573            }
574        }));
575    }
576
577    /// Pre-existing Open for a currency account should not be duplicated
578    /// by the plugin (would cause E1002 in the validator).
579    #[test]
580    fn test_skips_existing_open() {
581        let plugin = CurrencyAccountsPlugin::new();
582
583        let existing_open = DirectiveWrapper {
584            directive_type: "open".to_string(),
585            date: "2024-01-01".to_string(),
586            filename: None,
587            lineno: None,
588            data: DirectiveData::Open(OpenData {
589                account: "Equity:CurrencyAccounts:USD".to_string(),
590                currencies: vec![],
591                booking: None,
592                metadata: vec![],
593            }),
594        };
595
596        let mut p1 = posting("Assets:Bank:EUR", "-100", "EUR");
597        p1.price = Some(price_usd("1.10"));
598
599        let input = PluginInput {
600            directives: vec![
601                existing_open,
602                txn_wrapper(
603                    "2024-01-15",
604                    "Exchange",
605                    vec![p1, posting("Assets:Bank:USD", "110", "USD")],
606                ),
607            ],
608            options: default_options(),
609            config: None,
610        };
611
612        let input_dirs = input.directives.clone();
613        let output = plugin.process(input);
614        let directives = materialize_ops(&input_dirs, &output);
615
616        // Only Equity:CurrencyAccounts:EUR should be a newly-created open
617        // (filename marker <currency_accounts>). The USD open passed
618        // through from the input.
619        let new_currency_opens: Vec<&str> = directives
620            .iter()
621            .filter_map(|d| {
622                if let DirectiveData::Open(o) = &d.data
623                    && d.filename.as_deref() == Some("<currency_accounts>")
624                {
625                    Some(o.account.as_str())
626                } else {
627                    None
628                }
629            })
630            .collect();
631        assert_eq!(new_currency_opens, vec!["Equity:CurrencyAccounts:EUR"]);
632    }
633
634    /// Open directives for plugin-created accounts use the earliest date
635    /// observed in the input (matches Python `earliest_date = entries[0].date`
636    /// when entries are date-sorted upstream).
637    #[test]
638    fn test_open_uses_earliest_date() {
639        let plugin = CurrencyAccountsPlugin::new();
640
641        let mut p_later = posting("Assets:Bank:EUR", "-100", "EUR");
642        p_later.price = Some(price_usd("1.10"));
643
644        let input = PluginInput {
645            directives: vec![
646                DirectiveWrapper {
647                    directive_type: "open".to_string(),
648                    date: "2024-01-01".to_string(),
649                    filename: None,
650                    lineno: None,
651                    data: DirectiveData::Open(OpenData {
652                        account: "Assets:Bank:EUR".to_string(),
653                        currencies: vec![],
654                        booking: None,
655                        metadata: vec![],
656                    }),
657                },
658                txn_wrapper(
659                    "2026-03-17",
660                    "Exchange",
661                    vec![p_later, posting("Assets:Bank:USD", "110", "USD")],
662                ),
663            ],
664            options: default_options(),
665            config: None,
666        };
667
668        let input_dirs = input.directives.clone();
669        let output = plugin.process(input);
670        let directives = materialize_ops(&input_dirs, &output);
671        for wrapper in &directives {
672            if let DirectiveData::Open(o) = &wrapper.data
673                && o.account.starts_with("Equity:CurrencyAccounts:")
674                && wrapper.filename.as_deref() == Some("<currency_accounts>")
675            {
676                assert_eq!(
677                    wrapper.date, "2024-01-01",
678                    "plugin-created open should use earliest date"
679                );
680            }
681        }
682    }
683}