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,
32    PluginOutput, PostingData,
33};
34
35use super::super::NativePlugin;
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                    directives: input.directives,
57                    errors: Vec::new(),
58                };
59            }
60        };
61
62        if members.is_empty() {
63            return PluginOutput {
64                directives: input.directives,
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
73        // Process directives
74        let directives: Vec<_> = input
75            .directives
76            .into_iter()
77            .map(|mut wrapper| {
78                // Track earliest date for creating Open directives
79                if earliest_date.is_none()
80                    || wrapper.date < *earliest_date.as_ref().unwrap_or(&String::new())
81                {
82                    earliest_date = Some(wrapper.date.clone());
83                }
84
85                if wrapper.directive_type == "transaction"
86                    && let DirectiveData::Transaction(ref mut txn) = wrapper.data
87                {
88                    let mut new_postings = Vec::new();
89
90                    for posting in &txn.postings {
91                        // Check if this is an expense account
92                        let is_expense = posting.account.starts_with("Expenses:");
93
94                        // Check if account already contains a member name
95                        let has_member =
96                            members.iter().any(|m| posting.account.contains(m.as_str()));
97
98                        if is_expense && !has_member {
99                            // Split this posting among members
100                            if let Some(ref units) = posting.units {
101                                // Parse the amount
102                                if let Ok(amount) = Decimal::from_str(&units.number) {
103                                    let split_amount = amount / num_members;
104
105                                    for member in &members {
106                                        // Create subaccount with member name
107                                        let subaccount = format!("{}:{}", posting.account, member);
108                                        new_accounts.insert(subaccount.clone());
109
110                                        // Create new posting for this member
111                                        let mut new_metadata = posting.metadata.clone();
112                                        // Mark as automatically calculated
113                                        new_metadata.push((
114                                            "__automatic__".to_string(),
115                                            MetaValueData::String("True".to_string()),
116                                        ));
117
118                                        new_postings.push(PostingData {
119                                            account: subaccount,
120                                            units: Some(AmountData {
121                                                number: split_amount.to_string(),
122                                                currency: units.currency.clone(),
123                                            }),
124                                            cost: posting.cost.clone(),
125                                            price: posting.price.clone(),
126                                            flag: posting.flag.clone(),
127                                            metadata: new_metadata,
128                                        });
129                                    }
130                                } else {
131                                    // Couldn't parse amount, keep original
132                                    new_postings.push(posting.clone());
133                                }
134                            } else {
135                                // No units, keep original
136                                new_postings.push(posting.clone());
137                            }
138                        } else {
139                            // Keep posting as is
140                            new_postings.push(posting.clone());
141                        }
142                    }
143
144                    txn.postings = new_postings;
145                }
146                wrapper
147            })
148            .collect();
149
150        // Create Open directives for new accounts
151        let mut open_directives: Vec<DirectiveWrapper> = Vec::new();
152        if let Some(date) = earliest_date {
153            for account in &new_accounts {
154                open_directives.push(DirectiveWrapper {
155                    directive_type: "open".to_string(),
156                    date: date.clone(),
157                    filename: Some("<split_expenses>".to_string()),
158                    lineno: Some(0),
159                    data: DirectiveData::Open(OpenData {
160                        account: account.clone(),
161                        currencies: vec![],
162                        booking: None,
163                        metadata: vec![],
164                    }),
165                });
166            }
167        }
168
169        // Combine open directives with original directives
170        let mut all_directives = open_directives;
171        all_directives.extend(directives);
172
173        PluginOutput {
174            directives: all_directives,
175            errors: Vec::new(),
176        }
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::types::*;
184
185    fn create_test_transaction(postings: Vec<PostingData>) -> DirectiveWrapper {
186        DirectiveWrapper {
187            directive_type: "transaction".to_string(),
188            date: "2024-01-15".to_string(),
189            filename: None,
190            lineno: None,
191            data: DirectiveData::Transaction(TransactionData {
192                flag: "*".to_string(),
193                payee: Some("Test".to_string()),
194                narration: "Test transaction".to_string(),
195                tags: vec![],
196                links: vec![],
197                metadata: vec![],
198                postings,
199            }),
200        }
201    }
202
203    #[test]
204    fn test_split_expenses_basic() {
205        let plugin = SplitExpensesPlugin;
206
207        let input = PluginInput {
208            directives: vec![create_test_transaction(vec![
209                PostingData {
210                    account: "Income:Caroline:CreditCard".to_string(),
211                    units: Some(AmountData {
212                        number: "-269.00".to_string(),
213                        currency: "USD".to_string(),
214                    }),
215                    cost: None,
216                    price: None,
217                    flag: None,
218                    metadata: vec![],
219                },
220                PostingData {
221                    account: "Expenses:Accommodation".to_string(),
222                    units: Some(AmountData {
223                        number: "269.00".to_string(),
224                        currency: "USD".to_string(),
225                    }),
226                    cost: None,
227                    price: None,
228                    flag: None,
229                    metadata: vec![],
230                },
231            ])],
232            options: PluginOptions {
233                operating_currencies: vec!["USD".to_string()],
234                title: None,
235            },
236            config: Some("Martin Caroline".to_string()),
237        };
238
239        let output = plugin.process(input);
240        assert_eq!(output.errors.len(), 0);
241
242        // Should have 2 open directives + 1 transaction
243        assert_eq!(output.directives.len(), 3);
244
245        // Find the transaction
246        let txn = output
247            .directives
248            .iter()
249            .find(|d| d.directive_type == "transaction")
250            .unwrap();
251
252        if let DirectiveData::Transaction(txn_data) = &txn.data {
253            // Should have 3 postings: 1 income (unchanged) + 2 expenses (split)
254            assert_eq!(txn_data.postings.len(), 3);
255
256            // Check the split postings
257            let expense_postings: Vec<_> = txn_data
258                .postings
259                .iter()
260                .filter(|p| p.account.starts_with("Expenses:"))
261                .collect();
262
263            assert_eq!(expense_postings.len(), 2);
264            assert!(
265                expense_postings
266                    .iter()
267                    .any(|p| p.account == "Expenses:Accommodation:Martin")
268            );
269            assert!(
270                expense_postings
271                    .iter()
272                    .any(|p| p.account == "Expenses:Accommodation:Caroline")
273            );
274
275            // Each should have half the amount (134.50)
276            for p in expense_postings {
277                if let Some(units) = &p.units {
278                    assert_eq!(units.number, "134.50");
279                }
280            }
281        } else {
282            panic!("Expected transaction");
283        }
284    }
285
286    #[test]
287    fn test_split_expenses_preserves_member_accounts() {
288        let plugin = SplitExpensesPlugin;
289
290        let input = PluginInput {
291            directives: vec![create_test_transaction(vec![
292                PostingData {
293                    account: "Income:Martin:Cash".to_string(),
294                    units: Some(AmountData {
295                        number: "-100.00".to_string(),
296                        currency: "USD".to_string(),
297                    }),
298                    cost: None,
299                    price: None,
300                    flag: None,
301                    metadata: vec![],
302                },
303                PostingData {
304                    account: "Expenses:Food:Martin".to_string(),
305                    units: Some(AmountData {
306                        number: "100.00".to_string(),
307                        currency: "USD".to_string(),
308                    }),
309                    cost: None,
310                    price: None,
311                    flag: None,
312                    metadata: vec![],
313                },
314            ])],
315            options: PluginOptions {
316                operating_currencies: vec!["USD".to_string()],
317                title: None,
318            },
319            config: Some("Martin Caroline".to_string()),
320        };
321
322        let output = plugin.process(input);
323
324        // Should have only 1 directive (no new open directives since account already has member)
325        assert_eq!(output.directives.len(), 1);
326
327        if let DirectiveData::Transaction(txn_data) = &output.directives[0].data {
328            // Postings should be unchanged
329            assert_eq!(txn_data.postings.len(), 2);
330            assert!(
331                txn_data
332                    .postings
333                    .iter()
334                    .any(|p| p.account == "Expenses:Food:Martin")
335            );
336        } else {
337            panic!("Expected transaction");
338        }
339    }
340
341    #[test]
342    fn test_split_expenses_no_config() {
343        let plugin = SplitExpensesPlugin;
344
345        let input = PluginInput {
346            directives: vec![create_test_transaction(vec![PostingData {
347                account: "Expenses:Food".to_string(),
348                units: Some(AmountData {
349                    number: "100.00".to_string(),
350                    currency: "USD".to_string(),
351                }),
352                cost: None,
353                price: None,
354                flag: None,
355                metadata: vec![],
356            }])],
357            options: PluginOptions {
358                operating_currencies: vec!["USD".to_string()],
359                title: None,
360            },
361            config: None,
362        };
363
364        let output = plugin.process(input);
365
366        // Should return unchanged
367        assert_eq!(output.directives.len(), 1);
368        if let DirectiveData::Transaction(txn_data) = &output.directives[0].data {
369            assert_eq!(txn_data.postings.len(), 1);
370            assert_eq!(txn_data.postings[0].account, "Expenses:Food");
371        } else {
372            panic!("Expected transaction");
373        }
374    }
375}