ledger_utils/
simplified_ledger.rs

1use crate::*;
2use chrono::NaiveDate;
3use ledger_parser::{LedgerItem, Serializer, SerializerSettings, Tag, TagValue};
4use std::str::FromStr;
5use std::{fmt, io};
6
7///
8/// Main document. Contains transactions and/or commodity prices.
9///
10#[derive(Debug, PartialEq, Eq, Clone)]
11pub struct Ledger {
12    pub commodity_prices: Vec<ledger_parser::CommodityPrice>,
13    pub transactions: Vec<Transaction>,
14}
15
16impl fmt::Display for Ledger {
17    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
18        write!(
19            f,
20            "{}",
21            self.to_string_pretty(&SerializerSettings::default())
22        )?;
23        Ok(())
24    }
25}
26
27impl Serializer for Ledger {
28    fn write<W>(&self, writer: &mut W, settings: &SerializerSettings) -> Result<(), io::Error>
29    where
30        W: io::Write,
31    {
32        let mut first = true;
33
34        for commodity_price in &self.commodity_prices {
35            first = false;
36            commodity_price.write(writer, settings)?;
37            writeln!(writer)?;
38        }
39
40        for transaction in &self.transactions {
41            if !first {
42                writeln!(writer)?;
43            }
44
45            first = false;
46            transaction.write(writer, settings)?;
47            writeln!(writer)?;
48        }
49
50        Ok(())
51    }
52}
53
54#[non_exhaustive]
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum Error {
57    ParseError(ledger_parser::ParseError),
58    IncompleteTransaction(Box<ledger_parser::Posting>),
59    UnbalancedTransaction(Box<ledger_parser::Transaction>),
60    BalanceAssertionFailed(Box<ledger_parser::Transaction>),
61    ZeroBalanceAssertionFailed(Box<ledger_parser::Transaction>),
62    UnbalancedVirtualWithNoAmount(Box<ledger_parser::Transaction>),
63    ZeroBalanceMultipleCurrencies(Box<ledger_parser::Transaction>),
64}
65
66impl std::error::Error for Error {}
67
68impl fmt::Display for Error {
69    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
70        match self {
71            Error::ParseError(p) => {
72                write!(f, "Parse error:\n{}", p)
73            }
74            Error::IncompleteTransaction(p) => {
75                write!(f, "Incomplete transaction:\n{}", p)
76            }
77            Error::UnbalancedTransaction(t) => {
78                write!(f, "Unbalanced transaction:\n{}", t)
79            }
80            Error::BalanceAssertionFailed(t) => {
81                write!(f, "Balance assertion failed:\n{}", t)
82            }
83            Error::ZeroBalanceAssertionFailed(t) => {
84                write!(f, "Zero balance assertion failed:\n{}", t)
85            }
86            Error::UnbalancedVirtualWithNoAmount(t) => {
87                write!(f, "Unbalanced virtual posting with no amount:\n{}", t)
88            }
89            Error::ZeroBalanceMultipleCurrencies(t) => {
90                write!(f, "Zero balance with multiple currencies:\n{}", t)
91            }
92        }
93    }
94}
95
96impl From<ledger_parser::ParseError> for Error {
97    fn from(e: ledger_parser::ParseError) -> Self {
98        Error::ParseError(e)
99    }
100}
101
102impl FromStr for Ledger {
103    type Err = Error;
104
105    fn from_str(input: &str) -> Result<Self, Self::Err> {
106        input.parse::<ledger_parser::Ledger>()?.try_into()
107    }
108}
109
110impl TryFrom<ledger_parser::Ledger> for Ledger {
111    type Error = Error;
112
113    /// Fails if any transactions are unbalanced, any balance assertions fail, or if an unbalanced
114    /// virtual posting (account name in `()`) has no amount.
115    ///
116    /// "Balance assertions" are postings with both amount and balance provided. The calculated
117    /// amount using the balance must match the given amount.
118    fn try_from(ledger: ledger_parser::Ledger) -> Result<Self, Self::Error> {
119        let mut transactions = Vec::<ledger_parser::Transaction>::new();
120        let mut commodity_prices = Vec::<ledger_parser::CommodityPrice>::new();
121
122        let mut current_comment: Option<String> = None;
123
124        for item in ledger.items {
125            match item {
126                LedgerItem::EmptyLine => {
127                    current_comment = None;
128                }
129                LedgerItem::LineComment(comment) => {
130                    if let Some(ref mut c) = current_comment {
131                        c.push('\n');
132                        c.push_str(&comment);
133                    } else {
134                        current_comment = Some(comment);
135                    }
136                }
137                LedgerItem::Transaction(mut transaction) => {
138                    if let Some(current_comment) = current_comment {
139                        let mut full_comment = current_comment;
140                        if let Some(ref transaction_comment) = transaction.comment {
141                            full_comment.push('\n');
142                            full_comment.push_str(transaction_comment);
143                        }
144                        transaction.comment = Some(full_comment);
145                    }
146                    current_comment = None;
147
148                    transactions.push(transaction);
149                }
150                LedgerItem::CommodityPrice(commodity_price) => {
151                    current_comment = None;
152                    commodity_prices.push(commodity_price);
153                }
154                _ => {}
155            }
156        }
157
158        calculate_amounts::calculate_amounts_from_balances(
159            &mut transactions,
160            &mut commodity_prices,
161        )?;
162
163        Ok(Ledger {
164            transactions: transactions
165                .into_iter()
166                .map(Transaction::try_from)
167                .collect::<Result<_, _>>()?,
168            commodity_prices,
169        })
170    }
171}
172
173#[derive(Debug, PartialEq, Eq, Clone)]
174pub struct Transaction {
175    pub comment: Option<String>,
176    pub date: NaiveDate,
177    pub effective_date: NaiveDate,
178    pub status: Option<TransactionStatus>,
179    pub code: Option<String>,
180    pub description: String,
181    pub postings: Vec<Posting>,
182}
183
184impl TryFrom<ledger_parser::Transaction> for Transaction {
185    type Error = Error;
186
187    /// Fails if any transactions are unbalanced, or if an unbalanced virtual posting
188    /// (account name in `()`) has no amount.
189    ///
190    /// Ignores `balance`s. Fails if they are necessary to fill in any omitted `amount`s.
191    fn try_from(mut transaction: ledger_parser::Transaction) -> Result<Self, Self::Error> {
192        calculate_amounts::calculate_omitted_amounts(&mut transaction)?;
193
194        Ok(Transaction {
195            comment: transaction.comment,
196            date: transaction.date,
197            effective_date: transaction.effective_date.unwrap_or(transaction.date),
198            status: transaction.status,
199            code: transaction.code,
200            description: transaction.description,
201            postings: transaction
202                .postings
203                .into_iter()
204                .map(OptionalDatePosting::try_from)
205                .map(|res| {
206                    res.map(|posting| {
207                        posting.fill_dates(transaction.date, transaction.effective_date)
208                    })
209                })
210                .collect::<Result<_, _>>()?,
211        })
212    }
213}
214
215impl Serializer for Transaction {
216    fn write<W>(&self, writer: &mut W, settings: &SerializerSettings) -> Result<(), io::Error>
217    where
218        W: io::Write,
219    {
220        write!(writer, "{}", self.date.format("%Y-%m-%d"))?;
221
222        if self.effective_date != self.date {
223            write!(writer, "={}", self.effective_date.format("%Y-%m-%d"))?;
224        }
225
226        if let Some(ref status) = self.status {
227            write!(writer, " ")?;
228            status.write(writer, settings)?;
229        }
230
231        if let Some(ref code) = self.code {
232            write!(writer, " ({})", code)?;
233        }
234
235        if !self.description.is_empty() {
236            write!(writer, " {}", self.description)?;
237        }
238
239        if let Some(ref comment) = self.comment {
240            for comment in comment.split('\n') {
241                write!(writer, "{}{}; {}", settings.eol, settings.indent, comment)?;
242            }
243        }
244
245        for posting in &self.postings {
246            write!(writer, "{}{}", settings.eol, settings.indent)?;
247            posting.elide_dates(self).write(writer, settings)?;
248        }
249
250        Ok(())
251    }
252}
253
254impl fmt::Display for Transaction {
255    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
256        write!(
257            f,
258            "{}",
259            self.to_string_pretty(&SerializerSettings::default())
260        )?;
261        Ok(())
262    }
263}
264
265#[derive(Debug, PartialEq, Eq, Clone)]
266pub struct OptionalDatePosting {
267    pub date: Option<NaiveDate>,
268    pub effective_date: Option<NaiveDate>,
269    pub account: String,
270    pub reality: Reality,
271    pub amount: Amount,
272    pub status: Option<TransactionStatus>,
273    pub comment: Option<String>,
274    pub tags: Vec<Tag>,
275}
276
277#[derive(Debug, PartialEq, Eq, Clone)]
278pub struct Posting {
279    pub date: NaiveDate,
280    pub effective_date: NaiveDate,
281    pub account: String,
282    pub reality: Reality,
283    pub amount: Amount,
284    pub status: Option<TransactionStatus>,
285    pub comment: Option<String>,
286    pub tags: Vec<Tag>,
287}
288
289impl OptionalDatePosting {
290    pub fn fill_dates(self, txn_date: NaiveDate, txn_effective_date: Option<NaiveDate>) -> Posting {
291        Posting {
292            date: self.date.unwrap_or(txn_date),
293            effective_date: self
294                .effective_date
295                .or(self.date)
296                .or(txn_effective_date)
297                .unwrap_or(txn_date),
298            account: self.account,
299            reality: self.reality,
300            amount: self.amount,
301            status: self.status,
302            comment: self.comment,
303            tags: self.tags,
304        }
305    }
306}
307
308impl Posting {
309    pub fn elide_dates(&self, txn: &Transaction) -> OptionalDatePosting {
310        let date = if self.date != txn.date {
311            Some(self.date)
312        } else {
313            None
314        };
315
316        let effective_date = if self.effective_date != date.unwrap_or(txn.effective_date) {
317            Some(self.effective_date)
318        } else {
319            None
320        };
321
322        OptionalDatePosting {
323            date,
324            effective_date,
325            account: self.account.clone(),
326            reality: self.reality,
327            amount: self.amount.clone(),
328            status: self.status,
329            comment: self.comment.clone(),
330            tags: self.tags.clone(),
331        }
332    }
333}
334
335impl TryFrom<ledger_parser::Posting> for OptionalDatePosting {
336    type Error = Error;
337
338    /// Fails unless all `amount`s are `Some`. Ignores `balance`s.
339    fn try_from(posting: ledger_parser::Posting) -> Result<Self, Self::Error> {
340        if let Some(ledger_parser::PostingAmount { amount, .. }) = posting.amount {
341            Ok(Self {
342                date: posting.metadata.date,
343                effective_date: posting.metadata.effective_date,
344                account: posting.account,
345                reality: posting.reality,
346                status: posting.status,
347                comment: posting.comment,
348                amount,
349                tags: posting.metadata.tags,
350            })
351        } else {
352            Err(Error::IncompleteTransaction(posting.into()))
353        }
354    }
355}
356
357impl Serializer for OptionalDatePosting {
358    fn write<W>(&self, writer: &mut W, settings: &SerializerSettings) -> Result<(), io::Error>
359    where
360        W: io::Write,
361    {
362        if let Some(ref status) = self.status {
363            status.write(writer, settings)?;
364            write!(writer, " ")?;
365        }
366
367        match self.reality {
368            Reality::Real => write!(writer, "{}", self.account)?,
369            Reality::BalancedVirtual => write!(writer, "[{}]", self.account)?,
370            Reality::UnbalancedVirtual => write!(writer, "({})", self.account)?,
371        }
372
373        write!(writer, "  ")?;
374        self.amount.write(writer, settings)?;
375
376        let mut first = true;
377
378        if let Some(ref comment) = self.comment {
379            for comment in comment.split('\n') {
380                if first {
381                    first = false;
382                    write!(writer, "  ")?;
383                } else {
384                    write!(writer, "{}{}", settings.eol, settings.indent)?;
385                }
386                write!(writer, "; {}", comment)?;
387            }
388        }
389
390        if self.date.is_some() || self.effective_date.is_some() {
391            if first {
392                first = false;
393                write!(writer, "  ")?;
394            } else {
395                write!(writer, "{}{}", settings.eol, settings.indent)?;
396            }
397            write!(writer, "; [")?;
398            if let Some(d) = self.date {
399                write!(writer, "{d}")?;
400            }
401            if let Some(d) = self.effective_date {
402                write!(writer, "={d}")?;
403            }
404            write!(writer, "]")?;
405        }
406
407        let (tags, tags_with_values): (Vec<_>, Vec<_>) =
408            self.tags.iter().partition(|t| t.value.is_none());
409
410        if !tags.is_empty() {
411            if first {
412                first = false;
413                write!(writer, "  ")?;
414            } else {
415                write!(writer, "{}{}", settings.eol, settings.indent)?;
416            }
417            write!(writer, "; :")?;
418            for tag in tags {
419                write!(writer, "{}:", tag.name)?;
420            }
421        }
422
423        for tag in tags_with_values {
424            if first {
425                first = false;
426                write!(writer, "  ")?;
427            } else {
428                write!(writer, "{}{}", settings.eol, settings.indent)?;
429            }
430            match &tag.value {
431                Some(TagValue::String(s)) => write!(writer, "; {}: {s}", tag.name)?,
432                Some(other_type) => write!(writer, "; {}:: {other_type}", tag.name)?,
433                None => unreachable!(),
434            }
435        }
436
437        Ok(())
438    }
439}
440
441impl fmt::Display for OptionalDatePosting {
442    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
443        write!(
444            f,
445            "{}",
446            self.to_string_pretty(&SerializerSettings::default())
447        )?;
448        Ok(())
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use chrono::NaiveDate;
456    use ledger_parser::{Amount, Commodity, CommodityPosition, CommodityPrice, Reality};
457    use rust_decimal::Decimal;
458
459    #[test]
460    fn test_handle_commodity_exchange() {
461        let ledger = ledger_parser::parse(
462            r#"
4632022-02-19 Exchange
464  DollarAccount   $1.00
465  PLNAccount  -4.00 PLN
466"#,
467        )
468        .unwrap();
469        let simplified_ledger: Result<Ledger, _> = ledger.try_into();
470        assert!(simplified_ledger.is_ok());
471        assert_eq!(simplified_ledger.unwrap().commodity_prices.len(), 1);
472    }
473
474    #[test]
475    fn test_handle_commodity_exchange2() {
476        let ledger = ledger_parser::parse(
477            r#"
4782020-02-01 Buy ADA
479  assets:cc:ada          2000 ADA @ $0.02
480  assets:bank:checking                   $-40
481"#,
482        )
483        .unwrap();
484        let simplified_ledger: Result<Ledger, _> = ledger.try_into();
485        assert!(simplified_ledger.is_ok());
486        assert_eq!(simplified_ledger.unwrap().commodity_prices.len(), 1);
487    }
488
489    #[test]
490    fn display_ledger() {
491        let actual = format!(
492            "{}",
493            Ledger {
494                transactions: vec![
495                    Transaction {
496                        comment: Some("Comment Line 1\nComment Line 2".to_string()),
497                        date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(),
498                        effective_date: NaiveDate::from_ymd_opt(2018, 10, 14).unwrap(),
499                        status: Some(TransactionStatus::Pending),
500                        code: Some("123".to_string()),
501                        description: "Marek Ogarek".to_string(),
502                        postings: vec![
503                            Posting {
504                                date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(),
505                                effective_date: NaiveDate::from_ymd_opt(2018, 10, 14).unwrap(),
506                                account: "TEST:ABC 123".to_string(),
507                                reality: Reality::Real,
508                                amount: Amount {
509                                    quantity: Decimal::new(120, 2),
510                                    commodity: Commodity {
511                                        name: "$".to_string(),
512                                        position: CommodityPosition::Left
513                                    }
514                                },
515                                status: None,
516                                comment: Some("dd".to_string()),
517                                tags: vec![],
518                            },
519                            Posting {
520                                date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(),
521                                effective_date: NaiveDate::from_ymd_opt(2018, 10, 14).unwrap(),
522                                account: "TEST:ABC 123".to_string(),
523                                reality: Reality::Real,
524                                amount: Amount {
525                                    quantity: Decimal::new(120, 2),
526                                    commodity: Commodity {
527                                        name: "$".to_string(),
528                                        position: CommodityPosition::Left
529                                    }
530                                },
531                                status: None,
532                                comment: None,
533                                tags: vec![
534                                    Tag {
535                                        name: "Tag1".to_string(),
536                                        value: None
537                                    },
538                                    Tag {
539                                        name: "Tag2".to_string(),
540                                        value: None
541                                    }
542                                ],
543                            }
544                        ]
545                    },
546                    Transaction {
547                        comment: None,
548                        date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(),
549                        effective_date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(),
550                        status: None,
551                        code: None,
552                        description: "Marek Ogarek".to_string(),
553                        postings: vec![
554                            Posting {
555                                date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(),
556                                effective_date: NaiveDate::from_ymd_opt(2018, 10, 1).unwrap(),
557                                account: "TEST:ABC 123".to_string(),
558                                reality: Reality::Real,
559                                amount: Amount {
560                                    quantity: Decimal::new(120, 2),
561                                    commodity: Commodity {
562                                        name: "$".to_string(),
563                                        position: CommodityPosition::Left
564                                    }
565                                },
566                                status: None,
567                                comment: None,
568                                tags: vec![Tag {
569                                    name: "DateTag".to_string(),
570                                    value: Some(TagValue::Date(
571                                        NaiveDate::from_ymd_opt(2017, 12, 31).unwrap()
572                                    ))
573                                }],
574                            },
575                            Posting {
576                                date: NaiveDate::from_ymd_opt(2017, 12, 30).unwrap(),
577                                effective_date: NaiveDate::from_ymd_opt(2017, 12, 30).unwrap(),
578                                account: "TEST:ABC 123".to_string(),
579                                reality: Reality::Real,
580                                amount: Amount {
581                                    quantity: Decimal::new(120, 2),
582                                    commodity: Commodity {
583                                        name: "$".to_string(),
584                                        position: CommodityPosition::Left
585                                    }
586                                },
587                                status: None,
588                                comment: None,
589                                tags: vec![],
590                            }
591                        ]
592                    }
593                ],
594                commodity_prices: vec![CommodityPrice {
595                    datetime: NaiveDate::from_ymd_opt(2017, 11, 12)
596                        .unwrap()
597                        .and_hms_opt(12, 00, 00)
598                        .unwrap(),
599                    commodity_name: "mBH".to_string(),
600                    amount: Amount {
601                        quantity: Decimal::new(500, 2),
602                        commodity: Commodity {
603                            name: "PLN".to_string(),
604                            position: CommodityPosition::Right
605                        }
606                    }
607                }]
608            }
609        );
610        let expected = r#"P 2017-11-12 12:00:00 mBH 5.00 PLN
611
6122018-10-01=2018-10-14 ! (123) Marek Ogarek
613  ; Comment Line 1
614  ; Comment Line 2
615  TEST:ABC 123  $1.20  ; dd
616  TEST:ABC 123  $1.20  ; :Tag1:Tag2:
617
6182018-10-01 Marek Ogarek
619  TEST:ABC 123  $1.20  ; DateTag:: [2017-12-31]
620  TEST:ABC 123  $1.20  ; [2017-12-30]
621"#;
622        assert_eq!(actual, expected);
623    }
624}