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            // Return 400 for business rule violations, 500 for unexpected errors
206            if err.contains("unbalanced")
207                || err.contains("foreign key")
208                || err.contains("violates")
209                || err.contains("not found")
210            {
211                HttpResponse::BadRequest().json(serde_json::json!({
212                    "error": err
213                }))
214            } else {
215                HttpResponse::InternalServerError().json(serde_json::json!({
216                    "error": err
217                }))
218            }
219        }
220    }
221}
222
223/// List journal entries with filters
224///
225/// **Access:** Accountant, SuperAdmin, Syndic
226///
227/// **Query Parameters:**
228/// - `building_id`: Filter by building (optional)
229/// - `journal_type`: Filter by journal type (ACH, VEN, FIN, ODS) (optional)
230/// - `start_date`: Filter by start date (ISO 8601) (optional)
231/// - `end_date`: Filter by end date (ISO 8601) (optional)
232/// - `page`: Page number (default: 1)
233/// - `per_page`: Items per page (default: 20, max: 100)
234///
235/// **Example:**
236/// ```
237/// GET /api/v1/journal-entries?journal_type=ACH&page=1&per_page=20
238/// ```
239#[get("/journal-entries")]
240pub async fn list_journal_entries(
241    state: web::Data<AppState>,
242    user: AuthenticatedUser,
243    query: web::Query<ListJournalEntriesQuery>,
244) -> impl Responder {
245    // Only Accountant, SuperAdmin, and Syndic can view journal entries
246    if !matches!(user.role.as_str(), "accountant" | "superadmin" | "syndic") {
247        return HttpResponse::Forbidden().json(serde_json::json!({
248            "error": "Only accountants, syndics, and superadmins can view journal entries"
249        }));
250    }
251
252    let organization_id = match user.require_organization() {
253        Ok(org_id) => org_id,
254        Err(e) => {
255            return HttpResponse::Unauthorized().json(serde_json::json!({
256                "error": e.to_string()
257            }))
258        }
259    };
260
261    // Parse dates
262    let start_date = query.start_date.as_ref().and_then(|s| {
263        chrono::DateTime::parse_from_rfc3339(s)
264            .ok()
265            .map(|dt| dt.with_timezone(&chrono::Utc))
266    });
267
268    let end_date = query.end_date.as_ref().and_then(|s| {
269        chrono::DateTime::parse_from_rfc3339(s)
270            .ok()
271            .map(|dt| dt.with_timezone(&chrono::Utc))
272    });
273
274    // Pagination
275    let page = query.page.unwrap_or(1).max(1);
276    let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
277    let offset = (page - 1) * per_page;
278
279    match state
280        .journal_entry_use_cases
281        .list_entries(
282            organization_id,
283            query.building_id,
284            query.journal_type.clone(),
285            start_date,
286            end_date,
287            per_page,
288            offset,
289        )
290        .await
291    {
292        Ok(entries) => {
293            let responses: Vec<JournalEntryResponse> = entries
294                .into_iter()
295                .map(|entry| JournalEntryResponse {
296                    id: entry.id.to_string(),
297                    organization_id: entry.organization_id.to_string(),
298                    building_id: entry.building_id.map(|id| id.to_string()),
299                    journal_type: entry.journal_type,
300                    entry_date: entry.entry_date.to_rfc3339(),
301                    description: entry.description,
302                    document_ref: entry.document_ref,
303                    expense_id: entry.expense_id.map(|id| id.to_string()),
304                    contribution_id: entry.contribution_id.map(|id| id.to_string()),
305                    created_at: entry.created_at.to_rfc3339(),
306                    updated_at: entry.updated_at.to_rfc3339(),
307                })
308                .collect();
309
310            HttpResponse::Ok().json(serde_json::json!({
311                "data": responses,
312                "page": page,
313                "per_page": per_page
314            }))
315        }
316        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
317            "error": err
318        })),
319    }
320}
321
322/// Get a single journal entry with its lines
323///
324/// **Access:** Accountant, SuperAdmin, Syndic
325///
326/// **Example:**
327/// ```
328/// GET /api/v1/journal-entries/{id}
329/// ```
330#[get("/journal-entries/{id}")]
331pub async fn get_journal_entry(
332    state: web::Data<AppState>,
333    user: AuthenticatedUser,
334    entry_id: web::Path<Uuid>,
335) -> impl Responder {
336    if !matches!(user.role.as_str(), "accountant" | "superadmin" | "syndic") {
337        return HttpResponse::Forbidden().json(serde_json::json!({
338            "error": "Only accountants, syndics, and superadmins can view journal entries"
339        }));
340    }
341
342    let organization_id = match user.require_organization() {
343        Ok(org_id) => org_id,
344        Err(e) => {
345            return HttpResponse::Unauthorized().json(serde_json::json!({
346                "error": e.to_string()
347            }))
348        }
349    };
350
351    match state
352        .journal_entry_use_cases
353        .get_entry_with_lines(*entry_id, organization_id)
354        .await
355    {
356        Ok((entry, lines)) => {
357            let entry_response = JournalEntryResponse {
358                id: entry.id.to_string(),
359                organization_id: entry.organization_id.to_string(),
360                building_id: entry.building_id.map(|id| id.to_string()),
361                journal_type: entry.journal_type,
362                entry_date: entry.entry_date.to_rfc3339(),
363                description: entry.description,
364                document_ref: entry.document_ref,
365                expense_id: entry.expense_id.map(|id| id.to_string()),
366                contribution_id: entry.contribution_id.map(|id| id.to_string()),
367                created_at: entry.created_at.to_rfc3339(),
368                updated_at: entry.updated_at.to_rfc3339(),
369            };
370
371            let lines_response: Vec<JournalEntryLineResponse> = lines
372                .into_iter()
373                .map(|line| JournalEntryLineResponse {
374                    id: line.id.to_string(),
375                    journal_entry_id: line.journal_entry_id.to_string(),
376                    account_code: line.account_code,
377                    debit: line.debit,
378                    credit: line.credit,
379                    description: line.description,
380                    created_at: line.created_at.to_rfc3339(),
381                })
382                .collect();
383
384            HttpResponse::Ok().json(JournalEntryWithLinesResponse {
385                entry: entry_response,
386                lines: lines_response,
387            })
388        }
389        Err(err) => HttpResponse::NotFound().json(serde_json::json!({
390            "error": err
391        })),
392    }
393}
394
395/// Delete a manual journal entry
396///
397/// **Access:** Accountant, SuperAdmin
398///
399/// **Note:** Only manual entries (not auto-generated from expenses/contributions) can be deleted.
400///
401/// **Example:**
402/// ```
403/// DELETE /api/v1/journal-entries/{id}
404/// ```
405#[delete("/journal-entries/{id}")]
406pub async fn delete_journal_entry(
407    state: web::Data<AppState>,
408    user: AuthenticatedUser,
409    entry_id: web::Path<Uuid>,
410) -> impl Responder {
411    if !matches!(user.role.as_str(), "accountant" | "superadmin") {
412        return HttpResponse::Forbidden().json(serde_json::json!({
413            "error": "Only accountants and superadmins can delete journal entries"
414        }));
415    }
416
417    let organization_id = match user.require_organization() {
418        Ok(org_id) => org_id,
419        Err(e) => {
420            return HttpResponse::Unauthorized().json(serde_json::json!({
421                "error": e.to_string()
422            }))
423        }
424    };
425
426    match state
427        .journal_entry_use_cases
428        .delete_manual_entry(*entry_id, organization_id)
429        .await
430    {
431        Ok(_) => {
432            // Audit log
433            AuditLogEntry::new(
434                AuditEventType::JournalEntryDeleted,
435                Some(user.user_id),
436                Some(organization_id),
437            )
438            .with_metadata(serde_json::json!({
439                "entity_type": "journal_entry",
440                "entry_id": entry_id.to_string()
441            }))
442            .log();
443
444            HttpResponse::NoContent().finish()
445        }
446        Err(err) => {
447            // Audit log failure
448            AuditLogEntry::new(
449                AuditEventType::JournalEntryDeleted,
450                Some(user.user_id),
451                Some(organization_id),
452            )
453            .with_metadata(serde_json::json!({
454                "entity_type": "journal_entry",
455                "entry_id": entry_id.to_string()
456            }))
457            .with_error(err.clone())
458            .log();
459
460            HttpResponse::BadRequest().json(serde_json::json!({
461                "error": err
462            }))
463        }
464    }
465}