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;
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 });
137 }
138 changed = true;
139 } else {
140 new_postings.push(posting.clone());
142 }
143 } else {
144 new_postings.push(posting.clone());
146 }
147 } else {
148 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 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 assert_eq!(directives.len(), 3);
263
264 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 assert_eq!(txn_data.postings.len(), 3);
273
274 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 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 assert_eq!(directives.len(), 1);
346
347 if let DirectiveData::Transaction(txn_data) = &directives[0].data {
348 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 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}