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(state: web::Data<AppState>, id: web::Path<Uuid>) -> impl Responder {
63    match state.budget_use_cases.get_budget(*id).await {
64        Ok(Some(budget)) => HttpResponse::Ok().json(budget),
65        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
66            "error": "Budget not found"
67        })),
68        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
69            "error": err
70        })),
71    }
72}
73
74/// Get budget by building and fiscal year
75#[get("/buildings/{building_id}/budgets/fiscal-year/{fiscal_year}")]
76pub async fn get_budget_by_building_and_fiscal_year(
77    state: web::Data<AppState>,
78    params: web::Path<(Uuid, i32)>,
79) -> impl Responder {
80    let (building_id, fiscal_year) = params.into_inner();
81
82    match state
83        .budget_use_cases
84        .get_by_building_and_fiscal_year(building_id, fiscal_year)
85        .await
86    {
87        Ok(Some(budget)) => HttpResponse::Ok().json(budget),
88        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
89            "error": "Budget not found"
90        })),
91        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
92            "error": err
93        })),
94    }
95}
96
97/// Get active budget for a building
98#[get("/buildings/{building_id}/budgets/active")]
99pub async fn get_active_budget(
100    state: web::Data<AppState>,
101    building_id: web::Path<Uuid>,
102) -> impl Responder {
103    match state.budget_use_cases.get_active_budget(*building_id).await {
104        Ok(Some(budget)) => HttpResponse::Ok().json(budget),
105        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
106            "error": "No active budget found for this building"
107        })),
108        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
109            "error": err
110        })),
111    }
112}
113
114/// List budgets for a building
115#[get("/buildings/{building_id}/budgets")]
116pub async fn list_budgets_by_building(
117    state: web::Data<AppState>,
118    building_id: web::Path<Uuid>,
119) -> impl Responder {
120    match state.budget_use_cases.list_by_building(*building_id).await {
121        Ok(budgets) => HttpResponse::Ok().json(budgets),
122        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
123            "error": err
124        })),
125    }
126}
127
128/// List budgets by fiscal year
129#[get("/budgets/fiscal-year/{fiscal_year}")]
130pub async fn list_budgets_by_fiscal_year(
131    state: web::Data<AppState>,
132    user: AuthenticatedUser,
133    fiscal_year: web::Path<i32>,
134) -> impl Responder {
135    let organization_id = match user.require_organization() {
136        Ok(org_id) => org_id,
137        Err(e) => {
138            return HttpResponse::Unauthorized().json(serde_json::json!({
139                "error": e.to_string()
140            }))
141        }
142    };
143
144    match state
145        .budget_use_cases
146        .list_by_fiscal_year(organization_id, *fiscal_year)
147        .await
148    {
149        Ok(budgets) => HttpResponse::Ok().json(budgets),
150        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
151            "error": err
152        })),
153    }
154}
155
156/// List budgets by status
157#[get("/budgets/status/{status}")]
158pub async fn list_budgets_by_status(
159    state: web::Data<AppState>,
160    user: AuthenticatedUser,
161    status: web::Path<String>,
162) -> impl Responder {
163    let organization_id = match user.require_organization() {
164        Ok(org_id) => org_id,
165        Err(e) => {
166            return HttpResponse::Unauthorized().json(serde_json::json!({
167                "error": e.to_string()
168            }))
169        }
170    };
171
172    let budget_status = match status.as_str() {
173        "draft" => BudgetStatus::Draft,
174        "submitted" => BudgetStatus::Submitted,
175        "approved" => BudgetStatus::Approved,
176        "rejected" => BudgetStatus::Rejected,
177        "archived" => BudgetStatus::Archived,
178        _ => {
179            return HttpResponse::BadRequest().json(serde_json::json!({
180                "error": "Invalid status"
181            }))
182        }
183    };
184
185    match state
186        .budget_use_cases
187        .list_by_status(organization_id, budget_status)
188        .await
189    {
190        Ok(budgets) => HttpResponse::Ok().json(budgets),
191        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
192            "error": err
193        })),
194    }
195}
196
197/// List budgets paginated
198#[get("/budgets")]
199pub async fn list_budgets(
200    state: web::Data<AppState>,
201    user: AuthenticatedUser,
202    page_request: web::Query<PageRequest>,
203    filters: web::Query<serde_json::Value>,
204) -> impl Responder {
205    let organization_id = user.organization_id;
206
207    // Parse optional filters
208    let building_id = filters
209        .get("building_id")
210        .and_then(|v| v.as_str())
211        .and_then(|s| Uuid::parse_str(s).ok());
212
213    let status = filters
214        .get("status")
215        .and_then(|v| v.as_str())
216        .and_then(|s| match s {
217            "draft" => Some(BudgetStatus::Draft),
218            "submitted" => Some(BudgetStatus::Submitted),
219            "approved" => Some(BudgetStatus::Approved),
220            "rejected" => Some(BudgetStatus::Rejected),
221            "archived" => Some(BudgetStatus::Archived),
222            _ => None,
223        });
224
225    match state
226        .budget_use_cases
227        .list_paginated(&page_request, organization_id, building_id, status)
228        .await
229    {
230        Ok((budgets, total)) => {
231            let response =
232                PageResponse::new(budgets, page_request.page, page_request.per_page, total);
233            HttpResponse::Ok().json(response)
234        }
235        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
236            "error": err
237        })),
238    }
239}
240
241/// Update budget (Draft only)
242#[put("/budgets/{id}")]
243pub async fn update_budget(
244    state: web::Data<AppState>,
245    user: AuthenticatedUser,
246    id: web::Path<Uuid>,
247    request: web::Json<UpdateBudgetRequest>,
248) -> impl Responder {
249    match state
250        .budget_use_cases
251        .update_budget(*id, request.into_inner())
252        .await
253    {
254        Ok(budget) => {
255            AuditLogEntry::new(
256                AuditEventType::BudgetUpdated,
257                Some(user.user_id),
258                user.organization_id,
259            )
260            .with_resource("Budget", budget.id)
261            .log();
262
263            HttpResponse::Ok().json(budget)
264        }
265        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
266            "error": err
267        })),
268    }
269}
270
271/// Submit budget for approval
272#[put("/budgets/{id}/submit")]
273pub async fn submit_budget(
274    state: web::Data<AppState>,
275    user: AuthenticatedUser,
276    id: web::Path<Uuid>,
277) -> impl Responder {
278    match state.budget_use_cases.submit_for_approval(*id).await {
279        Ok(budget) => {
280            AuditLogEntry::new(
281                AuditEventType::BudgetSubmitted,
282                Some(user.user_id),
283                user.organization_id,
284            )
285            .with_resource("Budget", budget.id)
286            .log();
287
288            HttpResponse::Ok().json(budget)
289        }
290        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
291            "error": err
292        })),
293    }
294}
295
296/// Approve budget (requires meeting_id)
297#[put("/budgets/{id}/approve")]
298pub async fn approve_budget(
299    state: web::Data<AppState>,
300    user: AuthenticatedUser,
301    id: web::Path<Uuid>,
302    payload: web::Json<serde_json::Value>,
303) -> impl Responder {
304    let meeting_id = match payload.get("meeting_id") {
305        Some(serde_json::Value::String(id_str)) => match Uuid::parse_str(id_str) {
306            Ok(uuid) => uuid,
307            Err(_) => {
308                return HttpResponse::BadRequest().json(serde_json::json!({
309                    "error": "Invalid meeting_id format"
310                }))
311            }
312        },
313        _ => {
314            return HttpResponse::BadRequest().json(serde_json::json!({
315                "error": "meeting_id is required as a UUID string"
316            }))
317        }
318    };
319
320    match state.budget_use_cases.approve_budget(*id, meeting_id).await {
321        Ok(budget) => {
322            AuditLogEntry::new(
323                AuditEventType::BudgetApproved,
324                Some(user.user_id),
325                user.organization_id,
326            )
327            .with_resource("Budget", budget.id)
328            .with_metadata(serde_json::json!({"meeting_id": meeting_id}))
329            .log();
330
331            HttpResponse::Ok().json(budget)
332        }
333        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
334            "error": err
335        })),
336    }
337}
338
339/// Reject budget (with optional reason)
340#[put("/budgets/{id}/reject")]
341pub async fn reject_budget(
342    state: web::Data<AppState>,
343    user: AuthenticatedUser,
344    id: web::Path<Uuid>,
345    payload: web::Json<serde_json::Value>,
346) -> impl Responder {
347    let reason = payload
348        .get("reason")
349        .and_then(|v| v.as_str())
350        .map(|s| s.to_string());
351
352    match state.budget_use_cases.reject_budget(*id, reason).await {
353        Ok(budget) => {
354            AuditLogEntry::new(
355                AuditEventType::BudgetRejected,
356                Some(user.user_id),
357                user.organization_id,
358            )
359            .with_resource("Budget", budget.id)
360            .log();
361
362            HttpResponse::Ok().json(budget)
363        }
364        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
365            "error": err
366        })),
367    }
368}
369
370/// Archive budget
371#[put("/budgets/{id}/archive")]
372pub async fn archive_budget(
373    state: web::Data<AppState>,
374    user: AuthenticatedUser,
375    id: web::Path<Uuid>,
376) -> impl Responder {
377    match state.budget_use_cases.archive_budget(*id).await {
378        Ok(budget) => {
379            AuditLogEntry::new(
380                AuditEventType::BudgetArchived,
381                Some(user.user_id),
382                user.organization_id,
383            )
384            .with_resource("Budget", budget.id)
385            .log();
386
387            HttpResponse::Ok().json(budget)
388        }
389        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
390            "error": err
391        })),
392    }
393}
394
395/// Get budget statistics
396#[get("/budgets/stats")]
397pub async fn get_budget_stats(
398    state: web::Data<AppState>,
399    user: AuthenticatedUser,
400) -> impl Responder {
401    let organization_id = match user.require_organization() {
402        Ok(org_id) => org_id,
403        Err(e) => {
404            return HttpResponse::Unauthorized().json(serde_json::json!({
405                "error": e.to_string()
406            }))
407        }
408    };
409
410    match state.budget_use_cases.get_stats(organization_id).await {
411        Ok(stats) => HttpResponse::Ok().json(stats),
412        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
413            "error": err
414        })),
415    }
416}
417
418/// Get budget variance analysis (budget vs actual)
419#[get("/budgets/{id}/variance")]
420pub async fn get_budget_variance(
421    state: web::Data<AppState>,
422    id: web::Path<Uuid>,
423) -> impl Responder {
424    match state.budget_use_cases.get_variance(*id).await {
425        Ok(Some(variance)) => HttpResponse::Ok().json(variance),
426        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
427            "error": "Budget not found"
428        })),
429        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
430            "error": err
431        })),
432    }
433}
434
435/// Delete budget
436#[delete("/budgets/{id}")]
437pub async fn delete_budget(
438    state: web::Data<AppState>,
439    user: AuthenticatedUser,
440    id: web::Path<Uuid>,
441) -> impl Responder {
442    match state.budget_use_cases.delete_budget(*id).await {
443        Ok(true) => {
444            AuditLogEntry::new(
445                AuditEventType::BudgetDeleted,
446                Some(user.user_id),
447                user.organization_id,
448            )
449            .with_resource("Budget", *id)
450            .log();
451
452            HttpResponse::NoContent().finish()
453        }
454        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
455            "error": "Budget not found"
456        })),
457        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
458            "error": err
459        })),
460    }
461}