koprogo_api/infrastructure/web/handlers/
owner_contribution_handlers.rs1use 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
11type 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#[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 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 #[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 #[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 #[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 #[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#[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 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#[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 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 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 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#[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 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#[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 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}