Skip to main content

koprogo_api/infrastructure/web/handlers/
budget_handlers.rs

1use crate::application::dto::{
2    CreateBudgetRequest, PageRequest, PageResponse, UpdateBudgetRequest,
3};
4use crate::domain::entities::BudgetStatus;
5use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
6use crate::infrastructure::web::middleware::scope_guard::verify_acp_org_access;
7use crate::infrastructure::web::middleware::scope_guard::verify_building_org_access;
8use crate::infrastructure::web::{AppState, AuthenticatedUser};
9use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
10use uuid::Uuid;
11
12/// Cloisonne un budget AVANT de le muter (#864).
13///
14/// ── Pourquoi `verify_` et pas un nom français ─────────────────────────────
15///
16/// Ce helper s'appelait `cloisonner_*` à sa première écriture. Le cliquet de
17/// #864 est resté à 95 : son détecteur cherche les idiomes par lesquels CE
18/// dépôt refuse un accès — `verify_`, `scope_guard`, `Forbidden`,
19/// `require_organization` — et `cloisonner_` n'en est pas un. Dix trous
20/// venaient d'être bouchés, et l'instrument ne le voyait pas.
21///
22/// Deux sorties possibles : allonger la liste du détecteur, ou porter le nom
23/// que le dépôt emploie déjà (`verify_org_access`, `verify_acp_org_access`,
24/// `verify_building_org_access`). La première aurait fait tomber le compteur
25/// de dix par une modification de sa DÉFINITION, ce qui est précisément le
26/// geste que la méthode interdit. La seconde corrige une incohérence de
27/// nommage que je venais d'introduire, et la dette tombe à 85 parce que le
28/// travail a été fait.
29///
30/// ── Le défaut que ce garde ferme ──────────────────────────────────────────
31///
32/// Les cinq transitions d'état du budget prenaient `AuthenticatedUser` et ne
33/// s'en servaient que pour **journaliser après coup**. N'importe quel
34/// utilisateur authentifié pouvait donc approuver, rejeter, archiver ou
35/// modifier le budget de n'importe quelle copropriété — y compris d'un autre
36/// cabinet — en connaissant son UUID, et le journal d'audit enregistrait la
37/// transition comme régulière.
38///
39/// Le contrôle existait déjà dans ce fichier : `get_budget` et
40/// `get_budget_variance` le font. Il manquait sur les opérations qui
41/// **engagent** : un budget approuvé porte les appels de fonds de l'exercice.
42///
43/// ── Pourquoi un helper, et pas la ligne recopiée cinq fois ────────────────
44///
45/// Recopié, il se perd à la sixième transition. Nommé, il se voit dans la
46/// signature de chaque handler, et son absence se lit.
47///
48/// Rend `Some(réponse)` quand l'appel doit être refusé, `None` quand il peut
49/// continuer — le même idiome que `verify_syndic_role` ailleurs dans ce dépôt.
50async fn verify_budget_org_access(
51    state: &web::Data<AppState>,
52    user: &AuthenticatedUser,
53    id: Uuid,
54) -> Option<HttpResponse> {
55    match state.budget_use_cases.get_budget(id).await {
56        Ok(Some(budget)) => match user.verify_org_access(budget.organization_id) {
57            Ok(()) => None,
58            Err(e) => Some(HttpResponse::Forbidden().json(serde_json::json!({ "error": e }))),
59        },
60        Ok(None) => Some(HttpResponse::NotFound().json(serde_json::json!({
61            "error": "Budget not found"
62        }))),
63        Err(err) => Some(HttpResponse::InternalServerError().json(serde_json::json!({
64            "error": err.to_string()
65        }))),
66    }
67}
68
69/// Create a new budget
70#[post("/budgets")]
71pub async fn create_budget(
72    state: web::Data<AppState>,
73    user: AuthenticatedUser,
74    mut request: web::Json<CreateBudgetRequest>,
75) -> impl Responder {
76    // Override organization_id from JWT token (security)
77    let organization_id = match user.require_organization() {
78        Ok(org_id) => org_id,
79        Err(e) => {
80            return HttpResponse::Unauthorized().json(serde_json::json!({
81                "error": e.to_string()
82            }))
83        }
84    };
85    request.organization_id = organization_id;
86
87    // Isolation multi-tenant à l'ÉCRITURE : l'immeuble visé doit relever d'une
88    // ACP dont ce syndic a la gestion. L'affectation de `organization_id`
89    // ci-dessus protège le mauvais champ — elle empêche d'estampiller
90    // l'enregistrement au nom d'autrui, pas de le rattacher au patrimoine
91    // d'autrui (audit du 2026-09-02).
92    if let Err(err) = verify_building_org_access(
93        &user,
94        request.building_id,
95        &state.building_use_cases,
96        &state.acp_use_cases,
97    )
98    .await
99    {
100        return err.error_response();
101    }
102
103    match state
104        .budget_use_cases
105        .create_budget(request.into_inner())
106        .await
107    {
108        Ok(budget) => {
109            AuditLogEntry::new(
110                AuditEventType::BudgetCreated,
111                Some(user.user_id),
112                Some(organization_id),
113            )
114            .with_resource("Budget", budget.id)
115            .log();
116
117            HttpResponse::Created().json(budget)
118        }
119        Err(err) => {
120            AuditLogEntry::new(
121                AuditEventType::BudgetCreated,
122                Some(user.user_id),
123                Some(organization_id),
124            )
125            .with_error(err.to_string())
126            .log();
127
128            HttpResponse::BadRequest().json(serde_json::json!({
129                "error": err.to_string()
130            }))
131        }
132    }
133}
134
135/// Get budget by ID
136#[get("/budgets/{id}")]
137pub async fn get_budget(
138    state: web::Data<AppState>,
139    user: AuthenticatedUser,
140    id: web::Path<Uuid>,
141) -> impl Responder {
142    match state.budget_use_cases.get_budget(*id).await {
143        Ok(Some(budget)) => {
144            // Multi-tenant isolation: verify budget belongs to user's organization
145            if let Err(e) = user.verify_org_access(budget.organization_id) {
146                return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
147            }
148            HttpResponse::Ok().json(budget)
149        }
150        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
151            "error": "Budget not found"
152        })),
153        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
154            "error": err.to_string()
155        })),
156    }
157}
158
159/// Get budget by building and fiscal year
160#[get("/buildings/{building_id}/budgets/fiscal-year/{fiscal_year}")]
161pub async fn get_budget_by_building_and_fiscal_year(
162    state: web::Data<AppState>,
163    user: AuthenticatedUser,
164    params: web::Path<(Uuid, i32)>,
165) -> impl Responder {
166    let (building_id, fiscal_year) = params.into_inner();
167
168    // Hotfix #603 — multi-tenant isolation via ACP→organization resolution.
169    match state.building_use_cases.get_building(building_id).await {
170        Ok(Some(building)) => {
171            let acp_id = match Uuid::parse_str(&building.acp_id) {
172                Ok(id) => id,
173                Err(_) => {
174                    return HttpResponse::InternalServerError().json(serde_json::json!({
175                        "error": "Invalid building.acp_id format"
176                    }));
177                }
178            };
179            if let Err(err) = verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await {
180                return err.error_response();
181            }
182        }
183        Ok(None) => {
184            return HttpResponse::NotFound().json(serde_json::json!({
185                "error": "Building not found"
186            }));
187        }
188        Err(err) => {
189            return HttpResponse::InternalServerError().json(serde_json::json!({
190                "error": err.to_string()
191            }));
192        }
193    }
194
195    match state
196        .budget_use_cases
197        .get_by_building_and_fiscal_year(building_id, fiscal_year)
198        .await
199    {
200        Ok(Some(budget)) => HttpResponse::Ok().json(budget),
201        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
202            "error": "Budget not found"
203        })),
204        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
205            "error": err.to_string()
206        })),
207    }
208}
209
210/// Get active budget for a building
211#[get("/buildings/{building_id}/budgets/active")]
212pub async fn get_active_budget(
213    state: web::Data<AppState>,
214    user: AuthenticatedUser,
215    building_id: web::Path<Uuid>,
216) -> impl Responder {
217    // Hotfix #603 — multi-tenant isolation via ACP→organization resolution.
218    match state.building_use_cases.get_building(*building_id).await {
219        Ok(Some(building)) => {
220            let acp_id = match Uuid::parse_str(&building.acp_id) {
221                Ok(id) => id,
222                Err(_) => {
223                    return HttpResponse::InternalServerError().json(serde_json::json!({
224                        "error": "Invalid building.acp_id format"
225                    }));
226                }
227            };
228            if let Err(err) = verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await {
229                return err.error_response();
230            }
231        }
232        Ok(None) => {
233            return HttpResponse::NotFound().json(serde_json::json!({
234                "error": "Building not found"
235            }));
236        }
237        Err(err) => {
238            return HttpResponse::InternalServerError().json(serde_json::json!({
239                "error": err.to_string()
240            }));
241        }
242    }
243
244    match state.budget_use_cases.get_active_budget(*building_id).await {
245        Ok(Some(budget)) => HttpResponse::Ok().json(budget),
246        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
247            "error": "No active budget found for this building"
248        })),
249        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
250            "error": err.to_string()
251        })),
252    }
253}
254
255/// List budgets for a building
256#[get("/buildings/{building_id}/budgets")]
257pub async fn list_budgets_by_building(
258    state: web::Data<AppState>,
259    user: AuthenticatedUser,
260    building_id: web::Path<Uuid>,
261) -> impl Responder {
262    // Hotfix #603 — multi-tenant isolation via ACP→organization resolution.
263    match state.building_use_cases.get_building(*building_id).await {
264        Ok(Some(building)) => {
265            let acp_id = match Uuid::parse_str(&building.acp_id) {
266                Ok(id) => id,
267                Err(_) => {
268                    return HttpResponse::InternalServerError().json(serde_json::json!({
269                        "error": "Invalid building.acp_id format"
270                    }));
271                }
272            };
273            if let Err(err) = verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await {
274                return err.error_response();
275            }
276        }
277        Ok(None) => {
278            return HttpResponse::NotFound().json(serde_json::json!({
279                "error": "Building not found"
280            }));
281        }
282        Err(err) => {
283            return HttpResponse::InternalServerError().json(serde_json::json!({
284                "error": err.to_string()
285            }));
286        }
287    }
288
289    match state.budget_use_cases.list_by_building(*building_id).await {
290        Ok(budgets) => HttpResponse::Ok().json(budgets),
291        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
292            "error": err.to_string()
293        })),
294    }
295}
296
297/// List budgets by fiscal year
298#[get("/budgets/fiscal-year/{fiscal_year}")]
299pub async fn list_budgets_by_fiscal_year(
300    state: web::Data<AppState>,
301    user: AuthenticatedUser,
302    fiscal_year: web::Path<i32>,
303) -> impl Responder {
304    let organization_id = match user.require_organization() {
305        Ok(org_id) => org_id,
306        Err(e) => {
307            return HttpResponse::Unauthorized().json(serde_json::json!({
308                "error": e.to_string()
309            }))
310        }
311    };
312
313    match state
314        .budget_use_cases
315        .list_by_fiscal_year(organization_id, *fiscal_year)
316        .await
317    {
318        Ok(budgets) => HttpResponse::Ok().json(budgets),
319        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
320            "error": err.to_string()
321        })),
322    }
323}
324
325/// List budgets by status
326#[get("/budgets/status/{status}")]
327pub async fn list_budgets_by_status(
328    state: web::Data<AppState>,
329    user: AuthenticatedUser,
330    status: web::Path<String>,
331) -> impl Responder {
332    let organization_id = match user.require_organization() {
333        Ok(org_id) => org_id,
334        Err(e) => {
335            return HttpResponse::Unauthorized().json(serde_json::json!({
336                "error": e.to_string()
337            }))
338        }
339    };
340
341    let budget_status = match status.as_str() {
342        "draft" => BudgetStatus::Draft,
343        "submitted" => BudgetStatus::Submitted,
344        "approved" => BudgetStatus::Approved,
345        "rejected" => BudgetStatus::Rejected,
346        "archived" => BudgetStatus::Archived,
347        _ => {
348            return HttpResponse::BadRequest().json(serde_json::json!({
349                "error": "Invalid status"
350            }))
351        }
352    };
353
354    match state
355        .budget_use_cases
356        .list_by_status(organization_id, budget_status)
357        .await
358    {
359        Ok(budgets) => HttpResponse::Ok().json(budgets),
360        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
361            "error": err.to_string()
362        })),
363    }
364}
365
366/// List budgets paginated
367#[get("/budgets")]
368pub async fn list_budgets(
369    state: web::Data<AppState>,
370    user: AuthenticatedUser,
371    page_request: web::Query<PageRequest>,
372    filters: web::Query<serde_json::Value>,
373) -> impl Responder {
374    let organization_id = user.organization_id;
375
376    // Parse optional filters
377    let building_id = filters
378        .get("building_id")
379        .and_then(|v| v.as_str())
380        .and_then(|s| Uuid::parse_str(s).ok());
381
382    let status = filters
383        .get("status")
384        .and_then(|v| v.as_str())
385        .and_then(|s| match s {
386            "draft" => Some(BudgetStatus::Draft),
387            "submitted" => Some(BudgetStatus::Submitted),
388            "approved" => Some(BudgetStatus::Approved),
389            "rejected" => Some(BudgetStatus::Rejected),
390            "archived" => Some(BudgetStatus::Archived),
391            _ => None,
392        });
393
394    match state
395        .budget_use_cases
396        .list_paginated(&page_request, organization_id, building_id, status)
397        .await
398    {
399        Ok((budgets, total)) => {
400            let response =
401                PageResponse::new(budgets, page_request.page, page_request.per_page, total);
402            HttpResponse::Ok().json(response)
403        }
404        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
405            "error": err.to_string()
406        })),
407    }
408}
409
410/// Update budget (Draft only)
411#[put("/budgets/{id}")]
412pub async fn update_budget(
413    state: web::Data<AppState>,
414    user: AuthenticatedUser,
415    id: web::Path<Uuid>,
416    request: web::Json<UpdateBudgetRequest>,
417) -> impl Responder {
418    // Cloisonnement AVANT la transition (#864).
419    if let Some(refus) = verify_budget_org_access(&state, &user, *id).await {
420        return refus;
421    }
422
423    match state
424        .budget_use_cases
425        .update_budget(*id, request.into_inner())
426        .await
427    {
428        Ok(budget) => {
429            AuditLogEntry::new(
430                AuditEventType::BudgetUpdated,
431                Some(user.user_id),
432                user.organization_id,
433            )
434            .with_resource("Budget", budget.id)
435            .log();
436
437            HttpResponse::Ok().json(budget)
438        }
439        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
440            "error": err.to_string()
441        })),
442    }
443}
444
445/// Submit budget for approval
446#[put("/budgets/{id}/submit")]
447pub async fn submit_budget(
448    state: web::Data<AppState>,
449    user: AuthenticatedUser,
450    id: web::Path<Uuid>,
451) -> impl Responder {
452    // Cloisonnement AVANT la transition (#864).
453    if let Some(refus) = verify_budget_org_access(&state, &user, *id).await {
454        return refus;
455    }
456
457    match state.budget_use_cases.submit_for_approval(*id).await {
458        Ok(budget) => {
459            AuditLogEntry::new(
460                AuditEventType::BudgetSubmitted,
461                Some(user.user_id),
462                user.organization_id,
463            )
464            .with_resource("Budget", budget.id)
465            .log();
466
467            HttpResponse::Ok().json(budget)
468        }
469        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
470            "error": err.to_string()
471        })),
472    }
473}
474
475/// Approve budget (requires meeting_id)
476#[put("/budgets/{id}/approve")]
477pub async fn approve_budget(
478    state: web::Data<AppState>,
479    user: AuthenticatedUser,
480    id: web::Path<Uuid>,
481    payload: web::Json<serde_json::Value>,
482) -> impl Responder {
483    // Cloisonnement AVANT la transition (#864).
484    if let Some(refus) = verify_budget_org_access(&state, &user, *id).await {
485        return refus;
486    }
487
488    let meeting_id = match payload.get("meeting_id") {
489        Some(serde_json::Value::String(id_str)) => match Uuid::parse_str(id_str) {
490            Ok(uuid) => uuid,
491            Err(_) => {
492                return HttpResponse::BadRequest().json(serde_json::json!({
493                    "error": "Invalid meeting_id format"
494                }))
495            }
496        },
497        _ => {
498            return HttpResponse::BadRequest().json(serde_json::json!({
499                "error": "meeting_id is required as a UUID string"
500            }))
501        }
502    };
503
504    match state.budget_use_cases.approve_budget(*id, meeting_id).await {
505        Ok(budget) => {
506            AuditLogEntry::new(
507                AuditEventType::BudgetApproved,
508                Some(user.user_id),
509                user.organization_id,
510            )
511            .with_resource("Budget", budget.id)
512            .with_metadata(serde_json::json!({"meeting_id": meeting_id}))
513            .log();
514
515            HttpResponse::Ok().json(budget)
516        }
517        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
518            "error": err.to_string()
519        })),
520    }
521}
522
523/// Reject budget (with optional reason)
524#[put("/budgets/{id}/reject")]
525pub async fn reject_budget(
526    state: web::Data<AppState>,
527    user: AuthenticatedUser,
528    id: web::Path<Uuid>,
529    payload: web::Json<serde_json::Value>,
530) -> impl Responder {
531    // Cloisonnement AVANT la transition (#864).
532    if let Some(refus) = verify_budget_org_access(&state, &user, *id).await {
533        return refus;
534    }
535
536    let reason = payload
537        .get("reason")
538        .and_then(|v| v.as_str())
539        .map(|s| s.to_string());
540
541    match state.budget_use_cases.reject_budget(*id, reason).await {
542        Ok(budget) => {
543            AuditLogEntry::new(
544                AuditEventType::BudgetRejected,
545                Some(user.user_id),
546                user.organization_id,
547            )
548            .with_resource("Budget", budget.id)
549            .log();
550
551            HttpResponse::Ok().json(budget)
552        }
553        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
554            "error": err.to_string()
555        })),
556    }
557}
558
559/// Archive budget
560#[put("/budgets/{id}/archive")]
561pub async fn archive_budget(
562    state: web::Data<AppState>,
563    user: AuthenticatedUser,
564    id: web::Path<Uuid>,
565) -> impl Responder {
566    // Cloisonnement AVANT la transition (#864).
567    if let Some(refus) = verify_budget_org_access(&state, &user, *id).await {
568        return refus;
569    }
570
571    match state.budget_use_cases.archive_budget(*id).await {
572        Ok(budget) => {
573            AuditLogEntry::new(
574                AuditEventType::BudgetArchived,
575                Some(user.user_id),
576                user.organization_id,
577            )
578            .with_resource("Budget", budget.id)
579            .log();
580
581            HttpResponse::Ok().json(budget)
582        }
583        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
584            "error": err.to_string()
585        })),
586    }
587}
588
589/// Get budget statistics
590#[get("/budgets/stats")]
591pub async fn get_budget_stats(
592    state: web::Data<AppState>,
593    user: AuthenticatedUser,
594) -> impl Responder {
595    let organization_id = match user.require_organization() {
596        Ok(org_id) => org_id,
597        Err(e) => {
598            return HttpResponse::Unauthorized().json(serde_json::json!({
599                "error": e.to_string()
600            }))
601        }
602    };
603
604    match state.budget_use_cases.get_stats(organization_id).await {
605        Ok(stats) => HttpResponse::Ok().json(stats),
606        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
607            "error": err.to_string()
608        })),
609    }
610}
611
612/// Get budget variance analysis (budget vs actual)
613#[get("/budgets/{id}/variance")]
614pub async fn get_budget_variance(
615    state: web::Data<AppState>,
616    user: AuthenticatedUser,
617    id: web::Path<Uuid>,
618) -> impl Responder {
619    // Multi-tenant isolation: first check the budget belongs to user's org
620    match state.budget_use_cases.get_budget(*id).await {
621        Ok(Some(budget)) => {
622            if let Err(e) = user.verify_org_access(budget.organization_id) {
623                return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
624            }
625        }
626        Ok(None) => {
627            return HttpResponse::NotFound().json(serde_json::json!({
628                "error": "Budget not found"
629            }));
630        }
631        Err(err) => {
632            return HttpResponse::InternalServerError().json(serde_json::json!({
633                "error": err.to_string()
634            }));
635        }
636    }
637
638    match state.budget_use_cases.get_variance(*id).await {
639        Ok(Some(variance)) => HttpResponse::Ok().json(variance),
640        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
641            "error": "Budget not found"
642        })),
643        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
644            "error": err.to_string()
645        })),
646    }
647}
648
649/// Delete budget
650#[delete("/budgets/{id}")]
651pub async fn delete_budget(
652    state: web::Data<AppState>,
653    user: AuthenticatedUser,
654    id: web::Path<Uuid>,
655) -> impl Responder {
656    // Cloisonnement AVANT la suppression (#864).
657    //
658    // `AuthenticatedUser` était pris à la signature et ne servait qu'à
659    // journaliser QUI avait supprimé, après coup. N'importe quel utilisateur
660    // authentifié pouvait donc effacer le budget de n'importe quelle
661    // copropriété — y compris d'un autre cabinet — en connaissant son UUID, et
662    // le journal d'audit enregistrait fidèlement le geste.
663    //
664    // Le contrôle existe dans ce fichier depuis toujours : `get_budget` le fait
665    // trois cents lignes plus haut. Il manquait ici, sur l'opération
666    // irréversible.
667    match state.budget_use_cases.get_budget(*id).await {
668        Ok(Some(budget)) => {
669            if let Err(e) = user.verify_org_access(budget.organization_id) {
670                return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
671            }
672        }
673        Ok(None) => {
674            return HttpResponse::NotFound().json(serde_json::json!({
675                "error": "Budget not found"
676            }))
677        }
678        Err(err) => {
679            return HttpResponse::InternalServerError().json(serde_json::json!({
680                "error": err.to_string()
681            }))
682        }
683    }
684
685    match state.budget_use_cases.delete_budget(*id).await {
686        Ok(true) => {
687            AuditLogEntry::new(
688                AuditEventType::BudgetDeleted,
689                Some(user.user_id),
690                user.organization_id,
691            )
692            .with_resource("Budget", *id)
693            .log();
694
695            HttpResponse::NoContent().finish()
696        }
697        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
698            "error": "Budget not found"
699        })),
700        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
701            "error": err.to_string()
702        })),
703    }
704}