1use crate::application::dto::{ExpenseFilters, PageRequest};
2use crate::application::ports::ExpenseRepository;
3use crate::domain::entities::{ApprovalStatus, Expense, ExpenseCategory, PaymentStatus};
4use crate::infrastructure::database::pool::DbPool;
5use async_trait::async_trait;
6use sqlx::Row;
7use uuid::Uuid;
8
9pub struct PostgresExpenseRepository {
10 pool: DbPool,
11}
12
13impl PostgresExpenseRepository {
14 pub fn new(pool: DbPool) -> Self {
15 Self { pool }
16 }
17}
18
19#[async_trait]
20impl ExpenseRepository for PostgresExpenseRepository {
21 async fn create(&self, expense: &Expense) -> Result<Expense, String> {
22 let category_str = match expense.category {
23 ExpenseCategory::Maintenance => "maintenance",
24 ExpenseCategory::Repairs => "repairs",
25 ExpenseCategory::Insurance => "insurance",
26 ExpenseCategory::Utilities => "utilities",
27 ExpenseCategory::Cleaning => "cleaning",
28 ExpenseCategory::Administration => "administration",
29 ExpenseCategory::Works => "works",
30 ExpenseCategory::Other => "other",
31 };
32
33 let status_str = match expense.payment_status {
34 PaymentStatus::Pending => "pending",
35 PaymentStatus::Paid => "paid",
36 PaymentStatus::Overdue => "overdue",
37 PaymentStatus::Cancelled => "cancelled",
38 };
39
40 sqlx::query(
41 r#"
42 -- `amount_excl_vat`, `vat_rate`, `vat_amount`, `amount_incl_vat`,
43 -- `invoice_date` et `due_date` existent depuis la migration
44 -- `20251104000001_enrich_expenses_invoice_workflow` (novembre 2025)
45 -- mais n'étaient NI écrites ici, NI relues plus bas — le mapping
46 -- de lecture les forçait à `None`.
47 --
48 -- Toute dépense enregistrée depuis lors a donc ces six colonnes à
49 -- NULL. C'est la cause de fond des constats F12 (« échéance
50 -- affiche - ») et F20 (« seul le TTC est affiché ») du rapport du
51 -- 2026-09-01 : corriger le DTO d'entrée, le constructeur et le DTO
52 -- de sortie ne changeait rien tant que cette couche-ci perdait la
53 -- donnée.
54 INSERT INTO expenses (id, acp_id, organization_id, building_id, category, description, amount, expense_date, payment_status, supplier, invoice_number, account_code, contractor_report_id, created_at, updated_at,
55 amount_excl_vat, vat_rate, vat_amount, amount_incl_vat, invoice_date, due_date)
56 VALUES ($1, $2, $3, $4, CAST($5 AS expense_category), $6, $7, $8, CAST($9 AS payment_status), $10, $11, $12, $13, $14, $15,
57 $16, $17, $18, $19, $20, $21)
58 "#,
59 )
60 .bind(expense.id)
61 .bind(expense.acp_id)
62 .bind(expense.organization_id)
63 .bind(expense.building_id)
64 .bind(category_str)
65 .bind(&expense.description)
66 .bind(expense.amount)
67 .bind(expense.expense_date)
68 .bind(status_str)
69 .bind(&expense.supplier)
70 .bind(&expense.invoice_number)
71 .bind(&expense.account_code)
72 .bind(expense.contractor_report_id)
73 .bind(expense.created_at)
74 .bind(expense.updated_at)
75 .bind(expense.amount_excl_vat)
76 .bind(expense.vat_rate)
77 .bind(expense.vat_amount)
78 .bind(expense.amount_incl_vat)
79 .bind(expense.invoice_date)
80 .bind(expense.due_date)
81 .execute(&self.pool)
82 .await
83 .map_err(|e| format!("Database error: {}", e))?;
84
85 Ok(expense.clone())
86 }
87
88 async fn enregistrer_lignes_de_facture(
89 &self,
90 expense_id: Uuid,
91 lignes: &[crate::application::ports::expense_repository::LigneDeFacture],
92 ) -> Result<(), String> {
93 if lignes.is_empty() {
94 return Ok(());
95 }
96
97 for ligne in lignes {
101 sqlx::query(
102 r#"
103 INSERT INTO invoice_line_items
104 (id, expense_id, description, quantity, unit_price,
105 amount_excl_vat, vat_rate, vat_amount, amount_incl_vat, created_at)
106 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
107 "#,
108 )
109 .bind(Uuid::new_v4())
110 .bind(expense_id)
111 .bind(&ligne.description)
112 .bind(ligne.quantity)
113 .bind(ligne.unit_price)
114 .bind(ligne.montant_hors_tva())
115 .bind(ligne.vat_rate)
116 .bind(ligne.montant_tva())
117 .bind(ligne.montant_tva_comprise())
118 .execute(&self.pool)
119 .await
120 .map_err(|e| format!("Database error inserting invoice line: {e}"))?;
121 }
122 Ok(())
123 }
124
125 async fn find_by_id(&self, id: Uuid) -> Result<Option<Expense>, String> {
126 let row = sqlx::query(
127 r#"
128 SELECT id, acp_id, organization_id, building_id,
129 category::text AS category, description, amount, expense_date,
130 payment_status::text AS payment_status, approval_status::text AS approval_status,
131 submitted_at, approved_by, approved_at, rejection_reason, paid_date,
132 supplier, invoice_number, account_code, contractor_report_id, created_at, updated_at,
133 amount_excl_vat, vat_rate, vat_amount, amount_incl_vat, invoice_date, due_date
134 FROM expenses
135 WHERE id = $1
136 "#,
137 )
138 .bind(id)
139 .fetch_optional(&self.pool)
140 .await
141 .map_err(|e| format!("Database error: {}", e))?;
142
143 Ok(row.map(|row| {
144 let category_str: String = row.get("category");
145 let category = match category_str.as_str() {
146 "maintenance" => ExpenseCategory::Maintenance,
147 "repairs" => ExpenseCategory::Repairs,
148 "insurance" => ExpenseCategory::Insurance,
149 "utilities" => ExpenseCategory::Utilities,
150 "cleaning" => ExpenseCategory::Cleaning,
151 "administration" => ExpenseCategory::Administration,
152 "works" => ExpenseCategory::Works,
153 _ => ExpenseCategory::Other,
154 };
155
156 let status_str: String = row.get("payment_status");
157 let payment_status = match status_str.as_str() {
158 "paid" => PaymentStatus::Paid,
159 "overdue" => PaymentStatus::Overdue,
160 "cancelled" => PaymentStatus::Cancelled,
161 _ => PaymentStatus::Pending,
162 };
163
164 let approval_status_str: String = row.get("approval_status");
165 let approval_status = match approval_status_str.as_str() {
166 "pending_approval" => ApprovalStatus::PendingApproval,
167 "approved" => ApprovalStatus::Approved,
168 "rejected" => ApprovalStatus::Rejected,
169 _ => ApprovalStatus::Draft,
170 };
171
172 Expense {
173 id: row.get("id"),
174 acp_id: row.get("acp_id"),
175 organization_id: row.get("organization_id"),
176 building_id: row.get("building_id"),
177 category,
178 description: row.get("description"),
179 amount: row.get("amount"),
180 amount_excl_vat: row.try_get("amount_excl_vat").ok().flatten(),
181 vat_rate: row.try_get("vat_rate").ok().flatten(),
182 vat_amount: row.try_get("vat_amount").ok().flatten(),
183 amount_incl_vat: row
187 .try_get("amount_incl_vat")
188 .ok()
189 .flatten()
190 .or_else(|| Some(row.get("amount"))),
191 expense_date: row.get("expense_date"),
192 invoice_date: row.try_get("invoice_date").ok().flatten(),
193 due_date: row.try_get("due_date").ok().flatten(),
194 paid_date: row.try_get("paid_date").ok(),
195 approval_status,
196 submitted_at: row.try_get("submitted_at").ok(),
197 approved_by: row.try_get("approved_by").ok(),
198 approved_at: row.try_get("approved_at").ok(),
199 rejection_reason: row.try_get("rejection_reason").ok(),
200 payment_status,
201 supplier: row.get("supplier"),
202 invoice_number: row.get("invoice_number"),
203 account_code: row.get("account_code"),
204 contractor_report_id: row.try_get("contractor_report_id").ok(),
205 created_at: row.get("created_at"),
206 updated_at: row.get("updated_at"),
207 }
208 }))
209 }
210
211 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Expense>, String> {
212 let rows = sqlx::query(
213 r#"
214 SELECT id, acp_id, organization_id, building_id,
215 category::text AS category, description, amount, expense_date,
216 payment_status::text AS payment_status, approval_status::text AS approval_status,
217 submitted_at, approved_by, approved_at, rejection_reason, paid_date,
218 supplier, invoice_number, account_code, contractor_report_id, created_at, updated_at,
219 amount_excl_vat, vat_rate, vat_amount, amount_incl_vat, invoice_date, due_date
220 FROM expenses
221 WHERE building_id = $1
222 ORDER BY expense_date DESC
223 "#,
224 )
225 .bind(building_id)
226 .fetch_all(&self.pool)
227 .await
228 .map_err(|e| format!("Database error: {}", e))?;
229
230 Ok(rows
231 .iter()
232 .map(|row| {
233 let category_str: String = row.get("category");
234 let category = match category_str.as_str() {
235 "maintenance" => ExpenseCategory::Maintenance,
236 "repairs" => ExpenseCategory::Repairs,
237 "insurance" => ExpenseCategory::Insurance,
238 "utilities" => ExpenseCategory::Utilities,
239 "cleaning" => ExpenseCategory::Cleaning,
240 "administration" => ExpenseCategory::Administration,
241 "works" => ExpenseCategory::Works,
242 _ => ExpenseCategory::Other,
243 };
244
245 let status_str: String = row.get("payment_status");
246 let payment_status = match status_str.as_str() {
247 "paid" => PaymentStatus::Paid,
248 "overdue" => PaymentStatus::Overdue,
249 "cancelled" => PaymentStatus::Cancelled,
250 _ => PaymentStatus::Pending,
251 };
252
253 let approval_status_str: String = row.get("approval_status");
254 let approval_status = match approval_status_str.as_str() {
255 "pending_approval" => ApprovalStatus::PendingApproval,
256 "approved" => ApprovalStatus::Approved,
257 "rejected" => ApprovalStatus::Rejected,
258 _ => ApprovalStatus::Draft,
259 };
260
261 Expense {
262 id: row.get("id"),
263 acp_id: row.get("acp_id"),
264 organization_id: row.get("organization_id"),
265 building_id: row.get("building_id"),
266 category,
267 description: row.get("description"),
268 amount: row.get("amount"),
269 amount_excl_vat: row.try_get("amount_excl_vat").ok().flatten(),
270 vat_rate: row.try_get("vat_rate").ok().flatten(),
271 vat_amount: row.try_get("vat_amount").ok().flatten(),
272 amount_incl_vat: row
273 .try_get("amount_incl_vat")
274 .ok()
275 .flatten()
276 .or_else(|| Some(row.get("amount"))),
277 expense_date: row.get("expense_date"),
278 invoice_date: row.try_get("invoice_date").ok().flatten(),
279 due_date: row.try_get("due_date").ok().flatten(),
280 paid_date: row.try_get("paid_date").ok(),
281 approval_status,
282 submitted_at: row.try_get("submitted_at").ok(),
283 approved_by: row.try_get("approved_by").ok(),
284 approved_at: row.try_get("approved_at").ok(),
285 rejection_reason: row.try_get("rejection_reason").ok(),
286 payment_status,
287 supplier: row.get("supplier"),
288 invoice_number: row.get("invoice_number"),
289 account_code: row.get("account_code"),
290 contractor_report_id: row.try_get("contractor_report_id").ok(),
291 created_at: row.get("created_at"),
292 updated_at: row.get("updated_at"),
293 }
294 })
295 .collect())
296 }
297
298 async fn find_all_paginated(
299 &self,
300 page_request: &PageRequest,
301 filters: &ExpenseFilters,
302 ) -> Result<(Vec<Expense>, i64), String> {
303 page_request.validate()?;
305
306 let mut where_clauses = Vec::new();
308 let mut param_count = 0;
309
310 if filters.organization_id.is_some() {
329 param_count += 1;
330 where_clauses.push(format!(
331 "acp_id IN (SELECT id FROM acps WHERE organization_id = ${})",
332 param_count
333 ));
334 }
335
336 if filters.building_id.is_some() {
337 param_count += 1;
338 where_clauses.push(format!("building_id = ${}", param_count));
339 }
340
341 if filters.category.is_some() {
342 param_count += 1;
343 where_clauses.push(format!("category = ${}", param_count));
344 }
345
346 if filters.status.is_some() {
347 param_count += 1;
348 where_clauses.push(format!("payment_status = ${}", param_count));
349 }
350
351 if filters.date_from.is_some() {
352 param_count += 1;
353 where_clauses.push(format!("expense_date >= ${}", param_count));
354 }
355
356 if filters.date_to.is_some() {
357 param_count += 1;
358 where_clauses.push(format!("expense_date <= ${}", param_count));
359 }
360
361 if filters.min_amount.is_some() {
362 param_count += 1;
363 where_clauses.push(format!("amount >= ${}", param_count));
364 }
365
366 if filters.max_amount.is_some() {
367 param_count += 1;
368 where_clauses.push(format!("amount <= ${}", param_count));
369 }
370
371 if filters.approval_status.is_some() {
372 param_count += 1;
373 where_clauses.push(format!("approval_status::text = ${}", param_count));
374 }
375
376 let where_clause = if where_clauses.is_empty() {
377 String::new()
378 } else {
379 format!("WHERE {}", where_clauses.join(" AND "))
380 };
381
382 let allowed_columns = ["expense_date", "amount", "created_at", "payment_status"];
384 let sort_column = page_request.sort_by.as_deref().unwrap_or("expense_date");
385
386 if !allowed_columns.contains(&sort_column) {
387 return Err(format!("Invalid sort column: {}", sort_column));
388 }
389
390 let count_query = format!("SELECT COUNT(*) FROM expenses {}", where_clause);
392 let mut count_query = sqlx::query_scalar::<_, i64>(&count_query);
393
394 if let Some(organization_id) = filters.organization_id {
395 count_query = count_query.bind(organization_id);
396 }
397 if let Some(building_id) = filters.building_id {
398 count_query = count_query.bind(building_id);
399 }
400 if let Some(category) = &filters.category {
401 count_query = count_query.bind(category);
402 }
403 if let Some(status) = &filters.status {
404 count_query = count_query.bind(status);
405 }
406 if let Some(date_from) = filters.date_from {
407 count_query = count_query.bind(date_from);
408 }
409 if let Some(date_to) = filters.date_to {
410 count_query = count_query.bind(date_to);
411 }
412 if let Some(min_amount) = filters.min_amount {
413 count_query = count_query.bind(min_amount);
414 }
415 if let Some(max_amount) = filters.max_amount {
416 count_query = count_query.bind(max_amount);
417 }
418 if let Some(ref approval_status) = filters.approval_status {
419 let status_str = match approval_status {
420 ApprovalStatus::Draft => "draft",
421 ApprovalStatus::PendingApproval => "pending_approval",
422 ApprovalStatus::Approved => "approved",
423 ApprovalStatus::Rejected => "rejected",
424 };
425 count_query = count_query.bind(status_str);
426 }
427
428 let total_items = count_query
429 .fetch_one(&self.pool)
430 .await
431 .map_err(|e| format!("Database error: {}", e))?;
432
433 param_count += 1;
435 let limit_param = param_count;
436 param_count += 1;
437 let offset_param = param_count;
438
439 let data_query = format!(
440 "SELECT id, acp_id, organization_id, building_id, category::text AS category, description, amount, expense_date, payment_status::text AS payment_status, approval_status::text AS approval_status, submitted_at, approved_by, approved_at, rejection_reason, paid_date, supplier, invoice_number, account_code, contractor_report_id, created_at, updated_at, amount_excl_vat, vat_rate, vat_amount, amount_incl_vat, invoice_date, due_date \
441 FROM expenses {} ORDER BY {} {} LIMIT ${} OFFSET ${}",
442 where_clause,
443 sort_column,
444 page_request.order.to_sql(),
445 limit_param,
446 offset_param
447 );
448
449 let mut data_query = sqlx::query(&data_query);
450
451 if let Some(organization_id) = filters.organization_id {
452 data_query = data_query.bind(organization_id);
453 }
454 if let Some(building_id) = filters.building_id {
455 data_query = data_query.bind(building_id);
456 }
457 if let Some(category) = &filters.category {
458 data_query = data_query.bind(category);
459 }
460 if let Some(status) = &filters.status {
461 data_query = data_query.bind(status);
462 }
463 if let Some(date_from) = filters.date_from {
464 data_query = data_query.bind(date_from);
465 }
466 if let Some(date_to) = filters.date_to {
467 data_query = data_query.bind(date_to);
468 }
469 if let Some(min_amount) = filters.min_amount {
470 data_query = data_query.bind(min_amount);
471 }
472 if let Some(max_amount) = filters.max_amount {
473 data_query = data_query.bind(max_amount);
474 }
475 if let Some(ref approval_status) = filters.approval_status {
476 let status_str = match approval_status {
477 ApprovalStatus::Draft => "draft",
478 ApprovalStatus::PendingApproval => "pending_approval",
479 ApprovalStatus::Approved => "approved",
480 ApprovalStatus::Rejected => "rejected",
481 };
482 data_query = data_query.bind(status_str);
483 }
484
485 data_query = data_query
486 .bind(page_request.limit())
487 .bind(page_request.offset());
488
489 let rows = data_query
490 .fetch_all(&self.pool)
491 .await
492 .map_err(|e| format!("Database error: {}", e))?;
493
494 let expenses: Vec<Expense> = rows
495 .iter()
496 .map(|row| {
497 let category_str: String = row.get("category");
498 let category = match category_str.as_str() {
499 "maintenance" => ExpenseCategory::Maintenance,
500 "repairs" => ExpenseCategory::Repairs,
501 "insurance" => ExpenseCategory::Insurance,
502 "utilities" => ExpenseCategory::Utilities,
503 "cleaning" => ExpenseCategory::Cleaning,
504 "administration" => ExpenseCategory::Administration,
505 "works" => ExpenseCategory::Works,
506 _ => ExpenseCategory::Other,
507 };
508
509 let status_str: String = row.get("payment_status");
510 let payment_status = match status_str.as_str() {
511 "paid" => PaymentStatus::Paid,
512 "overdue" => PaymentStatus::Overdue,
513 "cancelled" => PaymentStatus::Cancelled,
514 _ => PaymentStatus::Pending,
515 };
516
517 let approval_status_str: String = row.get("approval_status");
518 let approval_status = match approval_status_str.as_str() {
519 "pending_approval" => ApprovalStatus::PendingApproval,
520 "approved" => ApprovalStatus::Approved,
521 "rejected" => ApprovalStatus::Rejected,
522 _ => ApprovalStatus::Draft,
523 };
524
525 Expense {
526 id: row.get("id"),
527 acp_id: row.get("acp_id"),
528 organization_id: row.get("organization_id"),
529 building_id: row.get("building_id"),
530 category,
531 description: row.get("description"),
532 amount: row.get("amount"),
533 amount_excl_vat: row.try_get("amount_excl_vat").ok().flatten(),
534 vat_rate: row.try_get("vat_rate").ok().flatten(),
535 vat_amount: row.try_get("vat_amount").ok().flatten(),
536 amount_incl_vat: row
537 .try_get("amount_incl_vat")
538 .ok()
539 .flatten()
540 .or_else(|| Some(row.get("amount"))),
541 expense_date: row.get("expense_date"),
542 invoice_date: row.try_get("invoice_date").ok().flatten(),
543 due_date: row.try_get("due_date").ok().flatten(),
544 paid_date: row.try_get("paid_date").ok(),
545 approval_status,
546 submitted_at: row.try_get("submitted_at").ok(),
547 approved_by: row.try_get("approved_by").ok(),
548 approved_at: row.try_get("approved_at").ok(),
549 rejection_reason: row.try_get("rejection_reason").ok(),
550 payment_status,
551 supplier: row.get("supplier"),
552 invoice_number: row.get("invoice_number"),
553 account_code: row.get("account_code"),
554 contractor_report_id: row.try_get("contractor_report_id").ok(),
555 created_at: row.get("created_at"),
556 updated_at: row.get("updated_at"),
557 }
558 })
559 .collect();
560
561 Ok((expenses, total_items))
562 }
563
564 async fn update(&self, expense: &Expense) -> Result<Expense, String> {
565 let payment_status_str = match expense.payment_status {
566 PaymentStatus::Pending => "pending",
567 PaymentStatus::Paid => "paid",
568 PaymentStatus::Overdue => "overdue",
569 PaymentStatus::Cancelled => "cancelled",
570 };
571
572 let approval_status_str = match expense.approval_status {
573 ApprovalStatus::Draft => "draft",
574 ApprovalStatus::PendingApproval => "pending_approval",
575 ApprovalStatus::Approved => "approved",
576 ApprovalStatus::Rejected => "rejected",
577 };
578
579 sqlx::query(
580 r#"
581 UPDATE expenses
582 SET
583 payment_status = CAST($2 AS payment_status),
584 approval_status = CAST($3 AS approval_status),
585 submitted_at = $4,
586 approved_by = $5,
587 approved_at = $6,
588 rejection_reason = $7,
589 paid_date = $8,
590 updated_at = $9
591 WHERE id = $1
592 "#,
593 )
594 .bind(expense.id)
595 .bind(payment_status_str)
596 .bind(approval_status_str)
597 .bind(expense.submitted_at)
598 .bind(expense.approved_by)
599 .bind(expense.approved_at)
600 .bind(expense.rejection_reason.as_deref())
601 .bind(expense.paid_date)
602 .bind(expense.updated_at)
603 .execute(&self.pool)
604 .await
605 .map_err(|e| format!("Database error: {}", e))?;
606
607 Ok(expense.clone())
608 }
609
610 async fn delete(&self, id: Uuid) -> Result<bool, String> {
611 let result = sqlx::query("DELETE FROM expenses WHERE id = $1")
612 .bind(id)
613 .execute(&self.pool)
614 .await
615 .map_err(|e| format!("Database error: {}", e))?;
616
617 Ok(result.rows_affected() > 0)
618 }
619}