ledger_parser/
model.rs

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