Skip to main content

tackler_core/model/
posting.rs

1/*
2 * Tackler-NG 2023-2025
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use crate::model::Commodity;
7use crate::model::Posts;
8use crate::model::TxnAccount;
9use crate::tackler;
10use rust_decimal::Decimal;
11use std::fmt::{Display, Formatter};
12use std::sync::Arc;
13
14#[derive(Debug, Clone)]
15#[non_exhaustive]
16pub struct Posting {
17    pub acctn: TxnAccount,
18    pub amount: Decimal,
19    // todo: fix / rename these (position?, exchange? amount, commodity)
20    pub txn_amount: Decimal,
21    pub is_total_amount: bool,
22    pub txn_commodity: Arc<Commodity>, // todo: check / fix this
23    pub comment: Option<String>,
24}
25
26impl Posting {
27    pub(crate) fn from(
28        acctn: TxnAccount,
29        amount: Decimal,
30        txn_amount: Decimal,
31        is_total_amount: bool,
32        txn_commodity: Arc<Commodity>,
33        comment: Option<String>,
34    ) -> Result<Posting, tackler::Error> {
35        if amount.is_zero() {
36            let msg = format!("Zero sum postings are not allowed: {}", acctn.atn.account);
37            return Err(msg.into());
38        }
39
40        Ok(Posting {
41            acctn,
42            amount,
43            txn_amount,
44            is_total_amount,
45            txn_commodity,
46            comment,
47        })
48    }
49}
50
51#[must_use]
52pub fn txn_sum(posts: &Posts) -> Decimal {
53    posts.iter().map(|p| p.txn_amount).sum()
54}
55
56impl Display for Posting {
57    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
58        let sign_space = if self.amount.is_sign_negative() {
59            ""
60        } else {
61            " "
62        };
63
64        let comm = &self.acctn.comm;
65        write!(
66            f,
67            "{}  {}{}{}{}{}",
68            self.acctn.atn,
69            sign_space,
70            self.amount,
71            if comm.is_any() {
72                format!(" {}", comm.name)
73            } else {
74                String::new()
75            },
76            if self.txn_commodity.is_any() {
77                #[allow(clippy::collapsible_else_if)]
78                // todo: old-scala comment: fix this
79                if self.txn_commodity.name == self.acctn.comm.name {
80                    String::default()
81                } else {
82                    if self.is_total_amount {
83                        format!(" = {} {}", self.txn_amount, self.txn_commodity.name)
84                    } else {
85                        format!(
86                            " @ {} {}",
87                            (self.txn_amount / self.amount),
88                            self.txn_commodity.name
89                        )
90                    }
91                }
92            } else {
93                String::default()
94            },
95            self.comment
96                .as_ref()
97                .map(|c| format!(" ; {c}"))
98                .unwrap_or_default()
99        )
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::model::AccountTreeNode;
107    use std::sync::Arc;
108
109    #[test]
110    // test: 42ad9d32-64aa-4fcd-a4ab-1e8521b921e3
111    // desc: "reject zero postings"
112    fn reject_zero_posting() {
113        {
114            let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap(/*:test:*/));
115            let txntn = TxnAccount {
116                atn: acctn,
117                comm: Arc::new(Commodity::default()),
118            };
119            let p = Posting::from(
120                txntn,
121                Decimal::new(0, 0),
122                Decimal::new(0, 0),
123                false,
124                Arc::new(Commodity::default()),
125                None,
126            );
127            assert!(p.is_err());
128        }
129        {
130            // check that difference precision doesn't mess Decimal comparisons
131            let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap(/*:test:*/));
132            let txntn = TxnAccount {
133                atn: acctn,
134                comm: Arc::new(Commodity::default()),
135            };
136            let p = Posting::from(
137                txntn,
138                Decimal::new(0, 28),
139                Decimal::new(0, 28),
140                false,
141                Arc::new(Commodity::default()),
142                None,
143            );
144            assert!(p.is_err());
145        }
146    }
147
148    #[test]
149    // test: e3c97b66-318c-4396-8857-0cd2c1dfb0d2
150    // desc: "preserve precision - 1E20"
151    fn preserve_precision_1e20() {
152        /*
153         * val v = //          3         2         1         .         1         2         3         4
154         *        TacklerReal("123456789012345678901234567890.123456789012345678901234567890123456789012")
155         * val p = Posting(acctn, v, v, false, None, None)
156         * assert(p.toString === "a:b   123456789012345678901234567890.123456789012345678901234567890123456789012")
157         */
158        let v_str =
159            //2         1         .         1         2         3         4
160             "12345678901234567890.123456789";
161        let ref_str = format!("a:b   {v_str}");
162        let v = Decimal::from_str_exact(v_str).unwrap(/*:test:*/);
163        let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap(/*:test:*/));
164        let txntn = TxnAccount {
165            atn: acctn,
166            comm: Arc::new(Commodity::default()),
167        };
168        let p = Posting::from(txntn, v, v, false, Arc::new(Commodity::default()), None).unwrap(/*:test:*/);
169
170        let p_str = format!("{p}");
171        assert_eq!(p_str, ref_str);
172        assert_eq!(p.to_string(), ref_str);
173    }
174
175    #[test]
176    // test: 26da0769-de5f-4344-b1d4-d3ddbf3f7f5a
177    // desc: "preserve precision - 1E15"
178    fn preserve_precision_1e15() {
179        /*
180         * val v = //          3         2         1         .         1         2         3         4
181         *        TacklerReal("123456789012345678901234567890.123456789012345678901234567890123456789012")
182         * val p = Posting(acctn, v, v, false, None, None)
183         * assert(p.toString === "a:b   123456789012345678901234567890.123456789012345678901234567890123456789012")
184         */
185        let v_str =
186            // Quadrillion is 15 digits, e.g. 100 * USA budget
187            //2         1         .         1         2         3         4
188                  "678901234567890.12345678901234";
189        let ref_str = format!("a:b   {v_str}");
190        let v = Decimal::from_str_exact(v_str).unwrap(/*:test:*/);
191        let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap(/*:test:*/));
192        let txntn = TxnAccount {
193            atn: acctn,
194            comm: Arc::new(Commodity::default()),
195        };
196        let p = Posting::from(txntn, v, v, false, Arc::new(Commodity::default()), None).unwrap(/*:test:*/);
197        let p_str = format!("{p}");
198        assert_eq!(p_str, ref_str);
199        assert_eq!(p.to_string(), ref_str);
200    }
201
202    #[test]
203    // test: 6ce68af4-5349-44e0-8fbc-35bebd8ac1ac
204    // desc: "toString e.g. Display"
205    fn display() {
206        let v = Decimal::new(12301, 2);
207        let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap(/*:test:*/));
208        let txntn = TxnAccount {
209            atn: acctn,
210            comm: Arc::new(Commodity::default()),
211        };
212        let p = Posting::from(txntn, v, v, false, Arc::new(Commodity::default()), Some("comment".to_string())).unwrap(/*:test:*/);
213
214        let p_str = format!("{p}");
215        assert_eq!(p_str, "a:b   123.01 ; comment");
216    }
217
218    #[test]
219    // test: 16b54e8c-5ea6-420c-bd72-157dbcc06a49
220    // desc: "unit price"
221    fn unit_price() {
222        let pv = Decimal::new(12300, 2);
223        let tv = Decimal::new(24600, 2);
224        let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap(/*:test:*/));
225        let txntn = TxnAccount {
226            atn: acctn,
227            comm: Arc::new(Commodity::default()),
228        };
229        let p = Posting::from(
230            txntn,
231            pv,
232            tv,
233            false,
234            Arc::new(Commodity {
235                name: "€".to_string(),
236            }),
237            None,
238        )
239        .unwrap(/*:test:*/);
240
241        assert_eq!(p.to_string(), "a:b   123.00 @ 2 €");
242    }
243
244    #[test]
245    // test: 22059d1d-7c10-42dc-831f-03bd1f1d6257
246    // desc: "unit price with comment"
247    fn unit_price_w_comment() {
248        let pv = Decimal::new(12300, 2);
249        let tv = Decimal::new(24600, 2);
250        let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap(/*:test:*/));
251        let txntn = TxnAccount {
252            atn: acctn,
253            comm: Arc::new(Commodity::default()),
254        };
255        let p = Posting::from(
256            txntn,
257            pv,
258            tv,
259            false,
260            Arc::new(Commodity {
261                name: "€".to_string(),
262            }),
263            Some("comment".to_string()),
264        )
265        .unwrap(/*:test:*/);
266
267        assert_eq!(p.to_string(), "a:b   123.00 @ 2 € ; comment");
268    }
269
270    #[test]
271    // test: 0fef204a-19da-418f-b7d0-86b5211c2182
272    // desc: "total price"
273    fn total_price() {
274        let pv = Decimal::new(12300, 2);
275        let tv = Decimal::new(24600, 2);
276        let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap(/*:test:*/));
277        let txntn = TxnAccount {
278            atn: acctn,
279            comm: Arc::new(Commodity::default()),
280        };
281        let p = Posting::from(
282            txntn,
283            pv,
284            tv,
285            true,
286            Arc::new(Commodity {
287                name: "€".to_string(),
288            }),
289            None,
290        )
291        .unwrap(/*:test:*/);
292
293        assert_eq!(p.to_string(), "a:b   123.00 = 246.00 €");
294    }
295
296    #[test]
297    // test: 718dd25c-aebc-4f29-9903-67942c6ba531
298    // desc: "total price with comment"
299    fn total_price_w_comment() {
300        let pv = Decimal::new(12300, 2);
301        let tv = Decimal::new(24600, 2);
302        let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap(/*:test:*/));
303        let txntn = TxnAccount {
304            atn: acctn,
305            comm: Arc::new(Commodity::default()),
306        };
307        let p = Posting::from(
308            txntn,
309            pv,
310            tv,
311            true,
312            Arc::new(Commodity {
313                name: "€".to_string(),
314            }),
315            Some("comment".to_string()),
316        )
317        .unwrap(/*:test:*/);
318
319        assert_eq!(p.to_string(), "a:b   123.00 = 246.00 € ; comment");
320    }
321}