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