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#[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#[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#[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#[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}