pub mod writer;
use crate::ast::*;
use pest::Parser as _;
use pest::iterators::{Pair, Pairs};
use pest::pratt_parser::PrattParser;
use pest_derive::Parser;
use rust_decimal::Decimal;
use std::path::PathBuf;
use std::sync::LazyLock;
#[derive(Parser)]
#[grammar = "grammars/beancount/beancount.pest"]
pub(crate) struct BeancountParser;
struct Parser<F: Fn(&str) -> Result<String, Box<dyn std::error::Error>>> {
opener: F,
base_path: PathBuf,
active_tags: Vec<String>,
active_meta: Vec<(String, String)>,
options: Vec<(String, String)>,
}
impl<F: Fn(&str) -> Result<String, Box<dyn std::error::Error>>> Parser<F> {
fn parse(&mut self, input: &str) -> Result<Journal, Box<dyn std::error::Error>> {
let pairs = BeancountParser::parse(Rule::journal, input)?;
let mut entries = Vec::new();
for pair in pairs.into_iter().next().unwrap().into_inner() {
match pair.as_rule() {
Rule::transaction => {
let mut tx = parse_transaction(pair)?;
if !self.active_tags.is_empty() {
tx.notes.push(format!(":{}:", self.active_tags.join(":")));
}
for (k, v) in &self.active_meta {
tx.notes.push(format!("{k}: {v}"));
}
entries.push(Entry::Transaction(tx));
}
Rule::open_directive => {
entries.push(Entry::Directive(parse_open_directive(pair)));
}
Rule::close_directive => {
entries.push(Entry::Comment(format!("close {}", pair.as_str().trim())));
}
Rule::commodity_directive => {
entries.push(Entry::Directive(parse_commodity_directive(pair)));
}
Rule::balance_directive => {
entries.push(Entry::Assertion(parse_balance_directive(pair)));
}
Rule::price_directive => {
entries.push(Entry::HistoricalPrice(parse_price_directive(pair)));
}
Rule::pad_directive => {
entries.push(Entry::Pad(parse_pad_directive(pair)));
}
Rule::option_directive => {
let raw = pair.as_str().trim().to_string();
let mut inner = pair.into_inner();
let key = string_inner(inner.next().expect("option key"));
let value = string_inner(inner.next().expect("option value"));
self.options.push((key, value));
entries.push(Entry::Comment(raw));
}
Rule::note_directive
| Rule::document_directive
| Rule::event_directive
| Rule::query_directive
| Rule::custom_directive
| Rule::plugin_directive => {
entries.push(Entry::Comment(pair.as_str().trim().to_string()));
}
Rule::pushtag_directive => {
let tag_pair = pair
.into_inner()
.find(|p| p.as_rule() == Rule::tag)
.expect("pushtag must contain a tag");
self.active_tags.push(tag_pair.as_str()[1..].to_string());
}
Rule::poptag_directive => {
let tag_pair = pair
.into_inner()
.find(|p| p.as_rule() == Rule::tag)
.expect("poptag must contain a tag");
let name = &tag_pair.as_str()[1..];
if let Some(idx) = self.active_tags.iter().rposition(|t| t == name) {
self.active_tags.remove(idx);
}
}
Rule::pushmeta_directive => {
let mut inner = pair.into_inner();
let key = inner.next().unwrap().as_str().to_string();
let value = inner
.next()
.map(|v| {
let raw = v.as_str().trim();
if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
raw[1..raw.len() - 1].to_string()
} else {
raw.to_string()
}
})
.unwrap_or_default();
self.active_meta.push((key, value));
}
Rule::popmeta_directive => {
let mut inner = pair.into_inner();
let key = inner.next().unwrap().as_str();
if let Some(idx) = self.active_meta.iter().rposition(|(k, _)| k == key) {
self.active_meta.remove(idx);
}
}
Rule::comment_line => {
entries.push(Entry::Comment(pair.as_str().to_string()));
}
Rule::org_mode_heading | Rule::shebang_line | Rule::org_mode_meta => {
entries.push(Entry::Comment(pair.as_str().to_string()));
}
Rule::include_directive => {
let raw = pair.into_inner().next().unwrap();
let path_str = string_inner(raw);
let include_path = self.base_path.join(&path_str);
let new_input = (self.opener)(include_path.as_os_str().to_str().unwrap())?;
let new_base_path = include_path
.parent()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| self.base_path.clone());
let old_base_path = std::mem::replace(&mut self.base_path, new_base_path);
entries.append(&mut self.parse(&new_input)?.entries);
let _ = std::mem::replace(&mut self.base_path, old_base_path);
}
_ => {}
}
}
Ok(Journal { entries })
}
}
fn parse_date(pair: Pair<Rule>) -> Date {
let mut inner = pair.into_inner();
let year: i32 = inner.next().unwrap().as_str().parse().unwrap();
let month: u32 = inner.next().unwrap().as_str().parse().unwrap();
let day: u32 = inner.next().unwrap().as_str().parse().unwrap();
Date {
year: Some(year),
month,
date: day,
}
}
fn parse_flag(s: &str) -> TransactionState {
match s {
"*" => TransactionState::Cleared,
"!" => TransactionState::Pending,
_ => TransactionState::Uncleared,
}
}
fn string_inner(pair: Pair<Rule>) -> String {
let inner_pair = pair
.into_inner()
.next()
.expect("string must contain string_inner");
decode_string_escapes(inner_pair.as_str())
}
fn decode_string_escapes(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
fn parse_transaction(pair: Pair<Rule>) -> Result<Transaction, Box<dyn std::error::Error>> {
let mut inner = pair.into_inner();
let header_pair = inner.next().expect("transaction must have a txn_header");
let mut header_fields = header_pair.into_inner();
let date = parse_date(header_fields.next().unwrap());
let mut state = TransactionState::Uncleared;
let mut payee_or_desc: Vec<String> = Vec::new();
let mut notes: Vec<String> = Vec::new();
let mut tags: Vec<String> = Vec::new();
for p in header_fields {
match p.as_rule() {
Rule::flag => state = parse_flag(p.as_str()),
Rule::string => payee_or_desc.push(string_inner(p)),
Rule::tag => tags.push(p.as_str()[1..].to_string()),
Rule::link => tags.push(p.as_str().to_string()),
Rule::note => {
notes.push(p.into_inner().as_str().trim().to_string());
}
_ => {}
}
}
if !tags.is_empty() {
notes.push(format!(":{}:", tags.join(":")));
}
let (code, description) = match payee_or_desc.len() {
0 => (None, String::new()),
1 => (None, payee_or_desc.remove(0)),
_ => {
let payee = payee_or_desc.remove(0);
let narration = payee_or_desc.remove(0);
(Some(payee), narration)
}
};
let mut postings = Vec::new();
for p in inner {
match p.as_rule() {
Rule::posting => postings.push(parse_posting(p)?),
Rule::metadata_line => {
notes.push(metadata_line_to_note(p));
}
_ => {}
}
}
Ok(Transaction {
date,
secondary_date: None,
state,
code,
description,
notes,
postings,
})
}
fn metadata_line_to_note(pair: Pair<Rule>) -> String {
let mut inner = pair.into_inner();
let key = inner.next().unwrap().as_str().to_string();
let val = inner
.next()
.map(|v| {
let raw = v.as_str().trim();
if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
raw[1..raw.len() - 1].to_string()
} else {
raw.to_string()
}
})
.unwrap_or_default();
format!("{key}: {val}")
}
fn parse_posting(pair: Pair<Rule>) -> Result<Posting, Box<dyn std::error::Error>> {
let mut state = TransactionState::Uncleared;
let mut account = String::new();
let mut amount: Option<AmountDetails> = None;
let mut notes: Vec<String> = Vec::new();
for p in pair.into_inner() {
match p.as_rule() {
Rule::flag => state = parse_flag(p.as_str()),
Rule::posting_account => {
let inner_pair = p
.into_inner()
.next()
.expect("posting_account must have one child");
account = inner_pair.as_str().trim().to_string();
}
Rule::amount_logic => amount = Some(parse_amount_logic(p)?),
Rule::note => notes.push(p.into_inner().as_str().trim().to_string()),
Rule::metadata_line => notes.push(metadata_line_to_note(p)),
_ => {}
}
}
Ok(Posting {
account,
amount,
state,
notes,
kind: PostingKind::Real,
})
}
fn parse_amount_logic(pair: Pair<Rule>) -> Result<AmountDetails, Box<dyn std::error::Error>> {
let mut value: Option<ValueExpr> = None;
let mut lot_annotation = LotAnnotation::default();
let mut has_lot_annotation = false;
let mut lot_pricing: Option<LotPricing> = None;
for p in pair.into_inner() {
match p.as_rule() {
Rule::value_expr => value = Some(parse_expr(p)),
Rule::lot_annotation => {
if p.as_str().starts_with("{{") {
lot_annotation.cost_is_total = true;
}
has_lot_annotation = true;
merge_lot_annotation_into(p, &mut lot_annotation);
}
Rule::lot_price => {
let s = p.as_str();
let inner_expr = parse_expr(p.into_inner().next().unwrap());
if s.starts_with("@@") {
lot_pricing = Some(LotPricing::Total(inner_expr));
} else {
lot_pricing = Some(LotPricing::Unit(inner_expr));
}
}
_ => {}
}
}
Ok(AmountDetails::Amount {
value: value.expect("amount_logic without a value_expr"),
lot_annotation: has_lot_annotation.then_some(lot_annotation),
lot_pricing,
balance_assertion: None,
})
}
fn merge_lot_annotation_into(pair: Pair<Rule>, acc: &mut LotAnnotation) {
let inner = pair
.into_inner()
.next()
.expect("lot_annotation must have an inner");
let raw = inner.as_str();
for part in raw.split(',') {
let part = part.trim();
if part.is_empty() || part == "*" {
continue;
}
if let Some(stripped) = part.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
if acc.note.is_none() {
acc.note = Some(stripped.to_string());
}
continue;
}
if let Ok(d) = chrono::NaiveDate::parse_from_str(part, "%Y-%m-%d") {
if acc.date.is_none() {
acc.date = Some(d);
}
continue;
}
if acc.cost.is_none()
&& let Ok(mut pairs) = BeancountParser::parse(Rule::value_expr, part)
&& let Some(p) = pairs.next()
{
acc.cost = Some(parse_expr(p));
}
}
}
fn parse_open_directive(pair: Pair<Rule>) -> Directive {
let mut inner = pair.into_inner();
let _date = parse_date(inner.next().unwrap()); let name = inner.next().unwrap().as_str().to_string();
let mut notes = Vec::new();
let mut items = Vec::new();
for p in inner {
match p.as_rule() {
Rule::commodity_list => {
let list: Vec<&str> = p.into_inner().map(|c| c.as_str()).collect();
notes.push(format!("currencies: {}", list.join(",")));
}
Rule::booking_method => {
let inner_pair = p.into_inner().next().unwrap();
let raw = inner_pair.as_str();
if let Some(method) = parse_booking_method(raw) {
items.push(crate::ast::AccountItem::Booking(method));
} else {
notes.push(format!("booking: {raw}"));
}
}
Rule::note => notes.push(p.into_inner().as_str().trim().to_string()),
Rule::metadata_line => notes.push(metadata_line_to_note(p)),
_ => {}
}
}
Directive::Account { name, notes, items }
}
fn parse_booking_method(raw: &str) -> Option<crate::resolution::BookingMethod> {
use crate::resolution::BookingMethod::*;
Some(match raw {
"STRICT" => Strict,
"STRICT_WITH_SIZE" => StrictWithSize,
"NONE" => None,
"AVERAGE" => Average,
"FIFO" => Fifo,
"LIFO" => Lifo,
"HIFO" => Hifo,
_ => return Option::None,
})
}
fn parse_commodity_directive(pair: Pair<Rule>) -> Directive {
let mut inner = pair.into_inner();
let _date = parse_date(inner.next().unwrap());
let name = inner.next().unwrap().as_str().to_string();
let mut notes = Vec::new();
let items = Vec::new();
for p in inner {
match p.as_rule() {
Rule::note => notes.push(p.into_inner().as_str().trim().to_string()),
Rule::metadata_line => notes.push(metadata_line_to_note(p)),
_ => {}
}
}
Directive::Commodity { name, notes, items }
}
fn parse_balance_directive(pair: Pair<Rule>) -> AssertionDirective {
let mut inner = pair.into_inner();
let date = parse_date(inner.next().unwrap());
let account = inner.next().unwrap().as_str().to_string();
let value_expr_pair = inner
.find(|p| p.as_rule() == Rule::value_expr)
.expect("balance_directive must have a value_expr");
let amount = parse_expr(value_expr_pair);
AssertionDirective {
date,
account,
amount,
strict: true,
}
}
fn parse_price_directive(pair: Pair<Rule>) -> HistoricalPrice {
let mut inner = pair.into_inner();
let date = parse_date(inner.next().unwrap());
let commodity = inner.next().unwrap().as_str().to_string();
let value_expr_pair = inner
.find(|p| p.as_rule() == Rule::value_expr)
.expect("price_directive must have a value_expr");
let price = parse_expr(value_expr_pair);
HistoricalPrice {
date,
time: None,
commodity,
price,
}
}
fn parse_pad_directive(pair: Pair<Rule>) -> PadDirective {
let mut inner = pair.into_inner();
let date = parse_date(inner.next().unwrap());
let target_account = inner.next().unwrap().as_str().to_string();
let source_account = inner.next().unwrap().as_str().to_string();
PadDirective {
date,
target_account,
source_account,
}
}
static PRATT: LazyLock<PrattParser<Rule>> = LazyLock::new(|| {
use Rule::*;
use pest::pratt_parser::{Assoc::*, Op};
PrattParser::new()
.op(Op::infix(add, Left) | Op::infix(sub, Left))
.op(Op::infix(mul, Left) | Op::infix(div, Left))
.op(Op::prefix(prefix_op))
});
fn parse_expr(pair: Pair<Rule>) -> ValueExpr {
let mut inner = pair.into_inner();
let expr_pair = inner.next().expect("empty value_expr");
let mut ast = run_pratt(expr_pair.into_inner());
if let Some(comm_pair) = inner.next() {
ast = ValueExpr::Typed {
expr: Box::new(ast),
commodity: comm_pair.as_str().to_string(),
};
}
ast
}
fn run_pratt(pairs: Pairs<Rule>) -> ValueExpr {
PRATT
.map_primary(|pair| match pair.as_rule() {
Rule::term => run_pratt(pair.into_inner()),
Rule::primary => {
let base = pair.into_inner().next().expect("primary must have a base");
run_pratt(pest::iterators::Pairs::single(base))
}
Rule::amount => {
let mut inner = pair.into_inner();
let first = inner.next().unwrap();
match first.as_rule() {
Rule::number => {
let val = clean_decimal(first.as_str());
let comm = inner.next().map(|c| c.as_str().to_string());
ValueExpr::Amount {
value: val,
commodity: comm,
}
}
_ => unreachable!("unexpected amount sub-rule: {:?}", first.as_rule()),
}
}
Rule::commodity => ValueExpr::Commodity(pair.as_str().to_string()),
Rule::expr => run_pratt(pair.into_inner()),
_ => unreachable!("unexpected primary rule: {:?}", pair.as_rule()),
})
.map_prefix(|op, expr| ValueExpr::Unary {
op: if op.as_str() == "-" { Op::Sub } else { Op::Add },
expr: Box::new(expr),
})
.map_infix(|lhs, op, rhs| {
let op = match op.as_rule() {
Rule::add => Op::Add,
Rule::sub => Op::Sub,
Rule::mul => Op::Mul,
Rule::div => Op::Div,
_ => unreachable!(),
};
ValueExpr::Binary {
lhs: Box::new(lhs),
rhs: Box::new(rhs),
op,
}
})
.parse(pairs)
}
fn clean_decimal(s: &str) -> Decimal {
s.replace(',', "").parse().unwrap_or(Decimal::ZERO)
}
fn sort_entries_by_date(entries: &mut [Entry]) {
entries.sort_by_key(entry_sort_key);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum SameDayOrder {
Pad,
Balance,
During,
}
fn entry_sort_key(entry: &Entry) -> (Option<Date>, SameDayOrder) {
(entry_date_key(entry), entry_sub_order(entry))
}
fn entry_date_key(entry: &Entry) -> Option<Date> {
match entry {
Entry::Transaction(t) => Some(t.date.clone()),
Entry::Assertion(a) => Some(a.date.clone()),
Entry::HistoricalPrice(hp) => Some(hp.date.clone()),
Entry::Pad(p) => Some(p.date.clone()),
Entry::Directive(_)
| Entry::Comment(_)
| Entry::AutoRule(_)
| Entry::CommodityConversion { .. } => None,
}
}
fn entry_sub_order(entry: &Entry) -> SameDayOrder {
match entry {
Entry::Pad(_) => SameDayOrder::Pad,
Entry::Assertion(_) => SameDayOrder::Balance,
_ => SameDayOrder::During,
}
}
#[cfg(test)]
pub(crate) fn parse_beancount(input: &str) -> Result<Journal, Box<dyn std::error::Error>> {
Ok(parse_beancount_with_options(input)?.0)
}
#[cfg(test)]
pub(crate) fn parse_beancount_with_options(
input: &str,
) -> Result<(Journal, Vec<(String, String)>), Box<dyn std::error::Error>> {
let mut parser = Parser {
opener: |_| Ok(String::new()),
base_path: PathBuf::new(),
active_tags: Vec::new(),
active_meta: Vec::new(),
options: Vec::new(),
};
let mut journal = parser.parse(input)?;
sort_entries_by_date(&mut journal.entries);
Ok((journal, parser.options))
}
pub struct BeancountFrontend;
pub fn beancount_defaults() -> crate::resolution::ElaborationConfig {
crate::resolution::ElaborationConfig {
tolerance_mode: crate::resolution::ToleranceMode::FractionOfSmallestPrecision(
rust_decimal::Decimal::new(5, 1),
),
balance_mode: crate::resolution::BalanceMode::CostBasis,
assertion_scope: crate::resolution::AssertionScope::Subtree,
lot_validation_mode: crate::resolution::LotValidationMode::Strict,
default_booking_method: crate::resolution::BookingMethod::Strict,
infer_implicit_total_cost: false,
}
}
impl crate::frontend::Frontend for BeancountFrontend {
fn extensions(&self) -> &'static [&'static str] {
&["beancount"]
}
fn elaboration_defaults(&self) -> crate::resolution::ElaborationConfig {
beancount_defaults()
}
fn parse(
&self,
input: &str,
base_path: &std::path::Path,
opener: &crate::frontend::Opener,
) -> Result<crate::resolution::HIR, Box<dyn std::error::Error>> {
let mut parser = Parser {
opener: |path: &str| opener(path),
base_path: base_path.to_path_buf(),
active_tags: Vec::new(),
active_meta: Vec::new(),
options: Vec::new(),
};
let mut ast_journal = parser.parse(input)?;
sort_entries_by_date(&mut ast_journal.entries);
let mut hir: crate::resolution::HIR = ast_journal.try_into()?;
for (k, v) in &parser.options {
if k == "inferred_tolerance_default"
&& let Some((commodity, decimal)) = v.split_once(':')
&& let Ok(d) = decimal.trim().parse::<rust_decimal::Decimal>()
{
hir.global_context
.tolerance_overrides
.insert(commodity.trim().to_string(), d);
}
}
Ok(hir)
}
fn write_journal(
&self,
hir: &crate::resolution::HIR,
w: &mut dyn std::io::Write,
) -> std::io::Result<()> {
writer::write(hir, w)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
const SAMPLE: &str = include_str!("../../../tests/fixtures/sample.beancount");
#[test]
fn sample_fixture_parses() {
BeancountParser::parse(Rule::journal, SAMPLE).unwrap_or_else(|e| {
panic!("sample.beancount failed to parse:\n{e}");
});
}
fn parse_one(rule: Rule, input: &str) {
BeancountParser::parse(rule, input)
.unwrap_or_else(|e| panic!("expected `{input}` to match {rule:?}, got: {e}"));
}
#[test]
fn date_iso_only() {
parse_one(Rule::date, "2024-01-15");
}
#[test]
fn date_rejects_slash_separator() {
assert!(BeancountParser::parse(Rule::date, "2024/01/15").is_err());
}
#[test]
fn currency_uppercase_identifier() {
parse_one(Rule::commodity, "USD");
parse_one(Rule::commodity, "EUR");
parse_one(Rule::commodity, "AAPL");
parse_one(Rule::commodity, "BTC");
parse_one(Rule::commodity, "VHT_2024");
}
#[test]
fn currency_must_start_uppercase() {
assert!(BeancountParser::parse(Rule::commodity, "usd").is_err());
}
#[test]
fn account_colon_segments() {
parse_one(Rule::account, "Assets:Bank:Checking");
parse_one(Rule::account, "Equity:Opening-Balances");
parse_one(Rule::account, "Income:US:Acme:Salary");
}
#[test]
fn account_requires_at_least_two_segments() {
assert!(BeancountParser::parse(Rule::account, "Assets").is_err());
}
#[test]
fn string_double_quoted() {
parse_one(Rule::string, "\"hello world\"");
parse_one(Rule::string, "\"\"");
}
#[test]
fn string_with_escape_sequences() {
parse_one(Rule::string, "\"hello \\\" world\"");
parse_one(Rule::string, "\"line1\\nline2\"");
parse_one(Rule::string, "\"col1\\tcol2\"");
parse_one(Rule::string, "\"path C:\\\\Users\\\\me\"");
}
#[test]
fn decode_string_escapes_recognises_known_sequences() {
assert_eq!(decode_string_escapes("hello"), "hello");
assert_eq!(decode_string_escapes(r"line1\nline2"), "line1\nline2");
assert_eq!(decode_string_escapes(r"col1\tcol2"), "col1\tcol2");
assert_eq!(decode_string_escapes(r"x\rclear"), "x\rclear");
assert_eq!(decode_string_escapes(r#"say \"hi\""#), r#"say "hi""#);
assert_eq!(decode_string_escapes(r"a\\b"), r"a\b");
}
#[test]
fn decode_string_escapes_passes_unknown_through() {
assert_eq!(decode_string_escapes(r"a\qb"), r"a\qb");
assert_eq!(decode_string_escapes(r"a\"), r"a\");
}
#[test]
fn flag_recognises_star_bang_txn() {
parse_one(Rule::flag, "*");
parse_one(Rule::flag, "!");
parse_one(Rule::flag, "txn");
}
#[test]
fn tag_and_link_chars() {
parse_one(Rule::tag, "#vacation");
parse_one(Rule::tag, "#trip-2024");
parse_one(Rule::link, "^statement-2024-01");
}
#[test]
fn plugin_directive_with_and_without_arg() {
parse_one(Rule::plugin_directive, "plugin \"beancount.plugins.foo\"\n");
parse_one(
Rule::plugin_directive,
"plugin \"beancount.plugins.foo\" \"argument-string\"\n",
);
}
#[test]
fn org_mode_heading_grammar_rule() {
parse_one(Rule::org_mode_heading, "* Personal");
parse_one(Rule::org_mode_heading, "** Bank accounts");
parse_one(Rule::org_mode_heading, "*** Deeply nested");
}
#[test]
fn shebang_and_org_mode_meta_grammar_rules() {
parse_one(Rule::shebang_line, "#!/usr/bin/env bean-check");
parse_one(Rule::shebang_line, "#!/usr/bin/python3");
parse_one(Rule::org_mode_meta, "#+STARTUP: showall");
parse_one(Rule::org_mode_meta, "#+TITLE: My Personal Journal");
parse_one(Rule::org_mode_meta, "#+OPTIONS: toc:nil");
}
#[test]
fn beancount_file_with_shebang_and_org_meta_prelude() {
let input = "\
#!/usr/bin/env bean-check
#+TITLE: Test journal
#+STARTUP: showall
2024-01-01 open Assets:Cash USD
2024-01-15 * \"Test\"
Assets:Cash 100.00 USD
Income:Initial -100.00 USD
";
let journal = parse_beancount(input).expect("parse with prelude");
let txn_count = journal
.entries
.iter()
.filter(|e| matches!(e, Entry::Transaction(_)))
.count();
assert_eq!(txn_count, 1, "transaction should still be picked up");
let comment_count = journal
.entries
.iter()
.filter(|e| matches!(e, Entry::Comment(_)))
.count();
assert!(
comment_count >= 3,
"shebang + 2 #+ lines should each become Entry::Comment, got {} comments",
comment_count
);
}
#[test]
fn simple_complete_transaction() {
let input = "\
2024-01-12 * \"Acme Studio\" \"Invoice 0123\"
Assets:Bank:Checking 3400.00 USD
Income:Salary -3400.00 USD
";
let journal = parse_beancount(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!("expected transaction");
};
assert!(matches!(tx.state, TransactionState::Cleared));
assert_eq!(tx.code.as_deref(), Some("Acme Studio"));
assert_eq!(tx.description, "Invoice 0123");
assert_eq!(tx.postings.len(), 2);
assert_eq!(tx.postings[0].account, "Assets:Bank:Checking");
}
#[test]
fn flagged_transaction_with_single_narration() {
let input = "\
2024-01-15 ! \"Groceries\"
Liabilities:CreditCard -87.43 USD
Expenses:Food 87.43 USD
";
let journal = parse_beancount(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
assert!(matches!(tx.state, TransactionState::Pending));
assert!(tx.code.is_none(), "single-string header has no payee/code");
assert_eq!(tx.description, "Groceries");
}
#[test]
fn txn_keyword_is_uncleared() {
let input = "\
2024-02-15 txn \"Apple lot purchase\"
Assets:Brokerage 10 AAPL
Assets:Bank:Checking -1825.00 USD
";
let journal = parse_beancount(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
assert!(matches!(tx.state, TransactionState::Uncleared));
}
#[test]
fn tags_and_links_collected_as_bare_tag_note() {
let input = "\
2024-01-12 * \"Trip\" #vacation #beach ^trip-001
Expenses:Travel 100.00 USD
Assets:Bank:Checking
";
let journal = parse_beancount(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
assert!(
tx.notes.iter().any(|n| n == ":vacation:beach:^trip-001:"),
"expected a bare-tag note (link prefix preserved), got notes={:?}",
tx.notes
);
}
#[test]
fn tags_and_links_land_in_resolved_transaction_tags() {
let input = "\
2024-01-01 open Expenses:Travel
2024-01-01 open Assets:Bank:Checking USD
2024-01-12 * \"Trip\" #vacation #beach ^trip-001
Expenses:Travel 100.00 USD
Assets:Bank:Checking -100.00 USD
";
let journal = parse_beancount(input).expect("parse");
let hir: crate::resolution::HIR = journal.try_into().expect("resolve");
let tx_entry = hir
.entries
.iter()
.find(|e| matches!(e.data, crate::resolution::Entry::Transaction(_)))
.expect("a transaction entry should be present");
let crate::resolution::Entry::Transaction(ref tx) = tx_entry.data else {
unreachable!();
};
let tags: std::collections::HashSet<&str> = tx.tags.iter().map(String::as_str).collect();
assert!(tags.contains("vacation"), "tags={:?}", tx.tags);
assert!(tags.contains("beach"), "tags={:?}", tx.tags);
assert!(tags.contains("^trip-001"), "tags={:?}", tx.tags);
assert!(
!tx.metadata.contains_key("tag"),
"metadata leak: {:?}",
tx.metadata
);
assert!(
!tx.metadata.contains_key("link"),
"metadata leak: {:?}",
tx.metadata
);
}
#[test]
fn open_directive_becomes_account_directive() {
let input = "2024-01-01 open Assets:Bank:Checking USD\n";
let journal = parse_beancount(input).expect("parse");
let Entry::Directive(Directive::Account { name, notes, .. }) = &journal.entries[0] else {
panic!("expected Account directive, got {:?}", journal.entries[0]);
};
assert_eq!(name, "Assets:Bank:Checking");
assert!(notes.iter().any(|n| n.starts_with("currencies:")));
}
#[test]
fn open_with_booking_method() {
let input = "2024-01-01 open Assets:Brokerage AAPL,USD \"FIFO\"\n";
let journal = parse_beancount(input).expect("parse");
let Entry::Directive(Directive::Account {
name, notes, items, ..
}) = &journal.entries[0]
else {
panic!();
};
assert_eq!(name, "Assets:Brokerage");
let booking = items.iter().find_map(|i| match i {
crate::ast::AccountItem::Booking(b) => Some(*b),
_ => Option::None,
});
assert_eq!(booking, Some(crate::resolution::BookingMethod::Fifo));
assert!(notes.iter().any(|n| n.contains("AAPL,USD")));
}
#[test]
fn open_with_unknown_booking_method_falls_back_to_note() {
let input = "2024-01-01 open Assets:Brokerage AAPL \"WACKY_METHOD\"\n";
let journal = parse_beancount(input).expect("parse");
let Entry::Directive(Directive::Account { notes, items, .. }) = &journal.entries[0] else {
panic!();
};
assert!(
!items
.iter()
.any(|i| matches!(i, crate::ast::AccountItem::Booking(_)))
);
assert!(notes.iter().any(|n| n == "booking: WACKY_METHOD"));
}
#[test]
fn close_directive_becomes_comment() {
let input = "2024-12-31 close Liabilities:CreditCard\n";
let journal = parse_beancount(input).expect("parse");
assert!(matches!(journal.entries[0], Entry::Comment(_)));
}
#[test]
fn commodity_directive_round_trip() {
let input = "\
2024-01-01 commodity USD
name: \"US Dollar\"
";
let journal = parse_beancount(input).expect("parse");
let Entry::Directive(Directive::Commodity { name, notes, .. }) = &journal.entries[0] else {
panic!();
};
assert_eq!(name, "USD");
assert!(notes.iter().any(|n| n.starts_with("name:")));
}
#[test]
fn balance_directive_becomes_assertion() {
let input = "2024-01-15 balance Assets:Bank:Checking 5000.00 USD\n";
let journal = parse_beancount(input).expect("parse");
let Entry::Assertion(a) = &journal.entries[0] else {
panic!();
};
assert_eq!(a.account, "Assets:Bank:Checking");
assert_eq!(a.date.year, Some(2024));
assert_eq!(a.date.month, 1);
assert_eq!(a.date.date, 15);
assert!(a.strict, "Beancount balance assertions are strict");
match &a.amount {
ValueExpr::Amount { value, commodity } => {
assert_eq!(*value, dec!(5000.00));
assert_eq!(commodity.as_deref(), Some("USD"));
}
other => panic!("unexpected amount shape: {other:?}"),
}
}
#[test]
fn price_directive_becomes_historical_price() {
let input = "2024-02-15 price AAPL 182.50 USD\n";
let journal = parse_beancount(input).expect("parse");
let Entry::HistoricalPrice(hp) = &journal.entries[0] else {
panic!();
};
assert_eq!(hp.commodity, "AAPL");
assert_eq!(hp.date.year, Some(2024));
}
#[test]
fn pad_directive_becomes_pad_marker() {
let input = "2024-01-01 pad Assets:Bank:Checking Equity:Opening-Balances\n";
let journal = parse_beancount(input).expect("parse");
let Entry::Pad(p) = &journal.entries[0] else {
panic!("expected Pad marker, got {:?}", journal.entries[0]);
};
assert_eq!(p.target_account, "Assets:Bank:Checking");
assert_eq!(p.source_account, "Equity:Opening-Balances");
}
#[test]
fn note_document_event_query_custom_become_comments() {
let input = "\
2024-01-20 note Assets:Bank:Checking \"Need to verify\"
2024-01-22 document Assets:Bank:Checking \"jan-statement.txt\"
2024-01-01 event \"location\" \"Berlin\"
2024-06-30 query \"q1\" \"SELECT date\"
2024-01-01 custom \"budget\" \"Expenses:Food\" 200.00 USD
";
let journal = parse_beancount(input).expect("parse");
assert_eq!(
journal.entries.len(),
5,
"five entries; got {}",
journal.entries.len()
);
for e in &journal.entries {
assert!(matches!(e, Entry::Comment(_)));
}
}
#[test]
fn empty_lot_annotation_parses_as_missing_cost() {
let input = "\
2024-03-15 * \"Sell with empty cost spec\"
Assets:Brokerage -5 AAPL {}
Assets:Cash 1000 USD
";
let journal = parse_beancount(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
let Some(AmountDetails::Amount { lot_annotation, .. }) = &tx.postings[0].amount else {
panic!("expected Amount details");
};
let ann = lot_annotation.as_ref().expect("lot annotation present");
assert!(ann.cost.is_none(), "empty {{}} should leave cost MISSING");
assert!(ann.date.is_none());
assert!(ann.note.is_none());
}
#[test]
fn partial_lot_annotation_date_only_parses_as_missing_cost() {
let input = "\
2024-03-15 * \"Sell with date-only lot spec\"
Assets:Brokerage -5 AAPL {2024-01-15}
Assets:Cash 1000 USD
";
let journal = parse_beancount(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
let Some(AmountDetails::Amount { lot_annotation, .. }) = &tx.postings[0].amount else {
panic!("expected Amount details");
};
let ann = lot_annotation.as_ref().expect("lot annotation present");
assert!(
ann.cost.is_none(),
"date-only spec should leave cost MISSING"
);
assert_eq!(ann.date, chrono::NaiveDate::from_ymd_opt(2024, 1, 15));
}
#[test]
fn lot_annotation_cost_date_label() {
let input = "\
2024-02-15 * \"Apple lot purchase\"
Assets:Brokerage 10 AAPL {182.50 USD, 2024-02-15, \"buy-2024-02\"}
Assets:Bank:Checking -1825.00 USD
";
let journal = parse_beancount(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
let Some(AmountDetails::Amount { lot_annotation, .. }) = &tx.postings[0].amount else {
panic!("expected Amount details");
};
let ann = lot_annotation.as_ref().expect("lot annotation present");
assert!(ann.cost.is_some(), "cost should be parsed");
assert_eq!(
ann.date,
chrono::NaiveDate::from_ymd_opt(2024, 2, 15),
"date should be parsed"
);
assert_eq!(ann.note.as_deref(), Some("buy-2024-02"));
}
#[test]
fn double_brace_total_cost_lot_elaborates_to_per_unit() {
let total_form = "\
2024-01-01 open Assets:Brokerage USD
2024-01-01 open Assets:Bank:Checking USD
2024-02-15 * \"Apple lot purchase (total-cost form)\"
Assets:Brokerage 10 AAPL {{1825.00 USD}}
Assets:Bank:Checking -1825.00 USD
";
let per_unit_form = "\
2024-01-01 open Assets:Brokerage USD
2024-01-01 open Assets:Bank:Checking USD
2024-02-15 * \"Apple lot purchase (per-unit form)\"
Assets:Brokerage 10 AAPL {182.50 USD}
Assets:Bank:Checking -1825.00 USD
";
let total_elab = elaborate(total_form).expect("`{{total}}` should elaborate");
let per_unit_elab = elaborate(per_unit_form).expect("`{cost}` should elaborate");
let total_lot = total_elab.transactions[0].postings[0]
.lot
.as_ref()
.expect("lot present (total form)");
let per_unit_lot = per_unit_elab.transactions[0].postings[0]
.lot
.as_ref()
.expect("lot present (per-unit form)");
assert_eq!(
total_lot, per_unit_lot,
"total and per-unit forms should produce identical elaborated lots"
);
}
#[test]
fn lot_cost_parenthesised_addition() {
let input = "\
2024-01-01 open Assets:Brokerage USD
2024-01-01 open Assets:Bank:Checking USD
2024-02-15 * \"Buy with computed basis\"
Assets:Brokerage 10 AAPL {(150.00 + 5.00) USD}
Assets:Bank:Checking -1550.00 USD
";
let elab = elaborate(input).expect("parenthesised lot-cost arithmetic must elaborate");
let lot = elab.transactions[0].postings[0]
.lot
.as_ref()
.expect("lot present");
let cost = lot.cost.as_ref().expect("cost present");
let usd = cost
.by_commodity
.get("USD")
.expect("USD cost present")
.to_decimal();
assert_eq!(
usd,
dec!(155.00),
"per-unit basis from `(150 + 5) USD` should be 155.00; got {usd}"
);
}
#[test]
fn lot_cost_division_matches_double_brace_total_form() {
let arithmetic_form = "\
2024-01-01 open Assets:Brokerage USD
2024-01-01 open Assets:Bank:Checking USD
2024-02-15 * \"Buy with explicit divide\"
Assets:Brokerage 10 AAPL {(1500.00 / 10) USD}
Assets:Bank:Checking -1500.00 USD
";
let total_brace_form = "\
2024-01-01 open Assets:Brokerage USD
2024-01-01 open Assets:Bank:Checking USD
2024-02-15 * \"Buy with double-brace total\"
Assets:Brokerage 10 AAPL {{1500.00 USD}}
Assets:Bank:Checking -1500.00 USD
";
let lhs = elaborate(arithmetic_form).expect("explicit-divide lot must elaborate");
let rhs = elaborate(total_brace_form).expect("double-brace lot must elaborate");
let lhs_lot = lhs.transactions[0].postings[0]
.lot
.as_ref()
.expect("lot present (arithmetic)");
let rhs_lot = rhs.transactions[0].postings[0]
.lot
.as_ref()
.expect("lot present (double-brace)");
assert_eq!(
lhs_lot, rhs_lot,
"explicit-divide and double-brace forms should produce identical elaborated lots"
);
}
#[test]
fn lot_cost_nested_parens_with_mixed_operators() {
let input = "\
2024-01-01 open Assets:Brokerage USD
2024-01-01 open Assets:Bank:Checking USD
2024-02-15 * \"Buy with computed basis (commission included)\"
Assets:Brokerage 10 AAPL {((1500.00 + 50.00) / 10) USD}
Assets:Bank:Checking -1550.00 USD
";
let elab = elaborate(input).expect("nested-parens lot-cost must elaborate");
let lot = elab.transactions[0].postings[0]
.lot
.as_ref()
.expect("lot present");
let cost = lot.cost.as_ref().expect("cost present");
let usd = cost
.by_commodity
.get("USD")
.expect("USD cost present")
.to_decimal();
assert_eq!(usd, dec!(155.00));
}
#[test]
fn lot_price_at_per_unit() {
let input = "\
2024-05-15 * \"Buy euros\"
Assets:Cash:EUR 500.00 EUR @ 1.07 USD
Assets:Bank:Checking
";
let journal = parse_beancount(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
let Some(AmountDetails::Amount { lot_pricing, .. }) = &tx.postings[0].amount else {
panic!();
};
assert!(matches!(lot_pricing, Some(LotPricing::Unit(_))));
}
#[test]
fn arithmetic_amount_in_posting() {
let input = "\
2024-03-01 *
Expenses:Food (50.00 + 50.00) USD
Assets:Bank:Checking
";
let journal = parse_beancount(input).expect("parse");
let Entry::Transaction(tx) = &journal.entries[0] else {
panic!();
};
let Some(AmountDetails::Amount { value, .. }) = &tx.postings[0].amount else {
panic!();
};
assert!(
matches!(value, ValueExpr::Typed { .. }),
"expected Typed wrapping arithmetic, got {value:?}"
);
}
#[test]
fn sample_fixture_round_trips_through_resolution() {
let journal = parse_beancount(SAMPLE).expect("parse sample.beancount");
assert!(!journal.entries.is_empty());
let _hir: crate::resolution::HIR = journal.try_into().expect("resolve sample.beancount");
}
#[test]
fn sample_fixture_round_trips_through_elaboration() {
let journal = parse_beancount(SAMPLE).expect("parse sample.beancount");
let hir: crate::resolution::HIR = journal.try_into().expect("resolve sample.beancount");
let elab: crate::elaboration::Journal = crate::elaborate(hir, &beancount_defaults())
.expect("elaborate sample.beancount (pad+balance must reconcile)");
let pad_tx_count = elab
.transactions
.iter()
.filter(|t| t.metadata.contains_key("pad"))
.count();
assert_eq!(
pad_tx_count, 1,
"exactly one synthesized pad transaction expected"
);
}
#[test]
fn frontend_extensions_lists_beancount() {
use crate::frontend::Frontend;
assert!(BeancountFrontend.extensions().contains(&"beancount"));
}
#[test]
fn org_mode_outline_journal_parses_and_resolves() {
let input = "\
* Personal
** Bank accounts
2024-01-01 open Assets:Bank:Checking USD
*** Transactions
2024-01-12 * \"Salary\"
Assets:Bank:Checking 3400.00 USD
Income:Salary -3400.00 USD
";
let journal = parse_beancount(input).expect("parse with org-mode headings");
let comment_count = journal
.entries
.iter()
.filter(|e| matches!(e, Entry::Comment(_)))
.count();
assert_eq!(
comment_count, 3,
"three org-mode headings preserved as comments, got entries={:?}",
journal.entries
);
let open_count = journal
.entries
.iter()
.filter(|e| matches!(e, Entry::Directive(Directive::Account { .. })))
.count();
assert_eq!(open_count, 1);
let txn_count = journal
.entries
.iter()
.filter(|e| matches!(e, Entry::Transaction(_)))
.count();
assert_eq!(txn_count, 1);
let hir: crate::resolution::HIR = journal.try_into().expect("resolve");
let resolved_txn_count = hir
.entries
.iter()
.filter(|e| matches!(e.data, crate::resolution::Entry::Transaction(_)))
.count();
assert_eq!(resolved_txn_count, 1);
}
#[test]
fn transaction_string_with_embedded_escapes() {
let input = "\
2024-01-01 open Expenses:Food
2024-01-01 open Assets:Cash USD
2024-03-10 * \"order #42 \\\"weekly\\\" delivery\\nnote: replace tomatoes\"
Expenses:Food 12.34 USD
Assets:Cash -12.34 USD
";
let journal = parse_beancount(input).expect("parse");
let Entry::Transaction(tx) = journal
.entries
.iter()
.find(|e| matches!(e, Entry::Transaction(_)))
.expect("transaction present")
else {
unreachable!()
};
assert_eq!(
tx.description, "order #42 \"weekly\" delivery\nnote: replace tomatoes",
"string escapes should be decoded into actual characters"
);
}
fn resolved_tx<'a>(hir: &'a crate::resolution::HIR) -> Vec<&'a crate::resolution::Transaction> {
hir.entries
.iter()
.filter_map(|e| match &e.data {
crate::resolution::Entry::Transaction(t) => Some(t),
_ => None,
})
.collect()
}
#[test]
fn pushtag_scopes_tag_to_subsequent_transactions() {
let input = "\
2024-01-01 open Expenses:Travel
2024-01-01 open Assets:Cash USD
pushtag #trip-2024
2024-06-15 * \"Hotel\"
Expenses:Travel 100.00 USD
Assets:Cash -100.00 USD
2024-06-16 * \"Dinner\"
Expenses:Travel 40.00 USD
Assets:Cash -40.00 USD
poptag #trip-2024
2024-07-01 * \"Outside the trip\"
Expenses:Travel 20.00 USD
Assets:Cash -20.00 USD
";
let journal = parse_beancount(input).expect("parse");
let hir: crate::resolution::HIR = journal.try_into().expect("resolve");
let txs = resolved_tx(&hir);
assert_eq!(txs.len(), 3);
assert!(
txs[0].tags.iter().any(|t| t == "trip-2024"),
"txs[0]={:?}",
txs[0].tags
);
assert!(
txs[1].tags.iter().any(|t| t == "trip-2024"),
"txs[1]={:?}",
txs[1].tags
);
assert!(
!txs[2].tags.iter().any(|t| t == "trip-2024"),
"third transaction must NOT have the popped tag, got: {:?}",
txs[2].tags
);
}
#[test]
fn nested_pushtags_compose() {
let input = "\
2024-01-01 open Expenses:Travel
2024-01-01 open Assets:Cash USD
pushtag #project-acme
pushtag #travel
2024-02-15 * \"Site visit\"
Expenses:Travel 300.00 USD
Assets:Cash -300.00 USD
poptag #travel
2024-02-20 * \"Project meeting (no travel)\"
Expenses:Travel 50.00 USD
Assets:Cash -50.00 USD
poptag #project-acme
";
let journal = parse_beancount(input).expect("parse");
let hir: crate::resolution::HIR = journal.try_into().expect("resolve");
let txs = resolved_tx(&hir);
assert_eq!(txs.len(), 2);
let t0_tags: std::collections::HashSet<&str> =
txs[0].tags.iter().map(String::as_str).collect();
assert!(
t0_tags.contains("project-acme"),
"tx0 tags={:?}",
txs[0].tags
);
assert!(t0_tags.contains("travel"), "tx0 tags={:?}", txs[0].tags);
let t1_tags: std::collections::HashSet<&str> =
txs[1].tags.iter().map(String::as_str).collect();
assert!(t1_tags.contains("project-acme"));
assert!(!t1_tags.contains("travel"));
}
#[test]
fn pushtag_unions_with_transaction_local_tags() {
let input = "\
2024-01-01 open Expenses:Food
2024-01-01 open Assets:Cash USD
pushtag #household
2024-03-10 * \"Groceries\" #weekly
Expenses:Food 50.00 USD
Assets:Cash -50.00 USD
poptag #household
";
let journal = parse_beancount(input).expect("parse");
let hir: crate::resolution::HIR = journal.try_into().expect("resolve");
let txs = resolved_tx(&hir);
let tags: std::collections::HashSet<&str> =
txs[0].tags.iter().map(String::as_str).collect();
assert!(
tags.contains("household"),
"pushtag missing: {:?}",
txs[0].tags
);
assert!(
tags.contains("weekly"),
"transaction-local tag missing: {:?}",
txs[0].tags
);
}
#[test]
fn poptag_with_no_matching_push_is_silently_ignored() {
let input = "\
2024-01-01 open Expenses:Food
2024-01-01 open Assets:Cash USD
poptag #never-pushed
2024-03-10 * \"Lunch\"
Expenses:Food 10.00 USD
Assets:Cash -10.00 USD
";
let journal = parse_beancount(input).expect("parse");
let hir: crate::resolution::HIR = journal.try_into().expect("resolve");
let txs = resolved_tx(&hir);
assert_eq!(txs.len(), 1);
assert!(
txs[0].tags.is_empty(),
"transaction should have no tags, got {:?}",
txs[0].tags
);
}
#[test]
fn pushmeta_scopes_metadata_to_subsequent_transactions() {
let input = "\
2024-01-01 open Expenses:Consulting
2024-01-01 open Assets:Cash USD
pushmeta project: \"acme-rebrand\"
2024-06-15 * \"Design review\"
Expenses:Consulting 500.00 USD
Assets:Cash -500.00 USD
popmeta project:
2024-07-01 * \"Outside the project\"
Expenses:Consulting 100.00 USD
Assets:Cash -100.00 USD
";
let journal = parse_beancount(input).expect("parse");
let hir: crate::resolution::HIR = journal.try_into().expect("resolve");
let txs = resolved_tx(&hir);
assert_eq!(txs.len(), 2);
assert_eq!(
txs[0].metadata.get("project").map(String::as_str),
Some("acme-rebrand"),
"pushmeta missing on tx0: {:?}",
txs[0].metadata
);
assert!(
!txs[1].metadata.contains_key("project"),
"popped metadata leaked: {:?}",
txs[1].metadata
);
}
#[test]
fn pushmeta_most_recent_value_wins_for_same_key() {
let input = "\
2024-01-01 open Expenses:Consulting
2024-01-01 open Assets:Cash USD
pushmeta phase: \"discovery\"
pushmeta phase: \"delivery\"
2024-08-15 * \"Sprint\"
Expenses:Consulting 1000.00 USD
Assets:Cash -1000.00 USD
";
let journal = parse_beancount(input).expect("parse");
let hir: crate::resolution::HIR = journal.try_into().expect("resolve");
let txs = resolved_tx(&hir);
assert_eq!(
txs[0].metadata.get("phase").map(String::as_str),
Some("delivery"),
"later pushmeta should win: {:?}",
txs[0].metadata
);
}
#[test]
fn pushmeta_does_not_leak_into_transactions_before_the_push() {
let input = "\
2024-01-01 open Expenses:Food
2024-01-01 open Assets:Cash USD
pushmeta tag: \"taxable\"
2024-01-05 * \"Lunch\"
Expenses:Food 10.00 USD
Assets:Cash -10.00 USD
popmeta tag:
";
let journal = parse_beancount(input).expect("parse");
let hir: crate::resolution::HIR = journal.try_into().expect("resolve");
let txs = resolved_tx(&hir);
assert_eq!(
txs[0].metadata.get("tag").map(String::as_str),
Some("taxable")
);
}
#[test]
fn sub_tolerance_residual_is_absorbed_into_synthetic_account() {
let input = "\
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Salary USD
2024-01-15 * \"Sub-tolerance residual\"
Assets:Cash 100.00 USD
Income:Salary -100.005 USD
";
let elab = elaborate(input).expect("sub-tolerance residual must elaborate");
let mut found = None;
for tx in &elab.transactions {
for p in &tx.postings {
if p.account.is_empty() {
found = Some(p);
}
}
}
let p = found.expect("a synthesized empty-account posting should exist");
let amt = p
.amount
.as_ref()
.and_then(|a| a.by_commodity.get("USD"))
.expect("rounding posting in USD");
assert_eq!(
(amt.mantissa_low, amt.mantissa_high, amt.scale),
(5, 0, 3),
"rounding posting should absorb +0.005 USD"
);
}
#[test]
fn over_tolerance_residual_still_errors() {
let input = "\
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Salary USD
2024-01-15 * \"Over-tolerance residual\"
Assets:Cash 100.00 USD
Income:Salary -100.05 USD
";
let err = elaborate(input).expect_err("over-tolerance residual must reject");
let msg = format!("{err:?}");
assert!(
msg.contains("TransactionDoesNotBalance"),
"expected TransactionDoesNotBalance, got: {msg}"
);
}
#[test]
fn inferred_tolerance_default_option_overrides_per_commodity() {
let input = "\
option \"inferred_tolerance_default\" \"USD:0.1\"
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Salary USD
2024-01-15 * \"Larger residual within override\"
Assets:Cash 100.00 USD
Income:Salary -100.05 USD
";
let elab = elaborate(input).expect("USD override should accept the 0.05 residual");
let mut found_amt: Option<rust_decimal::Decimal> = None;
for tx in &elab.transactions {
for p in &tx.postings {
if p.account.is_empty() {
if let Some(amt) = p.amount.as_ref().and_then(|a| a.by_commodity.get("USD")) {
let mantissa = (amt.mantissa_high as i128) << 64 | amt.mantissa_low as i128;
let d = rust_decimal::Decimal::from_i128_with_scale(mantissa, amt.scale);
found_amt = Some(d);
}
}
}
}
assert_eq!(
found_amt,
Some(rust_decimal::Decimal::new(5, 2)),
"rounding posting should absorb +0.05 USD when override permits it"
);
}
#[test]
fn inferred_tolerance_default_does_not_apply_to_other_commodities() {
let input = "\
option \"inferred_tolerance_default\" \"USD:0.1\"
2024-01-01 open Assets:Cash:EUR EUR
2024-01-01 open Income:Salary:EUR EUR
2024-01-15 * \"EUR over default tolerance\"
Assets:Cash:EUR 100.00 EUR
Income:Salary:EUR -100.05 EUR
";
let err = elaborate(input).expect_err("USD override must not affect EUR");
assert!(format!("{err:?}").contains("TransactionDoesNotBalance"));
}
fn elaborate(input: &str) -> Result<crate::elaboration::Journal, Box<dyn std::error::Error>> {
let (journal, options) = parse_beancount_with_options(input)?;
let mut hir: crate::resolution::HIR = journal.try_into()?;
for (k, v) in &options {
if k == "inferred_tolerance_default"
&& let Some((commodity, decimal)) = v.split_once(':')
&& let Ok(d) = decimal.trim().parse::<rust_decimal::Decimal>()
{
hir.global_context
.tolerance_overrides
.insert(commodity.trim().to_string(), d);
}
}
Ok(crate::elaborate(hir, &beancount_defaults())?)
}
#[test]
fn pad_fills_gap_to_next_balance_assertion() {
let input = "\
2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Income:Salary USD
2024-01-01 open Equity:Opening-Balances
2024-01-01 pad Assets:Bank:Checking Equity:Opening-Balances
2024-01-15 balance Assets:Bank:Checking 5000.00 USD
2024-01-12 *
Assets:Bank:Checking 3400.00 USD
Income:Salary -3400.00 USD
";
let elab = elaborate(input).expect("elaborate (pad+balance must reconcile)");
let pad_txs: Vec<_> = elab
.transactions
.iter()
.filter(|t| t.metadata.contains_key("pad"))
.collect();
assert_eq!(pad_txs.len(), 1, "expected exactly one synthesized pad txn");
let pad_tx = pad_txs[0];
assert_eq!(
pad_tx.metadata.get("pad").map(String::as_str),
Some("Equity:Opening-Balances"),
"pad provenance metadata should record the source account"
);
assert_eq!(
pad_tx.date,
chrono::NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.signed_duration_since(chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap())
.num_days() as i32,
"synthesized pad txn should be back-dated to the pad directive"
);
assert_eq!(pad_tx.postings.len(), 2);
let target = pad_tx
.postings
.iter()
.find(|p| p.account == "Assets:Bank:Checking")
.expect("target posting present");
let source = pad_tx
.postings
.iter()
.find(|p| p.account == "Equity:Opening-Balances")
.expect("source posting present");
let target_usd = target
.amount
.as_ref()
.and_then(|a| a.by_commodity.get("USD"))
.expect("target amount in USD");
let source_usd = source
.amount
.as_ref()
.and_then(|a| a.by_commodity.get("USD"))
.expect("source amount in USD");
assert_eq!(
(
target_usd.mantissa_low,
target_usd.mantissa_high,
target_usd.scale
),
(160000, 0, 2),
"target should receive +1600.00"
);
assert_eq!(
source_usd.mantissa_high, -1,
"source amount should be negative"
);
assert_eq!(source_usd.scale, 2);
}
#[test]
fn pad_without_following_balance_is_silently_dropped() {
let input = "\
2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Equity:Opening-Balances
2024-01-01 pad Assets:Bank:Checking Equity:Opening-Balances
";
let elab = elaborate(input).expect("elaborate");
let pad_txs = elab
.transactions
.iter()
.filter(|t| t.metadata.contains_key("pad"))
.count();
assert_eq!(
pad_txs, 0,
"pad without a follow-up balance assertion must be dropped, not error"
);
}
#[test]
fn most_recent_pad_wins_when_multiple_precede_one_balance() {
let input = "\
2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Equity:Opening-Balances
2024-01-01 open Equity:Late-Init
2024-01-01 pad Assets:Bank:Checking Equity:Opening-Balances
2024-01-05 pad Assets:Bank:Checking Equity:Late-Init
2024-01-15 balance Assets:Bank:Checking 1000.00 USD
";
let elab = elaborate(input).expect("elaborate");
let pad_txs: Vec<_> = elab
.transactions
.iter()
.filter(|t| t.metadata.contains_key("pad"))
.collect();
assert_eq!(pad_txs.len(), 1, "exactly one synthesized pad txn");
let pad_tx = pad_txs[0];
assert_eq!(
pad_tx.metadata.get("pad").map(String::as_str),
Some("Equity:Late-Init"),
"the most recent pad's source must be used"
);
assert_eq!(
pad_tx.date,
chrono::NaiveDate::from_ymd_opt(2024, 1, 5)
.unwrap()
.signed_duration_since(chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap())
.num_days() as i32,
);
}
#[test]
fn pad_covers_every_asserted_commodity_on_same_target() {
let input = "\
2024-01-01 open Assets:Cash
2024-01-01 open Equity:OpeningBalances
2024-01-02 pad Assets:Cash Equity:OpeningBalances
2024-01-15 balance Assets:Cash 200 CAD
2024-01-15 balance Assets:Cash 300 USD
";
let elab = elaborate(input).expect("multi-commodity pad must reconcile");
let pad_txs: Vec<_> = elab
.transactions
.iter()
.filter(|t| t.metadata.contains_key("pad"))
.collect();
assert_eq!(
pad_txs.len(),
2,
"one pad txn per asserted commodity, expected 2"
);
let cad_pad = pad_txs
.iter()
.find(|t| {
t.postings
.iter()
.any(|p| p.account == "Assets:Cash" && p.amount_in("CAD").is_some())
})
.expect("CAD pad txn");
let cad_target = cad_pad
.postings
.iter()
.find(|p| p.account == "Assets:Cash")
.unwrap();
assert_eq!(cad_target.amount_in("CAD"), Some(dec!(200)));
let usd_pad = pad_txs
.iter()
.find(|t| {
t.postings
.iter()
.any(|p| p.account == "Assets:Cash" && p.amount_in("USD").is_some())
})
.expect("USD pad txn");
let usd_target = usd_pad
.postings
.iter()
.find(|p| p.account == "Assets:Cash")
.unwrap();
assert_eq!(usd_target.amount_in("USD"), Some(dec!(300)));
}
#[test]
fn pad_does_not_double_synthesise_for_repeated_commodity() {
let input = "\
2024-01-01 open Assets:Cash
2024-01-01 open Equity:OpeningBalances
2024-01-02 pad Assets:Cash Equity:OpeningBalances
2024-01-15 balance Assets:Cash 200 USD
2024-01-31 balance Assets:Cash 200 USD
";
let elab = elaborate(input).expect("repeated same-commodity assertion must not double-pad");
let pad_txs = elab
.transactions
.iter()
.filter(|t| t.metadata.contains_key("pad"))
.count();
assert_eq!(
pad_txs, 1,
"exactly one pad txn for the single padded commodity"
);
}
#[test]
fn pad_does_not_re_fire_after_real_posting_in_same_commodity() {
let input = "\
2024-01-01 open Assets:Cash USD
2024-01-01 open Equity:Open
2024-01-01 open Expenses:Food USD
2024-01-01 pad Assets:Cash Equity:Open
2024-01-15 balance Assets:Cash 100 USD
2024-02-01 * \"Spend\"
Assets:Cash -50 USD
Expenses:Food
2024-03-01 balance Assets:Cash 25 USD
";
let err = elaborate(input).expect_err(
"second USD assertion must fail because the pad was consumed by the first; \
a real posting between them moved running to 50, not 25",
);
let s = format!("{err:?}");
assert!(
s.contains("BalanceAssertionFailed"),
"expected BalanceAssertionFailed, got: {s}"
);
}
#[test]
fn pad_per_commodity_independent_of_other_commodity_postings() {
let input = "\
2024-01-01 open Assets:Cash
2024-01-01 open Equity:Open
2024-01-01 open Equity:Other
2024-01-01 pad Assets:Cash Equity:Open
2024-01-15 balance Assets:Cash 100 USD
2024-02-01 * \"Buy EUR\"
Assets:Cash 50 EUR
Equity:Other
2024-03-01 balance Assets:Cash 200 EUR
";
let elab = elaborate(input).expect(
"EUR pad must fire independently; USD prior firing does not consume it for EUR",
);
let pad_txs: Vec<_> = elab
.transactions
.iter()
.filter(|t| t.metadata.contains_key("pad"))
.collect();
assert_eq!(
pad_txs.len(),
2,
"one pad txn per fired commodity (USD, EUR)"
);
}
#[test]
fn pad_with_zero_gap_emits_no_transaction() {
let input = "\
2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Income:Salary USD
2024-01-01 open Equity:Opening-Balances
2024-01-01 pad Assets:Bank:Checking Equity:Opening-Balances
2024-01-15 balance Assets:Bank:Checking 1000.00 USD
2024-01-10 *
Assets:Bank:Checking 1000.00 USD
Income:Salary -1000.00 USD
";
let elab = elaborate(input).expect("elaborate");
let pad_txs = elab
.transactions
.iter()
.filter(|t| t.metadata.contains_key("pad"))
.count();
assert_eq!(
pad_txs, 0,
"pad whose gap is zero should not emit a synthesized transaction"
);
}
#[test]
fn balance_directive_aggregates_subtree() {
let input = "\
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Salary USD
2024-01-01 open Income:Salary:Base USD
2024-06-01 * \"Salary\"
Assets:Cash 100.00 USD
Income:Salary:Base -100.00 USD
2024-12-31 balance Income:Salary -100.00 USD
";
elaborate(input).expect("balance on parent must reach the descendant's posting");
}
#[test]
fn pad_residual_uses_subtree_sum() {
let input = "\
2024-01-01 open Equity:OpeningBalances
2024-01-01 open Assets:Bank USD
2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Assets:Bank:Savings USD
2024-01-02 pad Assets:Bank Equity:OpeningBalances
2024-01-15 * \"Existing\"
Assets:Bank:Checking 100.00 USD
Assets:Bank:Savings 200.00 USD
Equity:OpeningBalances
2024-12-31 balance Assets:Bank 1000.00 USD
";
let elab = elaborate(input).expect("subtree pad must reconcile against subtree sum");
let pad_txs: Vec<_> = elab
.transactions
.iter()
.filter(|t| t.metadata.contains_key("pad"))
.collect();
assert_eq!(pad_txs.len(), 1, "exactly one pad txn expected");
let pad_tx = pad_txs[0];
let target = pad_tx
.postings
.iter()
.find(|p| p.account == "Assets:Bank")
.expect("synthesized posting must land on the literal pad target");
assert_eq!(target.amount_in("USD"), Some(dec!(700.00)));
let source = pad_tx
.postings
.iter()
.find(|p| p.account == "Equity:OpeningBalances")
.expect("synthesized source posting");
assert_eq!(source.amount_in("USD"), Some(dec!(-700.00)));
}
#[test]
fn balance_directive_on_leaf_is_unchanged() {
let input = "\
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Salary USD
2024-06-01 * \"Salary\"
Assets:Cash 100.00 USD
Income:Salary -100.00 USD
2024-12-31 balance Income:Salary -100.00 USD
";
elaborate(input).expect("leaf-account assertion must still pass");
}
#[test]
fn fifo_books_against_oldest_lot() {
let input = "\
2024-01-01 open Assets:Brokerage \"FIFO\"
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Trading
2024-01-15 * \"Buy\"
Assets:Brokerage 10 GOLD {1500.00 USD}
Assets:Cash -15000.00 USD
2024-02-15 * \"Buy more\"
Assets:Brokerage 10 GOLD {1600.00 USD}
Assets:Cash -16000.00 USD
2024-03-15 * \"Sell with empty cost spec\"
Assets:Brokerage -5 GOLD {} @ 1700.00 USD
Assets:Cash 8500.00 USD
Income:Trading
";
let elab = elaborate(input).expect("FIFO booking should succeed");
let sell_tx = elab
.transactions
.iter()
.find(|t| t.description == "Sell with empty cost spec")
.unwrap();
let booked = sell_tx
.postings
.iter()
.find(|p| p.account == "Assets:Brokerage")
.expect("brokerage posting present");
assert_eq!(booked.amount_in("GOLD"), Some(dec!(-5)));
assert_eq!(
booked.lot_cost_in("USD"),
Some(dec!(1500.00)),
"FIFO should match the oldest (1500 USD) lot"
);
}
#[test]
fn fifo_splits_multi_lot_reduction() {
let input = "\
2024-01-01 open Assets:Brokerage \"FIFO\"
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Trading
2024-01-15 * \"Buy\"
Assets:Brokerage 10 GOLD {1500.00 USD}
Assets:Cash -15000.00 USD
2024-02-15 * \"Buy more\"
Assets:Brokerage 10 GOLD {1600.00 USD}
Assets:Cash -16000.00 USD
2024-03-15 * \"Sell across two lots\"
Assets:Brokerage -15 GOLD {} @ 1700.00 USD
Assets:Cash 25500.00 USD
Income:Trading
";
let elab = elaborate(input).expect("FIFO multi-lot booking should succeed");
let sell_tx = elab
.transactions
.iter()
.find(|t| t.description == "Sell across two lots")
.unwrap();
let brokerage_postings: Vec<_> = sell_tx
.postings
.iter()
.filter(|p| p.account == "Assets:Brokerage")
.collect();
assert_eq!(
brokerage_postings.len(),
2,
"expected two booked postings, one per matched lot"
);
assert_eq!(brokerage_postings[0].amount_in("GOLD"), Some(dec!(-10)));
assert_eq!(
brokerage_postings[0].lot_cost_in("USD"),
Some(dec!(1500.00))
);
assert_eq!(brokerage_postings[1].amount_in("GOLD"), Some(dec!(-5)));
assert_eq!(
brokerage_postings[1].lot_cost_in("USD"),
Some(dec!(1600.00))
);
}
#[test]
fn lifo_books_against_newest_lot() {
let input = "\
2024-01-01 open Assets:Brokerage \"LIFO\"
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Trading
2024-01-15 * \"Buy\"
Assets:Brokerage 10 GOLD {1500.00 USD}
Assets:Cash -15000.00 USD
2024-02-15 * \"Buy more\"
Assets:Brokerage 10 GOLD {1600.00 USD}
Assets:Cash -16000.00 USD
2024-03-15 * \"Sell\"
Assets:Brokerage -5 GOLD {} @ 1700.00 USD
Assets:Cash 8500.00 USD
Income:Trading
";
let elab = elaborate(input).expect("LIFO booking should succeed");
let sell_tx = elab
.transactions
.iter()
.find(|t| t.description == "Sell")
.unwrap();
let booked = sell_tx
.postings
.iter()
.find(|p| p.account == "Assets:Brokerage")
.expect("brokerage posting present");
assert_eq!(
booked.lot_cost_in("USD"),
Some(dec!(1600.00)),
"LIFO should match the newest (1600 USD) lot"
);
}
#[test]
fn hifo_books_against_highest_cost_lot() {
let input = "\
2024-01-01 open Assets:Brokerage \"HIFO\"
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Trading
2024-01-15 * \"Buy\"
Assets:Brokerage 10 GOLD {1500.00 USD}
Assets:Cash -15000.00 USD
2024-02-15 * \"Buy expensive\"
Assets:Brokerage 10 GOLD {1700.00 USD}
Assets:Cash -17000.00 USD
2024-02-20 * \"Buy cheaper\"
Assets:Brokerage 10 GOLD {1600.00 USD}
Assets:Cash -16000.00 USD
2024-03-15 * \"Sell\"
Assets:Brokerage -5 GOLD {} @ 1750.00 USD
Assets:Cash 8750.00 USD
Income:Trading
";
let elab = elaborate(input).expect("HIFO booking should succeed");
let sell_tx = elab
.transactions
.iter()
.find(|t| t.description == "Sell")
.unwrap();
let booked = sell_tx
.postings
.iter()
.find(|p| p.account == "Assets:Brokerage")
.expect("brokerage posting present");
assert_eq!(
booked.lot_cost_in("USD"),
Some(dec!(1700.00)),
"HIFO should match the most-expensive (1700 USD) lot"
);
}
#[test]
fn strict_rejects_ambiguous_empty_lot_spec() {
let input = "\
2024-01-01 open Assets:Brokerage \"STRICT\"
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Trading
2024-01-15 * \"Buy\"
Assets:Brokerage 10 GOLD {1500.00 USD}
Assets:Cash -15000.00 USD
2024-02-15 * \"Buy more\"
Assets:Brokerage 10 GOLD {1600.00 USD}
Assets:Cash -16000.00 USD
2024-03-15 * \"Ambiguous sell\"
Assets:Brokerage -5 GOLD {} @ 1700.00 USD
Assets:Cash 8500.00 USD
Income:Trading
";
let err = elaborate(input).expect_err("STRICT should reject ambiguous {} reduction");
assert!(
format!("{err:?}").contains("AmbiguousLotMatch"),
"expected AmbiguousLotMatch, got {err:?}"
);
}
#[test]
fn strict_accepts_partial_spec_that_uniquely_resolves() {
let input = "\
2024-01-01 open Assets:Brokerage \"STRICT\"
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Trading
2024-01-15 * \"Buy\"
Assets:Brokerage 10 GOLD {1500.00 USD}
Assets:Cash -15000.00 USD
2024-02-15 * \"Buy more\"
Assets:Brokerage 10 GOLD {1600.00 USD}
Assets:Cash -16000.00 USD
2024-03-15 * \"Sell from January lot\"
Assets:Brokerage -5 GOLD {2024-01-15} @ 1700.00 USD
Assets:Cash 8500.00 USD
Income:Trading
";
let elab = elaborate(input).expect("STRICT + date hint should resolve");
let sell_tx = elab
.transactions
.iter()
.find(|t| t.description == "Sell from January lot")
.unwrap();
let booked = sell_tx
.postings
.iter()
.find(|p| p.account == "Assets:Brokerage")
.expect("brokerage posting present");
assert_eq!(booked.lot_cost_in("USD"), Some(dec!(1500.00)));
}
#[test]
fn fifo_null_income_posting_fills_with_cost_basis_gain() {
let input = "\
2024-01-01 open Assets:Brokerage \"FIFO\"
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Trading
2024-01-15 * \"Buy\"
Assets:Brokerage 10 GOLD {1500.00 USD}
Assets:Cash -15000.00 USD
2024-03-15 * \"Sell with empty cost spec\"
Assets:Brokerage -5 GOLD {} @ 1700.00 USD
Assets:Cash 8500.00 USD
Income:Trading
";
let elab = elaborate(input).expect("gain inference should succeed");
let sell_tx = elab
.transactions
.iter()
.find(|t| t.description == "Sell with empty cost spec")
.unwrap();
let income = sell_tx
.postings
.iter()
.find(|p| p.account == "Income:Trading")
.expect("Income:Trading null posting present");
assert_eq!(
income.amount_in("USD"),
Some(dec!(-1000.00)),
"null Income:Trading should absorb the cost-basis gain (cash 8500 - cost basis 7500 = 1000)"
);
}
#[test]
fn fifo_multi_lot_null_income_fills_with_total_gain() {
let input = "\
2024-01-01 open Assets:Brokerage \"FIFO\"
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Trading
2024-01-15 * \"Buy first lot\"
Assets:Brokerage 10 GOLD {1500.00 USD}
Assets:Cash -15000.00 USD
2024-02-15 * \"Buy second lot\"
Assets:Brokerage 10 GOLD {1600.00 USD}
Assets:Cash -16000.00 USD
2024-03-15 * \"Sell across two lots\"
Assets:Brokerage -15 GOLD {} @ 1700.00 USD
Assets:Cash 25500.00 USD
Income:Trading
";
let elab = elaborate(input).expect("multi-lot gain inference should succeed");
let sell_tx = elab
.transactions
.iter()
.find(|t| t.description == "Sell across two lots")
.unwrap();
let income = sell_tx
.postings
.iter()
.find(|p| p.account == "Income:Trading")
.expect("Income:Trading null posting present");
assert_eq!(income.amount_in("USD"), Some(dec!(-2500.00)));
}
#[test]
fn none_booking_passes_through_unchanged() {
let input = "\
2024-01-01 open Assets:Brokerage \"NONE\"
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Trading
2024-01-15 * \"Buy\"
Assets:Brokerage 10 GOLD {1500.00 USD}
Assets:Cash -15000.00 USD
2024-03-15 * \"Sell with no booking\"
Assets:Brokerage -5 GOLD {} @ 1700.00 USD
Assets:Cash 8500.00 USD
Income:Trading
";
let elab = elaborate(input).expect("NONE booking accepts the posting");
let sell_tx = elab
.transactions
.iter()
.find(|t| t.description == "Sell with no booking")
.unwrap();
let booked = sell_tx
.postings
.iter()
.find(|p| p.account == "Assets:Brokerage")
.expect("brokerage posting present");
assert_eq!(booked.amount_in("GOLD"), Some(dec!(-5)));
assert_eq!(
booked.lot_cost_in("USD"),
None,
"NONE should leave cost MISSING"
);
}
#[test]
fn over_reduction_in_booking_errors() {
let input = "\
2024-01-01 open Assets:Brokerage \"FIFO\"
2024-01-01 open Assets:Cash USD
2024-01-01 open Income:Trading
2024-01-15 * \"Buy\"
Assets:Brokerage 10 GOLD {1500.00 USD}
Assets:Cash -15000.00 USD
2024-03-15 * \"Sell more than we have\"
Assets:Brokerage -25 GOLD {} @ 1700.00 USD
Assets:Cash 42500.00 USD
Income:Trading
";
let err = elaborate(input).expect_err("over-reduction should error");
assert!(
format!("{err:?}").contains("OverReductionInBooking"),
"expected OverReductionInBooking, got {err:?}"
);
}
#[test]
fn augmenting_posting_with_missing_cost_errors() {
let input = "\
2024-01-01 open Assets:Brokerage \"FIFO\"
2024-01-01 open Assets:Cash USD
2024-03-15 * \"Buy with empty cost spec\"
Assets:Brokerage 5 GOLD {} @ 1700.00 USD
Assets:Cash -8500.00 USD
";
let err = elaborate(input).expect_err("augmenting + MISSING should error");
assert!(
format!("{err:?}").contains("AugmentingPostingWithMissingCost"),
"expected AugmentingPostingWithMissingCost, got {err:?}"
);
}
}