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