Skip to main content

koprogo_api/infrastructure/web/handlers/
payment_handlers.rs

1use crate::application::dto::{CreatePaymentRequest, RefundPaymentRequest};
2use crate::domain::entities::TransactionStatus;
3use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
4use crate::infrastructure::web::{AppState, AuthenticatedUser};
5use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
6use uuid::Uuid;
7
8// ==================== Payment CRUD Endpoints ====================
9
10#[utoipa::path(
11    post,
12    path = "/payments",
13    tag = "Payments",
14    summary = "Create a payment",
15    request_body = CreatePaymentRequest,
16    responses(
17        (status = 201, description = "Payment created"),
18        (status = 400, description = "Bad request"),
19        (status = 401, description = "Unauthorized"),
20    ),
21    security(("bearer_auth" = []))
22)]
23#[post("/payments")]
24pub async fn create_payment(
25    state: web::Data<AppState>,
26    user: AuthenticatedUser,
27    request: web::Json<CreatePaymentRequest>,
28) -> impl Responder {
29    let organization_id = match user.require_organization() {
30        Ok(org_id) => org_id,
31        Err(e) => {
32            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
33        }
34    };
35
36    match state
37        .payment_use_cases
38        .create_payment(organization_id, request.into_inner())
39        .await
40    {
41        Ok(payment) => {
42            AuditLogEntry::new(
43                AuditEventType::PaymentCreated,
44                Some(user.user_id),
45                Some(organization_id),
46            )
47            .with_resource("Payment", payment.id)
48            .log();
49
50            HttpResponse::Created().json(payment)
51        }
52        Err(err) => {
53            AuditLogEntry::new(
54                AuditEventType::PaymentCreated,
55                Some(user.user_id),
56                Some(organization_id),
57            )
58            .with_error(err.clone())
59            .log();
60
61            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
62        }
63    }
64}
65
66#[utoipa::path(
67    get,
68    path = "/payments/{id}",
69    tag = "Payments",
70    summary = "Get a payment by ID",
71    params(("id" = Uuid, Path, description = "Payment ID")),
72    responses(
73        (status = 200, description = "Payment found"),
74        (status = 404, description = "Payment not found"),
75        (status = 500, description = "Internal server error"),
76    ),
77    security(("bearer_auth" = []))
78)]
79#[get("/payments/{id}")]
80pub async fn get_payment(
81    state: web::Data<AppState>,
82    user: AuthenticatedUser,
83    id: web::Path<Uuid>,
84) -> impl Responder {
85    match state.payment_use_cases.get_payment(*id).await {
86        Ok(Some(payment)) => {
87            // Multi-tenant isolation: verify payment belongs to user's organization
88            if let Err(e) = user.verify_org_access(payment.organization_id) {
89                return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
90            }
91            HttpResponse::Ok().json(payment)
92        }
93        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
94            "error": "Payment not found"
95        })),
96        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
97    }
98}
99
100#[utoipa::path(
101    get,
102    path = "/payments/stripe/{stripe_payment_intent_id}",
103    tag = "Payments",
104    summary = "Get a payment by Stripe payment intent ID",
105    params(("stripe_payment_intent_id" = String, Path, description = "Stripe payment intent ID")),
106    responses(
107        (status = 200, description = "Payment found"),
108        (status = 404, description = "Payment not found"),
109        (status = 500, description = "Internal server error"),
110    ),
111    security(("bearer_auth" = []))
112)]
113#[get("/payments/stripe/{stripe_payment_intent_id}")]
114pub async fn get_payment_by_stripe_intent(
115    state: web::Data<AppState>,
116    user: AuthenticatedUser,
117    stripe_payment_intent_id: web::Path<String>,
118) -> impl Responder {
119    match state
120        .payment_use_cases
121        .get_payment_by_stripe_intent(&stripe_payment_intent_id)
122        .await
123    {
124        Ok(Some(payment)) => {
125            // Cloisonnement multi-organisations : cette route ne prenait AUCUNE
126            // identité. N'importe qui pouvait lire ces données de paiement sur
127            // simple connaissance de l'identifiant Stripe. Le cliquet d'identité
128            // de #772 ne la voyait pas : il ne compte que les routes PRENANT une
129            // identité sans s'en servir. Cf. #845.
130            if let Err(e) = user.verify_org_access(payment.organization_id) {
131                return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
132            }
133            HttpResponse::Ok().json(payment)
134        }
135        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
136            "error": "Payment not found"
137        })),
138        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
139    }
140}
141
142#[utoipa::path(
143    get,
144    path = "/owners/{owner_id}/payments",
145    tag = "Payments",
146    summary = "List all payments for an owner",
147    params(("owner_id" = Uuid, Path, description = "Owner ID")),
148    responses(
149        (status = 200, description = "List of owner payments"),
150        (status = 500, description = "Internal server error"),
151    ),
152    security(("bearer_auth" = []))
153)]
154#[get("/owners/{owner_id}/payments")]
155pub async fn list_owner_payments(
156    state: web::Data<AppState>,
157    owner_id: web::Path<Uuid>,
158    user: AuthenticatedUser,
159) -> impl Responder {
160    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
161    // servait la situation financiere NOMINATIVE d'une personne a quiconque
162    // connaissait son identifiant.
163    if let Err(err) = crate::infrastructure::web::middleware::scope_guard::verify_owner_org_access(
164        &user,
165        *owner_id,
166        &state.owner_use_cases,
167    )
168    .await
169    {
170        return err.error_response();
171    }
172
173    match state.payment_use_cases.list_owner_payments(*owner_id).await {
174        Ok(payments) => HttpResponse::Ok().json(payments),
175        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
176    }
177}
178
179#[utoipa::path(
180    get,
181    path = "/buildings/{building_id}/payments",
182    tag = "Payments",
183    summary = "List all payments for a building",
184    params(("building_id" = Uuid, Path, description = "Building ID")),
185    responses(
186        (status = 200, description = "List of building payments"),
187        (status = 500, description = "Internal server error"),
188    ),
189    security(("bearer_auth" = []))
190)]
191#[get("/buildings/{building_id}/payments")]
192pub async fn list_building_payments(
193    state: web::Data<AppState>,
194    building_id: web::Path<Uuid>,
195    user: AuthenticatedUser,
196) -> impl Responder {
197    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
198    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
199    // un identifiant, sans demander d'identite.
200    if let Err(err) =
201        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
202            &user,
203            *building_id,
204            &state.building_use_cases,
205            &state.acp_use_cases,
206        )
207        .await
208    {
209        return err.error_response();
210    }
211
212    match state
213        .payment_use_cases
214        .list_building_payments(*building_id)
215        .await
216    {
217        Ok(payments) => HttpResponse::Ok().json(payments),
218        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
219    }
220}
221
222#[utoipa::path(
223    get,
224    path = "/expenses/{expense_id}/payments",
225    tag = "Payments",
226    summary = "List all payments for an expense",
227    params(("expense_id" = Uuid, Path, description = "Expense ID")),
228    responses(
229        (status = 200, description = "List of expense payments"),
230        (status = 500, description = "Internal server error"),
231    ),
232    security(("bearer_auth" = []))
233)]
234#[get("/expenses/{expense_id}/payments")]
235pub async fn list_expense_payments(
236    state: web::Data<AppState>,
237    expense_id: web::Path<Uuid>,
238    user: AuthenticatedUser,
239) -> impl Responder {
240    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : les
241    // paiements d'une depense nomment qui a paye quoi.
242    if let Err(err) =
243        crate::infrastructure::web::middleware::scope_guard::verify_expense_org_access(
244            &user,
245            *expense_id,
246            &state.expense_use_cases,
247            &state.building_use_cases,
248            &state.acp_use_cases,
249        )
250        .await
251    {
252        return err.error_response();
253    }
254
255    match state
256        .payment_use_cases
257        .list_expense_payments(*expense_id)
258        .await
259    {
260        Ok(payments) => HttpResponse::Ok().json(payments),
261        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
262    }
263}
264
265#[utoipa::path(
266    get,
267    path = "/organizations/{organization_id}/payments",
268    tag = "Payments",
269    summary = "List all payments for an organization",
270    params(("organization_id" = Uuid, Path, description = "Organization ID")),
271    responses(
272        (status = 200, description = "List of organization payments"),
273        (status = 500, description = "Internal server error"),
274    ),
275    security(("bearer_auth" = []))
276)]
277#[get("/organizations/{organization_id}/payments")]
278pub async fn list_organization_payments(
279    state: web::Data<AppState>,
280    user: AuthenticatedUser,
281    organization_id: web::Path<Uuid>,
282) -> impl Responder {
283    if let Err(e) = user.verify_org_access(*organization_id) {
284        return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
285    }
286    match state
287        .payment_use_cases
288        .list_organization_payments(*organization_id)
289        .await
290    {
291        Ok(payments) => HttpResponse::Ok().json(payments),
292        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
293    }
294}
295
296#[utoipa::path(
297    get,
298    path = "/payments/status/{status}",
299    tag = "Payments",
300    summary = "List payments by transaction status",
301    params(("status" = String, Path, description = "Transaction status (pending, processing, requires_action, succeeded, failed, cancelled, refunded)")),
302    responses(
303        (status = 200, description = "List of payments with given status"),
304        (status = 400, description = "Invalid status value"),
305        (status = 401, description = "Unauthorized"),
306        (status = 500, description = "Internal server error"),
307    ),
308    security(("bearer_auth" = []))
309)]
310#[get("/payments/status/{status}")]
311pub async fn list_payments_by_status(
312    state: web::Data<AppState>,
313    user: AuthenticatedUser,
314    status_str: web::Path<String>,
315) -> impl Responder {
316    let organization_id = match user.require_organization() {
317        Ok(org_id) => org_id,
318        Err(e) => {
319            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
320        }
321    };
322
323    // Parse status string to TransactionStatus enum
324    let status = match status_str.as_str() {
325        "pending" => TransactionStatus::Pending,
326        "processing" => TransactionStatus::Processing,
327        "requires_action" => TransactionStatus::RequiresAction,
328        "succeeded" => TransactionStatus::Succeeded,
329        "failed" => TransactionStatus::Failed,
330        "cancelled" => TransactionStatus::Cancelled,
331        "refunded" => TransactionStatus::Refunded,
332        _ => {
333            return HttpResponse::BadRequest().json(serde_json::json!({
334                "error": "Invalid status. Must be one of: pending, processing, requires_action, succeeded, failed, cancelled, refunded"
335            }))
336        }
337    };
338
339    match state
340        .payment_use_cases
341        .list_payments_by_status(organization_id, status)
342        .await
343    {
344        Ok(payments) => HttpResponse::Ok().json(payments),
345        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
346    }
347}
348
349#[utoipa::path(
350    get,
351    path = "/payments/pending",
352    tag = "Payments",
353    summary = "List all pending payments for the organization",
354    responses(
355        (status = 200, description = "List of pending payments"),
356        (status = 401, description = "Unauthorized"),
357        (status = 500, description = "Internal server error"),
358    ),
359    security(("bearer_auth" = []))
360)]
361#[get("/payments/pending")]
362pub async fn list_pending_payments(
363    state: web::Data<AppState>,
364    user: AuthenticatedUser,
365) -> impl Responder {
366    let organization_id = match user.require_organization() {
367        Ok(org_id) => org_id,
368        Err(e) => {
369            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
370        }
371    };
372
373    match state
374        .payment_use_cases
375        .list_pending_payments(organization_id)
376        .await
377    {
378        Ok(payments) => HttpResponse::Ok().json(payments),
379        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
380    }
381}
382
383#[utoipa::path(
384    get,
385    path = "/payments/failed",
386    tag = "Payments",
387    summary = "List all failed payments for the organization",
388    responses(
389        (status = 200, description = "List of failed payments"),
390        (status = 401, description = "Unauthorized"),
391        (status = 500, description = "Internal server error"),
392    ),
393    security(("bearer_auth" = []))
394)]
395#[get("/payments/failed")]
396pub async fn list_failed_payments(
397    state: web::Data<AppState>,
398    user: AuthenticatedUser,
399) -> impl Responder {
400    let organization_id = match user.require_organization() {
401        Ok(org_id) => org_id,
402        Err(e) => {
403            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
404        }
405    };
406
407    match state
408        .payment_use_cases
409        .list_failed_payments(organization_id)
410        .await
411    {
412        Ok(payments) => HttpResponse::Ok().json(payments),
413        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
414    }
415}
416
417// ==================== Payment Status Update Endpoints ====================
418
419#[utoipa::path(
420    put,
421    path = "/payments/{id}/processing",
422    tag = "Payments",
423    summary = "Mark a payment as processing",
424    params(("id" = Uuid, Path, description = "Payment ID")),
425    responses(
426        (status = 200, description = "Payment marked as processing"),
427        (status = 400, description = "Invalid state transition"),
428        (status = 401, description = "Unauthorized"),
429    ),
430    security(("bearer_auth" = []))
431)]
432#[put("/payments/{id}/processing")]
433pub async fn mark_payment_processing(
434    state: web::Data<AppState>,
435    user: AuthenticatedUser,
436    id: web::Path<Uuid>,
437) -> impl Responder {
438    let organization_id = match user.require_organization() {
439        Ok(org_id) => org_id,
440        Err(e) => {
441            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
442        }
443    };
444
445    match state.payment_use_cases.mark_processing(*id).await {
446        Ok(payment) => {
447            AuditLogEntry::new(
448                AuditEventType::PaymentProcessing,
449                Some(user.user_id),
450                Some(organization_id),
451            )
452            .with_resource("Payment", payment.id)
453            .log();
454
455            HttpResponse::Ok().json(payment)
456        }
457        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
458    }
459}
460
461#[utoipa::path(
462    put,
463    path = "/payments/{id}/requires-action",
464    tag = "Payments",
465    summary = "Mark a payment as requiring action",
466    params(("id" = Uuid, Path, description = "Payment ID")),
467    responses(
468        (status = 200, description = "Payment marked as requires action"),
469        (status = 400, description = "Invalid state transition"),
470        (status = 401, description = "Unauthorized"),
471    ),
472    security(("bearer_auth" = []))
473)]
474#[put("/payments/{id}/requires-action")]
475pub async fn mark_payment_requires_action(
476    state: web::Data<AppState>,
477    user: AuthenticatedUser,
478    id: web::Path<Uuid>,
479) -> impl Responder {
480    let organization_id = match user.require_organization() {
481        Ok(org_id) => org_id,
482        Err(e) => {
483            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
484        }
485    };
486
487    match state.payment_use_cases.mark_requires_action(*id).await {
488        Ok(payment) => {
489            AuditLogEntry::new(
490                AuditEventType::PaymentRequiresAction,
491                Some(user.user_id),
492                Some(organization_id),
493            )
494            .with_resource("Payment", payment.id)
495            .log();
496
497            HttpResponse::Ok().json(payment)
498        }
499        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
500    }
501}
502
503#[utoipa::path(
504    put,
505    path = "/payments/{id}/succeeded",
506    tag = "Payments",
507    summary = "Mark a payment as succeeded",
508    params(("id" = Uuid, Path, description = "Payment ID")),
509    responses(
510        (status = 200, description = "Payment marked as succeeded"),
511        (status = 400, description = "Invalid state transition"),
512        (status = 401, description = "Unauthorized"),
513    ),
514    security(("bearer_auth" = []))
515)]
516#[put("/payments/{id}/succeeded")]
517pub async fn mark_payment_succeeded(
518    state: web::Data<AppState>,
519    user: AuthenticatedUser,
520    id: web::Path<Uuid>,
521) -> impl Responder {
522    let organization_id = match user.require_organization() {
523        Ok(org_id) => org_id,
524        Err(e) => {
525            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
526        }
527    };
528
529    match state.payment_use_cases.mark_succeeded(*id).await {
530        Ok(payment) => {
531            AuditLogEntry::new(
532                AuditEventType::PaymentSucceeded,
533                Some(user.user_id),
534                Some(organization_id),
535            )
536            .with_resource("Payment", payment.id)
537            .log();
538
539            HttpResponse::Ok().json(payment)
540        }
541        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
542    }
543}
544
545#[utoipa::path(
546    put,
547    path = "/payments/{id}/failed",
548    tag = "Payments",
549    summary = "Mark a payment as failed",
550    params(("id" = Uuid, Path, description = "Payment ID")),
551    request_body = inline(serde_json::Value),
552    responses(
553        (status = 200, description = "Payment marked as failed"),
554        (status = 400, description = "Invalid state transition"),
555        (status = 401, description = "Unauthorized"),
556    ),
557    security(("bearer_auth" = []))
558)]
559#[put("/payments/{id}/failed")]
560pub async fn mark_payment_failed(
561    state: web::Data<AppState>,
562    user: AuthenticatedUser,
563    id: web::Path<Uuid>,
564    request: web::Json<serde_json::Value>,
565) -> impl Responder {
566    let organization_id = match user.require_organization() {
567        Ok(org_id) => org_id,
568        Err(e) => {
569            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
570        }
571    };
572
573    let reason = request
574        .get("reason")
575        .and_then(|v| v.as_str())
576        .unwrap_or("Unknown failure reason")
577        .to_string();
578
579    match state.payment_use_cases.mark_failed(*id, reason).await {
580        Ok(payment) => {
581            AuditLogEntry::new(
582                AuditEventType::PaymentFailed,
583                Some(user.user_id),
584                Some(organization_id),
585            )
586            .with_resource("Payment", payment.id)
587            .log();
588
589            HttpResponse::Ok().json(payment)
590        }
591        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
592    }
593}
594
595#[utoipa::path(
596    put,
597    path = "/payments/{id}/cancelled",
598    tag = "Payments",
599    summary = "Mark a payment as cancelled",
600    params(("id" = Uuid, Path, description = "Payment ID")),
601    responses(
602        (status = 200, description = "Payment marked as cancelled"),
603        (status = 400, description = "Invalid state transition"),
604        (status = 401, description = "Unauthorized"),
605    ),
606    security(("bearer_auth" = []))
607)]
608#[put("/payments/{id}/cancelled")]
609pub async fn mark_payment_cancelled(
610    state: web::Data<AppState>,
611    user: AuthenticatedUser,
612    id: web::Path<Uuid>,
613) -> impl Responder {
614    let organization_id = match user.require_organization() {
615        Ok(org_id) => org_id,
616        Err(e) => {
617            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
618        }
619    };
620
621    match state.payment_use_cases.mark_cancelled(*id).await {
622        Ok(payment) => {
623            AuditLogEntry::new(
624                AuditEventType::PaymentCancelled,
625                Some(user.user_id),
626                Some(organization_id),
627            )
628            .with_resource("Payment", payment.id)
629            .log();
630
631            HttpResponse::Ok().json(payment)
632        }
633        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
634    }
635}
636
637#[utoipa::path(
638    post,
639    path = "/payments/{id}/refund",
640    tag = "Payments",
641    summary = "Refund a payment (partial or full)",
642    params(("id" = Uuid, Path, description = "Payment ID")),
643    request_body = RefundPaymentRequest,
644    responses(
645        (status = 200, description = "Payment refunded"),
646        (status = 400, description = "Refund not allowed or exceeds payment amount"),
647        (status = 401, description = "Unauthorized"),
648    ),
649    security(("bearer_auth" = []))
650)]
651#[post("/payments/{id}/refund")]
652pub async fn refund_payment(
653    state: web::Data<AppState>,
654    user: AuthenticatedUser,
655    id: web::Path<Uuid>,
656    request: web::Json<RefundPaymentRequest>,
657) -> impl Responder {
658    let organization_id = match user.require_organization() {
659        Ok(org_id) => org_id,
660        Err(e) => {
661            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
662        }
663    };
664
665    match state
666        .payment_use_cases
667        .refund_payment(*id, request.into_inner())
668        .await
669    {
670        Ok(payment) => {
671            AuditLogEntry::new(
672                AuditEventType::PaymentRefunded,
673                Some(user.user_id),
674                Some(organization_id),
675            )
676            .with_resource("Payment", payment.id)
677            .log();
678
679            HttpResponse::Ok().json(payment)
680        }
681        Err(err) => {
682            AuditLogEntry::new(
683                AuditEventType::PaymentRefunded,
684                Some(user.user_id),
685                Some(organization_id),
686            )
687            .with_error(err.clone())
688            .log();
689
690            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
691        }
692    }
693}
694
695#[utoipa::path(
696    delete,
697    path = "/payments/{id}",
698    tag = "Payments",
699    summary = "Delete a payment",
700    params(("id" = Uuid, Path, description = "Payment ID")),
701    responses(
702        (status = 204, description = "Payment deleted"),
703        (status = 401, description = "Unauthorized"),
704        (status = 404, description = "Payment not found"),
705        (status = 500, description = "Internal server error"),
706    ),
707    security(("bearer_auth" = []))
708)]
709#[delete("/payments/{id}")]
710pub async fn delete_payment(
711    state: web::Data<AppState>,
712    user: AuthenticatedUser,
713    id: web::Path<Uuid>,
714) -> impl Responder {
715    let organization_id = match user.require_organization() {
716        Ok(org_id) => org_id,
717        Err(e) => {
718            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
719        }
720    };
721
722    match state.payment_use_cases.delete_payment(*id).await {
723        Ok(true) => {
724            AuditLogEntry::new(
725                AuditEventType::PaymentDeleted,
726                Some(user.user_id),
727                Some(organization_id),
728            )
729            .with_resource("Payment", *id)
730            .log();
731
732            HttpResponse::NoContent().finish()
733        }
734        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
735            "error": "Payment not found"
736        })),
737        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
738    }
739}
740
741// ==================== Payment Statistics Endpoints ====================
742
743#[utoipa::path(
744    get,
745    path = "/owners/{owner_id}/payments/stats",
746    tag = "Payments",
747    summary = "Get payment statistics for an owner",
748    params(("owner_id" = Uuid, Path, description = "Owner ID")),
749    responses(
750        (status = 200, description = "Owner payment statistics"),
751        (status = 500, description = "Internal server error"),
752    ),
753    security(("bearer_auth" = []))
754)]
755#[get("/owners/{owner_id}/payments/stats")]
756pub async fn get_owner_payment_stats(
757    state: web::Data<AppState>,
758    owner_id: web::Path<Uuid>,
759    user: AuthenticatedUser,
760) -> impl Responder {
761    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
762    // servait la situation financiere NOMINATIVE d'une personne a quiconque
763    // connaissait son identifiant.
764    if let Err(err) = crate::infrastructure::web::middleware::scope_guard::verify_owner_org_access(
765        &user,
766        *owner_id,
767        &state.owner_use_cases,
768    )
769    .await
770    {
771        return err.error_response();
772    }
773
774    match state
775        .payment_use_cases
776        .get_owner_payment_stats(*owner_id)
777        .await
778    {
779        Ok(stats) => HttpResponse::Ok().json(stats),
780        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
781    }
782}
783
784#[utoipa::path(
785    get,
786    path = "/buildings/{building_id}/payments/stats",
787    tag = "Payments",
788    summary = "Get payment statistics for a building",
789    params(("building_id" = Uuid, Path, description = "Building ID")),
790    responses(
791        (status = 200, description = "Building payment statistics"),
792        (status = 500, description = "Internal server error"),
793    ),
794    security(("bearer_auth" = []))
795)]
796#[get("/buildings/{building_id}/payments/stats")]
797pub async fn get_building_payment_stats(
798    state: web::Data<AppState>,
799    building_id: web::Path<Uuid>,
800    user: AuthenticatedUser,
801) -> impl Responder {
802    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
803    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
804    // un identifiant, sans demander d'identite.
805    if let Err(err) =
806        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
807            &user,
808            *building_id,
809            &state.building_use_cases,
810            &state.acp_use_cases,
811        )
812        .await
813    {
814        return err.error_response();
815    }
816
817    match state
818        .payment_use_cases
819        .get_building_payment_stats(*building_id)
820        .await
821    {
822        Ok(stats) => HttpResponse::Ok().json(stats),
823        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
824    }
825}
826
827#[utoipa::path(
828    get,
829    path = "/expenses/{expense_id}/payments/total",
830    tag = "Payments",
831    summary = "Get total amount paid for an expense",
832    params(("expense_id" = Uuid, Path, description = "Expense ID")),
833    responses(
834        (status = 200, description = "Total paid amount in cents"),
835        (status = 500, description = "Internal server error"),
836    ),
837    security(("bearer_auth" = []))
838)]
839#[get("/expenses/{expense_id}/payments/total")]
840pub async fn get_expense_total_paid(
841    state: web::Data<AppState>,
842    expense_id: web::Path<Uuid>,
843    user: AuthenticatedUser,
844) -> impl Responder {
845    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : les
846    // paiements d'une depense nomment qui a paye quoi.
847    if let Err(err) =
848        crate::infrastructure::web::middleware::scope_guard::verify_expense_org_access(
849            &user,
850            *expense_id,
851            &state.expense_use_cases,
852            &state.building_use_cases,
853            &state.acp_use_cases,
854        )
855        .await
856    {
857        return err.error_response();
858    }
859
860    match state
861        .payment_use_cases
862        .get_total_paid_for_expense(*expense_id)
863        .await
864    {
865        Ok(total) => HttpResponse::Ok().json(serde_json::json!({
866            "expense_id": *expense_id,
867            "total_paid_cents": total
868        })),
869        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
870    }
871}
872
873#[utoipa::path(
874    get,
875    path = "/owners/{owner_id}/payments/total",
876    tag = "Payments",
877    summary = "Get total amount paid by an owner",
878    params(("owner_id" = Uuid, Path, description = "Owner ID")),
879    responses(
880        (status = 200, description = "Total paid amount in cents"),
881        (status = 500, description = "Internal server error"),
882    ),
883    security(("bearer_auth" = []))
884)]
885#[get("/owners/{owner_id}/payments/total")]
886pub async fn get_owner_total_paid(
887    state: web::Data<AppState>,
888    owner_id: web::Path<Uuid>,
889    user: AuthenticatedUser,
890) -> impl Responder {
891    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
892    // servait la situation financiere NOMINATIVE d'une personne a quiconque
893    // connaissait son identifiant.
894    if let Err(err) = crate::infrastructure::web::middleware::scope_guard::verify_owner_org_access(
895        &user,
896        *owner_id,
897        &state.owner_use_cases,
898    )
899    .await
900    {
901        return err.error_response();
902    }
903
904    match state
905        .payment_use_cases
906        .get_total_paid_by_owner(*owner_id)
907        .await
908    {
909        Ok(total) => HttpResponse::Ok().json(serde_json::json!({
910            "owner_id": *owner_id,
911            "total_paid_cents": total
912        })),
913        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
914    }
915}
916
917#[utoipa::path(
918    get,
919    path = "/buildings/{building_id}/payments/total",
920    tag = "Payments",
921    summary = "Get total amount paid for a building",
922    params(("building_id" = Uuid, Path, description = "Building ID")),
923    responses(
924        (status = 200, description = "Total paid amount in cents"),
925        (status = 500, description = "Internal server error"),
926    ),
927    security(("bearer_auth" = []))
928)]
929#[get("/buildings/{building_id}/payments/total")]
930pub async fn get_building_total_paid(
931    state: web::Data<AppState>,
932    building_id: web::Path<Uuid>,
933    user: AuthenticatedUser,
934) -> impl Responder {
935    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
936    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
937    // un identifiant, sans demander d'identite.
938    if let Err(err) =
939        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
940            &user,
941            *building_id,
942            &state.building_use_cases,
943            &state.acp_use_cases,
944        )
945        .await
946    {
947        return err.error_response();
948    }
949
950    match state
951        .payment_use_cases
952        .get_total_paid_for_building(*building_id)
953        .await
954    {
955        Ok(total) => HttpResponse::Ok().json(serde_json::json!({
956            "building_id": *building_id,
957            "total_paid_cents": total
958        })),
959        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
960    }
961}