Skip to main content

rustledger_core/
directive.rs

1//! Directive types representing all beancount directives.
2//!
3//! Beancount has 12 directive types that can appear in a ledger file:
4//!
5//! - [`Transaction`] - The most common directive, recording transfers between accounts
6//! - [`Balance`] - Assert that an account has a specific balance
7//! - [`Open`] - Open an account for use
8//! - [`Close`] - Close an account
9//! - [`Commodity`] - Declare a commodity/currency
10//! - [`Pad`] - Automatically pad an account to match a balance assertion
11//! - [`Event`] - Record a life event
12//! - [`Query`] - Store a named BQL query
13//! - [`Note`] - Add a note to an account
14//! - [`Document`] - Link a document to an account
15//! - [`Price`] - Record a price for a commodity
16//! - [`Custom`] - Custom directive type
17
18use crate::NaiveDate;
19use rust_decimal::Decimal;
20use rustc_hash::FxHashMap;
21use serde::{Deserialize, Serialize};
22use std::fmt;
23
24use crate::intern::InternedStr;
25#[cfg(feature = "rkyv")]
26use crate::intern::{AsDecimal, AsInternedStr, AsNaiveDate, AsOptionInternedStr};
27use crate::{Amount, CostSpec, IncompleteAmount};
28
29/// Metadata value types.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[cfg_attr(
32    feature = "rkyv",
33    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
34)]
35pub enum MetaValue {
36    /// String value
37    String(String),
38    /// Account reference
39    Account(crate::Account),
40    /// Currency code
41    Currency(crate::Currency),
42    /// Tag reference
43    Tag(crate::Tag),
44    /// Link reference
45    Link(crate::Link),
46    /// Date value
47    Date(#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))] NaiveDate),
48    /// Numeric value
49    Number(#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))] Decimal),
50    /// Boolean value
51    Bool(bool),
52    /// Amount value
53    Amount(Amount),
54    /// Null/None value
55    None,
56}
57
58impl fmt::Display for MetaValue {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match self {
61            Self::String(s) => write!(f, "\"{s}\""),
62            Self::Account(a) => write!(f, "{a}"),
63            Self::Currency(c) => write!(f, "{c}"),
64            Self::Tag(t) => write!(f, "#{t}"),
65            Self::Link(l) => write!(f, "^{l}"),
66            Self::Date(d) => write!(f, "{d}"),
67            Self::Number(n) => write!(f, "{n}"),
68            Self::Bool(b) => write!(f, "{b}"),
69            Self::Amount(a) => write!(f, "{a}"),
70            Self::None => write!(f, "None"),
71        }
72    }
73}
74
75/// Metadata is a key-value map attached to directives and postings.
76pub type Metadata = FxHashMap<String, MetaValue>;
77
78/// Try to interpret a [`MetaValue`] as a non-negative integer ≤ `u32::MAX`.
79///
80/// Used by the `precision` metadata feature on `commodity` directives (issue
81/// #991). Shared between the loader (which silently skips invalid values and
82/// falls back to inferred precision) and the validator (which surfaces the
83/// problem as an `InvalidPrecisionMetadata` warning), so both paths agree on
84/// what counts as valid.
85///
86/// # Errors
87///
88/// Returns a human-readable explanation when the value is not a number,
89/// is negative, has a fractional part, or is out of `u32` range.
90#[must_use = "ignoring the result silently drops invalid `precision:` metadata; the loader expects to skip invalid values, the validator expects to surface them"]
91pub fn parse_precision_meta(value: &MetaValue) -> Result<u32, String> {
92    use rust_decimal::prelude::ToPrimitive;
93    let MetaValue::Number(n) = value else {
94        return Err(format!(
95            "expected a non-negative integer, got {} value",
96            meta_value_kind(value)
97        ));
98    };
99    if n.is_sign_negative() {
100        return Err(format!("expected a non-negative integer, got {n}"));
101    }
102    if !n.fract().is_zero() {
103        return Err(format!("expected an integer, got {n}"));
104    }
105    n.to_u32().ok_or_else(|| {
106        format!(
107            "value {n} exceeds the maximum supported precision ({})",
108            u32::MAX
109        )
110    })
111}
112
113const fn meta_value_kind(v: &MetaValue) -> &'static str {
114    match v {
115        MetaValue::String(_) => "string",
116        MetaValue::Account(_) => "account",
117        MetaValue::Currency(_) => "currency",
118        MetaValue::Tag(_) => "tag",
119        MetaValue::Link(_) => "link",
120        MetaValue::Date(_) => "date",
121        MetaValue::Number(_) => "number",
122        MetaValue::Bool(_) => "bool",
123        MetaValue::Amount(_) => "amount",
124        MetaValue::None => "none",
125    }
126}
127
128/// A posting within a transaction.
129///
130/// Postings represent the individual legs of a transaction. Each posting
131/// specifies an account and optionally an amount, cost, and price.
132///
133/// When the units are `None`, the entire amount will be inferred by the
134/// interpolation algorithm to balance the transaction. When units is
135/// `Some(IncompleteAmount)`, it may still have missing components that
136/// need to be filled in.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[cfg_attr(
139    feature = "rkyv",
140    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
141)]
142pub struct Posting {
143    /// The account for this posting
144    pub account: crate::Account,
145    /// The units (may be incomplete or None for auto-calculated postings)
146    pub units: Option<IncompleteAmount>,
147    /// Cost specification for the position
148    pub cost: Option<CostSpec>,
149    /// Price annotation (@ or @@)
150    pub price: Option<PriceAnnotation>,
151    /// Whether this posting has the "!" flag
152    pub flag: Option<char>,
153    /// Posting metadata
154    pub meta: Metadata,
155    /// Comments that appear before this posting (one per line)
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub comments: Vec<String>,
158    /// Trailing comment(s) on the same line as the posting
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub trailing_comments: Vec<String>,
161}
162
163impl Posting {
164    /// Create a new posting with the given account and complete units.
165    #[must_use]
166    pub fn new(account: impl Into<crate::Account>, units: Amount) -> Self {
167        Self {
168            account: account.into(),
169            units: Some(IncompleteAmount::Complete(units)),
170            cost: None,
171            price: None,
172            flag: None,
173            meta: Metadata::default(),
174            comments: Vec::new(),
175            trailing_comments: Vec::new(),
176        }
177    }
178
179    /// Create a new posting with an incomplete amount.
180    #[must_use]
181    pub fn with_incomplete(account: impl Into<crate::Account>, units: IncompleteAmount) -> Self {
182        Self {
183            account: account.into(),
184            units: Some(units),
185            cost: None,
186            price: None,
187            flag: None,
188            meta: Metadata::default(),
189            comments: Vec::new(),
190            trailing_comments: Vec::new(),
191        }
192    }
193
194    /// Create a posting without any amount (to be fully interpolated).
195    #[must_use]
196    pub fn auto(account: impl Into<crate::Account>) -> Self {
197        Self {
198            account: account.into(),
199            units: None,
200            cost: None,
201            price: None,
202            flag: None,
203            meta: Metadata::default(),
204            comments: Vec::new(),
205            trailing_comments: Vec::new(),
206        }
207    }
208
209    /// Get the complete amount if available.
210    #[must_use]
211    pub fn amount(&self) -> Option<&Amount> {
212        self.units.as_ref().and_then(|u| u.as_amount())
213    }
214
215    /// Add a cost specification.
216    ///
217    /// Consuming builder — takes `self` by value and returns `Posting`.
218    /// To apply this to a `Spanned<Posting>` while preserving its source
219    /// location, use [`crate::Spanned::map`]:
220    ///
221    /// ```no_run
222    /// # use rustledger_core::{Posting, Amount, CostSpec, CostNumber, Spanned};
223    /// # use rust_decimal_macros::dec;
224    /// # let cost = CostSpec { number: Some(CostNumber::PerUnit { value: dec!(150) }),
225    /// #     currency: Some("USD".into()), date: None, label: None, merge: false };
226    /// let spanned: Spanned<Posting> = Spanned::synthesized(
227    ///     Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
228    /// );
229    /// let with_cost: Spanned<Posting> = spanned.map(|p| p.with_cost(cost));
230    /// ```
231    #[must_use]
232    pub fn with_cost(mut self, cost: CostSpec) -> Self {
233        self.cost = Some(cost);
234        self
235    }
236
237    /// Add a price annotation.
238    ///
239    /// See [`Self::with_cost`] for the `Spanned<Posting>` pattern.
240    #[must_use]
241    pub fn with_price(mut self, price: PriceAnnotation) -> Self {
242        self.price = Some(price);
243        self
244    }
245
246    /// Add a flag.
247    ///
248    /// See [`Self::with_cost`] for the `Spanned<Posting>` pattern.
249    #[must_use]
250    pub const fn with_flag(mut self, flag: char) -> Self {
251        self.flag = Some(flag);
252        self
253    }
254
255    /// Check if this posting has an amount.
256    #[must_use]
257    pub const fn has_units(&self) -> bool {
258        self.units.is_some()
259    }
260}
261
262impl fmt::Display for Posting {
263    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264        write!(f, "  ")?;
265        if let Some(flag) = self.flag {
266            write!(f, "{flag} ")?;
267        }
268        write!(f, "{}", self.account)?;
269        if let Some(units) = &self.units {
270            write!(f, "  {units}")?;
271        }
272        if let Some(cost) = &self.cost {
273            write!(f, " {cost}")?;
274        }
275        if let Some(price) = &self.price {
276            write!(f, " {price}")?;
277        }
278        // Posting-level metadata
279        for (key, value) in &self.meta {
280            write!(f, "\n    {key}: {value}")?;
281        }
282        Ok(())
283    }
284}
285
286/// Whether a price annotation is per-unit (`@`) or total (`@@`).
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
288#[cfg_attr(
289    feature = "rkyv",
290    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
291)]
292pub enum PriceKind {
293    /// Per-unit price (`@`).
294    Unit,
295    /// Total price for the posting (`@@`).
296    Total,
297}
298
299impl fmt::Display for PriceKind {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        f.write_str(match self {
302            Self::Unit => "@",
303            Self::Total => "@@",
304        })
305    }
306}
307
308/// Price annotation for a posting (`@` or `@@`).
309///
310/// Factored along its two orthogonal axes (#1167):
311/// - `kind`: per-unit (`@`) vs total (`@@`)
312/// - `amount`: present (possibly with missing number or currency that
313///   interpolation will fill) vs absent (the bare `@`/`@@` sigil
314///   without any amount)
315///
316/// Pre-#1167 these axes were flattened into a 6-variant enum
317/// (`Unit/Total/UnitIncomplete/TotalIncomplete/UnitEmpty/TotalEmpty`),
318/// which forced every consumer to write six match arms even when only
319/// one axis mattered.
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321#[cfg_attr(
322    feature = "rkyv",
323    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
324)]
325pub struct PriceAnnotation {
326    /// Per-unit (`@`) or total (`@@`).
327    pub kind: PriceKind,
328    /// The price amount, or `None` for the bare-sigil form.
329    /// `IncompleteAmount::Complete` indicates the parser saw a full
330    /// number+currency; the other variants indicate one of those
331    /// pieces was elided and will be filled by interpolation.
332    pub amount: Option<IncompleteAmount>,
333}
334
335impl PriceAnnotation {
336    /// Per-unit (`@`) price with a complete amount.
337    #[must_use]
338    pub const fn unit(amount: Amount) -> Self {
339        Self {
340            kind: PriceKind::Unit,
341            amount: Some(IncompleteAmount::Complete(amount)),
342        }
343    }
344
345    /// Total (`@@`) price with a complete amount.
346    #[must_use]
347    pub const fn total(amount: Amount) -> Self {
348        Self {
349            kind: PriceKind::Total,
350            amount: Some(IncompleteAmount::Complete(amount)),
351        }
352    }
353
354    /// Per-unit (`@`) price with an incomplete amount.
355    #[must_use]
356    pub const fn unit_incomplete(amount: IncompleteAmount) -> Self {
357        Self {
358            kind: PriceKind::Unit,
359            amount: Some(amount),
360        }
361    }
362
363    /// Total (`@@`) price with an incomplete amount.
364    #[must_use]
365    pub const fn total_incomplete(amount: IncompleteAmount) -> Self {
366        Self {
367            kind: PriceKind::Total,
368            amount: Some(amount),
369        }
370    }
371
372    /// Bare per-unit sigil (`@`) with no amount.
373    #[must_use]
374    pub const fn unit_empty() -> Self {
375        Self {
376            kind: PriceKind::Unit,
377            amount: None,
378        }
379    }
380
381    /// Bare total sigil (`@@`) with no amount.
382    #[must_use]
383    pub const fn total_empty() -> Self {
384        Self {
385            kind: PriceKind::Total,
386            amount: None,
387        }
388    }
389
390    /// Get the complete amount if available.
391    #[must_use]
392    pub fn amount(&self) -> Option<&Amount> {
393        self.amount.as_ref().and_then(IncompleteAmount::as_amount)
394    }
395
396    /// Check if this is a per-unit price (`@` vs `@@`).
397    #[must_use]
398    pub const fn is_unit(&self) -> bool {
399        matches!(self.kind, PriceKind::Unit)
400    }
401}
402
403impl fmt::Display for PriceAnnotation {
404    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405        match &self.amount {
406            Some(amt) => write!(f, "{} {amt}", self.kind),
407            None => write!(f, "{}", self.kind),
408        }
409    }
410}
411
412/// Directive ordering priority for sorting.
413///
414/// When directives have the same date, they are sorted by type priority
415/// to ensure proper processing order.
416#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
417pub enum DirectivePriority {
418    /// Open accounts first so they exist before use
419    Open = 0,
420    /// Commodities declared before use
421    Commodity = 1,
422    /// Padding before balance assertions
423    Pad = 2,
424    /// Balance assertions checked at start of day
425    Balance = 3,
426    /// Main entries
427    Transaction = 4,
428    /// Annotations after transactions
429    Note = 5,
430    /// Attachments after transactions
431    Document = 6,
432    /// State changes
433    Event = 7,
434    /// Queries defined after data
435    Query = 8,
436    /// Prices at end of day
437    Price = 9,
438    /// Accounts closed after all activity
439    Close = 10,
440    /// User extensions last
441    Custom = 11,
442}
443
444/// All directive types in beancount.
445#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
446#[cfg_attr(
447    feature = "rkyv",
448    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
449)]
450pub enum Directive {
451    /// Transaction directive - records transfers between accounts
452    Transaction(Transaction),
453    /// Balance assertion - asserts an account balance at a point in time
454    Balance(Balance),
455    /// Open account - opens an account for use
456    Open(Open),
457    /// Close account - closes an account
458    Close(Close),
459    /// Commodity declaration - declares a currency/commodity
460    Commodity(Commodity),
461    /// Pad directive - auto-pad an account to match a balance
462    Pad(Pad),
463    /// Event directive - records a life event
464    Event(Event),
465    /// Query directive - stores a named BQL query
466    Query(Query),
467    /// Note directive - adds a note to an account
468    Note(Note),
469    /// Document directive - links a document to an account
470    Document(Document),
471    /// Price directive - records a commodity price
472    Price(Price),
473    /// Custom directive - custom user-defined directive
474    Custom(Custom),
475}
476
477impl Directive {
478    /// Get the date of this directive.
479    #[must_use]
480    pub const fn date(&self) -> NaiveDate {
481        match self {
482            Self::Transaction(t) => t.date,
483            Self::Balance(b) => b.date,
484            Self::Open(o) => o.date,
485            Self::Close(c) => c.date,
486            Self::Commodity(c) => c.date,
487            Self::Pad(p) => p.date,
488            Self::Event(e) => e.date,
489            Self::Query(q) => q.date,
490            Self::Note(n) => n.date,
491            Self::Document(d) => d.date,
492            Self::Price(p) => p.date,
493            Self::Custom(c) => c.date,
494        }
495    }
496
497    /// Get the metadata of this directive.
498    #[must_use]
499    pub const fn meta(&self) -> &Metadata {
500        match self {
501            Self::Transaction(t) => &t.meta,
502            Self::Balance(b) => &b.meta,
503            Self::Open(o) => &o.meta,
504            Self::Close(c) => &c.meta,
505            Self::Commodity(c) => &c.meta,
506            Self::Pad(p) => &p.meta,
507            Self::Event(e) => &e.meta,
508            Self::Query(q) => &q.meta,
509            Self::Note(n) => &n.meta,
510            Self::Document(d) => &d.meta,
511            Self::Price(p) => &p.meta,
512            Self::Custom(c) => &c.meta,
513        }
514    }
515
516    /// Check if this is a transaction.
517    #[must_use]
518    pub const fn is_transaction(&self) -> bool {
519        matches!(self, Self::Transaction(_))
520    }
521
522    /// Get as a transaction, if this is one.
523    #[must_use]
524    pub const fn as_transaction(&self) -> Option<&Transaction> {
525        match self {
526            Self::Transaction(t) => Some(t),
527            _ => None,
528        }
529    }
530
531    /// Get the directive type name.
532    #[must_use]
533    pub const fn type_name(&self) -> &'static str {
534        match self {
535            Self::Transaction(_) => "transaction",
536            Self::Balance(_) => "balance",
537            Self::Open(_) => "open",
538            Self::Close(_) => "close",
539            Self::Commodity(_) => "commodity",
540            Self::Pad(_) => "pad",
541            Self::Event(_) => "event",
542            Self::Query(_) => "query",
543            Self::Note(_) => "note",
544            Self::Document(_) => "document",
545            Self::Price(_) => "price",
546            Self::Custom(_) => "custom",
547        }
548    }
549
550    /// Get the sorting priority for this directive.
551    ///
552    /// Used to determine order when directives have the same date.
553    #[must_use]
554    pub const fn priority(&self) -> DirectivePriority {
555        match self {
556            Self::Open(_) => DirectivePriority::Open,
557            Self::Commodity(_) => DirectivePriority::Commodity,
558            Self::Pad(_) => DirectivePriority::Pad,
559            Self::Balance(_) => DirectivePriority::Balance,
560            Self::Transaction(_) => DirectivePriority::Transaction,
561            Self::Note(_) => DirectivePriority::Note,
562            Self::Document(_) => DirectivePriority::Document,
563            Self::Event(_) => DirectivePriority::Event,
564            Self::Query(_) => DirectivePriority::Query,
565            Self::Price(_) => DirectivePriority::Price,
566            Self::Close(_) => DirectivePriority::Close,
567            Self::Custom(_) => DirectivePriority::Custom,
568        }
569    }
570
571    /// Check if this directive has any cost-basis reductions.
572    ///
573    /// A transaction "reduces" inventory when it has a posting with a cost
574    /// spec and negative units (selling lots). Used to order same-date
575    /// transactions: augmentations (buying) should process before
576    /// reductions (selling) so lots exist when they're matched.
577    #[must_use]
578    pub fn has_cost_reduction(&self) -> bool {
579        if let Self::Transaction(txn) = self {
580            txn.postings.iter().any(|p| {
581                p.cost.is_some()
582                    && p.units
583                        .as_ref()
584                        .and_then(IncompleteAmount::number)
585                        .is_some_and(|n| n.is_sign_negative())
586            })
587        } else {
588            false
589        }
590    }
591}
592
593/// Sort directives by date, then type priority, then cost-basis reductions last.
594///
595/// This is a stable sort that preserves file order for directives
596/// with the same date, type, and reduction status.
597///
598/// Within the same date, transactions without cost-basis reductions
599/// (no negative-units + cost-spec postings) are processed before
600/// those that do reduce cost-basis lots. This ensures lots exist
601/// when they're matched, regardless of file ordering.
602pub fn sort_directives(directives: &mut [Directive]) {
603    directives.sort_by_cached_key(|d| (d.date(), d.priority(), d.has_cost_reduction()));
604}
605
606/// A transaction directive.
607///
608/// Transactions are the most common directive type. They record transfers
609/// between accounts and must balance (sum of all postings equals zero).
610#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
611#[cfg_attr(
612    feature = "rkyv",
613    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
614)]
615pub struct Transaction {
616    /// Transaction date
617    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
618    pub date: NaiveDate,
619    /// Transaction flag (* or !)
620    pub flag: char,
621    /// Payee (optional)
622    #[cfg_attr(feature = "rkyv", rkyv(with = AsOptionInternedStr))]
623    pub payee: Option<InternedStr>,
624    /// Narration (description)
625    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
626    pub narration: InternedStr,
627    /// Tags attached to this transaction
628    pub tags: Vec<crate::Tag>,
629    /// Links attached to this transaction
630    pub links: Vec<crate::Link>,
631    /// Transaction metadata
632    pub meta: Metadata,
633    /// Postings (account entries), each wrapped with its source span and
634    /// file ID. Parser-emitted postings carry the byte range of the
635    /// posting line (from leading indent through trailing same-line
636    /// comment, not including following metadata lines); programmatically
637    /// constructed postings use [`crate::Spanned::synthesized`] which
638    /// pairs [`crate::Span::ZERO`] with [`crate::SYNTHESIZED_FILE_ID`].
639    pub postings: Vec<crate::Spanned<Posting>>,
640    /// Comments that appear after all postings
641    #[serde(default, skip_serializing_if = "Vec::is_empty")]
642    pub trailing_comments: Vec<String>,
643}
644
645impl Transaction {
646    /// Create a new transaction.
647    #[must_use]
648    pub fn new(date: NaiveDate, narration: impl Into<InternedStr>) -> Self {
649        Self {
650            date,
651            flag: '*',
652            payee: None,
653            narration: narration.into(),
654            tags: Vec::new(),
655            links: Vec::new(),
656            meta: Metadata::default(),
657            postings: Vec::new(),
658            trailing_comments: Vec::new(),
659        }
660    }
661
662    /// Set the flag.
663    #[must_use]
664    pub const fn with_flag(mut self, flag: char) -> Self {
665        self.flag = flag;
666        self
667    }
668
669    /// Set the payee.
670    #[must_use]
671    pub fn with_payee(mut self, payee: impl Into<InternedStr>) -> Self {
672        self.payee = Some(payee.into());
673        self
674    }
675
676    /// Add a tag.
677    #[must_use]
678    pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
679        self.tags.push(tag.into());
680        self
681    }
682
683    /// Add a link.
684    #[must_use]
685    pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
686        self.links.push(link.into());
687        self
688    }
689
690    /// Add a posting that already carries source location metadata.
691    /// Use this when constructing transactions from source (e.g.
692    /// a parser implementation) or when round-tripping through a
693    /// plugin that preserves spans.
694    #[must_use]
695    pub fn with_posting(mut self, posting: crate::Spanned<Posting>) -> Self {
696        self.postings.push(posting);
697        self
698    }
699
700    /// Add a programmatically-built posting (no source representation),
701    /// wrapping it with [`crate::Spanned::synthesized`]. Use this from
702    /// test fixtures, CLI commands, and importers that build directives
703    /// in memory rather than parsing them. The name is intentionally
704    /// longer than [`Self::with_posting`] so that synthesis is visible
705    /// at the call site — silently dropping a real span is the foot-gun
706    /// this rename prevents.
707    #[must_use]
708    pub fn with_synthesized_posting(mut self, posting: Posting) -> Self {
709        self.postings.push(crate::Spanned::synthesized(posting));
710        self
711    }
712
713    /// Check if this transaction is marked as complete (*).
714    #[must_use]
715    pub const fn is_complete(&self) -> bool {
716        self.flag == '*'
717    }
718
719    /// Check if this transaction is marked as incomplete/pending (!).
720    #[must_use]
721    pub const fn is_incomplete(&self) -> bool {
722        self.flag == '!'
723    }
724
725    /// Check if this transaction is marked as pending (!).
726    /// Alias for `is_incomplete`.
727    #[must_use]
728    pub const fn is_pending(&self) -> bool {
729        self.flag == '!'
730    }
731
732    /// Check if this is a summarization transaction (S).
733    #[must_use]
734    pub const fn is_summarization(&self) -> bool {
735        self.flag == 'S'
736    }
737
738    /// Check if this is a transfer transaction (T).
739    #[must_use]
740    pub const fn is_transfer(&self) -> bool {
741        self.flag == 'T'
742    }
743
744    /// Check if this is a currency conversion transaction (C).
745    #[must_use]
746    pub const fn is_conversion(&self) -> bool {
747        self.flag == 'C'
748    }
749
750    /// Check if this is an unrealized gains transaction (U).
751    #[must_use]
752    pub const fn is_unrealized(&self) -> bool {
753        self.flag == 'U'
754    }
755
756    /// Check if this is a return/dividend transaction (R).
757    #[must_use]
758    pub const fn is_return(&self) -> bool {
759        self.flag == 'R'
760    }
761
762    /// Check if this is a merge transaction (M).
763    #[must_use]
764    pub const fn is_merge(&self) -> bool {
765        self.flag == 'M'
766    }
767
768    /// Check if this transaction is bookmarked (#).
769    #[must_use]
770    pub const fn is_bookmarked(&self) -> bool {
771        self.flag == '#'
772    }
773
774    /// Check if this transaction needs investigation (?).
775    #[must_use]
776    pub const fn needs_investigation(&self) -> bool {
777        self.flag == '?'
778    }
779
780    /// Check if the given character is a valid transaction flag.
781    #[must_use]
782    pub const fn is_valid_flag(flag: char) -> bool {
783        matches!(
784            flag,
785            '*' | '!' | 'P' | 'S' | 'T' | 'C' | 'U' | 'R' | 'M' | '#' | '?' | '%' | '&'
786        )
787    }
788}
789
790impl fmt::Display for Transaction {
791    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
792        write!(f, "{} {} ", self.date, self.flag)?;
793        if let Some(payee) = &self.payee {
794            write!(f, "\"{payee}\" ")?;
795        }
796        write!(f, "\"{}\"", self.narration)?;
797        for tag in &self.tags {
798            write!(f, " #{tag}")?;
799        }
800        for link in &self.links {
801            write!(f, " ^{link}")?;
802        }
803        // Transaction-level metadata
804        for (key, value) in &self.meta {
805            write!(f, "\n  {key}: {value}")?;
806        }
807        for posting in &self.postings {
808            write!(f, "\n{posting}")?;
809        }
810        Ok(())
811    }
812}
813
814/// A balance assertion directive.
815///
816/// Asserts that an account has a specific balance at the beginning of a date.
817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
818#[cfg_attr(
819    feature = "rkyv",
820    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
821)]
822pub struct Balance {
823    /// Assertion date
824    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
825    pub date: NaiveDate,
826    /// Account to check
827    pub account: crate::Account,
828    /// Expected amount
829    pub amount: Amount,
830    /// Tolerance (if explicitly specified)
831    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
832    pub tolerance: Option<Decimal>,
833    /// Metadata
834    pub meta: Metadata,
835}
836
837impl Balance {
838    /// Create a new balance assertion.
839    #[must_use]
840    pub fn new(date: NaiveDate, account: impl Into<crate::Account>, amount: Amount) -> Self {
841        Self {
842            date,
843            account: account.into(),
844            amount,
845            tolerance: None,
846            meta: Metadata::default(),
847        }
848    }
849
850    /// Set explicit tolerance.
851    #[must_use]
852    pub const fn with_tolerance(mut self, tolerance: Decimal) -> Self {
853        self.tolerance = Some(tolerance);
854        self
855    }
856
857    /// Set metadata.
858    #[must_use]
859    pub fn with_meta(mut self, meta: Metadata) -> Self {
860        self.meta = meta;
861        self
862    }
863}
864
865impl fmt::Display for Balance {
866    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
867        write!(f, "{} balance {} {}", self.date, self.account, self.amount)?;
868        if let Some(tol) = self.tolerance {
869            write!(f, " ~ {tol}")?;
870        }
871        Ok(())
872    }
873}
874
875/// An open account directive.
876///
877/// Opens an account for use. Accounts must be opened before they can be used.
878#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
879#[cfg_attr(
880    feature = "rkyv",
881    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
882)]
883pub struct Open {
884    /// Date account was opened
885    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
886    pub date: NaiveDate,
887    /// Account name (e.g., "Assets:Bank:Checking")
888    pub account: crate::Account,
889    /// Allowed currencies (empty = any currency allowed)
890    pub currencies: Vec<crate::Currency>,
891    /// Booking method for this account
892    pub booking: Option<String>,
893    /// Metadata
894    pub meta: Metadata,
895}
896
897impl Open {
898    /// Create a new open directive.
899    #[must_use]
900    pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
901        Self {
902            date,
903            account: account.into(),
904            currencies: Vec::new(),
905            booking: None,
906            meta: Metadata::default(),
907        }
908    }
909
910    /// Set allowed currencies.
911    #[must_use]
912    pub fn with_currencies(mut self, currencies: Vec<crate::Currency>) -> Self {
913        self.currencies = currencies;
914        self
915    }
916
917    /// Set booking method.
918    #[must_use]
919    pub fn with_booking(mut self, booking: impl Into<String>) -> Self {
920        self.booking = Some(booking.into());
921        self
922    }
923
924    /// Set metadata.
925    #[must_use]
926    pub fn with_meta(mut self, meta: Metadata) -> Self {
927        self.meta = meta;
928        self
929    }
930}
931
932impl fmt::Display for Open {
933    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
934        write!(f, "{} open {}", self.date, self.account)?;
935        if !self.currencies.is_empty() {
936            let currencies: Vec<&str> = self
937                .currencies
938                .iter()
939                .map(crate::Currency::as_str)
940                .collect();
941            write!(f, " {}", currencies.join(","))?;
942        }
943        if let Some(booking) = &self.booking {
944            write!(f, " \"{booking}\"")?;
945        }
946        Ok(())
947    }
948}
949
950/// A close account directive.
951///
952/// Closes an account. The account should have zero balance when closed.
953#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
954#[cfg_attr(
955    feature = "rkyv",
956    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
957)]
958pub struct Close {
959    /// Date account was closed
960    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
961    pub date: NaiveDate,
962    /// Account name
963    pub account: crate::Account,
964    /// Metadata
965    pub meta: Metadata,
966}
967
968impl Close {
969    /// Create a new close directive.
970    #[must_use]
971    pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
972        Self {
973            date,
974            account: account.into(),
975            meta: Metadata::default(),
976        }
977    }
978
979    /// Set metadata.
980    #[must_use]
981    pub fn with_meta(mut self, meta: Metadata) -> Self {
982        self.meta = meta;
983        self
984    }
985}
986
987impl fmt::Display for Close {
988    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
989        write!(f, "{} close {}", self.date, self.account)
990    }
991}
992
993/// A commodity declaration directive.
994///
995/// Declares a commodity/currency that can be used in the ledger.
996#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
997#[cfg_attr(
998    feature = "rkyv",
999    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1000)]
1001pub struct Commodity {
1002    /// Declaration date
1003    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1004    pub date: NaiveDate,
1005    /// Currency/commodity code (e.g., "USD", "AAPL")
1006    pub currency: crate::Currency,
1007    /// Metadata
1008    pub meta: Metadata,
1009}
1010
1011impl Commodity {
1012    /// Create a new commodity declaration.
1013    #[must_use]
1014    pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>) -> Self {
1015        Self {
1016            date,
1017            currency: currency.into(),
1018            meta: Metadata::default(),
1019        }
1020    }
1021
1022    /// Set metadata.
1023    #[must_use]
1024    pub fn with_meta(mut self, meta: Metadata) -> Self {
1025        self.meta = meta;
1026        self
1027    }
1028}
1029
1030impl fmt::Display for Commodity {
1031    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1032        write!(f, "{} commodity {}", self.date, self.currency)
1033    }
1034}
1035
1036/// A pad directive.
1037///
1038/// Automatically inserts a transaction to pad an account to match
1039/// a subsequent balance assertion.
1040#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1041#[cfg_attr(
1042    feature = "rkyv",
1043    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1044)]
1045pub struct Pad {
1046    /// Pad date
1047    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1048    pub date: NaiveDate,
1049    /// Account to pad
1050    pub account: crate::Account,
1051    /// Source account for padding (e.g., Equity:Opening-Balances)
1052    pub source_account: crate::Account,
1053    /// Metadata
1054    pub meta: Metadata,
1055}
1056
1057impl Pad {
1058    /// Create a new pad directive.
1059    #[must_use]
1060    pub fn new(
1061        date: NaiveDate,
1062        account: impl Into<crate::Account>,
1063        source_account: impl Into<crate::Account>,
1064    ) -> Self {
1065        Self {
1066            date,
1067            account: account.into(),
1068            source_account: source_account.into(),
1069            meta: Metadata::default(),
1070        }
1071    }
1072
1073    /// Set metadata.
1074    #[must_use]
1075    pub fn with_meta(mut self, meta: Metadata) -> Self {
1076        self.meta = meta;
1077        self
1078    }
1079}
1080
1081impl fmt::Display for Pad {
1082    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1083        write!(
1084            f,
1085            "{} pad {} {}",
1086            self.date, self.account, self.source_account
1087        )
1088    }
1089}
1090
1091/// An event directive.
1092///
1093/// Records a life event (e.g., location changes, employment changes).
1094#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1095#[cfg_attr(
1096    feature = "rkyv",
1097    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1098)]
1099pub struct Event {
1100    /// Event date
1101    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1102    pub date: NaiveDate,
1103    /// Event type (e.g., "location", "employer")
1104    pub event_type: String,
1105    /// Event value
1106    pub value: String,
1107    /// Metadata
1108    pub meta: Metadata,
1109}
1110
1111impl Event {
1112    /// Create a new event directive.
1113    #[must_use]
1114    pub fn new(date: NaiveDate, event_type: impl Into<String>, value: impl Into<String>) -> Self {
1115        Self {
1116            date,
1117            event_type: event_type.into(),
1118            value: value.into(),
1119            meta: Metadata::default(),
1120        }
1121    }
1122
1123    /// Set metadata.
1124    #[must_use]
1125    pub fn with_meta(mut self, meta: Metadata) -> Self {
1126        self.meta = meta;
1127        self
1128    }
1129}
1130
1131impl fmt::Display for Event {
1132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1133        write!(
1134            f,
1135            "{} event \"{}\" \"{}\"",
1136            self.date, self.event_type, self.value
1137        )
1138    }
1139}
1140
1141/// A query directive.
1142///
1143/// Stores a named BQL query that can be referenced later.
1144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1145#[cfg_attr(
1146    feature = "rkyv",
1147    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1148)]
1149pub struct Query {
1150    /// Query date
1151    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1152    pub date: NaiveDate,
1153    /// Query name
1154    pub name: String,
1155    /// BQL query string
1156    pub query: String,
1157    /// Metadata
1158    pub meta: Metadata,
1159}
1160
1161impl Query {
1162    /// Create a new query directive.
1163    #[must_use]
1164    pub fn new(date: NaiveDate, name: impl Into<String>, query: impl Into<String>) -> Self {
1165        Self {
1166            date,
1167            name: name.into(),
1168            query: query.into(),
1169            meta: Metadata::default(),
1170        }
1171    }
1172
1173    /// Set metadata.
1174    #[must_use]
1175    pub fn with_meta(mut self, meta: Metadata) -> Self {
1176        self.meta = meta;
1177        self
1178    }
1179}
1180
1181impl fmt::Display for Query {
1182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1183        write!(
1184            f,
1185            "{} query \"{}\" \"{}\"",
1186            self.date, self.name, self.query
1187        )
1188    }
1189}
1190
1191/// A note directive.
1192///
1193/// Adds a note/comment to an account on a specific date.
1194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1195#[cfg_attr(
1196    feature = "rkyv",
1197    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1198)]
1199pub struct Note {
1200    /// Note date
1201    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1202    pub date: NaiveDate,
1203    /// Account
1204    pub account: crate::Account,
1205    /// Note text
1206    pub comment: String,
1207    /// Metadata
1208    pub meta: Metadata,
1209}
1210
1211impl Note {
1212    /// Create a new note directive.
1213    #[must_use]
1214    pub fn new(
1215        date: NaiveDate,
1216        account: impl Into<crate::Account>,
1217        comment: impl Into<String>,
1218    ) -> Self {
1219        Self {
1220            date,
1221            account: account.into(),
1222            comment: comment.into(),
1223            meta: Metadata::default(),
1224        }
1225    }
1226
1227    /// Set metadata.
1228    #[must_use]
1229    pub fn with_meta(mut self, meta: Metadata) -> Self {
1230        self.meta = meta;
1231        self
1232    }
1233}
1234
1235impl fmt::Display for Note {
1236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1237        write!(
1238            f,
1239            "{} note {} \"{}\"",
1240            self.date, self.account, self.comment
1241        )
1242    }
1243}
1244
1245/// A document directive.
1246///
1247/// Links an external document file to an account.
1248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1249#[cfg_attr(
1250    feature = "rkyv",
1251    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1252)]
1253pub struct Document {
1254    /// Document date
1255    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1256    pub date: NaiveDate,
1257    /// Account
1258    pub account: crate::Account,
1259    /// File path to the document
1260    pub path: String,
1261    /// Tags
1262    pub tags: Vec<crate::Tag>,
1263    /// Links
1264    pub links: Vec<crate::Link>,
1265    /// Metadata
1266    pub meta: Metadata,
1267}
1268
1269impl Document {
1270    /// Create a new document directive.
1271    #[must_use]
1272    pub fn new(
1273        date: NaiveDate,
1274        account: impl Into<crate::Account>,
1275        path: impl Into<String>,
1276    ) -> Self {
1277        Self {
1278            date,
1279            account: account.into(),
1280            path: path.into(),
1281            tags: Vec::new(),
1282            links: Vec::new(),
1283            meta: Metadata::default(),
1284        }
1285    }
1286
1287    /// Add a tag.
1288    #[must_use]
1289    pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
1290        self.tags.push(tag.into());
1291        self
1292    }
1293
1294    /// Add a link.
1295    #[must_use]
1296    pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
1297        self.links.push(link.into());
1298        self
1299    }
1300
1301    /// Set metadata.
1302    #[must_use]
1303    pub fn with_meta(mut self, meta: Metadata) -> Self {
1304        self.meta = meta;
1305        self
1306    }
1307}
1308
1309impl fmt::Display for Document {
1310    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1311        write!(
1312            f,
1313            "{} document {} \"{}\"",
1314            self.date, self.account, self.path
1315        )
1316    }
1317}
1318
1319/// A price directive.
1320///
1321/// Records the price of a commodity in another currency.
1322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1323#[cfg_attr(
1324    feature = "rkyv",
1325    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1326)]
1327pub struct Price {
1328    /// Price date
1329    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1330    pub date: NaiveDate,
1331    /// Currency being priced
1332    pub currency: crate::Currency,
1333    /// Price amount (in another currency)
1334    pub amount: Amount,
1335    /// Metadata
1336    pub meta: Metadata,
1337}
1338
1339impl Price {
1340    /// Create a new price directive.
1341    #[must_use]
1342    pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>, amount: Amount) -> Self {
1343        Self {
1344            date,
1345            currency: currency.into(),
1346            amount,
1347            meta: Metadata::default(),
1348        }
1349    }
1350
1351    /// Set metadata.
1352    #[must_use]
1353    pub fn with_meta(mut self, meta: Metadata) -> Self {
1354        self.meta = meta;
1355        self
1356    }
1357}
1358
1359impl fmt::Display for Price {
1360    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1361        write!(f, "{} price {} {}", self.date, self.currency, self.amount)
1362    }
1363}
1364
1365/// A custom directive.
1366///
1367/// User-defined directive type for extensions.
1368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1369#[cfg_attr(
1370    feature = "rkyv",
1371    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1372)]
1373pub struct Custom {
1374    /// Custom directive date
1375    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1376    pub date: NaiveDate,
1377    /// Custom type name (e.g., "budget", "autopay")
1378    pub custom_type: String,
1379    /// Values/arguments for this custom directive
1380    pub values: Vec<MetaValue>,
1381    /// Metadata
1382    pub meta: Metadata,
1383}
1384
1385impl Custom {
1386    /// Create a new custom directive.
1387    #[must_use]
1388    pub fn new(date: NaiveDate, custom_type: impl Into<String>) -> Self {
1389        Self {
1390            date,
1391            custom_type: custom_type.into(),
1392            values: Vec::new(),
1393            meta: Metadata::default(),
1394        }
1395    }
1396
1397    /// Add a value.
1398    #[must_use]
1399    pub fn with_value(mut self, value: MetaValue) -> Self {
1400        self.values.push(value);
1401        self
1402    }
1403
1404    /// Set metadata.
1405    #[must_use]
1406    pub fn with_meta(mut self, meta: Metadata) -> Self {
1407        self.meta = meta;
1408        self
1409    }
1410}
1411
1412impl fmt::Display for Custom {
1413    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1414        write!(f, "{} custom \"{}\"", self.date, self.custom_type)?;
1415        for value in &self.values {
1416            write!(f, " {value}")?;
1417        }
1418        Ok(())
1419    }
1420}
1421
1422impl fmt::Display for Directive {
1423    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1424        match self {
1425            Self::Transaction(t) => write!(f, "{t}"),
1426            Self::Balance(b) => write!(f, "{b}"),
1427            Self::Open(o) => write!(f, "{o}"),
1428            Self::Close(c) => write!(f, "{c}"),
1429            Self::Commodity(c) => write!(f, "{c}"),
1430            Self::Pad(p) => write!(f, "{p}"),
1431            Self::Event(e) => write!(f, "{e}"),
1432            Self::Query(q) => write!(f, "{q}"),
1433            Self::Note(n) => write!(f, "{n}"),
1434            Self::Document(d) => write!(f, "{d}"),
1435            Self::Price(p) => write!(f, "{p}"),
1436            Self::Custom(c) => write!(f, "{c}"),
1437        }
1438    }
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443    use super::*;
1444    use rust_decimal_macros::dec;
1445
1446    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1447        crate::naive_date(year, month, day).unwrap()
1448    }
1449
1450    #[test]
1451    fn test_transaction() {
1452        let txn = Transaction::new(date(2024, 1, 15), "Grocery shopping")
1453            .with_payee("Whole Foods")
1454            .with_flag('*')
1455            .with_tag("food")
1456            .with_synthesized_posting(Posting::new(
1457                "Expenses:Food",
1458                Amount::new(dec!(50.00), "USD"),
1459            ))
1460            .with_synthesized_posting(Posting::auto("Assets:Checking"));
1461
1462        assert_eq!(txn.flag, '*');
1463        assert_eq!(txn.payee.as_deref(), Some("Whole Foods"));
1464        assert_eq!(txn.postings.len(), 2);
1465        assert!(txn.is_complete());
1466    }
1467
1468    #[test]
1469    fn test_balance() {
1470        let bal = Balance::new(
1471            date(2024, 1, 1),
1472            "Assets:Checking",
1473            Amount::new(dec!(1000.00), "USD"),
1474        );
1475
1476        assert_eq!(bal.account, "Assets:Checking");
1477        assert_eq!(bal.amount.number, dec!(1000.00));
1478    }
1479
1480    #[test]
1481    fn test_open() {
1482        let open = Open::new(date(2024, 1, 1), "Assets:Bank:Checking")
1483            .with_currencies(vec!["USD".into()])
1484            .with_booking("FIFO");
1485
1486        assert_eq!(open.currencies, vec![InternedStr::from("USD")]);
1487        assert_eq!(open.booking, Some("FIFO".to_string()));
1488    }
1489
1490    #[test]
1491    fn test_directive_date() {
1492        let txn = Transaction::new(date(2024, 1, 15), "Test");
1493        let dir = Directive::Transaction(txn);
1494
1495        assert_eq!(dir.date(), date(2024, 1, 15));
1496        assert!(dir.is_transaction());
1497        assert_eq!(dir.type_name(), "transaction");
1498    }
1499
1500    #[test]
1501    fn test_posting_display() {
1502        let posting = Posting::new("Assets:Checking", Amount::new(dec!(100.00), "USD"));
1503        let s = format!("{posting}");
1504        assert!(s.contains("Assets:Checking"));
1505        assert!(s.contains("100.00 USD"));
1506    }
1507
1508    #[test]
1509    fn test_transaction_display() {
1510        let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1511            .with_payee("Test Payee")
1512            .with_synthesized_posting(Posting::new(
1513                "Expenses:Test",
1514                Amount::new(dec!(50.00), "USD"),
1515            ))
1516            .with_synthesized_posting(Posting::auto("Assets:Cash"));
1517
1518        let s = format!("{txn}");
1519        assert!(s.contains("2024-01-15"));
1520        assert!(s.contains("Test Payee"));
1521        assert!(s.contains("Test transaction"));
1522    }
1523
1524    #[test]
1525    fn test_directive_priority() {
1526        // Test that priorities are ordered correctly
1527        assert!(DirectivePriority::Open < DirectivePriority::Transaction);
1528        assert!(DirectivePriority::Pad < DirectivePriority::Balance);
1529        assert!(DirectivePriority::Balance < DirectivePriority::Transaction);
1530        assert!(DirectivePriority::Transaction < DirectivePriority::Close);
1531        assert!(DirectivePriority::Price < DirectivePriority::Close);
1532    }
1533
1534    #[test]
1535    fn test_sort_directives_by_date() {
1536        let mut directives = vec![
1537            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Third")),
1538            Directive::Transaction(Transaction::new(date(2024, 1, 1), "First")),
1539            Directive::Transaction(Transaction::new(date(2024, 1, 10), "Second")),
1540        ];
1541
1542        sort_directives(&mut directives);
1543
1544        assert_eq!(directives[0].date(), date(2024, 1, 1));
1545        assert_eq!(directives[1].date(), date(2024, 1, 10));
1546        assert_eq!(directives[2].date(), date(2024, 1, 15));
1547    }
1548
1549    #[test]
1550    fn test_sort_directives_by_type_same_date() {
1551        // On the same date, open should come before transaction, transaction before close
1552        let mut directives = vec![
1553            Directive::Close(Close::new(date(2024, 1, 1), "Assets:Bank")),
1554            Directive::Transaction(Transaction::new(date(2024, 1, 1), "Payment")),
1555            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1556            Directive::Balance(Balance::new(
1557                date(2024, 1, 1),
1558                "Assets:Bank",
1559                Amount::new(dec!(0), "USD"),
1560            )),
1561        ];
1562
1563        sort_directives(&mut directives);
1564
1565        assert_eq!(directives[0].type_name(), "open");
1566        assert_eq!(directives[1].type_name(), "balance");
1567        assert_eq!(directives[2].type_name(), "transaction");
1568        assert_eq!(directives[3].type_name(), "close");
1569    }
1570
1571    #[test]
1572    fn test_sort_directives_pad_before_balance() {
1573        // Pad must come before balance assertion on the same day
1574        let mut directives = vec![
1575            Directive::Balance(Balance::new(
1576                date(2024, 1, 1),
1577                "Assets:Bank",
1578                Amount::new(dec!(1000), "USD"),
1579            )),
1580            Directive::Pad(Pad::new(
1581                date(2024, 1, 1),
1582                "Assets:Bank",
1583                "Equity:Opening-Balances",
1584            )),
1585        ];
1586
1587        sort_directives(&mut directives);
1588
1589        assert_eq!(directives[0].type_name(), "pad");
1590        assert_eq!(directives[1].type_name(), "balance");
1591    }
1592
1593    #[test]
1594    fn test_sort_augmentations_before_reductions_same_date() {
1595        // Issue #841: same-date transactions should process augmentations
1596        // (buying lots) before reductions (selling lots) so lots exist
1597        // when they're matched.
1598        let reduction = Directive::Transaction(
1599            Transaction::new(date(2024, 9, 1), "Transfer Received")
1600                .with_synthesized_posting(
1601                    Posting::new("Assets:AccountB", Amount::new(dec!(11.11), "USD")).with_cost(
1602                        CostSpec::empty()
1603                            .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1604                            .with_currency("EUR"),
1605                    ),
1606                )
1607                .with_synthesized_posting(
1608                    Posting::new("Assets:Transit", Amount::new(dec!(-11.11), "USD")).with_cost(
1609                        CostSpec::empty()
1610                            .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1611                            .with_currency("EUR"),
1612                    ),
1613                ),
1614        );
1615
1616        let augmentation = Directive::Transaction(
1617            Transaction::new(date(2024, 9, 1), "Transfer Sent")
1618                .with_synthesized_posting(Posting::new(
1619                    "Assets:AccountA",
1620                    Amount::new(dec!(-10.00), "EUR"),
1621                ))
1622                .with_synthesized_posting(
1623                    Posting::new("Assets:Transit", Amount::new(dec!(11.11), "USD")).with_cost(
1624                        CostSpec::empty()
1625                            .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1626                            .with_currency("EUR"),
1627                    ),
1628                ),
1629        );
1630
1631        // Reduction first in file order — sort should fix this
1632        let mut directives = vec![reduction, augmentation];
1633        sort_directives(&mut directives);
1634
1635        // Augmentation (no negative cost posting) should come first
1636        assert!(
1637            !directives[0].has_cost_reduction(),
1638            "first directive should be augmentation"
1639        );
1640        assert!(
1641            directives[1].has_cost_reduction(),
1642            "second directive should be reduction"
1643        );
1644    }
1645
1646    #[test]
1647    fn test_has_cost_reduction() {
1648        // Transaction with negative units + cost = reduction
1649        let reduction = Directive::Transaction(
1650            Transaction::new(date(2024, 1, 1), "Sell")
1651                .with_synthesized_posting(
1652                    Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL")).with_cost(
1653                        CostSpec::empty()
1654                            .with_number(crate::CostNumber::PerUnit { value: dec!(150) })
1655                            .with_currency("USD"),
1656                    ),
1657                )
1658                .with_synthesized_posting(Posting::new(
1659                    "Assets:Cash",
1660                    Amount::new(dec!(1500), "USD"),
1661                )),
1662        );
1663        assert!(reduction.has_cost_reduction());
1664
1665        // Transaction with positive units + cost = augmentation
1666        let augmentation = Directive::Transaction(
1667            Transaction::new(date(2024, 1, 1), "Buy")
1668                .with_synthesized_posting(
1669                    Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1670                        CostSpec::empty()
1671                            .with_number(crate::CostNumber::PerUnit { value: dec!(150) })
1672                            .with_currency("USD"),
1673                    ),
1674                )
1675                .with_synthesized_posting(Posting::new(
1676                    "Assets:Cash",
1677                    Amount::new(dec!(-1500), "USD"),
1678                )),
1679        );
1680        assert!(!augmentation.has_cost_reduction());
1681
1682        // Transaction without cost = not a reduction
1683        let simple = Directive::Transaction(
1684            Transaction::new(date(2024, 1, 1), "Payment")
1685                .with_synthesized_posting(Posting::new(
1686                    "Expenses:Food",
1687                    Amount::new(dec!(50), "USD"),
1688                ))
1689                .with_synthesized_posting(Posting::new(
1690                    "Assets:Cash",
1691                    Amount::new(dec!(-50), "USD"),
1692                )),
1693        );
1694        assert!(!simple.has_cost_reduction());
1695    }
1696
1697    #[test]
1698    fn test_transaction_flags() {
1699        let make_txn = |flag: char| Transaction::new(date(2024, 1, 15), "Test").with_flag(flag);
1700
1701        // Standard flags
1702        assert!(make_txn('*').is_complete());
1703        assert!(make_txn('!').is_incomplete());
1704        assert!(make_txn('!').is_pending());
1705
1706        // Extended flags
1707        assert!(make_txn('S').is_summarization());
1708        assert!(make_txn('T').is_transfer());
1709        assert!(make_txn('C').is_conversion());
1710        assert!(make_txn('U').is_unrealized());
1711        assert!(make_txn('R').is_return());
1712        assert!(make_txn('M').is_merge());
1713        assert!(make_txn('#').is_bookmarked());
1714        assert!(make_txn('?').needs_investigation());
1715
1716        // Negative cases
1717        assert!(!make_txn('*').is_pending());
1718        assert!(!make_txn('!').is_complete());
1719    }
1720
1721    #[test]
1722    fn test_is_valid_flag() {
1723        // Valid flags
1724        for flag in [
1725            '*', '!', 'P', 'S', 'T', 'C', 'U', 'R', 'M', '#', '?', '%', '&',
1726        ] {
1727            assert!(
1728                Transaction::is_valid_flag(flag),
1729                "Flag '{flag}' should be valid"
1730            );
1731        }
1732
1733        // Invalid flags
1734        for flag in ['x', 'X', '0', ' ', 'a', 'Z'] {
1735            assert!(
1736                !Transaction::is_valid_flag(flag),
1737                "Flag '{flag}' should be invalid"
1738            );
1739        }
1740    }
1741
1742    #[test]
1743    fn test_transaction_display_includes_metadata() {
1744        let mut meta = Metadata::default();
1745        meta.insert(
1746            "document".to_string(),
1747            MetaValue::String("myfile.pdf".to_string()),
1748        );
1749
1750        let txn = Transaction {
1751            date: date(2026, 2, 23),
1752            flag: '*',
1753            payee: None,
1754            narration: "Example".into(),
1755            tags: vec![],
1756            links: vec![],
1757            meta,
1758            postings: vec![
1759                crate::Spanned::synthesized(Posting::new(
1760                    "Assets:Bank",
1761                    Amount::new(dec!(-2), "USD"),
1762                )),
1763                crate::Spanned::synthesized(Posting::auto("Expenses:Example")),
1764            ],
1765            trailing_comments: Vec::new(),
1766        };
1767
1768        let output = txn.to_string();
1769        assert!(
1770            output.contains("document: \"myfile.pdf\""),
1771            "Transaction Display should include metadata: {output}"
1772        );
1773        assert!(
1774            output.contains("Assets:Bank"),
1775            "Transaction Display should include postings: {output}"
1776        );
1777    }
1778
1779    #[test]
1780    fn test_posting_display_includes_metadata() {
1781        let mut meta = Metadata::default();
1782        meta.insert(
1783            "category".to_string(),
1784            MetaValue::String("groceries".to_string()),
1785        );
1786
1787        let posting = Posting {
1788            account: "Expenses:Food".into(),
1789            units: Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD"))),
1790            cost: None,
1791            price: None,
1792            flag: None,
1793            meta,
1794            comments: Vec::new(),
1795            trailing_comments: Vec::new(),
1796        };
1797
1798        let output = posting.to_string();
1799        assert!(
1800            output.contains("category: \"groceries\""),
1801            "Posting Display should include metadata: {output}"
1802        );
1803    }
1804
1805    #[test]
1806    fn test_directive_display() {
1807        // Test that Directive enum delegates to inner type's Display
1808        let txn = Transaction::new(date(2024, 1, 15), "Test transaction");
1809        let dir = Directive::Transaction(txn.clone());
1810
1811        // Directive::Display should produce same output as Transaction::Display
1812        assert_eq!(format!("{dir}"), format!("{txn}"));
1813
1814        // Test other directive types
1815        let open = Open::new(date(2024, 1, 1), "Assets:Bank");
1816        let dir_open = Directive::Open(open.clone());
1817        assert_eq!(format!("{dir_open}"), format!("{open}"));
1818
1819        let balance = Balance::new(
1820            date(2024, 1, 1),
1821            "Assets:Bank",
1822            Amount::new(dec!(100), "USD"),
1823        );
1824        let dir_balance = Directive::Balance(balance.clone());
1825        assert_eq!(format!("{dir_balance}"), format!("{balance}"));
1826    }
1827
1828    // ----- parse_precision_meta (issue #991) ---------------------------------
1829
1830    #[test]
1831    fn parse_precision_meta_accepts_non_negative_integers() {
1832        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0))), Ok(0));
1833        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2))), Ok(2));
1834        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(28))), Ok(28));
1835        // Integer-valued decimals (e.g. `precision: 2.0` in source) must
1836        // round-trip the same as `precision: 2` — the parser will produce
1837        // `Number(dec!(2.0))` for the dotted form.
1838        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2.0))), Ok(2));
1839        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0.000))), Ok(0));
1840    }
1841
1842    #[test]
1843    fn parse_precision_meta_rejects_negatives() {
1844        let err = parse_precision_meta(&MetaValue::Number(dec!(-1))).unwrap_err();
1845        assert!(err.contains("non-negative"), "got: {err}");
1846    }
1847
1848    #[test]
1849    fn parse_precision_meta_rejects_fractional() {
1850        let err = parse_precision_meta(&MetaValue::Number(dec!(2.5))).unwrap_err();
1851        assert!(err.contains("integer"), "got: {err}");
1852    }
1853
1854    #[test]
1855    fn parse_precision_meta_rejects_overflow() {
1856        // 2^33 — out of u32 range.
1857        let err = parse_precision_meta(&MetaValue::Number(dec!(8589934592))).unwrap_err();
1858        assert!(err.contains("exceeds"), "got: {err}");
1859    }
1860
1861    #[test]
1862    fn parse_precision_meta_rejects_non_number_variants() {
1863        // Cover every non-Number `MetaValue` variant so the kind-labeling
1864        // arms in `meta_value_kind` are all exercised. Each error message
1865        // names the kind ("string value", "bool value", etc.) so users
1866        // see what they actually wrote.
1867        use crate::Amount;
1868        use rust_decimal_macros::dec;
1869        let cases = [
1870            (MetaValue::String("2".into()), "string"),
1871            (MetaValue::Account("Assets:Cash".into()), "account"),
1872            (MetaValue::Currency("USD".into()), "currency"),
1873            (MetaValue::Tag("foo".into()), "tag"),
1874            (MetaValue::Link("bar".into()), "link"),
1875            (MetaValue::Date(date(2024, 1, 1)), "date"),
1876            (MetaValue::Bool(true), "bool"),
1877            (MetaValue::Amount(Amount::new(dec!(2), "USD")), "amount"),
1878            (MetaValue::None, "none"),
1879        ];
1880        for (case, kind) in cases {
1881            let err = match parse_precision_meta(&case) {
1882                Ok(_) => panic!("should have rejected {case:?}"),
1883                Err(e) => e,
1884            };
1885            assert!(
1886                err.contains(kind),
1887                "error for {case:?} should mention kind {kind:?}, got: {err}"
1888            );
1889        }
1890    }
1891}