Skip to main content

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::middleware::scope_guard::verify_building_org_access;
14use crate::infrastructure::web::{AppState, AuthenticatedUser};
15use actix_web::ResponseError;
16use actix_web::{delete, get, post, web, HttpResponse, Responder};
17use serde::{Deserialize, Serialize};
18use uuid::Uuid;
19
20/// `deny_unknown_fields` : le rapport de test du 2026-09-01 (constat F16)
21/// signalait `operation_date` et `reference` « non persistes ». Les noms
22/// attendus sont `entry_date` et `document_ref` ; l'interface les envoie
23/// correctement, mais un appelant qui se trompait recevait un 201 avec une
24/// ecriture amputee de sa reference. Serde les rejette desormais.
25#[derive(Debug, Deserialize, utoipa::ToSchema)]
26#[serde(deny_unknown_fields)]
27pub struct CreateJournalEntryRequest {
28    pub building_id: Option<Uuid>,
29    pub journal_type: String,
30    pub entry_date: String, // ISO 8601
31    pub description: String,
32    pub document_ref: Option<String>,
33    pub lines: Vec<JournalEntryLineRequest>,
34}
35
36#[derive(Debug, Deserialize, utoipa::ToSchema)]
37#[serde(deny_unknown_fields)]
38pub struct JournalEntryLineRequest {
39    pub account_code: String,
40    pub debit: rust_decimal::Decimal,
41    pub credit: rust_decimal::Decimal,
42    pub description: String,
43}
44
45#[derive(Debug, Serialize, utoipa::ToSchema)]
46pub struct JournalEntryResponse {
47    pub id: String,
48    pub organization_id: String,
49    pub building_id: Option<String>,
50    pub journal_type: Option<String>,
51    pub entry_date: String,
52    pub description: Option<String>,
53    pub document_ref: Option<String>,
54    pub expense_id: Option<String>,
55    pub contribution_id: Option<String>,
56    pub created_at: String,
57    pub updated_at: String,
58}
59
60#[derive(Debug, Serialize, utoipa::ToSchema)]
61pub struct JournalEntryLineResponse {
62    pub id: String,
63    pub journal_entry_id: String,
64    pub account_code: String,
65    pub debit: rust_decimal::Decimal,
66    pub credit: rust_decimal::Decimal,
67    pub description: Option<String>,
68    pub created_at: String,
69}
70
71#[derive(Debug, Serialize, utoipa::ToSchema)]
72pub struct JournalEntryWithLinesResponse {
73    pub entry: JournalEntryResponse,
74    pub lines: Vec<JournalEntryLineResponse>,
75}
76
77#[derive(Debug, Deserialize, utoipa::IntoParams)]
78pub struct ListJournalEntriesQuery {
79    pub building_id: Option<Uuid>,
80    pub journal_type: Option<String>,
81    pub start_date: Option<String>,
82    pub end_date: Option<String>,
83    pub page: Option<i64>,
84    pub per_page: Option<i64>,
85}
86
87/// Create a manual journal entry (double-entry bookkeeping)
88///
89/// **Access:** Accountant, SuperAdmin
90///
91/// **Noalyss-Inspired Features:**
92/// - Journal types: ACH (Purchases), VEN (Sales), FIN (Financial), ODS (Miscellaneous)
93/// - Double-entry validation (debits = credits)
94/// - Multi-line entries with account codes
95///
96/// **Example:**
97/// ```json
98/// POST /api/v1/journal-entries
99/// {
100///   "building_id": "uuid",
101///   "journal_type": "ACH",
102///   "entry_date": "2025-01-01T00:00:00Z",
103///   "description": "Achat fournitures",
104///   "reference": "FA-2025-001",
105///   "lines": [
106///     {"account_code": "604", "debit": 100.0, "credit": 0.0, "description": "Fournitures"},
107///     {"account_code": "440", "debit": 0.0, "credit": 100.0, "description": "Fournisseur X"}
108///   ]
109/// }
110/// ```
111#[utoipa::path(
112    post,
113    path = "/journal-entries",
114    tag = "JournalEntries",
115    summary = "Create a manual journal entry (double-entry bookkeeping)",
116    request_body = CreateJournalEntryRequest,
117    responses(
118        (status = 201, description = "Journal entry created", body = JournalEntryWithLinesResponse),
119        (status = 400, description = "Unbalanced entry, missing building, unknown field in the body"),
120        (status = 401, description = "User does not belong to an organization"),
121        (status = 403, description = "Forbidden (accountant or superadmin only)"),
122        (status = 404, description = "Designated building does not exist"),
123    ),
124    security(("bearer_auth" = []))
125)]
126#[post("/journal-entries")]
127pub async fn create_journal_entry(
128    state: web::Data<AppState>,
129    user: AuthenticatedUser,
130    req: web::Json<CreateJournalEntryRequest>,
131) -> impl Responder {
132    // Only Accountant and SuperAdmin can create journal entries
133    if !matches!(user.role.as_str(), "accountant" | "superadmin") {
134        return HttpResponse::Forbidden().json(serde_json::json!({
135            "error": "Only accountants and superadmins can create journal entries"
136        }));
137    }
138
139    let organization_id = match user.require_organization() {
140        Ok(org_id) => org_id,
141        Err(e) => {
142            return HttpResponse::Unauthorized().json(serde_json::json!({
143                "error": e.to_string()
144            }))
145        }
146    };
147
148    // Isolation multi-tenant à l'ÉCRITURE (ADR-0045) : l'immeuble visé doit
149    // relever d'une ACP confiée à ce syndic. Sans cette garde, l'affectation de
150    // `organization_id` depuis le jeton protège l'estampille et non le
151    // rattachement — on écrit au bon nom dans le mauvais dossier.
152    //
153    // `building_id` est facultatif ici, et le use-case refuse déjà une saisie
154    // qui n'en désigne aucun : sans immeuble, on ne sait pas dans quels livres
155    // l'écriture s'inscrit. La garde ne s'applique donc qu'au cas renseigné.
156    if let Some(building_id) = req.building_id {
157        if let Err(err) = verify_building_org_access(
158            &user,
159            building_id,
160            &state.building_use_cases,
161            &state.acp_use_cases,
162        )
163        .await
164        {
165            return err.error_response();
166        }
167    }
168
169    // Parse entry_date
170    let entry_date = match chrono::DateTime::parse_from_rfc3339(&req.entry_date) {
171        Ok(dt) => dt.with_timezone(&chrono::Utc),
172        Err(_) => {
173            return HttpResponse::BadRequest().json(serde_json::json!({
174                "error": "Invalid entry_date format. Use ISO 8601 (e.g., 2025-01-01T00:00:00Z)"
175            }))
176        }
177    };
178
179    // Convert lines to tuple format
180    let lines: Vec<(String, rust_decimal::Decimal, rust_decimal::Decimal, String)> = req
181        .lines
182        .iter()
183        .map(|l| {
184            (
185                l.account_code.clone(),
186                l.debit,
187                l.credit,
188                l.description.clone(),
189            )
190        })
191        .collect();
192
193    match state
194        .journal_entry_use_cases
195        .create_manual_entry(
196            organization_id,
197            req.building_id,
198            Some(req.journal_type.clone()),
199            entry_date,
200            Some(req.description.clone()),
201            req.document_ref.clone(),
202            lines,
203        )
204        .await
205    {
206        Ok(entry) => {
207            // Audit log
208            AuditLogEntry::new(
209                AuditEventType::JournalEntryCreated,
210                Some(user.user_id),
211                Some(organization_id),
212            )
213            .with_metadata(serde_json::json!({
214                "entity_type": "journal_entry",
215                "entry_id": entry.id.to_string(),
216                "journal_type": &req.journal_type
217            }))
218            .log();
219
220            let response = JournalEntryResponse {
221                id: entry.id.to_string(),
222                organization_id: entry.organization_id.to_string(),
223                building_id: entry.building_id.map(|id| id.to_string()),
224                journal_type: entry.journal_type,
225                entry_date: entry.entry_date.to_rfc3339(),
226                description: entry.description,
227                document_ref: entry.document_ref,
228                expense_id: entry.expense_id.map(|id| id.to_string()),
229                contribution_id: entry.contribution_id.map(|id| id.to_string()),
230                created_at: entry.created_at.to_rfc3339(),
231                updated_at: entry.updated_at.to_rfc3339(),
232            };
233
234            HttpResponse::Created().json(response)
235        }
236        Err(err) => {
237            // Audit log failure
238            AuditLogEntry::new(
239                AuditEventType::JournalEntryCreated,
240                Some(user.user_id),
241                Some(organization_id),
242            )
243            .with_metadata(serde_json::json!({
244                "entity_type": "journal_entry",
245                "journal_type": &req.journal_type
246            }))
247            .with_error(err.to_string())
248            .log();
249
250            // #762 : le code HTTP se déduit du TYPE de `err` (`AppError`),
251            // jamais d'une sous-chaîne de son message. Ce gestionnaire
252            // cherchait auparavant les motifs "unbalanced", "foreign key" et
253            // "violates" dans le texte — tout message qui ne correspondait
254            // à aucun motif tombait en 500. C'est ainsi qu'une écriture sans
255            // immeuble, dont le refus est en français (« Impossible de
256            // déterminer l'ACP… »), ressortait en panne serveur le
257            // 2026-09-04 : une saisie incomplète, pas une panne.
258            //
259            // `AppError::error_response()` fait ce travail une fois pour
260            // toutes les erreurs applicatives (voir application/error.rs) :
261            // `Validation` → 400, `NotFound` → 404, `Internal`/`Database` →
262            // 500 avec message masqué (jamais de contrainte SQL ni de nom de
263            // table renvoyé au client). Un message français ou un type
264            // d'erreur jamais vu ici n'a plus besoin d'être ajouté à une
265            // liste : le compilateur oblige déjà chaque variante d'`AppError`
266            // à choisir son code dans `status_code()`.
267            err.error_response()
268        }
269    }
270}
271
272/// List journal entries with filters
273///
274/// **Access:** Accountant, SuperAdmin, Syndic
275///
276/// **Query Parameters:**
277/// - `building_id`: Filter by building (optional)
278/// - `journal_type`: Filter by journal type (ACH, VEN, FIN, ODS) (optional)
279/// - `start_date`: Filter by start date (ISO 8601) (optional)
280/// - `end_date`: Filter by end date (ISO 8601) (optional)
281/// - `page`: Page number (default: 1)
282/// - `per_page`: Items per page (default: 20, max: 100)
283///
284/// **Example:**
285/// ```
286/// GET /api/v1/journal-entries?journal_type=ACH&page=1&per_page=20
287/// ```
288#[utoipa::path(
289    get,
290    path = "/journal-entries",
291    tag = "JournalEntries",
292    summary = "List journal entries (paginated, filterable)",
293    params(ListJournalEntriesQuery),
294    responses(
295        (status = 200, description = "Journal entries page", body = Vec<JournalEntryResponse>),
296        (status = 401, description = "User does not belong to an organization"),
297        (status = 403, description = "Forbidden (accountant, syndic or superadmin only)"),
298    ),
299    security(("bearer_auth" = []))
300)]
301#[get("/journal-entries")]
302pub async fn list_journal_entries(
303    state: web::Data<AppState>,
304    user: AuthenticatedUser,
305    query: web::Query<ListJournalEntriesQuery>,
306) -> impl Responder {
307    // Only Accountant, SuperAdmin, and Syndic can view journal entries
308    if !matches!(user.role.as_str(), "accountant" | "superadmin" | "syndic") {
309        return HttpResponse::Forbidden().json(serde_json::json!({
310            "error": "Only accountants, syndics, and superadmins can view journal entries"
311        }));
312    }
313
314    let organization_id = match user.require_organization() {
315        Ok(org_id) => org_id,
316        Err(e) => {
317            return HttpResponse::Unauthorized().json(serde_json::json!({
318                "error": e.to_string()
319            }))
320        }
321    };
322
323    // Parse dates
324    let start_date = query.start_date.as_ref().and_then(|s| {
325        chrono::DateTime::parse_from_rfc3339(s)
326            .ok()
327            .map(|dt| dt.with_timezone(&chrono::Utc))
328    });
329
330    let end_date = query.end_date.as_ref().and_then(|s| {
331        chrono::DateTime::parse_from_rfc3339(s)
332            .ok()
333            .map(|dt| dt.with_timezone(&chrono::Utc))
334    });
335
336    // Pagination
337    let page = query.page.unwrap_or(1).max(1);
338    let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
339    let offset = (page - 1) * per_page;
340
341    match state
342        .journal_entry_use_cases
343        .list_entries(
344            organization_id,
345            query.building_id,
346            query.journal_type.clone(),
347            start_date,
348            end_date,
349            per_page,
350            offset,
351        )
352        .await
353    {
354        Ok(entries) => {
355            let responses: Vec<JournalEntryResponse> = entries
356                .into_iter()
357                .map(|entry| 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                .collect();
371
372            HttpResponse::Ok().json(serde_json::json!({
373                "data": responses,
374                "page": page,
375                "per_page": per_page
376            }))
377        }
378        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
379            "error": err
380        })),
381    }
382}
383
384/// Get a single journal entry with its lines
385///
386/// **Access:** Accountant, SuperAdmin, Syndic
387///
388/// **Example:**
389/// ```
390/// GET /api/v1/journal-entries/{id}
391/// ```
392#[utoipa::path(
393    get,
394    path = "/journal-entries/{id}",
395    tag = "JournalEntries",
396    summary = "Get a single journal entry with its lines",
397    params(("id" = Uuid, Path, description = "Journal entry identifier")),
398    responses(
399        (status = 200, description = "Journal entry with lines", body = JournalEntryWithLinesResponse),
400        (status = 401, description = "User does not belong to an organization"),
401        (status = 403, description = "Forbidden"),
402        (status = 404, description = "Journal entry not found"),
403    ),
404    security(("bearer_auth" = []))
405)]
406#[get("/journal-entries/{id}")]
407pub async fn get_journal_entry(
408    state: web::Data<AppState>,
409    user: AuthenticatedUser,
410    entry_id: web::Path<Uuid>,
411) -> impl Responder {
412    if !matches!(user.role.as_str(), "accountant" | "superadmin" | "syndic") {
413        return HttpResponse::Forbidden().json(serde_json::json!({
414            "error": "Only accountants, syndics, and superadmins can view journal entries"
415        }));
416    }
417
418    let organization_id = match user.require_organization() {
419        Ok(org_id) => org_id,
420        Err(e) => {
421            return HttpResponse::Unauthorized().json(serde_json::json!({
422                "error": e.to_string()
423            }))
424        }
425    };
426
427    match state
428        .journal_entry_use_cases
429        .get_entry_with_lines(*entry_id, organization_id)
430        .await
431    {
432        Ok((entry, lines)) => {
433            let entry_response = JournalEntryResponse {
434                id: entry.id.to_string(),
435                organization_id: entry.organization_id.to_string(),
436                building_id: entry.building_id.map(|id| id.to_string()),
437                journal_type: entry.journal_type,
438                entry_date: entry.entry_date.to_rfc3339(),
439                description: entry.description,
440                document_ref: entry.document_ref,
441                expense_id: entry.expense_id.map(|id| id.to_string()),
442                contribution_id: entry.contribution_id.map(|id| id.to_string()),
443                created_at: entry.created_at.to_rfc3339(),
444                updated_at: entry.updated_at.to_rfc3339(),
445            };
446
447            let lines_response: Vec<JournalEntryLineResponse> = lines
448                .into_iter()
449                .map(|line| JournalEntryLineResponse {
450                    id: line.id.to_string(),
451                    journal_entry_id: line.journal_entry_id.to_string(),
452                    account_code: line.account_code,
453                    debit: line.debit,
454                    credit: line.credit,
455                    description: line.description,
456                    created_at: line.created_at.to_rfc3339(),
457                })
458                .collect();
459
460            HttpResponse::Ok().json(JournalEntryWithLinesResponse {
461                entry: entry_response,
462                lines: lines_response,
463            })
464        }
465        Err(err) => HttpResponse::NotFound().json(serde_json::json!({
466            "error": err
467        })),
468    }
469}
470
471/// Delete a manual journal entry
472///
473/// **Access:** Accountant, SuperAdmin
474///
475/// **Note:** Only manual entries (not auto-generated from expenses/contributions) can be deleted.
476///
477/// **Example:**
478/// ```
479/// DELETE /api/v1/journal-entries/{id}
480/// ```
481#[utoipa::path(
482    delete,
483    path = "/journal-entries/{id}",
484    tag = "JournalEntries",
485    summary = "Delete a journal entry and its lines",
486    params(("id" = Uuid, Path, description = "Journal entry identifier")),
487    responses(
488        (status = 204, description = "Journal entry deleted"),
489        (status = 401, description = "User does not belong to an organization"),
490        (status = 403, description = "Forbidden (accountant or superadmin only)"),
491        (status = 404, description = "Journal entry not found"),
492    ),
493    security(("bearer_auth" = []))
494)]
495#[delete("/journal-entries/{id}")]
496pub async fn delete_journal_entry(
497    state: web::Data<AppState>,
498    user: AuthenticatedUser,
499    entry_id: web::Path<Uuid>,
500) -> impl Responder {
501    if !matches!(user.role.as_str(), "accountant" | "superadmin") {
502        return HttpResponse::Forbidden().json(serde_json::json!({
503            "error": "Only accountants and superadmins can delete journal entries"
504        }));
505    }
506
507    let organization_id = match user.require_organization() {
508        Ok(org_id) => org_id,
509        Err(e) => {
510            return HttpResponse::Unauthorized().json(serde_json::json!({
511                "error": e.to_string()
512            }))
513        }
514    };
515
516    match state
517        .journal_entry_use_cases
518        .delete_manual_entry(*entry_id, organization_id)
519        .await
520    {
521        Ok(_) => {
522            // Audit log
523            AuditLogEntry::new(
524                AuditEventType::JournalEntryDeleted,
525                Some(user.user_id),
526                Some(organization_id),
527            )
528            .with_metadata(serde_json::json!({
529                "entity_type": "journal_entry",
530                "entry_id": entry_id.to_string()
531            }))
532            .log();
533
534            HttpResponse::NoContent().finish()
535        }
536        Err(err) => {
537            // Audit log failure
538            AuditLogEntry::new(
539                AuditEventType::JournalEntryDeleted,
540                Some(user.user_id),
541                Some(organization_id),
542            )
543            .with_metadata(serde_json::json!({
544                "entity_type": "journal_entry",
545                "entry_id": entry_id.to_string()
546            }))
547            .with_error(err.clone())
548            .log();
549
550            HttpResponse::BadRequest().json(serde_json::json!({
551                "error": err
552            }))
553        }
554    }
555}