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,
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 directives: input.directives,
57 errors: Vec::new(),
58 };
59 }
60 };
61
62 if members.is_empty() {
63 return PluginOutput {
64 directives: input.directives,
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
73 let directives: Vec<_> = input
75 .directives
76 .into_iter()
77 .map(|mut wrapper| {
78 if earliest_date.is_none()
80 || wrapper.date < *earliest_date.as_ref().unwrap_or(&String::new())
81 {
82 earliest_date = Some(wrapper.date.clone());
83 }
84
85 if wrapper.directive_type == "transaction"
86 && let DirectiveData::Transaction(ref mut txn) = wrapper.data
87 {
88 let mut new_postings = Vec::new();
89
90 for posting in &txn.postings {
91 let is_expense = posting.account.starts_with("Expenses:");
93
94 let has_member =
96 members.iter().any(|m| posting.account.contains(m.as_str()));
97
98 if is_expense && !has_member {
99 if let Some(ref units) = posting.units {
101 if let Ok(amount) = Decimal::from_str(&units.number) {
103 let split_amount = amount / num_members;
104
105 for member in &members {
106 let subaccount = format!("{}:{}", posting.account, member);
108 new_accounts.insert(subaccount.clone());
109
110 let mut new_metadata = posting.metadata.clone();
112 new_metadata.push((
114 "__automatic__".to_string(),
115 MetaValueData::String("True".to_string()),
116 ));
117
118 new_postings.push(PostingData {
119 account: subaccount,
120 units: Some(AmountData {
121 number: split_amount.to_string(),
122 currency: units.currency.clone(),
123 }),
124 cost: posting.cost.clone(),
125 price: posting.price.clone(),
126 flag: posting.flag.clone(),
127 metadata: new_metadata,
128 });
129 }
130 } else {
131 new_postings.push(posting.clone());
133 }
134 } else {
135 new_postings.push(posting.clone());
137 }
138 } else {
139 new_postings.push(posting.clone());
141 }
142 }
143
144 txn.postings = new_postings;
145 }
146 wrapper
147 })
148 .collect();
149
150 let mut open_directives: Vec<DirectiveWrapper> = Vec::new();
152 if let Some(date) = earliest_date {
153 for account in &new_accounts {
154 open_directives.push(DirectiveWrapper {
155 directive_type: "open".to_string(),
156 date: date.clone(),
157 filename: Some("<split_expenses>".to_string()),
158 lineno: Some(0),
159 data: DirectiveData::Open(OpenData {
160 account: account.clone(),
161 currencies: vec![],
162 booking: None,
163 metadata: vec![],
164 }),
165 });
166 }
167 }
168
169 let mut all_directives = open_directives;
171 all_directives.extend(directives);
172
173 PluginOutput {
174 directives: all_directives,
175 errors: Vec::new(),
176 }
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::types::*;
184
185 fn create_test_transaction(postings: Vec<PostingData>) -> DirectiveWrapper {
186 DirectiveWrapper {
187 directive_type: "transaction".to_string(),
188 date: "2024-01-15".to_string(),
189 filename: None,
190 lineno: None,
191 data: DirectiveData::Transaction(TransactionData {
192 flag: "*".to_string(),
193 payee: Some("Test".to_string()),
194 narration: "Test transaction".to_string(),
195 tags: vec![],
196 links: vec![],
197 metadata: vec![],
198 postings,
199 }),
200 }
201 }
202
203 #[test]
204 fn test_split_expenses_basic() {
205 let plugin = SplitExpensesPlugin;
206
207 let input = PluginInput {
208 directives: vec![create_test_transaction(vec![
209 PostingData {
210 account: "Income:Caroline:CreditCard".to_string(),
211 units: Some(AmountData {
212 number: "-269.00".to_string(),
213 currency: "USD".to_string(),
214 }),
215 cost: None,
216 price: None,
217 flag: None,
218 metadata: vec![],
219 },
220 PostingData {
221 account: "Expenses:Accommodation".to_string(),
222 units: Some(AmountData {
223 number: "269.00".to_string(),
224 currency: "USD".to_string(),
225 }),
226 cost: None,
227 price: None,
228 flag: None,
229 metadata: vec![],
230 },
231 ])],
232 options: PluginOptions {
233 operating_currencies: vec!["USD".to_string()],
234 title: None,
235 },
236 config: Some("Martin Caroline".to_string()),
237 };
238
239 let output = plugin.process(input);
240 assert_eq!(output.errors.len(), 0);
241
242 assert_eq!(output.directives.len(), 3);
244
245 let txn = output
247 .directives
248 .iter()
249 .find(|d| d.directive_type == "transaction")
250 .unwrap();
251
252 if let DirectiveData::Transaction(txn_data) = &txn.data {
253 assert_eq!(txn_data.postings.len(), 3);
255
256 let expense_postings: Vec<_> = txn_data
258 .postings
259 .iter()
260 .filter(|p| p.account.starts_with("Expenses:"))
261 .collect();
262
263 assert_eq!(expense_postings.len(), 2);
264 assert!(
265 expense_postings
266 .iter()
267 .any(|p| p.account == "Expenses:Accommodation:Martin")
268 );
269 assert!(
270 expense_postings
271 .iter()
272 .any(|p| p.account == "Expenses:Accommodation:Caroline")
273 );
274
275 for p in expense_postings {
277 if let Some(units) = &p.units {
278 assert_eq!(units.number, "134.50");
279 }
280 }
281 } else {
282 panic!("Expected transaction");
283 }
284 }
285
286 #[test]
287 fn test_split_expenses_preserves_member_accounts() {
288 let plugin = SplitExpensesPlugin;
289
290 let input = PluginInput {
291 directives: vec![create_test_transaction(vec![
292 PostingData {
293 account: "Income:Martin:Cash".to_string(),
294 units: Some(AmountData {
295 number: "-100.00".to_string(),
296 currency: "USD".to_string(),
297 }),
298 cost: None,
299 price: None,
300 flag: None,
301 metadata: vec![],
302 },
303 PostingData {
304 account: "Expenses:Food:Martin".to_string(),
305 units: Some(AmountData {
306 number: "100.00".to_string(),
307 currency: "USD".to_string(),
308 }),
309 cost: None,
310 price: None,
311 flag: None,
312 metadata: vec![],
313 },
314 ])],
315 options: PluginOptions {
316 operating_currencies: vec!["USD".to_string()],
317 title: None,
318 },
319 config: Some("Martin Caroline".to_string()),
320 };
321
322 let output = plugin.process(input);
323
324 assert_eq!(output.directives.len(), 1);
326
327 if let DirectiveData::Transaction(txn_data) = &output.directives[0].data {
328 assert_eq!(txn_data.postings.len(), 2);
330 assert!(
331 txn_data
332 .postings
333 .iter()
334 .any(|p| p.account == "Expenses:Food:Martin")
335 );
336 } else {
337 panic!("Expected transaction");
338 }
339 }
340
341 #[test]
342 fn test_split_expenses_no_config() {
343 let plugin = SplitExpensesPlugin;
344
345 let input = PluginInput {
346 directives: vec![create_test_transaction(vec![PostingData {
347 account: "Expenses:Food".to_string(),
348 units: Some(AmountData {
349 number: "100.00".to_string(),
350 currency: "USD".to_string(),
351 }),
352 cost: None,
353 price: None,
354 flag: None,
355 metadata: vec![],
356 }])],
357 options: PluginOptions {
358 operating_currencies: vec!["USD".to_string()],
359 title: None,
360 },
361 config: None,
362 };
363
364 let output = plugin.process(input);
365
366 assert_eq!(output.directives.len(), 1);
368 if let DirectiveData::Transaction(txn_data) = &output.directives[0].data {
369 assert_eq!(txn_data.postings.len(), 1);
370 assert_eq!(txn_data.postings[0].account, "Expenses:Food");
371 } else {
372 panic!("Expected transaction");
373 }
374 }
375}