rustledger_plugin/native/plugins/
split_expenses.rs1use 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
37pub 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 let members: Vec<String> = match &input.config {
52 Some(config) => config.split_whitespace().map(String::from).collect(),
53 None => {
54 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 let mut existing_opens: HashSet<String> = HashSet::new();
76
77 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 let is_expense = rustledger_core::account_type(&posting.account) == "expenses";
105
106 let has_member = members.iter().any(|m| posting.account.contains(m.as_str()));
108
109 if is_expense && !has_member {
110 if let Some(ref units) = posting.units {
112 if let Ok(amount) = Decimal::from_str(&units.number) {
114 let split_amount = amount / num_members;
115
116 for member in &members {
117 let subaccount = format!("{}:{}", posting.account, member);
119 new_accounts.insert(subaccount.clone());
120
121 let mut new_metadata = posting.metadata.clone();
123 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 new_postings.push(posting.clone());
146 }
147 } else {
148 new_postings.push(posting.clone());
150 }
151 } else {
152 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 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 assert_eq!(directives.len(), 3);
271
272 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 assert_eq!(txn_data.postings.len(), 3);
281
282 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 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 assert_eq!(directives.len(), 1);
356
357 if let DirectiveData::Transaction(txn_data) = &directives[0].data {
358 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 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}