Skip to main content

tackler_core/model/
transaction.rs

1/*
2 * Tackler-NG 2023-2025
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use crate::model::{Posts, posting};
7use crate::tackler;
8use jiff::tz;
9use std::cmp::Ordering;
10use std::fmt::Write;
11use std::fmt::{Display, Formatter};
12use tackler_api::txn_header::TxnHeader;
13use tackler_api::txn_ts;
14
15#[derive(Debug, Default)]
16pub struct Transaction {
17    pub(crate) header: TxnHeader,
18    pub(crate) posts: Posts,
19}
20
21impl Transaction {
22    /// # Errors
23    /// Returns `Err` if transaction sum is not zero
24    pub fn try_from(header: TxnHeader, posts: Posts) -> Result<Transaction, tackler::Error> {
25        let txn_sum = posting::txn_sum(&posts);
26        if !txn_sum.is_zero() {
27            let msg = format!("TXN postings do not zero: {txn_sum}");
28            return Err(msg.into());
29        }
30
31        Ok(Transaction { header, posts })
32    }
33}
34
35#[must_use]
36pub fn ord_by_txn(before: &Transaction, after: &Transaction) -> Ordering {
37    before.cmp(after)
38}
39
40impl Eq for Transaction {}
41
42impl Ord for Transaction {
43    fn cmp(&self, other: &Self) -> Ordering {
44        self.header.cmp(&other.header)
45    }
46}
47
48impl PartialOrd for Transaction {
49    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
50        Some(self.cmp(other))
51    }
52}
53
54impl PartialEq<Self> for Transaction {
55    fn eq(&self, other: &Self) -> bool {
56        self.header == other.header
57    }
58}
59
60impl Display for Transaction {
61    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
62        let indent = "   ";
63        write!(
64            f,
65            "{}{}",
66            self.header.to_string_with_indent(
67                indent,
68                |ts, _tz| { txn_ts::rfc_3339(ts) },
69                tz::TimeZone::UTC
70            ),
71            self.posts
72                .iter()
73                .fold(String::with_capacity(256), |mut output, p| {
74                    let _ = writeln!(output, "{indent}{p}");
75                    output
76                })
77        )
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use indoc::indoc;
85    use rust_decimal::Decimal;
86    use std::sync::Arc;
87    use tackler_rs::IndocUtils;
88
89    use crate::model::TxnAccount;
90    use crate::model::{AccountTreeNode, Commodity, Posting};
91    use tackler_api::txn_header::TxnHeader;
92    use tackler_api::txn_ts::rfc3339_to_zoned;
93
94    fn atn2txntn(atn: AccountTreeNode) -> TxnAccount {
95        TxnAccount {
96            atn: Arc::new(atn),
97            comm: Arc::new(Commodity::default()),
98        }
99    }
100
101    #[test]
102    fn txn_to_display() {
103        let ts = rfc3339_to_zoned("2023-02-04T14:03:05.047974+02:00").unwrap(/*:test:*/);
104
105        let tnx_hdr = TxnHeader {
106            timestamp: ts,
107            code: None,
108            description: Some("desc".to_string()),
109            uuid: None,
110            extid: None,
111            location: None,
112            tags: None,
113            comments: None,
114        };
115
116        let atn_ab = AccountTreeNode::from("a:b").unwrap(/*:test:*/);
117        let atn_cd = AccountTreeNode::from("c:d").unwrap(/*:test:*/);
118        let atn_ef = AccountTreeNode::from("e:f").unwrap(/*:test:*/);
119
120        let ef_post = Posting::from(
121            atn2txntn(atn_ef),
122            Decimal::from_str_exact("1").unwrap(/*:test:*/),
123            Decimal::from_str_exact("0").unwrap(/*:test:*/),
124            false,
125            Arc::new(Commodity::default()),
126            None,
127        )
128        .unwrap(/*:test:*/);
129        let cd_post = Posting::from(
130            atn2txntn(atn_cd),
131            Decimal::from_str_exact("2").unwrap(/*:test:*/),
132            Decimal::from_str_exact("0").unwrap(/*:test:*/),
133            false,
134            Arc::new(Commodity::default()),
135            None,
136        )
137        .unwrap(/*:test:*/);
138        let ab_post = Posting::from(
139            atn2txntn(atn_ab),
140            Decimal::from_str_exact("-3").unwrap(/*:test:*/),
141            Decimal::from_str_exact("0").unwrap(/*:test:*/),
142            false,
143            Arc::new(Commodity::default()),
144            None,
145        )
146        .unwrap(/*:test:*/);
147
148        let tests: Vec<(Transaction, String)> = vec![(
149            Transaction {
150                header: tnx_hdr,
151                posts: vec![ef_post, cd_post, ab_post],
152            },
153            indoc!(
154                "|2023-02-04T14:03:05.047974+02:00 'desc
155                 |   e:f   1
156                 |   c:d   2
157                 |   a:b  -3
158                 |"
159            )
160            .strip_margin(),
161        )];
162
163        let mut count = 0;
164        let should_be_count = tests.len();
165        for t in tests {
166            let txn_hdr_str = format!("{}", t.0);
167            assert_eq!(txn_hdr_str, t.1);
168            count += 1;
169        }
170        assert_eq!(count, should_be_count);
171    }
172}