1use crate::application::dto::PageRequest;
2use crate::application::error::AppError;
3use crate::application::ports::{BudgetRepository, BudgetStatsResponse, BudgetVarianceResponse};
4use crate::domain::entities::{Budget, BudgetStatus, ExpenseCategory};
5use async_trait::async_trait;
6use chrono::Datelike;
7use rust_decimal::Decimal;
8use rust_decimal_macros::dec;
9use sqlx::postgres::PgRow;
10use sqlx::{PgPool, Row};
11use uuid::Uuid;
12
13pub struct PostgresBudgetRepository {
14 pool: PgPool,
15}
16
17const BUDGET_COLUMNS: &str = r#"
20 id, acp_id, organization_id, building_id, fiscal_year,
21 ordinary_budget,
22 extraordinary_budget,
23 total_budget,
24 status::text as status_text,
25 submitted_date, approved_date, approved_by_meeting_id,
26 monthly_provision_amount,
27 notes, created_at, updated_at
28"#;
29
30impl PostgresBudgetRepository {
31 pub fn new(pool: PgPool) -> Self {
32 Self { pool }
33 }
34
35 fn row_to_budget(&self, row: PgRow) -> Budget {
37 let status_str: String = row.get("status_text");
38 let status = match status_str.as_str() {
39 "draft" => BudgetStatus::Draft,
40 "submitted" => BudgetStatus::Submitted,
41 "approved" => BudgetStatus::Approved,
42 "rejected" => BudgetStatus::Rejected,
43 "archived" => BudgetStatus::Archived,
44 _ => BudgetStatus::Draft,
45 };
46
47 Budget {
48 id: row.get("id"),
49 acp_id: row.get("acp_id"),
50 organization_id: row.get("organization_id"),
51 building_id: row.get("building_id"),
52 fiscal_year: row.get("fiscal_year"),
53 ordinary_budget: row.get("ordinary_budget"),
54 extraordinary_budget: row.get("extraordinary_budget"),
55 total_budget: row.get("total_budget"),
56 status,
57 submitted_date: row.get("submitted_date"),
58 approved_date: row.get("approved_date"),
59 approved_by_meeting_id: row.get("approved_by_meeting_id"),
60 monthly_provision_amount: row.get("monthly_provision_amount"),
61 notes: row.get("notes"),
62 created_at: row.get("created_at"),
63 updated_at: row.get("updated_at"),
64 }
65 }
66}
67
68#[async_trait]
69impl BudgetRepository for PostgresBudgetRepository {
70 async fn create(&self, budget: &Budget) -> Result<Budget, AppError> {
71 let status_str = match budget.status {
72 BudgetStatus::Draft => "draft",
73 BudgetStatus::Submitted => "submitted",
74 BudgetStatus::Approved => "approved",
75 BudgetStatus::Rejected => "rejected",
76 BudgetStatus::Archived => "archived",
77 };
78
79 let row = sqlx::query(
80 r#"
81 INSERT INTO budgets (
82 id, acp_id, organization_id, building_id, fiscal_year,
83 ordinary_budget, extraordinary_budget, total_budget,
84 status, submitted_date, approved_date, approved_by_meeting_id,
85 monthly_provision_amount, notes,
86 created_at, updated_at
87 ) VALUES (
88 $1, $2, $3, $4, $5, $6, $7, $8, $9::budget_status,
89 $10, $11, $12, $13, $14, $15, $16
90 )
91 RETURNING id, acp_id, organization_id, building_id, fiscal_year,
92 ordinary_budget,
93 extraordinary_budget,
94 total_budget,
95 status::text as status_text,
96 submitted_date, approved_date, approved_by_meeting_id,
97 monthly_provision_amount,
98 notes, created_at, updated_at
99 "#,
100 )
101 .bind(budget.id)
102 .bind(budget.acp_id)
103 .bind(budget.organization_id)
104 .bind(budget.building_id)
105 .bind(budget.fiscal_year)
106 .bind(budget.ordinary_budget)
107 .bind(budget.extraordinary_budget)
108 .bind(budget.total_budget)
109 .bind(status_str)
110 .bind(budget.submitted_date)
111 .bind(budget.approved_date)
112 .bind(budget.approved_by_meeting_id)
113 .bind(budget.monthly_provision_amount)
114 .bind(&budget.notes)
115 .bind(budget.created_at)
116 .bind(budget.updated_at)
117 .fetch_one(&self.pool)
118 .await
119 .map_err(|e| format!("Failed to create budget: {}", e))?;
120
121 Ok(self.row_to_budget(row))
122 }
123
124 async fn find_by_id(&self, id: Uuid) -> Result<Option<Budget>, AppError> {
125 let sql = format!("SELECT {} FROM budgets WHERE id = $1", BUDGET_COLUMNS);
126 let result = sqlx::query(&sql)
127 .bind(id)
128 .fetch_optional(&self.pool)
129 .await
130 .map_err(|e| format!("Failed to find budget: {}", e))?;
131
132 Ok(result.map(|row| self.row_to_budget(row)))
133 }
134
135 async fn find_by_building_and_fiscal_year(
136 &self,
137 building_id: Uuid,
138 fiscal_year: i32,
139 ) -> Result<Option<Budget>, AppError> {
140 let sql = format!(
141 "SELECT {} FROM budgets WHERE building_id = $1 AND fiscal_year = $2",
142 BUDGET_COLUMNS
143 );
144 let result = sqlx::query(&sql)
145 .bind(building_id)
146 .bind(fiscal_year)
147 .fetch_optional(&self.pool)
148 .await
149 .map_err(|e| format!("Failed to find budget: {}", e))?;
150
151 Ok(result.map(|row| self.row_to_budget(row)))
152 }
153
154 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Budget>, AppError> {
155 let sql = format!(
156 "SELECT {} FROM budgets WHERE building_id = $1 ORDER BY fiscal_year DESC",
157 BUDGET_COLUMNS
158 );
159 let rows = sqlx::query(&sql)
160 .bind(building_id)
161 .fetch_all(&self.pool)
162 .await
163 .map_err(|e| format!("Failed to find budgets: {}", e))?;
164
165 Ok(rows
166 .into_iter()
167 .map(|row| self.row_to_budget(row))
168 .collect())
169 }
170
171 async fn find_active_by_building(&self, building_id: Uuid) -> Result<Option<Budget>, AppError> {
172 let sql = format!("SELECT {} FROM budgets WHERE building_id = $1 AND status = 'approved' ORDER BY fiscal_year DESC LIMIT 1", BUDGET_COLUMNS);
173 let result = sqlx::query(&sql)
174 .bind(building_id)
175 .fetch_optional(&self.pool)
176 .await
177 .map_err(|e| format!("Failed to find active budget: {}", e))?;
178
179 Ok(result.map(|row| self.row_to_budget(row)))
180 }
181
182 async fn find_by_fiscal_year(
183 &self,
184 organization_id: Uuid,
185 fiscal_year: i32,
186 ) -> Result<Vec<Budget>, AppError> {
187 let sql = format!("SELECT {} FROM budgets WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = $1) AND fiscal_year = $2 ORDER BY created_at DESC", BUDGET_COLUMNS);
188 let rows = sqlx::query(&sql)
189 .bind(organization_id)
190 .bind(fiscal_year)
191 .fetch_all(&self.pool)
192 .await
193 .map_err(|e| format!("Failed to find budgets: {}", e))?;
194
195 Ok(rows
196 .into_iter()
197 .map(|row| self.row_to_budget(row))
198 .collect())
199 }
200
201 async fn find_by_status(
202 &self,
203 organization_id: Uuid,
204 status: BudgetStatus,
205 ) -> Result<Vec<Budget>, AppError> {
206 let status_str = match status {
207 BudgetStatus::Draft => "draft",
208 BudgetStatus::Submitted => "submitted",
209 BudgetStatus::Approved => "approved",
210 BudgetStatus::Rejected => "rejected",
211 BudgetStatus::Archived => "archived",
212 };
213
214 let sql = format!("SELECT {} FROM budgets WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = $1) AND status = $2::budget_status ORDER BY created_at DESC", BUDGET_COLUMNS);
215 let rows = sqlx::query(&sql)
216 .bind(organization_id)
217 .bind(status_str)
218 .fetch_all(&self.pool)
219 .await
220 .map_err(|e| format!("Failed to find budgets: {}", e))?;
221
222 Ok(rows
223 .into_iter()
224 .map(|row| self.row_to_budget(row))
225 .collect())
226 }
227
228 async fn find_all_paginated(
229 &self,
230 page_request: &PageRequest,
231 organization_id: Option<Uuid>,
232 building_id: Option<Uuid>,
233 status: Option<BudgetStatus>,
234 ) -> Result<(Vec<Budget>, i64), AppError> {
235 let offset = (page_request.page - 1) * page_request.per_page;
236
237 let mut query = format!("SELECT {} FROM budgets WHERE 1=1", BUDGET_COLUMNS);
239 let mut count_query = String::from("SELECT COUNT(*) as count FROM budgets WHERE 1=1");
240
241 let mut bind_index = 1;
242 let mut bindings: Vec<String> = Vec::new();
243
244 if let Some(org_id) = organization_id {
245 query.push_str(&format!(
246 " AND acp_id IN (SELECT id FROM acps WHERE organization_id = ${}::uuid)",
247 bind_index
248 ));
249 count_query.push_str(&format!(
250 " AND acp_id IN (SELECT id FROM acps WHERE organization_id = ${}::uuid)",
251 bind_index
252 ));
253 bindings.push(org_id.to_string());
254 bind_index += 1;
255 }
256
257 if let Some(bldg_id) = building_id {
258 query.push_str(&format!(" AND building_id = ${}::uuid", bind_index));
259 count_query.push_str(&format!(" AND building_id = ${}::uuid", bind_index));
260 bindings.push(bldg_id.to_string());
261 bind_index += 1;
262 }
263
264 if let Some(s) = status {
265 let status_str = match s {
266 BudgetStatus::Draft => "draft",
267 BudgetStatus::Submitted => "submitted",
268 BudgetStatus::Approved => "approved",
269 BudgetStatus::Rejected => "rejected",
270 BudgetStatus::Archived => "archived",
271 };
272 query.push_str(&format!(" AND status = ${}::budget_status", bind_index));
273 count_query.push_str(&format!(" AND status = ${}::budget_status", bind_index));
274 bindings.push(status_str.to_string());
275 bind_index += 1;
276 }
277
278 query.push_str(" ORDER BY fiscal_year DESC, created_at DESC");
279 query.push_str(&format!(
280 " LIMIT ${} OFFSET ${}",
281 bind_index,
282 bind_index + 1
283 ));
284
285 let mut count_q = sqlx::query(&count_query);
287 for binding in &bindings {
288 count_q = count_q.bind(binding);
289 }
290 let count_row = count_q
291 .fetch_one(&self.pool)
292 .await
293 .map_err(|e| format!("Failed to count budgets: {}", e))?;
294 let total: i64 = count_row.get("count");
295
296 let mut main_q = sqlx::query(&query);
298 for binding in &bindings {
299 main_q = main_q.bind(binding);
300 }
301 main_q = main_q.bind(page_request.per_page as i64);
302 main_q = main_q.bind(offset as i64);
303
304 let rows = main_q
305 .fetch_all(&self.pool)
306 .await
307 .map_err(|e| format!("Failed to fetch budgets: {}", e))?;
308
309 let budgets = rows
310 .into_iter()
311 .map(|row| self.row_to_budget(row))
312 .collect();
313
314 Ok((budgets, total))
315 }
316
317 async fn update(&self, budget: &Budget) -> Result<Budget, AppError> {
318 let status_str = match budget.status {
319 BudgetStatus::Draft => "draft",
320 BudgetStatus::Submitted => "submitted",
321 BudgetStatus::Approved => "approved",
322 BudgetStatus::Rejected => "rejected",
323 BudgetStatus::Archived => "archived",
324 };
325
326 let row = sqlx::query(
327 r#"
328 UPDATE budgets SET
329 ordinary_budget = $2,
330 extraordinary_budget = $3,
331 total_budget = $4,
332 status = $5::budget_status,
333 submitted_date = $6,
334 approved_date = $7,
335 approved_by_meeting_id = $8,
336 monthly_provision_amount = $9,
337 notes = $10,
338 updated_at = $11
339 WHERE id = $1
340 RETURNING id, acp_id, organization_id, building_id, fiscal_year,
341 ordinary_budget,
342 extraordinary_budget,
343 total_budget,
344 status::text as status_text,
345 submitted_date, approved_date, approved_by_meeting_id,
346 monthly_provision_amount,
347 notes, created_at, updated_at
348 "#,
349 )
350 .bind(budget.id)
351 .bind(budget.ordinary_budget)
352 .bind(budget.extraordinary_budget)
353 .bind(budget.total_budget)
354 .bind(status_str)
355 .bind(budget.submitted_date)
356 .bind(budget.approved_date)
357 .bind(budget.approved_by_meeting_id)
358 .bind(budget.monthly_provision_amount)
359 .bind(&budget.notes)
360 .bind(budget.updated_at)
361 .fetch_one(&self.pool)
362 .await
363 .map_err(|e| format!("Failed to update budget: {}", e))?;
364
365 Ok(self.row_to_budget(row))
366 }
367
368 async fn delete(&self, id: Uuid) -> Result<bool, AppError> {
369 let result = sqlx::query("DELETE FROM budgets WHERE id = $1")
370 .bind(id)
371 .execute(&self.pool)
372 .await
373 .map_err(|e| format!("Failed to delete budget: {}", e))?;
374
375 Ok(result.rows_affected() > 0)
376 }
377
378 async fn get_stats(&self, organization_id: Uuid) -> Result<BudgetStatsResponse, AppError> {
379 let row = sqlx::query(
380 r#"
381 SELECT
382 COUNT(*) as total_budgets,
383 COUNT(*) FILTER (WHERE status = 'draft') as draft_count,
384 COUNT(*) FILTER (WHERE status = 'submitted') as submitted_count,
385 COUNT(*) FILTER (WHERE status = 'approved') as approved_count,
386 COUNT(*) FILTER (WHERE status = 'rejected') as rejected_count,
387 COUNT(*) FILTER (WHERE status = 'archived') as archived_count,
388 COALESCE(AVG(total_budget), 0) as average_total_budget,
389 COALESCE(AVG(monthly_provision_amount), 0) as average_monthly_provision
390 FROM budgets
391 WHERE acp_id IN (SELECT id FROM acps WHERE organization_id = $1)
392 "#,
393 )
394 .bind(organization_id)
395 .fetch_one(&self.pool)
396 .await
397 .map_err(|e| format!("Failed to get budget stats: {}", e))?;
398
399 Ok(BudgetStatsResponse {
400 total_budgets: row.get("total_budgets"),
401 draft_count: row.get("draft_count"),
402 submitted_count: row.get("submitted_count"),
403 approved_count: row.get("approved_count"),
404 rejected_count: row.get("rejected_count"),
405 archived_count: row.get("archived_count"),
406 average_total_budget: row.get("average_total_budget"),
407 average_monthly_provision: row.get("average_monthly_provision"),
408 })
409 }
410
411 async fn get_variance(
412 &self,
413 budget_id: Uuid,
414 ) -> Result<Option<BudgetVarianceResponse>, AppError> {
415 let budget = match self.find_by_id(budget_id).await? {
417 Some(b) => b,
418 None => return Ok(None),
419 };
420
421 let budget_ordinary = budget.ordinary_budget;
427 let budget_extraordinary = budget.extraordinary_budget;
428 let budget_total = budget.total_budget;
429
430 let fiscal_year_start = format!("{}-01-01", budget.fiscal_year);
432 let fiscal_year_end = format!("{}-12-31", budget.fiscal_year);
433
434 let expense_rows = sqlx::query(
460 r#"
461 SELECT
462 category::text AS category,
463 COALESCE(SUM(amount), 0) as total_amount
464 FROM expenses
465 WHERE building_id = $1
466 AND expense_date >= $2::date
467 AND expense_date <= $3::date
468 AND approval_status = 'approved'
469 AND payment_status <> 'cancelled'
470 GROUP BY category
471 "#,
472 )
473 .bind(budget.building_id)
474 .bind(fiscal_year_start)
475 .bind(fiscal_year_end)
476 .fetch_all(&self.pool)
477 .await
478 .map_err(|e| format!("Failed to get expenses: {}", e))?;
479
480 let mut actual_ordinary = Decimal::ZERO;
481 let mut actual_extraordinary = Decimal::ZERO;
482 let mut overrun_categories = Vec::new();
483
484 for row in expense_rows {
485 let category_str: String = row.try_get("category")?;
488 let amount: Decimal = row.try_get("total_amount")?;
489
490 let category = match category_str.as_str() {
491 "utilities" => ExpenseCategory::Utilities,
492 "maintenance" => ExpenseCategory::Maintenance,
493 "repairs" => ExpenseCategory::Repairs,
494 "insurance" => ExpenseCategory::Insurance,
495 "cleaning" => ExpenseCategory::Cleaning,
496 "administration" => ExpenseCategory::Administration,
497 "works" => ExpenseCategory::Works,
498 _ => ExpenseCategory::Other,
499 };
500
501 match category {
502 ExpenseCategory::Works => actual_extraordinary += amount,
503 _ => actual_ordinary += amount,
504 }
505 }
506
507 let actual_total = actual_ordinary + actual_extraordinary;
508
509 let variance_ordinary = budget_ordinary - actual_ordinary;
511 let variance_extraordinary = budget_extraordinary - actual_extraordinary;
512 let variance_total = budget_total - actual_total;
513
514 let pct = |variance: Decimal, budget: Decimal| -> Decimal {
516 if budget > Decimal::ZERO {
517 (variance / budget) * dec!(100)
518 } else {
519 Decimal::ZERO
520 }
521 };
522 let variance_ordinary_pct = pct(variance_ordinary, budget_ordinary);
523 let variance_extraordinary_pct = pct(variance_extraordinary, budget_extraordinary);
524 let variance_total_pct = pct(variance_total, budget_total);
525
526 const OVERRUN_THRESHOLD_PCT: Decimal = dec!(-10);
528 let has_overruns = variance_ordinary_pct < OVERRUN_THRESHOLD_PCT
529 || variance_extraordinary_pct < OVERRUN_THRESHOLD_PCT
530 || variance_total_pct < OVERRUN_THRESHOLD_PCT;
531
532 if variance_ordinary_pct < OVERRUN_THRESHOLD_PCT {
533 overrun_categories.push("Ordinary charges".to_string());
534 }
535 if variance_extraordinary_pct < OVERRUN_THRESHOLD_PCT {
536 overrun_categories.push("Extraordinary charges".to_string());
537 }
538
539 let now = chrono::Utc::now();
541 let months_elapsed = if now.year() == budget.fiscal_year {
542 now.month() as i32
543 } else if now.year() > budget.fiscal_year {
544 12
545 } else {
546 0
547 };
548
549 let projected_year_end_total = if months_elapsed > 0 {
551 (actual_total / Decimal::from(months_elapsed)) * dec!(12)
552 } else {
553 Decimal::ZERO
554 };
555
556 Ok(Some(BudgetVarianceResponse {
557 budget_id: budget.id,
558 fiscal_year: budget.fiscal_year,
559 building_id: budget.building_id,
560 budgeted_ordinary: budget_ordinary,
561 budgeted_extraordinary: budget_extraordinary,
562 budgeted_total: budget_total,
563 actual_ordinary,
564 actual_extraordinary,
565 actual_total,
566 variance_ordinary,
567 variance_extraordinary,
568 variance_total,
569 variance_ordinary_pct,
570 variance_extraordinary_pct,
571 variance_total_pct,
572 has_overruns,
573 overrun_categories,
574 months_elapsed,
575 projected_year_end_total,
576 }))
577 }
578}