use std::collections::BTreeMap;
use std::sync::Arc;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::Serialize;
use uuid::Uuid;
use crate::domain::repositories::reporting_repository::{
AccountNodeRow, PartyLedgerRow, ReportingRepository,
};
#[derive(Debug, Clone, Serialize)]
pub struct TrialBalanceLine {
pub account_number: String,
pub name: String,
pub account_type: String,
pub debit: Decimal,
pub credit: Decimal,
}
#[derive(Debug, Clone, Serialize)]
pub struct TrialBalanceNode {
pub account_number: String,
pub name: String,
pub account_type: String,
pub level: i32,
pub is_header: bool,
pub debit: Decimal,
pub credit: Decimal,
pub children: Vec<TrialBalanceNode>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TrialBalance {
pub as_of: NaiveDate,
pub lines: Vec<TrialBalanceLine>,
pub tree: Vec<TrialBalanceNode>,
pub total_debit: Decimal,
pub total_credit: Decimal,
pub balanced: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct BalanceSheet {
pub as_of: NaiveDate,
pub assets: Decimal,
pub liabilities: Decimal,
pub equity: Decimal,
pub current_earnings: Decimal,
pub total_liabilities_and_equity: Decimal,
pub balanced: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct IncomeStatement {
pub period_start: NaiveDate,
pub period_end: NaiveDate,
pub revenue: Decimal,
pub other_income: Decimal,
pub cogs: Decimal,
pub expenses: Decimal,
pub other_expense: Decimal,
pub net_income: Decimal,
}
#[derive(Debug, Clone, Serialize)]
pub struct GeneralLedgerLine {
pub journal_number: String,
pub transaction_date: NaiveDate,
pub posting_date: NaiveDate,
pub description: String,
pub reference: Option<String>,
pub currency: String,
pub debit: Decimal,
pub credit: Decimal,
pub balance_after: Decimal,
pub party_type: Option<String>,
pub party_id: Option<Uuid>,
pub source_type: Option<String>,
pub source_reference: Option<String>,
pub is_reconciled: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct GeneralLedgerSection {
pub account_id: Uuid,
pub account_number: String,
pub account_name: String,
pub opening_balance: Decimal,
pub lines: Vec<GeneralLedgerLine>,
pub total_debit: Decimal,
pub total_credit: Decimal,
pub closing_balance: Decimal,
}
#[derive(Debug, Clone, Serialize)]
pub struct GeneralLedger {
pub from_date: Option<NaiveDate>,
pub to_date: NaiveDate,
pub limit: i64,
pub offset: i64,
pub sections: Vec<GeneralLedgerSection>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PartnerLedgerLine {
pub line_id: Uuid,
pub journal_number: String,
pub transaction_date: NaiveDate,
pub account_number: String,
pub account_name: String,
pub description: Option<String>,
pub debit: Decimal,
pub credit: Decimal,
pub currency: String,
pub residual: Decimal,
}
#[derive(Debug, Clone, Serialize)]
pub struct PartnerLedger {
pub party_type: String,
pub party_id: Uuid,
pub as_of: NaiveDate,
pub lines: Vec<PartnerLedgerLine>,
pub total_debit: Decimal,
pub total_credit: Decimal,
pub open_residual: Decimal,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct AgedPartyRow {
pub party_type: String,
pub party_id: Uuid,
pub bucket_0_30: Decimal,
pub bucket_31_60: Decimal,
pub bucket_61_90: Decimal,
pub bucket_91_plus: Decimal,
pub total: Decimal,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgedReport {
pub as_of: NaiveDate,
pub account_subtype: String,
pub parties: Vec<AgedPartyRow>,
pub totals: AgedPartyRow,
}
#[derive(Clone)]
pub struct ReportingService {
repo: Arc<dyn ReportingRepository>,
}
impl ReportingService {
pub fn new(repo: Arc<dyn ReportingRepository>) -> Self {
Self { repo }
}
fn normal_balance(at: &str, debit: Decimal, credit: Decimal) -> Decimal {
match at {
"asset" | "expense" | "cogs" | "other_expense" => debit - credit,
_ => credit - debit, }
}
fn trial_balance_tree(
directory: &[AccountNodeRow],
lines: &[(Uuid, Decimal, Decimal)], ) -> Vec<TrialBalanceNode> {
use std::collections::HashMap;
let mut sums: HashMap<Uuid, (Decimal, Decimal)> = HashMap::new();
for (id, debit, credit) in lines {
let e = sums.entry(*id).or_insert((Decimal::ZERO, Decimal::ZERO));
e.0 += *debit;
e.1 += *credit;
}
let by_id: HashMap<Uuid, &AccountNodeRow> = directory.iter().map(|n| (n.id, n)).collect();
let detail_entries: Vec<(Uuid, Decimal, Decimal)> =
sums.iter().map(|(id, (d, c))| (*id, *d, *c)).collect();
for (id, debit, credit) in detail_entries {
let mut cur = by_id.get(&id).and_then(|n| n.parent_id);
let mut steps = directory.len() + 1;
while let Some(pid) = cur {
if steps == 0 {
break; }
steps -= 1;
if let Some(node) = by_id.get(&pid) {
let e = sums.entry(pid).or_insert((Decimal::ZERO, Decimal::ZERO));
e.0 += debit;
e.1 += credit;
cur = node.parent_id;
} else {
break; }
}
}
let mut children_of: HashMap<Uuid, Vec<&AccountNodeRow>> = HashMap::new();
let mut roots: Vec<&AccountNodeRow> = Vec::new();
for n in directory {
match n.parent_id {
Some(p) => children_of.entry(p).or_default().push(n),
None => roots.push(n),
}
}
fn build(
n: &AccountNodeRow,
children_of: &HashMap<Uuid, Vec<&AccountNodeRow>>,
sums: &HashMap<Uuid, (Decimal, Decimal)>,
) -> TrialBalanceNode {
let (debit, credit) = sums
.get(&n.id)
.cloned()
.unwrap_or((Decimal::ZERO, Decimal::ZERO));
let mut kids: Vec<TrialBalanceNode> = children_of
.get(&n.id)
.map(|cs| {
let mut v: Vec<&AccountNodeRow> = cs.clone();
v.sort_by(|a, b| a.account_number.cmp(&b.account_number));
v.into_iter().map(|c| build(c, children_of, sums)).collect()
})
.unwrap_or_default();
kids.retain(|k| {
k.debit != Decimal::ZERO || k.credit != Decimal::ZERO || !k.children.is_empty()
});
TrialBalanceNode {
account_number: n.account_number.clone(),
name: n.name.clone(),
account_type: n.account_type.clone(),
level: n.level,
is_header: n.is_header,
debit,
credit,
children: kids,
}
}
roots.sort_by(|a, b| a.account_number.cmp(&b.account_number));
let mut tree: Vec<TrialBalanceNode> = roots
.iter()
.map(|r| build(r, &children_of, &sums))
.collect();
tree.retain(|t| {
t.debit != Decimal::ZERO || t.credit != Decimal::ZERO || !t.children.is_empty()
});
tree
}
pub async fn trial_balance(
&self,
as_of: NaiveDate,
) -> anyhow::Result<TrialBalance> {
let (sums, directory) = tokio::join!(
self.repo.account_sums(None, as_of),
self.repo.account_directory()
);
let sums = sums?;
let directory = directory?;
let mut lines = Vec::new();
let mut total_debit = Decimal::ZERO;
let mut total_credit = Decimal::ZERO;
let mut detail_net: Vec<(Uuid, Decimal, Decimal)> = Vec::new();
for s in &sums {
let net = s.debit - s.credit;
if net == Decimal::ZERO {
continue;
}
let (debit, credit) = if net > Decimal::ZERO {
(net, Decimal::ZERO)
} else {
(Decimal::ZERO, -net)
};
total_debit += debit;
total_credit += credit;
detail_net.push((s.account_id, debit, credit));
lines.push(TrialBalanceLine {
account_number: s.account_number.clone(),
name: s.name.clone(),
account_type: s.account_type.clone(),
debit,
credit,
});
}
let tree = Self::trial_balance_tree(&directory, &detail_net);
Ok(TrialBalance {
as_of,
lines,
tree,
total_debit,
total_credit,
balanced: total_debit == total_credit,
})
}
pub async fn balance_sheet(
&self,
as_of: NaiveDate,
) -> anyhow::Result<BalanceSheet> {
let sums = self.repo.account_sums(None, as_of).await?;
let mut assets = Decimal::ZERO;
let mut liabilities = Decimal::ZERO;
let mut equity = Decimal::ZERO;
let mut current_earnings = Decimal::ZERO;
for s in &sums {
let bal = Self::normal_balance(&s.account_type, s.debit, s.credit);
match s.account_type.as_str() {
"asset" => assets += bal,
"liability" => liabilities += bal,
"equity" => equity += bal,
"revenue" | "other_income" => current_earnings += bal,
"expense" | "cogs" | "other_expense" => current_earnings -= bal,
_ => {}
}
}
let total_liabilities_and_equity = liabilities + equity + current_earnings;
Ok(BalanceSheet {
as_of,
assets,
liabilities,
equity,
current_earnings,
total_liabilities_and_equity,
balanced: assets == total_liabilities_and_equity,
})
}
pub async fn income_statement(
&self,
period_start: NaiveDate,
period_end: NaiveDate,
) -> anyhow::Result<IncomeStatement> {
let sums = self
.repo
.account_sums(Some(period_start), period_end)
.await?;
let mut revenue = Decimal::ZERO;
let mut other_income = Decimal::ZERO;
let mut cogs = Decimal::ZERO;
let mut expenses = Decimal::ZERO;
let mut other_expense = Decimal::ZERO;
for s in &sums {
let bal = Self::normal_balance(&s.account_type, s.debit, s.credit);
match s.account_type.as_str() {
"revenue" => revenue += bal,
"other_income" => other_income += bal,
"cogs" => cogs += bal,
"expense" => expenses += bal,
"other_expense" => other_expense += bal,
_ => {}
}
}
let net_income = revenue + other_income - cogs - expenses - other_expense;
Ok(IncomeStatement {
period_start,
period_end,
revenue,
other_income,
cogs,
expenses,
other_expense,
net_income,
})
}
pub async fn general_ledger(
&self,
account_id: Option<Uuid>,
from_date: Option<NaiveDate>,
to_date: NaiveDate,
limit: Option<i64>,
offset: i64,
) -> anyhow::Result<GeneralLedger> {
let limit = limit.unwrap_or(200).clamp(1, 1000);
let offset = offset.max(0);
let rows = self
.repo
.gl_lines(account_id, from_date, to_date, limit, offset)
.await?;
let mut sections: Vec<GeneralLedgerSection> = Vec::new();
for r in rows {
let start_new = sections
.last()
.map(|s| s.account_id != r.account_id)
.unwrap_or(true);
if start_new {
sections.push(GeneralLedgerSection {
account_id: r.account_id,
account_number: r.account_number.clone(),
account_name: r.account_name.clone(),
opening_balance: r.balance_before,
lines: Vec::new(),
total_debit: Decimal::ZERO,
total_credit: Decimal::ZERO,
closing_balance: r.balance_before,
});
}
let section = sections.last_mut().expect("section just pushed");
section.lines.push(GeneralLedgerLine {
journal_number: r.journal_number.clone(),
transaction_date: r.transaction_date,
posting_date: r.posting_date,
description: r.description.clone(),
reference: r.reference.clone(),
currency: r.currency.clone(),
debit: r.debit,
credit: r.credit,
balance_after: r.balance_after,
party_type: r.party_type.clone(),
party_id: r.party_id,
source_type: r.source_type.clone(),
source_reference: r.source_reference.clone(),
is_reconciled: r.is_reconciled,
});
section.total_debit += r.debit;
section.total_credit += r.credit;
section.closing_balance = r.balance_after;
}
Ok(GeneralLedger {
from_date,
to_date,
limit,
offset,
sections,
})
}
pub async fn partner_ledger(
&self,
party_type: &str,
party_id: Uuid,
as_of: NaiveDate,
) -> anyhow::Result<PartnerLedger> {
let rows = self
.repo
.party_ledger_lines(party_type, party_id, as_of)
.await?;
let mut total_debit = Decimal::ZERO;
let mut total_credit = Decimal::ZERO;
let mut open_residual = Decimal::ZERO;
let lines: Vec<PartnerLedgerLine> = rows
.into_iter()
.map(|r: PartyLedgerRow| {
total_debit += r.debit;
total_credit += r.credit;
open_residual += r.residual;
PartnerLedgerLine {
line_id: r.line_id,
journal_number: r.journal_number,
transaction_date: r.transaction_date,
account_number: r.account_number,
account_name: r.account_name,
description: r.description,
debit: r.debit,
credit: r.credit,
currency: r.currency,
residual: r.residual,
}
})
.collect();
Ok(PartnerLedger {
party_type: party_type.to_string(),
party_id,
as_of,
lines,
total_debit,
total_credit,
open_residual,
})
}
pub async fn aged_report(
&self,
account_subtype: &str,
as_of: NaiveDate,
) -> anyhow::Result<AgedReport> {
let items = self
.repo
.aged_open_items(account_subtype, as_of)
.await?;
let mut parties: BTreeMap<(String, Uuid), AgedPartyRow> = BTreeMap::new();
let mut totals = AgedPartyRow::default();
for it in items {
let days = (as_of - it.transaction_date).num_days();
let row = parties
.entry((it.party_type.clone(), it.party_id))
.or_insert_with(|| AgedPartyRow {
party_type: it.party_type.clone(),
party_id: it.party_id,
..Default::default()
});
Self::bucket(row, days, it.residual);
Self::bucket(&mut totals, days, it.residual);
}
Ok(AgedReport {
as_of,
account_subtype: account_subtype.to_string(),
parties: parties.into_values().collect(),
totals,
})
}
pub async fn aged_receivables(
&self,
as_of: NaiveDate,
) -> anyhow::Result<AgedReport> {
self.aged_report("accounts_receivable", as_of)
.await
}
pub async fn aged_payables(
&self,
as_of: NaiveDate,
) -> anyhow::Result<AgedReport> {
self.aged_report("accounts_payable", as_of)
.await
}
fn bucket(row: &mut AgedPartyRow, days: i64, amount: Decimal) {
row.total += amount;
if days <= 30 {
row.bucket_0_30 += amount;
} else if days <= 60 {
row.bucket_31_60 += amount;
} else if days <= 90 {
row.bucket_61_90 += amount;
} else {
row.bucket_91_plus += amount;
}
}
}