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 HttpResponse::InternalServerError().json(serde_json::json!({
206 "error": err
207 }))
208 }
209 }
210}
211
212#[get("/journal-entries")]
229pub async fn list_journal_entries(
230 state: web::Data<AppState>,
231 user: AuthenticatedUser,
232 query: web::Query<ListJournalEntriesQuery>,
233) -> impl Responder {
234 if !matches!(user.role.as_str(), "accountant" | "superadmin" | "syndic") {
236 return HttpResponse::Forbidden().json(serde_json::json!({
237 "error": "Only accountants, syndics, and superadmins can view journal entries"
238 }));
239 }
240
241 let organization_id = match user.require_organization() {
242 Ok(org_id) => org_id,
243 Err(e) => {
244 return HttpResponse::Unauthorized().json(serde_json::json!({
245 "error": e.to_string()
246 }))
247 }
248 };
249
250 let start_date = query.start_date.as_ref().and_then(|s| {
252 chrono::DateTime::parse_from_rfc3339(s)
253 .ok()
254 .map(|dt| dt.with_timezone(&chrono::Utc))
255 });
256
257 let end_date = query.end_date.as_ref().and_then(|s| {
258 chrono::DateTime::parse_from_rfc3339(s)
259 .ok()
260 .map(|dt| dt.with_timezone(&chrono::Utc))
261 });
262
263 let page = query.page.unwrap_or(1).max(1);
265 let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
266 let offset = (page - 1) * per_page;
267
268 match state
269 .journal_entry_use_cases
270 .list_entries(
271 organization_id,
272 query.building_id,
273 query.journal_type.clone(),
274 start_date,
275 end_date,
276 per_page,
277 offset,
278 )
279 .await
280 {
281 Ok(entries) => {
282 let responses: Vec<JournalEntryResponse> = entries
283 .into_iter()
284 .map(|entry| JournalEntryResponse {
285 id: entry.id.to_string(),
286 organization_id: entry.organization_id.to_string(),
287 building_id: entry.building_id.map(|id| id.to_string()),
288 journal_type: entry.journal_type,
289 entry_date: entry.entry_date.to_rfc3339(),
290 description: entry.description,
291 document_ref: entry.document_ref,
292 expense_id: entry.expense_id.map(|id| id.to_string()),
293 contribution_id: entry.contribution_id.map(|id| id.to_string()),
294 created_at: entry.created_at.to_rfc3339(),
295 updated_at: entry.updated_at.to_rfc3339(),
296 })
297 .collect();
298
299 HttpResponse::Ok().json(serde_json::json!({
300 "data": responses,
301 "page": page,
302 "per_page": per_page
303 }))
304 }
305 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
306 "error": err
307 })),
308 }
309}
310
311#[get("/journal-entries/{id}")]
320pub async fn get_journal_entry(
321 state: web::Data<AppState>,
322 user: AuthenticatedUser,
323 entry_id: web::Path<Uuid>,
324) -> impl Responder {
325 if !matches!(user.role.as_str(), "accountant" | "superadmin" | "syndic") {
326 return HttpResponse::Forbidden().json(serde_json::json!({
327 "error": "Only accountants, syndics, and superadmins can view journal entries"
328 }));
329 }
330
331 let organization_id = match user.require_organization() {
332 Ok(org_id) => org_id,
333 Err(e) => {
334 return HttpResponse::Unauthorized().json(serde_json::json!({
335 "error": e.to_string()
336 }))
337 }
338 };
339
340 match state
341 .journal_entry_use_cases
342 .get_entry_with_lines(*entry_id, organization_id)
343 .await
344 {
345 Ok((entry, lines)) => {
346 let entry_response = JournalEntryResponse {
347 id: entry.id.to_string(),
348 organization_id: entry.organization_id.to_string(),
349 building_id: entry.building_id.map(|id| id.to_string()),
350 journal_type: entry.journal_type,
351 entry_date: entry.entry_date.to_rfc3339(),
352 description: entry.description,
353 document_ref: entry.document_ref,
354 expense_id: entry.expense_id.map(|id| id.to_string()),
355 contribution_id: entry.contribution_id.map(|id| id.to_string()),
356 created_at: entry.created_at.to_rfc3339(),
357 updated_at: entry.updated_at.to_rfc3339(),
358 };
359
360 let lines_response: Vec<JournalEntryLineResponse> = lines
361 .into_iter()
362 .map(|line| JournalEntryLineResponse {
363 id: line.id.to_string(),
364 journal_entry_id: line.journal_entry_id.to_string(),
365 account_code: line.account_code,
366 debit: line.debit,
367 credit: line.credit,
368 description: line.description,
369 created_at: line.created_at.to_rfc3339(),
370 })
371 .collect();
372
373 HttpResponse::Ok().json(JournalEntryWithLinesResponse {
374 entry: entry_response,
375 lines: lines_response,
376 })
377 }
378 Err(err) => HttpResponse::NotFound().json(serde_json::json!({
379 "error": err
380 })),
381 }
382}
383
384#[delete("/journal-entries/{id}")]
395pub async fn delete_journal_entry(
396 state: web::Data<AppState>,
397 user: AuthenticatedUser,
398 entry_id: web::Path<Uuid>,
399) -> impl Responder {
400 if !matches!(user.role.as_str(), "accountant" | "superadmin") {
401 return HttpResponse::Forbidden().json(serde_json::json!({
402 "error": "Only accountants and superadmins can delete journal entries"
403 }));
404 }
405
406 let organization_id = match user.require_organization() {
407 Ok(org_id) => org_id,
408 Err(e) => {
409 return HttpResponse::Unauthorized().json(serde_json::json!({
410 "error": e.to_string()
411 }))
412 }
413 };
414
415 match state
416 .journal_entry_use_cases
417 .delete_manual_entry(*entry_id, organization_id)
418 .await
419 {
420 Ok(_) => {
421 AuditLogEntry::new(
423 AuditEventType::JournalEntryDeleted,
424 Some(user.user_id),
425 Some(organization_id),
426 )
427 .with_metadata(serde_json::json!({
428 "entity_type": "journal_entry",
429 "entry_id": entry_id.to_string()
430 }))
431 .log();
432
433 HttpResponse::NoContent().finish()
434 }
435 Err(err) => {
436 AuditLogEntry::new(
438 AuditEventType::JournalEntryDeleted,
439 Some(user.user_id),
440 Some(organization_id),
441 )
442 .with_metadata(serde_json::json!({
443 "entity_type": "journal_entry",
444 "entry_id": entry_id.to_string()
445 }))
446 .with_error(err.clone())
447 .log();
448
449 HttpResponse::BadRequest().json(serde_json::json!({
450 "error": err
451 }))
452 }
453 }
454}