koprogo_api/infrastructure/web/handlers/
budget_handlers.rs1use 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#[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 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("/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 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("/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 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("/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 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#[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 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#[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#[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#[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 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#[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#[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#[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#[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#[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("/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("/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 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("/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}