Skip to main content

backbone_accounting/presentation/http/
reporting_handler.rs

1//! Non-CRUD HTTP surface for financial reports (read-only, computed on the fly).
2//!
3//! Hand-authored (user-owned; see `metaphor.codegen.yaml`). Wraps `ReportingService`.
4//!   GET /accounting/reports/trial-balance?as_of=YYYY-MM-DD
5//!   GET /accounting/reports/balance-sheet?as_of=YYYY-MM-DD
6//!   GET /accounting/reports/income-statement?period_start=..&period_end=..
7//!   GET /accounting/reports/general-ledger?to_date=..&from_date=..&account_id=..&limit=..&offset=..
8//!   GET /accounting/reports/partner-ledger?party_type=customer|supplier&party_id=..&as_of=..
9//!   GET /accounting/reports/aged-receivables?as_of=..
10//!   GET /accounting/reports/aged-payables?as_of=..
11//!
12//! Tenancy (ADR-0029): the wire carries no tenant parameter — the wire shapes
13//! keep it so unstripped callers compile and run unchanged; the module keys no statement on it
14//! and the composing service's tenancy decorator scopes every read.
15
16use std::sync::Arc;
17
18use axum::{
19    extract::{Query, State},
20    http::StatusCode,
21    response::IntoResponse,
22    routing::get,
23    Json, Router,
24};
25use chrono::NaiveDate;
26use serde::Deserialize;
27use uuid::Uuid;
28
29use crate::application::service::reporting_service::ReportingService;
30
31#[derive(Debug, Deserialize)]
32pub struct AsOfQuery {
33    pub as_of: NaiveDate,
34}
35
36#[derive(Debug, Deserialize)]
37pub struct PeriodQuery {
38    pub period_start: NaiveDate,
39    pub period_end: NaiveDate,
40}
41
42#[derive(Debug, Deserialize)]
43pub struct GeneralLedgerQuery {
44    pub to_date: NaiveDate,
45    pub from_date: Option<NaiveDate>,
46    pub account_id: Option<Uuid>,
47    pub limit: Option<i64>,
48    pub offset: Option<i64>,
49}
50
51#[derive(Debug, Deserialize)]
52pub struct PartnerLedgerQuery {
53    pub party_type: String,
54    pub party_id: Uuid,
55    pub as_of: NaiveDate,
56}
57
58fn err(e: anyhow::Error) -> (StatusCode, Json<serde_json::Value>) {
59    (
60        StatusCode::INTERNAL_SERVER_ERROR,
61        Json(serde_json::json!({ "success": false, "error": e.to_string() })),
62    )
63}
64
65async fn trial_balance(
66    State(svc): State<Arc<ReportingService>>,
67    Query(q): Query<AsOfQuery>,
68) -> impl IntoResponse {
69    match svc.trial_balance(q.as_of).await {
70        Ok(r) => (StatusCode::OK, Json(serde_json::to_value(r).unwrap())),
71        Err(e) => err(e),
72    }
73}
74
75async fn balance_sheet(
76    State(svc): State<Arc<ReportingService>>,
77    Query(q): Query<AsOfQuery>,
78) -> impl IntoResponse {
79    match svc.balance_sheet(q.as_of).await {
80        Ok(r) => (StatusCode::OK, Json(serde_json::to_value(r).unwrap())),
81        Err(e) => err(e),
82    }
83}
84
85async fn income_statement(
86    State(svc): State<Arc<ReportingService>>,
87    Query(q): Query<PeriodQuery>,
88) -> impl IntoResponse {
89    match svc
90        .income_statement(q.period_start, q.period_end)
91        .await
92    {
93        Ok(r) => (StatusCode::OK, Json(serde_json::to_value(r).unwrap())),
94        Err(e) => err(e),
95    }
96}
97
98async fn general_ledger(
99    State(svc): State<Arc<ReportingService>>,
100    Query(q): Query<GeneralLedgerQuery>,
101) -> impl IntoResponse {
102    match svc
103        .general_ledger(
104            q.account_id,
105            q.from_date,
106            q.to_date,
107            q.limit,
108            q.offset.unwrap_or(0),
109        )
110        .await
111    {
112        Ok(r) => (StatusCode::OK, Json(serde_json::to_value(r).unwrap())),
113        Err(e) => err(e),
114    }
115}
116
117async fn partner_ledger(
118    State(svc): State<Arc<ReportingService>>,
119    Query(q): Query<PartnerLedgerQuery>,
120) -> impl IntoResponse {
121    // Validate up front: an unknown party_type would otherwise surface as a 500
122    // carrying the raw database cast error (the swallowed-enum-detail failure class).
123    if !matches!(q.party_type.as_str(), "customer" | "supplier") {
124        return (
125            StatusCode::BAD_REQUEST,
126            Json(serde_json::json!({
127                "success": false,
128                "error": format!(
129                    "party_type must be 'customer' or 'supplier', got {:?}",
130                    q.party_type
131                ),
132            })),
133        );
134    }
135    match svc
136        .partner_ledger(&q.party_type, q.party_id, q.as_of)
137        .await
138    {
139        Ok(r) => (StatusCode::OK, Json(serde_json::to_value(r).unwrap())),
140        Err(e) => err(e),
141    }
142}
143
144async fn aged_receivables(
145    State(svc): State<Arc<ReportingService>>,
146    Query(q): Query<AsOfQuery>,
147) -> impl IntoResponse {
148    match svc.aged_receivables(q.as_of).await {
149        Ok(r) => (StatusCode::OK, Json(serde_json::to_value(r).unwrap())),
150        Err(e) => err(e),
151    }
152}
153
154async fn aged_payables(
155    State(svc): State<Arc<ReportingService>>,
156    Query(q): Query<AsOfQuery>,
157) -> impl IntoResponse {
158    match svc.aged_payables(q.as_of).await {
159        Ok(r) => (StatusCode::OK, Json(serde_json::to_value(r).unwrap())),
160        Err(e) => err(e),
161    }
162}
163
164/// Read-only financial-report routes.
165pub fn create_reporting_routes(service: Arc<ReportingService>) -> Router {
166    Router::new()
167        .route("/accounting/reports/trial-balance", get(trial_balance))
168        .route("/accounting/reports/balance-sheet", get(balance_sheet))
169        .route(
170            "/accounting/reports/income-statement",
171            get(income_statement),
172        )
173        .route("/accounting/reports/general-ledger", get(general_ledger))
174        .route("/accounting/reports/partner-ledger", get(partner_ledger))
175        .route(
176            "/accounting/reports/aged-receivables",
177            get(aged_receivables),
178        )
179        .route("/accounting/reports/aged-payables", get(aged_payables))
180        .with_state(service)
181}