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