koprogo_api/infrastructure/web/handlers/
journal_entry_handlers.rs

1// Web Handlers: Journal Entry (Manual Accounting Operations)
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 manual journal entry creation and management
11
12use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
13use crate::infrastructure::web::{AppState, AuthenticatedUser};
14use actix_web::{delete, get, post, web, HttpResponse, Responder};
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18#[derive(Debug, Deserialize)]
19pub struct CreateJournalEntryRequest {
20    pub building_id: Option<Uuid>,
21    pub journal_type: String,
22    pub entry_date: String, // ISO 8601
23    pub description: String,
24    pub document_ref: Option<String>,
25    pub lines: Vec<JournalEntryLineRequest>,
26}
27
28#[derive(Debug, Deserialize)]
29pub struct JournalEntryLineRequest {
30    pub account_code: String,
31    pub debit: f64,
32    pub credit: f64,
33    pub description: String,
34}
35
36#[derive(Debug, Serialize)]
37pub struct JournalEntryResponse {
38    pub id: String,
39    pub organization_id: String,
40    pub building_id: Option<String>,
41    pub journal_type: Option<String>,
42    pub entry_date: String,
43    pub description: Option<String>,
44    pub document_ref: Option<String>,
45    pub expense_id: Option<String>,
46    pub contribution_id: Option<String>,
47    pub created_at: String,
48    pub updated_at: String,
49}
50
51#[derive(Debug, Serialize)]
52pub struct JournalEntryLineResponse {
53    pub id: String,
54    pub journal_entry_id: String,
55    pub account_code: String,
56    pub debit: f64,
57    pub credit: f64,
58    pub description: Option<String>,
59    pub created_at: String,
60}
61
62#[derive(Debug, Serialize)]
63pub struct JournalEntryWithLinesResponse {
64    pub entry: JournalEntryResponse,
65    pub lines: Vec<JournalEntryLineResponse>,
66}
67
68#[derive(Debug, Deserialize)]
69pub struct ListJournalEntriesQuery {
70    pub building_id: Option<Uuid>,
71    pub journal_type: Option<String>,
72    pub start_date: Option<String>,
73    pub end_date: Option<String>,
74    pub page: Option<i64>,
75    pub per_page: Option<i64>,
76}
77
78/// Create a manual journal entry (double-entry bookkeeping)
79///
80/// **Access:** Accountant, SuperAdmin
81///
82/// **Noalyss-Inspired Features:**
83/// - Journal types: ACH (Purchases), VEN (Sales), FIN (Financial), ODS (Miscellaneous)
84/// - Double-entry validation (debits = credits)
85/// - Multi-line entries with account codes
86///
87/// **Example:**
88/// ```json
89/// POST /api/v1/journal-entries
90/// {
91///   "building_id": "uuid",
92///   "journal_type": "ACH",
93///   "entry_date": "2025-01-01T00:00:00Z",
94///   "description": "Achat fournitures",
95///   "reference": "FA-2025-001",
96///   "lines": [
97///     {"account_code": "604", "debit": 100.0, "credit": 0.0, "description": "Fournitures"},
98///     {"account_code": "440", "debit": 0.0, "credit": 100.0, "description": "Fournisseur X"}
99///   ]
100/// }
101/// ```
102#[post("/journal-entries")]
103pub async fn create_journal_entry(
104    state: web::Data<AppState>,
105    user: AuthenticatedUser,
106    req: web::Json<CreateJournalEntryRequest>,
107) -> impl Responder {
108    // Only Accountant and SuperAdmin can create journal entries
109    if !matches!(user.role.as_str(), "accountant" | "superadmin") {
110        return HttpResponse::Forbidden().json(serde_json::json!({
111            "error": "Only accountants and superadmins can create journal entries"
112        }));
113    }
114
115    let organization_id = match user.require_organization() {
116        Ok(org_id) => org_id,
117        Err(e) => {
118            return HttpResponse::Unauthorized().json(serde_json::json!({
119                "error": e.to_string()
120            }))
121        }
122    };
123
124    // Parse entry_date
125    let entry_date = match chrono::DateTime::parse_from_rfc3339(&req.entry_date) {
126        Ok(dt) => dt.with_timezone(&chrono::Utc),
127        Err(_) => {
128            return HttpResponse::BadRequest().json(serde_json::json!({
129                "error": "Invalid entry_date format. Use ISO 8601 (e.g., 2025-01-01T00:00:00Z)"
130            }))
131        }
132    };
133
134    // Convert lines to tuple format
135    let lines: Vec<(String, f64, f64, String)> = req
136        .lines
137        .iter()
138        .map(|l| {
139            (
140                l.account_code.clone(),
141                l.debit,
142                l.credit,
143                l.description.clone(),
144            )
145        })
146        .collect();
147
148    match state
149        .journal_entry_use_cases
150        .create_manual_entry(
151            organization_id,
152            req.building_id,
153            Some(req.journal_type.clone()),
154            entry_date,
155            Some(req.description.clone()),
156            req.document_ref.clone(),
157            lines,
158        )
159        .await
160    {
161        Ok(entry) => {
162            // Audit log
163            AuditLogEntry::new(
164                AuditEventType::JournalEntryCreated,
165                Some(user.user_id),
166                Some(organization_id),
167            )
168            .with_metadata(serde_json::json!({
169                "entity_type": "journal_entry",
170                "entry_id": entry.id.to_string(),
171                "journal_type": &req.journal_type
172            }))
173            .log();
174
175            let response = JournalEntryResponse {
176                id: entry.id.to_string(),
177                organization_id: entry.organization_id.to_string(),
178                building_id: entry.building_id.map(|id| id.to_string()),
179                journal_type: entry.journal_type,
180                entry_date: entry.entry_date.to_rfc3339(),
181                description: entry.description,
182                document_ref: entry.document_ref,
183                expense_id: entry.expense_id.map(|id| id.to_string()),
184                contribution_id: entry.contribution_id.map(|id| id.to_string()),
185                created_at: entry.created_at.to_rfc3339(),
186                updated_at: entry.updated_at.to_rfc3339(),
187            };
188
189            HttpResponse::Created().json(response)
190        }
191        Err(err) => {
192            // Audit log failure
193            AuditLogEntry::new(
194                AuditEventType::JournalEntryCreated,
195                Some(user.user_id),
196                Some(organization_id),
197            )
198            .with_metadata(serde_json::json!({
199                "entity_type": "journal_entry",
200                "journal_type": &req.journal_type
201            }))
202            .with_error(err.clone())
203            .log();
204
205            HttpResponse::InternalServerError().json(serde_json::json!({
206                "error": err
207            }))
208        }
209    }
210}
211
212/// List journal entries with filters
213///
214/// **Access:** Accountant, SuperAdmin, Syndic
215///
216/// **Query Parameters:**
217/// - `building_id`: Filter by building (optional)
218/// - `journal_type`: Filter by journal type (ACH, VEN, FIN, ODS) (optional)
219/// - `start_date`: Filter by start date (ISO 8601) (optional)
220/// - `end_date`: Filter by end date (ISO 8601) (optional)
221/// - `page`: Page number (default: 1)
222/// - `per_page`: Items per page (default: 20, max: 100)
223///
224/// **Example:**
225/// ```
226/// GET /api/v1/journal-entries?journal_type=ACH&page=1&per_page=20
227/// ```
228#[get("/journal-entries")]
229pub async fn list_journal_entries(
230    state: web::Data<AppState>,
231    user: AuthenticatedUser,
232    query: web::Query<ListJournalEntriesQuery>,
233) -> impl Responder {
234    // Only Accountant, SuperAdmin, and Syndic can view journal entries
235    if !matches!(user.role.as_str(), "accountant" | "superadmin" | "syndic") {
236        return HttpResponse::Forbidden().json(serde_json::json!({
237            "error": "Only accountants, syndics, and superadmins can view journal entries"
238        }));
239    }
240
241    let organization_id = match user.require_organization() {
242        Ok(org_id) => org_id,
243        Err(e) => {
244            return HttpResponse::Unauthorized().json(serde_json::json!({
245                "error": e.to_string()
246            }))
247        }
248    };
249
250    // Parse dates
251    let start_date = query.start_date.as_ref().and_then(|s| {
252        chrono::DateTime::parse_from_rfc3339(s)
253            .ok()
254            .map(|dt| dt.with_timezone(&chrono::Utc))
255    });
256
257    let end_date = query.end_date.as_ref().and_then(|s| {
258        chrono::DateTime::parse_from_rfc3339(s)
259            .ok()
260            .map(|dt| dt.with_timezone(&chrono::Utc))
261    });
262
263    // Pagination
264    let page = query.page.unwrap_or(1).max(1);
265    let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
266    let offset = (page - 1) * per_page;
267
268    match state
269        .journal_entry_use_cases
270        .list_entries(
271            organization_id,
272            query.building_id,
273            query.journal_type.clone(),
274            start_date,
275            end_date,
276            per_page,
277            offset,
278        )
279        .await
280    {
281        Ok(entries) => {
282            let responses: Vec<JournalEntryResponse> = entries
283                .into_iter()
284                .map(|entry| JournalEntryResponse {
285                    id: entry.id.to_string(),
286                    organization_id: entry.organization_id.to_string(),
287                    building_id: entry.building_id.map(|id| id.to_string()),
288                    journal_type: entry.journal_type,
289                    entry_date: entry.entry_date.to_rfc3339(),
290                    description: entry.description,
291                    document_ref: entry.document_ref,
292                    expense_id: entry.expense_id.map(|id| id.to_string()),
293                    contribution_id: entry.contribution_id.map(|id| id.to_string()),
294                    created_at: entry.created_at.to_rfc3339(),
295                    updated_at: entry.updated_at.to_rfc3339(),
296                })
297                .collect();
298
299            HttpResponse::Ok().json(serde_json::json!({
300                "data": responses,
301                "page": page,
302                "per_page": per_page
303            }))
304        }
305        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
306            "error": err
307        })),
308    }
309}
310
311/// Get a single journal entry with its lines
312///
313/// **Access:** Accountant, SuperAdmin, Syndic
314///
315/// **Example:**
316/// ```
317/// GET /api/v1/journal-entries/{id}
318/// ```
319#[get("/journal-entries/{id}")]
320pub async fn get_journal_entry(
321    state: web::Data<AppState>,
322    user: AuthenticatedUser,
323    entry_id: web::Path<Uuid>,
324) -> impl Responder {
325    if !matches!(user.role.as_str(), "accountant" | "superadmin" | "syndic") {
326        return HttpResponse::Forbidden().json(serde_json::json!({
327            "error": "Only accountants, syndics, and superadmins can view journal entries"
328        }));
329    }
330
331    let organization_id = match user.require_organization() {
332        Ok(org_id) => org_id,
333        Err(e) => {
334            return HttpResponse::Unauthorized().json(serde_json::json!({
335                "error": e.to_string()
336            }))
337        }
338    };
339
340    match state
341        .journal_entry_use_cases
342        .get_entry_with_lines(*entry_id, organization_id)
343        .await
344    {
345        Ok((entry, lines)) => {
346            let entry_response = JournalEntryResponse {
347                id: entry.id.to_string(),
348                organization_id: entry.organization_id.to_string(),
349                building_id: entry.building_id.map(|id| id.to_string()),
350                journal_type: entry.journal_type,
351                entry_date: entry.entry_date.to_rfc3339(),
352                description: entry.description,
353                document_ref: entry.document_ref,
354                expense_id: entry.expense_id.map(|id| id.to_string()),
355                contribution_id: entry.contribution_id.map(|id| id.to_string()),
356                created_at: entry.created_at.to_rfc3339(),
357                updated_at: entry.updated_at.to_rfc3339(),
358            };
359
360            let lines_response: Vec<JournalEntryLineResponse> = lines
361                .into_iter()
362                .map(|line| JournalEntryLineResponse {
363                    id: line.id.to_string(),
364                    journal_entry_id: line.journal_entry_id.to_string(),
365                    account_code: line.account_code,
366                    debit: line.debit,
367                    credit: line.credit,
368                    description: line.description,
369                    created_at: line.created_at.to_rfc3339(),
370                })
371                .collect();
372
373            HttpResponse::Ok().json(JournalEntryWithLinesResponse {
374                entry: entry_response,
375                lines: lines_response,
376            })
377        }
378        Err(err) => HttpResponse::NotFound().json(serde_json::json!({
379            "error": err
380        })),
381    }
382}
383
384/// Delete a manual journal entry
385///
386/// **Access:** Accountant, SuperAdmin
387///
388/// **Note:** Only manual entries (not auto-generated from expenses/contributions) can be deleted.
389///
390/// **Example:**
391/// ```
392/// DELETE /api/v1/journal-entries/{id}
393/// ```
394#[delete("/journal-entries/{id}")]
395pub async fn delete_journal_entry(
396    state: web::Data<AppState>,
397    user: AuthenticatedUser,
398    entry_id: web::Path<Uuid>,
399) -> impl Responder {
400    if !matches!(user.role.as_str(), "accountant" | "superadmin") {
401        return HttpResponse::Forbidden().json(serde_json::json!({
402            "error": "Only accountants and superadmins can delete journal entries"
403        }));
404    }
405
406    let organization_id = match user.require_organization() {
407        Ok(org_id) => org_id,
408        Err(e) => {
409            return HttpResponse::Unauthorized().json(serde_json::json!({
410                "error": e.to_string()
411            }))
412        }
413    };
414
415    match state
416        .journal_entry_use_cases
417        .delete_manual_entry(*entry_id, organization_id)
418        .await
419    {
420        Ok(_) => {
421            // Audit log
422            AuditLogEntry::new(
423                AuditEventType::JournalEntryDeleted,
424                Some(user.user_id),
425                Some(organization_id),
426            )
427            .with_metadata(serde_json::json!({
428                "entity_type": "journal_entry",
429                "entry_id": entry_id.to_string()
430            }))
431            .log();
432
433            HttpResponse::NoContent().finish()
434        }
435        Err(err) => {
436            // Audit log failure
437            AuditLogEntry::new(
438                AuditEventType::JournalEntryDeleted,
439                Some(user.user_id),
440                Some(organization_id),
441            )
442            .with_metadata(serde_json::json!({
443                "entity_type": "journal_entry",
444                "entry_id": entry_id.to_string()
445            }))
446            .with_error(err.clone())
447            .log();
448
449            HttpResponse::BadRequest().json(serde_json::json!({
450                "error": err
451            }))
452        }
453    }
454}