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