use crate::NaiveDate;
use rust_decimal::Decimal;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::intern::InternedStr;
#[cfg(feature = "rkyv")]
use crate::intern::{AsDecimal, AsInternedStr, AsNaiveDate, AsOptionInternedStr, AsVecInternedStr};
use crate::{Amount, CostSpec, IncompleteAmount};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub enum MetaValue {
String(String),
Account(String),
Currency(String),
Tag(String),
Link(String),
Date(#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))] NaiveDate),
Number(#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))] Decimal),
Bool(bool),
Amount(Amount),
None,
}
impl fmt::Display for MetaValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::String(s) => write!(f, "\"{s}\""),
Self::Account(a) => write!(f, "{a}"),
Self::Currency(c) => write!(f, "{c}"),
Self::Tag(t) => write!(f, "#{t}"),
Self::Link(l) => write!(f, "^{l}"),
Self::Date(d) => write!(f, "{d}"),
Self::Number(n) => write!(f, "{n}"),
Self::Bool(b) => write!(f, "{b}"),
Self::Amount(a) => write!(f, "{a}"),
Self::None => write!(f, "None"),
}
}
}
pub type Metadata = FxHashMap<String, MetaValue>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Posting {
#[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
pub account: InternedStr,
pub units: Option<IncompleteAmount>,
pub cost: Option<CostSpec>,
pub price: Option<PriceAnnotation>,
pub flag: Option<char>,
pub meta: Metadata,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub comments: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trailing_comments: Vec<String>,
}
impl Posting {
#[must_use]
pub fn new(account: impl Into<InternedStr>, units: Amount) -> Self {
Self {
account: account.into(),
units: Some(IncompleteAmount::Complete(units)),
cost: None,
price: None,
flag: None,
meta: Metadata::default(),
comments: Vec::new(),
trailing_comments: Vec::new(),
}
}
#[must_use]
pub fn with_incomplete(account: impl Into<InternedStr>, units: IncompleteAmount) -> Self {
Self {
account: account.into(),
units: Some(units),
cost: None,
price: None,
flag: None,
meta: Metadata::default(),
comments: Vec::new(),
trailing_comments: Vec::new(),
}
}
#[must_use]
pub fn auto(account: impl Into<InternedStr>) -> Self {
Self {
account: account.into(),
units: None,
cost: None,
price: None,
flag: None,
meta: Metadata::default(),
comments: Vec::new(),
trailing_comments: Vec::new(),
}
}
#[must_use]
pub fn amount(&self) -> Option<&Amount> {
self.units.as_ref().and_then(|u| u.as_amount())
}
#[must_use]
pub fn with_cost(mut self, cost: CostSpec) -> Self {
self.cost = Some(cost);
self
}
#[must_use]
pub fn with_price(mut self, price: PriceAnnotation) -> Self {
self.price = Some(price);
self
}
#[must_use]
pub const fn with_flag(mut self, flag: char) -> Self {
self.flag = Some(flag);
self
}
#[must_use]
pub const fn has_units(&self) -> bool {
self.units.is_some()
}
}
impl fmt::Display for Posting {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, " ")?;
if let Some(flag) = self.flag {
write!(f, "{flag} ")?;
}
write!(f, "{}", self.account)?;
if let Some(units) = &self.units {
write!(f, " {units}")?;
}
if let Some(cost) = &self.cost {
write!(f, " {cost}")?;
}
if let Some(price) = &self.price {
write!(f, " {price}")?;
}
for (key, value) in &self.meta {
write!(f, "\n {key}: {value}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub enum PriceAnnotation {
Unit(Amount),
Total(Amount),
UnitIncomplete(IncompleteAmount),
TotalIncomplete(IncompleteAmount),
UnitEmpty,
TotalEmpty,
}
impl PriceAnnotation {
#[must_use]
pub const fn amount(&self) -> Option<&Amount> {
match self {
Self::Unit(a) | Self::Total(a) => Some(a),
Self::UnitIncomplete(ia) | Self::TotalIncomplete(ia) => ia.as_amount(),
Self::UnitEmpty | Self::TotalEmpty => None,
}
}
#[must_use]
pub const fn is_unit(&self) -> bool {
matches!(
self,
Self::Unit(_) | Self::UnitIncomplete(_) | Self::UnitEmpty
)
}
}
impl fmt::Display for PriceAnnotation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unit(a) => write!(f, "@ {a}"),
Self::Total(a) => write!(f, "@@ {a}"),
Self::UnitIncomplete(ia) => write!(f, "@ {ia}"),
Self::TotalIncomplete(ia) => write!(f, "@@ {ia}"),
Self::UnitEmpty => write!(f, "@"),
Self::TotalEmpty => write!(f, "@@"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DirectivePriority {
Open = 0,
Commodity = 1,
Pad = 2,
Balance = 3,
Transaction = 4,
Note = 5,
Document = 6,
Event = 7,
Query = 8,
Price = 9,
Close = 10,
Custom = 11,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub enum Directive {
Transaction(Transaction),
Balance(Balance),
Open(Open),
Close(Close),
Commodity(Commodity),
Pad(Pad),
Event(Event),
Query(Query),
Note(Note),
Document(Document),
Price(Price),
Custom(Custom),
}
impl Directive {
#[must_use]
pub const fn date(&self) -> NaiveDate {
match self {
Self::Transaction(t) => t.date,
Self::Balance(b) => b.date,
Self::Open(o) => o.date,
Self::Close(c) => c.date,
Self::Commodity(c) => c.date,
Self::Pad(p) => p.date,
Self::Event(e) => e.date,
Self::Query(q) => q.date,
Self::Note(n) => n.date,
Self::Document(d) => d.date,
Self::Price(p) => p.date,
Self::Custom(c) => c.date,
}
}
#[must_use]
pub const fn meta(&self) -> &Metadata {
match self {
Self::Transaction(t) => &t.meta,
Self::Balance(b) => &b.meta,
Self::Open(o) => &o.meta,
Self::Close(c) => &c.meta,
Self::Commodity(c) => &c.meta,
Self::Pad(p) => &p.meta,
Self::Event(e) => &e.meta,
Self::Query(q) => &q.meta,
Self::Note(n) => &n.meta,
Self::Document(d) => &d.meta,
Self::Price(p) => &p.meta,
Self::Custom(c) => &c.meta,
}
}
#[must_use]
pub const fn is_transaction(&self) -> bool {
matches!(self, Self::Transaction(_))
}
#[must_use]
pub const fn as_transaction(&self) -> Option<&Transaction> {
match self {
Self::Transaction(t) => Some(t),
_ => None,
}
}
#[must_use]
pub const fn type_name(&self) -> &'static str {
match self {
Self::Transaction(_) => "transaction",
Self::Balance(_) => "balance",
Self::Open(_) => "open",
Self::Close(_) => "close",
Self::Commodity(_) => "commodity",
Self::Pad(_) => "pad",
Self::Event(_) => "event",
Self::Query(_) => "query",
Self::Note(_) => "note",
Self::Document(_) => "document",
Self::Price(_) => "price",
Self::Custom(_) => "custom",
}
}
#[must_use]
pub const fn priority(&self) -> DirectivePriority {
match self {
Self::Open(_) => DirectivePriority::Open,
Self::Commodity(_) => DirectivePriority::Commodity,
Self::Pad(_) => DirectivePriority::Pad,
Self::Balance(_) => DirectivePriority::Balance,
Self::Transaction(_) => DirectivePriority::Transaction,
Self::Note(_) => DirectivePriority::Note,
Self::Document(_) => DirectivePriority::Document,
Self::Event(_) => DirectivePriority::Event,
Self::Query(_) => DirectivePriority::Query,
Self::Price(_) => DirectivePriority::Price,
Self::Close(_) => DirectivePriority::Close,
Self::Custom(_) => DirectivePriority::Custom,
}
}
#[must_use]
pub fn has_cost_reduction(&self) -> bool {
if let Self::Transaction(txn) = self {
txn.postings.iter().any(|p| {
p.cost.is_some()
&& p.units
.as_ref()
.and_then(IncompleteAmount::number)
.is_some_and(|n| n.is_sign_negative())
})
} else {
false
}
}
}
pub fn sort_directives(directives: &mut [Directive]) {
directives.sort_by_cached_key(|d| (d.date(), d.priority(), d.has_cost_reduction()));
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Transaction {
#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
pub date: NaiveDate,
pub flag: char,
#[cfg_attr(feature = "rkyv", rkyv(with = AsOptionInternedStr))]
pub payee: Option<InternedStr>,
#[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
pub narration: InternedStr,
#[cfg_attr(feature = "rkyv", rkyv(with = AsVecInternedStr))]
pub tags: Vec<InternedStr>,
#[cfg_attr(feature = "rkyv", rkyv(with = AsVecInternedStr))]
pub links: Vec<InternedStr>,
pub meta: Metadata,
pub postings: Vec<Posting>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trailing_comments: Vec<String>,
}
impl Transaction {
#[must_use]
pub fn new(date: NaiveDate, narration: impl Into<InternedStr>) -> Self {
Self {
date,
flag: '*',
payee: None,
narration: narration.into(),
tags: Vec::new(),
links: Vec::new(),
meta: Metadata::default(),
postings: Vec::new(),
trailing_comments: Vec::new(),
}
}
#[must_use]
pub const fn with_flag(mut self, flag: char) -> Self {
self.flag = flag;
self
}
#[must_use]
pub fn with_payee(mut self, payee: impl Into<InternedStr>) -> Self {
self.payee = Some(payee.into());
self
}
#[must_use]
pub fn with_tag(mut self, tag: impl Into<InternedStr>) -> Self {
self.tags.push(tag.into());
self
}
#[must_use]
pub fn with_link(mut self, link: impl Into<InternedStr>) -> Self {
self.links.push(link.into());
self
}
#[must_use]
pub fn with_posting(mut self, posting: Posting) -> Self {
self.postings.push(posting);
self
}
#[must_use]
pub const fn is_complete(&self) -> bool {
self.flag == '*'
}
#[must_use]
pub const fn is_incomplete(&self) -> bool {
self.flag == '!'
}
#[must_use]
pub const fn is_pending(&self) -> bool {
self.flag == '!'
}
#[must_use]
pub const fn is_pad_generated(&self) -> bool {
self.flag == 'P'
}
#[must_use]
pub const fn is_summarization(&self) -> bool {
self.flag == 'S'
}
#[must_use]
pub const fn is_transfer(&self) -> bool {
self.flag == 'T'
}
#[must_use]
pub const fn is_conversion(&self) -> bool {
self.flag == 'C'
}
#[must_use]
pub const fn is_unrealized(&self) -> bool {
self.flag == 'U'
}
#[must_use]
pub const fn is_return(&self) -> bool {
self.flag == 'R'
}
#[must_use]
pub const fn is_merge(&self) -> bool {
self.flag == 'M'
}
#[must_use]
pub const fn is_bookmarked(&self) -> bool {
self.flag == '#'
}
#[must_use]
pub const fn needs_investigation(&self) -> bool {
self.flag == '?'
}
#[must_use]
pub const fn is_valid_flag(flag: char) -> bool {
matches!(
flag,
'*' | '!' | 'P' | 'S' | 'T' | 'C' | 'U' | 'R' | 'M' | '#' | '?' | '%' | '&'
)
}
}
impl fmt::Display for Transaction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {} ", self.date, self.flag)?;
if let Some(payee) = &self.payee {
write!(f, "\"{payee}\" ")?;
}
write!(f, "\"{}\"", self.narration)?;
for tag in &self.tags {
write!(f, " #{tag}")?;
}
for link in &self.links {
write!(f, " ^{link}")?;
}
for (key, value) in &self.meta {
write!(f, "\n {key}: {value}")?;
}
for posting in &self.postings {
write!(f, "\n{posting}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Balance {
#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
pub date: NaiveDate,
#[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
pub account: InternedStr,
pub amount: Amount,
#[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
pub tolerance: Option<Decimal>,
pub meta: Metadata,
}
impl Balance {
#[must_use]
pub fn new(date: NaiveDate, account: impl Into<InternedStr>, amount: Amount) -> Self {
Self {
date,
account: account.into(),
amount,
tolerance: None,
meta: Metadata::default(),
}
}
#[must_use]
pub const fn with_tolerance(mut self, tolerance: Decimal) -> Self {
self.tolerance = Some(tolerance);
self
}
#[must_use]
pub fn with_meta(mut self, meta: Metadata) -> Self {
self.meta = meta;
self
}
}
impl fmt::Display for Balance {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} balance {} {}", self.date, self.account, self.amount)?;
if let Some(tol) = self.tolerance {
write!(f, " ~ {tol}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Open {
#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
pub date: NaiveDate,
#[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
pub account: InternedStr,
#[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsInternedStr>))]
pub currencies: Vec<InternedStr>,
pub booking: Option<String>,
pub meta: Metadata,
}
impl Open {
#[must_use]
pub fn new(date: NaiveDate, account: impl Into<InternedStr>) -> Self {
Self {
date,
account: account.into(),
currencies: Vec::new(),
booking: None,
meta: Metadata::default(),
}
}
#[must_use]
pub fn with_currencies(mut self, currencies: Vec<InternedStr>) -> Self {
self.currencies = currencies;
self
}
#[must_use]
pub fn with_booking(mut self, booking: impl Into<String>) -> Self {
self.booking = Some(booking.into());
self
}
#[must_use]
pub fn with_meta(mut self, meta: Metadata) -> Self {
self.meta = meta;
self
}
}
impl fmt::Display for Open {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} open {}", self.date, self.account)?;
if !self.currencies.is_empty() {
let currencies: Vec<&str> = self.currencies.iter().map(InternedStr::as_str).collect();
write!(f, " {}", currencies.join(","))?;
}
if let Some(booking) = &self.booking {
write!(f, " \"{booking}\"")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Close {
#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
pub date: NaiveDate,
#[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
pub account: InternedStr,
pub meta: Metadata,
}
impl Close {
#[must_use]
pub fn new(date: NaiveDate, account: impl Into<InternedStr>) -> Self {
Self {
date,
account: account.into(),
meta: Metadata::default(),
}
}
#[must_use]
pub fn with_meta(mut self, meta: Metadata) -> Self {
self.meta = meta;
self
}
}
impl fmt::Display for Close {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} close {}", self.date, self.account)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Commodity {
#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
pub date: NaiveDate,
#[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
pub currency: InternedStr,
pub meta: Metadata,
}
impl Commodity {
#[must_use]
pub fn new(date: NaiveDate, currency: impl Into<InternedStr>) -> Self {
Self {
date,
currency: currency.into(),
meta: Metadata::default(),
}
}
#[must_use]
pub fn with_meta(mut self, meta: Metadata) -> Self {
self.meta = meta;
self
}
}
impl fmt::Display for Commodity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} commodity {}", self.date, self.currency)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Pad {
#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
pub date: NaiveDate,
#[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
pub account: InternedStr,
#[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
pub source_account: InternedStr,
pub meta: Metadata,
}
impl Pad {
#[must_use]
pub fn new(
date: NaiveDate,
account: impl Into<InternedStr>,
source_account: impl Into<InternedStr>,
) -> Self {
Self {
date,
account: account.into(),
source_account: source_account.into(),
meta: Metadata::default(),
}
}
#[must_use]
pub fn with_meta(mut self, meta: Metadata) -> Self {
self.meta = meta;
self
}
}
impl fmt::Display for Pad {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} pad {} {}",
self.date, self.account, self.source_account
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Event {
#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
pub date: NaiveDate,
pub event_type: String,
pub value: String,
pub meta: Metadata,
}
impl Event {
#[must_use]
pub fn new(date: NaiveDate, event_type: impl Into<String>, value: impl Into<String>) -> Self {
Self {
date,
event_type: event_type.into(),
value: value.into(),
meta: Metadata::default(),
}
}
#[must_use]
pub fn with_meta(mut self, meta: Metadata) -> Self {
self.meta = meta;
self
}
}
impl fmt::Display for Event {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} event \"{}\" \"{}\"",
self.date, self.event_type, self.value
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Query {
#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
pub date: NaiveDate,
pub name: String,
pub query: String,
pub meta: Metadata,
}
impl Query {
#[must_use]
pub fn new(date: NaiveDate, name: impl Into<String>, query: impl Into<String>) -> Self {
Self {
date,
name: name.into(),
query: query.into(),
meta: Metadata::default(),
}
}
#[must_use]
pub fn with_meta(mut self, meta: Metadata) -> Self {
self.meta = meta;
self
}
}
impl fmt::Display for Query {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} query \"{}\" \"{}\"",
self.date, self.name, self.query
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Note {
#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
pub date: NaiveDate,
#[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
pub account: InternedStr,
pub comment: String,
pub meta: Metadata,
}
impl Note {
#[must_use]
pub fn new(
date: NaiveDate,
account: impl Into<InternedStr>,
comment: impl Into<String>,
) -> Self {
Self {
date,
account: account.into(),
comment: comment.into(),
meta: Metadata::default(),
}
}
#[must_use]
pub fn with_meta(mut self, meta: Metadata) -> Self {
self.meta = meta;
self
}
}
impl fmt::Display for Note {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} note {} \"{}\"",
self.date, self.account, self.comment
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Document {
#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
pub date: NaiveDate,
#[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
pub account: InternedStr,
pub path: String,
#[cfg_attr(feature = "rkyv", rkyv(with = AsVecInternedStr))]
pub tags: Vec<InternedStr>,
#[cfg_attr(feature = "rkyv", rkyv(with = AsVecInternedStr))]
pub links: Vec<InternedStr>,
pub meta: Metadata,
}
impl Document {
#[must_use]
pub fn new(date: NaiveDate, account: impl Into<InternedStr>, path: impl Into<String>) -> Self {
Self {
date,
account: account.into(),
path: path.into(),
tags: Vec::new(),
links: Vec::new(),
meta: Metadata::default(),
}
}
#[must_use]
pub fn with_tag(mut self, tag: impl Into<InternedStr>) -> Self {
self.tags.push(tag.into());
self
}
#[must_use]
pub fn with_link(mut self, link: impl Into<InternedStr>) -> Self {
self.links.push(link.into());
self
}
#[must_use]
pub fn with_meta(mut self, meta: Metadata) -> Self {
self.meta = meta;
self
}
}
impl fmt::Display for Document {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} document {} \"{}\"",
self.date, self.account, self.path
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Price {
#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
pub date: NaiveDate,
#[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
pub currency: InternedStr,
pub amount: Amount,
pub meta: Metadata,
}
impl Price {
#[must_use]
pub fn new(date: NaiveDate, currency: impl Into<InternedStr>, amount: Amount) -> Self {
Self {
date,
currency: currency.into(),
amount,
meta: Metadata::default(),
}
}
#[must_use]
pub fn with_meta(mut self, meta: Metadata) -> Self {
self.meta = meta;
self
}
}
impl fmt::Display for Price {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} price {} {}", self.date, self.currency, self.amount)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Custom {
#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
pub date: NaiveDate,
pub custom_type: String,
pub values: Vec<MetaValue>,
pub meta: Metadata,
}
impl Custom {
#[must_use]
pub fn new(date: NaiveDate, custom_type: impl Into<String>) -> Self {
Self {
date,
custom_type: custom_type.into(),
values: Vec::new(),
meta: Metadata::default(),
}
}
#[must_use]
pub fn with_value(mut self, value: MetaValue) -> Self {
self.values.push(value);
self
}
#[must_use]
pub fn with_meta(mut self, meta: Metadata) -> Self {
self.meta = meta;
self
}
}
impl fmt::Display for Custom {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} custom \"{}\"", self.date, self.custom_type)?;
for value in &self.values {
write!(f, " {value}")?;
}
Ok(())
}
}
impl fmt::Display for Directive {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Transaction(t) => write!(f, "{t}"),
Self::Balance(b) => write!(f, "{b}"),
Self::Open(o) => write!(f, "{o}"),
Self::Close(c) => write!(f, "{c}"),
Self::Commodity(c) => write!(f, "{c}"),
Self::Pad(p) => write!(f, "{p}"),
Self::Event(e) => write!(f, "{e}"),
Self::Query(q) => write!(f, "{q}"),
Self::Note(n) => write!(f, "{n}"),
Self::Document(d) => write!(f, "{d}"),
Self::Price(p) => write!(f, "{p}"),
Self::Custom(c) => write!(f, "{c}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
crate::naive_date(year, month, day).unwrap()
}
#[test]
fn test_transaction() {
let txn = Transaction::new(date(2024, 1, 15), "Grocery shopping")
.with_payee("Whole Foods")
.with_flag('*')
.with_tag("food")
.with_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50.00), "USD"),
))
.with_posting(Posting::auto("Assets:Checking"));
assert_eq!(txn.flag, '*');
assert_eq!(txn.payee.as_deref(), Some("Whole Foods"));
assert_eq!(txn.postings.len(), 2);
assert!(txn.is_complete());
}
#[test]
fn test_balance() {
let bal = Balance::new(
date(2024, 1, 1),
"Assets:Checking",
Amount::new(dec!(1000.00), "USD"),
);
assert_eq!(bal.account, "Assets:Checking");
assert_eq!(bal.amount.number, dec!(1000.00));
}
#[test]
fn test_open() {
let open = Open::new(date(2024, 1, 1), "Assets:Bank:Checking")
.with_currencies(vec!["USD".into()])
.with_booking("FIFO");
assert_eq!(open.currencies, vec![InternedStr::from("USD")]);
assert_eq!(open.booking, Some("FIFO".to_string()));
}
#[test]
fn test_directive_date() {
let txn = Transaction::new(date(2024, 1, 15), "Test");
let dir = Directive::Transaction(txn);
assert_eq!(dir.date(), date(2024, 1, 15));
assert!(dir.is_transaction());
assert_eq!(dir.type_name(), "transaction");
}
#[test]
fn test_posting_display() {
let posting = Posting::new("Assets:Checking", Amount::new(dec!(100.00), "USD"));
let s = format!("{posting}");
assert!(s.contains("Assets:Checking"));
assert!(s.contains("100.00 USD"));
}
#[test]
fn test_transaction_display() {
let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
.with_payee("Test Payee")
.with_posting(Posting::new(
"Expenses:Test",
Amount::new(dec!(50.00), "USD"),
))
.with_posting(Posting::auto("Assets:Cash"));
let s = format!("{txn}");
assert!(s.contains("2024-01-15"));
assert!(s.contains("Test Payee"));
assert!(s.contains("Test transaction"));
}
#[test]
fn test_directive_priority() {
assert!(DirectivePriority::Open < DirectivePriority::Transaction);
assert!(DirectivePriority::Pad < DirectivePriority::Balance);
assert!(DirectivePriority::Balance < DirectivePriority::Transaction);
assert!(DirectivePriority::Transaction < DirectivePriority::Close);
assert!(DirectivePriority::Price < DirectivePriority::Close);
}
#[test]
fn test_sort_directives_by_date() {
let mut directives = vec![
Directive::Transaction(Transaction::new(date(2024, 1, 15), "Third")),
Directive::Transaction(Transaction::new(date(2024, 1, 1), "First")),
Directive::Transaction(Transaction::new(date(2024, 1, 10), "Second")),
];
sort_directives(&mut directives);
assert_eq!(directives[0].date(), date(2024, 1, 1));
assert_eq!(directives[1].date(), date(2024, 1, 10));
assert_eq!(directives[2].date(), date(2024, 1, 15));
}
#[test]
fn test_sort_directives_by_type_same_date() {
let mut directives = vec![
Directive::Close(Close::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Transaction(Transaction::new(date(2024, 1, 1), "Payment")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Balance(Balance::new(
date(2024, 1, 1),
"Assets:Bank",
Amount::new(dec!(0), "USD"),
)),
];
sort_directives(&mut directives);
assert_eq!(directives[0].type_name(), "open");
assert_eq!(directives[1].type_name(), "balance");
assert_eq!(directives[2].type_name(), "transaction");
assert_eq!(directives[3].type_name(), "close");
}
#[test]
fn test_sort_directives_pad_before_balance() {
let mut directives = vec![
Directive::Balance(Balance::new(
date(2024, 1, 1),
"Assets:Bank",
Amount::new(dec!(1000), "USD"),
)),
Directive::Pad(Pad::new(
date(2024, 1, 1),
"Assets:Bank",
"Equity:Opening-Balances",
)),
];
sort_directives(&mut directives);
assert_eq!(directives[0].type_name(), "pad");
assert_eq!(directives[1].type_name(), "balance");
}
#[test]
fn test_sort_augmentations_before_reductions_same_date() {
let reduction = Directive::Transaction(
Transaction::new(date(2024, 9, 1), "Transfer Received")
.with_posting(
Posting::new("Assets:AccountB", Amount::new(dec!(11.11), "USD")).with_cost(
CostSpec::empty()
.with_number_per(dec!(0.90))
.with_currency("EUR"),
),
)
.with_posting(
Posting::new("Assets:Transit", Amount::new(dec!(-11.11), "USD")).with_cost(
CostSpec::empty()
.with_number_per(dec!(0.90))
.with_currency("EUR"),
),
),
);
let augmentation = Directive::Transaction(
Transaction::new(date(2024, 9, 1), "Transfer Sent")
.with_posting(Posting::new(
"Assets:AccountA",
Amount::new(dec!(-10.00), "EUR"),
))
.with_posting(
Posting::new("Assets:Transit", Amount::new(dec!(11.11), "USD")).with_cost(
CostSpec::empty()
.with_number_per(dec!(0.90))
.with_currency("EUR"),
),
),
);
let mut directives = vec![reduction, augmentation];
sort_directives(&mut directives);
assert!(
!directives[0].has_cost_reduction(),
"first directive should be augmentation"
);
assert!(
directives[1].has_cost_reduction(),
"second directive should be reduction"
);
}
#[test]
fn test_has_cost_reduction() {
let reduction = Directive::Transaction(
Transaction::new(date(2024, 1, 1), "Sell")
.with_posting(
Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL")).with_cost(
CostSpec::empty()
.with_number_per(dec!(150))
.with_currency("USD"),
),
)
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD"))),
);
assert!(reduction.has_cost_reduction());
let augmentation = Directive::Transaction(
Transaction::new(date(2024, 1, 1), "Buy")
.with_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number_per(dec!(150))
.with_currency("USD"),
),
)
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
);
assert!(!augmentation.has_cost_reduction());
let simple = Directive::Transaction(
Transaction::new(date(2024, 1, 1), "Payment")
.with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD")))
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-50), "USD"))),
);
assert!(!simple.has_cost_reduction());
}
#[test]
fn test_transaction_flags() {
let make_txn = |flag: char| Transaction::new(date(2024, 1, 15), "Test").with_flag(flag);
assert!(make_txn('*').is_complete());
assert!(make_txn('!').is_incomplete());
assert!(make_txn('!').is_pending());
assert!(make_txn('P').is_pad_generated());
assert!(make_txn('S').is_summarization());
assert!(make_txn('T').is_transfer());
assert!(make_txn('C').is_conversion());
assert!(make_txn('U').is_unrealized());
assert!(make_txn('R').is_return());
assert!(make_txn('M').is_merge());
assert!(make_txn('#').is_bookmarked());
assert!(make_txn('?').needs_investigation());
assert!(!make_txn('*').is_pending());
assert!(!make_txn('!').is_complete());
assert!(!make_txn('*').is_pad_generated());
}
#[test]
fn test_is_valid_flag() {
for flag in [
'*', '!', 'P', 'S', 'T', 'C', 'U', 'R', 'M', '#', '?', '%', '&',
] {
assert!(
Transaction::is_valid_flag(flag),
"Flag '{flag}' should be valid"
);
}
for flag in ['x', 'X', '0', ' ', 'a', 'Z'] {
assert!(
!Transaction::is_valid_flag(flag),
"Flag '{flag}' should be invalid"
);
}
}
#[test]
fn test_transaction_display_includes_metadata() {
let mut meta = Metadata::default();
meta.insert(
"document".to_string(),
MetaValue::String("myfile.pdf".to_string()),
);
let txn = Transaction {
date: date(2026, 2, 23),
flag: '*',
payee: None,
narration: "Example".into(),
tags: vec![],
links: vec![],
meta,
postings: vec![
Posting::new("Assets:Bank", Amount::new(dec!(-2), "USD")),
Posting::auto("Expenses:Example"),
],
trailing_comments: Vec::new(),
};
let output = txn.to_string();
assert!(
output.contains("document: \"myfile.pdf\""),
"Transaction Display should include metadata: {output}"
);
assert!(
output.contains("Assets:Bank"),
"Transaction Display should include postings: {output}"
);
}
#[test]
fn test_posting_display_includes_metadata() {
let mut meta = Metadata::default();
meta.insert(
"category".to_string(),
MetaValue::String("groceries".to_string()),
);
let posting = Posting {
account: "Expenses:Food".into(),
units: Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD"))),
cost: None,
price: None,
flag: None,
meta,
comments: Vec::new(),
trailing_comments: Vec::new(),
};
let output = posting.to_string();
assert!(
output.contains("category: \"groceries\""),
"Posting Display should include metadata: {output}"
);
}
#[test]
fn test_directive_display() {
let txn = Transaction::new(date(2024, 1, 15), "Test transaction");
let dir = Directive::Transaction(txn.clone());
assert_eq!(format!("{dir}"), format!("{txn}"));
let open = Open::new(date(2024, 1, 1), "Assets:Bank");
let dir_open = Directive::Open(open.clone());
assert_eq!(format!("{dir_open}"), format!("{open}"));
let balance = Balance::new(
date(2024, 1, 1),
"Assets:Bank",
Amount::new(dec!(100), "USD"),
);
let dir_balance = Directive::Balance(balance.clone());
assert_eq!(format!("{dir_balance}"), format!("{balance}"));
}
}