Skip to main content

rustledger_plugin/native/plugins/
split_expenses.rs

1//! Split expenses between multiple people.
2//!
3//! This plugin splits expense postings between multiple members.
4//! Any expense account that doesn't already contain a member's name
5//! will be split into multiple postings, one per member.
6//!
7//! Configuration: Space-separated list of member names, e.g., "Martin Caroline"
8//!
9//! Example:
10//! ```beancount
11//! plugin "beancount.plugins.split_expenses" "Martin Caroline"
12//!
13//! 2015-02-01 * "Aqua Viva Tulum"
14//!    Income:Caroline:CreditCard  -269.00 USD
15//!    Expenses:Accommodation
16//! ```
17//!
18//! Becomes:
19//! ```beancount
20//! 2015-02-01 * "Aqua Viva Tulum"
21//!   Income:Caroline:CreditCard       -269.00 USD
22//!   Expenses:Accommodation:Martin     134.50 USD
23//!   Expenses:Accommodation:Caroline   134.50 USD
24//! ```
25
26use rust_decimal::Decimal;
27use std::collections::HashSet;
28use std::str::FromStr;
29
30use crate::types::{
31    AmountData, DirectiveData, DirectiveWrapper, MetaValueData, OpenData, PluginInput, PluginOp,
32    PluginOutput, PostingData,
33};
34
35use super::super::{NativePlugin, RegularPlugin};
36
37/// Plugin for splitting expenses between multiple people.
38pub struct SplitExpensesPlugin;
39
40impl NativePlugin for SplitExpensesPlugin {
41    fn name(&self) -> &'static str {
42        "split_expenses"
43    }
44
45    fn description(&self) -> &'static str {
46        "Split expense postings between multiple members"
47    }
48
49    fn process(&self, input: PluginInput) -> PluginOutput {
50        // Parse configuration to get member names
51        let members: Vec<String> = match &input.config {
52            Some(config) => config.split_whitespace().map(String::from).collect(),
53            None => {
54                // No config provided, return unchanged
55                return PluginOutput {
56                    ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
57                    errors: Vec::new(),
58                };
59            }
60        };
61
62        if members.is_empty() {
63            return PluginOutput {
64                ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
65                errors: Vec::new(),
66            };
67        }
68
69        let num_members = Decimal::from(members.len());
70        let mut new_accounts: HashSet<String> = HashSet::new();
71        let mut earliest_date: Option<String> = None;
72        // Accounts already opened by the user; we must not synthesize
73        // a duplicate Open for any of these or Late validation will
74        // emit E1002 (AccountAlreadyOpen).
75        let mut existing_opens: HashSet<String> = HashSet::new();
76
77        // Compute earliest date AND record existing opens in one pass.
78        for d in &input.directives {
79            if earliest_date.as_ref().is_none_or(|e| d.date < *e) {
80                earliest_date = Some(d.date.clone());
81            }
82            if let DirectiveData::Open(open) = &d.data {
83                existing_opens.insert(open.account.clone());
84            }
85        }
86
87        let mut ops: Vec<PluginOp> = Vec::with_capacity(input.directives.len());
88
89        for (i, mut wrapper) in input.directives.into_iter().enumerate() {
90            if wrapper.directive_type != "transaction" {
91                ops.push(PluginOp::Keep(i));
92                continue;
93            }
94
95            let mut changed = false;
96            if let DirectiveData::Transaction(ref mut txn) = wrapper.data {
97                let mut new_postings = Vec::new();
98
99                for posting in &txn.postings {
100                    // Check if this is an expense account (root-component
101                    // classification via core; identical to the old
102                    // `starts_with("Expenses:")` for any parsable account,
103                    // which always has >= 2 components).
104                    let is_expense = rustledger_core::account_type(&posting.account) == "expenses";
105
106                    // Check if account already contains a member name
107                    let has_member = members.iter().any(|m| posting.account.contains(m.as_str()));
108
109                    if is_expense && !has_member {
110                        // Split this posting among members
111                        if let Some(ref units) = posting.units {
112                            // Parse the amount
113                            if let Ok(amount) = Decimal::from_str(&units.number) {
114                                let split_amount = amount / num_members;
115
116                                for member in &members {
117                                    // Create subaccount with member name
118                                    let subaccount = format!("{}:{}", posting.account, member);
119                                    new_accounts.insert(subaccount.clone());
120
121                                    // Create new posting for this member
122                                    let mut new_metadata = posting.metadata.clone();
123                                    // Mark as automatically calculated
124                                    new_metadata.push((
125                                        "__automatic__".to_string(),
126                                        MetaValueData::String("True".to_string()),
127                                    ));
128
129                                    new_postings.push(PostingData {
130                                        account: subaccount,
131                                        units: Some(AmountData {
132                                            number: split_amount.to_string(),
133                                            currency: units.currency.clone(),
134                                        }),
135                                        cost: posting.cost.clone(),
136                                        price: posting.price.clone(),
137                                        flag: posting.flag.clone(),
138                                        metadata: new_metadata,
139                                        span: None,
140                                    });
141                                }
142                                changed = true;
143                            } else {
144                                // Couldn't parse amount, keep original
145                                new_postings.push(posting.clone());
146                            }
147                        } else {
148                            // No units, keep original
149                            new_postings.push(posting.clone());
150                        }
151                    } else {
152                        // Keep posting as is
153                        new_postings.push(posting.clone());
154                    }
155                }
156
157                if changed {
158                    txn.postings = new_postings;
159                }
160            }
161
162            if changed {
163                ops.push(PluginOp::Modify(i, wrapper));
164            } else {
165                ops.push(PluginOp::Keep(i));
166            }
167        }
168
169        // Insert Open directives for newly synthesized member sub-accounts
170        // that the user hasn't already opened.
171        if let Some(date) = earliest_date {
172            let mut accounts: Vec<String> = new_accounts
173                .into_iter()
174                .filter(|a| !existing_opens.contains(a))
175                .collect();
176            accounts.sort();
177            for account in accounts {
178                ops.push(PluginOp::Insert(DirectiveWrapper {
179                    directive_type: "open".to_string(),
180                    date: date.clone(),
181                    filename: Some("<split_expenses>".to_string()),
182                    lineno: Some(0),
183                    data: DirectiveData::Open(OpenData {
184                        account,
185                        currencies: vec![],
186                        booking: None,
187                        metadata: vec![],
188                    }),
189                }));
190            }
191        }
192
193        PluginOutput {
194            ops,
195            errors: Vec::new(),
196        }
197    }
198}
199
200impl RegularPlugin for SplitExpensesPlugin {}
201
202#[cfg(test)]
203mod tests {
204    use super::super::utils::materialize_ops;
205    use super::*;
206    use crate::types::*;
207
208    fn create_test_transaction(postings: Vec<PostingData>) -> DirectiveWrapper {
209        DirectiveWrapper {
210            directive_type: "transaction".to_string(),
211            date: "2024-01-15".to_string(),
212            filename: None,
213            lineno: None,
214            data: DirectiveData::Transaction(TransactionData {
215                flag: "*".to_string(),
216                payee: Some("Test".to_string()),
217                narration: "Test transaction".to_string(),
218                tags: vec![],
219                links: vec![],
220                metadata: vec![],
221                postings,
222            }),
223        }
224    }
225
226    #[test]
227    fn test_split_expenses_basic() {
228        let plugin = SplitExpensesPlugin;
229
230        let input = PluginInput {
231            directives: vec![create_test_transaction(vec![
232                PostingData {
233                    account: "Income:Caroline:CreditCard".to_string(),
234                    units: Some(AmountData {
235                        number: "-269.00".to_string(),
236                        currency: "USD".to_string(),
237                    }),
238                    cost: None,
239                    price: None,
240                    flag: None,
241                    metadata: vec![],
242                    span: None,
243                },
244                PostingData {
245                    account: "Expenses:Accommodation".to_string(),
246                    units: Some(AmountData {
247                        number: "269.00".to_string(),
248                        currency: "USD".to_string(),
249                    }),
250                    cost: None,
251                    price: None,
252                    flag: None,
253                    metadata: vec![],
254                    span: None,
255                },
256            ])],
257            options: PluginOptions {
258                operating_currencies: vec!["USD".to_string()],
259                title: None,
260            },
261            config: Some("Martin Caroline".to_string()),
262        };
263
264        let input_dirs = input.directives.clone();
265        let output = plugin.process(input);
266        assert_eq!(output.errors.len(), 0);
267        let directives = materialize_ops(&input_dirs, &output);
268
269        // Should have 2 open directives + 1 transaction
270        assert_eq!(directives.len(), 3);
271
272        // Find the transaction
273        let txn = directives
274            .iter()
275            .find(|d| matches!(d.data, DirectiveData::Transaction(_)))
276            .unwrap();
277
278        if let DirectiveData::Transaction(txn_data) = &txn.data {
279            // Should have 3 postings: 1 income (unchanged) + 2 expenses (split)
280            assert_eq!(txn_data.postings.len(), 3);
281
282            // Check the split postings
283            let expense_postings: Vec<_> = txn_data
284                .postings
285                .iter()
286                .filter(|p| p.account.starts_with("Expenses:"))
287                .collect();
288
289            assert_eq!(expense_postings.len(), 2);
290            assert!(
291                expense_postings
292                    .iter()
293                    .any(|p| p.account == "Expenses:Accommodation:Martin")
294            );
295            assert!(
296                expense_postings
297                    .iter()
298                    .any(|p| p.account == "Expenses:Accommodation:Caroline")
299            );
300
301            // Each should have half the amount (134.50)
302            for p in expense_postings {
303                if let Some(units) = &p.units {
304                    assert_eq!(units.number, "134.50");
305                }
306            }
307        } else {
308            panic!("Expected transaction");
309        }
310    }
311
312    #[test]
313    fn test_split_expenses_preserves_member_accounts() {
314        let plugin = SplitExpensesPlugin;
315
316        let input = PluginInput {
317            directives: vec![create_test_transaction(vec![
318                PostingData {
319                    account: "Income:Martin:Cash".to_string(),
320                    units: Some(AmountData {
321                        number: "-100.00".to_string(),
322                        currency: "USD".to_string(),
323                    }),
324                    cost: None,
325                    price: None,
326                    flag: None,
327                    metadata: vec![],
328                    span: None,
329                },
330                PostingData {
331                    account: "Expenses:Food:Martin".to_string(),
332                    units: Some(AmountData {
333                        number: "100.00".to_string(),
334                        currency: "USD".to_string(),
335                    }),
336                    cost: None,
337                    price: None,
338                    flag: None,
339                    metadata: vec![],
340                    span: None,
341                },
342            ])],
343            options: PluginOptions {
344                operating_currencies: vec!["USD".to_string()],
345                title: None,
346            },
347            config: Some("Martin Caroline".to_string()),
348        };
349
350        let input_dirs = input.directives.clone();
351        let output = plugin.process(input);
352        let directives = materialize_ops(&input_dirs, &output);
353
354        // Should have only 1 directive (no new open directives since account already has member)
355        assert_eq!(directives.len(), 1);
356
357        if let DirectiveData::Transaction(txn_data) = &directives[0].data {
358            // Postings should be unchanged
359            assert_eq!(txn_data.postings.len(), 2);
360            assert!(
361                txn_data
362                    .postings
363                    .iter()
364                    .any(|p| p.account == "Expenses:Food:Martin")
365            );
366        } else {
367            panic!("Expected transaction");
368        }
369    }
370
371    #[test]
372    fn test_split_expenses_no_config() {
373        let plugin = SplitExpensesPlugin;
374
375        let input = PluginInput {
376            directives: vec![create_test_transaction(vec![PostingData {
377                account: "Expenses:Food".to_string(),
378                units: Some(AmountData {
379                    number: "100.00".to_string(),
380                    currency: "USD".to_string(),
381                }),
382                cost: None,
383                price: None,
384                flag: None,
385                metadata: vec![],
386                span: None,
387            }])],
388            options: PluginOptions {
389                operating_currencies: vec!["USD".to_string()],
390                title: None,
391            },
392            config: None,
393        };
394
395        let input_dirs = input.directives.clone();
396        let output = plugin.process(input);
397        let directives = materialize_ops(&input_dirs, &output);
398
399        // Should return unchanged
400        assert_eq!(directives.len(), 1);
401        if let DirectiveData::Transaction(txn_data) = &directives[0].data {
402            assert_eq!(txn_data.postings.len(), 1);
403            assert_eq!(txn_data.postings[0].account, "Expenses:Food");
404        } else {
405            panic!("Expected transaction");
406        }
407    }
408}