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