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::{AppState, AuthenticatedUser};
7use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
8use uuid::Uuid;
9
10/// Create a new budget
11#[post("/budgets")]
12pub async fn create_budget(
13    state: web::Data<AppState>,
14    user: AuthenticatedUser,
15    mut request: web::Json<CreateBudgetRequest>,
16) -> impl Responder {
17    // Override organization_id from JWT token (security)
18    let organization_id = match user.require_organization() {
19        Ok(org_id) => org_id,
20        Err(e) => {
21            return HttpResponse::Unauthorized().json(serde_json::json!({
22                "error": e.to_string()
23            }))
24        }
25    };
26    request.organization_id = organization_id;
27
28    match state
29        .budget_use_cases
30        .create_budget(request.into_inner())
31        .await
32    {
33        Ok(budget) => {
34            AuditLogEntry::new(
35                AuditEventType::BudgetCreated,
36                Some(user.user_id),
37                Some(organization_id),
38            )
39            .with_resource("Budget", budget.id)
40            .log();
41
42            HttpResponse::Created().json(budget)
43        }
44        Err(err) => {
45            AuditLogEntry::new(
46                AuditEventType::BudgetCreated,
47                Some(user.user_id),
48                Some(organization_id),
49            )
50            .with_error(err.clone())
51            .log();
52
53            HttpResponse::BadRequest().json(serde_json::json!({
54                "error": err
55            }))
56        }
57    }
58}
59
60/// Get budget by ID
61#[get("/budgets/{id}")]
62pub async fn get_budget(
63    state: web::Data<AppState>,
64    user: AuthenticatedUser,
65    id: web::Path<Uuid>,
66) -> impl Responder {
67    match state.budget_use_cases.get_budget(*id).await {
68        Ok(Some(budget)) => {
69            // Multi-tenant isolation: verify budget belongs to user's organization
70            if let Err(e) = user.verify_org_access(budget.organization_id) {
71                return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
72            }
73            HttpResponse::Ok().json(budget)
74        }
75        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
76            "error": "Budget not found"
77        })),
78        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
79            "error": err
80        })),
81    }
82}
83
84/// Get budget by building and fiscal year
85#[get("/buildings/{building_id}/budgets/fiscal-year/{fiscal_year}")]
86pub async fn get_budget_by_building_and_fiscal_year(
87    state: web::Data<AppState>,
88    user: AuthenticatedUser,
89    params: web::Path<(Uuid, i32)>,
90) -> impl Responder {
91    let (building_id, fiscal_year) = params.into_inner();
92
93    // Multi-tenant isolation: verify building belongs to user's organization
94    match state.building_use_cases.get_building(building_id).await {
95        Ok(Some(building)) => {
96            if let Ok(building_org) = Uuid::parse_str(&building.organization_id) {
97                if let Err(e) = user.verify_org_access(building_org) {
98                    return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
99                }
100            }
101        }
102        Ok(None) => {
103            return HttpResponse::NotFound().json(serde_json::json!({
104                "error": "Building not found"
105            }));
106        }
107        Err(err) => {
108            return HttpResponse::InternalServerError().json(serde_json::json!({
109                "error": err
110            }));
111        }
112    }
113
114    match state
115        .budget_use_cases
116        .get_by_building_and_fiscal_year(building_id, fiscal_year)
117        .await
118    {
119        Ok(Some(budget)) => HttpResponse::Ok().json(budget),
120        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
121            "error": "Budget not found"
122        })),
123        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
124            "error": err
125        })),
126    }
127}
128
129/// Get active budget for a building
130#[get("/buildings/{building_id}/budgets/active")]
131pub async fn get_active_budget(
132    state: web::Data<AppState>,
133    user: AuthenticatedUser,
134    building_id: web::Path<Uuid>,
135) -> impl Responder {
136    // Multi-tenant isolation: verify building belongs to user's organization
137    match state.building_use_cases.get_building(*building_id).await {
138        Ok(Some(building)) => {
139            if let Ok(building_org) = Uuid::parse_str(&building.organization_id) {
140                if let Err(e) = user.verify_org_access(building_org) {
141                    return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
142                }
143            }
144        }
145        Ok(None) => {
146            return HttpResponse::NotFound().json(serde_json::json!({
147                "error": "Building not found"
148            }));
149        }
150        Err(err) => {
151            return HttpResponse::InternalServerError().json(serde_json::json!({
152                "error": err
153            }));
154        }
155    }
156
157    match state.budget_use_cases.get_active_budget(*building_id).await {
158        Ok(Some(budget)) => HttpResponse::Ok().json(budget),
159        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
160            "error": "No active budget found for this building"
161        })),
162        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
163            "error": err
164        })),
165    }
166}
167
168/// List budgets for a building
169#[get("/buildings/{building_id}/budgets")]
170pub async fn list_budgets_by_building(
171    state: web::Data<AppState>,
172    user: AuthenticatedUser,
173    building_id: web::Path<Uuid>,
174) -> impl Responder {
175    // Multi-tenant isolation: verify building belongs to user's organization
176    match state.building_use_cases.get_building(*building_id).await {
177        Ok(Some(building)) => {
178            if let Ok(building_org) = Uuid::parse_str(&building.organization_id) {
179                if let Err(e) = user.verify_org_access(building_org) {
180                    return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
181                }
182            }
183        }
184        Ok(None) => {
185            return HttpResponse::NotFound().json(serde_json::json!({
186                "error": "Building not found"
187            }));
188        }
189        Err(err) => {
190            return HttpResponse::InternalServerError().json(serde_json::json!({
191                "error": err
192            }));
193        }
194    }
195
196    match state.budget_use_cases.list_by_building(*building_id).await {
197        Ok(budgets) => HttpResponse::Ok().json(budgets),
198        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
199            "error": err
200        })),
201    }
202}
203
204/// List budgets by fiscal year
205#[get("/budgets/fiscal-year/{fiscal_year}")]
206pub async fn list_budgets_by_fiscal_year(
207    state: web::Data<AppState>,
208    user: AuthenticatedUser,
209    fiscal_year: web::Path<i32>,
210) -> impl Responder {
211    let organization_id = match user.require_organization() {
212        Ok(org_id) => org_id,
213        Err(e) => {
214            return HttpResponse::Unauthorized().json(serde_json::json!({
215                "error": e.to_string()
216            }))
217        }
218    };
219
220    match state
221        .budget_use_cases
222        .list_by_fiscal_year(organization_id, *fiscal_year)
223        .await
224    {
225        Ok(budgets) => HttpResponse::Ok().json(budgets),
226        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
227            "error": err
228        })),
229    }
230}
231
232/// List budgets by status
233#[get("/budgets/status/{status}")]
234pub async fn list_budgets_by_status(
235    state: web::Data<AppState>,
236    user: AuthenticatedUser,
237    status: web::Path<String>,
238) -> impl Responder {
239    let organization_id = match user.require_organization() {
240        Ok(org_id) => org_id,
241        Err(e) => {
242            return HttpResponse::Unauthorized().json(serde_json::json!({
243                "error": e.to_string()
244            }))
245        }
246    };
247
248    let budget_status = match status.as_str() {
249        "draft" => BudgetStatus::Draft,
250        "submitted" => BudgetStatus::Submitted,
251        "approved" => BudgetStatus::Approved,
252        "rejected" => BudgetStatus::Rejected,
253        "archived" => BudgetStatus::Archived,
254        _ => {
255            return HttpResponse::BadRequest().json(serde_json::json!({
256                "error": "Invalid status"
257            }))
258        }
259    };
260
261    match state
262        .budget_use_cases
263        .list_by_status(organization_id, budget_status)
264        .await
265    {
266        Ok(budgets) => HttpResponse::Ok().json(budgets),
267        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
268            "error": err
269        })),
270    }
271}
272
273/// List budgets paginated
274#[get("/budgets")]
275pub async fn list_budgets(
276    state: web::Data<AppState>,
277    user: AuthenticatedUser,
278    page_request: web::Query<PageRequest>,
279    filters: web::Query<serde_json::Value>,
280) -> impl Responder {
281    let organization_id = user.organization_id;
282
283    // Parse optional filters
284    let building_id = filters
285        .get("building_id")
286        .and_then(|v| v.as_str())
287        .and_then(|s| Uuid::parse_str(s).ok());
288
289    let status = filters
290        .get("status")
291        .and_then(|v| v.as_str())
292        .and_then(|s| match s {
293            "draft" => Some(BudgetStatus::Draft),
294            "submitted" => Some(BudgetStatus::Submitted),
295            "approved" => Some(BudgetStatus::Approved),
296            "rejected" => Some(BudgetStatus::Rejected),
297            "archived" => Some(BudgetStatus::Archived),
298            _ => None,
299        });
300
301    match state
302        .budget_use_cases
303        .list_paginated(&page_request, organization_id, building_id, status)
304        .await
305    {
306        Ok((budgets, total)) => {
307            let response =
308                PageResponse::new(budgets, page_request.page, page_request.per_page, total);
309            HttpResponse::Ok().json(response)
310        }
311        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
312            "error": err
313        })),
314    }
315}
316
317/// Update budget (Draft only)
318#[put("/budgets/{id}")]
319pub async fn update_budget(
320    state: web::Data<AppState>,
321    user: AuthenticatedUser,
322    id: web::Path<Uuid>,
323    request: web::Json<UpdateBudgetRequest>,
324) -> impl Responder {
325    match state
326        .budget_use_cases
327        .update_budget(*id, request.into_inner())
328        .await
329    {
330        Ok(budget) => {
331            AuditLogEntry::new(
332                AuditEventType::BudgetUpdated,
333                Some(user.user_id),
334                user.organization_id,
335            )
336            .with_resource("Budget", budget.id)
337            .log();
338
339            HttpResponse::Ok().json(budget)
340        }
341        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
342            "error": err
343        })),
344    }
345}
346
347/// Submit budget for approval
348#[put("/budgets/{id}/submit")]
349pub async fn submit_budget(
350    state: web::Data<AppState>,
351    user: AuthenticatedUser,
352    id: web::Path<Uuid>,
353) -> impl Responder {
354    match state.budget_use_cases.submit_for_approval(*id).await {
355        Ok(budget) => {
356            AuditLogEntry::new(
357                AuditEventType::BudgetSubmitted,
358                Some(user.user_id),
359                user.organization_id,
360            )
361            .with_resource("Budget", budget.id)
362            .log();
363
364            HttpResponse::Ok().json(budget)
365        }
366        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
367            "error": err
368        })),
369    }
370}
371
372/// Approve budget (requires meeting_id)
373#[put("/budgets/{id}/approve")]
374pub async fn approve_budget(
375    state: web::Data<AppState>,
376    user: AuthenticatedUser,
377    id: web::Path<Uuid>,
378    payload: web::Json<serde_json::Value>,
379) -> impl Responder {
380    let meeting_id = match payload.get("meeting_id") {
381        Some(serde_json::Value::String(id_str)) => match Uuid::parse_str(id_str) {
382            Ok(uuid) => uuid,
383            Err(_) => {
384                return HttpResponse::BadRequest().json(serde_json::json!({
385                    "error": "Invalid meeting_id format"
386                }))
387            }
388        },
389        _ => {
390            return HttpResponse::BadRequest().json(serde_json::json!({
391                "error": "meeting_id is required as a UUID string"
392            }))
393        }
394    };
395
396    match state.budget_use_cases.approve_budget(*id, meeting_id).await {
397        Ok(budget) => {
398            AuditLogEntry::new(
399                AuditEventType::BudgetApproved,
400                Some(user.user_id),
401                user.organization_id,
402            )
403            .with_resource("Budget", budget.id)
404            .with_metadata(serde_json::json!({"meeting_id": meeting_id}))
405            .log();
406
407            HttpResponse::Ok().json(budget)
408        }
409        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
410            "error": err
411        })),
412    }
413}
414
415/// Reject budget (with optional reason)
416#[put("/budgets/{id}/reject")]
417pub async fn reject_budget(
418    state: web::Data<AppState>,
419    user: AuthenticatedUser,
420    id: web::Path<Uuid>,
421    payload: web::Json<serde_json::Value>,
422) -> impl Responder {
423    let reason = payload
424        .get("reason")
425        .and_then(|v| v.as_str())
426        .map(|s| s.to_string());
427
428    match state.budget_use_cases.reject_budget(*id, reason).await {
429        Ok(budget) => {
430            AuditLogEntry::new(
431                AuditEventType::BudgetRejected,
432                Some(user.user_id),
433                user.organization_id,
434            )
435            .with_resource("Budget", budget.id)
436            .log();
437
438            HttpResponse::Ok().json(budget)
439        }
440        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
441            "error": err
442        })),
443    }
444}
445
446/// Archive budget
447#[put("/budgets/{id}/archive")]
448pub async fn archive_budget(
449    state: web::Data<AppState>,
450    user: AuthenticatedUser,
451    id: web::Path<Uuid>,
452) -> impl Responder {
453    match state.budget_use_cases.archive_budget(*id).await {
454        Ok(budget) => {
455            AuditLogEntry::new(
456                AuditEventType::BudgetArchived,
457                Some(user.user_id),
458                user.organization_id,
459            )
460            .with_resource("Budget", budget.id)
461            .log();
462
463            HttpResponse::Ok().json(budget)
464        }
465        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
466            "error": err
467        })),
468    }
469}
470
471/// Get budget statistics
472#[get("/budgets/stats")]
473pub async fn get_budget_stats(
474    state: web::Data<AppState>,
475    user: AuthenticatedUser,
476) -> impl Responder {
477    let organization_id = match user.require_organization() {
478        Ok(org_id) => org_id,
479        Err(e) => {
480            return HttpResponse::Unauthorized().json(serde_json::json!({
481                "error": e.to_string()
482            }))
483        }
484    };
485
486    match state.budget_use_cases.get_stats(organization_id).await {
487        Ok(stats) => HttpResponse::Ok().json(stats),
488        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
489            "error": err
490        })),
491    }
492}
493
494/// Get budget variance analysis (budget vs actual)
495#[get("/budgets/{id}/variance")]
496pub async fn get_budget_variance(
497    state: web::Data<AppState>,
498    user: AuthenticatedUser,
499    id: web::Path<Uuid>,
500) -> impl Responder {
501    // Multi-tenant isolation: first check the budget belongs to user's org
502    match state.budget_use_cases.get_budget(*id).await {
503        Ok(Some(budget)) => {
504            if let Err(e) = user.verify_org_access(budget.organization_id) {
505                return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
506            }
507        }
508        Ok(None) => {
509            return HttpResponse::NotFound().json(serde_json::json!({
510                "error": "Budget not found"
511            }));
512        }
513        Err(err) => {
514            return HttpResponse::InternalServerError().json(serde_json::json!({
515                "error": err
516            }));
517        }
518    }
519
520    match state.budget_use_cases.get_variance(*id).await {
521        Ok(Some(variance)) => HttpResponse::Ok().json(variance),
522        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
523            "error": "Budget not found"
524        })),
525        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
526            "error": err
527        })),
528    }
529}
530
531/// Delete budget
532#[delete("/budgets/{id}")]
533pub async fn delete_budget(
534    state: web::Data<AppState>,
535    user: AuthenticatedUser,
536    id: web::Path<Uuid>,
537) -> impl Responder {
538    match state.budget_use_cases.delete_budget(*id).await {
539        Ok(true) => {
540            AuditLogEntry::new(
541                AuditEventType::BudgetDeleted,
542                Some(user.user_id),
543                user.organization_id,
544            )
545            .with_resource("Budget", *id)
546            .log();
547
548            HttpResponse::NoContent().finish()
549        }
550        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
551            "error": "Budget not found"
552        })),
553        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
554            "error": err
555        })),
556    }
557}