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(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("/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("/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#[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#[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#[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#[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 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#[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#[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#[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#[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#[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("/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("/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("/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}