1use std::collections::BTreeMap;
23use std::sync::Arc;
24
25use chrono::NaiveDate;
26use rust_decimal::Decimal;
27use serde::Serialize;
28use uuid::Uuid;
29
30use crate::domain::repositories::reporting_repository::{
31 AccountNodeRow, PartyLedgerRow, ReportingRepository,
32};
33
34#[derive(Debug, Clone, Serialize)]
35pub struct TrialBalanceLine {
36 pub account_number: String,
37 pub name: String,
38 pub account_type: String,
39 pub debit: Decimal,
40 pub credit: Decimal,
41}
42
43#[derive(Debug, Clone, Serialize)]
45pub struct TrialBalanceNode {
46 pub account_number: String,
47 pub name: String,
48 pub account_type: String,
49 pub level: i32,
50 pub is_header: bool,
51 pub debit: Decimal,
52 pub credit: Decimal,
53 pub children: Vec<TrialBalanceNode>,
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct TrialBalance {
58 pub as_of: NaiveDate,
59 pub lines: Vec<TrialBalanceLine>,
60 pub tree: Vec<TrialBalanceNode>,
61 pub total_debit: Decimal,
62 pub total_credit: Decimal,
63 pub balanced: bool,
64}
65
66#[derive(Debug, Clone, Serialize)]
67pub struct BalanceSheet {
68 pub as_of: NaiveDate,
69 pub assets: Decimal,
70 pub liabilities: Decimal,
71 pub equity: Decimal,
72 pub current_earnings: Decimal,
73 pub total_liabilities_and_equity: Decimal,
74 pub balanced: bool,
75}
76
77#[derive(Debug, Clone, Serialize)]
78pub struct IncomeStatement {
79 pub period_start: NaiveDate,
80 pub period_end: NaiveDate,
81 pub revenue: Decimal,
82 pub other_income: Decimal,
83 pub cogs: Decimal,
84 pub expenses: Decimal,
85 pub other_expense: Decimal,
86 pub net_income: Decimal,
87}
88
89#[derive(Debug, Clone, Serialize)]
91pub struct GeneralLedgerLine {
92 pub journal_number: String,
93 pub transaction_date: NaiveDate,
94 pub posting_date: NaiveDate,
95 pub description: String,
96 pub reference: Option<String>,
97 pub currency: String,
98 pub debit: Decimal,
99 pub credit: Decimal,
100 pub balance_after: Decimal,
101 pub party_type: Option<String>,
102 pub party_id: Option<Uuid>,
103 pub source_type: Option<String>,
104 pub source_reference: Option<String>,
105 pub is_reconciled: bool,
106}
107
108#[derive(Debug, Clone, Serialize)]
110pub struct GeneralLedgerSection {
111 pub account_id: Uuid,
112 pub account_number: String,
113 pub account_name: String,
114 pub opening_balance: Decimal,
115 pub lines: Vec<GeneralLedgerLine>,
116 pub total_debit: Decimal,
117 pub total_credit: Decimal,
118 pub closing_balance: Decimal,
119}
120
121#[derive(Debug, Clone, Serialize)]
122pub struct GeneralLedger {
123 pub from_date: Option<NaiveDate>,
124 pub to_date: NaiveDate,
125 pub limit: i64,
126 pub offset: i64,
127 pub sections: Vec<GeneralLedgerSection>,
128}
129
130#[derive(Debug, Clone, Serialize)]
132pub struct PartnerLedgerLine {
133 pub line_id: Uuid,
134 pub journal_number: String,
135 pub transaction_date: NaiveDate,
136 pub account_number: String,
137 pub account_name: String,
138 pub description: Option<String>,
139 pub debit: Decimal,
140 pub credit: Decimal,
141 pub currency: String,
142 pub residual: Decimal,
143}
144
145#[derive(Debug, Clone, Serialize)]
146pub struct PartnerLedger {
147 pub party_type: String,
148 pub party_id: Uuid,
149 pub as_of: NaiveDate,
150 pub lines: Vec<PartnerLedgerLine>,
151 pub total_debit: Decimal,
152 pub total_credit: Decimal,
153 pub open_residual: Decimal,
155}
156
157#[derive(Debug, Clone, Serialize, Default)]
159pub struct AgedPartyRow {
160 pub party_type: String,
161 pub party_id: Uuid,
162 pub bucket_0_30: Decimal,
163 pub bucket_31_60: Decimal,
164 pub bucket_61_90: Decimal,
165 pub bucket_91_plus: Decimal,
166 pub total: Decimal,
167}
168
169#[derive(Debug, Clone, Serialize)]
170pub struct AgedReport {
171 pub as_of: NaiveDate,
172 pub account_subtype: String,
173 pub parties: Vec<AgedPartyRow>,
174 pub totals: AgedPartyRow,
175}
176
177#[derive(Clone)]
179pub struct ReportingService {
180 repo: Arc<dyn ReportingRepository>,
181}
182
183impl ReportingService {
184 pub fn new(repo: Arc<dyn ReportingRepository>) -> Self {
185 Self { repo }
186 }
187
188 fn normal_balance(at: &str, debit: Decimal, credit: Decimal) -> Decimal {
190 match at {
191 "asset" | "expense" | "cogs" | "other_expense" => debit - credit,
192 _ => credit - debit, }
194 }
195
196 fn trial_balance_tree(
200 directory: &[AccountNodeRow],
201 lines: &[(Uuid, Decimal, Decimal)], ) -> Vec<TrialBalanceNode> {
203 use std::collections::HashMap;
204
205 let mut sums: HashMap<Uuid, (Decimal, Decimal)> = HashMap::new();
207 for (id, debit, credit) in lines {
208 let e = sums.entry(*id).or_insert((Decimal::ZERO, Decimal::ZERO));
209 e.0 += *debit;
210 e.1 += *credit;
211 }
212 let by_id: HashMap<Uuid, &AccountNodeRow> = directory.iter().map(|n| (n.id, n)).collect();
215 let detail_entries: Vec<(Uuid, Decimal, Decimal)> =
216 sums.iter().map(|(id, (d, c))| (*id, *d, *c)).collect();
217 for (id, debit, credit) in detail_entries {
218 let mut cur = by_id.get(&id).and_then(|n| n.parent_id);
219 let mut steps = directory.len() + 1;
222 while let Some(pid) = cur {
223 if steps == 0 {
224 break; }
226 steps -= 1;
227 if let Some(node) = by_id.get(&pid) {
228 let e = sums.entry(pid).or_insert((Decimal::ZERO, Decimal::ZERO));
229 e.0 += debit;
230 e.1 += credit;
231 cur = node.parent_id;
232 } else {
233 break; }
235 }
236 }
237 let mut children_of: HashMap<Uuid, Vec<&AccountNodeRow>> = HashMap::new();
239 let mut roots: Vec<&AccountNodeRow> = Vec::new();
240 for n in directory {
241 match n.parent_id {
242 Some(p) => children_of.entry(p).or_default().push(n),
243 None => roots.push(n),
244 }
245 }
246 fn build(
247 n: &AccountNodeRow,
248 children_of: &HashMap<Uuid, Vec<&AccountNodeRow>>,
249 sums: &HashMap<Uuid, (Decimal, Decimal)>,
250 ) -> TrialBalanceNode {
251 let (debit, credit) = sums
252 .get(&n.id)
253 .cloned()
254 .unwrap_or((Decimal::ZERO, Decimal::ZERO));
255 let mut kids: Vec<TrialBalanceNode> = children_of
256 .get(&n.id)
257 .map(|cs| {
258 let mut v: Vec<&AccountNodeRow> = cs.clone();
259 v.sort_by(|a, b| a.account_number.cmp(&b.account_number));
260 v.into_iter().map(|c| build(c, children_of, sums)).collect()
261 })
262 .unwrap_or_default();
263 kids.retain(|k| {
265 k.debit != Decimal::ZERO || k.credit != Decimal::ZERO || !k.children.is_empty()
266 });
267 TrialBalanceNode {
268 account_number: n.account_number.clone(),
269 name: n.name.clone(),
270 account_type: n.account_type.clone(),
271 level: n.level,
272 is_header: n.is_header,
273 debit,
274 credit,
275 children: kids,
276 }
277 }
278 roots.sort_by(|a, b| a.account_number.cmp(&b.account_number));
279 let mut tree: Vec<TrialBalanceNode> = roots
280 .iter()
281 .map(|r| build(r, &children_of, &sums))
282 .collect();
283 tree.retain(|t| {
284 t.debit != Decimal::ZERO || t.credit != Decimal::ZERO || !t.children.is_empty()
285 });
286 tree
287 }
288
289 pub async fn trial_balance(
290 &self,
291 as_of: NaiveDate,
292 ) -> anyhow::Result<TrialBalance> {
293 let (sums, directory) = tokio::join!(
294 self.repo.account_sums(None, as_of),
295 self.repo.account_directory()
296 );
297 let sums = sums?;
298 let directory = directory?;
299 let mut lines = Vec::new();
300 let mut total_debit = Decimal::ZERO;
301 let mut total_credit = Decimal::ZERO;
302 let mut detail_net: Vec<(Uuid, Decimal, Decimal)> = Vec::new();
303 for s in &sums {
304 let net = s.debit - s.credit;
305 if net == Decimal::ZERO {
306 continue;
307 }
308 let (debit, credit) = if net > Decimal::ZERO {
309 (net, Decimal::ZERO)
310 } else {
311 (Decimal::ZERO, -net)
312 };
313 total_debit += debit;
314 total_credit += credit;
315 detail_net.push((s.account_id, debit, credit));
316 lines.push(TrialBalanceLine {
317 account_number: s.account_number.clone(),
318 name: s.name.clone(),
319 account_type: s.account_type.clone(),
320 debit,
321 credit,
322 });
323 }
324 let tree = Self::trial_balance_tree(&directory, &detail_net);
325 Ok(TrialBalance {
326 as_of,
327 lines,
328 tree,
329 total_debit,
330 total_credit,
331 balanced: total_debit == total_credit,
332 })
333 }
334
335 pub async fn balance_sheet(
336 &self,
337 as_of: NaiveDate,
338 ) -> anyhow::Result<BalanceSheet> {
339 let sums = self.repo.account_sums(None, as_of).await?;
340 let mut assets = Decimal::ZERO;
341 let mut liabilities = Decimal::ZERO;
342 let mut equity = Decimal::ZERO;
343 let mut current_earnings = Decimal::ZERO;
344 for s in &sums {
345 let bal = Self::normal_balance(&s.account_type, s.debit, s.credit);
346 match s.account_type.as_str() {
347 "asset" => assets += bal,
348 "liability" => liabilities += bal,
349 "equity" => equity += bal,
350 "revenue" | "other_income" => current_earnings += bal,
351 "expense" | "cogs" | "other_expense" => current_earnings -= bal,
352 _ => {}
353 }
354 }
355 let total_liabilities_and_equity = liabilities + equity + current_earnings;
356 Ok(BalanceSheet {
357 as_of,
358 assets,
359 liabilities,
360 equity,
361 current_earnings,
362 total_liabilities_and_equity,
363 balanced: assets == total_liabilities_and_equity,
364 })
365 }
366
367 pub async fn income_statement(
368 &self,
369 period_start: NaiveDate,
370 period_end: NaiveDate,
371 ) -> anyhow::Result<IncomeStatement> {
372 let sums = self
373 .repo
374 .account_sums(Some(period_start), period_end)
375 .await?;
376 let mut revenue = Decimal::ZERO;
377 let mut other_income = Decimal::ZERO;
378 let mut cogs = Decimal::ZERO;
379 let mut expenses = Decimal::ZERO;
380 let mut other_expense = Decimal::ZERO;
381 for s in &sums {
382 let bal = Self::normal_balance(&s.account_type, s.debit, s.credit);
383 match s.account_type.as_str() {
384 "revenue" => revenue += bal,
385 "other_income" => other_income += bal,
386 "cogs" => cogs += bal,
387 "expense" => expenses += bal,
388 "other_expense" => other_expense += bal,
389 _ => {}
390 }
391 }
392 let net_income = revenue + other_income - cogs - expenses - other_expense;
393 Ok(IncomeStatement {
394 period_start,
395 period_end,
396 revenue,
397 other_income,
398 cogs,
399 expenses,
400 other_expense,
401 net_income,
402 })
403 }
404
405 pub async fn general_ledger(
408 &self,
409 account_id: Option<Uuid>,
410 from_date: Option<NaiveDate>,
411 to_date: NaiveDate,
412 limit: Option<i64>,
413 offset: i64,
414 ) -> anyhow::Result<GeneralLedger> {
415 let limit = limit.unwrap_or(200).clamp(1, 1000);
416 let offset = offset.max(0);
417 let rows = self
418 .repo
419 .gl_lines(account_id, from_date, to_date, limit, offset)
420 .await?;
421
422 let mut sections: Vec<GeneralLedgerSection> = Vec::new();
424 for r in rows {
425 let start_new = sections
426 .last()
427 .map(|s| s.account_id != r.account_id)
428 .unwrap_or(true);
429 if start_new {
430 sections.push(GeneralLedgerSection {
431 account_id: r.account_id,
432 account_number: r.account_number.clone(),
433 account_name: r.account_name.clone(),
434 opening_balance: r.balance_before,
437 lines: Vec::new(),
438 total_debit: Decimal::ZERO,
439 total_credit: Decimal::ZERO,
440 closing_balance: r.balance_before,
441 });
442 }
443 let section = sections.last_mut().expect("section just pushed");
444 section.lines.push(GeneralLedgerLine {
445 journal_number: r.journal_number.clone(),
446 transaction_date: r.transaction_date,
447 posting_date: r.posting_date,
448 description: r.description.clone(),
449 reference: r.reference.clone(),
450 currency: r.currency.clone(),
451 debit: r.debit,
452 credit: r.credit,
453 balance_after: r.balance_after,
454 party_type: r.party_type.clone(),
455 party_id: r.party_id,
456 source_type: r.source_type.clone(),
457 source_reference: r.source_reference.clone(),
458 is_reconciled: r.is_reconciled,
459 });
460 section.total_debit += r.debit;
461 section.total_credit += r.credit;
462 section.closing_balance = r.balance_after;
463 }
464 Ok(GeneralLedger {
465 from_date,
466 to_date,
467 limit,
468 offset,
469 sections,
470 })
471 }
472
473 pub async fn partner_ledger(
476 &self,
477 party_type: &str,
478 party_id: Uuid,
479 as_of: NaiveDate,
480 ) -> anyhow::Result<PartnerLedger> {
481 let rows = self
482 .repo
483 .party_ledger_lines(party_type, party_id, as_of)
484 .await?;
485 let mut total_debit = Decimal::ZERO;
486 let mut total_credit = Decimal::ZERO;
487 let mut open_residual = Decimal::ZERO;
488 let lines: Vec<PartnerLedgerLine> = rows
489 .into_iter()
490 .map(|r: PartyLedgerRow| {
491 total_debit += r.debit;
492 total_credit += r.credit;
493 open_residual += r.residual;
494 PartnerLedgerLine {
495 line_id: r.line_id,
496 journal_number: r.journal_number,
497 transaction_date: r.transaction_date,
498 account_number: r.account_number,
499 account_name: r.account_name,
500 description: r.description,
501 debit: r.debit,
502 credit: r.credit,
503 currency: r.currency,
504 residual: r.residual,
505 }
506 })
507 .collect();
508 Ok(PartnerLedger {
509 party_type: party_type.to_string(),
510 party_id,
511 as_of,
512 lines,
513 total_debit,
514 total_credit,
515 open_residual,
516 })
517 }
518
519 pub async fn aged_report(
523 &self,
524 account_subtype: &str,
525 as_of: NaiveDate,
526 ) -> anyhow::Result<AgedReport> {
527 let items = self
528 .repo
529 .aged_open_items(account_subtype, as_of)
530 .await?;
531 let mut parties: BTreeMap<(String, Uuid), AgedPartyRow> = BTreeMap::new();
532 let mut totals = AgedPartyRow::default();
533 for it in items {
534 let days = (as_of - it.transaction_date).num_days();
535 let row = parties
536 .entry((it.party_type.clone(), it.party_id))
537 .or_insert_with(|| AgedPartyRow {
538 party_type: it.party_type.clone(),
539 party_id: it.party_id,
540 ..Default::default()
541 });
542 Self::bucket(row, days, it.residual);
543 Self::bucket(&mut totals, days, it.residual);
544 }
545 Ok(AgedReport {
546 as_of,
547 account_subtype: account_subtype.to_string(),
548 parties: parties.into_values().collect(),
549 totals,
550 })
551 }
552
553 pub async fn aged_receivables(
554 &self,
555 as_of: NaiveDate,
556 ) -> anyhow::Result<AgedReport> {
557 self.aged_report("accounts_receivable", as_of)
558 .await
559 }
560
561 pub async fn aged_payables(
562 &self,
563 as_of: NaiveDate,
564 ) -> anyhow::Result<AgedReport> {
565 self.aged_report("accounts_payable", as_of)
566 .await
567 }
568
569 fn bucket(row: &mut AgedPartyRow, days: i64, amount: Decimal) {
570 row.total += amount;
571 if days <= 30 {
572 row.bucket_0_30 += amount;
573 } else if days <= 60 {
574 row.bucket_31_60 += amount;
575 } else if days <= 90 {
576 row.bucket_61_90 += amount;
577 } else {
578 row.bucket_91_plus += amount;
579 }
580 }
581}