Skip to main content

koprogo_api/infrastructure/web/handlers/
expense_handlers.rs

1use crate::application::dto::{
2    ApproveInvoiceDto, CreateExpenseDto, CreateInvoiceDraftDto, PageRequest, PageResponse,
3    RejectInvoiceDto, SubmitForApprovalDto, UpdateInvoiceDraftDto,
4};
5use crate::domain::entities::UserRole;
6use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
7use crate::infrastructure::web::handlers::conformity_response::try_build_conformity_response;
8use crate::infrastructure::web::middleware::scope_guard::{
9    verify_acp_org_access, verify_building_org_access,
10};
11use crate::infrastructure::web::{AppState, AuthenticatedUser};
12use actix_web::{get, post, put, web, HttpResponse, Responder, ResponseError};
13use chrono::{DateTime, Utc};
14use serde::Deserialize;
15use std::str::FromStr;
16use uuid::Uuid;
17use validator::Validate;
18
19/// Story 3.1 — Resolve the typed `UserRole` from the JWT string.
20///
21/// Returns `None` when the JWT carries an unknown / malformed role (treated as
22/// no privilege — caller emits 403 `invalid_role`).
23fn user_role(user: &AuthenticatedUser) -> Option<UserRole> {
24    UserRole::from_str(&user.role).ok()
25}
26
27/// Helper function to check if owner role is trying to modify data.
28/// Note: Accountant CAN create expenses and mark them as paid.
29fn verify_owner_readonly(user: &AuthenticatedUser) -> Option<HttpResponse> {
30    if user.role == "owner" {
31        Some(HttpResponse::Forbidden().json(serde_json::json!({
32            "error": "Owner role has read-only access",
33            "code": "invalid_role",
34        })))
35    } else {
36        None
37    }
38}
39
40/// Helper function to check if user has syndic role (for approval workflow).
41fn verify_syndic_role(user: &AuthenticatedUser) -> Option<HttpResponse> {
42    match user_role(user) {
43        Some(UserRole::Syndic) | Some(UserRole::SuperAdmin) => None,
44        _ => Some(HttpResponse::Forbidden().json(serde_json::json!({
45            "error": "Only syndic or superadmin can approve/reject invoices",
46            "code": "invalid_role",
47        }))),
48    }
49}
50
51/// Story 3.1 — Authorise *emitting* expenses / call-for-funds (sortie financière).
52/// INV-10 séparation des pouvoirs : un encodeur seul est rejeté ici.
53fn check_can_emit_expenses(user: &AuthenticatedUser) -> Option<HttpResponse> {
54    match user_role(user) {
55        Some(role) if role.can_emit_expenses() => None,
56        _ => Some(HttpResponse::Forbidden().json(serde_json::json!({
57            "error":
58                "Only syndic, superadmin, or accountant émetteur can create/pay expenses",
59            "code": "invalid_role",
60        }))),
61    }
62}
63
64/// Story 3.1 — Authorise *encoding* invoices / drafts (entrée comptable amont).
65fn check_can_encode_invoices(user: &AuthenticatedUser) -> Option<HttpResponse> {
66    match user_role(user) {
67        Some(role) if role.can_encode_invoices() => None,
68        _ => Some(HttpResponse::Forbidden().json(serde_json::json!({
69            "error":
70                "Only syndic, superadmin, or accountant encodeur can create/edit invoices",
71            "code": "invalid_role",
72        }))),
73    }
74}
75
76/// Helper function to check if user has accountant role (for creating/editing invoices).
77///
78/// Story 3.1: alias kept for backwards-compat — delegates to `check_can_encode_invoices`.
79fn verify_accountant_role(user: &AuthenticatedUser) -> Option<HttpResponse> {
80    check_can_encode_invoices(user)
81}
82
83/// Cloisonne une dépense AVANT de la muter (#864).
84///
85/// ── Ce que ces quatre routes laissaient passer ────────────────────────────
86///
87/// `cancel_expense`, `mark_expense_overdue`, `reactivate_expense` et
88/// `unpay_expense` prenaient `AuthenticatedUser` et ne s'en servaient que
89/// pour journaliser. `get_expense`, dans ce même fichier, remonte pourtant
90/// `expense → building → acp → organization` depuis le hotfix #603.
91///
92/// ── Une différence avec `get_expense`, et elle est volontaire ─────────────
93///
94/// `get_expense` enchaîne des `if let Ok(...)` : quand l'immeuble ou l'ACP
95/// ne se résout pas, il ne refuse RIEN et sert la dépense. Sur une lecture
96/// c'est déjà une fuite ; sur une écriture ce serait un blanc-seing. Ici,
97/// une chaîne qui ne se résout pas REFUSE.
98///
99/// Rend `Some(réponse)` quand l'appel doit être refusé, `None` sinon.
100async fn verify_expense_org_access(
101    state: &web::Data<AppState>,
102    user: &AuthenticatedUser,
103    id: Uuid,
104) -> Option<HttpResponse> {
105    let expense = match state.expense_use_cases.get_expense(id).await {
106        Ok(Some(e)) => e,
107        Ok(None) => {
108            return Some(HttpResponse::NotFound().json(serde_json::json!({
109                "error": "Expense not found"
110            })))
111        }
112        Err(err) => {
113            return Some(HttpResponse::InternalServerError().json(serde_json::json!({
114                "error": err.to_string()
115            })))
116        }
117    };
118
119    let acp_id = match Uuid::parse_str(&expense.acp_id) {
120        Ok(id) => id,
121        Err(_) => {
122            return Some(HttpResponse::Forbidden().json(serde_json::json!({
123                "error": "Impossible de rattacher cette dépense à une ACP"
124            })))
125        }
126    };
127
128    match verify_acp_org_access(user, acp_id, &state.acp_use_cases).await {
129        Ok(()) => None,
130        Err(err) => Some(err.error_response()),
131    }
132}
133
134#[utoipa::path(
135    post,
136    path = "/expenses",
137    tag = "Expenses",
138    summary = "Créer une dépense",
139    request_body = CreateExpenseDto,
140    responses(
141        (status = 201, description = "Dépense créée"),
142        (status = 400, description = "Requête invalide"),
143        (status = 401, description = "Non authentifié"),
144        (status = 403, description = "Rôle sans droit d'encodage"),
145    ),
146    security(("bearer_auth" = []))
147)]
148#[post("/expenses")]
149pub async fn create_expense(
150    state: web::Data<AppState>,
151    user: AuthenticatedUser, // JWT-extracted user info (SECURE!)
152    mut dto: web::Json<CreateExpenseDto>,
153) -> impl Responder {
154    if let Some(response) = verify_owner_readonly(&user) {
155        return response;
156    }
157    // Story 3.1 INV-10 : un encodeur ne peut PAS émettre. POST /expenses est une
158    // sortie financière, donc réservé aux émetteurs (syndic/superadmin/accountant
159    // générique/accountant.emetteur).
160    if let Some(response) = check_can_emit_expenses(&user) {
161        return response;
162    }
163
164    // Override the organization_id from DTO with the one from JWT token
165    // This prevents users from creating expenses in other organizations
166    let organization_id = match user.require_organization() {
167        Ok(org_id) => org_id,
168        Err(e) => {
169            return HttpResponse::Unauthorized().json(serde_json::json!({
170                "error": e.to_string()
171            }))
172        }
173    };
174    dto.organization_id = organization_id.to_string();
175
176    // Isolation multi-tenant à l'ÉCRITURE : l'immeuble visé doit relever d'une
177    // ACP dont ce syndic a la gestion.
178    //
179    // L'affectation ci-dessus protège le mauvais champ : elle empêche
180    // d'ESTAMPILLER l'enregistrement au nom d'autrui, pas de le RATTACHER au
181    // patrimoine d'autrui. Mesuré le 2026-09-02 entre deux cabinets syndics
182    // indépendants : `POST /expenses` sur l'immeuble d'un tiers répondait 201,
183    // et la dépense apparaissait dans la liste des charges de cet immeuble.
184    let building_uuid = match Uuid::parse_str(&dto.building_id) {
185        Ok(id) => id,
186        Err(_) => {
187            return HttpResponse::BadRequest()
188                .json(serde_json::json!({ "error": "Invalid building_id format" }))
189        }
190    };
191    if let Err(err) = verify_building_org_access(
192        &user,
193        building_uuid,
194        &state.building_use_cases,
195        &state.acp_use_cases,
196    )
197    .await
198    {
199        return err.error_response();
200    }
201
202    if let Err(errors) = dto.validate() {
203        return HttpResponse::BadRequest().json(serde_json::json!({
204            "error": "Validation failed",
205            "details": errors.to_string()
206        }));
207    }
208
209    match state
210        .expense_use_cases
211        .create_expense(dto.into_inner())
212        .await
213    {
214        Ok(expense) => {
215            // Audit log: successful expense creation
216            AuditLogEntry::new(
217                AuditEventType::ExpenseCreated,
218                Some(user.user_id),
219                Some(organization_id),
220            )
221            .with_resource("Expense", Uuid::parse_str(&expense.id).unwrap())
222            .log();
223
224            HttpResponse::Created().json(expense)
225        }
226        Err(err) => {
227            // Audit log: failed expense creation
228            AuditLogEntry::new(
229                AuditEventType::ExpenseCreated,
230                Some(user.user_id),
231                Some(organization_id),
232            )
233            .with_error(err.clone())
234            .log();
235
236            // Track H Story H2 — pre-check validate-before-compute → 422 narratif
237            if let Some(resp) = try_build_conformity_response(&err) {
238                return resp;
239            }
240            HttpResponse::BadRequest().json(serde_json::json!({
241                "error": err
242            }))
243        }
244    }
245}
246
247#[utoipa::path(
248    get,
249    path = "/expenses/{id}",
250    tag = "Expenses",
251    summary = "Lire une dépense",
252    responses(
253        (status = 200, description = "Dépense"),
254        (status = 401, description = "Non authentifié"),
255        (status = 404, description = "Introuvable"),
256    ),
257    security(("bearer_auth" = []))
258)]
259#[get("/expenses/{id}")]
260pub async fn get_expense(
261    state: web::Data<AppState>,
262    user: AuthenticatedUser,
263    id: web::Path<Uuid>,
264) -> impl Responder {
265    match state.expense_use_cases.get_expense(*id).await {
266        Ok(Some(expense)) => {
267            // Hotfix #603 — multi-tenant isolation via ACP→organization resolution.
268            if let Ok(building_id) = Uuid::parse_str(&expense.building_id) {
269                if let Ok(Some(building)) = state.building_use_cases.get_building(building_id).await
270                {
271                    let acp_id = match Uuid::parse_str(&building.acp_id) {
272                        Ok(id) => id,
273                        Err(_) => {
274                            return HttpResponse::InternalServerError().json(serde_json::json!({
275                                "error": "Invalid building.acp_id format"
276                            }));
277                        }
278                    };
279                    if let Err(err) =
280                        verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await
281                    {
282                        return err.error_response();
283                    }
284                }
285            }
286            HttpResponse::Ok().json(expense)
287        }
288        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
289            "error": "Expense not found"
290        })),
291        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
292            "error": err
293        })),
294    }
295}
296
297#[utoipa::path(
298    get,
299    path = "/expenses",
300    tag = "Expenses",
301    summary = "Lister les dépenses de l'organisation",
302    responses(
303        (status = 200, description = "Liste paginée"),
304        (status = 401, description = "Non authentifié"),
305    ),
306    security(("bearer_auth" = []))
307)]
308#[get("/expenses")]
309pub async fn list_expenses(
310    state: web::Data<AppState>,
311    user: AuthenticatedUser,
312    page_request: web::Query<PageRequest>,
313) -> impl Responder {
314    let organization_id = user.organization_id;
315
316    match state
317        .expense_use_cases
318        .list_expenses_paginated(&page_request, organization_id)
319        .await
320    {
321        Ok((expenses, total)) => {
322            let response =
323                PageResponse::new(expenses, page_request.page, page_request.per_page, total);
324            HttpResponse::Ok().json(response)
325        }
326        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
327            "error": err
328        })),
329    }
330}
331
332#[utoipa::path(
333    get,
334    path = "/buildings/{building_id}/expenses",
335    tag = "Expenses",
336    summary = "Lister les dépenses d'un immeuble",
337    responses(
338        (status = 200, description = "Liste"),
339        (status = 401, description = "Non authentifié"),
340        (status = 403, description = "Immeuble hors de votre organisation"),
341        (status = 404, description = "Immeuble introuvable"),
342    ),
343    security(("bearer_auth" = []))
344)]
345#[get("/buildings/{building_id}/expenses")]
346pub async fn list_expenses_by_building(
347    state: web::Data<AppState>,
348    user: AuthenticatedUser,
349    building_id: web::Path<Uuid>,
350) -> impl Responder {
351    // Hotfix #603 — multi-tenant isolation via ACP→organization resolution.
352    match state.building_use_cases.get_building(*building_id).await {
353        Ok(Some(building)) => {
354            let acp_id = match Uuid::parse_str(&building.acp_id) {
355                Ok(id) => id,
356                Err(_) => {
357                    return HttpResponse::InternalServerError().json(serde_json::json!({
358                        "error": "Invalid building.acp_id format"
359                    }));
360                }
361            };
362            if let Err(err) = verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await {
363                return err.error_response();
364            }
365        }
366        Ok(None) => {
367            return HttpResponse::NotFound().json(serde_json::json!({
368                "error": "Building not found"
369            }));
370        }
371        Err(err) => {
372            return HttpResponse::InternalServerError().json(serde_json::json!({
373                "error": err
374            }));
375        }
376    }
377
378    match state
379        .expense_use_cases
380        .list_expenses_by_building(*building_id)
381        .await
382    {
383        Ok(expenses) => HttpResponse::Ok().json(expenses),
384        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
385            "error": err
386        })),
387    }
388}
389
390#[utoipa::path(
391    put,
392    path = "/expenses/{id}/mark-paid",
393    tag = "Expenses",
394    summary = "Marquer une dépense comme payée",
395    responses(
396        (status = 200, description = "Dépense mise à jour"),
397        (status = 401, description = "Non authentifié"),
398        (status = 404, description = "Introuvable"),
399    ),
400    security(("bearer_auth" = []))
401)]
402#[put("/expenses/{id}/mark-paid")]
403pub async fn mark_expense_paid(
404    state: web::Data<AppState>,
405    user: AuthenticatedUser,
406    id: web::Path<Uuid>,
407) -> impl Responder {
408    if let Some(response) = verify_owner_readonly(&user) {
409        return response;
410    }
411    // Story 3.1 INV-10 : marquage payé = mouvement financier sortant.
412    if let Some(response) = check_can_emit_expenses(&user) {
413        return response;
414    }
415
416    match state.expense_use_cases.mark_as_paid(*id).await {
417        Ok(expense) => {
418            // Audit log: successful expense marked paid
419            AuditLogEntry::new(
420                AuditEventType::ExpenseMarkedPaid,
421                Some(user.user_id),
422                user.organization_id,
423            )
424            .with_resource("Expense", *id)
425            .log();
426
427            HttpResponse::Ok().json(expense)
428        }
429        Err(err) => {
430            // Audit log: failed expense marked paid
431            AuditLogEntry::new(
432                AuditEventType::ExpenseMarkedPaid,
433                Some(user.user_id),
434                user.organization_id,
435            )
436            .with_resource("Expense", *id)
437            .with_error(err.clone())
438            .log();
439
440            HttpResponse::BadRequest().json(serde_json::json!({
441                "error": err
442            }))
443        }
444    }
445}
446
447#[utoipa::path(
448    post,
449    path = "/expenses/{id}/mark-overdue",
450    tag = "Expenses",
451    summary = "Marquer une dépense en retard",
452    responses(
453        (status = 200, description = "Dépense mise à jour"),
454        (status = 401, description = "Non authentifié"),
455        (status = 404, description = "Introuvable"),
456    ),
457    security(("bearer_auth" = []))
458)]
459#[post("/expenses/{id}/mark-overdue")]
460pub async fn mark_expense_overdue(
461    state: web::Data<AppState>,
462    user: AuthenticatedUser,
463    id: web::Path<Uuid>,
464) -> impl Responder {
465    // Cloisonnement AVANT la mutation (#864).
466    if let Some(refus) = verify_expense_org_access(&state, &user, *id).await {
467        return refus;
468    }
469
470    match state.expense_use_cases.mark_as_overdue(*id).await {
471        Ok(expense) => {
472            AuditLogEntry::new(
473                AuditEventType::ExpenseMarkedOverdue,
474                Some(user.user_id),
475                user.organization_id,
476            )
477            .with_resource("Expense", *id)
478            .log();
479
480            HttpResponse::Ok().json(expense)
481        }
482        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
483            "error": err
484        })),
485    }
486}
487
488#[utoipa::path(
489    post,
490    path = "/expenses/{id}/cancel",
491    tag = "Expenses",
492    summary = "Annuler une dépense",
493    responses(
494        (status = 200, description = "Dépense annulée"),
495        (status = 401, description = "Non authentifié"),
496        (status = 404, description = "Introuvable"),
497    ),
498    security(("bearer_auth" = []))
499)]
500#[post("/expenses/{id}/cancel")]
501pub async fn cancel_expense(
502    state: web::Data<AppState>,
503    user: AuthenticatedUser,
504    id: web::Path<Uuid>,
505) -> impl Responder {
506    // Cloisonnement AVANT la mutation (#864).
507    if let Some(refus) = verify_expense_org_access(&state, &user, *id).await {
508        return refus;
509    }
510
511    match state.expense_use_cases.cancel_expense(*id).await {
512        Ok(expense) => {
513            AuditLogEntry::new(
514                AuditEventType::ExpenseCancelled,
515                Some(user.user_id),
516                user.organization_id,
517            )
518            .with_resource("Expense", *id)
519            .log();
520
521            HttpResponse::Ok().json(expense)
522        }
523        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
524            "error": err
525        })),
526    }
527}
528
529#[utoipa::path(
530    post,
531    path = "/expenses/{id}/reactivate",
532    tag = "Expenses",
533    summary = "Réactiver une dépense annulée",
534    responses(
535        (status = 200, description = "Dépense réactivée"),
536        (status = 401, description = "Non authentifié"),
537        (status = 404, description = "Introuvable"),
538    ),
539    security(("bearer_auth" = []))
540)]
541#[post("/expenses/{id}/reactivate")]
542pub async fn reactivate_expense(
543    state: web::Data<AppState>,
544    user: AuthenticatedUser,
545    id: web::Path<Uuid>,
546) -> impl Responder {
547    // Cloisonnement AVANT la mutation (#864).
548    if let Some(refus) = verify_expense_org_access(&state, &user, *id).await {
549        return refus;
550    }
551
552    match state.expense_use_cases.reactivate_expense(*id).await {
553        Ok(expense) => {
554            AuditLogEntry::new(
555                AuditEventType::ExpenseReactivated,
556                Some(user.user_id),
557                user.organization_id,
558            )
559            .with_resource("Expense", *id)
560            .log();
561
562            HttpResponse::Ok().json(expense)
563        }
564        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
565            "error": err
566        })),
567    }
568}
569
570#[utoipa::path(
571    post,
572    path = "/expenses/{id}/unpay",
573    tag = "Expenses",
574    summary = "Annuler le paiement d'une dépense",
575    responses(
576        (status = 200, description = "Dépense remise en attente"),
577        (status = 401, description = "Non authentifié"),
578        (status = 404, description = "Introuvable"),
579    ),
580    security(("bearer_auth" = []))
581)]
582#[post("/expenses/{id}/unpay")]
583pub async fn unpay_expense(
584    state: web::Data<AppState>,
585    user: AuthenticatedUser,
586    id: web::Path<Uuid>,
587) -> impl Responder {
588    // Cloisonnement AVANT la mutation (#864).
589    if let Some(refus) = verify_expense_org_access(&state, &user, *id).await {
590        return refus;
591    }
592
593    match state.expense_use_cases.unpay_expense(*id).await {
594        Ok(expense) => {
595            AuditLogEntry::new(
596                AuditEventType::ExpenseUnpaid,
597                Some(user.user_id),
598                user.organization_id,
599            )
600            .with_resource("Expense", *id)
601            .log();
602
603            HttpResponse::Ok().json(expense)
604        }
605        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
606            "error": err
607        })),
608    }
609}
610
611// ========== Invoice Workflow Endpoints (Issue #73) ==========
612
613/// POST /invoices/draft - Create a new invoice draft with VAT
614#[utoipa::path(
615    post,
616    path = "/invoices/draft",
617    tag = "Expenses",
618    summary = "Créer une facture brouillon avec TVA",
619    request_body = CreateInvoiceDraftDto,
620    responses(
621        (status = 201, description = "Brouillon créé"),
622        (status = 400, description = "Requête invalide"),
623        (status = 401, description = "Non authentifié"),
624        (status = 403, description = "Rôle sans droit d'encodage"),
625    ),
626    security(("bearer_auth" = []))
627)]
628#[post("/invoices/draft")]
629pub async fn create_invoice_draft(
630    state: web::Data<AppState>,
631    user: AuthenticatedUser,
632    mut dto: web::Json<CreateInvoiceDraftDto>,
633) -> impl Responder {
634    if let Some(response) = verify_accountant_role(&user) {
635        return response;
636    }
637
638    // Override organization_id from JWT token
639    let organization_id = match user.require_organization() {
640        Ok(org_id) => org_id,
641        Err(e) => {
642            return HttpResponse::Unauthorized().json(serde_json::json!({
643                "error": e.to_string()
644            }))
645        }
646    };
647    dto.organization_id = organization_id.to_string();
648
649    // Isolation multi-tenant à l'ÉCRITURE : l'immeuble visé doit relever d'une
650    // ACP dont ce syndic a la gestion.
651    //
652    // L'affectation ci-dessus protège le mauvais champ : elle empêche
653    // d'ESTAMPILLER l'enregistrement au nom d'autrui, pas de le RATTACHER au
654    // patrimoine d'autrui. Mesuré le 2026-09-02 entre deux cabinets syndics
655    // indépendants : `POST /expenses` sur l'immeuble d'un tiers répondait 201,
656    // et la dépense apparaissait dans la liste des charges de cet immeuble.
657    let building_uuid = match Uuid::parse_str(&dto.building_id) {
658        Ok(id) => id,
659        Err(_) => {
660            return HttpResponse::BadRequest()
661                .json(serde_json::json!({ "error": "Invalid building_id format" }))
662        }
663    };
664    if let Err(err) = verify_building_org_access(
665        &user,
666        building_uuid,
667        &state.building_use_cases,
668        &state.acp_use_cases,
669    )
670    .await
671    {
672        return err.error_response();
673    }
674
675    if let Err(errors) = dto.validate() {
676        return HttpResponse::BadRequest().json(serde_json::json!({
677            "error": "Validation failed",
678            "details": errors.to_string()
679        }));
680    }
681
682    match state
683        .expense_use_cases
684        .create_invoice_draft(dto.into_inner())
685        .await
686    {
687        Ok(invoice) => {
688            AuditLogEntry::new(
689                AuditEventType::ExpenseCreated,
690                Some(user.user_id),
691                Some(organization_id),
692            )
693            .with_resource("Invoice", Uuid::parse_str(&invoice.id).unwrap())
694            .log();
695
696            HttpResponse::Created().json(invoice)
697        }
698        Err(err) => {
699            AuditLogEntry::new(
700                AuditEventType::ExpenseCreated,
701                Some(user.user_id),
702                Some(organization_id),
703            )
704            .with_error(err.clone())
705            .log();
706
707            // Track H Story H2 — pre-check validate-before-compute → 422 narratif
708            if let Some(resp) = try_build_conformity_response(&err) {
709                return resp;
710            }
711            HttpResponse::BadRequest().json(serde_json::json!({
712                "error": err
713            }))
714        }
715    }
716}
717
718/// PUT /invoices/{id} - Update invoice draft (only if Draft or Rejected)
719#[utoipa::path(
720    put,
721    path = "/invoices/{id}",
722    tag = "Expenses",
723    summary = "Modifier une facture brouillon ou rejetée",
724    request_body = UpdateInvoiceDraftDto,
725    responses(
726        (status = 200, description = "Brouillon modifié"),
727        (status = 400, description = "Requête invalide"),
728        (status = 401, description = "Non authentifié"),
729        (status = 404, description = "Introuvable"),
730    ),
731    security(("bearer_auth" = []))
732)]
733#[put("/invoices/{id}")]
734pub async fn update_invoice_draft(
735    state: web::Data<AppState>,
736    user: AuthenticatedUser,
737    id: web::Path<Uuid>,
738    dto: web::Json<UpdateInvoiceDraftDto>,
739) -> impl Responder {
740    if let Some(response) = verify_accountant_role(&user) {
741        return response;
742    }
743
744    if let Err(errors) = dto.validate() {
745        return HttpResponse::BadRequest().json(serde_json::json!({
746            "error": "Validation failed",
747            "details": errors.to_string()
748        }));
749    }
750
751    match state
752        .expense_use_cases
753        .update_invoice_draft(*id, dto.into_inner())
754        .await
755    {
756        Ok(invoice) => {
757            AuditLogEntry::new(
758                AuditEventType::InvoiceUpdated,
759                Some(user.user_id),
760                user.organization_id,
761            )
762            .with_resource("Invoice", *id)
763            .log();
764
765            HttpResponse::Ok().json(invoice)
766        }
767        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
768            "error": err
769        })),
770    }
771}
772
773/// PUT /invoices/{id}/submit - Submit invoice for approval (Draft → PendingApproval)
774#[utoipa::path(
775    put,
776    path = "/invoices/{id}/submit",
777    tag = "Expenses",
778    summary = "Soumettre une facture pour validation",
779    request_body = SubmitForApprovalDto,
780    responses(
781        (status = 200, description = "Facture soumise"),
782        (status = 401, description = "Non authentifié"),
783        (status = 404, description = "Introuvable"),
784    ),
785    security(("bearer_auth" = []))
786)]
787#[put("/invoices/{id}/submit")]
788pub async fn submit_invoice_for_approval(
789    state: web::Data<AppState>,
790    user: AuthenticatedUser,
791    id: web::Path<Uuid>,
792) -> impl Responder {
793    if let Some(response) = verify_accountant_role(&user) {
794        return response;
795    }
796
797    match state
798        .expense_use_cases
799        .submit_for_approval(*id, SubmitForApprovalDto {})
800        .await
801    {
802        Ok(invoice) => {
803            AuditLogEntry::new(
804                AuditEventType::InvoiceSubmitted,
805                Some(user.user_id),
806                user.organization_id,
807            )
808            .with_resource("Invoice", *id)
809            .log();
810
811            HttpResponse::Ok().json(invoice)
812        }
813        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
814            "error": err
815        })),
816    }
817}
818
819/// PUT /invoices/{id}/approve - Approve invoice (PendingApproval → Approved)
820/// Only syndic or superadmin can approve
821#[utoipa::path(
822    put,
823    path = "/invoices/{id}/approve",
824    tag = "Expenses",
825    summary = "Approuver une facture",
826    request_body = ApproveInvoiceDto,
827    responses(
828        (status = 200, description = "Facture approuvée"),
829        (status = 401, description = "Non authentifié"),
830        (status = 403, description = "Rôle sans droit d'approbation"),
831        (status = 404, description = "Introuvable"),
832    ),
833    security(("bearer_auth" = []))
834)]
835#[put("/invoices/{id}/approve")]
836pub async fn approve_invoice(
837    state: web::Data<AppState>,
838    user: AuthenticatedUser,
839    id: web::Path<Uuid>,
840) -> impl Responder {
841    if let Some(response) = verify_syndic_role(&user) {
842        return response;
843    }
844
845    let dto = ApproveInvoiceDto {
846        approved_by_user_id: user.user_id.to_string(),
847    };
848
849    match state.expense_use_cases.approve_invoice(*id, dto).await {
850        Ok(invoice) => {
851            AuditLogEntry::new(
852                AuditEventType::InvoiceApproved,
853                Some(user.user_id),
854                user.organization_id,
855            )
856            .with_resource("Invoice", *id)
857            .log();
858
859            HttpResponse::Ok().json(invoice)
860        }
861        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
862            "error": err
863        })),
864    }
865}
866
867/// PUT /invoices/{id}/reject - Reject invoice with reason (PendingApproval → Rejected)
868/// Only syndic or superadmin can reject
869#[utoipa::path(
870    put,
871    path = "/invoices/{id}/reject",
872    tag = "Expenses",
873    summary = "Rejeter une facture avec motif",
874    request_body = RejectInvoiceDto,
875    responses(
876        (status = 200, description = "Facture rejetée"),
877        (status = 400, description = "Motif manquant"),
878        (status = 401, description = "Non authentifié"),
879        (status = 404, description = "Introuvable"),
880    ),
881    security(("bearer_auth" = []))
882)]
883#[put("/invoices/{id}/reject")]
884pub async fn reject_invoice(
885    state: web::Data<AppState>,
886    user: AuthenticatedUser,
887    id: web::Path<Uuid>,
888    dto: web::Json<RejectInvoiceDto>,
889) -> impl Responder {
890    if let Some(response) = verify_syndic_role(&user) {
891        return response;
892    }
893
894    if let Err(errors) = dto.validate() {
895        return HttpResponse::BadRequest().json(serde_json::json!({
896            "error": "Validation failed",
897            "details": errors.to_string()
898        }));
899    }
900
901    let mut reject_dto = dto.into_inner();
902    reject_dto.rejected_by_user_id = user.user_id.to_string();
903
904    match state
905        .expense_use_cases
906        .reject_invoice(*id, reject_dto)
907        .await
908    {
909        Ok(invoice) => {
910            AuditLogEntry::new(
911                AuditEventType::InvoiceRejected,
912                Some(user.user_id),
913                user.organization_id,
914            )
915            .with_resource("Invoice", *id)
916            .log();
917
918            HttpResponse::Ok().json(invoice)
919        }
920        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
921            "error": err
922        })),
923    }
924}
925
926/// GET /invoices/pending - Get all pending invoices (for syndic dashboard)
927/// Only syndic or superadmin can view pending invoices
928#[utoipa::path(
929    get,
930    path = "/invoices/pending",
931    tag = "Expenses",
932    summary = "Lister les factures en attente d'approbation",
933    responses(
934        (status = 200, description = "Liste"),
935        (status = 401, description = "Non authentifié"),
936    ),
937    security(("bearer_auth" = []))
938)]
939#[get("/invoices/pending")]
940pub async fn get_pending_invoices(
941    state: web::Data<AppState>,
942    user: AuthenticatedUser,
943) -> impl Responder {
944    if let Some(response) = verify_syndic_role(&user) {
945        return response;
946    }
947
948    let organization_id = match user.require_organization() {
949        Ok(org_id) => org_id,
950        Err(e) => {
951            return HttpResponse::Unauthorized().json(serde_json::json!({
952                "error": e.to_string()
953            }))
954        }
955    };
956
957    match state
958        .expense_use_cases
959        .get_pending_invoices(organization_id)
960        .await
961    {
962        Ok(pending_list) => HttpResponse::Ok().json(pending_list),
963        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
964            "error": err
965        })),
966    }
967}
968
969/// GET /invoices/{id} - Get full invoice details (enriched with all fields)
970#[utoipa::path(
971    get,
972    path = "/invoices/{id}",
973    tag = "Expenses",
974    summary = "Lire une facture",
975    responses(
976        (status = 200, description = "Facture"),
977        (status = 401, description = "Non authentifié"),
978        (status = 404, description = "Introuvable"),
979    ),
980    security(("bearer_auth" = []))
981)]
982#[get("/invoices/{id}")]
983pub async fn get_invoice(
984    state: web::Data<AppState>,
985    user: AuthenticatedUser,
986    id: web::Path<Uuid>,
987) -> impl Responder {
988    match state.expense_use_cases.get_invoice(*id).await {
989        Ok(Some(invoice)) => {
990            // Hotfix #603 — multi-tenant isolation via ACP→organization resolution.
991            if let Ok(building_id) = Uuid::parse_str(&invoice.building_id) {
992                if let Ok(Some(building)) = state.building_use_cases.get_building(building_id).await
993                {
994                    let acp_id = match Uuid::parse_str(&building.acp_id) {
995                        Ok(id) => id,
996                        Err(_) => {
997                            return HttpResponse::InternalServerError().json(serde_json::json!({
998                                "error": "Invalid building.acp_id format"
999                            }));
1000                        }
1001                    };
1002                    if let Err(err) =
1003                        verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await
1004                    {
1005                        return err.error_response();
1006                    }
1007                }
1008            }
1009            HttpResponse::Ok().json(invoice)
1010        }
1011        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
1012            "error": "Invoice not found"
1013        })),
1014        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
1015            "error": err
1016        })),
1017    }
1018}
1019
1020/// Export Work Quote to PDF
1021///
1022/// GET /expenses/{expense_id}/export-quote-pdf?contractor_name={name}&contractor_contact={email}&timeline={description}
1023///
1024/// Generates a "Devis de Travaux" PDF for work-related expenses.
1025#[derive(Debug, Deserialize)]
1026pub struct ExportWorkQuoteQuery {
1027    pub contractor_name: String,
1028    pub contractor_contact: String,
1029    pub timeline: String, // e.g., "2-3 weeks" or "Délai: 15 jours ouvrables"
1030}
1031
1032#[utoipa::path(
1033    get,
1034    path = "/expenses/{id}/export-quote-pdf",
1035    tag = "Expenses",
1036    summary = "Exporter un devis en PDF",
1037    responses(
1038        (status = 200, description = "PDF"),
1039        (status = 401, description = "Non authentifié"),
1040        (status = 404, description = "Introuvable"),
1041    ),
1042    security(("bearer_auth" = []))
1043)]
1044#[get("/expenses/{id}/export-quote-pdf")]
1045pub async fn export_work_quote_pdf(
1046    state: web::Data<AppState>,
1047    user: AuthenticatedUser,
1048    id: web::Path<Uuid>,
1049    query: web::Query<ExportWorkQuoteQuery>,
1050) -> impl Responder {
1051    use crate::domain::entities::{Building, Expense};
1052    use crate::domain::services::{QuoteLineItem, WorkQuoteExporter};
1053
1054    let organization_id = match user.require_organization() {
1055        Ok(org_id) => org_id,
1056        Err(e) => {
1057            return HttpResponse::Unauthorized().json(serde_json::json!({
1058                "error": e.to_string()
1059            }))
1060        }
1061    };
1062
1063    let expense_id = *id;
1064
1065    // 1. Get expense
1066    let expense_dto = match state.expense_use_cases.get_expense(expense_id).await {
1067        Ok(Some(dto)) => dto,
1068        Ok(None) => {
1069            return HttpResponse::NotFound().json(serde_json::json!({
1070                "error": "Expense not found"
1071            }))
1072        }
1073        Err(err) => {
1074            return HttpResponse::InternalServerError().json(serde_json::json!({
1075                "error": err
1076            }))
1077        }
1078    };
1079
1080    // 2. Parse expense DTO fields
1081    let expense_building_id = match Uuid::parse_str(&expense_dto.building_id) {
1082        Ok(id) => id,
1083        Err(e) => {
1084            return HttpResponse::BadRequest().json(serde_json::json!({
1085                "error": format!("Invalid building_id: {}", e)
1086            }))
1087        }
1088    };
1089    let expense_id_uuid = match Uuid::parse_str(&expense_dto.id) {
1090        Ok(id) => id,
1091        Err(e) => {
1092            return HttpResponse::BadRequest().json(serde_json::json!({
1093                "error": format!("Invalid expense_id: {}", e)
1094            }))
1095        }
1096    };
1097
1098    // 3. Get building
1099    let building_dto = match state
1100        .building_use_cases
1101        .get_building(expense_building_id)
1102        .await
1103    {
1104        Ok(Some(dto)) => dto,
1105        Ok(None) => {
1106            return HttpResponse::NotFound().json(serde_json::json!({
1107                "error": "Building not found"
1108            }))
1109        }
1110        Err(err) => {
1111            return HttpResponse::InternalServerError().json(serde_json::json!({
1112                "error": err
1113            }))
1114        }
1115    };
1116
1117    // Convert DTOs to domain entities — Story 1.2 : DTO field renamed
1118    // `organization_id` → `acp_id` (FK vers acps.id). `organization_id`
1119    // upstream (de require_organization()) reste utilisé pour audit
1120    // mais n'est plus stocké sur Building.
1121    let _ = organization_id;
1122    let building_acp_id = Uuid::parse_str(&building_dto.acp_id).unwrap_or_else(|_| Uuid::new_v4());
1123
1124    let building_created_at = DateTime::parse_from_rfc3339(&building_dto.created_at)
1125        .map(|dt| dt.with_timezone(&Utc))
1126        .unwrap_or_else(|_| Utc::now());
1127
1128    let building_updated_at = DateTime::parse_from_rfc3339(&building_dto.updated_at)
1129        .map(|dt| dt.with_timezone(&Utc))
1130        .unwrap_or_else(|_| Utc::now());
1131
1132    let building_entity = Building {
1133        id: Uuid::parse_str(&building_dto.id).unwrap_or(expense_building_id),
1134        name: building_dto.name.clone(),
1135        address: building_dto.address,
1136        city: building_dto.city,
1137        postal_code: building_dto.postal_code,
1138        country: building_dto.country,
1139        total_units: building_dto.total_units,
1140        total_tantiemes: building_dto.total_tantiemes,
1141        construction_year: building_dto.construction_year,
1142        syndic_name: None,
1143        syndic_email: None,
1144        syndic_phone: None,
1145        syndic_address: None,
1146        syndic_office_hours: None,
1147        syndic_emergency_contact: None,
1148        slug: None,
1149        acp_id: building_acp_id,
1150        created_at: building_created_at,
1151        updated_at: building_updated_at,
1152    };
1153
1154    let expense_date = DateTime::parse_from_rfc3339(&expense_dto.expense_date)
1155        .map(|dt| dt.with_timezone(&Utc))
1156        .unwrap_or_else(|_| Utc::now());
1157
1158    let expense_entity = Expense {
1159        id: expense_id_uuid,
1160        acp_id: Uuid::parse_str(&expense_dto.acp_id).unwrap_or(organization_id),
1161        organization_id,
1162        building_id: expense_building_id,
1163        category: expense_dto.category.clone(),
1164        description: expense_dto.description.clone(),
1165        amount: expense_dto.amount,
1166        amount_excl_vat: None,
1167        vat_rate: None,
1168        vat_amount: None,
1169        amount_incl_vat: None,
1170        expense_date,
1171        invoice_date: None,
1172        due_date: None,
1173        paid_date: None,
1174        approval_status: expense_dto.approval_status.clone(),
1175        submitted_at: None,
1176        approved_by: None,
1177        approved_at: None,
1178        rejection_reason: None,
1179        payment_status: expense_dto.payment_status.clone(),
1180        supplier: expense_dto.supplier.clone(),
1181        invoice_number: expense_dto.invoice_number.clone(),
1182        account_code: expense_dto.account_code.clone(),
1183        contractor_report_id: None,
1184        created_at: Utc::now(),
1185        updated_at: Utc::now(),
1186    };
1187
1188    // Create a single line item from expense amount (simplified for now)
1189    // Note: For full invoice support with line items, we would need to:
1190    // 1. Get the invoice via get_invoice()
1191    // 2. Add a repository method to fetch line items
1192    // For now, we use simplified single line item approach
1193    let quote_line_items: Vec<QuoteLineItem> = vec![QuoteLineItem {
1194        description: expense_dto.description.clone(),
1195        quantity: rust_decimal::Decimal::ONE,
1196        unit_price: expense_dto.amount,
1197        total: expense_dto.amount,
1198    }];
1199
1200    // 4. Generate PDF
1201    match WorkQuoteExporter::export_to_pdf(
1202        &building_entity,
1203        &expense_entity,
1204        &quote_line_items,
1205        &query.contractor_name,
1206        &query.contractor_contact,
1207        &query.timeline,
1208    ) {
1209        Ok(pdf_bytes) => {
1210            // Audit log
1211            AuditLogEntry::new(
1212                AuditEventType::ReportGenerated,
1213                Some(user.user_id),
1214                Some(organization_id),
1215            )
1216            .with_resource("Expense", expense_id)
1217            .with_metadata(serde_json::json!({
1218                "report_type": "work_quote_pdf",
1219                "building_name": building_entity.name,
1220                "contractor_name": query.contractor_name,
1221                "amount": expense_dto.amount
1222            }))
1223            .log();
1224
1225            HttpResponse::Ok()
1226                .content_type("application/pdf")
1227                .insert_header((
1228                    "Content-Disposition",
1229                    format!(
1230                        "attachment; filename=\"Devis_Travaux_{}_{}.pdf\"",
1231                        building_entity.name.replace(' ', "_"),
1232                        expense_entity.id
1233                    ),
1234                ))
1235                .body(pdf_bytes)
1236        }
1237        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
1238            "error": format!("Failed to generate PDF: {}", err)
1239        })),
1240    }
1241}