koprogo_api/infrastructure/web/handlers/
financial_report_handlers.rs

1// Web Handlers: Financial Reports
2//
3// CREDITS & ATTRIBUTION:
4// This implementation is inspired by the Noalyss project (https://gitlab.com/noalyss/noalyss)
5// Noalyss is a free accounting software for Belgian and French accounting
6// License: GPL-2.0-or-later (GNU General Public License version 2 or later)
7// Copyright: (C) 1989, 1991 Free Software Foundation, Inc.
8// Copyright: Dany De Bontridder <dany@alchimerys.eu>
9//
10// API endpoints for generating Belgian PCMN financial reports
11
12use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
13use crate::infrastructure::web::{AppState, AuthenticatedUser};
14use actix_web::{get, web, HttpResponse, Responder};
15use chrono::{DateTime, NaiveDate, Utc};
16use serde::Deserialize;
17
18/// Parse a date string flexibly: accepts both "2026-01-01T00:00:00Z" (RFC 3339) and "2026-01-01" (date only)
19pub fn parse_date_flexible(input: &str) -> Option<DateTime<Utc>> {
20    // Try RFC 3339 first (e.g., "2026-01-01T00:00:00Z")
21    if let Ok(dt) = DateTime::parse_from_rfc3339(input) {
22        return Some(dt.with_timezone(&Utc));
23    }
24    // Fallback: try date-only format (e.g., "2026-01-01") → start of day UTC
25    if let Ok(nd) = NaiveDate::parse_from_str(input, "%Y-%m-%d") {
26        return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc());
27    }
28    None
29}
30
31#[derive(Debug, Deserialize)]
32pub struct IncomeStatementQuery {
33    /// Period start date (ISO 8601 format, e.g., "2024-01-01T00:00:00Z")
34    pub period_start: String,
35    /// Period end date (ISO 8601 format, e.g., "2024-12-31T23:59:59Z")
36    pub period_end: String,
37}
38
39/// Generate balance sheet report for an organization
40///
41/// **Access:** Accountant, SuperAdmin
42///
43/// **Belgian PCMN Balance Sheet:**
44/// - Assets (Classes 2-5): Buildings, receivables, bank, cash
45/// - Liabilities (Class 1): Capital, reserves, provisions, payables
46///
47/// **Example:**
48/// ```
49/// GET /api/v1/reports/balance-sheet
50/// Authorization: Bearer <token>
51/// ```
52#[get("/reports/balance-sheet")]
53pub async fn generate_balance_sheet(
54    state: web::Data<AppState>,
55    user: AuthenticatedUser,
56) -> impl Responder {
57    // Only Accountant and SuperAdmin can generate financial reports
58    if !matches!(user.role.as_str(), "accountant" | "superadmin") {
59        return HttpResponse::Forbidden().json(serde_json::json!({
60            "error": "Only accountants and superadmins can generate financial reports"
61        }));
62    }
63
64    let organization_id = match user.require_organization() {
65        Ok(org_id) => org_id,
66        Err(e) => {
67            return HttpResponse::Unauthorized().json(serde_json::json!({
68                "error": e.to_string()
69            }))
70        }
71    };
72
73    match state
74        .financial_report_use_cases
75        .generate_balance_sheet(organization_id)
76        .await
77    {
78        Ok(report) => {
79            // Audit log: balance sheet generated
80            AuditLogEntry::new(
81                AuditEventType::ReportGenerated,
82                Some(user.user_id),
83                Some(organization_id),
84            )
85            .with_metadata(serde_json::json!({
86                "report_type": "balance_sheet"
87            }))
88            .log();
89
90            HttpResponse::Ok().json(report)
91        }
92        Err(err) => {
93            // Audit log: failed to generate balance sheet
94            AuditLogEntry::new(
95                AuditEventType::ReportGenerated,
96                Some(user.user_id),
97                Some(organization_id),
98            )
99            .with_metadata(serde_json::json!({
100                "report_type": "balance_sheet"
101            }))
102            .with_error(err.clone())
103            .log();
104
105            HttpResponse::InternalServerError().json(serde_json::json!({
106                "error": err
107            }))
108        }
109    }
110}
111
112/// Generate income statement (profit & loss) report for a time period
113///
114/// **Access:** Accountant, SuperAdmin
115///
116/// **Belgian PCMN Income Statement:**
117/// - Expenses (Class 6): Operating costs, maintenance, utilities
118/// - Revenue (Class 7): Regular fees, extraordinary fees, interest income
119///
120/// **Query Parameters:**
121/// - `period_start`: ISO 8601 datetime (e.g., "2024-01-01T00:00:00Z")
122/// - `period_end`: ISO 8601 datetime (e.g., "2024-12-31T23:59:59Z")
123///
124/// **Example:**
125/// ```
126/// GET /api/v1/reports/income-statement?period_start=2024-01-01T00:00:00Z&period_end=2024-12-31T23:59:59Z
127/// Authorization: Bearer <token>
128/// ```
129#[get("/reports/income-statement")]
130pub async fn generate_income_statement(
131    state: web::Data<AppState>,
132    user: AuthenticatedUser,
133    query: web::Query<IncomeStatementQuery>,
134) -> impl Responder {
135    // Only Accountant and SuperAdmin can generate financial reports
136    if !matches!(user.role.as_str(), "accountant" | "superadmin") {
137        return HttpResponse::Forbidden().json(serde_json::json!({
138            "error": "Only accountants and superadmins can generate financial reports"
139        }));
140    }
141
142    let organization_id = match user.require_organization() {
143        Ok(org_id) => org_id,
144        Err(e) => {
145            return HttpResponse::Unauthorized().json(serde_json::json!({
146                "error": e.to_string()
147            }))
148        }
149    };
150
151    // Parse dates (accepts both "2026-01-01T00:00:00Z" and "2026-01-01")
152    let period_start = match parse_date_flexible(&query.period_start) {
153        Some(dt) => dt,
154        None => return HttpResponse::BadRequest().json(serde_json::json!({
155            "error": "Invalid period_start format. Use ISO 8601 (e.g., 2024-01-01 or 2024-01-01T00:00:00Z)"
156        })),
157    };
158
159    let period_end = match parse_date_flexible(&query.period_end) {
160        Some(dt) => dt,
161        None => {
162            return HttpResponse::BadRequest().json(serde_json::json!({
163                "error": "Invalid period_end format. Use ISO 8601 (e.g., 2024-12-31 or 2024-12-31T23:59:59Z)"
164            }))
165        }
166    };
167
168    // Validate date range
169    if period_start >= period_end {
170        return HttpResponse::BadRequest().json(serde_json::json!({
171            "error": "period_start must be before period_end"
172        }));
173    }
174
175    match state
176        .financial_report_use_cases
177        .generate_income_statement(organization_id, period_start, period_end)
178        .await
179    {
180        Ok(report) => {
181            // Audit log: income statement generated
182            AuditLogEntry::new(
183                AuditEventType::ReportGenerated,
184                Some(user.user_id),
185                Some(organization_id),
186            )
187            .with_metadata(serde_json::json!({
188                "report_type": "income_statement",
189                "period_start": &query.period_start,
190                "period_end": &query.period_end
191            }))
192            .log();
193
194            HttpResponse::Ok().json(report)
195        }
196        Err(err) => {
197            // Audit log: failed to generate income statement
198            AuditLogEntry::new(
199                AuditEventType::ReportGenerated,
200                Some(user.user_id),
201                Some(organization_id),
202            )
203            .with_metadata(serde_json::json!({
204                "report_type": "income_statement"
205            }))
206            .with_error(err.clone())
207            .log();
208
209            HttpResponse::InternalServerError().json(serde_json::json!({
210                "error": err
211            }))
212        }
213    }
214}