Skip to main content

koprogo_api/infrastructure/web/handlers/
owner_contribution_handlers.rs

1use crate::application::dto::{
2    CreateOwnerContributionRequest, OwnerContributionResponse, RecordPaymentRequest,
3};
4use crate::infrastructure::web::middleware::scope_guard::{
5    verify_contribution_org_access, verify_owner_org_access,
6};
7use crate::infrastructure::web::{AppState, AuthenticatedUser};
8use actix_web::{get, post, put, web, HttpResponse, ResponseError};
9use uuid::Uuid;
10
11/// Distingue, pour ce corps de requête, un JSON malformé (`@negative`
12/// générique, 400 — le corps n'est pas exploitable) d'un JSON valide qui ne
13/// respecte pas le schéma déclaré (422 — un champ requis manque, un champ
14/// inconnu traîne, ou un type ne correspond pas).
15///
16/// Issue #852 : `CreateOwnerContributionRequest::unit_id` est redevenu
17/// obligatoire, mais le refus qu'il provoque doit apprendre au client la
18/// règle de la signature, pas se confondre avec « je n'ai pas su lire du
19/// JSON ». D'où l'extraction en deux temps : `web::Json<Value>` capte encore
20/// la syntaxe JSON et le Content-Type (comportement inchangé, 400 en cas de
21/// souci) ; c'est seulement la conversion vers le type précis qui distingue
22/// le 422 de validation.
23/// `Box<HttpResponse>` et non `HttpResponse` : clippy refuse un variant
24/// `Err` de 128 octets (`result_large_err`), et il a raison — chaque appel
25/// déplacerait la réponse entière sur la pile, y compris sur le chemin
26/// nominal où elle n'existe pas.
27///
28/// L'alternative aurait été de désactiver la règle. Elle vaut mieux qu'un
29/// `allow` : le coût réel est un déréférencement sur le chemin d'ERREUR,
30/// c'est-à-dire là où la performance n'a jamais compté.
31type RefusDeValidation = Box<HttpResponse>;
32
33fn parse_create_contribution_request(
34    body: serde_json::Value,
35) -> Result<CreateOwnerContributionRequest, RefusDeValidation> {
36    serde_json::from_value(body).map_err(|e| {
37        Box::new(HttpResponse::UnprocessableEntity().json(serde_json::json!({
38            "error": "Validation error: the request body does not match the schema",
39            "details": e.to_string(),
40        })))
41    })
42}
43
44/// POST /api/v1/owner-contributions
45/// Create a new owner contribution
46#[utoipa::path(
47    post,
48    path = "/owner-contributions",
49    tag = "OwnerContributions",
50    summary = "Create an owner contribution (quote-part)",
51    request_body = CreateOwnerContributionRequest,
52    responses(
53        (status = 201, description = "Contribution created", body = OwnerContributionResponse),
54        (status = 400, description = "Malformed JSON body, or wrong Content-Type"),
55        (status = 401, description = "User does not belong to an organization"),
56        (status = 422, description = "Body does not match the schema (e.g. unit_id missing) — see Issue #852"),
57    ),
58    security(("bearer_auth" = []))
59)]
60#[post("/owner-contributions")]
61pub async fn create_contribution(
62    state: web::Data<AppState>,
63    user: AuthenticatedUser,
64    body: web::Json<serde_json::Value>,
65) -> HttpResponse {
66    // Get organization_id from user (required for creating contributions)
67    let organization_id = match user.organization_id {
68        Some(org_id) => org_id,
69        None => {
70            return HttpResponse::BadRequest()
71                .json(serde_json::json!({ "error": "Organization ID required" }))
72        }
73    };
74
75    let req = match parse_create_contribution_request(body.into_inner()) {
76        Ok(req) => req,
77        Err(response) => return *response,
78    };
79
80    match state
81        .owner_contribution_use_cases
82        .create_contribution(
83            organization_id,
84            req.owner_id,
85            Some(req.unit_id),
86            req.description.clone(),
87            req.amount,
88            req.contribution_type.clone(),
89            req.contribution_date,
90            req.account_code.clone(),
91        )
92        .await
93    {
94        Ok(contribution) => {
95            let response = OwnerContributionResponse::from(contribution);
96            HttpResponse::Created().json(response)
97        }
98        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({ "error": e })),
99    }
100}
101
102#[cfg(test)]
103mod create_contribution_parsing_tests {
104    use super::*;
105
106    fn corps_valide() -> serde_json::Value {
107        serde_json::json!({
108            "owner_id": Uuid::new_v4(),
109            "unit_id": Uuid::new_v4(),
110            "description": "Appel de fonds Q1 2026",
111            "amount": "750.00",
112            "contribution_type": "regular",
113            "contribution_date": chrono::Utc::now().to_rfc3339(),
114        })
115    }
116
117    // @happy — un corps complet, avec unit_id, se convertit sans erreur.
118    #[test]
119    fn happy_corps_avec_unit_id_est_accepte() {
120        let resultat = parse_create_contribution_request(corps_valide());
121        assert!(resultat.is_ok(), "corps valide attendu accepté");
122    }
123
124    // @negative — unit_id absent : 422, pas un 400 de syntaxe JSON générique
125    // (Issue #852 — le client doit apprendre la règle de la signature, pas de
126    // l'échec).
127    #[test]
128    fn negative_unit_id_absent_retourne_422() {
129        let mut corps = corps_valide();
130        corps.as_object_mut().unwrap().remove("unit_id");
131
132        let erreur = parse_create_contribution_request(corps).expect_err("doit refuser");
133        assert_eq!(erreur.status(), 422);
134    }
135
136    // @edge — un champ inconnu reste refusé (deny_unknown_fields), désormais
137    // avec le même code 422 que les autres écarts de schéma.
138    #[test]
139    fn edge_champ_inconnu_retourne_422() {
140        let mut corps = corps_valide();
141        corps
142            .as_object_mut()
143            .unwrap()
144            .insert("champ_fantaisiste".to_string(), serde_json::json!(true));
145
146        let erreur = parse_create_contribution_request(corps).expect_err("doit refuser");
147        assert_eq!(erreur.status(), 422);
148    }
149
150    // @security — aucun repli implicite : `unit_id` manquant fait échouer la
151    // conversion, sans jamais construire de requête valide à partir d'un
152    // autre champ (ex. `owner_id`) glissé à sa place. Le pendant HTTP complet
153    // — le refus nomme bien `unit_id` — est vérifié par
154    // `test_owner_contributions_create_missing_unit_id_returns_422`
155    // (tests/e2e_owner_contributions.rs), qui lit la réponse via le harnais
156    // actix éprouvé plutôt qu'en décodant le corps ici.
157    #[test]
158    fn security_unit_id_absent_ne_produit_jamais_de_requete_valide() {
159        let mut corps = corps_valide();
160        corps.as_object_mut().unwrap().remove("unit_id");
161
162        assert!(
163            parse_create_contribution_request(corps).is_err(),
164            "sans unit_id, aucune CreateOwnerContributionRequest ne doit être construite"
165        );
166    }
167}
168
169/// GET /api/v1/owner-contributions/{id}
170/// Get contribution by ID
171#[utoipa::path(
172    get,
173    path = "/owner-contributions/{id}",
174    tag = "OwnerContributions",
175    summary = "Get a single owner contribution",
176    params(("id" = Uuid, Path, description = "Contribution identifier")),
177    responses(
178        (status = 200, description = "Contribution", body = OwnerContributionResponse),
179        (status = 404, description = "Contribution not found"),
180    ),
181    security(("bearer_auth" = []))
182)]
183#[get("/owner-contributions/{id}")]
184pub async fn get_contribution(
185    state: web::Data<AppState>,
186    user: AuthenticatedUser,
187    id: web::Path<Uuid>,
188) -> HttpResponse {
189    // Cloisonnement : cette quote-part relève d'une ACP précise (#772).
190    if let Err(err) = verify_contribution_org_access(
191        &user,
192        *id,
193        &state.owner_contribution_use_cases,
194        &state.acp_use_cases,
195    )
196    .await
197    {
198        return err.error_response();
199    }
200
201    match state
202        .owner_contribution_use_cases
203        .get_contribution(*id)
204        .await
205    {
206        Ok(Some(contribution)) => {
207            let response = OwnerContributionResponse::from(contribution);
208            HttpResponse::Ok().json(response)
209        }
210        Ok(None) => {
211            HttpResponse::NotFound().json(serde_json::json!({ "error": "Contribution not found" }))
212        }
213        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e })),
214    }
215}
216
217/// GET /api/v1/owner-contributions?owner_id={uuid}
218/// Get contributions by owner, or all contributions for organization if owner_id not provided
219#[utoipa::path(
220    get,
221    path = "/owner-contributions",
222    tag = "OwnerContributions",
223    summary = "List contributions of the organization, or of a single owner",
224    params(("owner_id" = Option<Uuid>, Query, description = "Restrict to one owner")),
225    responses(
226        (status = 200, description = "Contributions", body = Vec<OwnerContributionResponse>),
227        (status = 401, description = "User does not belong to an organization"),
228    ),
229    security(("bearer_auth" = []))
230)]
231#[get("/owner-contributions")]
232pub async fn get_contributions_by_owner(
233    state: web::Data<AppState>,
234    user: AuthenticatedUser,
235    query: web::Query<std::collections::HashMap<String, String>>,
236) -> HttpResponse {
237    // If owner_id provided, filter by owner
238    if let Some(id_str) = query.get("owner_id") {
239        let owner_id = match Uuid::parse_str(id_str) {
240            Ok(id) => id,
241            Err(_) => {
242                return HttpResponse::BadRequest()
243                    .json(serde_json::json!({ "error": "Invalid owner_id format" }))
244            }
245        };
246
247        // Cloisonnement du FILTRE (#864).
248        //
249        // Ce gestionnaire a deux branches, et une seule cloisonnait. Sans
250        // `owner_id`, il rendait `get_contributions_by_organization(...)`,
251        // correctement borné. AVEC `owner_id`, il rendait
252        // `get_contributions_by_owner(owner_id)` — sans aucun contrôle.
253        //
254        // C'est la DEUXIÈME occurrence de cette forme, après
255        // `list_call_for_funds` (même correctif, même jour). Ce n'est donc
256        // pas un oubli isolé : un paramètre facultatif qui contourne le
257        // chemin protégé est un MOTIF, et il vise les deux fois la même
258        // donnée — qui doit combien.
259        //
260        // Aucun cliquet ne voit cette forme : le cloisonnement n'est pas
261        // absent du gestionnaire, il est absent d'UNE de ses branches, et la
262        // route a l'air gardée parce que son cas nominal l'est.
263        if let Err(err) = verify_owner_org_access(&user, owner_id, &state.owner_use_cases).await {
264            return err.error_response();
265        }
266
267        match state
268            .owner_contribution_use_cases
269            .get_contributions_by_owner(owner_id)
270            .await
271        {
272            Ok(contributions) => {
273                let responses: Vec<OwnerContributionResponse> =
274                    contributions.into_iter().map(Into::into).collect();
275                return HttpResponse::Ok().json(responses);
276            }
277            Err(e) => {
278                return HttpResponse::InternalServerError().json(serde_json::json!({ "error": e }))
279            }
280        }
281    }
282
283    // Otherwise, return all contributions for user's organization
284    let organization_id = match user.organization_id {
285        Some(org_id) => org_id,
286        None => {
287            return HttpResponse::BadRequest()
288                .json(serde_json::json!({ "error": "Organization ID required" }))
289        }
290    };
291
292    match state
293        .owner_contribution_use_cases
294        .get_contributions_by_organization(organization_id)
295        .await
296    {
297        Ok(contributions) => {
298            let responses: Vec<OwnerContributionResponse> =
299                contributions.into_iter().map(Into::into).collect();
300            HttpResponse::Ok().json(responses)
301        }
302        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e })),
303    }
304}
305
306/// GET /api/v1/owner-contributions/outstanding?owner_id={uuid}
307/// Get outstanding (unpaid) contributions for an owner
308#[utoipa::path(
309    get,
310    path = "/owner-contributions/outstanding",
311    tag = "OwnerContributions",
312    summary = "List unpaid contributions",
313    responses(
314        (status = 200, description = "Outstanding contributions", body = Vec<OwnerContributionResponse>),
315        (status = 401, description = "User does not belong to an organization"),
316    ),
317    security(("bearer_auth" = []))
318)]
319#[get("/owner-contributions/outstanding")]
320pub async fn get_outstanding_contributions(
321    state: web::Data<AppState>,
322    user: AuthenticatedUser,
323    query: web::Query<std::collections::HashMap<String, String>>,
324) -> HttpResponse {
325    let owner_id = match query.get("owner_id") {
326        Some(id_str) => match Uuid::parse_str(id_str) {
327            Ok(id) => id,
328            Err(_) => {
329                return HttpResponse::BadRequest()
330                    .json(serde_json::json!({ "error": "Invalid owner_id format" }))
331            }
332        },
333        None => {
334            return HttpResponse::BadRequest()
335                .json(serde_json::json!({ "error": "owner_id is required" }))
336        }
337    };
338
339    // Cloisonnement (#882) : `owner_id` arrivait en paramètre de requête sans
340    // aucune vérification — n'importe quel utilisateur authentifié pouvait
341    // lire les arriérés d'un copropriétaire quelconque, dans n'importe quelle
342    // organisation, en connaissant son seul UUID.
343    if let Err(err) = verify_owner_org_access(&user, owner_id, &state.owner_use_cases).await {
344        return err.error_response();
345    }
346
347    match state
348        .owner_contribution_use_cases
349        .get_outstanding_contributions(owner_id)
350        .await
351    {
352        Ok(contributions) => {
353            let responses: Vec<OwnerContributionResponse> =
354                contributions.into_iter().map(Into::into).collect();
355            HttpResponse::Ok().json(responses)
356        }
357        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e })),
358    }
359}
360
361/// PUT /api/v1/owner-contributions/{id}/mark-paid
362/// Record payment for a contribution
363#[utoipa::path(
364    put,
365    path = "/owner-contributions/{id}/mark-paid",
366    tag = "OwnerContributions",
367    summary = "Record a payment against a contribution",
368    description = "Voie SUPPORTEE pour solder une quote-part depuis l'interface. \
369Un paiement du module `/payments` peut aussi la solder automatiquement : \
370il suffit de lui passer `contribution_id`, et la quote-part bascule quand \
371le paiement atteint `succeeded`.",
372    params(("id" = Uuid, Path, description = "Contribution identifier")),
373    request_body = RecordPaymentRequest,
374    responses(
375        (status = 200, description = "Payment recorded", body = OwnerContributionResponse),
376        (status = 400, description = "Already paid, or unknown field in the body"),
377        (status = 404, description = "Contribution not found"),
378    ),
379    security(("bearer_auth" = []))
380)]
381#[put("/owner-contributions/{id}/mark-paid")]
382pub async fn record_payment(
383    state: web::Data<AppState>,
384    user: AuthenticatedUser,
385    id: web::Path<Uuid>,
386    req: web::Json<RecordPaymentRequest>,
387) -> HttpResponse {
388    // Cloisonnement : déclarer qu'un copropriétaire a payé éteint une dette.
389    // Sans ce contrôle, on pouvait le faire dans la comptabilité d'une autre
390    // copropriété. L'identité était prise puis ignorée — `_user` (#772).
391    if let Err(err) = verify_contribution_org_access(
392        &user,
393        *id,
394        &state.owner_contribution_use_cases,
395        &state.acp_use_cases,
396    )
397    .await
398    {
399        return err.error_response();
400    }
401
402    match state
403        .owner_contribution_use_cases
404        .record_payment(
405            *id,
406            req.payment_date,
407            req.payment_method.clone(),
408            req.payment_reference.clone(),
409        )
410        .await
411    {
412        Ok(contribution) => {
413            let response = OwnerContributionResponse::from(contribution);
414            HttpResponse::Ok().json(response)
415        }
416        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({ "error": e })),
417    }
418}