dinero/models/
transaction.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::ops::Deref;
4use std::rc::{Rc, Weak};
5
6use chrono::NaiveDate;
7use num::rational::BigRational;
8
9use crate::error::{BalanceError, LedgerError};
10use crate::models::balance::Balance;
11use crate::models::{Account, Comment, HasName, Money, Payee};
12use crate::List;
13use num::BigInt;
14use std::fmt;
15use std::fmt::{Display, Formatter};
16
17use super::Tag;
18use crate::filter::preprocess_query;
19use regex::Regex;
20
21#[derive(Debug, Clone)]
22pub struct Transaction<PostingType> {
23    pub status: TransactionStatus,
24    pub date: Option<NaiveDate>,
25    pub effective_date: Option<NaiveDate>,
26    pub cleared: Cleared,
27    pub code: Option<String>,
28    pub description: String,
29    pub payee: Option<String>,
30    pub postings: RefCell<Vec<PostingType>>,
31    pub comments: Vec<Comment>,
32    pub transaction_type: TransactionType,
33    pub tags: Vec<Tag>,
34    filter_query: Option<String>,
35}
36
37#[derive(Debug, Clone)]
38pub struct Posting {
39    pub(crate) account: Rc<Account>,
40    pub date: NaiveDate,
41    pub amount: Option<Money>,
42    pub balance: Option<Money>,
43    pub cost: Option<Cost>,
44    pub kind: PostingType,
45    pub comments: Vec<Comment>,
46    pub tags: RefCell<Vec<Tag>>,
47    pub payee: Option<Rc<Payee>>,
48    pub transaction: RefCell<Weak<Transaction<Posting>>>,
49    pub origin: PostingOrigin,
50}
51
52#[derive(Debug, Clone, Copy, Eq, PartialEq)]
53pub enum PostingOrigin {
54    FromTransaction,
55    Automated,
56    Periodic,
57}
58
59impl<T> Transaction<T> {
60    pub fn get_filter_query(&mut self) -> String {
61        match self.filter_query.clone() {
62            None => {
63                let mut parts: Vec<String> = vec![];
64                let mut current = String::new();
65                let mut in_regex = false;
66                let mut in_string = false;
67                for c in self.description.chars() {
68                    if (c == ' ') & !in_string & !in_regex {
69                        parts.push(current.clone());
70                        current = String::new();
71                    }
72                    if c == '"' {
73                        in_string = !in_string;
74                    } else if c == '/' {
75                        in_regex = !in_regex;
76                        current.push(c);
77                    } else {
78                        current.push(c)
79                    }
80                }
81                parts.push(current);
82                //self.description.split(' ').map(|x| x.to_string()).collect();
83                let res = preprocess_query(&parts, &false);
84                self.filter_query = Some(res.clone());
85                res
86            }
87            Some(x) => x,
88        }
89    }
90    pub fn get_payee(&self, payees: &List<Payee>) -> Option<Rc<Payee>> {
91        match &self.payee {
92            Some(payee) => match payees.get(payee) {
93                Ok(x) => Some(x.clone()),
94                Err(_) => panic!("Couldn't find payee {}", payee),
95            },
96            None => match payees.get(&self.description) {
97                Ok(x) => Some(x.clone()),
98                Err(_) => None,
99            },
100        }
101    }
102}
103
104#[derive(Debug, Copy, Clone, PartialEq, Eq)]
105pub enum TransactionStatus {
106    NotChecked,
107    InternallyBalanced,
108    Correct,
109}
110
111#[derive(Debug, Copy, Clone, PartialEq, Eq)]
112pub enum TransactionType {
113    Real,
114    Automated,
115    Periodic,
116}
117
118#[derive(Debug, Copy, Clone, PartialEq, Eq)]
119pub enum Cleared {
120    Unknown,
121    NotCleared,
122    Cleared,
123}
124
125#[derive(Debug, Clone, Copy, Eq, PartialEq)]
126pub enum PostingType {
127    Real,
128    Virtual,
129    VirtualMustBalance,
130}
131
132impl Posting {
133    pub fn new(
134        account: &Rc<Account>,
135        kind: PostingType,
136        payee: &Payee,
137        origin: PostingOrigin,
138        date: NaiveDate,
139    ) -> Posting {
140        Posting {
141            account: account.clone(),
142            amount: None,
143            date,
144            balance: None,
145            cost: None,
146            kind,
147            comments: vec![],
148            tags: RefCell::new(vec![]),
149            payee: Some(Rc::new(payee.clone())),
150            transaction: RefCell::new(Default::default()),
151            origin,
152        }
153    }
154    pub fn set_amount(&mut self, money: Money) {
155        self.amount = Some(money)
156    }
157    pub fn has_tag(&self, regex: Regex) -> bool {
158        for t in self.tags.borrow().iter() {
159            if regex.is_match(t.get_name()) {
160                return true;
161            }
162        }
163        false
164    }
165    pub fn get_tag(&self, regex: Regex) -> Option<String> {
166        for t in self.tags.borrow().iter() {
167            if regex.is_match(t.get_name()) {
168                return t.value.clone();
169            }
170        }
171        None
172    }
173    pub fn get_exact_tag(&self, regex: String) -> Option<String> {
174        for t in self.tags.borrow().iter() {
175            if regex.as_str() == t.get_name() {
176                return t.value.clone();
177            }
178        }
179        None
180    }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum Cost {
185    Total { amount: Money },
186    PerUnit { amount: Money },
187}
188
189impl<PostingType> Transaction<PostingType> {
190    pub fn new(t_type: TransactionType) -> Transaction<PostingType> {
191        Transaction {
192            status: TransactionStatus::NotChecked,
193            date: None,
194            effective_date: None,
195            cleared: Cleared::Unknown,
196            code: None,
197            description: "".to_string(),
198            payee: None,
199            postings: RefCell::new(vec![]),
200            comments: vec![],
201            transaction_type: t_type,
202            tags: vec![],
203            filter_query: None,
204        }
205    }
206}
207
208fn total_balance(postings: &[Posting], kind: PostingType) -> Balance {
209    let bal = Balance::new();
210    postings
211        .iter()
212        .filter(|p| p.amount.is_some() & (p.kind == kind))
213        .map(|p| match &p.cost {
214            None => Balance::from(p.amount.as_ref().unwrap().clone()),
215            Some(cost) => match cost {
216                Cost::Total { amount } => {
217                    if p.amount.as_ref().unwrap().clone().is_negative() {
218                        Balance::from(-amount.clone())
219                    } else {
220                        Balance::from(amount.clone())
221                    }
222                }
223                Cost::PerUnit { amount } => {
224                    let currency = match amount {
225                        Money::Zero => panic!("Cost has no currency"),
226                        Money::Money { currency, .. } => currency,
227                    };
228                    let units = match amount {
229                        Money::Zero => BigRational::new(BigInt::from(0), BigInt::from(1)),
230                        Money::Money { amount, .. } => amount.clone(),
231                    } * match p.amount.as_ref().unwrap() {
232                        Money::Zero => BigRational::new(BigInt::from(0), BigInt::from(1)),
233                        Money::Money { amount, .. } => amount.clone(),
234                    };
235                    let money = Money::Money {
236                        amount: units
237                            * (if p.amount.as_ref().unwrap().is_negative() {
238                                -BigInt::from(1)
239                            } else {
240                                BigInt::from(1)
241                            }),
242                        currency: currency.clone(),
243                    };
244                    Balance::from(money)
245                }
246            },
247        })
248        .fold(bal, |acc, cur| acc + cur)
249}
250
251impl Transaction<Posting> {
252    pub fn is_balanced(&self) -> bool {
253        total_balance(&*self.postings.borrow(), PostingType::Real).can_be_zero()
254    }
255
256    pub fn num_empty_postings(&self) -> usize {
257        self.postings
258            .borrow()
259            .iter()
260            .filter(|p| p.amount.is_none() & p.balance.is_none())
261            .count()
262    }
263
264    /// Balances the transaction
265    /// There are two groups of postings that have to balance:
266    /// - the *real postings*
267    /// - the *virtual postings* that must balance, but not the virtual postings
268    /// Real postings can have things like cost or balance assertions. However virtual postings
269    /// can't.
270    ///
271    /// Because balance assertions depend on previous transactions, this function receives a
272    /// balances Hashmap as a parameter. If the skip_balance_check flag is set to true, balance
273    /// assertions are skipped.
274    pub fn balance(
275        &mut self,
276        balances: &mut HashMap<Rc<Account>, Balance>,
277        skip_balance_check: bool,
278    ) -> Result<Balance, Box<dyn std::error::Error>> {
279        let mut transaction_balance = Balance::new();
280
281        // 1. Check the virtual postings
282        match total_balance(&*self.postings.borrow(), PostingType::VirtualMustBalance).can_be_zero()
283        {
284            true => {}
285            false => return Err(Box::new(BalanceError::TransactionIsNotBalanced)),
286        }
287
288        // 1. Iterate over postings
289        let mut fill_account = Rc::new(Account::from("this will never be used"));
290        let mut fill_payee = None;
291        let mut fill_date: NaiveDate = NaiveDate::from_ymd(1900, 1, 1); // it will be overwritten
292        let mut postings: Vec<Posting> = Vec::new();
293
294        for p in self.postings.get_mut().iter() {
295            if p.kind != PostingType::Real {
296                continue;
297            }
298            // If it has money, update the balance
299            if let Some(money) = &p.amount {
300                let expected_balance = balances.get(p.account.deref()).unwrap().clone()  // What we had 
301                    + Balance::from(money.clone()); // What we add
302                if !skip_balance_check {
303                    if let Some(balance) = &p.balance {
304                        if Balance::from(balance.clone()) != expected_balance {
305                            eprintln!("Found:       {}", balance);
306                            eprintln!("Expected:    {}", expected_balance);
307                            eprintln!(
308                                "Difference:  {}",
309                                expected_balance - Balance::from(balance.clone())
310                            );
311                            return Err(Box::new(BalanceError::TransactionIsNotBalanced));
312                        }
313                    }
314                }
315
316                // Update the balance of the account
317                balances.insert(p.account.clone(), expected_balance);
318
319                // Update the balance of the transaction
320                transaction_balance = transaction_balance   // What we had
321                    + match &p.cost {
322                    None => Balance::from(money.clone()),
323                    // If it has a cost, the secondary currency is added for the balance
324                    Some(cost) => match cost {
325                        Cost::Total { amount } => {
326                            if p.amount.as_ref().unwrap().is_negative() {
327                                Balance::from(-amount.clone())
328                            } else {
329                                Balance::from(amount.clone())
330                            }
331                        }
332                        Cost::PerUnit { amount } => {
333                            let currency = match amount {
334                                Money::Zero => panic!("Cost has no currency"),
335                                Money::Money { currency, .. } => currency,
336                            };
337                            let units = match amount {
338                                Money::Zero => BigRational::from(BigInt::from(0)),
339                                Money::Money { amount, .. } => amount.clone(),
340                            } * match p.amount.as_ref().unwrap() {
341                                Money::Zero => BigRational::from(BigInt::from(0)),
342                                Money::Money { amount, .. } => amount.clone(),
343                            };
344                            let money = Money::Money {
345                                amount: units,
346                                currency: currency.clone(),
347                            };
348                            Balance::from(money)
349                        }
350                    },
351                };
352
353                // Add the posting
354                postings.push(Posting {
355                    account: p.account.clone(),
356                    amount: p.amount.clone(),
357                    date: p.date,
358                    balance: p.balance.clone(),
359                    cost: p.cost.clone(),
360                    kind: PostingType::Real,
361                    comments: p.comments.clone(),
362                    tags: p.tags.clone(),
363                    payee: p.payee.clone(),
364                    transaction: p.transaction.clone(),
365                    origin: PostingOrigin::FromTransaction,
366                });
367            } else if p.balance.is_some() & !skip_balance_check {
368                // There is a balance
369                let balance = p.balance.as_ref().unwrap();
370
371                // update the amount
372                let account_bal = balances.get(p.account.deref()).unwrap().clone();
373                let amount_bal = Balance::from(balance.clone()) - account_bal;
374                let money = amount_bal.to_money()?;
375                transaction_balance = transaction_balance + Balance::from(money.clone());
376                // update the account balance
377                balances.insert(p.account.clone(), Balance::from(balance.clone()));
378                postings.push(Posting {
379                    account: p.account.clone(),
380                    date: p.date,
381                    amount: Some(money),
382                    balance: p.balance.clone(),
383                    cost: p.cost.clone(),
384                    kind: PostingType::Real,
385                    comments: p.comments.clone(),
386                    tags: p.tags.clone(),
387                    payee: p.payee.clone(),
388                    transaction: p.transaction.clone(),
389                    origin: PostingOrigin::FromTransaction,
390                });
391            } else {
392                // We do nothing, but this is the account for the empty post
393                fill_account = p.account.clone();
394                fill_payee = p.payee.clone();
395                fill_date = p.date;
396            }
397        }
398
399        let empties = self
400            .postings
401            .borrow()
402            .iter()
403            .filter(|p| p.kind == PostingType::Real)
404            .count()
405            - postings.len();
406        if empties > 1 {
407            Err(Box::new(LedgerError::TooManyEmptyPostings(empties)))
408        } else if empties == 0 {
409            match transaction_balance.can_be_zero() {
410                true => {
411                    //self.postings = RefCell::new(postings);
412                    postings.append(
413                        &mut self
414                            .postings
415                            .borrow_mut()
416                            .iter()
417                            .filter(|p| p.kind != PostingType::Real)
418                            .cloned()
419                            .collect(),
420                    );
421                    self.postings.replace(postings);
422                    Ok(transaction_balance)
423                }
424                false => Err(Box::new(BalanceError::TransactionIsNotBalanced)),
425            }
426        } else {
427            // Fill the empty posting
428            // let account = &self.postings.last().unwrap().account;
429            for (_, money) in (-transaction_balance).iter() {
430                let expected_balance = balances.get(&fill_account.clone()).unwrap().clone()
431                    + Balance::from(money.clone());
432
433                balances.insert(fill_account.clone(), expected_balance);
434
435                postings.push(Posting {
436                    account: fill_account.clone(),
437                    amount: Some(money.clone()),
438                    balance: None,
439                    cost: None,
440                    kind: PostingType::Real,
441                    comments: self.comments.clone(),
442                    tags: RefCell::new(self.tags.clone()),
443                    payee: fill_payee.clone(),
444                    date: fill_date,
445                    transaction: self.postings.borrow()[0].transaction.clone(),
446                    origin: PostingOrigin::FromTransaction,
447                });
448            }
449            // self.postings = RefCell::new(postings);
450            postings.append(
451                &mut self
452                    .postings
453                    .get_mut()
454                    .iter()
455                    .filter(|p| p.kind != PostingType::Real)
456                    .cloned()
457                    .collect(),
458            );
459            self.postings.replace(postings);
460            // self_postings = postings;
461            Ok(Balance::new())
462        }
463    }
464}
465
466impl Display for Transaction<Posting> {
467    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
468        let mut message = String::new();
469        message.push_str(format!("{} {}", self.date.unwrap(), self.description).as_str());
470        for p in self.postings.borrow().iter() {
471            if p.amount.as_ref().is_some() {
472                message.push_str(
473                    format!(
474                        "\n\t{:50}{}",
475                        p.account.get_name(),
476                        p.amount.as_ref().unwrap()
477                    )
478                    .as_str(),
479                );
480            } else {
481                message.push_str(format!("\n\t{:50}", p.account.get_name()).as_str());
482            }
483        }
484        write!(f, "{}", message)
485    }
486}