You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
126 lines
3.7 KiB
126 lines
3.7 KiB
use std::{
|
|
io::Read,
|
|
fs::File,
|
|
env::args,
|
|
process::exit,
|
|
};
|
|
|
|
use rust_decimal::Decimal;
|
|
use beancount_parser::{
|
|
Parser,
|
|
Directive,
|
|
Transaction,
|
|
Account,
|
|
Open,
|
|
Close,
|
|
Date,
|
|
};
|
|
|
|
fn last_posting_is_none(transaction: &Transaction<'_>) -> bool {
|
|
transaction.postings()
|
|
.last()
|
|
.expect("There are no transactions in this posting")
|
|
.amount()
|
|
.is_none()
|
|
}
|
|
fn balance(transaction: &Transaction<'_>) -> Decimal {
|
|
transaction.postings()
|
|
.iter()
|
|
.filter_map(|p| Some::<Decimal>(p.amount()?
|
|
.expression()
|
|
.evaluate()
|
|
.into())
|
|
)
|
|
.sum()
|
|
}
|
|
fn account_exists(acct: &Account, opens: &Vec<Open<'_>>) -> bool {
|
|
opens.iter()
|
|
.filter(|a| a.account() == acct)
|
|
.count() == 1
|
|
}
|
|
fn accounts_exist(transaction: &Transaction, opens: &Vec<Open<'_>>) -> bool {
|
|
transaction.postings()
|
|
.iter()
|
|
.all(|post| account_exists(post.account(), opens))
|
|
}
|
|
fn account_open(account: &Account, date: Date, opens: &Vec<Open<'_>>) -> bool {
|
|
opens.iter()
|
|
.any(|o| o.date() <= date && account == o.account())
|
|
}
|
|
fn accounts_open(transaction: &Transaction, opens: &Vec<Open<'_>>) -> bool {
|
|
transaction.postings()
|
|
.iter()
|
|
.all(|post| account_open(post.account(), transaction.date(), opens))
|
|
}
|
|
fn account_closed(account: &Account, date: Date, closes: &Vec<Close<'_>>) -> bool {
|
|
closes.iter()
|
|
.any(|c| c.date() < date && account == c.account())
|
|
}
|
|
fn accounts_closed(transaction: &Transaction, closes: &Vec<Close<'_>>) -> bool {
|
|
transaction.postings()
|
|
.iter()
|
|
.all(|post| account_closed(post.account(), transaction.date(), closes))
|
|
}
|
|
|
|
#[non_exhaustive]
|
|
#[derive(Debug, Clone, Copy, Hash)]
|
|
enum TransactionValidationError {
|
|
AccountDoesNotExist,
|
|
AccountNotOpenYet,
|
|
AccountPreviouslyClosed,
|
|
DoesNotBalanceToZero,
|
|
}
|
|
|
|
fn is_valid_transaction(transaction: &Transaction<'_>, opens: &Vec<Open<'_>>, closes: &Vec<Close<'_>>) -> Result<bool, TransactionValidationError> {
|
|
if balance(transaction) != Decimal::ZERO && !last_posting_is_none(transaction) {
|
|
return Err(TransactionValidationError::DoesNotBalanceToZero);
|
|
}
|
|
if !accounts_exist(transaction, opens) {
|
|
return Err(TransactionValidationError::AccountDoesNotExist);
|
|
}
|
|
if !accounts_open(transaction, opens) {
|
|
return Err(TransactionValidationError::AccountNotOpenYet);
|
|
}
|
|
if accounts_closed(transaction, closes) {
|
|
return Err(TransactionValidationError::AccountPreviouslyClosed);
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
fn main() -> Result<(), beancount_parser::Error> {
|
|
let bean_filename = args().nth(1).expect("A file must be specified");
|
|
let mut bean_file = File::open(bean_filename).expect("Can not open file");
|
|
let mut bean_contents = String::new();
|
|
bean_file.read_to_string(&mut bean_contents).expect("Can not read file");
|
|
let directives: Vec<Directive<'_>> = Parser::new(&bean_contents)
|
|
.collect::<Result<_, _>>()
|
|
.expect("Can not load directives; invalid syntax.");
|
|
let transactions: Vec<Transaction> = directives.iter()
|
|
.filter_map(|d| match d {
|
|
Directive::Transaction(t) => Some(t.clone()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
let opens: Vec<Open<'_>> = directives.iter()
|
|
.filter_map(|d| match d {
|
|
Directive::Open(o) => Some(o.clone()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
let closes: Vec<Close<'_>> = directives.iter()
|
|
.filter_map(|d| match d {
|
|
Directive::Close(c) => Some(c.clone()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
for transaction in &transactions {
|
|
if let Err(e) = is_valid_transaction(&transaction, &opens, &closes) {
|
|
println!("Invalid transaction: {:#?}", e);
|
|
exit(1);
|
|
}
|
|
}
|
|
println!("Accounts: {}", opens.len());
|
|
println!("Transactions: {}", transactions.len());
|
|
Ok(())
|
|
}
|