Skip to main content

koprogo_api/infrastructure/web/handlers/
call_for_funds_handlers.rs

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
15/// Story 3.1 — Vérifie que l'utilisateur peut créer un appel de fonds.
16///
17/// INV-10 : sortie financière → réservé aux émetteurs (syndic, superadmin,
18/// accountant générique, accountant.emetteur). Un `accountant.encodeur` seul
19/// est rejeté (403 `invalid_role`).
20fn 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/// POST /api/v1/call-for-funds
32/// Create a new call for funds
33#[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    // Story 3.1 INV-10 : seuls les émetteurs peuvent créer un appel de fonds.
53    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    // Isolation multi-tenant à l'ÉCRITURE : l'immeuble visé doit relever d'une
66    // ACP dont ce syndic a la gestion. Mesuré le 2026-09-02 : un cabinet tiers
67    // pouvait créer PUIS ENVOYER un appel de fonds sur l'immeuble d'un autre,
68    // générant des quotes-parts réclamées à des copropriétaires qui ne sont
69    // pas les siens.
70    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    // Parse contribution type
82    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            // Track H Story H2 — pre-check validate-before-compute → 422 narratif
116            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/// GET /api/v1/call-for-funds/{id}
125/// Get a call for funds by ID
126#[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    // Cloisonnement : cet appel de fonds relève d'une ACP précise (#772).
145    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/// GET /api/v1/call-for-funds?building_id={uuid}
168/// List all calls for funds for a building or organization
169#[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 building_id provided, filter by building
191    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        // Cloisonnement du FILTRE (#864).
201        //
202        // Ce gestionnaire a deux branches, et une seule cloisonnait. Sans
203        // `building_id`, il rendait `list_by_organization(user.organization_id)`,
204        // correctement borné. AVEC `building_id`, il rendait
205        // `list_by_building(building_id)` — sans aucun contrôle.
206        //
207        // Passer un immeuble d'un autre cabinet en paramètre de requête
208        // suffisait donc à lire ses appels de fonds, c'est-à-dire QUI DOIT
209        // COMBIEN dans une copropriété qu'on ne gère pas.
210        //
211        // C'est une forme que le relevé de #864 ne cherchait pas : le
212        // cloisonnement n'est pas ABSENT du gestionnaire, il est absent d'UNE
213        // de ses branches. Un paramètre facultatif contourne le chemin
214        // protégé, et la route a l'air gardée parce que son cas nominal
215        // l'est.
216        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    // Otherwise, return all calls for user's organization
244    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/// GET /api/v1/call-for-funds/overdue
266/// Get all overdue calls for funds
267#[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    // Cloisonnement (#882) : cette route rendait TOUS les appels de fonds en
284    // retard de l'INSTANCE ENTIÈRE à n'importe quel utilisateur authentifié —
285    // aucun argument, aucune organisation, aucune ACP. C'est une donnée
286    // personnelle à caractère financier (qui doit combien) sur une instance
287    // mutualisée entre plusieurs cabinets syndics.
288    //
289    // `get_overdue_calls` ne peut désormais plus être appelée sans
290    // organisation : le défaut est corrigé à la SIGNATURE du cas d'usage, pas
291    // seulement ici.
292    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/// POST /api/v1/call-for-funds/{id}/send
314/// Send a call for funds (marks as sent and generates individual contributions)
315#[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    // Cloisonnement : cet appel de fonds engage l'argent des copropriétaires
341    // d'une ACP précise. L'envoyer hors de la sienne écrirait à des personnes
342    // qu'on n'a pas à contacter, au nom d'une copropriété qui n'est pas la
343    // sienne. L'identité était prise puis ignorée — `_user` (#772).
344    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            // Get the number of contributions generated
362            // (In a real implementation, we'd return this from send_call_for_funds)
363            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            // Track H Story H2 — pre-check validate-before-compute → 422 narratif
383            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/// PUT /api/v1/call-for-funds/{id}/cancel
392/// Cancel a call for funds
393#[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    // Cloisonnement : cet appel de fonds engage l'argent des copropriétaires
412    // d'une ACP précise. L'envoyer hors de la sienne écrirait à des personnes
413    // qu'on n'a pas à contacter, au nom d'une copropriété qui n'est pas la
414    // sienne. L'identité était prise puis ignorée — `_user` (#772).
415    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/// DELETE /api/v1/call-for-funds/{id}
440/// Delete a call for funds (only if in draft status)
441#[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    // Cloisonnement : cet appel de fonds relève d'une ACP précise (#772).
461    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}