1use crate::application::dto::{
2 ApproveInvoiceDto, CreateExpenseDto, CreateInvoiceDraftDto, PageRequest, PageResponse,
3 RejectInvoiceDto, SubmitForApprovalDto, UpdateInvoiceDraftDto,
4};
5use crate::domain::entities::UserRole;
6use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
7use crate::infrastructure::web::handlers::conformity_response::try_build_conformity_response;
8use crate::infrastructure::web::middleware::scope_guard::{
9 verify_acp_org_access, verify_building_org_access,
10};
11use crate::infrastructure::web::{AppState, AuthenticatedUser};
12use actix_web::{get, post, put, web, HttpResponse, Responder, ResponseError};
13use chrono::{DateTime, Utc};
14use serde::Deserialize;
15use std::str::FromStr;
16use uuid::Uuid;
17use validator::Validate;
18
19fn user_role(user: &AuthenticatedUser) -> Option<UserRole> {
24 UserRole::from_str(&user.role).ok()
25}
26
27fn verify_owner_readonly(user: &AuthenticatedUser) -> Option<HttpResponse> {
30 if user.role == "owner" {
31 Some(HttpResponse::Forbidden().json(serde_json::json!({
32 "error": "Owner role has read-only access",
33 "code": "invalid_role",
34 })))
35 } else {
36 None
37 }
38}
39
40fn verify_syndic_role(user: &AuthenticatedUser) -> Option<HttpResponse> {
42 match user_role(user) {
43 Some(UserRole::Syndic) | Some(UserRole::SuperAdmin) => None,
44 _ => Some(HttpResponse::Forbidden().json(serde_json::json!({
45 "error": "Only syndic or superadmin can approve/reject invoices",
46 "code": "invalid_role",
47 }))),
48 }
49}
50
51fn check_can_emit_expenses(user: &AuthenticatedUser) -> Option<HttpResponse> {
54 match user_role(user) {
55 Some(role) if role.can_emit_expenses() => None,
56 _ => Some(HttpResponse::Forbidden().json(serde_json::json!({
57 "error":
58 "Only syndic, superadmin, or accountant émetteur can create/pay expenses",
59 "code": "invalid_role",
60 }))),
61 }
62}
63
64fn check_can_encode_invoices(user: &AuthenticatedUser) -> Option<HttpResponse> {
66 match user_role(user) {
67 Some(role) if role.can_encode_invoices() => None,
68 _ => Some(HttpResponse::Forbidden().json(serde_json::json!({
69 "error":
70 "Only syndic, superadmin, or accountant encodeur can create/edit invoices",
71 "code": "invalid_role",
72 }))),
73 }
74}
75
76fn verify_accountant_role(user: &AuthenticatedUser) -> Option<HttpResponse> {
80 check_can_encode_invoices(user)
81}
82
83async fn verify_expense_org_access(
101 state: &web::Data<AppState>,
102 user: &AuthenticatedUser,
103 id: Uuid,
104) -> Option<HttpResponse> {
105 let expense = match state.expense_use_cases.get_expense(id).await {
106 Ok(Some(e)) => e,
107 Ok(None) => {
108 return Some(HttpResponse::NotFound().json(serde_json::json!({
109 "error": "Expense not found"
110 })))
111 }
112 Err(err) => {
113 return Some(HttpResponse::InternalServerError().json(serde_json::json!({
114 "error": err.to_string()
115 })))
116 }
117 };
118
119 let acp_id = match Uuid::parse_str(&expense.acp_id) {
120 Ok(id) => id,
121 Err(_) => {
122 return Some(HttpResponse::Forbidden().json(serde_json::json!({
123 "error": "Impossible de rattacher cette dépense à une ACP"
124 })))
125 }
126 };
127
128 match verify_acp_org_access(user, acp_id, &state.acp_use_cases).await {
129 Ok(()) => None,
130 Err(err) => Some(err.error_response()),
131 }
132}
133
134#[utoipa::path(
135 post,
136 path = "/expenses",
137 tag = "Expenses",
138 summary = "Créer une dépense",
139 request_body = CreateExpenseDto,
140 responses(
141 (status = 201, description = "Dépense créée"),
142 (status = 400, description = "Requête invalide"),
143 (status = 401, description = "Non authentifié"),
144 (status = 403, description = "Rôle sans droit d'encodage"),
145 ),
146 security(("bearer_auth" = []))
147)]
148#[post("/expenses")]
149pub async fn create_expense(
150 state: web::Data<AppState>,
151 user: AuthenticatedUser, mut dto: web::Json<CreateExpenseDto>,
153) -> impl Responder {
154 if let Some(response) = verify_owner_readonly(&user) {
155 return response;
156 }
157 if let Some(response) = check_can_emit_expenses(&user) {
161 return response;
162 }
163
164 let organization_id = match user.require_organization() {
167 Ok(org_id) => org_id,
168 Err(e) => {
169 return HttpResponse::Unauthorized().json(serde_json::json!({
170 "error": e.to_string()
171 }))
172 }
173 };
174 dto.organization_id = organization_id.to_string();
175
176 let building_uuid = match Uuid::parse_str(&dto.building_id) {
185 Ok(id) => id,
186 Err(_) => {
187 return HttpResponse::BadRequest()
188 .json(serde_json::json!({ "error": "Invalid building_id format" }))
189 }
190 };
191 if let Err(err) = verify_building_org_access(
192 &user,
193 building_uuid,
194 &state.building_use_cases,
195 &state.acp_use_cases,
196 )
197 .await
198 {
199 return err.error_response();
200 }
201
202 if let Err(errors) = dto.validate() {
203 return HttpResponse::BadRequest().json(serde_json::json!({
204 "error": "Validation failed",
205 "details": errors.to_string()
206 }));
207 }
208
209 match state
210 .expense_use_cases
211 .create_expense(dto.into_inner())
212 .await
213 {
214 Ok(expense) => {
215 AuditLogEntry::new(
217 AuditEventType::ExpenseCreated,
218 Some(user.user_id),
219 Some(organization_id),
220 )
221 .with_resource("Expense", Uuid::parse_str(&expense.id).unwrap())
222 .log();
223
224 HttpResponse::Created().json(expense)
225 }
226 Err(err) => {
227 AuditLogEntry::new(
229 AuditEventType::ExpenseCreated,
230 Some(user.user_id),
231 Some(organization_id),
232 )
233 .with_error(err.clone())
234 .log();
235
236 if let Some(resp) = try_build_conformity_response(&err) {
238 return resp;
239 }
240 HttpResponse::BadRequest().json(serde_json::json!({
241 "error": err
242 }))
243 }
244 }
245}
246
247#[utoipa::path(
248 get,
249 path = "/expenses/{id}",
250 tag = "Expenses",
251 summary = "Lire une dépense",
252 responses(
253 (status = 200, description = "Dépense"),
254 (status = 401, description = "Non authentifié"),
255 (status = 404, description = "Introuvable"),
256 ),
257 security(("bearer_auth" = []))
258)]
259#[get("/expenses/{id}")]
260pub async fn get_expense(
261 state: web::Data<AppState>,
262 user: AuthenticatedUser,
263 id: web::Path<Uuid>,
264) -> impl Responder {
265 match state.expense_use_cases.get_expense(*id).await {
266 Ok(Some(expense)) => {
267 if let Ok(building_id) = Uuid::parse_str(&expense.building_id) {
269 if let Ok(Some(building)) = state.building_use_cases.get_building(building_id).await
270 {
271 let acp_id = match Uuid::parse_str(&building.acp_id) {
272 Ok(id) => id,
273 Err(_) => {
274 return HttpResponse::InternalServerError().json(serde_json::json!({
275 "error": "Invalid building.acp_id format"
276 }));
277 }
278 };
279 if let Err(err) =
280 verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await
281 {
282 return err.error_response();
283 }
284 }
285 }
286 HttpResponse::Ok().json(expense)
287 }
288 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
289 "error": "Expense not found"
290 })),
291 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
292 "error": err
293 })),
294 }
295}
296
297#[utoipa::path(
298 get,
299 path = "/expenses",
300 tag = "Expenses",
301 summary = "Lister les dépenses de l'organisation",
302 responses(
303 (status = 200, description = "Liste paginée"),
304 (status = 401, description = "Non authentifié"),
305 ),
306 security(("bearer_auth" = []))
307)]
308#[get("/expenses")]
309pub async fn list_expenses(
310 state: web::Data<AppState>,
311 user: AuthenticatedUser,
312 page_request: web::Query<PageRequest>,
313) -> impl Responder {
314 let organization_id = user.organization_id;
315
316 match state
317 .expense_use_cases
318 .list_expenses_paginated(&page_request, organization_id)
319 .await
320 {
321 Ok((expenses, total)) => {
322 let response =
323 PageResponse::new(expenses, page_request.page, page_request.per_page, total);
324 HttpResponse::Ok().json(response)
325 }
326 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
327 "error": err
328 })),
329 }
330}
331
332#[utoipa::path(
333 get,
334 path = "/buildings/{building_id}/expenses",
335 tag = "Expenses",
336 summary = "Lister les dépenses d'un immeuble",
337 responses(
338 (status = 200, description = "Liste"),
339 (status = 401, description = "Non authentifié"),
340 (status = 403, description = "Immeuble hors de votre organisation"),
341 (status = 404, description = "Immeuble introuvable"),
342 ),
343 security(("bearer_auth" = []))
344)]
345#[get("/buildings/{building_id}/expenses")]
346pub async fn list_expenses_by_building(
347 state: web::Data<AppState>,
348 user: AuthenticatedUser,
349 building_id: web::Path<Uuid>,
350) -> impl Responder {
351 match state.building_use_cases.get_building(*building_id).await {
353 Ok(Some(building)) => {
354 let acp_id = match Uuid::parse_str(&building.acp_id) {
355 Ok(id) => id,
356 Err(_) => {
357 return HttpResponse::InternalServerError().json(serde_json::json!({
358 "error": "Invalid building.acp_id format"
359 }));
360 }
361 };
362 if let Err(err) = verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await {
363 return err.error_response();
364 }
365 }
366 Ok(None) => {
367 return HttpResponse::NotFound().json(serde_json::json!({
368 "error": "Building not found"
369 }));
370 }
371 Err(err) => {
372 return HttpResponse::InternalServerError().json(serde_json::json!({
373 "error": err
374 }));
375 }
376 }
377
378 match state
379 .expense_use_cases
380 .list_expenses_by_building(*building_id)
381 .await
382 {
383 Ok(expenses) => HttpResponse::Ok().json(expenses),
384 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
385 "error": err
386 })),
387 }
388}
389
390#[utoipa::path(
391 put,
392 path = "/expenses/{id}/mark-paid",
393 tag = "Expenses",
394 summary = "Marquer une dépense comme payée",
395 responses(
396 (status = 200, description = "Dépense mise à jour"),
397 (status = 401, description = "Non authentifié"),
398 (status = 404, description = "Introuvable"),
399 ),
400 security(("bearer_auth" = []))
401)]
402#[put("/expenses/{id}/mark-paid")]
403pub async fn mark_expense_paid(
404 state: web::Data<AppState>,
405 user: AuthenticatedUser,
406 id: web::Path<Uuid>,
407) -> impl Responder {
408 if let Some(response) = verify_owner_readonly(&user) {
409 return response;
410 }
411 if let Some(response) = check_can_emit_expenses(&user) {
413 return response;
414 }
415
416 match state.expense_use_cases.mark_as_paid(*id).await {
417 Ok(expense) => {
418 AuditLogEntry::new(
420 AuditEventType::ExpenseMarkedPaid,
421 Some(user.user_id),
422 user.organization_id,
423 )
424 .with_resource("Expense", *id)
425 .log();
426
427 HttpResponse::Ok().json(expense)
428 }
429 Err(err) => {
430 AuditLogEntry::new(
432 AuditEventType::ExpenseMarkedPaid,
433 Some(user.user_id),
434 user.organization_id,
435 )
436 .with_resource("Expense", *id)
437 .with_error(err.clone())
438 .log();
439
440 HttpResponse::BadRequest().json(serde_json::json!({
441 "error": err
442 }))
443 }
444 }
445}
446
447#[utoipa::path(
448 post,
449 path = "/expenses/{id}/mark-overdue",
450 tag = "Expenses",
451 summary = "Marquer une dépense en retard",
452 responses(
453 (status = 200, description = "Dépense mise à jour"),
454 (status = 401, description = "Non authentifié"),
455 (status = 404, description = "Introuvable"),
456 ),
457 security(("bearer_auth" = []))
458)]
459#[post("/expenses/{id}/mark-overdue")]
460pub async fn mark_expense_overdue(
461 state: web::Data<AppState>,
462 user: AuthenticatedUser,
463 id: web::Path<Uuid>,
464) -> impl Responder {
465 if let Some(refus) = verify_expense_org_access(&state, &user, *id).await {
467 return refus;
468 }
469
470 match state.expense_use_cases.mark_as_overdue(*id).await {
471 Ok(expense) => {
472 AuditLogEntry::new(
473 AuditEventType::ExpenseMarkedOverdue,
474 Some(user.user_id),
475 user.organization_id,
476 )
477 .with_resource("Expense", *id)
478 .log();
479
480 HttpResponse::Ok().json(expense)
481 }
482 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
483 "error": err
484 })),
485 }
486}
487
488#[utoipa::path(
489 post,
490 path = "/expenses/{id}/cancel",
491 tag = "Expenses",
492 summary = "Annuler une dépense",
493 responses(
494 (status = 200, description = "Dépense annulée"),
495 (status = 401, description = "Non authentifié"),
496 (status = 404, description = "Introuvable"),
497 ),
498 security(("bearer_auth" = []))
499)]
500#[post("/expenses/{id}/cancel")]
501pub async fn cancel_expense(
502 state: web::Data<AppState>,
503 user: AuthenticatedUser,
504 id: web::Path<Uuid>,
505) -> impl Responder {
506 if let Some(refus) = verify_expense_org_access(&state, &user, *id).await {
508 return refus;
509 }
510
511 match state.expense_use_cases.cancel_expense(*id).await {
512 Ok(expense) => {
513 AuditLogEntry::new(
514 AuditEventType::ExpenseCancelled,
515 Some(user.user_id),
516 user.organization_id,
517 )
518 .with_resource("Expense", *id)
519 .log();
520
521 HttpResponse::Ok().json(expense)
522 }
523 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
524 "error": err
525 })),
526 }
527}
528
529#[utoipa::path(
530 post,
531 path = "/expenses/{id}/reactivate",
532 tag = "Expenses",
533 summary = "Réactiver une dépense annulée",
534 responses(
535 (status = 200, description = "Dépense réactivée"),
536 (status = 401, description = "Non authentifié"),
537 (status = 404, description = "Introuvable"),
538 ),
539 security(("bearer_auth" = []))
540)]
541#[post("/expenses/{id}/reactivate")]
542pub async fn reactivate_expense(
543 state: web::Data<AppState>,
544 user: AuthenticatedUser,
545 id: web::Path<Uuid>,
546) -> impl Responder {
547 if let Some(refus) = verify_expense_org_access(&state, &user, *id).await {
549 return refus;
550 }
551
552 match state.expense_use_cases.reactivate_expense(*id).await {
553 Ok(expense) => {
554 AuditLogEntry::new(
555 AuditEventType::ExpenseReactivated,
556 Some(user.user_id),
557 user.organization_id,
558 )
559 .with_resource("Expense", *id)
560 .log();
561
562 HttpResponse::Ok().json(expense)
563 }
564 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
565 "error": err
566 })),
567 }
568}
569
570#[utoipa::path(
571 post,
572 path = "/expenses/{id}/unpay",
573 tag = "Expenses",
574 summary = "Annuler le paiement d'une dépense",
575 responses(
576 (status = 200, description = "Dépense remise en attente"),
577 (status = 401, description = "Non authentifié"),
578 (status = 404, description = "Introuvable"),
579 ),
580 security(("bearer_auth" = []))
581)]
582#[post("/expenses/{id}/unpay")]
583pub async fn unpay_expense(
584 state: web::Data<AppState>,
585 user: AuthenticatedUser,
586 id: web::Path<Uuid>,
587) -> impl Responder {
588 if let Some(refus) = verify_expense_org_access(&state, &user, *id).await {
590 return refus;
591 }
592
593 match state.expense_use_cases.unpay_expense(*id).await {
594 Ok(expense) => {
595 AuditLogEntry::new(
596 AuditEventType::ExpenseUnpaid,
597 Some(user.user_id),
598 user.organization_id,
599 )
600 .with_resource("Expense", *id)
601 .log();
602
603 HttpResponse::Ok().json(expense)
604 }
605 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
606 "error": err
607 })),
608 }
609}
610
611#[utoipa::path(
615 post,
616 path = "/invoices/draft",
617 tag = "Expenses",
618 summary = "Créer une facture brouillon avec TVA",
619 request_body = CreateInvoiceDraftDto,
620 responses(
621 (status = 201, description = "Brouillon créé"),
622 (status = 400, description = "Requête invalide"),
623 (status = 401, description = "Non authentifié"),
624 (status = 403, description = "Rôle sans droit d'encodage"),
625 ),
626 security(("bearer_auth" = []))
627)]
628#[post("/invoices/draft")]
629pub async fn create_invoice_draft(
630 state: web::Data<AppState>,
631 user: AuthenticatedUser,
632 mut dto: web::Json<CreateInvoiceDraftDto>,
633) -> impl Responder {
634 if let Some(response) = verify_accountant_role(&user) {
635 return response;
636 }
637
638 let organization_id = match user.require_organization() {
640 Ok(org_id) => org_id,
641 Err(e) => {
642 return HttpResponse::Unauthorized().json(serde_json::json!({
643 "error": e.to_string()
644 }))
645 }
646 };
647 dto.organization_id = organization_id.to_string();
648
649 let building_uuid = match Uuid::parse_str(&dto.building_id) {
658 Ok(id) => id,
659 Err(_) => {
660 return HttpResponse::BadRequest()
661 .json(serde_json::json!({ "error": "Invalid building_id format" }))
662 }
663 };
664 if let Err(err) = verify_building_org_access(
665 &user,
666 building_uuid,
667 &state.building_use_cases,
668 &state.acp_use_cases,
669 )
670 .await
671 {
672 return err.error_response();
673 }
674
675 if let Err(errors) = dto.validate() {
676 return HttpResponse::BadRequest().json(serde_json::json!({
677 "error": "Validation failed",
678 "details": errors.to_string()
679 }));
680 }
681
682 match state
683 .expense_use_cases
684 .create_invoice_draft(dto.into_inner())
685 .await
686 {
687 Ok(invoice) => {
688 AuditLogEntry::new(
689 AuditEventType::ExpenseCreated,
690 Some(user.user_id),
691 Some(organization_id),
692 )
693 .with_resource("Invoice", Uuid::parse_str(&invoice.id).unwrap())
694 .log();
695
696 HttpResponse::Created().json(invoice)
697 }
698 Err(err) => {
699 AuditLogEntry::new(
700 AuditEventType::ExpenseCreated,
701 Some(user.user_id),
702 Some(organization_id),
703 )
704 .with_error(err.clone())
705 .log();
706
707 if let Some(resp) = try_build_conformity_response(&err) {
709 return resp;
710 }
711 HttpResponse::BadRequest().json(serde_json::json!({
712 "error": err
713 }))
714 }
715 }
716}
717
718#[utoipa::path(
720 put,
721 path = "/invoices/{id}",
722 tag = "Expenses",
723 summary = "Modifier une facture brouillon ou rejetée",
724 request_body = UpdateInvoiceDraftDto,
725 responses(
726 (status = 200, description = "Brouillon modifié"),
727 (status = 400, description = "Requête invalide"),
728 (status = 401, description = "Non authentifié"),
729 (status = 404, description = "Introuvable"),
730 ),
731 security(("bearer_auth" = []))
732)]
733#[put("/invoices/{id}")]
734pub async fn update_invoice_draft(
735 state: web::Data<AppState>,
736 user: AuthenticatedUser,
737 id: web::Path<Uuid>,
738 dto: web::Json<UpdateInvoiceDraftDto>,
739) -> impl Responder {
740 if let Some(response) = verify_accountant_role(&user) {
741 return response;
742 }
743
744 if let Err(errors) = dto.validate() {
745 return HttpResponse::BadRequest().json(serde_json::json!({
746 "error": "Validation failed",
747 "details": errors.to_string()
748 }));
749 }
750
751 match state
752 .expense_use_cases
753 .update_invoice_draft(*id, dto.into_inner())
754 .await
755 {
756 Ok(invoice) => {
757 AuditLogEntry::new(
758 AuditEventType::InvoiceUpdated,
759 Some(user.user_id),
760 user.organization_id,
761 )
762 .with_resource("Invoice", *id)
763 .log();
764
765 HttpResponse::Ok().json(invoice)
766 }
767 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
768 "error": err
769 })),
770 }
771}
772
773#[utoipa::path(
775 put,
776 path = "/invoices/{id}/submit",
777 tag = "Expenses",
778 summary = "Soumettre une facture pour validation",
779 request_body = SubmitForApprovalDto,
780 responses(
781 (status = 200, description = "Facture soumise"),
782 (status = 401, description = "Non authentifié"),
783 (status = 404, description = "Introuvable"),
784 ),
785 security(("bearer_auth" = []))
786)]
787#[put("/invoices/{id}/submit")]
788pub async fn submit_invoice_for_approval(
789 state: web::Data<AppState>,
790 user: AuthenticatedUser,
791 id: web::Path<Uuid>,
792) -> impl Responder {
793 if let Some(response) = verify_accountant_role(&user) {
794 return response;
795 }
796
797 match state
798 .expense_use_cases
799 .submit_for_approval(*id, SubmitForApprovalDto {})
800 .await
801 {
802 Ok(invoice) => {
803 AuditLogEntry::new(
804 AuditEventType::InvoiceSubmitted,
805 Some(user.user_id),
806 user.organization_id,
807 )
808 .with_resource("Invoice", *id)
809 .log();
810
811 HttpResponse::Ok().json(invoice)
812 }
813 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
814 "error": err
815 })),
816 }
817}
818
819#[utoipa::path(
822 put,
823 path = "/invoices/{id}/approve",
824 tag = "Expenses",
825 summary = "Approuver une facture",
826 request_body = ApproveInvoiceDto,
827 responses(
828 (status = 200, description = "Facture approuvée"),
829 (status = 401, description = "Non authentifié"),
830 (status = 403, description = "Rôle sans droit d'approbation"),
831 (status = 404, description = "Introuvable"),
832 ),
833 security(("bearer_auth" = []))
834)]
835#[put("/invoices/{id}/approve")]
836pub async fn approve_invoice(
837 state: web::Data<AppState>,
838 user: AuthenticatedUser,
839 id: web::Path<Uuid>,
840) -> impl Responder {
841 if let Some(response) = verify_syndic_role(&user) {
842 return response;
843 }
844
845 let dto = ApproveInvoiceDto {
846 approved_by_user_id: user.user_id.to_string(),
847 };
848
849 match state.expense_use_cases.approve_invoice(*id, dto).await {
850 Ok(invoice) => {
851 AuditLogEntry::new(
852 AuditEventType::InvoiceApproved,
853 Some(user.user_id),
854 user.organization_id,
855 )
856 .with_resource("Invoice", *id)
857 .log();
858
859 HttpResponse::Ok().json(invoice)
860 }
861 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
862 "error": err
863 })),
864 }
865}
866
867#[utoipa::path(
870 put,
871 path = "/invoices/{id}/reject",
872 tag = "Expenses",
873 summary = "Rejeter une facture avec motif",
874 request_body = RejectInvoiceDto,
875 responses(
876 (status = 200, description = "Facture rejetée"),
877 (status = 400, description = "Motif manquant"),
878 (status = 401, description = "Non authentifié"),
879 (status = 404, description = "Introuvable"),
880 ),
881 security(("bearer_auth" = []))
882)]
883#[put("/invoices/{id}/reject")]
884pub async fn reject_invoice(
885 state: web::Data<AppState>,
886 user: AuthenticatedUser,
887 id: web::Path<Uuid>,
888 dto: web::Json<RejectInvoiceDto>,
889) -> impl Responder {
890 if let Some(response) = verify_syndic_role(&user) {
891 return response;
892 }
893
894 if let Err(errors) = dto.validate() {
895 return HttpResponse::BadRequest().json(serde_json::json!({
896 "error": "Validation failed",
897 "details": errors.to_string()
898 }));
899 }
900
901 let mut reject_dto = dto.into_inner();
902 reject_dto.rejected_by_user_id = user.user_id.to_string();
903
904 match state
905 .expense_use_cases
906 .reject_invoice(*id, reject_dto)
907 .await
908 {
909 Ok(invoice) => {
910 AuditLogEntry::new(
911 AuditEventType::InvoiceRejected,
912 Some(user.user_id),
913 user.organization_id,
914 )
915 .with_resource("Invoice", *id)
916 .log();
917
918 HttpResponse::Ok().json(invoice)
919 }
920 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
921 "error": err
922 })),
923 }
924}
925
926#[utoipa::path(
929 get,
930 path = "/invoices/pending",
931 tag = "Expenses",
932 summary = "Lister les factures en attente d'approbation",
933 responses(
934 (status = 200, description = "Liste"),
935 (status = 401, description = "Non authentifié"),
936 ),
937 security(("bearer_auth" = []))
938)]
939#[get("/invoices/pending")]
940pub async fn get_pending_invoices(
941 state: web::Data<AppState>,
942 user: AuthenticatedUser,
943) -> impl Responder {
944 if let Some(response) = verify_syndic_role(&user) {
945 return response;
946 }
947
948 let organization_id = match user.require_organization() {
949 Ok(org_id) => org_id,
950 Err(e) => {
951 return HttpResponse::Unauthorized().json(serde_json::json!({
952 "error": e.to_string()
953 }))
954 }
955 };
956
957 match state
958 .expense_use_cases
959 .get_pending_invoices(organization_id)
960 .await
961 {
962 Ok(pending_list) => HttpResponse::Ok().json(pending_list),
963 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
964 "error": err
965 })),
966 }
967}
968
969#[utoipa::path(
971 get,
972 path = "/invoices/{id}",
973 tag = "Expenses",
974 summary = "Lire une facture",
975 responses(
976 (status = 200, description = "Facture"),
977 (status = 401, description = "Non authentifié"),
978 (status = 404, description = "Introuvable"),
979 ),
980 security(("bearer_auth" = []))
981)]
982#[get("/invoices/{id}")]
983pub async fn get_invoice(
984 state: web::Data<AppState>,
985 user: AuthenticatedUser,
986 id: web::Path<Uuid>,
987) -> impl Responder {
988 match state.expense_use_cases.get_invoice(*id).await {
989 Ok(Some(invoice)) => {
990 if let Ok(building_id) = Uuid::parse_str(&invoice.building_id) {
992 if let Ok(Some(building)) = state.building_use_cases.get_building(building_id).await
993 {
994 let acp_id = match Uuid::parse_str(&building.acp_id) {
995 Ok(id) => id,
996 Err(_) => {
997 return HttpResponse::InternalServerError().json(serde_json::json!({
998 "error": "Invalid building.acp_id format"
999 }));
1000 }
1001 };
1002 if let Err(err) =
1003 verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await
1004 {
1005 return err.error_response();
1006 }
1007 }
1008 }
1009 HttpResponse::Ok().json(invoice)
1010 }
1011 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
1012 "error": "Invoice not found"
1013 })),
1014 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
1015 "error": err
1016 })),
1017 }
1018}
1019
1020#[derive(Debug, Deserialize)]
1026pub struct ExportWorkQuoteQuery {
1027 pub contractor_name: String,
1028 pub contractor_contact: String,
1029 pub timeline: String, }
1031
1032#[utoipa::path(
1033 get,
1034 path = "/expenses/{id}/export-quote-pdf",
1035 tag = "Expenses",
1036 summary = "Exporter un devis en PDF",
1037 responses(
1038 (status = 200, description = "PDF"),
1039 (status = 401, description = "Non authentifié"),
1040 (status = 404, description = "Introuvable"),
1041 ),
1042 security(("bearer_auth" = []))
1043)]
1044#[get("/expenses/{id}/export-quote-pdf")]
1045pub async fn export_work_quote_pdf(
1046 state: web::Data<AppState>,
1047 user: AuthenticatedUser,
1048 id: web::Path<Uuid>,
1049 query: web::Query<ExportWorkQuoteQuery>,
1050) -> impl Responder {
1051 use crate::domain::entities::{Building, Expense};
1052 use crate::domain::services::{QuoteLineItem, WorkQuoteExporter};
1053
1054 let organization_id = match user.require_organization() {
1055 Ok(org_id) => org_id,
1056 Err(e) => {
1057 return HttpResponse::Unauthorized().json(serde_json::json!({
1058 "error": e.to_string()
1059 }))
1060 }
1061 };
1062
1063 let expense_id = *id;
1064
1065 let expense_dto = match state.expense_use_cases.get_expense(expense_id).await {
1067 Ok(Some(dto)) => dto,
1068 Ok(None) => {
1069 return HttpResponse::NotFound().json(serde_json::json!({
1070 "error": "Expense not found"
1071 }))
1072 }
1073 Err(err) => {
1074 return HttpResponse::InternalServerError().json(serde_json::json!({
1075 "error": err
1076 }))
1077 }
1078 };
1079
1080 let expense_building_id = match Uuid::parse_str(&expense_dto.building_id) {
1082 Ok(id) => id,
1083 Err(e) => {
1084 return HttpResponse::BadRequest().json(serde_json::json!({
1085 "error": format!("Invalid building_id: {}", e)
1086 }))
1087 }
1088 };
1089 let expense_id_uuid = match Uuid::parse_str(&expense_dto.id) {
1090 Ok(id) => id,
1091 Err(e) => {
1092 return HttpResponse::BadRequest().json(serde_json::json!({
1093 "error": format!("Invalid expense_id: {}", e)
1094 }))
1095 }
1096 };
1097
1098 let building_dto = match state
1100 .building_use_cases
1101 .get_building(expense_building_id)
1102 .await
1103 {
1104 Ok(Some(dto)) => dto,
1105 Ok(None) => {
1106 return HttpResponse::NotFound().json(serde_json::json!({
1107 "error": "Building not found"
1108 }))
1109 }
1110 Err(err) => {
1111 return HttpResponse::InternalServerError().json(serde_json::json!({
1112 "error": err
1113 }))
1114 }
1115 };
1116
1117 let _ = organization_id;
1122 let building_acp_id = Uuid::parse_str(&building_dto.acp_id).unwrap_or_else(|_| Uuid::new_v4());
1123
1124 let building_created_at = DateTime::parse_from_rfc3339(&building_dto.created_at)
1125 .map(|dt| dt.with_timezone(&Utc))
1126 .unwrap_or_else(|_| Utc::now());
1127
1128 let building_updated_at = DateTime::parse_from_rfc3339(&building_dto.updated_at)
1129 .map(|dt| dt.with_timezone(&Utc))
1130 .unwrap_or_else(|_| Utc::now());
1131
1132 let building_entity = Building {
1133 id: Uuid::parse_str(&building_dto.id).unwrap_or(expense_building_id),
1134 name: building_dto.name.clone(),
1135 address: building_dto.address,
1136 city: building_dto.city,
1137 postal_code: building_dto.postal_code,
1138 country: building_dto.country,
1139 total_units: building_dto.total_units,
1140 total_tantiemes: building_dto.total_tantiemes,
1141 construction_year: building_dto.construction_year,
1142 syndic_name: None,
1143 syndic_email: None,
1144 syndic_phone: None,
1145 syndic_address: None,
1146 syndic_office_hours: None,
1147 syndic_emergency_contact: None,
1148 slug: None,
1149 acp_id: building_acp_id,
1150 created_at: building_created_at,
1151 updated_at: building_updated_at,
1152 };
1153
1154 let expense_date = DateTime::parse_from_rfc3339(&expense_dto.expense_date)
1155 .map(|dt| dt.with_timezone(&Utc))
1156 .unwrap_or_else(|_| Utc::now());
1157
1158 let expense_entity = Expense {
1159 id: expense_id_uuid,
1160 acp_id: Uuid::parse_str(&expense_dto.acp_id).unwrap_or(organization_id),
1161 organization_id,
1162 building_id: expense_building_id,
1163 category: expense_dto.category.clone(),
1164 description: expense_dto.description.clone(),
1165 amount: expense_dto.amount,
1166 amount_excl_vat: None,
1167 vat_rate: None,
1168 vat_amount: None,
1169 amount_incl_vat: None,
1170 expense_date,
1171 invoice_date: None,
1172 due_date: None,
1173 paid_date: None,
1174 approval_status: expense_dto.approval_status.clone(),
1175 submitted_at: None,
1176 approved_by: None,
1177 approved_at: None,
1178 rejection_reason: None,
1179 payment_status: expense_dto.payment_status.clone(),
1180 supplier: expense_dto.supplier.clone(),
1181 invoice_number: expense_dto.invoice_number.clone(),
1182 account_code: expense_dto.account_code.clone(),
1183 contractor_report_id: None,
1184 created_at: Utc::now(),
1185 updated_at: Utc::now(),
1186 };
1187
1188 let quote_line_items: Vec<QuoteLineItem> = vec![QuoteLineItem {
1194 description: expense_dto.description.clone(),
1195 quantity: rust_decimal::Decimal::ONE,
1196 unit_price: expense_dto.amount,
1197 total: expense_dto.amount,
1198 }];
1199
1200 match WorkQuoteExporter::export_to_pdf(
1202 &building_entity,
1203 &expense_entity,
1204 "e_line_items,
1205 &query.contractor_name,
1206 &query.contractor_contact,
1207 &query.timeline,
1208 ) {
1209 Ok(pdf_bytes) => {
1210 AuditLogEntry::new(
1212 AuditEventType::ReportGenerated,
1213 Some(user.user_id),
1214 Some(organization_id),
1215 )
1216 .with_resource("Expense", expense_id)
1217 .with_metadata(serde_json::json!({
1218 "report_type": "work_quote_pdf",
1219 "building_name": building_entity.name,
1220 "contractor_name": query.contractor_name,
1221 "amount": expense_dto.amount
1222 }))
1223 .log();
1224
1225 HttpResponse::Ok()
1226 .content_type("application/pdf")
1227 .insert_header((
1228 "Content-Disposition",
1229 format!(
1230 "attachment; filename=\"Devis_Travaux_{}_{}.pdf\"",
1231 building_entity.name.replace(' ', "_"),
1232 expense_entity.id
1233 ),
1234 ))
1235 .body(pdf_bytes)
1236 }
1237 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
1238 "error": format!("Failed to generate PDF: {}", err)
1239 })),
1240 }
1241}