koprogo_api/infrastructure/web/handlers/
expense_handlers.rs

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
13/// Helper function to check if owner role is trying to modify data
14/// Note: Accountant CAN create expenses and mark them as paid
15fn 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
25/// Helper function to check if user has syndic role (for approval workflow)
26fn 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
36/// Helper function to check if user has accountant role (for creating/editing invoices)
37fn 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, // JWT-extracted user info (SECURE!)
51    mut dto: web::Json<CreateExpenseDto>,
52) -> impl Responder {
53    if let Some(response) = check_owner_readonly(&user) {
54        return response;
55    }
56
57    // Override the organization_id from DTO with the one from JWT token
58    // This prevents users from creating expenses in other organizations
59    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            // Audit log: successful expense creation
83            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            // Audit log: failed expense creation
95            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            // Audit log: successful expense marked paid
177            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            // Audit log: failed expense marked paid
189            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// ========== Invoice Workflow Endpoints (Issue #73) ==========
302
303/// POST /invoices/draft - Create a new invoice draft with VAT
304#[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    // Override organization_id from JWT token
315    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} - Update invoice draft (only if Draft or Rejected)
365#[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 - Submit invoice for approval (Draft → PendingApproval)
406#[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 - Approve invoice (PendingApproval → Approved)
439/// Only syndic or superadmin can approve
440#[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 - Reject invoice with reason (PendingApproval → Rejected)
473/// Only syndic or superadmin can reject
474#[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 - Get all pending invoices (for syndic dashboard)
518/// Only syndic or superadmin can view pending invoices
519#[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} - Get full invoice details (enriched with all fields)
550#[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/// Export Work Quote to PDF
564///
565/// GET /expenses/{expense_id}/export-quote-pdf?contractor_name={name}&contractor_contact={email}&timeline={description}
566///
567/// Generates a "Devis de Travaux" PDF for work-related expenses.
568#[derive(Debug, Deserialize)]
569pub struct ExportWorkQuoteQuery {
570    pub contractor_name: String,
571    pub contractor_contact: String,
572    pub timeline: String, // e.g., "2-3 weeks" or "Délai: 15 jours ouvrables"
573}
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    // 1. Get expense
597    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    // 2. Parse expense DTO fields
612    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    // 3. Get building
630    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    // Convert DTOs to domain entities
649    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    // Create a single line item from expense amount (simplified for now)
714    // Note: For full invoice support with line items, we would need to:
715    // 1. Get the invoice via get_invoice()
716    // 2. Add a repository method to fetch line items
717    // For now, we use simplified single line item approach
718    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    // 4. Generate PDF
726    match WorkQuoteExporter::export_to_pdf(
727        &building_entity,
728        &expense_entity,
729        &quote_line_items,
730        &query.contractor_name,
731        &query.contractor_contact,
732        &query.timeline,
733    ) {
734        Ok(pdf_bytes) => {
735            // Audit log
736            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}