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