Skip to main content

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