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