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(
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            // Multi-tenant isolation: verify expense's building belongs to user's organization
119            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    // Multi-tenant isolation: verify building belongs to user's organization
172    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            // Audit log: successful expense marked paid
217            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            // Audit log: failed expense marked paid
229            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// ========== Invoice Workflow Endpoints (Issue #73) ==========
342
343/// POST /invoices/draft - Create a new invoice draft with VAT
344#[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    // Override organization_id from JWT token
355    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} - Update invoice draft (only if Draft or Rejected)
405#[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 - Submit invoice for approval (Draft → PendingApproval)
446#[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 - Approve invoice (PendingApproval → Approved)
479/// Only syndic or superadmin can approve
480#[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 - Reject invoice with reason (PendingApproval → Rejected)
513/// Only syndic or superadmin can reject
514#[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 - Get all pending invoices (for syndic dashboard)
558/// Only syndic or superadmin can view pending invoices
559#[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} - Get full invoice details (enriched with all fields)
590#[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            // Multi-tenant isolation: verify invoice's building belongs to user's organization
599            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/// Export Work Quote to PDF
622///
623/// GET /expenses/{expense_id}/export-quote-pdf?contractor_name={name}&contractor_contact={email}&timeline={description}
624///
625/// Generates a "Devis de Travaux" PDF for work-related expenses.
626#[derive(Debug, Deserialize)]
627pub struct ExportWorkQuoteQuery {
628    pub contractor_name: String,
629    pub contractor_contact: String,
630    pub timeline: String, // e.g., "2-3 weeks" or "Délai: 15 jours ouvrables"
631}
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    // 1. Get expense
655    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    // 2. Parse expense DTO fields
670    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    // 3. Get building
688    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    // Convert DTOs to domain entities
707    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    // Create a single line item from expense amount (simplified for now)
773    // Note: For full invoice support with line items, we would need to:
774    // 1. Get the invoice via get_invoice()
775    // 2. Add a repository method to fetch line items
776    // For now, we use simplified single line item approach
777    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    // 4. Generate PDF
785    match WorkQuoteExporter::export_to_pdf(
786        &building_entity,
787        &expense_entity,
788        &quote_line_items,
789        &query.contractor_name,
790        &query.contractor_contact,
791        &query.timeline,
792    ) {
793        Ok(pdf_bytes) => {
794            // Audit log
795            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}