1use crate::application::dto::{
2 ApproveInvoiceDto, CreateExpenseDto, CreateInvoiceDraftDto, PageRequest, PageResponse,
3 RejectInvoiceDto, SubmitForApprovalDto, UpdateInvoiceDraftDto,
4};
5use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
6use crate::infrastructure::web::{AppState, AuthenticatedUser};
7use actix_web::{get, post, put, web, HttpResponse, Responder};
8use chrono::{DateTime, Utc};
9use serde::Deserialize;
10use uuid::Uuid;
11use validator::Validate;
12
13fn check_owner_readonly(user: &AuthenticatedUser) -> Option<HttpResponse> {
16 if user.role == "owner" {
17 Some(HttpResponse::Forbidden().json(serde_json::json!({
18 "error": "Owner role has read-only access"
19 })))
20 } else {
21 None
22 }
23}
24
25fn check_syndic_role(user: &AuthenticatedUser) -> Option<HttpResponse> {
27 if user.role != "syndic" && user.role != "superadmin" {
28 Some(HttpResponse::Forbidden().json(serde_json::json!({
29 "error": "Only syndic or superadmin can approve/reject invoices"
30 })))
31 } else {
32 None
33 }
34}
35
36fn check_accountant_role(user: &AuthenticatedUser) -> Option<HttpResponse> {
38 if user.role != "accountant" && user.role != "syndic" && user.role != "superadmin" {
39 Some(HttpResponse::Forbidden().json(serde_json::json!({
40 "error": "Only accountant, syndic, or superadmin can create/edit invoices"
41 })))
42 } else {
43 None
44 }
45}
46
47#[post("/expenses")]
48pub async fn create_expense(
49 state: web::Data<AppState>,
50 user: AuthenticatedUser, mut dto: web::Json<CreateExpenseDto>,
52) -> impl Responder {
53 if let Some(response) = check_owner_readonly(&user) {
54 return response;
55 }
56
57 let organization_id = match user.require_organization() {
60 Ok(org_id) => org_id,
61 Err(e) => {
62 return HttpResponse::Unauthorized().json(serde_json::json!({
63 "error": e.to_string()
64 }))
65 }
66 };
67 dto.organization_id = organization_id.to_string();
68
69 if let Err(errors) = dto.validate() {
70 return HttpResponse::BadRequest().json(serde_json::json!({
71 "error": "Validation failed",
72 "details": errors.to_string()
73 }));
74 }
75
76 match state
77 .expense_use_cases
78 .create_expense(dto.into_inner())
79 .await
80 {
81 Ok(expense) => {
82 AuditLogEntry::new(
84 AuditEventType::ExpenseCreated,
85 Some(user.user_id),
86 Some(organization_id),
87 )
88 .with_resource("Expense", Uuid::parse_str(&expense.id).unwrap())
89 .log();
90
91 HttpResponse::Created().json(expense)
92 }
93 Err(err) => {
94 AuditLogEntry::new(
96 AuditEventType::ExpenseCreated,
97 Some(user.user_id),
98 Some(organization_id),
99 )
100 .with_error(err.clone())
101 .log();
102
103 HttpResponse::BadRequest().json(serde_json::json!({
104 "error": err
105 }))
106 }
107 }
108}
109
110#[get("/expenses/{id}")]
111pub async fn get_expense(
112 state: web::Data<AppState>,
113 user: AuthenticatedUser,
114 id: web::Path<Uuid>,
115) -> impl Responder {
116 match state.expense_use_cases.get_expense(*id).await {
117 Ok(Some(expense)) => {
118 if let Ok(building_id) = Uuid::parse_str(&expense.building_id) {
120 if let Ok(Some(building)) = state.building_use_cases.get_building(building_id).await
121 {
122 if let Ok(building_org) = Uuid::parse_str(&building.organization_id) {
123 if let Err(e) = user.verify_org_access(building_org) {
124 return HttpResponse::Forbidden()
125 .json(serde_json::json!({ "error": e }));
126 }
127 }
128 }
129 }
130 HttpResponse::Ok().json(expense)
131 }
132 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
133 "error": "Expense not found"
134 })),
135 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
136 "error": err
137 })),
138 }
139}
140
141#[get("/expenses")]
142pub async fn list_expenses(
143 state: web::Data<AppState>,
144 user: AuthenticatedUser,
145 page_request: web::Query<PageRequest>,
146) -> impl Responder {
147 let organization_id = user.organization_id;
148
149 match state
150 .expense_use_cases
151 .list_expenses_paginated(&page_request, organization_id)
152 .await
153 {
154 Ok((expenses, total)) => {
155 let response =
156 PageResponse::new(expenses, page_request.page, page_request.per_page, total);
157 HttpResponse::Ok().json(response)
158 }
159 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
160 "error": err
161 })),
162 }
163}
164
165#[get("/buildings/{building_id}/expenses")]
166pub async fn list_expenses_by_building(
167 state: web::Data<AppState>,
168 user: AuthenticatedUser,
169 building_id: web::Path<Uuid>,
170) -> impl Responder {
171 match state.building_use_cases.get_building(*building_id).await {
173 Ok(Some(building)) => {
174 if let Ok(building_org) = Uuid::parse_str(&building.organization_id) {
175 if let Err(e) = user.verify_org_access(building_org) {
176 return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
177 }
178 }
179 }
180 Ok(None) => {
181 return HttpResponse::NotFound().json(serde_json::json!({
182 "error": "Building not found"
183 }));
184 }
185 Err(err) => {
186 return HttpResponse::InternalServerError().json(serde_json::json!({
187 "error": err
188 }));
189 }
190 }
191
192 match state
193 .expense_use_cases
194 .list_expenses_by_building(*building_id)
195 .await
196 {
197 Ok(expenses) => HttpResponse::Ok().json(expenses),
198 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
199 "error": err
200 })),
201 }
202}
203
204#[put("/expenses/{id}/mark-paid")]
205pub async fn mark_expense_paid(
206 state: web::Data<AppState>,
207 user: AuthenticatedUser,
208 id: web::Path<Uuid>,
209) -> impl Responder {
210 if let Some(response) = check_owner_readonly(&user) {
211 return response;
212 }
213
214 match state.expense_use_cases.mark_as_paid(*id).await {
215 Ok(expense) => {
216 AuditLogEntry::new(
218 AuditEventType::ExpenseMarkedPaid,
219 Some(user.user_id),
220 user.organization_id,
221 )
222 .with_resource("Expense", *id)
223 .log();
224
225 HttpResponse::Ok().json(expense)
226 }
227 Err(err) => {
228 AuditLogEntry::new(
230 AuditEventType::ExpenseMarkedPaid,
231 Some(user.user_id),
232 user.organization_id,
233 )
234 .with_resource("Expense", *id)
235 .with_error(err.clone())
236 .log();
237
238 HttpResponse::BadRequest().json(serde_json::json!({
239 "error": err
240 }))
241 }
242 }
243}
244
245#[post("/expenses/{id}/mark-overdue")]
246pub async fn mark_expense_overdue(
247 state: web::Data<AppState>,
248 user: AuthenticatedUser,
249 id: web::Path<Uuid>,
250) -> impl Responder {
251 match state.expense_use_cases.mark_as_overdue(*id).await {
252 Ok(expense) => {
253 AuditLogEntry::new(
254 AuditEventType::ExpenseMarkedPaid,
255 Some(user.user_id),
256 user.organization_id,
257 )
258 .with_resource("Expense", *id)
259 .log();
260
261 HttpResponse::Ok().json(expense)
262 }
263 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
264 "error": err
265 })),
266 }
267}
268
269#[post("/expenses/{id}/cancel")]
270pub async fn cancel_expense(
271 state: web::Data<AppState>,
272 user: AuthenticatedUser,
273 id: web::Path<Uuid>,
274) -> impl Responder {
275 match state.expense_use_cases.cancel_expense(*id).await {
276 Ok(expense) => {
277 AuditLogEntry::new(
278 AuditEventType::ExpenseMarkedPaid,
279 Some(user.user_id),
280 user.organization_id,
281 )
282 .with_resource("Expense", *id)
283 .log();
284
285 HttpResponse::Ok().json(expense)
286 }
287 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
288 "error": err
289 })),
290 }
291}
292
293#[post("/expenses/{id}/reactivate")]
294pub async fn reactivate_expense(
295 state: web::Data<AppState>,
296 user: AuthenticatedUser,
297 id: web::Path<Uuid>,
298) -> impl Responder {
299 match state.expense_use_cases.reactivate_expense(*id).await {
300 Ok(expense) => {
301 AuditLogEntry::new(
302 AuditEventType::ExpenseMarkedPaid,
303 Some(user.user_id),
304 user.organization_id,
305 )
306 .with_resource("Expense", *id)
307 .log();
308
309 HttpResponse::Ok().json(expense)
310 }
311 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
312 "error": err
313 })),
314 }
315}
316
317#[post("/expenses/{id}/unpay")]
318pub async fn unpay_expense(
319 state: web::Data<AppState>,
320 user: AuthenticatedUser,
321 id: web::Path<Uuid>,
322) -> impl Responder {
323 match state.expense_use_cases.unpay_expense(*id).await {
324 Ok(expense) => {
325 AuditLogEntry::new(
326 AuditEventType::ExpenseMarkedPaid,
327 Some(user.user_id),
328 user.organization_id,
329 )
330 .with_resource("Expense", *id)
331 .log();
332
333 HttpResponse::Ok().json(expense)
334 }
335 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
336 "error": err
337 })),
338 }
339}
340
341#[post("/invoices/draft")]
345pub async fn create_invoice_draft(
346 state: web::Data<AppState>,
347 user: AuthenticatedUser,
348 mut dto: web::Json<CreateInvoiceDraftDto>,
349) -> impl Responder {
350 if let Some(response) = check_accountant_role(&user) {
351 return response;
352 }
353
354 let organization_id = match user.require_organization() {
356 Ok(org_id) => org_id,
357 Err(e) => {
358 return HttpResponse::Unauthorized().json(serde_json::json!({
359 "error": e.to_string()
360 }))
361 }
362 };
363 dto.organization_id = organization_id.to_string();
364
365 if let Err(errors) = dto.validate() {
366 return HttpResponse::BadRequest().json(serde_json::json!({
367 "error": "Validation failed",
368 "details": errors.to_string()
369 }));
370 }
371
372 match state
373 .expense_use_cases
374 .create_invoice_draft(dto.into_inner())
375 .await
376 {
377 Ok(invoice) => {
378 AuditLogEntry::new(
379 AuditEventType::ExpenseCreated,
380 Some(user.user_id),
381 Some(organization_id),
382 )
383 .with_resource("Invoice", Uuid::parse_str(&invoice.id).unwrap())
384 .log();
385
386 HttpResponse::Created().json(invoice)
387 }
388 Err(err) => {
389 AuditLogEntry::new(
390 AuditEventType::ExpenseCreated,
391 Some(user.user_id),
392 Some(organization_id),
393 )
394 .with_error(err.clone())
395 .log();
396
397 HttpResponse::BadRequest().json(serde_json::json!({
398 "error": err
399 }))
400 }
401 }
402}
403
404#[put("/invoices/{id}")]
406pub async fn update_invoice_draft(
407 state: web::Data<AppState>,
408 user: AuthenticatedUser,
409 id: web::Path<Uuid>,
410 dto: web::Json<UpdateInvoiceDraftDto>,
411) -> impl Responder {
412 if let Some(response) = check_accountant_role(&user) {
413 return response;
414 }
415
416 if let Err(errors) = dto.validate() {
417 return HttpResponse::BadRequest().json(serde_json::json!({
418 "error": "Validation failed",
419 "details": errors.to_string()
420 }));
421 }
422
423 match state
424 .expense_use_cases
425 .update_invoice_draft(*id, dto.into_inner())
426 .await
427 {
428 Ok(invoice) => {
429 AuditLogEntry::new(
430 AuditEventType::InvoiceUpdated,
431 Some(user.user_id),
432 user.organization_id,
433 )
434 .with_resource("Invoice", *id)
435 .log();
436
437 HttpResponse::Ok().json(invoice)
438 }
439 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
440 "error": err
441 })),
442 }
443}
444
445#[put("/invoices/{id}/submit")]
447pub async fn submit_invoice_for_approval(
448 state: web::Data<AppState>,
449 user: AuthenticatedUser,
450 id: web::Path<Uuid>,
451) -> impl Responder {
452 if let Some(response) = check_accountant_role(&user) {
453 return response;
454 }
455
456 match state
457 .expense_use_cases
458 .submit_for_approval(*id, SubmitForApprovalDto {})
459 .await
460 {
461 Ok(invoice) => {
462 AuditLogEntry::new(
463 AuditEventType::InvoiceSubmitted,
464 Some(user.user_id),
465 user.organization_id,
466 )
467 .with_resource("Invoice", *id)
468 .log();
469
470 HttpResponse::Ok().json(invoice)
471 }
472 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
473 "error": err
474 })),
475 }
476}
477
478#[put("/invoices/{id}/approve")]
481pub async fn approve_invoice(
482 state: web::Data<AppState>,
483 user: AuthenticatedUser,
484 id: web::Path<Uuid>,
485) -> impl Responder {
486 if let Some(response) = check_syndic_role(&user) {
487 return response;
488 }
489
490 let dto = ApproveInvoiceDto {
491 approved_by_user_id: user.user_id.to_string(),
492 };
493
494 match state.expense_use_cases.approve_invoice(*id, dto).await {
495 Ok(invoice) => {
496 AuditLogEntry::new(
497 AuditEventType::InvoiceApproved,
498 Some(user.user_id),
499 user.organization_id,
500 )
501 .with_resource("Invoice", *id)
502 .log();
503
504 HttpResponse::Ok().json(invoice)
505 }
506 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
507 "error": err
508 })),
509 }
510}
511
512#[put("/invoices/{id}/reject")]
515pub async fn reject_invoice(
516 state: web::Data<AppState>,
517 user: AuthenticatedUser,
518 id: web::Path<Uuid>,
519 dto: web::Json<RejectInvoiceDto>,
520) -> impl Responder {
521 if let Some(response) = check_syndic_role(&user) {
522 return response;
523 }
524
525 if let Err(errors) = dto.validate() {
526 return HttpResponse::BadRequest().json(serde_json::json!({
527 "error": "Validation failed",
528 "details": errors.to_string()
529 }));
530 }
531
532 let mut reject_dto = dto.into_inner();
533 reject_dto.rejected_by_user_id = user.user_id.to_string();
534
535 match state
536 .expense_use_cases
537 .reject_invoice(*id, reject_dto)
538 .await
539 {
540 Ok(invoice) => {
541 AuditLogEntry::new(
542 AuditEventType::InvoiceRejected,
543 Some(user.user_id),
544 user.organization_id,
545 )
546 .with_resource("Invoice", *id)
547 .log();
548
549 HttpResponse::Ok().json(invoice)
550 }
551 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
552 "error": err
553 })),
554 }
555}
556
557#[get("/invoices/pending")]
560pub async fn get_pending_invoices(
561 state: web::Data<AppState>,
562 user: AuthenticatedUser,
563) -> impl Responder {
564 if let Some(response) = check_syndic_role(&user) {
565 return response;
566 }
567
568 let organization_id = match user.require_organization() {
569 Ok(org_id) => org_id,
570 Err(e) => {
571 return HttpResponse::Unauthorized().json(serde_json::json!({
572 "error": e.to_string()
573 }))
574 }
575 };
576
577 match state
578 .expense_use_cases
579 .get_pending_invoices(organization_id)
580 .await
581 {
582 Ok(pending_list) => HttpResponse::Ok().json(pending_list),
583 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
584 "error": err
585 })),
586 }
587}
588
589#[get("/invoices/{id}")]
591pub async fn get_invoice(
592 state: web::Data<AppState>,
593 user: AuthenticatedUser,
594 id: web::Path<Uuid>,
595) -> impl Responder {
596 match state.expense_use_cases.get_invoice(*id).await {
597 Ok(Some(invoice)) => {
598 if let Ok(building_id) = Uuid::parse_str(&invoice.building_id) {
600 if let Ok(Some(building)) = state.building_use_cases.get_building(building_id).await
601 {
602 if let Ok(building_org) = Uuid::parse_str(&building.organization_id) {
603 if let Err(e) = user.verify_org_access(building_org) {
604 return HttpResponse::Forbidden()
605 .json(serde_json::json!({ "error": e }));
606 }
607 }
608 }
609 }
610 HttpResponse::Ok().json(invoice)
611 }
612 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
613 "error": "Invoice not found"
614 })),
615 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
616 "error": err
617 })),
618 }
619}
620
621#[derive(Debug, Deserialize)]
627pub struct ExportWorkQuoteQuery {
628 pub contractor_name: String,
629 pub contractor_contact: String,
630 pub timeline: String, }
632
633#[get("/expenses/{id}/export-quote-pdf")]
634pub async fn export_work_quote_pdf(
635 state: web::Data<AppState>,
636 user: AuthenticatedUser,
637 id: web::Path<Uuid>,
638 query: web::Query<ExportWorkQuoteQuery>,
639) -> impl Responder {
640 use crate::domain::entities::{Building, Expense};
641 use crate::domain::services::{QuoteLineItem, WorkQuoteExporter};
642
643 let organization_id = match user.require_organization() {
644 Ok(org_id) => org_id,
645 Err(e) => {
646 return HttpResponse::Unauthorized().json(serde_json::json!({
647 "error": e.to_string()
648 }))
649 }
650 };
651
652 let expense_id = *id;
653
654 let expense_dto = match state.expense_use_cases.get_expense(expense_id).await {
656 Ok(Some(dto)) => dto,
657 Ok(None) => {
658 return HttpResponse::NotFound().json(serde_json::json!({
659 "error": "Expense not found"
660 }))
661 }
662 Err(err) => {
663 return HttpResponse::InternalServerError().json(serde_json::json!({
664 "error": err
665 }))
666 }
667 };
668
669 let expense_building_id = match Uuid::parse_str(&expense_dto.building_id) {
671 Ok(id) => id,
672 Err(e) => {
673 return HttpResponse::BadRequest().json(serde_json::json!({
674 "error": format!("Invalid building_id: {}", e)
675 }))
676 }
677 };
678 let expense_id_uuid = match Uuid::parse_str(&expense_dto.id) {
679 Ok(id) => id,
680 Err(e) => {
681 return HttpResponse::BadRequest().json(serde_json::json!({
682 "error": format!("Invalid expense_id: {}", e)
683 }))
684 }
685 };
686
687 let building_dto = match state
689 .building_use_cases
690 .get_building(expense_building_id)
691 .await
692 {
693 Ok(Some(dto)) => dto,
694 Ok(None) => {
695 return HttpResponse::NotFound().json(serde_json::json!({
696 "error": "Building not found"
697 }))
698 }
699 Err(err) => {
700 return HttpResponse::InternalServerError().json(serde_json::json!({
701 "error": err
702 }))
703 }
704 };
705
706 let building_org_id = Uuid::parse_str(&building_dto.organization_id).unwrap_or(organization_id);
708
709 let building_created_at = DateTime::parse_from_rfc3339(&building_dto.created_at)
710 .map(|dt| dt.with_timezone(&Utc))
711 .unwrap_or_else(|_| Utc::now());
712
713 let building_updated_at = DateTime::parse_from_rfc3339(&building_dto.updated_at)
714 .map(|dt| dt.with_timezone(&Utc))
715 .unwrap_or_else(|_| Utc::now());
716
717 let building_entity = Building {
718 id: Uuid::parse_str(&building_dto.id).unwrap_or(expense_building_id),
719 name: building_dto.name.clone(),
720 address: building_dto.address,
721 city: building_dto.city,
722 postal_code: building_dto.postal_code,
723 country: building_dto.country,
724 total_units: building_dto.total_units,
725 total_tantiemes: building_dto.total_tantiemes,
726 construction_year: building_dto.construction_year,
727 syndic_name: None,
728 syndic_email: None,
729 syndic_phone: None,
730 syndic_address: None,
731 syndic_office_hours: None,
732 syndic_emergency_contact: None,
733 slug: None,
734 organization_id: building_org_id,
735 created_at: building_created_at,
736 updated_at: building_updated_at,
737 };
738
739 let expense_date = DateTime::parse_from_rfc3339(&expense_dto.expense_date)
740 .map(|dt| dt.with_timezone(&Utc))
741 .unwrap_or_else(|_| Utc::now());
742
743 let expense_entity = Expense {
744 id: expense_id_uuid,
745 organization_id,
746 building_id: expense_building_id,
747 category: expense_dto.category.clone(),
748 description: expense_dto.description.clone(),
749 amount: expense_dto.amount,
750 amount_excl_vat: None,
751 vat_rate: None,
752 vat_amount: None,
753 amount_incl_vat: None,
754 expense_date,
755 invoice_date: None,
756 due_date: None,
757 paid_date: None,
758 approval_status: expense_dto.approval_status.clone(),
759 submitted_at: None,
760 approved_by: None,
761 approved_at: None,
762 rejection_reason: None,
763 payment_status: expense_dto.payment_status.clone(),
764 supplier: expense_dto.supplier.clone(),
765 invoice_number: expense_dto.invoice_number.clone(),
766 account_code: expense_dto.account_code.clone(),
767 contractor_report_id: None,
768 created_at: Utc::now(),
769 updated_at: Utc::now(),
770 };
771
772 let quote_line_items: Vec<QuoteLineItem> = vec![QuoteLineItem {
778 description: expense_dto.description.clone(),
779 quantity: 1.0,
780 unit_price: expense_dto.amount,
781 total: expense_dto.amount,
782 }];
783
784 match WorkQuoteExporter::export_to_pdf(
786 &building_entity,
787 &expense_entity,
788 "e_line_items,
789 &query.contractor_name,
790 &query.contractor_contact,
791 &query.timeline,
792 ) {
793 Ok(pdf_bytes) => {
794 AuditLogEntry::new(
796 AuditEventType::ReportGenerated,
797 Some(user.user_id),
798 Some(organization_id),
799 )
800 .with_resource("Expense", expense_id)
801 .with_metadata(serde_json::json!({
802 "report_type": "work_quote_pdf",
803 "building_name": building_entity.name,
804 "contractor_name": query.contractor_name,
805 "amount": expense_dto.amount
806 }))
807 .log();
808
809 HttpResponse::Ok()
810 .content_type("application/pdf")
811 .insert_header((
812 "Content-Disposition",
813 format!(
814 "attachment; filename=\"Devis_Travaux_{}_{}.pdf\"",
815 building_entity.name.replace(' ', "_"),
816 expense_entity.id
817 ),
818 ))
819 .body(pdf_bytes)
820 }
821 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
822 "error": format!("Failed to generate PDF: {}", err)
823 })),
824 }
825}