1use 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 pub txn_amount: Decimal,
21 pub is_total_amount: bool,
22 pub txn_commodity: Arc<Commodity>, 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 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 fn reject_zero_posting() {
113 {
114 let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap());
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 let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap());
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 fn preserve_precision_1e20() {
152 let v_str =
159 "12345678901234567890.123456789";
161 let ref_str = format!("a:b {v_str}");
162 let v = Decimal::from_str_exact(v_str).unwrap();
163 let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap());
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();
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 fn preserve_precision_1e15() {
179 let v_str =
186 "678901234567890.12345678901234";
189 let ref_str = format!("a:b {v_str}");
190 let v = Decimal::from_str_exact(v_str).unwrap();
191 let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap());
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();
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 fn display() {
206 let v = Decimal::new(12301, 2);
207 let acctn = Arc::new(AccountTreeNode::from("a:b").unwrap());
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();
213
214 let p_str = format!("{p}");
215 assert_eq!(p_str, "a:b 123.01 ; comment");
216 }
217
218 #[test]
219 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());
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();
240
241 assert_eq!(p.to_string(), "a:b 123.00 @ 2 €");
242 }
243
244 #[test]
245 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());
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();
266
267 assert_eq!(p.to_string(), "a:b 123.00 @ 2 € ; comment");
268 }
269
270 #[test]
271 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());
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();
292
293 assert_eq!(p.to_string(), "a:b 123.00 = 246.00 €");
294 }
295
296 #[test]
297 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());
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();
318
319 assert_eq!(p.to_string(), "a:b 123.00 = 246.00 € ; comment");
320 }
321}