Skip to main content

ledger_parser/
model.rs

1use crate::ParseError;
2use crate::parser;
3use crate::serializer::*;
4use chrono::{NaiveDate, NaiveDateTime};
5use nom::Finish;
6use nom_language::error::convert_error;
7use ordered_float::NotNan;
8use rust_decimal::Decimal;
9use std::fmt;
10use std::str::FromStr;
11
12///
13/// Main document. Contains transactions and/or commodity prices.
14///
15#[derive(Debug, PartialEq, Eq, Clone)]
16pub struct Ledger {
17    pub items: Vec<LedgerItem>,
18}
19
20impl FromStr for Ledger {
21    type Err = ParseError;
22
23    fn from_str(input: &str) -> Result<Self, Self::Err> {
24        let result = parser::parse_ledger(input);
25        match result.finish() {
26            Ok((_, result)) => Ok(result),
27            Err(error) => Err(ParseError::String(convert_error(input, error))),
28        }
29    }
30}
31
32impl fmt::Display for Ledger {
33    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
34        write!(
35            f,
36            "{}",
37            self.to_string_pretty(&SerializerSettings::default())
38        )?;
39        Ok(())
40    }
41}
42
43#[non_exhaustive]
44#[derive(Debug, PartialEq, Eq, Clone)]
45pub enum LedgerItem {
46    EmptyLine,
47    LineComment(String),
48    Transaction(Transaction),
49    CommodityPrice(CommodityPrice),
50    Include(String),
51}
52
53impl fmt::Display for LedgerItem {
54    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
55        write!(
56            f,
57            "{}",
58            self.to_string_pretty(&SerializerSettings::default())
59        )?;
60        Ok(())
61    }
62}
63
64///
65/// Transaction.
66///
67#[derive(Debug, PartialEq, Eq, Clone)]
68pub struct Transaction {
69    pub status: Option<TransactionStatus>,
70    pub code: Option<String>,
71    pub description: Option<String>,
72    pub comment: Option<String>,
73    pub date: NaiveDate,
74    pub effective_date: Option<NaiveDate>,
75    pub posting_metadata: PostingMetadata,
76    pub postings: Vec<Posting>,
77}
78
79impl fmt::Display for Transaction {
80    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
81        write!(
82            f,
83            "{}",
84            self.to_string_pretty(&SerializerSettings::default())
85        )?;
86        Ok(())
87    }
88}
89
90#[derive(Debug, PartialEq, Eq, Clone, Copy)]
91pub enum TransactionStatus {
92    Pending,
93    Cleared,
94}
95
96impl fmt::Display for TransactionStatus {
97    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
98        write!(
99            f,
100            "{}",
101            self.to_string_pretty(&SerializerSettings::default())
102        )?;
103        Ok(())
104    }
105}
106
107#[derive(Debug, PartialEq, Eq, Clone)]
108pub struct Posting {
109    pub account: String,
110    pub reality: Reality,
111    pub amount: Option<PostingAmount>,
112    pub balance: Option<Balance>,
113    pub status: Option<TransactionStatus>,
114    pub comment: Option<String>,
115    pub metadata: PostingMetadata,
116}
117
118impl fmt::Display for Posting {
119    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
120        write!(
121            f,
122            "{}",
123            self.to_string_pretty(&SerializerSettings::default())
124        )?;
125        Ok(())
126    }
127}
128
129#[derive(Debug, PartialEq, Eq, Clone, Copy)]
130pub enum Reality {
131    Real,
132    BalancedVirtual,
133    UnbalancedVirtual,
134}
135
136#[derive(Debug, PartialEq, Eq, Clone)]
137pub struct PostingAmount {
138    pub amount: Amount,
139    pub lot_price: Option<Price>,
140    pub price: Option<Price>,
141}
142
143impl fmt::Display for PostingAmount {
144    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
145        write!(
146            f,
147            "{}",
148            self.to_string_pretty(&SerializerSettings::default())
149        )?;
150        Ok(())
151    }
152}
153
154#[derive(Debug, PartialEq, Eq, Clone)]
155pub struct Amount {
156    pub quantity: Decimal,
157    pub commodity: Commodity,
158}
159
160impl fmt::Display for Amount {
161    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
162        write!(
163            f,
164            "{}",
165            self.to_string_pretty(&SerializerSettings::default())
166        )?;
167        Ok(())
168    }
169}
170
171#[derive(Debug, PartialEq, Eq, Clone)]
172pub struct Commodity {
173    pub name: String,
174    pub position: CommodityPosition,
175}
176
177#[derive(Debug, PartialEq, Eq, Clone, Copy)]
178pub enum CommodityPosition {
179    Left,
180    Right,
181}
182
183#[derive(Debug, PartialEq, Eq, Clone)]
184pub enum Price {
185    Unit(Amount),
186    Total(Amount),
187}
188
189#[derive(Debug, PartialEq, Eq, Clone)]
190pub enum Balance {
191    Zero,
192    Amount(Amount),
193}
194
195impl fmt::Display for Balance {
196    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
197        write!(
198            f,
199            "{}",
200            self.to_string_pretty(&SerializerSettings::default())
201        )?;
202        Ok(())
203    }
204}
205
206///
207/// Commodity price.
208///
209#[derive(Debug, PartialEq, Eq, Clone)]
210pub struct CommodityPrice {
211    pub datetime: NaiveDateTime,
212    pub commodity_name: String,
213    pub amount: Amount,
214}
215
216impl fmt::Display for CommodityPrice {
217    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
218        write!(
219            f,
220            "{}",
221            self.to_string_pretty(&SerializerSettings::default())
222        )?;
223        Ok(())
224    }
225}
226
227///
228/// Posting metadata. Also appears on Transaction
229///
230#[derive(Debug, PartialEq, Eq, Clone)]
231pub struct PostingMetadata {
232    pub date: Option<NaiveDate>,
233    pub effective_date: Option<NaiveDate>,
234    pub tags: Vec<Tag>,
235}
236
237#[derive(Clone, Debug, PartialEq, Eq)]
238pub struct Tag {
239    pub name: String,
240    pub value: Option<TagValue>,
241}
242
243#[derive(Clone, Debug, PartialEq, Eq)]
244pub enum TagValue {
245    String(String),
246    Integer(i64),
247    Float(NotNan<f64>),
248    Date(NaiveDate),
249}
250
251impl fmt::Display for TagValue {
252    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
253        match self {
254            TagValue::String(v) => v.fmt(f),
255            TagValue::Integer(v) => v.fmt(f),
256            TagValue::Float(v) => v.fmt(f),
257            TagValue::Date(v) => write!(f, "[{v}]"),
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use chrono::NaiveDate;
266    use rust_decimal::Decimal;
267
268    #[test]
269    fn display_transaction_status() {
270        assert_eq!(format!("{}", TransactionStatus::Pending), "!");
271        assert_eq!(format!("{}", TransactionStatus::Cleared), "*");
272    }
273
274    #[test]
275    fn display_amount() {
276        assert_eq!(
277            format!(
278                "{}",
279                Amount {
280                    quantity: Decimal::new(4200, 2),
281                    commodity: Commodity {
282                        name: "€".to_owned(),
283                        position: CommodityPosition::Right,
284                    }
285                }
286            ),
287            "42.00 €"
288        );
289        assert_eq!(
290            format!(
291                "{}",
292                Amount {
293                    quantity: Decimal::new(4200, 2),
294                    commodity: Commodity {
295                        name: "USD".to_owned(),
296                        position: CommodityPosition::Left,
297                    }
298                }
299            ),
300            "USD42.00"
301        );
302    }
303
304    #[test]
305    fn display_commodity_price() {
306        let actual = format!(
307            "{}",
308            CommodityPrice {
309                datetime: NaiveDate::from_ymd_opt(2017, 11, 12)
310                    .unwrap()
311                    .and_hms_opt(12, 0, 0)
312                    .unwrap(),
313                commodity_name: "mBH".to_owned(),
314                amount: Amount {
315                    quantity: Decimal::new(500, 2),
316                    commodity: Commodity {
317                        name: "PLN".to_owned(),
318                        position: CommodityPosition::Right
319                    }
320                }
321            }
322        );
323        let expected = "P 2017-11-12 12:00:00 mBH 5.00 PLN";
324        assert_eq!(actual, expected);
325    }
326
327    #[test]
328    fn display_balance() {
329        assert_eq!(
330            format!(
331                "{}",
332                Balance::Amount(Amount {
333                    quantity: Decimal::new(4200, 2),
334                    commodity: Commodity {
335                        name: "€".to_owned(),
336                        position: CommodityPosition::Right,
337                    }
338                })
339            ),
340            "42.00 €"
341        );
342        assert_eq!(format!("{}", Balance::Zero), "0");
343    }
344
345    #[test]
346    fn display_posting() {
347        assert_eq!(
348            format!(
349                "{}",
350                Posting {
351                    account: "Assets:Checking".to_owned(),
352                    reality: Reality::Real,
353                    amount: Some(PostingAmount {
354                        amount: Amount {
355                            quantity: Decimal::new(4200, 2),
356                            commodity: Commodity {
357                                name: "USD".to_owned(),
358                                position: CommodityPosition::Left,
359                            }
360                        },
361                        lot_price: None,
362                        price: None,
363                    }),
364                    balance: Some(Balance::Amount(Amount {
365                        quantity: Decimal::new(5000, 2),
366                        commodity: Commodity {
367                            name: "USD".to_owned(),
368                            position: CommodityPosition::Left,
369                        }
370                    })),
371                    status: Some(TransactionStatus::Cleared),
372                    comment: Some("asdf".to_owned()),
373                    metadata: PostingMetadata {
374                        date: None,
375                        effective_date: None,
376                        tags: vec![],
377                    },
378                }
379            ),
380            "* Assets:Checking  USD42.00 = USD50.00\n  ; asdf"
381        );
382    }
383
384    #[test]
385    fn display_transaction() {
386        let actual = format!(
387            "{}",
388            Transaction {
389                comment: Some("Comment Line 1\nComment Line 2".to_owned()),
390                date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(),
391                effective_date: Some(NaiveDate::from_ymd_opt(2018, 10, 14).unwrap()),
392                status: Some(TransactionStatus::Pending),
393                code: Some("123".to_owned()),
394                description: Some("Marek Ogarek".to_owned()),
395                posting_metadata: PostingMetadata {
396                    date: None,
397                    effective_date: None,
398                    tags: vec![],
399                },
400                postings: vec![
401                    Posting {
402                        account: "TEST:ABC 123".to_owned(),
403                        reality: Reality::Real,
404                        amount: Some(PostingAmount {
405                            amount: Amount {
406                                quantity: Decimal::new(120, 2),
407                                commodity: Commodity {
408                                    name: "$".to_owned(),
409                                    position: CommodityPosition::Left
410                                }
411                            },
412                            lot_price: None,
413                            price: None
414                        }),
415                        balance: None,
416                        status: None,
417                        comment: Some("dd".to_owned()),
418                        metadata: PostingMetadata {
419                            date: None,
420                            effective_date: None,
421                            tags: vec![],
422                        },
423                    },
424                    Posting {
425                        account: "TEST:ABC 123".to_owned(),
426                        reality: Reality::Real,
427                        amount: Some(PostingAmount {
428                            amount: Amount {
429                                quantity: Decimal::new(120, 2),
430                                commodity: Commodity {
431                                    name: "$".to_owned(),
432                                    position: CommodityPosition::Left
433                                }
434                            },
435                            lot_price: None,
436                            price: None
437                        }),
438                        balance: None,
439                        status: None,
440                        comment: None,
441                        metadata: PostingMetadata {
442                            date: None,
443                            effective_date: None,
444                            tags: vec![],
445                        },
446                    }
447                ]
448            },
449        );
450        let expected = r#"2018-10-01=2018-10-14 ! (123) Marek Ogarek
451  ; Comment Line 1
452  ; Comment Line 2
453  TEST:ABC 123  $1.20
454  ; dd
455  TEST:ABC 123  $1.20"#;
456        assert_eq!(actual, expected);
457    }
458
459    #[test]
460    fn display_ledger() {
461        let actual = format!(
462            "{}",
463            Ledger {
464                items: vec![
465                    LedgerItem::Transaction(Transaction {
466                        comment: Some("Comment Line 1\nComment Line 2".to_owned()),
467                        date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(),
468                        effective_date: Some(NaiveDate::from_ymd_opt(2018, 10, 14).unwrap()),
469                        status: Some(TransactionStatus::Pending),
470                        code: Some("123".to_owned()),
471                        description: Some("Marek Ogarek".to_owned()),
472                        posting_metadata: PostingMetadata {
473                            date: None,
474                            effective_date: None,
475                            tags: vec![],
476                        },
477                        postings: vec![
478                            Posting {
479                                account: "TEST:ABC 123".to_owned(),
480                                reality: Reality::Real,
481                                amount: Some(PostingAmount {
482                                    amount: Amount {
483                                        quantity: Decimal::new(120, 2),
484                                        commodity: Commodity {
485                                            name: "$".to_owned(),
486                                            position: CommodityPosition::Left
487                                        }
488                                    },
489                                    lot_price: None,
490                                    price: None
491                                }),
492                                balance: None,
493                                status: None,
494                                comment: Some("dd".to_owned()),
495                                metadata: PostingMetadata {
496                                    date: None,
497                                    effective_date: None,
498                                    tags: vec![],
499                                },
500                            },
501                            Posting {
502                                account: "TEST:ABC 123".to_owned(),
503                                reality: Reality::Real,
504                                amount: Some(PostingAmount {
505                                    amount: Amount {
506                                        quantity: Decimal::new(120, 2),
507                                        commodity: Commodity {
508                                            name: "$".to_owned(),
509                                            position: CommodityPosition::Left
510                                        }
511                                    },
512                                    lot_price: None,
513                                    price: None
514                                }),
515                                balance: None,
516                                status: None,
517                                comment: None,
518                                metadata: PostingMetadata {
519                                    date: None,
520                                    effective_date: None,
521                                    tags: vec![],
522                                },
523                            }
524                        ]
525                    }),
526                    LedgerItem::EmptyLine,
527                    LedgerItem::Transaction(Transaction {
528                        comment: None,
529                        date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(),
530                        effective_date: Some(NaiveDate::from_ymd_opt(2018, 10, 14).unwrap()),
531                        posting_metadata: PostingMetadata {
532                            date: None,
533                            effective_date: None,
534                            tags: vec![],
535                        },
536                        status: Some(TransactionStatus::Pending),
537                        code: Some("123".to_owned()),
538                        description: Some("Marek Ogarek".to_owned()),
539                        postings: vec![
540                            Posting {
541                                account: "TEST:ABC 123".to_owned(),
542                                reality: Reality::Real,
543                                amount: Some(PostingAmount {
544                                    amount: Amount {
545                                        quantity: Decimal::new(120, 2),
546                                        commodity: Commodity {
547                                            name: "$".to_owned(),
548                                            position: CommodityPosition::Left
549                                        }
550                                    },
551                                    lot_price: Some(Price::Unit(Amount {
552                                        quantity: Decimal::new(500, 2),
553                                        commodity: Commodity {
554                                            name: "PLN".to_owned(),
555                                            position: CommodityPosition::Right
556                                        }
557                                    })),
558                                    price: Some(Price::Unit(Amount {
559                                        quantity: Decimal::new(600, 2),
560                                        commodity: Commodity {
561                                            name: "PLN".to_owned(),
562                                            position: CommodityPosition::Right
563                                        }
564                                    }))
565                                }),
566                                balance: None,
567                                status: None,
568                                comment: None,
569                                metadata: PostingMetadata {
570                                    date: None,
571                                    effective_date: None,
572                                    tags: vec![],
573                                },
574                            },
575                            Posting {
576                                account: "TEST:ABC 123".to_owned(),
577                                reality: Reality::Real,
578                                amount: Some(PostingAmount {
579                                    amount: Amount {
580                                        quantity: Decimal::new(120, 2),
581                                        commodity: Commodity {
582                                            name: "$".to_owned(),
583                                            position: CommodityPosition::Left
584                                        }
585                                    },
586                                    lot_price: Some(Price::Total(Amount {
587                                        quantity: Decimal::new(500, 2),
588                                        commodity: Commodity {
589                                            name: "PLN".to_owned(),
590                                            position: CommodityPosition::Right
591                                        }
592                                    })),
593                                    price: Some(Price::Total(Amount {
594                                        quantity: Decimal::new(600, 2),
595                                        commodity: Commodity {
596                                            name: "PLN".to_owned(),
597                                            position: CommodityPosition::Right
598                                        }
599                                    }))
600                                }),
601                                balance: None,
602                                status: None,
603                                comment: None,
604                                metadata: PostingMetadata {
605                                    date: None,
606                                    effective_date: None,
607                                    tags: vec![],
608                                },
609                            }
610                        ]
611                    }),
612                    LedgerItem::EmptyLine,
613                    LedgerItem::CommodityPrice(CommodityPrice {
614                        datetime: NaiveDate::from_ymd_opt(2017, 11, 12)
615                            .unwrap()
616                            .and_hms_opt(12, 0, 0)
617                            .unwrap(),
618                        commodity_name: "mBH".to_owned(),
619                        amount: Amount {
620                            quantity: Decimal::new(500, 2),
621                            commodity: Commodity {
622                                name: "PLN".to_owned(),
623                                position: CommodityPosition::Right
624                            }
625                        }
626                    }),
627                ]
628            }
629        );
630        let expected = r#"2018-10-01=2018-10-14 ! (123) Marek Ogarek
631  ; Comment Line 1
632  ; Comment Line 2
633  TEST:ABC 123  $1.20
634  ; dd
635  TEST:ABC 123  $1.20
636
6372018-10-01=2018-10-14 ! (123) Marek Ogarek
638  TEST:ABC 123  $1.20 {5.00 PLN} @ 6.00 PLN
639  TEST:ABC 123  $1.20 {{5.00 PLN}} @@ 6.00 PLN
640
641P 2017-11-12 12:00:00 mBH 5.00 PLN
642"#;
643        assert_eq!(actual, expected);
644    }
645}