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