use std::collections::HashMap;
use rustledger_core::{
Amount, Cost, CostNumber, CostSpec, Decimal, Directive, IncompleteAmount, Inventory, Metadata,
NaiveDate, Position, Posting, Span, Spanned, Transaction,
};
fn account_root(account: &str) -> &str {
account.split(':').next().unwrap_or("")
}
fn is_balance_sheet(account: &str) -> bool {
matches!(account_root(account), "Assets" | "Liabilities" | "Equity")
}
fn is_income_statement(account: &str) -> bool {
matches!(account_root(account), "Income" | "Expenses")
}
const fn type_priority(d: &Directive) -> u8 {
match d {
Directive::Open(_) => 0,
Directive::Balance(_) => 1,
Directive::Transaction(_) => 2,
Directive::Close(_) => 10,
_ => 5,
}
}
fn posting_position(units: &Amount, cost: Option<&CostSpec>) -> Position {
let Some(spec) = cost else {
return Position::simple(units.clone());
};
let Some(cost_number) = spec.number else {
return Position::simple(units.clone());
};
let per_unit = cost_number.per_unit().or(match cost_number {
CostNumber::Total { value } if !units.number.is_zero() => Some(value / units.number.abs()),
_ => None,
});
let (Some(number), Some(currency)) = (per_unit, spec.currency.clone()) else {
return Position::simple(units.clone());
};
Position::with_cost(
units.clone(),
Cost {
number,
currency,
date: spec.date,
label: spec.label.clone(),
},
)
}
fn synthetic_posting(
account: &str,
number: Decimal,
currency: &rustledger_core::Currency,
cost: Option<CostSpec>,
) -> Spanned<Posting> {
Spanned::new(
Posting {
account: account.into(),
units: Some(IncompleteAmount::from(Amount {
number,
currency: currency.clone(),
})),
cost,
price: None,
flag: None,
meta: Metadata::default(),
comments: Vec::new(),
trailing_comments: Vec::new(),
},
Span::ZERO,
)
}
fn synthetic_transaction(date: NaiveDate, postings: Vec<Spanned<Posting>>) -> Directive {
Directive::Transaction(Transaction {
date,
flag: 'S',
payee: None,
narration: "Opening balance".into(),
tags: Vec::new(),
links: Vec::new(),
meta: Metadata::default(),
postings,
trailing_comments: Vec::new(),
})
}
fn summary_transaction(account: &str, inventory: &Inventory, date: NaiveDate) -> Directive {
let mut postings = Vec::new();
for position in inventory.positions() {
let cost = position.cost.as_ref().map(|c| CostSpec {
number: Some(CostNumber::PerUnit { value: c.number }),
currency: Some(c.currency.clone()),
date: c.date,
label: c.label.clone(),
merge: false,
});
postings.push(synthetic_posting(
account,
position.units.number,
&position.units.currency,
cost,
));
}
for position in inventory.positions() {
postings.push(synthetic_posting(
"Equity:Opening-Balances",
-position.units.number,
&position.units.currency,
None,
));
}
synthetic_transaction(date, postings)
}
fn earnings_transaction(pnl: &HashMap<String, Decimal>, date: NaiveDate) -> Option<Directive> {
let mut currencies: Vec<&String> = pnl.keys().collect();
currencies.sort();
let mut postings = Vec::new();
for currency in currencies {
let number = pnl[currency];
if number.is_zero() {
continue;
}
let cur: rustledger_core::Currency = currency.as_str().into();
postings.push(synthetic_posting(
"Equity:Earnings:Previous",
number,
&cur,
None,
));
postings.push(synthetic_posting(
"Equity:Opening-Balances",
-number,
&cur,
None,
));
}
if postings.is_empty() {
return None;
}
Some(synthetic_transaction(date, postings))
}
#[must_use]
pub fn clamp(directives: &[Directive], begin: NaiveDate, end: NaiveDate) -> Vec<Directive> {
let mut balances: HashMap<String, Inventory> = HashMap::new();
let mut latest_prices: HashMap<(String, String), (NaiveDate, Directive)> = HashMap::new();
let mut filtered: Vec<Directive> = Vec::new();
for d in directives {
let date = d.date();
if date < begin {
match d {
Directive::Transaction(t) => {
for sp in &t.postings {
let p = &sp.value;
if let Some(units) = p.units.as_ref().and_then(IncompleteAmount::as_amount)
{
let pos = posting_position(units, p.cost.as_ref());
balances.entry(p.account.to_string()).or_default().add(pos);
}
}
}
Directive::Price(pr) => {
let key = (pr.currency.to_string(), pr.amount.currency.to_string());
let keep = latest_prices.get(&key).is_none_or(|(d0, _)| date >= *d0);
if keep {
latest_prices.insert(key, (date, d.clone()));
}
}
Directive::Open(_) => filtered.push(d.clone()),
_ => {}
}
} else if date < end && !matches!(d, Directive::Commodity(_)) {
filtered.push(d.clone());
}
}
let mut bs_accounts: Vec<(&String, &Inventory)> = balances
.iter()
.filter(|(account, inv)| is_balance_sheet(account) && !inv.is_empty())
.collect();
bs_accounts.sort_by_key(|(account, _)| (*account).clone());
let mut summaries: Vec<Directive> = bs_accounts
.into_iter()
.map(|(account, inv)| summary_transaction(account, inv, begin))
.collect();
let mut pnl: HashMap<String, Decimal> = HashMap::new();
for (account, inv) in &balances {
if is_income_statement(account) {
for position in inv.positions() {
*pnl.entry(position.units.currency.to_string()).or_default() +=
position.units.number;
}
}
}
if let Some(earnings) = earnings_transaction(&pnl, begin) {
summaries.push(earnings);
}
let mut prices: Vec<Directive> = latest_prices.into_values().map(|(_, d)| d).collect();
let mut all = Vec::new();
all.append(&mut prices);
all.append(&mut summaries);
all.append(&mut filtered);
all.sort_by(|a, b| {
a.date()
.cmp(&b.date())
.then_with(|| type_priority(a).cmp(&type_priority(b)))
.then_with(|| a.to_string().cmp(&b.to_string()))
});
all
}
#[cfg(test)]
mod tests {
#![allow(clippy::cmp_owned)]
use super::*;
use rustledger_core::naive_date;
use rustledger_parser::parse;
fn dirs(src: &str) -> Vec<Directive> {
parse(src).directives.into_iter().map(|s| s.value).collect()
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
naive_date(y, m, day).unwrap()
}
fn is_summary(dir: &Directive) -> bool {
matches!(dir, Directive::Transaction(t) if t.flag == 'S' && t.narration.to_string() == "Opening balance")
}
fn mentions(dir: &Directive, account: &str) -> bool {
matches!(dir, Directive::Transaction(t)
if t.postings.iter().any(|p| p.value.account.to_string() == account))
}
#[test]
fn summarizes_pre_begin_balance_into_opening() {
let input = dirs(
"2023-06-01 * \"old\"\n Assets:Cash 100 USD\n Equity:Opening-Balances -100 USD\n\
2024-02-01 * \"in range\"\n Assets:Cash -5 USD\n Expenses:Food 5 USD\n",
);
let out = clamp(&input, d(2024, 1, 1), d(2024, 12, 31));
assert!(out.iter().all(|dir| dir.date() >= d(2024, 1, 1)));
assert!(
out.iter().any(|dir| is_summary(dir)
&& dir.date() == d(2024, 1, 1)
&& mentions(dir, "Assets:Cash")),
"expected an opening-balance summary mentioning Assets:Cash",
);
assert!(out.iter().any(|dir| matches!(dir, Directive::Transaction(t)
if t.narration.to_string() == "in range")));
}
#[test]
fn drops_entries_after_end() {
let input = dirs("2025-01-01 * \"future\"\n Assets:Cash 1 USD\n Expenses:X -1 USD\n");
let out = clamp(&input, d(2024, 1, 1), d(2024, 12, 31));
assert!(
out.iter()
.all(|dir| !matches!(dir, Directive::Transaction(t)
if t.narration.to_string() == "future"))
);
}
#[test]
fn excludes_commodity_in_range() {
let input = dirs("2024-03-01 commodity USD\n");
let out = clamp(&input, d(2024, 1, 1), d(2024, 12, 31));
assert!(
out.iter()
.all(|dir| !matches!(dir, Directive::Commodity(_)))
);
}
#[test]
fn keeps_pre_begin_open() {
let input = dirs("2020-01-01 open Assets:Cash USD\n");
let out = clamp(&input, d(2024, 1, 1), d(2024, 12, 31));
assert!(out.iter().any(|dir| matches!(dir, Directive::Open(_))));
}
#[test]
fn earnings_rolled_up_from_income() {
let input =
dirs("2023-05-01 * \"salary\"\n Assets:Cash 1000 USD\n Income:Salary -1000 USD\n");
let out = clamp(&input, d(2024, 1, 1), d(2024, 12, 31));
assert!(
out.iter()
.any(|dir| mentions(dir, "Equity:Earnings:Previous")),
"expected an earnings roll-up posting",
);
}
}