1use crate::application::dto::{
2 CallForFundsResponse, CreateCallForFundsRequest, SendCallForFundsRequest,
3 SendCallForFundsResponse,
4};
5use crate::domain::entities::{ContributionType, UserRole};
6use crate::infrastructure::web::handlers::conformity_response::try_build_conformity_response;
7use crate::infrastructure::web::middleware::scope_guard::{
8 verify_building_org_access, verify_call_for_funds_org_access,
9};
10use crate::infrastructure::web::{AppState, AuthenticatedUser};
11use actix_web::{delete, get, post, put, web, HttpResponse, ResponseError};
12use std::str::FromStr;
13use uuid::Uuid;
14
15fn check_can_create_call_for_funds(user: &AuthenticatedUser) -> Option<HttpResponse> {
21 match UserRole::from_str(&user.role).ok() {
22 Some(role) if role.can_create_call_for_funds() => None,
23 _ => Some(HttpResponse::Forbidden().json(serde_json::json!({
24 "error":
25 "Only syndic, superadmin, or accountant émetteur can create call-for-funds",
26 "code": "invalid_role",
27 }))),
28 }
29}
30
31#[utoipa::path(
34 post,
35 path = "/call-for-funds",
36 tag = "CallForFunds",
37 summary = "Create a collective call for funds (draft)",
38 request_body = CreateCallForFundsRequest,
39 responses(
40 (status = 201, description = "Call for funds created", body = CallForFundsResponse),
41 (status = 400, description = "Validation error, or unknown field in the body"),
42 (status = 401, description = "User does not belong to an organization"),
43 ),
44 security(("bearer_auth" = []))
45)]
46#[post("/call-for-funds")]
47pub async fn create_call_for_funds(
48 state: web::Data<AppState>,
49 user: AuthenticatedUser,
50 req: web::Json<CreateCallForFundsRequest>,
51) -> HttpResponse {
52 if let Some(response) = check_can_create_call_for_funds(&user) {
54 return response;
55 }
56
57 let organization_id = match user.organization_id {
58 Some(org_id) => org_id,
59 None => {
60 return HttpResponse::BadRequest()
61 .json(serde_json::json!({ "error": "Organization ID required" }))
62 }
63 };
64
65 if let Err(err) = verify_building_org_access(
71 &user,
72 req.building_id,
73 &state.building_use_cases,
74 &state.acp_use_cases,
75 )
76 .await
77 {
78 return err.error_response();
79 }
80
81 let contribution_type = match req.contribution_type.as_str() {
83 "regular" => ContributionType::Regular,
84 "extraordinary" => ContributionType::Extraordinary,
85 "advance" => ContributionType::Advance,
86 "adjustment" => ContributionType::Adjustment,
87 _ => {
88 return HttpResponse::BadRequest()
89 .json(serde_json::json!({ "error": "Invalid contribution type" }))
90 }
91 };
92
93 match state
94 .call_for_funds_use_cases
95 .create_call_for_funds(
96 organization_id,
97 req.building_id,
98 req.title.clone(),
99 req.description.clone(),
100 req.total_amount,
101 contribution_type,
102 req.call_date,
103 req.due_date,
104 req.account_code.clone(),
105 Some(user.user_id),
106 req.reserve_fund_share,
107 )
108 .await
109 {
110 Ok(call) => {
111 let response = CallForFundsResponse::from(call);
112 HttpResponse::Created().json(response)
113 }
114 Err(e) => {
115 if let Some(resp) = try_build_conformity_response(&e) {
117 return resp;
118 }
119 HttpResponse::BadRequest().json(serde_json::json!({ "error": e }))
120 }
121 }
122}
123
124#[utoipa::path(
127 get,
128 path = "/call-for-funds/{id}",
129 tag = "CallForFunds",
130 summary = "Get a single call for funds",
131 params(("id" = Uuid, Path, description = "Call for funds identifier")),
132 responses(
133 (status = 200, description = "Call for funds", body = CallForFundsResponse),
134 (status = 404, description = "Not found"),
135 ),
136 security(("bearer_auth" = []))
137)]
138#[get("/call-for-funds/{id}")]
139pub async fn get_call_for_funds(
140 state: web::Data<AppState>,
141 user: AuthenticatedUser,
142 id: web::Path<Uuid>,
143) -> HttpResponse {
144 if let Err(err) = verify_call_for_funds_org_access(
146 &user,
147 *id,
148 &state.call_for_funds_use_cases,
149 &state.acp_use_cases,
150 )
151 .await
152 {
153 return err.error_response();
154 }
155
156 match state.call_for_funds_use_cases.get_call_for_funds(*id).await {
157 Ok(Some(call)) => {
158 let response = CallForFundsResponse::from(call);
159 HttpResponse::Ok().json(response)
160 }
161 Ok(None) => HttpResponse::NotFound()
162 .json(serde_json::json!({ "error": "Call for funds not found" })),
163 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e })),
164 }
165}
166
167#[utoipa::path(
170 get,
171 path = "/call-for-funds",
172 tag = "CallForFunds",
173 summary = "List calls for funds, optionally filtered by building or status",
174 params(
175 ("building_id" = Option<Uuid>, Query, description = "Restrict to one building"),
176 ("status" = Option<String>, Query, description = "draft | sent | overdue | cancelled"),
177 ),
178 responses(
179 (status = 200, description = "Calls for funds", body = Vec<CallForFundsResponse>),
180 (status = 401, description = "User does not belong to an organization"),
181 ),
182 security(("bearer_auth" = []))
183)]
184#[get("/call-for-funds")]
185pub async fn list_call_for_funds(
186 state: web::Data<AppState>,
187 user: AuthenticatedUser,
188 query: web::Query<std::collections::HashMap<String, String>>,
189) -> HttpResponse {
190 if let Some(id_str) = query.get("building_id") {
192 let building_id = match Uuid::parse_str(id_str) {
193 Ok(id) => id,
194 Err(_) => {
195 return HttpResponse::BadRequest()
196 .json(serde_json::json!({ "error": "Invalid building_id format" }))
197 }
198 };
199
200 if let Err(err) = verify_building_org_access(
217 &user,
218 building_id,
219 &state.building_use_cases,
220 &state.acp_use_cases,
221 )
222 .await
223 {
224 return err.error_response();
225 }
226
227 match state
228 .call_for_funds_use_cases
229 .list_by_building(building_id)
230 .await
231 {
232 Ok(calls) => {
233 let responses: Vec<CallForFundsResponse> =
234 calls.into_iter().map(Into::into).collect();
235 return HttpResponse::Ok().json(responses);
236 }
237 Err(e) => {
238 return HttpResponse::InternalServerError().json(serde_json::json!({ "error": e }))
239 }
240 }
241 }
242
243 let organization_id = match user.organization_id {
245 Some(org_id) => org_id,
246 None => {
247 return HttpResponse::BadRequest()
248 .json(serde_json::json!({ "error": "Organization ID required" }))
249 }
250 };
251
252 match state
253 .call_for_funds_use_cases
254 .list_by_organization(organization_id)
255 .await
256 {
257 Ok(calls) => {
258 let responses: Vec<CallForFundsResponse> = calls.into_iter().map(Into::into).collect();
259 HttpResponse::Ok().json(responses)
260 }
261 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e })),
262 }
263}
264
265#[utoipa::path(
268 get,
269 path = "/call-for-funds/overdue",
270 tag = "CallForFunds",
271 summary = "List overdue calls for funds",
272 responses(
273 (status = 200, description = "Overdue calls", body = Vec<CallForFundsResponse>),
274 (status = 401, description = "User does not belong to an organization"),
275 ),
276 security(("bearer_auth" = []))
277)]
278#[get("/call-for-funds/overdue")]
279pub async fn get_overdue_calls(
280 state: web::Data<AppState>,
281 user: AuthenticatedUser,
282) -> HttpResponse {
283 let organization_id = match user.organization_id {
293 Some(org_id) => org_id,
294 None => {
295 return HttpResponse::BadRequest()
296 .json(serde_json::json!({ "error": "Organization ID required" }))
297 }
298 };
299
300 match state
301 .call_for_funds_use_cases
302 .get_overdue_calls(organization_id)
303 .await
304 {
305 Ok(calls) => {
306 let responses: Vec<CallForFundsResponse> = calls.into_iter().map(Into::into).collect();
307 HttpResponse::Ok().json(responses)
308 }
309 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e })),
310 }
311}
312
313#[utoipa::path(
316 post,
317 path = "/call-for-funds/{id}/send",
318 tag = "CallForFunds",
319 summary = "Send a call for funds and generate individual contributions",
320 description = "Ventile le montant total entre les coproprietaires ACTIFS du \
321batiment, au prorata de leurs quotites. Les detentions sont lues dans \
322`unit_owners` (routes `/unit-owners`), PAS dans le champ deprecie \
323`units.owner_id` : un batiment dont les lots n'ont pas de detenteur actif \
324enregistre la echoue avec « No active owners found for this building ».",
325 params(("id" = Uuid, Path, description = "Call for funds identifier")),
326 responses(
327 (status = 200, description = "Sent, contributions generated", body = SendCallForFundsResponse),
328 (status = 400, description = "No active owners, or building not conformant"),
329 (status = 404, description = "Not found"),
330 ),
331 security(("bearer_auth" = []))
332)]
333#[post("/call-for-funds/{id}/send")]
334pub async fn send_call_for_funds(
335 state: web::Data<AppState>,
336 user: AuthenticatedUser,
337 id: web::Path<Uuid>,
338 _req: web::Json<SendCallForFundsRequest>,
339) -> HttpResponse {
340 if let Err(err) = verify_call_for_funds_org_access(
345 &user,
346 *id,
347 &state.call_for_funds_use_cases,
348 &state.acp_use_cases,
349 )
350 .await
351 {
352 return err.error_response();
353 }
354
355 match state
356 .call_for_funds_use_cases
357 .send_call_for_funds(*id)
358 .await
359 {
360 Ok(call) => {
361 let contributions_generated = match state
364 .owner_contribution_use_cases
365 .get_contributions_by_organization(call.organization_id)
366 .await
367 {
368 Ok(contribs) => contribs
369 .iter()
370 .filter(|c| c.call_for_funds_id == Some(call.id))
371 .count(),
372 Err(_) => 0,
373 };
374
375 let response = SendCallForFundsResponse {
376 call_for_funds: CallForFundsResponse::from(call),
377 contributions_generated,
378 };
379 HttpResponse::Ok().json(response)
380 }
381 Err(e) => {
382 if let Some(resp) = try_build_conformity_response(&e) {
384 return resp;
385 }
386 HttpResponse::BadRequest().json(serde_json::json!({ "error": e }))
387 }
388 }
389}
390
391#[utoipa::path(
394 put,
395 path = "/call-for-funds/{id}/cancel",
396 tag = "CallForFunds",
397 summary = "Cancel a call for funds",
398 params(("id" = Uuid, Path, description = "Call for funds identifier")),
399 responses(
400 (status = 200, description = "Cancelled", body = CallForFundsResponse),
401 (status = 404, description = "Not found"),
402 ),
403 security(("bearer_auth" = []))
404)]
405#[put("/call-for-funds/{id}/cancel")]
406pub async fn cancel_call_for_funds(
407 state: web::Data<AppState>,
408 user: AuthenticatedUser,
409 id: web::Path<Uuid>,
410) -> HttpResponse {
411 if let Err(err) = verify_call_for_funds_org_access(
416 &user,
417 *id,
418 &state.call_for_funds_use_cases,
419 &state.acp_use_cases,
420 )
421 .await
422 {
423 return err.error_response();
424 }
425
426 match state
427 .call_for_funds_use_cases
428 .cancel_call_for_funds(*id)
429 .await
430 {
431 Ok(call) => {
432 let response = CallForFundsResponse::from(call);
433 HttpResponse::Ok().json(response)
434 }
435 Err(e) => HttpResponse::BadRequest().json(serde_json::json!({ "error": e })),
436 }
437}
438
439#[utoipa::path(
442 delete,
443 path = "/call-for-funds/{id}",
444 tag = "CallForFunds",
445 summary = "Delete a draft call for funds",
446 params(("id" = Uuid, Path, description = "Call for funds identifier")),
447 responses(
448 (status = 204, description = "Deleted"),
449 (status = 400, description = "Only a draft can be deleted"),
450 (status = 404, description = "Not found"),
451 ),
452 security(("bearer_auth" = []))
453)]
454#[delete("/call-for-funds/{id}")]
455pub async fn delete_call_for_funds(
456 state: web::Data<AppState>,
457 user: AuthenticatedUser,
458 id: web::Path<Uuid>,
459) -> HttpResponse {
460 if let Err(err) = verify_call_for_funds_org_access(
462 &user,
463 *id,
464 &state.call_for_funds_use_cases,
465 &state.acp_use_cases,
466 )
467 .await
468 {
469 return err.error_response();
470 }
471
472 match state
473 .call_for_funds_use_cases
474 .delete_call_for_funds(*id)
475 .await
476 {
477 Ok(true) => HttpResponse::NoContent().finish(),
478 Ok(false) => HttpResponse::NotFound()
479 .json(serde_json::json!({ "error": "Call for funds not found" })),
480 Err(e) => HttpResponse::BadRequest().json(serde_json::json!({ "error": e })),
481 }
482}