Skip to main content

backbone_accounting/application/service/
reporting_service.rs

1//! Financial-report generation — Trial Balance, Balance Sheet, Income Statement, General
2//! Ledger, Partner Ledger, Aged Receivables / Payables.
3//!
4//! Hand-authored (user-owned; see `metaphor.codegen.yaml`). Application orchestration over the
5//! `ReportingRepository` port — no `sqlx`/`PgPool` here. Computes every report from the
6//! immutable ledger (never from cached `accounts.current_balance`, so any as-of date / period
7//! works). Proven by `tests/reporting_golden_cases.rs`.
8//!
9//! Sign convention: a debit-normal account's balance = Σdebit−Σcredit; a credit-normal account's
10//! balance = Σcredit−Σdebit. The global ledger is always balanced (Σdebit = Σcredit), so a Trial
11//! Balance always foots and a Balance Sheet always balances via
12//! `Assets = Liabilities + Equity + CurrentEarnings`.
13//!
14//! Reports are computed on the fly and never persisted — the `financial_statement` entity is
15//! not the compute target (the Odoo community report engine is absent; these reads stand on
16//! our own spec).
17//!
18//! Tenancy (ADR-0029): the `company_id` parameter on every report is the legacy twin — kept so
19//! unstripped callers compile and run unchanged, and echoed into the report headers. Nothing
20//! keys a statement on it; the repository scopes by the ambient org scope.
21
22use 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/// One node of the rolled-up trial-balance tree (headers aggregate their detail descendants).
44#[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/// One chronological GL line as returned over HTTP.
90#[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/// One account section of the GL.
109#[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/// One AR/AP line of a partner ledger.
131#[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    /// Σ line residuals as of `as_of` — the party's open position.
154    pub open_residual: Decimal,
155}
156
157/// One party's aged row: open residual bucketed by the age of the oldest open item's date.
158#[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/// Report generator over the GL.
178#[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    /// Signed balance on the account's normal side.
189    fn normal_balance(at: &str, debit: Decimal, credit: Decimal) -> Decimal {
190        match at {
191            "asset" | "expense" | "cogs" | "other_expense" => debit - credit,
192            _ => credit - debit, // liability | equity | revenue | other_income
193        }
194    }
195
196    /// Build the rolled-up trial-balance tree: detail accounts carry their net debit/credit;
197    /// every ancestor aggregates its descendants. The chart comes from the account directory,
198    /// so headers with zero activity still appear (a flat chart degrades to the old output).
199    fn trial_balance_tree(
200        directory: &[AccountNodeRow],
201        lines: &[(Uuid, Decimal, Decimal)], // (detail account id, debit, credit)
202    ) -> Vec<TrialBalanceNode> {
203        use std::collections::HashMap;
204
205        // Detail sums per account id.
206        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        // Propagate every detail's sums up its ancestor chain. Iterate an owned copy of the
213        // detail entries — the map itself grows as ancestors aggregate.
214        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            // Step budget guards against a parent CYCLE (a→b→a): no constraint prevents
220            // one in the accounts table, and an unguarded walk would hang the report.
221            let mut steps = directory.len() + 1;
222            while let Some(pid) = cur {
223                if steps == 0 {
224                    break; // cycle — aggregate what we walked, stop rather than loop
225                }
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; // dangling parent — stop rather than loop
234                }
235            }
236        }
237        // Materialize the tree, children ordered by account number.
238        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            // Zero-activity headers with no zero-activity children collapse away.
264            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    /// General ledger: per-account chronological sections with opening/closing balances.
406    /// `limit` is clamped to 1..=1000 (default 200); `offset` pages within the same window.
407    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        // Group consecutive rows by account (the read is ordered by account number).
423        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                    // The materialized running balance: the first line in the window
435                    // opens at its balance_before (absolute since inception).
436                    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    /// Partner ledger: every party-stamped line through `as_of`, each with its residual as of
474    /// that date. `open_residual` is the party's still-open position.
475    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    /// Aged receivables/payables: open items bucketed by age ((as_of − item date) days).
520    /// Buckets are fixed 0–30 / 31–60 / 61–90 / 91+; `account_subtype` selects the side
521    /// (`accounts_receivable` / `accounts_payable`).
522    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}