use std::collections::HashMap;
use rust_decimal::Decimal;
use uuid::Uuid;
use crate::domain::gl_posting::{PostingError, PostingLine};
use crate::domain::repositories::budget_control::BudgetBreach;
use crate::domain::repositories::posting_repository::PostableAccount;
pub fn validate(
lines: &[PostingLine],
accounts: &HashMap<Uuid, PostableAccount>,
period_closed: bool,
) -> Result<(), PostingError> {
if lines.len() < 2 {
return Err(PostingError::TooFewLines);
}
for line in lines {
let clean = line.debit >= Decimal::ZERO
&& line.credit >= Decimal::ZERO
&& ((line.debit > Decimal::ZERO) ^ (line.credit > Decimal::ZERO));
if !clean {
return Err(PostingError::InvalidLineAmount);
}
}
let total_debit: Decimal = lines.iter().map(|l| l.debit).sum();
let total_credit: Decimal = lines.iter().map(|l| l.credit).sum();
if total_debit != total_credit {
return Err(PostingError::Unbalanced);
}
for line in lines {
let acct = accounts
.get(&line.account_id)
.ok_or(PostingError::AccountNotFound(line.account_id))?;
if acct.is_header || !acct.is_detail || acct.status != "active" {
return Err(PostingError::NonPostableAccount(acct.number.clone()));
}
let is_party_account =
acct.subtype == "accounts_receivable" || acct.subtype == "accounts_payable";
let has_party = line.party_type.is_some() && line.party_id.is_some();
if is_party_account && !has_party {
return Err(PostingError::PartyRequired(acct.number.clone()));
}
if !is_party_account && (line.party_type.is_some() || line.party_id.is_some()) {
return Err(PostingError::PartyNotAllowed(acct.number.clone()));
}
}
if period_closed {
return Err(PostingError::PeriodClosed);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BudgetOutcome {
WithinBudget,
Warned(Vec<BudgetBreach>),
Blocked(Vec<BudgetBreach>),
}
pub fn budget_outcome(breaches: &[BudgetBreach]) -> BudgetOutcome {
let blocked: Vec<BudgetBreach> = breaches
.iter()
.filter(|b| {
matches!(
b.enforcement,
crate::domain::repositories::budget_control::BudgetEnforcement::Block
)
})
.cloned()
.collect();
if !blocked.is_empty() {
return BudgetOutcome::Blocked(blocked);
}
if breaches.is_empty() {
BudgetOutcome::WithinBudget
} else {
BudgetOutcome::Warned(breaches.to_vec())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::gl_posting::PostingLine;
use rust_decimal::Decimal;
use uuid::Uuid;
fn dec(s: &str) -> Decimal {
Decimal::from_str_exact(s).unwrap()
}
fn acct(id: Uuid) -> PostableAccount {
PostableAccount {
id,
number: "1000".into(),
name: "Cash".into(),
account_type: "asset".into(),
subtype: "cash".into(),
normal_balance: "debit".into(),
is_detail: true,
is_header: false,
status: "active".into(),
current_balance: Decimal::ZERO,
}
}
fn line(id: Uuid, debit: &str, credit: &str) -> PostingLine {
PostingLine {
account_id: id,
debit: dec(debit),
credit: dec(credit),
party_type: None,
party_id: None,
cost_center_id: None,
project_id: None,
department_id: None,
description: None,
}
}
fn validate(lines: &[PostingLine]) -> Result<(), PostingError> {
let a = acct(lines[0].account_id);
let accounts = std::iter::once((a.id, a)).collect();
super::validate(lines, &accounts, false)
}
#[test]
fn rejects_negative_amounts_even_when_balanced() {
let id = Uuid::new_v4();
let err = validate(&[line(id, "-100", "-100"), line(id, "-100", "-100")]).unwrap_err();
assert_eq!(err.code(), "invalid_line_amount");
}
#[test]
fn rejects_both_sides_and_zero_lines() {
let id = Uuid::new_v4();
assert_eq!(
validate(&[line(id, "100", "100"), line(id, "100", "100")])
.unwrap_err()
.code(),
"invalid_line_amount"
);
assert_eq!(
validate(&[line(id, "0", "0"), line(id, "0", "0")])
.unwrap_err()
.code(),
"invalid_line_amount"
);
}
#[test]
fn accepts_clean_balanced_lines() {
let id = Uuid::new_v4();
assert!(validate(&[line(id, "100", "0"), line(id, "0", "100")]).is_ok());
}
use crate::domain::repositories::budget_control::{
BudgetBreach, BudgetEnforcement,
};
fn breach(enforcement: BudgetEnforcement) -> BudgetBreach {
BudgetBreach {
budget_id: Uuid::new_v4(),
budget_line_id: Uuid::new_v4(),
account_id: Uuid::new_v4(),
cost_center_id: None,
fiscal_period_id: Uuid::new_v4(),
planned_amount: dec("100"),
achieved_amount: dec("40"),
pending_amount: dec("70"),
enforcement,
}
}
#[test]
fn budget_outcome_empty_is_within_budget() {
assert_eq!(super::budget_outcome(&[]), super::BudgetOutcome::WithinBudget);
}
#[test]
fn budget_outcome_warn_only_warns() {
let out = super::budget_outcome(&[breach(BudgetEnforcement::Warn)]);
assert!(matches!(out, super::BudgetOutcome::Warned(b) if b.len() == 1));
}
#[test]
fn budget_outcome_block_blocks() {
let out = super::budget_outcome(&[breach(BudgetEnforcement::Block)]);
assert!(matches!(out, super::BudgetOutcome::Blocked(b) if b.len() == 1));
}
#[test]
fn budget_outcome_block_wins_over_warn() {
let out = super::budget_outcome(&[
breach(BudgetEnforcement::Warn),
breach(BudgetEnforcement::Block),
breach(BudgetEnforcement::Warn),
]);
assert!(matches!(out, super::BudgetOutcome::Blocked(b) if b.len() == 1));
}
}