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::{Amount, CostSpec, IncompleteAmount};
25
26/// Metadata value types.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub enum MetaValue {
29    /// String value
30    String(String),
31    /// Account reference
32    Account(String),
33    /// Currency code
34    Currency(String),
35    /// Tag reference
36    Tag(String),
37    /// Link reference
38    Link(String),
39    /// Date value
40    Date(NaiveDate),
41    /// Numeric value
42    Number(Decimal),
43    /// Boolean value
44    Bool(bool),
45    /// Amount value
46    Amount(Amount),
47    /// Null/None value
48    None,
49}
50
51impl fmt::Display for MetaValue {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::String(s) => write!(f, "\"{s}\""),
55            Self::Account(a) => write!(f, "{a}"),
56            Self::Currency(c) => write!(f, "{c}"),
57            Self::Tag(t) => write!(f, "#{t}"),
58            Self::Link(l) => write!(f, "^{l}"),
59            Self::Date(d) => write!(f, "{d}"),
60            Self::Number(n) => write!(f, "{n}"),
61            Self::Bool(b) => write!(f, "{b}"),
62            Self::Amount(a) => write!(f, "{a}"),
63            Self::None => write!(f, "None"),
64        }
65    }
66}
67
68/// Metadata is a key-value map attached to directives and postings.
69pub type Metadata = HashMap<String, MetaValue>;
70
71/// A posting within a transaction.
72///
73/// Postings represent the individual legs of a transaction. Each posting
74/// specifies an account and optionally an amount, cost, and price.
75///
76/// When the units are `None`, the entire amount will be inferred by the
77/// interpolation algorithm to balance the transaction. When units is
78/// `Some(IncompleteAmount)`, it may still have missing components that
79/// need to be filled in.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct Posting {
82    /// The account for this posting
83    pub account: String,
84    /// The units (may be incomplete or None for auto-calculated postings)
85    pub units: Option<IncompleteAmount>,
86    /// Cost specification for the position
87    pub cost: Option<CostSpec>,
88    /// Price annotation (@ or @@)
89    pub price: Option<PriceAnnotation>,
90    /// Whether this posting has the "!" flag
91    pub flag: Option<char>,
92    /// Posting metadata
93    pub meta: Metadata,
94}
95
96impl Posting {
97    /// Create a new posting with the given account and complete units.
98    #[must_use]
99    pub fn new(account: impl Into<String>, units: Amount) -> Self {
100        Self {
101            account: account.into(),
102            units: Some(IncompleteAmount::Complete(units)),
103            cost: None,
104            price: None,
105            flag: None,
106            meta: Metadata::new(),
107        }
108    }
109
110    /// Create a new posting with an incomplete amount.
111    #[must_use]
112    pub fn with_incomplete(account: impl Into<String>, units: IncompleteAmount) -> Self {
113        Self {
114            account: account.into(),
115            units: Some(units),
116            cost: None,
117            price: None,
118            flag: None,
119            meta: Metadata::new(),
120        }
121    }
122
123    /// Create a posting without any amount (to be fully interpolated).
124    #[must_use]
125    pub fn auto(account: impl Into<String>) -> Self {
126        Self {
127            account: account.into(),
128            units: None,
129            cost: None,
130            price: None,
131            flag: None,
132            meta: Metadata::new(),
133        }
134    }
135
136    /// Get the complete amount if available.
137    #[must_use]
138    pub fn amount(&self) -> Option<&Amount> {
139        self.units.as_ref().and_then(|u| u.as_amount())
140    }
141
142    /// Add a cost specification.
143    #[must_use]
144    pub fn with_cost(mut self, cost: CostSpec) -> Self {
145        self.cost = Some(cost);
146        self
147    }
148
149    /// Add a price annotation.
150    #[must_use]
151    pub fn with_price(mut self, price: PriceAnnotation) -> Self {
152        self.price = Some(price);
153        self
154    }
155
156    /// Add a flag.
157    #[must_use]
158    pub const fn with_flag(mut self, flag: char) -> Self {
159        self.flag = Some(flag);
160        self
161    }
162
163    /// Check if this posting has an amount.
164    #[must_use]
165    pub const fn has_units(&self) -> bool {
166        self.units.is_some()
167    }
168}
169
170impl fmt::Display for Posting {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        write!(f, "  ")?;
173        if let Some(flag) = self.flag {
174            write!(f, "{flag} ")?;
175        }
176        write!(f, "{}", self.account)?;
177        if let Some(units) = &self.units {
178            write!(f, "  {units}")?;
179        }
180        if let Some(cost) = &self.cost {
181            write!(f, " {cost}")?;
182        }
183        if let Some(price) = &self.price {
184            write!(f, " {price}")?;
185        }
186        Ok(())
187    }
188}
189
190/// Price annotation for a posting (@ or @@).
191///
192/// Price annotations can be incomplete (missing number or currency)
193/// before interpolation fills in the missing values.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub enum PriceAnnotation {
196    /// Per-unit price (@) with complete amount
197    Unit(Amount),
198    /// Total price (@@) with complete amount
199    Total(Amount),
200    /// Per-unit price (@) with incomplete amount
201    UnitIncomplete(IncompleteAmount),
202    /// Total price (@@) with incomplete amount
203    TotalIncomplete(IncompleteAmount),
204    /// Empty per-unit price (@ with no amount)
205    UnitEmpty,
206    /// Empty total price (@@ with no amount)
207    TotalEmpty,
208}
209
210impl PriceAnnotation {
211    /// Get the complete amount if available.
212    #[must_use]
213    pub const fn amount(&self) -> Option<&Amount> {
214        match self {
215            Self::Unit(a) | Self::Total(a) => Some(a),
216            Self::UnitIncomplete(ia) | Self::TotalIncomplete(ia) => ia.as_amount(),
217            Self::UnitEmpty | Self::TotalEmpty => None,
218        }
219    }
220
221    /// Check if this is a per-unit price (@ vs @@).
222    #[must_use]
223    pub const fn is_unit(&self) -> bool {
224        matches!(
225            self,
226            Self::Unit(_) | Self::UnitIncomplete(_) | Self::UnitEmpty
227        )
228    }
229}
230
231impl fmt::Display for PriceAnnotation {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        match self {
234            Self::Unit(a) => write!(f, "@ {a}"),
235            Self::Total(a) => write!(f, "@@ {a}"),
236            Self::UnitIncomplete(ia) => write!(f, "@ {ia}"),
237            Self::TotalIncomplete(ia) => write!(f, "@@ {ia}"),
238            Self::UnitEmpty => write!(f, "@"),
239            Self::TotalEmpty => write!(f, "@@"),
240        }
241    }
242}
243
244/// Directive ordering priority for sorting.
245///
246/// When directives have the same date, they are sorted by type priority
247/// to ensure proper processing order.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
249pub enum DirectivePriority {
250    /// Open accounts first so they exist before use
251    Open = 0,
252    /// Commodities declared before use
253    Commodity = 1,
254    /// Padding before balance assertions
255    Pad = 2,
256    /// Balance assertions checked at start of day
257    Balance = 3,
258    /// Main entries
259    Transaction = 4,
260    /// Annotations after transactions
261    Note = 5,
262    /// Attachments after transactions
263    Document = 6,
264    /// State changes
265    Event = 7,
266    /// Queries defined after data
267    Query = 8,
268    /// Prices at end of day
269    Price = 9,
270    /// Accounts closed after all activity
271    Close = 10,
272    /// User extensions last
273    Custom = 11,
274}
275
276/// All directive types in beancount.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub enum Directive {
279    /// Transaction directive - records transfers between accounts
280    Transaction(Transaction),
281    /// Balance assertion - asserts an account balance at a point in time
282    Balance(Balance),
283    /// Open account - opens an account for use
284    Open(Open),
285    /// Close account - closes an account
286    Close(Close),
287    /// Commodity declaration - declares a currency/commodity
288    Commodity(Commodity),
289    /// Pad directive - auto-pad an account to match a balance
290    Pad(Pad),
291    /// Event directive - records a life event
292    Event(Event),
293    /// Query directive - stores a named BQL query
294    Query(Query),
295    /// Note directive - adds a note to an account
296    Note(Note),
297    /// Document directive - links a document to an account
298    Document(Document),
299    /// Price directive - records a commodity price
300    Price(Price),
301    /// Custom directive - custom user-defined directive
302    Custom(Custom),
303}
304
305impl Directive {
306    /// Get the date of this directive.
307    #[must_use]
308    pub const fn date(&self) -> NaiveDate {
309        match self {
310            Self::Transaction(t) => t.date,
311            Self::Balance(b) => b.date,
312            Self::Open(o) => o.date,
313            Self::Close(c) => c.date,
314            Self::Commodity(c) => c.date,
315            Self::Pad(p) => p.date,
316            Self::Event(e) => e.date,
317            Self::Query(q) => q.date,
318            Self::Note(n) => n.date,
319            Self::Document(d) => d.date,
320            Self::Price(p) => p.date,
321            Self::Custom(c) => c.date,
322        }
323    }
324
325    /// Get the metadata of this directive.
326    #[must_use]
327    pub const fn meta(&self) -> &Metadata {
328        match self {
329            Self::Transaction(t) => &t.meta,
330            Self::Balance(b) => &b.meta,
331            Self::Open(o) => &o.meta,
332            Self::Close(c) => &c.meta,
333            Self::Commodity(c) => &c.meta,
334            Self::Pad(p) => &p.meta,
335            Self::Event(e) => &e.meta,
336            Self::Query(q) => &q.meta,
337            Self::Note(n) => &n.meta,
338            Self::Document(d) => &d.meta,
339            Self::Price(p) => &p.meta,
340            Self::Custom(c) => &c.meta,
341        }
342    }
343
344    /// Check if this is a transaction.
345    #[must_use]
346    pub const fn is_transaction(&self) -> bool {
347        matches!(self, Self::Transaction(_))
348    }
349
350    /// Get as a transaction, if this is one.
351    #[must_use]
352    pub const fn as_transaction(&self) -> Option<&Transaction> {
353        match self {
354            Self::Transaction(t) => Some(t),
355            _ => None,
356        }
357    }
358
359    /// Get the directive type name.
360    #[must_use]
361    pub const fn type_name(&self) -> &'static str {
362        match self {
363            Self::Transaction(_) => "transaction",
364            Self::Balance(_) => "balance",
365            Self::Open(_) => "open",
366            Self::Close(_) => "close",
367            Self::Commodity(_) => "commodity",
368            Self::Pad(_) => "pad",
369            Self::Event(_) => "event",
370            Self::Query(_) => "query",
371            Self::Note(_) => "note",
372            Self::Document(_) => "document",
373            Self::Price(_) => "price",
374            Self::Custom(_) => "custom",
375        }
376    }
377
378    /// Get the sorting priority for this directive.
379    ///
380    /// Used to determine order when directives have the same date.
381    #[must_use]
382    pub const fn priority(&self) -> DirectivePriority {
383        match self {
384            Self::Open(_) => DirectivePriority::Open,
385            Self::Commodity(_) => DirectivePriority::Commodity,
386            Self::Pad(_) => DirectivePriority::Pad,
387            Self::Balance(_) => DirectivePriority::Balance,
388            Self::Transaction(_) => DirectivePriority::Transaction,
389            Self::Note(_) => DirectivePriority::Note,
390            Self::Document(_) => DirectivePriority::Document,
391            Self::Event(_) => DirectivePriority::Event,
392            Self::Query(_) => DirectivePriority::Query,
393            Self::Price(_) => DirectivePriority::Price,
394            Self::Close(_) => DirectivePriority::Close,
395            Self::Custom(_) => DirectivePriority::Custom,
396        }
397    }
398}
399
400/// Sort directives by date, then by type priority.
401///
402/// This is a stable sort that preserves file order for directives
403/// with the same date and type.
404pub fn sort_directives(directives: &mut [Directive]) {
405    directives.sort_by(|a, b| {
406        // Primary: date ascending
407        a.date()
408            .cmp(&b.date())
409            // Secondary: type priority
410            .then_with(|| a.priority().cmp(&b.priority()))
411    });
412}
413
414/// A transaction directive.
415///
416/// Transactions are the most common directive type. They record transfers
417/// between accounts and must balance (sum of all postings equals zero).
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419pub struct Transaction {
420    /// Transaction date
421    pub date: NaiveDate,
422    /// Transaction flag (* or !)
423    pub flag: char,
424    /// Payee (optional)
425    pub payee: Option<String>,
426    /// Narration (description)
427    pub narration: String,
428    /// Tags attached to this transaction
429    pub tags: Vec<String>,
430    /// Links attached to this transaction
431    pub links: Vec<String>,
432    /// Transaction metadata
433    pub meta: Metadata,
434    /// Postings (account entries)
435    pub postings: Vec<Posting>,
436}
437
438impl Transaction {
439    /// Create a new transaction.
440    #[must_use]
441    pub fn new(date: NaiveDate, narration: impl Into<String>) -> Self {
442        Self {
443            date,
444            flag: '*',
445            payee: None,
446            narration: narration.into(),
447            tags: Vec::new(),
448            links: Vec::new(),
449            meta: Metadata::new(),
450            postings: Vec::new(),
451        }
452    }
453
454    /// Set the flag.
455    #[must_use]
456    pub const fn with_flag(mut self, flag: char) -> Self {
457        self.flag = flag;
458        self
459    }
460
461    /// Set the payee.
462    #[must_use]
463    pub fn with_payee(mut self, payee: impl Into<String>) -> Self {
464        self.payee = Some(payee.into());
465        self
466    }
467
468    /// Add a tag.
469    #[must_use]
470    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
471        self.tags.push(tag.into());
472        self
473    }
474
475    /// Add a link.
476    #[must_use]
477    pub fn with_link(mut self, link: impl Into<String>) -> Self {
478        self.links.push(link.into());
479        self
480    }
481
482    /// Add a posting.
483    #[must_use]
484    pub fn with_posting(mut self, posting: Posting) -> Self {
485        self.postings.push(posting);
486        self
487    }
488
489    /// Check if this transaction is marked as complete (*).
490    #[must_use]
491    pub const fn is_complete(&self) -> bool {
492        self.flag == '*'
493    }
494
495    /// Check if this transaction is marked as incomplete (!).
496    #[must_use]
497    pub const fn is_incomplete(&self) -> bool {
498        self.flag == '!'
499    }
500}
501
502impl fmt::Display for Transaction {
503    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
504        write!(f, "{} {} ", self.date, self.flag)?;
505        if let Some(payee) = &self.payee {
506            write!(f, "\"{payee}\" ")?;
507        }
508        write!(f, "\"{}\"", self.narration)?;
509        for tag in &self.tags {
510            write!(f, " #{tag}")?;
511        }
512        for link in &self.links {
513            write!(f, " ^{link}")?;
514        }
515        for posting in &self.postings {
516            write!(f, "\n{posting}")?;
517        }
518        Ok(())
519    }
520}
521
522/// A balance assertion directive.
523///
524/// Asserts that an account has a specific balance at the beginning of a date.
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
526pub struct Balance {
527    /// Assertion date
528    pub date: NaiveDate,
529    /// Account to check
530    pub account: String,
531    /// Expected amount
532    pub amount: Amount,
533    /// Tolerance (if explicitly specified)
534    pub tolerance: Option<Decimal>,
535    /// Metadata
536    pub meta: Metadata,
537}
538
539impl Balance {
540    /// Create a new balance assertion.
541    #[must_use]
542    pub fn new(date: NaiveDate, account: impl Into<String>, amount: Amount) -> Self {
543        Self {
544            date,
545            account: account.into(),
546            amount,
547            tolerance: None,
548            meta: Metadata::new(),
549        }
550    }
551
552    /// Set explicit tolerance.
553    #[must_use]
554    pub const fn with_tolerance(mut self, tolerance: Decimal) -> Self {
555        self.tolerance = Some(tolerance);
556        self
557    }
558}
559
560impl fmt::Display for Balance {
561    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
562        write!(f, "{} balance {} {}", self.date, self.account, self.amount)?;
563        if let Some(tol) = self.tolerance {
564            write!(f, " ~ {tol}")?;
565        }
566        Ok(())
567    }
568}
569
570/// An open account directive.
571///
572/// Opens an account for use. Accounts must be opened before they can be used.
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
574pub struct Open {
575    /// Date account was opened
576    pub date: NaiveDate,
577    /// Account name (e.g., "Assets:Bank:Checking")
578    pub account: String,
579    /// Allowed currencies (empty = any currency allowed)
580    pub currencies: Vec<String>,
581    /// Booking method for this account
582    pub booking: Option<String>,
583    /// Metadata
584    pub meta: Metadata,
585}
586
587impl Open {
588    /// Create a new open directive.
589    #[must_use]
590    pub fn new(date: NaiveDate, account: impl Into<String>) -> Self {
591        Self {
592            date,
593            account: account.into(),
594            currencies: Vec::new(),
595            booking: None,
596            meta: Metadata::new(),
597        }
598    }
599
600    /// Set allowed currencies.
601    #[must_use]
602    pub fn with_currencies(mut self, currencies: Vec<String>) -> Self {
603        self.currencies = currencies;
604        self
605    }
606
607    /// Set booking method.
608    #[must_use]
609    pub fn with_booking(mut self, booking: impl Into<String>) -> Self {
610        self.booking = Some(booking.into());
611        self
612    }
613}
614
615impl fmt::Display for Open {
616    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
617        write!(f, "{} open {}", self.date, self.account)?;
618        if !self.currencies.is_empty() {
619            write!(f, " {}", self.currencies.join(","))?;
620        }
621        if let Some(booking) = &self.booking {
622            write!(f, " \"{booking}\"")?;
623        }
624        Ok(())
625    }
626}
627
628/// A close account directive.
629///
630/// Closes an account. The account should have zero balance when closed.
631#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
632pub struct Close {
633    /// Date account was closed
634    pub date: NaiveDate,
635    /// Account name
636    pub account: String,
637    /// Metadata
638    pub meta: Metadata,
639}
640
641impl Close {
642    /// Create a new close directive.
643    #[must_use]
644    pub fn new(date: NaiveDate, account: impl Into<String>) -> Self {
645        Self {
646            date,
647            account: account.into(),
648            meta: Metadata::new(),
649        }
650    }
651}
652
653impl fmt::Display for Close {
654    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655        write!(f, "{} close {}", self.date, self.account)
656    }
657}
658
659/// A commodity declaration directive.
660///
661/// Declares a commodity/currency that can be used in the ledger.
662#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
663pub struct Commodity {
664    /// Declaration date
665    pub date: NaiveDate,
666    /// Currency/commodity code (e.g., "USD", "AAPL")
667    pub currency: String,
668    /// Metadata
669    pub meta: Metadata,
670}
671
672impl Commodity {
673    /// Create a new commodity declaration.
674    #[must_use]
675    pub fn new(date: NaiveDate, currency: impl Into<String>) -> Self {
676        Self {
677            date,
678            currency: currency.into(),
679            meta: Metadata::new(),
680        }
681    }
682}
683
684impl fmt::Display for Commodity {
685    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
686        write!(f, "{} commodity {}", self.date, self.currency)
687    }
688}
689
690/// A pad directive.
691///
692/// Automatically inserts a transaction to pad an account to match
693/// a subsequent balance assertion.
694#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
695pub struct Pad {
696    /// Pad date
697    pub date: NaiveDate,
698    /// Account to pad
699    pub account: String,
700    /// Source account for padding (e.g., Equity:Opening-Balances)
701    pub source_account: String,
702    /// Metadata
703    pub meta: Metadata,
704}
705
706impl Pad {
707    /// Create a new pad directive.
708    #[must_use]
709    pub fn new(
710        date: NaiveDate,
711        account: impl Into<String>,
712        source_account: impl Into<String>,
713    ) -> Self {
714        Self {
715            date,
716            account: account.into(),
717            source_account: source_account.into(),
718            meta: Metadata::new(),
719        }
720    }
721}
722
723impl fmt::Display for Pad {
724    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
725        write!(
726            f,
727            "{} pad {} {}",
728            self.date, self.account, self.source_account
729        )
730    }
731}
732
733/// An event directive.
734///
735/// Records a life event (e.g., location changes, employment changes).
736#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
737pub struct Event {
738    /// Event date
739    pub date: NaiveDate,
740    /// Event type (e.g., "location", "employer")
741    pub event_type: String,
742    /// Event value
743    pub value: String,
744    /// Metadata
745    pub meta: Metadata,
746}
747
748impl Event {
749    /// Create a new event directive.
750    #[must_use]
751    pub fn new(date: NaiveDate, event_type: impl Into<String>, value: impl Into<String>) -> Self {
752        Self {
753            date,
754            event_type: event_type.into(),
755            value: value.into(),
756            meta: Metadata::new(),
757        }
758    }
759}
760
761impl fmt::Display for Event {
762    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
763        write!(
764            f,
765            "{} event \"{}\" \"{}\"",
766            self.date, self.event_type, self.value
767        )
768    }
769}
770
771/// A query directive.
772///
773/// Stores a named BQL query that can be referenced later.
774#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
775pub struct Query {
776    /// Query date
777    pub date: NaiveDate,
778    /// Query name
779    pub name: String,
780    /// BQL query string
781    pub query: String,
782    /// Metadata
783    pub meta: Metadata,
784}
785
786impl Query {
787    /// Create a new query directive.
788    #[must_use]
789    pub fn new(date: NaiveDate, name: impl Into<String>, query: impl Into<String>) -> Self {
790        Self {
791            date,
792            name: name.into(),
793            query: query.into(),
794            meta: Metadata::new(),
795        }
796    }
797}
798
799impl fmt::Display for Query {
800    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
801        write!(
802            f,
803            "{} query \"{}\" \"{}\"",
804            self.date, self.name, self.query
805        )
806    }
807}
808
809/// A note directive.
810///
811/// Adds a note/comment to an account on a specific date.
812#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
813pub struct Note {
814    /// Note date
815    pub date: NaiveDate,
816    /// Account
817    pub account: String,
818    /// Note text
819    pub comment: String,
820    /// Metadata
821    pub meta: Metadata,
822}
823
824impl Note {
825    /// Create a new note directive.
826    #[must_use]
827    pub fn new(date: NaiveDate, account: impl Into<String>, comment: impl Into<String>) -> Self {
828        Self {
829            date,
830            account: account.into(),
831            comment: comment.into(),
832            meta: Metadata::new(),
833        }
834    }
835}
836
837impl fmt::Display for Note {
838    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
839        write!(
840            f,
841            "{} note {} \"{}\"",
842            self.date, self.account, self.comment
843        )
844    }
845}
846
847/// A document directive.
848///
849/// Links an external document file to an account.
850#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
851pub struct Document {
852    /// Document date
853    pub date: NaiveDate,
854    /// Account
855    pub account: String,
856    /// File path to the document
857    pub path: String,
858    /// Tags
859    pub tags: Vec<String>,
860    /// Links
861    pub links: Vec<String>,
862    /// Metadata
863    pub meta: Metadata,
864}
865
866impl Document {
867    /// Create a new document directive.
868    #[must_use]
869    pub fn new(date: NaiveDate, account: impl Into<String>, path: impl Into<String>) -> Self {
870        Self {
871            date,
872            account: account.into(),
873            path: path.into(),
874            tags: Vec::new(),
875            links: Vec::new(),
876            meta: Metadata::new(),
877        }
878    }
879}
880
881impl fmt::Display for Document {
882    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
883        write!(
884            f,
885            "{} document {} \"{}\"",
886            self.date, self.account, self.path
887        )
888    }
889}
890
891/// A price directive.
892///
893/// Records the price of a commodity in another currency.
894#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
895pub struct Price {
896    /// Price date
897    pub date: NaiveDate,
898    /// Currency being priced
899    pub currency: String,
900    /// Price amount (in another currency)
901    pub amount: Amount,
902    /// Metadata
903    pub meta: Metadata,
904}
905
906impl Price {
907    /// Create a new price directive.
908    #[must_use]
909    pub fn new(date: NaiveDate, currency: impl Into<String>, amount: Amount) -> Self {
910        Self {
911            date,
912            currency: currency.into(),
913            amount,
914            meta: Metadata::new(),
915        }
916    }
917}
918
919impl fmt::Display for Price {
920    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
921        write!(f, "{} price {} {}", self.date, self.currency, self.amount)
922    }
923}
924
925/// A custom directive.
926///
927/// User-defined directive type for extensions.
928#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
929pub struct Custom {
930    /// Custom directive date
931    pub date: NaiveDate,
932    /// Custom type name (e.g., "budget", "autopay")
933    pub custom_type: String,
934    /// Values/arguments for this custom directive
935    pub values: Vec<MetaValue>,
936    /// Metadata
937    pub meta: Metadata,
938}
939
940impl Custom {
941    /// Create a new custom directive.
942    #[must_use]
943    pub fn new(date: NaiveDate, custom_type: impl Into<String>) -> Self {
944        Self {
945            date,
946            custom_type: custom_type.into(),
947            values: Vec::new(),
948            meta: Metadata::new(),
949        }
950    }
951
952    /// Add a value.
953    #[must_use]
954    pub fn with_value(mut self, value: MetaValue) -> Self {
955        self.values.push(value);
956        self
957    }
958}
959
960impl fmt::Display for Custom {
961    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
962        write!(f, "{} custom \"{}\"", self.date, self.custom_type)?;
963        for value in &self.values {
964            write!(f, " {value}")?;
965        }
966        Ok(())
967    }
968}
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973    use rust_decimal_macros::dec;
974
975    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
976        NaiveDate::from_ymd_opt(year, month, day).unwrap()
977    }
978
979    #[test]
980    fn test_transaction() {
981        let txn = Transaction::new(date(2024, 1, 15), "Grocery shopping")
982            .with_payee("Whole Foods")
983            .with_flag('*')
984            .with_tag("food")
985            .with_posting(Posting::new(
986                "Expenses:Food",
987                Amount::new(dec!(50.00), "USD"),
988            ))
989            .with_posting(Posting::auto("Assets:Checking"));
990
991        assert_eq!(txn.flag, '*');
992        assert_eq!(txn.payee, Some("Whole Foods".to_string()));
993        assert_eq!(txn.postings.len(), 2);
994        assert!(txn.is_complete());
995    }
996
997    #[test]
998    fn test_balance() {
999        let bal = Balance::new(
1000            date(2024, 1, 1),
1001            "Assets:Checking",
1002            Amount::new(dec!(1000.00), "USD"),
1003        );
1004
1005        assert_eq!(bal.account, "Assets:Checking");
1006        assert_eq!(bal.amount.number, dec!(1000.00));
1007    }
1008
1009    #[test]
1010    fn test_open() {
1011        let open = Open::new(date(2024, 1, 1), "Assets:Bank:Checking")
1012            .with_currencies(vec!["USD".to_string()])
1013            .with_booking("FIFO");
1014
1015        assert_eq!(open.currencies, vec!["USD"]);
1016        assert_eq!(open.booking, Some("FIFO".to_string()));
1017    }
1018
1019    #[test]
1020    fn test_directive_date() {
1021        let txn = Transaction::new(date(2024, 1, 15), "Test");
1022        let dir = Directive::Transaction(txn);
1023
1024        assert_eq!(dir.date(), date(2024, 1, 15));
1025        assert!(dir.is_transaction());
1026        assert_eq!(dir.type_name(), "transaction");
1027    }
1028
1029    #[test]
1030    fn test_posting_display() {
1031        let posting = Posting::new("Assets:Checking", Amount::new(dec!(100.00), "USD"));
1032        let s = format!("{posting}");
1033        assert!(s.contains("Assets:Checking"));
1034        assert!(s.contains("100.00 USD"));
1035    }
1036
1037    #[test]
1038    fn test_transaction_display() {
1039        let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1040            .with_payee("Test Payee")
1041            .with_posting(Posting::new(
1042                "Expenses:Test",
1043                Amount::new(dec!(50.00), "USD"),
1044            ))
1045            .with_posting(Posting::auto("Assets:Cash"));
1046
1047        let s = format!("{txn}");
1048        assert!(s.contains("2024-01-15"));
1049        assert!(s.contains("Test Payee"));
1050        assert!(s.contains("Test transaction"));
1051    }
1052
1053    #[test]
1054    fn test_directive_priority() {
1055        // Test that priorities are ordered correctly
1056        assert!(DirectivePriority::Open < DirectivePriority::Transaction);
1057        assert!(DirectivePriority::Pad < DirectivePriority::Balance);
1058        assert!(DirectivePriority::Balance < DirectivePriority::Transaction);
1059        assert!(DirectivePriority::Transaction < DirectivePriority::Close);
1060        assert!(DirectivePriority::Price < DirectivePriority::Close);
1061    }
1062
1063    #[test]
1064    fn test_sort_directives_by_date() {
1065        let mut directives = vec![
1066            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Third")),
1067            Directive::Transaction(Transaction::new(date(2024, 1, 1), "First")),
1068            Directive::Transaction(Transaction::new(date(2024, 1, 10), "Second")),
1069        ];
1070
1071        sort_directives(&mut directives);
1072
1073        assert_eq!(directives[0].date(), date(2024, 1, 1));
1074        assert_eq!(directives[1].date(), date(2024, 1, 10));
1075        assert_eq!(directives[2].date(), date(2024, 1, 15));
1076    }
1077
1078    #[test]
1079    fn test_sort_directives_by_type_same_date() {
1080        // On the same date, open should come before transaction, transaction before close
1081        let mut directives = vec![
1082            Directive::Close(Close::new(date(2024, 1, 1), "Assets:Bank")),
1083            Directive::Transaction(Transaction::new(date(2024, 1, 1), "Payment")),
1084            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1085            Directive::Balance(Balance::new(
1086                date(2024, 1, 1),
1087                "Assets:Bank",
1088                Amount::new(dec!(0), "USD"),
1089            )),
1090        ];
1091
1092        sort_directives(&mut directives);
1093
1094        assert_eq!(directives[0].type_name(), "open");
1095        assert_eq!(directives[1].type_name(), "balance");
1096        assert_eq!(directives[2].type_name(), "transaction");
1097        assert_eq!(directives[3].type_name(), "close");
1098    }
1099
1100    #[test]
1101    fn test_sort_directives_pad_before_balance() {
1102        // Pad must come before balance assertion on the same day
1103        let mut directives = vec![
1104            Directive::Balance(Balance::new(
1105                date(2024, 1, 1),
1106                "Assets:Bank",
1107                Amount::new(dec!(1000), "USD"),
1108            )),
1109            Directive::Pad(Pad::new(
1110                date(2024, 1, 1),
1111                "Assets:Bank",
1112                "Equity:Opening-Balances",
1113            )),
1114        ];
1115
1116        sort_directives(&mut directives);
1117
1118        assert_eq!(directives[0].type_name(), "pad");
1119        assert_eq!(directives[1].type_name(), "balance");
1120    }
1121}