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(state: web::Data<AppState>, id: web::Path<Uuid>) -> impl Responder {
112 match state.expense_use_cases.get_expense(*id).await {
113 Ok(Some(expense)) => HttpResponse::Ok().json(expense),
114 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
115 "error": "Expense not found"
116 })),
117 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
118 "error": err
119 })),
120 }
121}
122
123#[get("/expenses")]
124pub async fn list_expenses(
125 state: web::Data<AppState>,
126 user: AuthenticatedUser,
127 page_request: web::Query<PageRequest>,
128) -> impl Responder {
129 let organization_id = user.organization_id;
130
131 match state
132 .expense_use_cases
133 .list_expenses_paginated(&page_request, organization_id)
134 .await
135 {
136 Ok((expenses, total)) => {
137 let response =
138 PageResponse::new(expenses, page_request.page, page_request.per_page, total);
139 HttpResponse::Ok().json(response)
140 }
141 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
142 "error": err
143 })),
144 }
145}
146
147#[get("/buildings/{building_id}/expenses")]
148pub async fn list_expenses_by_building(
149 state: web::Data<AppState>,
150 building_id: web::Path<Uuid>,
151) -> impl Responder {
152 match state
153 .expense_use_cases
154 .list_expenses_by_building(*building_id)
155 .await
156 {
157 Ok(expenses) => HttpResponse::Ok().json(expenses),
158 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
159 "error": err
160 })),
161 }
162}
163
164#[put("/expenses/{id}/mark-paid")]
165pub async fn mark_expense_paid(
166 state: web::Data<AppState>,
167 user: AuthenticatedUser,
168 id: web::Path<Uuid>,
169) -> impl Responder {
170 if let Some(response) = check_owner_readonly(&user) {
171 return response;
172 }
173
174 match state.expense_use_cases.mark_as_paid(*id).await {
175 Ok(expense) => {
176 AuditLogEntry::new(
178 AuditEventType::ExpenseMarkedPaid,
179 Some(user.user_id),
180 user.organization_id,
181 )
182 .with_resource("Expense", *id)
183 .log();
184
185 HttpResponse::Ok().json(expense)
186 }
187 Err(err) => {
188 AuditLogEntry::new(
190 AuditEventType::ExpenseMarkedPaid,
191 Some(user.user_id),
192 user.organization_id,
193 )
194 .with_resource("Expense", *id)
195 .with_error(err.clone())
196 .log();
197
198 HttpResponse::BadRequest().json(serde_json::json!({
199 "error": err
200 }))
201 }
202 }
203}
204
205#[post("/expenses/{id}/mark-overdue")]
206pub async fn mark_expense_overdue(
207 state: web::Data<AppState>,
208 user: AuthenticatedUser,
209 id: web::Path<Uuid>,
210) -> impl Responder {
211 match state.expense_use_cases.mark_as_overdue(*id).await {
212 Ok(expense) => {
213 AuditLogEntry::new(
214 AuditEventType::ExpenseMarkedPaid,
215 Some(user.user_id),
216 user.organization_id,
217 )
218 .with_resource("Expense", *id)
219 .log();
220
221 HttpResponse::Ok().json(expense)
222 }
223 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
224 "error": err
225 })),
226 }
227}
228
229#[post("/expenses/{id}/cancel")]
230pub async fn cancel_expense(
231 state: web::Data<AppState>,
232 user: AuthenticatedUser,
233 id: web::Path<Uuid>,
234) -> impl Responder {
235 match state.expense_use_cases.cancel_expense(*id).await {
236 Ok(expense) => {
237 AuditLogEntry::new(
238 AuditEventType::ExpenseMarkedPaid,
239 Some(user.user_id),
240 user.organization_id,
241 )
242 .with_resource("Expense", *id)
243 .log();
244
245 HttpResponse::Ok().json(expense)
246 }
247 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
248 "error": err
249 })),
250 }
251}
252
253#[post("/expenses/{id}/reactivate")]
254pub async fn reactivate_expense(
255 state: web::Data<AppState>,
256 user: AuthenticatedUser,
257 id: web::Path<Uuid>,
258) -> impl Responder {
259 match state.expense_use_cases.reactivate_expense(*id).await {
260 Ok(expense) => {
261 AuditLogEntry::new(
262 AuditEventType::ExpenseMarkedPaid,
263 Some(user.user_id),
264 user.organization_id,
265 )
266 .with_resource("Expense", *id)
267 .log();
268
269 HttpResponse::Ok().json(expense)
270 }
271 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
272 "error": err
273 })),
274 }
275}
276
277#[post("/expenses/{id}/unpay")]
278pub async fn unpay_expense(
279 state: web::Data<AppState>,
280 user: AuthenticatedUser,
281 id: web::Path<Uuid>,
282) -> impl Responder {
283 match state.expense_use_cases.unpay_expense(*id).await {
284 Ok(expense) => {
285 AuditLogEntry::new(
286 AuditEventType::ExpenseMarkedPaid,
287 Some(user.user_id),
288 user.organization_id,
289 )
290 .with_resource("Expense", *id)
291 .log();
292
293 HttpResponse::Ok().json(expense)
294 }
295 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
296 "error": err
297 })),
298 }
299}
300
301#[post("/invoices/draft")]
305pub async fn create_invoice_draft(
306 state: web::Data<AppState>,
307 user: AuthenticatedUser,
308 mut dto: web::Json<CreateInvoiceDraftDto>,
309) -> impl Responder {
310 if let Some(response) = check_accountant_role(&user) {
311 return response;
312 }
313
314 let organization_id = match user.require_organization() {
316 Ok(org_id) => org_id,
317 Err(e) => {
318 return HttpResponse::Unauthorized().json(serde_json::json!({
319 "error": e.to_string()
320 }))
321 }
322 };
323 dto.organization_id = organization_id.to_string();
324
325 if let Err(errors) = dto.validate() {
326 return HttpResponse::BadRequest().json(serde_json::json!({
327 "error": "Validation failed",
328 "details": errors.to_string()
329 }));
330 }
331
332 match state
333 .expense_use_cases
334 .create_invoice_draft(dto.into_inner())
335 .await
336 {
337 Ok(invoice) => {
338 AuditLogEntry::new(
339 AuditEventType::ExpenseCreated,
340 Some(user.user_id),
341 Some(organization_id),
342 )
343 .with_resource("Invoice", Uuid::parse_str(&invoice.id).unwrap())
344 .log();
345
346 HttpResponse::Created().json(invoice)
347 }
348 Err(err) => {
349 AuditLogEntry::new(
350 AuditEventType::ExpenseCreated,
351 Some(user.user_id),
352 Some(organization_id),
353 )
354 .with_error(err.clone())
355 .log();
356
357 HttpResponse::BadRequest().json(serde_json::json!({
358 "error": err
359 }))
360 }
361 }
362}
363
364#[put("/invoices/{id}")]
366pub async fn update_invoice_draft(
367 state: web::Data<AppState>,
368 user: AuthenticatedUser,
369 id: web::Path<Uuid>,
370 dto: web::Json<UpdateInvoiceDraftDto>,
371) -> impl Responder {
372 if let Some(response) = check_accountant_role(&user) {
373 return response;
374 }
375
376 if let Err(errors) = dto.validate() {
377 return HttpResponse::BadRequest().json(serde_json::json!({
378 "error": "Validation failed",
379 "details": errors.to_string()
380 }));
381 }
382
383 match state
384 .expense_use_cases
385 .update_invoice_draft(*id, dto.into_inner())
386 .await
387 {
388 Ok(invoice) => {
389 AuditLogEntry::new(
390 AuditEventType::InvoiceUpdated,
391 Some(user.user_id),
392 user.organization_id,
393 )
394 .with_resource("Invoice", *id)
395 .log();
396
397 HttpResponse::Ok().json(invoice)
398 }
399 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
400 "error": err
401 })),
402 }
403}
404
405#[put("/invoices/{id}/submit")]
407pub async fn submit_invoice_for_approval(
408 state: web::Data<AppState>,
409 user: AuthenticatedUser,
410 id: web::Path<Uuid>,
411) -> impl Responder {
412 if let Some(response) = check_accountant_role(&user) {
413 return response;
414 }
415
416 match state
417 .expense_use_cases
418 .submit_for_approval(*id, SubmitForApprovalDto {})
419 .await
420 {
421 Ok(invoice) => {
422 AuditLogEntry::new(
423 AuditEventType::InvoiceSubmitted,
424 Some(user.user_id),
425 user.organization_id,
426 )
427 .with_resource("Invoice", *id)
428 .log();
429
430 HttpResponse::Ok().json(invoice)
431 }
432 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
433 "error": err
434 })),
435 }
436}
437
438#[put("/invoices/{id}/approve")]
441pub async fn approve_invoice(
442 state: web::Data<AppState>,
443 user: AuthenticatedUser,
444 id: web::Path<Uuid>,
445) -> impl Responder {
446 if let Some(response) = check_syndic_role(&user) {
447 return response;
448 }
449
450 let dto = ApproveInvoiceDto {
451 approved_by_user_id: user.user_id.to_string(),
452 };
453
454 match state.expense_use_cases.approve_invoice(*id, dto).await {
455 Ok(invoice) => {
456 AuditLogEntry::new(
457 AuditEventType::InvoiceApproved,
458 Some(user.user_id),
459 user.organization_id,
460 )
461 .with_resource("Invoice", *id)
462 .log();
463
464 HttpResponse::Ok().json(invoice)
465 }
466 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
467 "error": err
468 })),
469 }
470}
471
472#[put("/invoices/{id}/reject")]
475pub async fn reject_invoice(
476 state: web::Data<AppState>,
477 user: AuthenticatedUser,
478 id: web::Path<Uuid>,
479 dto: web::Json<RejectInvoiceDto>,
480) -> impl Responder {
481 if let Some(response) = check_syndic_role(&user) {
482 return response;
483 }
484
485 if let Err(errors) = dto.validate() {
486 return HttpResponse::BadRequest().json(serde_json::json!({
487 "error": "Validation failed",
488 "details": errors.to_string()
489 }));
490 }
491
492 let mut reject_dto = dto.into_inner();
493 reject_dto.rejected_by_user_id = user.user_id.to_string();
494
495 match state
496 .expense_use_cases
497 .reject_invoice(*id, reject_dto)
498 .await
499 {
500 Ok(invoice) => {
501 AuditLogEntry::new(
502 AuditEventType::InvoiceRejected,
503 Some(user.user_id),
504 user.organization_id,
505 )
506 .with_resource("Invoice", *id)
507 .log();
508
509 HttpResponse::Ok().json(invoice)
510 }
511 Err(err) => HttpResponse::BadRequest().json(serde_json::json!({
512 "error": err
513 })),
514 }
515}
516
517#[get("/invoices/pending")]
520pub async fn get_pending_invoices(
521 state: web::Data<AppState>,
522 user: AuthenticatedUser,
523) -> impl Responder {
524 if let Some(response) = check_syndic_role(&user) {
525 return response;
526 }
527
528 let organization_id = match user.require_organization() {
529 Ok(org_id) => org_id,
530 Err(e) => {
531 return HttpResponse::Unauthorized().json(serde_json::json!({
532 "error": e.to_string()
533 }))
534 }
535 };
536
537 match state
538 .expense_use_cases
539 .get_pending_invoices(organization_id)
540 .await
541 {
542 Ok(pending_list) => HttpResponse::Ok().json(pending_list),
543 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
544 "error": err
545 })),
546 }
547}
548
549#[get("/invoices/{id}")]
551pub async fn get_invoice(state: web::Data<AppState>, id: web::Path<Uuid>) -> impl Responder {
552 match state.expense_use_cases.get_invoice(*id).await {
553 Ok(Some(invoice)) => HttpResponse::Ok().json(invoice),
554 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
555 "error": "Invoice not found"
556 })),
557 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
558 "error": err
559 })),
560 }
561}
562
563#[derive(Debug, Deserialize)]
569pub struct ExportWorkQuoteQuery {
570 pub contractor_name: String,
571 pub contractor_contact: String,
572 pub timeline: String, }
574
575#[get("/expenses/{id}/export-quote-pdf")]
576pub async fn export_work_quote_pdf(
577 state: web::Data<AppState>,
578 user: AuthenticatedUser,
579 id: web::Path<Uuid>,
580 query: web::Query<ExportWorkQuoteQuery>,
581) -> impl Responder {
582 use crate::domain::entities::{Building, Expense};
583 use crate::domain::services::{QuoteLineItem, WorkQuoteExporter};
584
585 let organization_id = match user.require_organization() {
586 Ok(org_id) => org_id,
587 Err(e) => {
588 return HttpResponse::Unauthorized().json(serde_json::json!({
589 "error": e.to_string()
590 }))
591 }
592 };
593
594 let expense_id = *id;
595
596 let expense_dto = match state.expense_use_cases.get_expense(expense_id).await {
598 Ok(Some(dto)) => dto,
599 Ok(None) => {
600 return HttpResponse::NotFound().json(serde_json::json!({
601 "error": "Expense not found"
602 }))
603 }
604 Err(err) => {
605 return HttpResponse::InternalServerError().json(serde_json::json!({
606 "error": err
607 }))
608 }
609 };
610
611 let expense_building_id = match Uuid::parse_str(&expense_dto.building_id) {
613 Ok(id) => id,
614 Err(e) => {
615 return HttpResponse::BadRequest().json(serde_json::json!({
616 "error": format!("Invalid building_id: {}", e)
617 }))
618 }
619 };
620 let expense_id_uuid = match Uuid::parse_str(&expense_dto.id) {
621 Ok(id) => id,
622 Err(e) => {
623 return HttpResponse::BadRequest().json(serde_json::json!({
624 "error": format!("Invalid expense_id: {}", e)
625 }))
626 }
627 };
628
629 let building_dto = match state
631 .building_use_cases
632 .get_building(expense_building_id)
633 .await
634 {
635 Ok(Some(dto)) => dto,
636 Ok(None) => {
637 return HttpResponse::NotFound().json(serde_json::json!({
638 "error": "Building not found"
639 }))
640 }
641 Err(err) => {
642 return HttpResponse::InternalServerError().json(serde_json::json!({
643 "error": err
644 }))
645 }
646 };
647
648 let building_org_id = Uuid::parse_str(&building_dto.organization_id).unwrap_or(organization_id);
650
651 let building_created_at = DateTime::parse_from_rfc3339(&building_dto.created_at)
652 .map(|dt| dt.with_timezone(&Utc))
653 .unwrap_or_else(|_| Utc::now());
654
655 let building_updated_at = DateTime::parse_from_rfc3339(&building_dto.updated_at)
656 .map(|dt| dt.with_timezone(&Utc))
657 .unwrap_or_else(|_| Utc::now());
658
659 let building_entity = Building {
660 id: Uuid::parse_str(&building_dto.id).unwrap_or(expense_building_id),
661 name: building_dto.name.clone(),
662 address: building_dto.address,
663 city: building_dto.city,
664 postal_code: building_dto.postal_code,
665 country: building_dto.country,
666 total_units: building_dto.total_units,
667 total_tantiemes: building_dto.total_tantiemes,
668 construction_year: building_dto.construction_year,
669 syndic_name: None,
670 syndic_email: None,
671 syndic_phone: None,
672 syndic_address: None,
673 syndic_office_hours: None,
674 syndic_emergency_contact: None,
675 slug: None,
676 organization_id: building_org_id,
677 created_at: building_created_at,
678 updated_at: building_updated_at,
679 };
680
681 let expense_date = DateTime::parse_from_rfc3339(&expense_dto.expense_date)
682 .map(|dt| dt.with_timezone(&Utc))
683 .unwrap_or_else(|_| Utc::now());
684
685 let expense_entity = Expense {
686 id: expense_id_uuid,
687 organization_id,
688 building_id: expense_building_id,
689 category: expense_dto.category.clone(),
690 description: expense_dto.description.clone(),
691 amount: expense_dto.amount,
692 amount_excl_vat: None,
693 vat_rate: None,
694 vat_amount: None,
695 amount_incl_vat: None,
696 expense_date,
697 invoice_date: None,
698 due_date: None,
699 paid_date: None,
700 approval_status: expense_dto.approval_status.clone(),
701 submitted_at: None,
702 approved_by: None,
703 approved_at: None,
704 rejection_reason: None,
705 payment_status: expense_dto.payment_status.clone(),
706 supplier: expense_dto.supplier.clone(),
707 invoice_number: expense_dto.invoice_number.clone(),
708 account_code: expense_dto.account_code.clone(),
709 created_at: Utc::now(),
710 updated_at: Utc::now(),
711 };
712
713 let quote_line_items: Vec<QuoteLineItem> = vec![QuoteLineItem {
719 description: expense_dto.description.clone(),
720 quantity: 1.0,
721 unit_price: expense_dto.amount,
722 total: expense_dto.amount,
723 }];
724
725 match WorkQuoteExporter::export_to_pdf(
727 &building_entity,
728 &expense_entity,
729 "e_line_items,
730 &query.contractor_name,
731 &query.contractor_contact,
732 &query.timeline,
733 ) {
734 Ok(pdf_bytes) => {
735 AuditLogEntry::new(
737 AuditEventType::ReportGenerated,
738 Some(user.user_id),
739 Some(organization_id),
740 )
741 .with_resource("Expense", expense_id)
742 .with_metadata(serde_json::json!({
743 "report_type": "work_quote_pdf",
744 "building_name": building_entity.name,
745 "contractor_name": query.contractor_name,
746 "amount": expense_dto.amount
747 }))
748 .log();
749
750 HttpResponse::Ok()
751 .content_type("application/pdf")
752 .insert_header((
753 "Content-Disposition",
754 format!(
755 "attachment; filename=\"Devis_Travaux_{}_{}.pdf\"",
756 building_entity.name.replace(' ', "_"),
757 expense_entity.id
758 ),
759 ))
760 .body(pdf_bytes)
761 }
762 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
763 "error": format!("Failed to generate PDF: {}", err)
764 })),
765 }
766}