1use crate::models::{
2 AccountName, Amount, CommodityName, Entry, Journal, Transaction, TransactionStatus,
3};
4use chrono::NaiveDate;
5use rust_decimal::Decimal;
6
7#[derive(Debug, Clone, Default, PartialEq)]
14pub struct RegisterFilter {
15 pub account: Option<AccountName>,
19
20 pub from: Option<NaiveDate>,
22
23 pub to: Option<NaiveDate>,
25
26 pub payee: Option<String>,
29
30 pub narration: Option<String>,
33
34 pub tag: Option<String>,
36
37 pub status: Option<TransactionStatus>,
39}
40
41#[derive(Debug, Clone, PartialEq)]
45pub struct RegisterEntry {
46 pub date: NaiveDate,
48 pub status: TransactionStatus,
50 pub payee: Option<String>,
52 pub narration: Option<String>,
54 pub account: AccountName,
56 pub amount: Option<Amount>,
58
59 pub balance: Vec<(CommodityName, Decimal)>,
62}
63
64pub fn register(journal: &Journal, filter: &RegisterFilter) -> Vec<RegisterEntry> {
74 let mut running: Vec<(CommodityName, Decimal)> = Vec::new();
78 let mut entries = Vec::new();
79
80 for entry in journal.entries() {
81 let Entry::Transaction(transaction) = entry else {
82 continue;
83 };
84
85 if !transaction_matches(transaction, filter) {
86 continue;
87 }
88
89 for posting in &transaction.postings {
90 if !account_matches(&posting.account, filter.account.as_ref()) {
91 continue;
92 }
93
94 if let Some(amount) = &posting.amount {
95 match running
96 .iter_mut()
97 .find(|(commodity, _)| commodity == &amount.commodity)
98 {
99 Some((_, sum)) => *sum += amount.number,
100 None => running.push((amount.commodity.clone(), amount.number)),
101 }
102 }
103
104 entries.push(RegisterEntry {
105 date: transaction.date,
106 status: transaction.status.clone(),
107 payee: transaction.payee.clone(),
108 narration: transaction.narration.clone(),
109 account: posting.account.clone(),
110 amount: posting.amount.clone(),
111 balance: running.clone(),
112 });
113 }
114 }
115
116 entries
117}
118
119fn transaction_matches(transaction: &Transaction, filter: &RegisterFilter) -> bool {
120 if let Some(from) = filter.from
121 && transaction.date < from
122 {
123 return false;
124 }
125 if let Some(to) = filter.to
126 && transaction.date > to
127 {
128 return false;
129 }
130 if let Some(status) = &filter.status
131 && &transaction.status != status
132 {
133 return false;
134 }
135 if let Some(payee) = &filter.payee
136 && !contains_case_insensitive(transaction.payee.as_deref(), payee)
137 {
138 return false;
139 }
140 if let Some(narration) = &filter.narration
141 && !contains_case_insensitive(transaction.narration.as_deref(), narration)
142 {
143 return false;
144 }
145 if let Some(tag) = &filter.tag
146 && !transaction.tags.iter().any(|t| t == tag)
147 {
148 return false;
149 }
150
151 true
152}
153
154fn contains_case_insensitive(haystack: Option<&str>, needle: &str) -> bool {
155 match haystack {
156 Some(haystack) => haystack.to_lowercase().contains(&needle.to_lowercase()),
157 None => false,
158 }
159}
160
161fn account_matches(account: &AccountName, filter: Option<&AccountName>) -> bool {
165 let Some(filter) = filter else {
166 return true;
167 };
168
169 let account = account.as_str();
170 let filter = filter.as_str();
171
172 account == filter || account.starts_with(&format!("{filter}:"))
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::models::{Metadata, Posting};
179
180 fn date(y: i32, m: u32, d: u32) -> NaiveDate {
181 NaiveDate::from_ymd_opt(y, m, d).unwrap()
182 }
183
184 fn posting(account: &str, amount: Option<(&str, &str)>) -> Posting {
185 Posting {
186 account: AccountName::new(account).unwrap(),
187 amount: amount.map(|(number, commodity)| Amount {
188 number: number.parse().unwrap(),
189 commodity: CommodityName::new(commodity).unwrap(),
190 }),
191 meta: Metadata::new(),
192 }
193 }
194
195 fn transaction(
196 d: NaiveDate,
197 payee: Option<&str>,
198 narration: Option<&str>,
199 tags: Vec<&str>,
200 status: TransactionStatus,
201 postings: Vec<Posting>,
202 ) -> Entry {
203 Entry::Transaction(Transaction {
204 date: d,
205 effective_date: None,
206 status,
207 payee: payee.map(str::to_string),
208 narration: narration.map(str::to_string),
209 postings,
210 tags: tags.into_iter().map(str::to_string).collect(),
211 links: Vec::new(),
212 reference: None,
213 meta: Metadata::new(),
214 })
215 }
216
217 #[test]
218 fn lists_every_posting_with_running_balance_when_unfiltered() {
219 let journal = Journal {
220 entries: vec![
221 transaction(
222 date(2024, 1, 5),
223 Some("Carrefour"),
224 Some("Courses"),
225 vec![],
226 TransactionStatus::Cleared,
227 vec![
228 posting("Depenses:Alimentation", Some(("45.30", "EUR"))),
229 posting("Actifs:Compte", Some(("-45.30", "EUR"))),
230 ],
231 ),
232 transaction(
233 date(2024, 1, 10),
234 Some("Carrefour"),
235 None,
236 vec![],
237 TransactionStatus::Cleared,
238 vec![
239 posting("Depenses:Alimentation", Some(("10.00", "EUR"))),
240 posting("Actifs:Compte", Some(("-10.00", "EUR"))),
241 ],
242 ),
243 ],
244 };
245
246 let entries = register(&journal, &RegisterFilter::default());
247
248 assert_eq!(entries.len(), 4);
249 assert_eq!(
250 entries[3].balance,
251 vec![(CommodityName::new("EUR").unwrap(), "0.00".parse().unwrap())]
252 );
253 }
254
255 #[test]
256 fn filters_by_account_including_sub_accounts() {
257 let journal = Journal {
258 entries: vec![transaction(
259 date(2024, 1, 5),
260 None,
261 None,
262 vec![],
263 TransactionStatus::Cleared,
264 vec![
265 posting("Depenses:Alimentation", Some(("45.30", "EUR"))),
266 posting("Actifs:Compte", Some(("-45.30", "EUR"))),
267 ],
268 )],
269 };
270
271 let entries = register(
272 &journal,
273 &RegisterFilter {
274 account: Some(AccountName::new("Depenses").unwrap()),
275 ..Default::default()
276 },
277 );
278
279 assert_eq!(entries.len(), 1);
280 assert_eq!(entries[0].account.as_str(), "Depenses:Alimentation");
281 }
282
283 #[test]
284 fn filters_by_date_range() {
285 let journal = Journal {
286 entries: vec![
287 transaction(
288 date(2024, 1, 5),
289 None,
290 None,
291 vec![],
292 TransactionStatus::Cleared,
293 vec![posting("Actifs:Compte", Some(("1.00", "EUR")))],
294 ),
295 transaction(
296 date(2024, 2, 5),
297 None,
298 None,
299 vec![],
300 TransactionStatus::Cleared,
301 vec![posting("Actifs:Compte", Some(("2.00", "EUR")))],
302 ),
303 ],
304 };
305
306 let entries = register(
307 &journal,
308 &RegisterFilter {
309 from: Some(date(2024, 2, 1)),
310 ..Default::default()
311 },
312 );
313
314 assert_eq!(entries.len(), 1);
315 assert_eq!(entries[0].date, date(2024, 2, 5));
316 }
317
318 #[test]
319 fn filters_by_payee_case_insensitively() {
320 let journal = Journal {
321 entries: vec![transaction(
322 date(2024, 1, 5),
323 Some("Carrefour"),
324 None,
325 vec![],
326 TransactionStatus::Cleared,
327 vec![posting("Actifs:Compte", Some(("1.00", "EUR")))],
328 )],
329 };
330
331 let entries = register(
332 &journal,
333 &RegisterFilter {
334 payee: Some("carre".to_string()),
335 ..Default::default()
336 },
337 );
338
339 assert_eq!(entries.len(), 1);
340 }
341
342 #[test]
343 fn filters_by_tag_and_status() {
344 let journal = Journal {
345 entries: vec![
346 transaction(
347 date(2024, 1, 5),
348 None,
349 None,
350 vec!["vacances"],
351 TransactionStatus::Cleared,
352 vec![posting("Actifs:Compte", Some(("1.00", "EUR")))],
353 ),
354 transaction(
355 date(2024, 1, 6),
356 None,
357 None,
358 vec![],
359 TransactionStatus::Pending,
360 vec![posting("Actifs:Compte", Some(("2.00", "EUR")))],
361 ),
362 ],
363 };
364
365 let entries = register(
366 &journal,
367 &RegisterFilter {
368 tag: Some("vacances".to_string()),
369 ..Default::default()
370 },
371 );
372 assert_eq!(entries.len(), 1);
373 assert_eq!(entries[0].date, date(2024, 1, 5));
374
375 let entries = register(
376 &journal,
377 &RegisterFilter {
378 status: Some(TransactionStatus::Pending),
379 ..Default::default()
380 },
381 );
382 assert_eq!(entries.len(), 1);
383 assert_eq!(entries[0].date, date(2024, 1, 6));
384 }
385}