Skip to main content

rustledger_plugin/native/plugins/
currency_accounts.rs

1//! Auto-generate currency trading account postings.
2
3use crate::types::{DirectiveData, DirectiveWrapper, PluginInput, PluginOutput};
4
5use super::super::NativePlugin;
6
7/// Plugin that auto-generates currency trading account postings.
8///
9/// 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 new_directives: Vec<DirectiveWrapper> = Vec::with_capacity(input.directives.len());
98        let mut created_accounts: HashSet<String> = HashSet::new();
99
100        for wrapper in &input.directives {
101            let DirectiveData::Transaction(txn) = &wrapper.data else {
102                new_directives.push(wrapper.clone());
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                new_directives.push(wrapper.clone());
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. In rustledger, booking runs
212            // first and the validator re-checks afterwards, so we
213            // must keep prices to preserve the weight-based balance.
214            let mut new_postings: Vec<PostingData> =
215                Vec::with_capacity(txn.postings.len() + curmap.len());
216            for posting in &txn.postings {
217                new_postings.push(posting.clone());
218            }
219
220            // Append neutralizing postings (sorted by group key for
221            // deterministic output).
222            for (group_key, inv) in &group_inv {
223                // Python calls `inv.get_only_position()` and errors on
224                // multi-currency groups. We skip neutralization in that
225                // case rather than failing — it indicates a transaction
226                // shape the prototype plugin never handled.
227                if inv.len() != 1 {
228                    continue;
229                }
230
231                let (weight_currency, weight_amount) = inv.iter().next().unwrap();
232                let account_name = format!("{base_account}:{group_key}");
233                created_accounts.insert(account_name.clone());
234
235                new_postings.push(PostingData {
236                    account: account_name,
237                    units: Some(AmountData {
238                        number: (-*weight_amount).to_string(),
239                        currency: weight_currency.clone(),
240                    }),
241                    cost: None,
242                    price: None,
243                    flag: None,
244                    metadata: vec![],
245                });
246            }
247
248            let mut modified_txn = txn.clone();
249            modified_txn.postings = new_postings;
250
251            new_directives.push(DirectiveWrapper {
252                directive_type: wrapper.directive_type.clone(),
253                date: wrapper.date.clone(),
254                filename: wrapper.filename.clone(),
255                lineno: wrapper.lineno,
256                data: DirectiveData::Transaction(modified_txn),
257            });
258        }
259
260        // Generate Open directives for created currency accounts (skip existing).
261        let mut open_directives: Vec<DirectiveWrapper> = created_accounts
262            .into_iter()
263            .filter(|account| !existing_opens.contains(account))
264            .map(|account| DirectiveWrapper {
265                directive_type: "open".to_string(),
266                date: earliest_date.clone(),
267                filename: Some("<currency_accounts>".to_string()),
268                lineno: None,
269                data: DirectiveData::Open(OpenData {
270                    account,
271                    currencies: vec![],
272                    booking: None,
273                    metadata: vec![],
274                }),
275            })
276            .collect();
277
278        // Sort for deterministic output.
279        open_directives.sort_by(|a, b| {
280            if let (DirectiveData::Open(oa), DirectiveData::Open(ob)) = (&a.data, &b.data) {
281                oa.account.cmp(&ob.account)
282            } else {
283                std::cmp::Ordering::Equal
284            }
285        });
286
287        // Prepend Open directives to the output (matches Python which does
288        // `open_entries + new_entries`).
289        open_directives.extend(new_directives);
290
291        PluginOutput {
292            directives: open_directives,
293            errors: Vec::new(),
294        }
295    }
296}
297
298#[cfg(test)]
299mod currency_accounts_tests {
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 output = plugin.process(input);
376        assert_eq!(output.errors.len(), 0);
377
378        // 2 opens + 1 modified txn
379        assert_eq!(output.directives.len(), 3);
380
381        let opens: Vec<&str> = output
382            .directives
383            .iter()
384            .filter_map(|d| {
385                if let DirectiveData::Open(o) = &d.data {
386                    Some(o.account.as_str())
387                } else {
388                    None
389                }
390            })
391            .collect();
392        assert_eq!(opens, vec!["Equity:Currency:EUR", "Equity:Currency:USD"]);
393
394        let DirectiveData::Transaction(txn) = &output.directives[2].data else {
395            panic!("expected transaction at index 2");
396        };
397        // 2 originals + 2 neutralizers
398        assert_eq!(txn.postings.len(), 4);
399        // Original postings keep their price annotations (rustledger
400        // runs booking before plugins, so stripping prices would cause
401        // E3001 in the validator).
402        assert!(txn.postings[0].price.is_some()); // EUR posting has price
403        assert!(txn.postings[1].price.is_none()); // USD posting never had price
404
405        // EUR group weight is -110 USD → neutralizer +110 USD on Equity:Currency:EUR.
406        // Note the counter-intuitive currency mismatch — this is what Python emits.
407        let eur_neut = txn
408            .postings
409            .iter()
410            .find(|p| p.account == "Equity:Currency:EUR")
411            .expect("missing EUR neutralizer");
412        // rust_decimal preserves precision of operands: -100 * 1.10 = -110.00,
413        // so the negated weight string is "110.00" (two trailing zeros from
414        // the 1.10 factor). Python prints the same Decimal as "110.00".
415        assert_eq!(eur_neut.units.as_ref().unwrap().number, "110.00");
416        assert_eq!(eur_neut.units.as_ref().unwrap().currency, "USD");
417
418        // USD group weight is +110 USD → neutralizer -110 USD on Equity:Currency:USD.
419        let usd_neut = txn
420            .postings
421            .iter()
422            .find(|p| p.account == "Equity:Currency:USD")
423            .expect("missing USD neutralizer");
424        assert_eq!(usd_neut.units.as_ref().unwrap().number, "-110");
425        assert_eq!(usd_neut.units.as_ref().unwrap().currency, "USD");
426    }
427
428    /// Cost-only transaction: grouping key is cost.currency, and the plugin
429    /// only neutralizes when `has_price` is true. Without a price annotation,
430    /// the transaction passes through unchanged (no currency accounts created).
431    #[test]
432    fn test_cost_only_no_price_skipped() {
433        let plugin = CurrencyAccountsPlugin::new();
434
435        let mut p1 = posting("Assets:Shares:RING", "9", "RING");
436        p1.cost = Some(CostData {
437            number_per: Some("68.55".to_string()),
438            number_total: None,
439            currency: Some("USD".to_string()),
440            date: None,
441            label: None,
442            merge: false,
443        });
444
445        let input = PluginInput {
446            directives: vec![txn_wrapper(
447                "2026-03-21",
448                "Buy RING",
449                vec![
450                    p1,
451                    posting("Expenses:Financial", "0.35", "USD"),
452                    posting("Assets:Cash:USD", "-617.30", "USD"),
453                ],
454            )],
455            options: default_options(),
456            config: None,
457        };
458
459        let output = plugin.process(input);
460        assert_eq!(output.errors.len(), 0);
461        assert_eq!(output.directives.len(), 1);
462        let DirectiveData::Transaction(txn) = &output.directives[0].data else {
463            panic!("expected transaction");
464        };
465        assert_eq!(txn.postings.len(), 3);
466    }
467
468    /// Single-currency transaction (no price, no cost): passed through.
469    #[test]
470    fn test_single_currency_unchanged() {
471        let plugin = CurrencyAccountsPlugin::new();
472        let input = PluginInput {
473            directives: vec![txn_wrapper(
474                "2024-01-15",
475                "Simple transfer",
476                vec![
477                    posting("Assets:Bank", "-100", "USD"),
478                    posting("Expenses:Food", "100", "USD"),
479                ],
480            )],
481            options: default_options(),
482            config: None,
483        };
484
485        let output = plugin.process(input);
486        assert_eq!(output.directives.len(), 1);
487        let DirectiveData::Transaction(txn) = &output.directives[0].data else {
488            panic!("expected transaction");
489        };
490        assert_eq!(txn.postings.len(), 2);
491    }
492
493    /// Custom base account via config string.
494    #[test]
495    fn test_custom_base_account() {
496        let plugin = CurrencyAccountsPlugin::new();
497
498        let mut p1 = posting("Assets:Bank:EUR", "-100", "EUR");
499        p1.price = Some(price_usd("1.10"));
500
501        let input = PluginInput {
502            directives: vec![txn_wrapper(
503                "2024-01-15",
504                "Exchange",
505                vec![p1, posting("Assets:Bank:USD", "110", "USD")],
506            )],
507            options: default_options(),
508            config: Some("Income:Trading".to_string()),
509        };
510
511        let output = plugin.process(input);
512        assert_eq!(output.directives.len(), 3);
513        assert!(output.directives.iter().any(|d| {
514            if let DirectiveData::Open(o) = &d.data {
515                o.account == "Income:Trading:EUR"
516            } else {
517                false
518            }
519        }));
520        assert!(output.directives.iter().any(|d| {
521            if let DirectiveData::Open(o) = &d.data {
522                o.account == "Income:Trading:USD"
523            } else {
524                false
525            }
526        }));
527    }
528
529    /// Pre-existing Open for a currency account should not be duplicated
530    /// by the plugin (would cause E1002 in the validator).
531    #[test]
532    fn test_skips_existing_open() {
533        let plugin = CurrencyAccountsPlugin::new();
534
535        let existing_open = DirectiveWrapper {
536            directive_type: "open".to_string(),
537            date: "2024-01-01".to_string(),
538            filename: None,
539            lineno: None,
540            data: DirectiveData::Open(OpenData {
541                account: "Equity:CurrencyAccounts:USD".to_string(),
542                currencies: vec![],
543                booking: None,
544                metadata: vec![],
545            }),
546        };
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![
553                existing_open,
554                txn_wrapper(
555                    "2024-01-15",
556                    "Exchange",
557                    vec![p1, posting("Assets:Bank:USD", "110", "USD")],
558                ),
559            ],
560            options: default_options(),
561            config: None,
562        };
563
564        let output = plugin.process(input);
565
566        // Only Equity:CurrencyAccounts:EUR should be a newly-created open
567        // (filename marker <currency_accounts>). The USD open passed
568        // through from the input.
569        let new_currency_opens: Vec<&str> = output
570            .directives
571            .iter()
572            .filter_map(|d| {
573                if let DirectiveData::Open(o) = &d.data
574                    && d.filename.as_deref() == Some("<currency_accounts>")
575                {
576                    Some(o.account.as_str())
577                } else {
578                    None
579                }
580            })
581            .collect();
582        assert_eq!(new_currency_opens, vec!["Equity:CurrencyAccounts:EUR"]);
583    }
584
585    /// Open directives for plugin-created accounts use the earliest date
586    /// observed in the input (matches Python `earliest_date = entries[0].date`
587    /// when entries are date-sorted upstream).
588    #[test]
589    fn test_open_uses_earliest_date() {
590        let plugin = CurrencyAccountsPlugin::new();
591
592        let mut p_later = posting("Assets:Bank:EUR", "-100", "EUR");
593        p_later.price = Some(price_usd("1.10"));
594
595        let input = PluginInput {
596            directives: vec![
597                DirectiveWrapper {
598                    directive_type: "open".to_string(),
599                    date: "2024-01-01".to_string(),
600                    filename: None,
601                    lineno: None,
602                    data: DirectiveData::Open(OpenData {
603                        account: "Assets:Bank:EUR".to_string(),
604                        currencies: vec![],
605                        booking: None,
606                        metadata: vec![],
607                    }),
608                },
609                txn_wrapper(
610                    "2026-03-17",
611                    "Exchange",
612                    vec![p_later, posting("Assets:Bank:USD", "110", "USD")],
613                ),
614            ],
615            options: default_options(),
616            config: None,
617        };
618
619        let output = plugin.process(input);
620        for wrapper in &output.directives {
621            if let DirectiveData::Open(o) = &wrapper.data
622                && o.account.starts_with("Equity:CurrencyAccounts:")
623                && wrapper.filename.as_deref() == Some("<currency_accounts>")
624            {
625                assert_eq!(
626                    wrapper.date, "2024-01-01",
627                    "plugin-created open should use earliest date"
628                );
629            }
630        }
631    }
632}