1use crate::*;
2use chrono::NaiveDate;
3use ledger_parser::{LedgerItem, Serializer, SerializerSettings, Tag, TagValue};
4use std::str::FromStr;
5use std::{fmt, io};
6
7#[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 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 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 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}