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/// For multi-currency transactions, this plugin adds neutralizing postings
10/// to equity accounts like `Equity:CurrencyAccounts:USD` to track currency
11/// conversion gains/losses. This enables proper reporting of currency
12/// trading activity.
13pub struct CurrencyAccountsPlugin {
14    /// Base account for currency tracking (default: "Equity:CurrencyAccounts").
15    base_account: String,
16}
17
18impl CurrencyAccountsPlugin {
19    /// Create with default base account.
20    pub fn new() -> Self {
21        Self {
22            base_account: "Equity:CurrencyAccounts".to_string(),
23        }
24    }
25
26    /// Create with custom base account.
27    pub const fn with_base_account(base_account: String) -> Self {
28        Self { base_account }
29    }
30}
31
32impl Default for CurrencyAccountsPlugin {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl NativePlugin for CurrencyAccountsPlugin {
39    fn name(&self) -> &'static str {
40        "currency_accounts"
41    }
42
43    fn description(&self) -> &'static str {
44        "Auto-generate currency trading postings"
45    }
46
47    fn process(&self, input: PluginInput) -> PluginOutput {
48        use crate::types::{AmountData, PostingData};
49        use rust_decimal::Decimal;
50        use std::collections::HashMap;
51        use std::str::FromStr;
52
53        // Get base account from config if provided
54        let base_account = input
55            .config
56            .as_ref()
57            .map_or_else(|| self.base_account.clone(), |c| c.trim().to_string());
58
59        let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
60
61        for wrapper in &input.directives {
62            if let DirectiveData::Transaction(txn) = &wrapper.data {
63                // Calculate currency totals for this transaction
64                // Map from currency -> total amount in that currency
65                let mut currency_totals: HashMap<String, Decimal> = HashMap::new();
66
67                for posting in &txn.postings {
68                    if let Some(units) = &posting.units {
69                        let amount = Decimal::from_str(&units.number).unwrap_or_default();
70                        *currency_totals.entry(units.currency.clone()).or_default() += amount;
71                    }
72                }
73
74                // If we have multiple currencies with non-zero totals, add balancing postings
75                let non_zero_currencies: Vec<_> = currency_totals
76                    .iter()
77                    .filter(|&(_, total)| *total != Decimal::ZERO)
78                    .collect();
79
80                if non_zero_currencies.len() > 1 {
81                    // Clone the transaction and add currency account postings
82                    let mut modified_txn = txn.clone();
83
84                    for &(currency, total) in &non_zero_currencies {
85                        // Add posting to currency account to neutralize
86                        modified_txn.postings.push(PostingData {
87                            account: format!("{base_account}:{currency}"),
88                            units: Some(AmountData {
89                                number: (-*total).to_string(),
90                                currency: (*currency).clone(),
91                            }),
92                            cost: None,
93                            price: None,
94                            flag: None,
95                            metadata: vec![],
96                        });
97                    }
98
99                    new_directives.push(DirectiveWrapper {
100                        directive_type: wrapper.directive_type.clone(),
101                        date: wrapper.date.clone(),
102                        filename: wrapper.filename.clone(), // Preserve original location
103                        lineno: wrapper.lineno,
104                        data: DirectiveData::Transaction(modified_txn),
105                    });
106                } else {
107                    // Single currency or balanced - pass through
108                    new_directives.push(wrapper.clone());
109                }
110            } else {
111                new_directives.push(wrapper.clone());
112            }
113        }
114
115        PluginOutput {
116            directives: new_directives,
117            errors: Vec::new(),
118        }
119    }
120}
121
122#[cfg(test)]
123mod currency_accounts_tests {
124    use super::*;
125    use crate::types::*;
126
127    #[test]
128    fn test_currency_accounts_adds_balancing_postings() {
129        let plugin = CurrencyAccountsPlugin::new();
130
131        let input = PluginInput {
132            directives: vec![DirectiveWrapper {
133                directive_type: "transaction".to_string(),
134                date: "2024-01-15".to_string(),
135                filename: None,
136                lineno: None,
137                data: DirectiveData::Transaction(TransactionData {
138                    flag: "*".to_string(),
139                    payee: None,
140                    narration: "Currency exchange".to_string(),
141                    tags: vec![],
142                    links: vec![],
143                    metadata: vec![],
144                    postings: vec![
145                        PostingData {
146                            account: "Assets:Bank:USD".to_string(),
147                            units: Some(AmountData {
148                                number: "-100".to_string(),
149                                currency: "USD".to_string(),
150                            }),
151                            cost: None,
152                            price: None,
153                            flag: None,
154                            metadata: vec![],
155                        },
156                        PostingData {
157                            account: "Assets:Bank:EUR".to_string(),
158                            units: Some(AmountData {
159                                number: "85".to_string(),
160                                currency: "EUR".to_string(),
161                            }),
162                            cost: None,
163                            price: None,
164                            flag: None,
165                            metadata: vec![],
166                        },
167                    ],
168                }),
169            }],
170            options: PluginOptions {
171                operating_currencies: vec!["USD".to_string()],
172                title: None,
173            },
174            config: None,
175        };
176
177        let output = plugin.process(input);
178        assert_eq!(output.errors.len(), 0);
179        assert_eq!(output.directives.len(), 1);
180
181        if let DirectiveData::Transaction(txn) = &output.directives[0].data {
182            // Should have original 2 postings + 2 currency account postings
183            assert_eq!(txn.postings.len(), 4);
184
185            // Check for currency account postings
186            let usd_posting = txn
187                .postings
188                .iter()
189                .find(|p| p.account == "Equity:CurrencyAccounts:USD");
190            assert!(usd_posting.is_some());
191            let usd_posting = usd_posting.unwrap();
192            // Should neutralize the -100 USD
193            assert_eq!(usd_posting.units.as_ref().unwrap().number, "100");
194
195            let eur_posting = txn
196                .postings
197                .iter()
198                .find(|p| p.account == "Equity:CurrencyAccounts:EUR");
199            assert!(eur_posting.is_some());
200            let eur_posting = eur_posting.unwrap();
201            // Should neutralize the 85 EUR
202            assert_eq!(eur_posting.units.as_ref().unwrap().number, "-85");
203        } else {
204            panic!("Expected Transaction directive");
205        }
206    }
207
208    #[test]
209    fn test_currency_accounts_single_currency_unchanged() {
210        let plugin = CurrencyAccountsPlugin::new();
211
212        let input = PluginInput {
213            directives: vec![DirectiveWrapper {
214                directive_type: "transaction".to_string(),
215                date: "2024-01-15".to_string(),
216                filename: None,
217                lineno: None,
218                data: DirectiveData::Transaction(TransactionData {
219                    flag: "*".to_string(),
220                    payee: None,
221                    narration: "Simple transfer".to_string(),
222                    tags: vec![],
223                    links: vec![],
224                    metadata: vec![],
225                    postings: vec![
226                        PostingData {
227                            account: "Assets:Bank".to_string(),
228                            units: Some(AmountData {
229                                number: "-100".to_string(),
230                                currency: "USD".to_string(),
231                            }),
232                            cost: None,
233                            price: None,
234                            flag: None,
235                            metadata: vec![],
236                        },
237                        PostingData {
238                            account: "Expenses:Food".to_string(),
239                            units: Some(AmountData {
240                                number: "100".to_string(),
241                                currency: "USD".to_string(),
242                            }),
243                            cost: None,
244                            price: None,
245                            flag: None,
246                            metadata: vec![],
247                        },
248                    ],
249                }),
250            }],
251            options: PluginOptions {
252                operating_currencies: vec!["USD".to_string()],
253                title: None,
254            },
255            config: None,
256        };
257
258        let output = plugin.process(input);
259        assert_eq!(output.errors.len(), 0);
260
261        // Single currency balanced - should not add any postings
262        if let DirectiveData::Transaction(txn) = &output.directives[0].data {
263            assert_eq!(txn.postings.len(), 2);
264        }
265    }
266
267    #[test]
268    fn test_currency_accounts_custom_base_account() {
269        let plugin = CurrencyAccountsPlugin::new();
270
271        let input = PluginInput {
272            directives: vec![DirectiveWrapper {
273                directive_type: "transaction".to_string(),
274                date: "2024-01-15".to_string(),
275                filename: None,
276                lineno: None,
277                data: DirectiveData::Transaction(TransactionData {
278                    flag: "*".to_string(),
279                    payee: None,
280                    narration: "Exchange".to_string(),
281                    tags: vec![],
282                    links: vec![],
283                    metadata: vec![],
284                    postings: vec![
285                        PostingData {
286                            account: "Assets:USD".to_string(),
287                            units: Some(AmountData {
288                                number: "-50".to_string(),
289                                currency: "USD".to_string(),
290                            }),
291                            cost: None,
292                            price: None,
293                            flag: None,
294                            metadata: vec![],
295                        },
296                        PostingData {
297                            account: "Assets:EUR".to_string(),
298                            units: Some(AmountData {
299                                number: "42".to_string(),
300                                currency: "EUR".to_string(),
301                            }),
302                            cost: None,
303                            price: None,
304                            flag: None,
305                            metadata: vec![],
306                        },
307                    ],
308                }),
309            }],
310            options: PluginOptions {
311                operating_currencies: vec!["USD".to_string()],
312                title: None,
313            },
314            config: Some("Income:Trading".to_string()),
315        };
316
317        let output = plugin.process(input);
318        if let DirectiveData::Transaction(txn) = &output.directives[0].data {
319            // Check for custom base account
320            assert!(
321                txn.postings
322                    .iter()
323                    .any(|p| p.account.starts_with("Income:Trading:"))
324            );
325        }
326    }
327}