beancount_parser_2/
transaction.rs

1use std::borrow::Borrow;
2use std::collections::HashSet;
3use std::fmt::{Display, Formatter};
4use std::sync::Arc;
5
6use nom::{
7    branch::alt,
8    bytes::complete::{tag, take_while},
9    character::complete::satisfy,
10    character::complete::{char as char_tag, space0, space1},
11    combinator::{cut, iterator, map, opt, success, value},
12    sequence::{delimited, preceded, separated_pair, terminated, tuple},
13    Parser,
14};
15
16use crate::{
17    account, account::Account, amount, amount::Amount, date, empty_line, end_of_line, metadata,
18    string, Date, Decimal, IResult, Span,
19};
20
21/// A transaction
22///
23/// It notably contains a list of [`Posting`]
24///
25/// # Example
26/// ```
27/// # use beancount_parser_2::{DirectiveContent};
28/// let input = r#"
29/// 2022-05-22 * "Grocery store" "Grocery shopping" #food
30///   Assets:Cash           -10 CHF
31///   Expenses:Groceries
32/// "#;
33///
34/// let beancount = beancount_parser_2::parse::<f64>(input).unwrap();
35/// let DirectiveContent::Transaction(trx) = &beancount.directives[0].content else {
36///   unreachable!("was not a transaction")
37/// };
38/// assert_eq!(trx.flag, Some('*'));
39/// assert_eq!(trx.payee.as_deref(), Some("Grocery store"));
40/// assert_eq!(trx.narration.as_deref(), Some("Grocery shopping"));
41/// assert!(trx.tags.contains("food"));
42/// assert_eq!(trx.postings.len(), 2);
43/// ```
44#[derive(Debug, Clone)]
45#[non_exhaustive]
46pub struct Transaction<D> {
47    /// Transaction flag (`*` or `!` or `None` when using the `txn` keyword)
48    pub flag: Option<char>,
49    /// Payee (if present)
50    pub payee: Option<String>,
51    /// Narration (if present)
52    pub narration: Option<String>,
53    /// Set of tags
54    pub tags: HashSet<Tag>,
55    /// Set of links
56    pub links: HashSet<Link>,
57    /// Postings
58    pub postings: Vec<Posting<D>>,
59}
60
61/// A transaction posting
62///
63/// # Example
64/// ```
65/// # use beancount_parser_2::{DirectiveContent, PostingPrice};
66/// let input = r#"
67/// 2022-05-22 * "Grocery shopping"
68///   Assets:Cash           1 CHF {2 PLN} @ 3 EUR
69///   Expenses:Groceries
70/// "#;
71///
72/// let beancount = beancount_parser_2::parse::<f64>(input).unwrap();
73/// let DirectiveContent::Transaction(trx) = &beancount.directives[0].content else {
74///   unreachable!("was not a transaction")
75/// };
76/// let posting = &trx.postings[0];
77/// assert_eq!(posting.account.as_str(), "Assets:Cash");
78/// assert_eq!(posting.amount.as_ref().unwrap().value, 1.0);
79/// assert_eq!(posting.amount.as_ref().unwrap().currency.as_str(), "CHF");
80/// assert_eq!(posting.cost.as_ref().unwrap().amount.as_ref().unwrap().value, 2.0);
81/// assert_eq!(posting.cost.as_ref().unwrap().amount.as_ref().unwrap().currency.as_str(), "PLN");
82/// let Some(PostingPrice::Unit(price)) = &posting.price else {
83///   unreachable!("no price");
84/// };
85/// assert_eq!(price.value, 3.0);
86/// assert_eq!(price.currency.as_str(), "EUR");
87/// ```
88#[derive(Debug, Clone)]
89#[non_exhaustive]
90pub struct Posting<D> {
91    /// Transaction flag (`*` or `!` or `None` when absent)
92    pub flag: Option<char>,
93    /// Account modified by the posting
94    pub account: Account,
95    /// Amount being added to the account
96    pub amount: Option<Amount<D>>,
97    /// Cost (content within `{` and `}`)
98    pub cost: Option<Cost<D>>,
99    /// Price (`@` or `@@`) syntax
100    pub price: Option<PostingPrice<D>>,
101    /// The metadata attached to the posting
102    pub metadata: metadata::Map<D>,
103}
104
105/// Cost of a posting
106///
107/// It is the amount within `{` and `}`.
108#[derive(Debug, Clone)]
109#[non_exhaustive]
110pub struct Cost<D> {
111    /// Cost basis of the posting
112    pub amount: Option<Amount<D>>,
113    /// The date of this cost basis
114    pub date: Option<Date>,
115}
116
117/// Price of a posting
118///
119/// It is the amount following the `@` or `@@` symbols
120#[derive(Debug, Clone)]
121pub enum PostingPrice<D> {
122    /// Unit cost (`@`)
123    Unit(Amount<D>),
124    /// Total cost (`@@`)
125    Total(Amount<D>),
126}
127
128/// Transaction tag
129///
130/// # Example
131/// ```
132/// # use beancount_parser_2::{DirectiveContent};
133/// let input = r#"
134/// 2022-05-22 * "Grocery store" "Grocery shopping" #food
135///   Assets:Cash           -10 CHF
136///   Expenses:Groceries
137/// "#;
138///
139/// let beancount = beancount_parser_2::parse::<f64>(input).unwrap();
140/// let DirectiveContent::Transaction(trx) = &beancount.directives[0].content else {
141///   unreachable!("was not a transaction")
142/// };
143/// assert!(trx.tags.contains("food"));
144/// ```
145#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
146pub struct Tag(Arc<str>);
147
148impl Tag {
149    /// Returns underlying string representation
150    #[must_use]
151    pub fn as_str(&self) -> &str {
152        &self.0
153    }
154}
155
156impl Display for Tag {
157    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
158        Display::fmt(&self.0, f)
159    }
160}
161
162impl AsRef<str> for Tag {
163    fn as_ref(&self) -> &str {
164        self.0.as_ref()
165    }
166}
167
168impl Borrow<str> for Tag {
169    fn borrow(&self) -> &str {
170        self.0.borrow()
171    }
172}
173
174/// Transaction link
175///
176/// # Example
177/// ```
178/// # use beancount_parser_2::{DirectiveContent};
179/// let input = r#"
180/// 2014-02-05 * "Invoice for January" ^invoice-pepe-studios-jan14
181///    Income:Clients:PepeStudios           -8450.00 USD
182///    Assets:AccountsReceivable
183/// "#;
184///
185/// let beancount = beancount_parser_2::parse::<f64>(input).unwrap();
186/// let DirectiveContent::Transaction(trx) = &beancount.directives[0].content else {
187///   unreachable!("was not a transaction")
188/// };
189/// assert!(trx.links.contains("invoice-pepe-studios-jan14"));
190/// ```
191#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
192pub struct Link(Arc<str>);
193
194impl Link {
195    /// Returns underlying string representation
196    #[must_use]
197    pub fn as_str(&self) -> &str {
198        &self.0
199    }
200}
201
202impl Display for Link {
203    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
204        Display::fmt(&self.0, f)
205    }
206}
207
208impl AsRef<str> for Link {
209    fn as_ref(&self) -> &str {
210        self.0.as_ref()
211    }
212}
213
214impl Borrow<str> for Link {
215    fn borrow(&self) -> &str {
216        self.0.borrow()
217    }
218}
219
220#[allow(clippy::type_complexity)]
221pub(crate) fn parse<D: Decimal>(
222    input: Span<'_>,
223) -> IResult<'_, (Transaction<D>, metadata::Map<D>)> {
224    let (input, flag) = alt((map(flag, Some), value(None, tag("txn"))))(input)?;
225    cut(do_parse(flag))(input)
226}
227
228fn flag(input: Span<'_>) -> IResult<'_, char> {
229    satisfy(|c: char| !c.is_ascii_lowercase())(input)
230}
231
232fn do_parse<D: Decimal>(
233    flag: Option<char>,
234) -> impl Fn(Span<'_>) -> IResult<'_, (Transaction<D>, metadata::Map<D>)> {
235    move |input| {
236        let (input, payee_and_narration) = opt(preceded(space1, payee_and_narration))(input)?;
237        let (input, (tags, links)) = tags_and_links(input)?;
238        let (input, _) = end_of_line(input)?;
239        let (input, metadata) = metadata::parse(input)?;
240        let mut iter = iterator(input, alt((posting.map(Some), empty_line.map(|_| None))));
241        let postings = iter.flatten().collect();
242        let (input, _) = iter.finish()?;
243        let narration = payee_and_narration.map(|(_, n)| n).map(ToOwned::to_owned);
244        let payee = payee_and_narration
245            .and_then(|(p, _)| p)
246            .map(ToOwned::to_owned);
247        Ok((
248            input,
249            (
250                Transaction {
251                    flag,
252                    payee,
253                    narration,
254                    tags,
255                    links,
256                    postings,
257                },
258                metadata,
259            ),
260        ))
261    }
262}
263
264pub(super) enum TagOrLink {
265    Tag(Tag),
266    Link(Link),
267}
268
269pub(super) fn parse_tag(input: Span<'_>) -> IResult<'_, Tag> {
270    map(
271        preceded(
272            char_tag('#'),
273            take_while(|c: char| c.is_alphanumeric() || c == '-' || c == '_'),
274        ),
275        |s: Span<'_>| Tag((*s.fragment()).into()),
276    )(input)
277}
278
279pub(super) fn parse_link(input: Span<'_>) -> IResult<'_, Link> {
280    map(
281        preceded(
282            char_tag('^'),
283            take_while(|c: char| c.is_alphanumeric() || c == '-' || c == '_'),
284        ),
285        |s: Span<'_>| Link((*s.fragment()).into()),
286    )(input)
287}
288
289pub(super) fn parse_tag_or_link(input: Span<'_>) -> IResult<'_, TagOrLink> {
290    alt((
291        map(parse_tag, TagOrLink::Tag),
292        map(parse_link, TagOrLink::Link),
293    ))(input)
294}
295
296fn tags_and_links(input: Span<'_>) -> IResult<'_, (HashSet<Tag>, HashSet<Link>)> {
297    let mut tags_and_links_iter = iterator(input, preceded(space1, parse_tag_or_link));
298    let (tags, links) = tags_and_links_iter.fold(
299        (HashSet::new(), HashSet::new()),
300        |(mut tags, mut links), x| {
301            match x {
302                TagOrLink::Tag(tag) => tags.insert(tag),
303                TagOrLink::Link(link) => links.insert(link),
304            };
305            (tags, links)
306        },
307    );
308    let (input, _) = tags_and_links_iter.finish()?;
309    Ok((input, (tags, links)))
310}
311
312fn payee_and_narration(input: Span<'_>) -> IResult<'_, (Option<&str>, &str)> {
313    let (input, s1) = string(input)?;
314    let (input, s2) = opt(preceded(space1, string))(input)?;
315    Ok((
316        input,
317        match s2 {
318            Some(narration) => (Some(s1), narration),
319            None => (None, s1),
320        },
321    ))
322}
323
324fn posting<D: Decimal>(input: Span<'_>) -> IResult<'_, Posting<D>> {
325    let (input, _) = space1(input)?;
326    let (input, flag) = opt(terminated(flag, space1))(input)?;
327    let (input, account) = account::parse(input)?;
328    let (input, amounts) = opt(tuple((
329        preceded(space1, amount::parse),
330        opt(preceded(space1, cost)),
331        opt(preceded(
332            space1,
333            alt((
334                map(
335                    preceded(tuple((char_tag('@'), space1)), amount::parse),
336                    PostingPrice::Unit,
337                ),
338                map(
339                    preceded(tuple((tag("@@"), space1)), amount::parse),
340                    PostingPrice::Total,
341                ),
342            )),
343        )),
344    )))(input)?;
345    let (input, _) = end_of_line(input)?;
346    let (input, metadata) = metadata::parse(input)?;
347    let (amount, cost, price) = match amounts {
348        Some((a, l, p)) => (Some(a), l, p),
349        None => (None, None, None),
350    };
351    Ok((
352        input,
353        Posting {
354            flag,
355            account,
356            amount,
357            cost,
358            price,
359            metadata,
360        },
361    ))
362}
363
364fn cost<D: Decimal>(input: Span<'_>) -> IResult<'_, Cost<D>> {
365    let (input, _) = terminated(char_tag('{'), space0)(input)?;
366    let (input, (cost, date)) = alt((
367        map(
368            separated_pair(
369                amount::parse,
370                delimited(space0, char_tag(','), space0),
371                date::parse,
372            ),
373            |(a, d)| (Some(a), Some(d)),
374        ),
375        map(
376            separated_pair(
377                date::parse,
378                delimited(space0, char_tag(','), space0),
379                amount::parse,
380            ),
381            |(d, a)| (Some(a), Some(d)),
382        ),
383        map(amount::parse, |a| (Some(a), None)),
384        map(date::parse, |d| (None, Some(d))),
385        map(success(true), |_| (None, None)),
386    ))(input)?;
387    let (input, _) = preceded(space0, char_tag('}'))(input)?;
388    Ok((input, Cost { amount: cost, date }))
389}