Skip to main content

pactole_core/
register.rs

1use crate::models::{
2    AccountName, Amount, CommodityName, Entry, Journal, Transaction, TransactionStatus,
3};
4use chrono::NaiveDate;
5use rust_decimal::Decimal;
6
7/// Filters used to select which postings are included in a [`register`]
8/// listing.
9///
10/// Every field is optional; unset fields simply do not filter anything.
11/// When several filters are set, an entry must satisfy all of them
12/// (logical AND).
13#[derive(Debug, Clone, Default, PartialEq)]
14pub struct RegisterFilter {
15    /// Only keep postings on this account, or on one of its
16    /// sub-accounts (e.g. filtering on `Depenses` also matches
17    /// `Depenses:Alimentation`).
18    pub account: Option<AccountName>,
19
20    /// Only keep transactions on or after this date.
21    pub from: Option<NaiveDate>,
22
23    /// Only keep transactions on or before this date.
24    pub to: Option<NaiveDate>,
25
26    /// Only keep transactions whose payee contains this text
27    /// (case-insensitive).
28    pub payee: Option<String>,
29
30    /// Only keep transactions whose narration contains this text
31    /// (case-insensitive).
32    pub narration: Option<String>,
33
34    /// Only keep transactions carrying this tag.
35    pub tag: Option<String>,
36
37    /// Only keep transactions with this status.
38    pub status: Option<TransactionStatus>,
39}
40
41/// A single line of a [`register`] listing: one matched posting, along
42/// with the transaction it belongs to and the running balance of the
43/// register up to (and including) this line.
44#[derive(Debug, Clone, PartialEq)]
45pub struct RegisterEntry {
46    /// Date of the transaction containing the matched posting.
47    pub date: NaiveDate,
48    /// Cleared, pending or uncleared status of the transaction.
49    pub status: TransactionStatus,
50    /// Optional transaction payee.
51    pub payee: Option<String>,
52    /// Optional transaction narration.
53    pub narration: Option<String>,
54    /// Account of the matched posting.
55    pub account: AccountName,
56    /// Amount of the matched posting, if it was explicitly or implicitly set.
57    pub amount: Option<Amount>,
58
59    /// Running balance of the register, per commodity, after this
60    /// entry. Only postings with an amount contribute to it.
61    pub balance: Vec<(CommodityName, Decimal)>,
62}
63
64/// Lists the postings of `journal` matching `filter`, in chronological
65/// order (the journal is expected to already be sorted, e.g. by
66/// [`crate::validate_journal`]), together with a running balance kept
67/// per commodity across the whole listing.
68///
69/// This is akin to the `register` report found in other plain-text
70/// accounting tools (Ledger, hledger, Beancount): it is meant to answer
71/// "what happened on this account/payee/period?" rather than "what is
72/// the current balance?" (for that, see balance assertions).
73pub fn register(journal: &Journal, filter: &RegisterFilter) -> Vec<RegisterEntry> {
74    // Kept as a `Vec` (rather than e.g. a `BTreeMap`) since `CommodityName`
75    // has no ordering; the handful of commodities used in a single
76    // register listing makes the linear lookup below a non-issue.
77    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
161/// An account matches the filter if no account filter is set, if it is
162/// exactly the filtered account, or if it is one of its sub-accounts
163/// (i.e. its name starts with `<filter>:`).
164fn 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}