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 = posting.account.starts_with("Expenses:");
102
103 let has_member = members.iter().any(|m| posting.account.contains(m.as_str()));
105
106 if is_expense && !has_member {
107 if let Some(ref units) = posting.units {
109 if let Ok(amount) = Decimal::from_str(&units.number) {
111 let split_amount = amount / num_members;
112
113 for member in &members {
114 let subaccount = format!("{}:{}", posting.account, member);
116 new_accounts.insert(subaccount.clone());
117
118 let mut new_metadata = posting.metadata.clone();
120 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 new_postings.push(posting.clone());
143 }
144 } else {
145 new_postings.push(posting.clone());
147 }
148 } else {
149 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 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 assert_eq!(directives.len(), 3);
268
269 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 assert_eq!(txn_data.postings.len(), 3);
278
279 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 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 assert_eq!(directives.len(), 1);
353
354 if let DirectiveData::Transaction(txn_data) = &directives[0].data {
355 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 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}