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
12async 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#[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 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 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("/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 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("/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 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("/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 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#[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 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#[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#[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#[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 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#[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 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#[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 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#[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 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#[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 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#[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 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("/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("/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 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("/budgets/{id}")]
651pub async fn delete_budget(
652 state: web::Data<AppState>,
653 user: AuthenticatedUser,
654 id: web::Path<Uuid>,
655) -> impl Responder {
656 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}