1use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
13use crate::infrastructure::web::{AppState, AuthenticatedUser};
14use actix_web::{delete, get, post, web, HttpResponse, Responder};
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18#[derive(Debug, Deserialize)]
19pub struct CreateJournalEntryRequest {
20 pub building_id: Option<Uuid>,
21 pub journal_type: String,
22 pub entry_date: String, pub description: String,
24 pub document_ref: Option<String>,
25 pub lines: Vec<JournalEntryLineRequest>,
26}
27
28#[derive(Debug, Deserialize)]
29pub struct JournalEntryLineRequest {
30 pub account_code: String,
31 pub debit: f64,
32 pub credit: f64,
33 pub description: String,
34}
35
36#[derive(Debug, Serialize)]
37pub struct JournalEntryResponse {
38 pub id: String,
39 pub organization_id: String,
40 pub building_id: Option<String>,
41 pub journal_type: Option<String>,
42 pub entry_date: String,
43 pub description: Option<String>,
44 pub document_ref: Option<String>,
45 pub expense_id: Option<String>,
46 pub contribution_id: Option<String>,
47 pub created_at: String,
48 pub updated_at: String,
49}
50
51#[derive(Debug, Serialize)]
52pub struct JournalEntryLineResponse {
53 pub id: String,
54 pub journal_entry_id: String,
55 pub account_code: String,
56 pub debit: f64,
57 pub credit: f64,
58 pub description: Option<String>,
59 pub created_at: String,
60}
61
62#[derive(Debug, Serialize)]
63pub struct JournalEntryWithLinesResponse {
64 pub entry: JournalEntryResponse,
65 pub lines: Vec<JournalEntryLineResponse>,
66}
67
68#[derive(Debug, Deserialize)]
69pub struct ListJournalEntriesQuery {
70 pub building_id: Option<Uuid>,
71 pub journal_type: Option<String>,
72 pub start_date: Option<String>,
73 pub end_date: Option<String>,
74 pub page: Option<i64>,
75 pub per_page: Option<i64>,
76}
77
78#[post("/journal-entries")]
103pub async fn create_journal_entry(
104 state: web::Data<AppState>,
105 user: AuthenticatedUser,
106 req: web::Json<CreateJournalEntryRequest>,
107) -> impl Responder {
108 if !matches!(user.role.as_str(), "accountant" | "superadmin") {
110 return HttpResponse::Forbidden().json(serde_json::json!({
111 "error": "Only accountants and superadmins can create journal entries"
112 }));
113 }
114
115 let organization_id = match user.require_organization() {
116 Ok(org_id) => org_id,
117 Err(e) => {
118 return HttpResponse::Unauthorized().json(serde_json::json!({
119 "error": e.to_string()
120 }))
121 }
122 };
123
124 let entry_date = match chrono::DateTime::parse_from_rfc3339(&req.entry_date) {
126 Ok(dt) => dt.with_timezone(&chrono::Utc),
127 Err(_) => {
128 return HttpResponse::BadRequest().json(serde_json::json!({
129 "error": "Invalid entry_date format. Use ISO 8601 (e.g., 2025-01-01T00:00:00Z)"
130 }))
131 }
132 };
133
134 let lines: Vec<(String, f64, f64, String)> = req
136 .lines
137 .iter()
138 .map(|l| {
139 (
140 l.account_code.clone(),
141 l.debit,
142 l.credit,
143 l.description.clone(),
144 )
145 })
146 .collect();
147
148 match state
149 .journal_entry_use_cases
150 .create_manual_entry(
151 organization_id,
152 req.building_id,
153 Some(req.journal_type.clone()),
154 entry_date,
155 Some(req.description.clone()),
156 req.document_ref.clone(),
157 lines,
158 )
159 .await
160 {
161 Ok(entry) => {
162 AuditLogEntry::new(
164 AuditEventType::JournalEntryCreated,
165 Some(user.user_id),
166 Some(organization_id),
167 )
168 .with_metadata(serde_json::json!({
169 "entity_type": "journal_entry",
170 "entry_id": entry.id.to_string(),
171 "journal_type": &req.journal_type
172 }))
173 .log();
174
175 let response = JournalEntryResponse {
176 id: entry.id.to_string(),
177 organization_id: entry.organization_id.to_string(),
178 building_id: entry.building_id.map(|id| id.to_string()),
179 journal_type: entry.journal_type,
180 entry_date: entry.entry_date.to_rfc3339(),
181 description: entry.description,
182 document_ref: entry.document_ref,
183 expense_id: entry.expense_id.map(|id| id.to_string()),
184 contribution_id: entry.contribution_id.map(|id| id.to_string()),
185 created_at: entry.created_at.to_rfc3339(),
186 updated_at: entry.updated_at.to_rfc3339(),
187 };
188
189 HttpResponse::Created().json(response)
190 }
191 Err(err) => {
192 AuditLogEntry::new(
194 AuditEventType::JournalEntryCreated,
195 Some(user.user_id),
196 Some(organization_id),
197 )
198 .with_metadata(serde_json::json!({
199 "entity_type": "journal_entry",
200 "journal_type": &req.journal_type
201 }))
202 .with_error(err.clone())
203 .log();
204
205 if err.contains("unbalanced")
207 || err.contains("foreign key")
208 || err.contains("violates")
209 || err.contains("not found")
210 {
211 HttpResponse::BadRequest().json(serde_json::json!({
212 "error": err
213 }))
214 } else {
215 HttpResponse::InternalServerError().json(serde_json::json!({
216 "error": err
217 }))
218 }
219 }
220 }
221}
222
223#[get("/journal-entries")]
240pub async fn list_journal_entries(
241 state: web::Data<AppState>,
242 user: AuthenticatedUser,
243 query: web::Query<ListJournalEntriesQuery>,
244) -> impl Responder {
245 if !matches!(user.role.as_str(), "accountant" | "superadmin" | "syndic") {
247 return HttpResponse::Forbidden().json(serde_json::json!({
248 "error": "Only accountants, syndics, and superadmins can view journal entries"
249 }));
250 }
251
252 let organization_id = match user.require_organization() {
253 Ok(org_id) => org_id,
254 Err(e) => {
255 return HttpResponse::Unauthorized().json(serde_json::json!({
256 "error": e.to_string()
257 }))
258 }
259 };
260
261 let start_date = query.start_date.as_ref().and_then(|s| {
263 chrono::DateTime::parse_from_rfc3339(s)
264 .ok()
265 .map(|dt| dt.with_timezone(&chrono::Utc))
266 });
267
268 let end_date = query.end_date.as_ref().and_then(|s| {
269 chrono::DateTime::parse_from_rfc3339(s)
270 .ok()
271 .map(|dt| dt.with_timezone(&chrono::Utc))
272 });
273
274 let page = query.page.unwrap_or(1).max(1);
276 let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
277 let offset = (page - 1) * per_page;
278
279 match state
280 .journal_entry_use_cases
281 .list_entries(
282 organization_id,
283 query.building_id,
284 query.journal_type.clone(),
285 start_date,
286 end_date,
287 per_page,
288 offset,
289 )
290 .await
291 {
292 Ok(entries) => {
293 let responses: Vec<JournalEntryResponse> = entries
294 .into_iter()
295 .map(|entry| JournalEntryResponse {
296 id: entry.id.to_string(),
297 organization_id: entry.organization_id.to_string(),
298 building_id: entry.building_id.map(|id| id.to_string()),
299 journal_type: entry.journal_type,
300 entry_date: entry.entry_date.to_rfc3339(),
301 description: entry.description,
302 document_ref: entry.document_ref,
303 expense_id: entry.expense_id.map(|id| id.to_string()),
304 contribution_id: entry.contribution_id.map(|id| id.to_string()),
305 created_at: entry.created_at.to_rfc3339(),
306 updated_at: entry.updated_at.to_rfc3339(),
307 })
308 .collect();
309
310 HttpResponse::Ok().json(serde_json::json!({
311 "data": responses,
312 "page": page,
313 "per_page": per_page
314 }))
315 }
316 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
317 "error": err
318 })),
319 }
320}
321
322#[get("/journal-entries/{id}")]
331pub async fn get_journal_entry(
332 state: web::Data<AppState>,
333 user: AuthenticatedUser,
334 entry_id: web::Path<Uuid>,
335) -> impl Responder {
336 if !matches!(user.role.as_str(), "accountant" | "superadmin" | "syndic") {
337 return HttpResponse::Forbidden().json(serde_json::json!({
338 "error": "Only accountants, syndics, and superadmins can view journal entries"
339 }));
340 }
341
342 let organization_id = match user.require_organization() {
343 Ok(org_id) => org_id,
344 Err(e) => {
345 return HttpResponse::Unauthorized().json(serde_json::json!({
346 "error": e.to_string()
347 }))
348 }
349 };
350
351 match state
352 .journal_entry_use_cases
353 .get_entry_with_lines(*entry_id, organization_id)
354 .await
355 {
356 Ok((entry, lines)) => {
357 let entry_response = JournalEntryResponse {
358 id: entry.id.to_string(),
359 organization_id: entry.organization_id.to_string(),
360 building_id: entry.building_id.map(|id| id.to_string()),
361 journal_type: entry.journal_type,
362 entry_date: entry.entry_date.to_rfc3339(),
363 description: entry.description,
364 document_ref: entry.document_ref,
365 expense_id: entry.expense_id.map(|id| id.to_string()),
366 contribution_id: entry.contribution_id.map(|id| id.to_string()),
367 created_at: entry.created_at.to_rfc3339(),
368 updated_at: entry.updated_at.to_rfc3339(),
369 };
370
371 let lines_response: Vec<JournalEntryLineResponse> = lines
372 .into_iter()
373 .map(|line| JournalEntryLineResponse {
374 id: line.id.to_string(),
375 journal_entry_id: line.journal_entry_id.to_string(),
376 account_code: line.account_code,
377 debit: line.debit,
378 credit: line.credit,
379 description: line.description,
380 created_at: line.created_at.to_rfc3339(),
381 })
382 .collect();
383
384 HttpResponse::Ok().json(JournalEntryWithLinesResponse {
385 entry: entry_response,
386 lines: lines_response,
387 })
388 }
389 Err(err) => HttpResponse::NotFound().json(serde_json::json!({
390 "error": err
391 })),
392 }
393}
394
395#[delete("/journal-entries/{id}")]
406pub async fn delete_journal_entry(
407 state: web::Data<AppState>,
408 user: AuthenticatedUser,
409 entry_id: web::Path<Uuid>,
410) -> impl Responder {
411 if !matches!(user.role.as_str(), "accountant" | "superadmin") {
412 return HttpResponse::Forbidden().json(serde_json::json!({
413 "error": "Only accountants and superadmins can delete journal entries"
414 }));
415 }
416
417 let organization_id = match user.require_organization() {
418 Ok(org_id) => org_id,
419 Err(e) => {
420 return HttpResponse::Unauthorized().json(serde_json::json!({
421 "error": e.to_string()
422 }))
423 }
424 };
425
426 match state
427 .journal_entry_use_cases
428 .delete_manual_entry(*entry_id, organization_id)
429 .await
430 {
431 Ok(_) => {
432 AuditLogEntry::new(
434 AuditEventType::JournalEntryDeleted,
435 Some(user.user_id),
436 Some(organization_id),
437 )
438 .with_metadata(serde_json::json!({
439 "entity_type": "journal_entry",
440 "entry_id": entry_id.to_string()
441 }))
442 .log();
443
444 HttpResponse::NoContent().finish()
445 }
446 Err(err) => {
447 AuditLogEntry::new(
449 AuditEventType::JournalEntryDeleted,
450 Some(user.user_id),
451 Some(organization_id),
452 )
453 .with_metadata(serde_json::json!({
454 "entity_type": "journal_entry",
455 "entry_id": entry_id.to_string()
456 }))
457 .with_error(err.clone())
458 .log();
459
460 HttpResponse::BadRequest().json(serde_json::json!({
461 "error": err
462 }))
463 }
464 }
465}