1use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
13use crate::infrastructure::web::middleware::scope_guard::verify_building_org_access;
14use crate::infrastructure::web::{AppState, AuthenticatedUser};
15use actix_web::ResponseError;
16use actix_web::{delete, get, post, web, HttpResponse, Responder};
17use serde::{Deserialize, Serialize};
18use uuid::Uuid;
19
20#[derive(Debug, Deserialize, utoipa::ToSchema)]
26#[serde(deny_unknown_fields)]
27pub struct CreateJournalEntryRequest {
28 pub building_id: Option<Uuid>,
29 pub journal_type: String,
30 pub entry_date: String, pub description: String,
32 pub document_ref: Option<String>,
33 pub lines: Vec<JournalEntryLineRequest>,
34}
35
36#[derive(Debug, Deserialize, utoipa::ToSchema)]
37#[serde(deny_unknown_fields)]
38pub struct JournalEntryLineRequest {
39 pub account_code: String,
40 pub debit: rust_decimal::Decimal,
41 pub credit: rust_decimal::Decimal,
42 pub description: String,
43}
44
45#[derive(Debug, Serialize, utoipa::ToSchema)]
46pub struct JournalEntryResponse {
47 pub id: String,
48 pub organization_id: String,
49 pub building_id: Option<String>,
50 pub journal_type: Option<String>,
51 pub entry_date: String,
52 pub description: Option<String>,
53 pub document_ref: Option<String>,
54 pub expense_id: Option<String>,
55 pub contribution_id: Option<String>,
56 pub created_at: String,
57 pub updated_at: String,
58}
59
60#[derive(Debug, Serialize, utoipa::ToSchema)]
61pub struct JournalEntryLineResponse {
62 pub id: String,
63 pub journal_entry_id: String,
64 pub account_code: String,
65 pub debit: rust_decimal::Decimal,
66 pub credit: rust_decimal::Decimal,
67 pub description: Option<String>,
68 pub created_at: String,
69}
70
71#[derive(Debug, Serialize, utoipa::ToSchema)]
72pub struct JournalEntryWithLinesResponse {
73 pub entry: JournalEntryResponse,
74 pub lines: Vec<JournalEntryLineResponse>,
75}
76
77#[derive(Debug, Deserialize, utoipa::IntoParams)]
78pub struct ListJournalEntriesQuery {
79 pub building_id: Option<Uuid>,
80 pub journal_type: Option<String>,
81 pub start_date: Option<String>,
82 pub end_date: Option<String>,
83 pub page: Option<i64>,
84 pub per_page: Option<i64>,
85}
86
87#[utoipa::path(
112 post,
113 path = "/journal-entries",
114 tag = "JournalEntries",
115 summary = "Create a manual journal entry (double-entry bookkeeping)",
116 request_body = CreateJournalEntryRequest,
117 responses(
118 (status = 201, description = "Journal entry created", body = JournalEntryWithLinesResponse),
119 (status = 400, description = "Unbalanced entry, missing building, unknown field in the body"),
120 (status = 401, description = "User does not belong to an organization"),
121 (status = 403, description = "Forbidden (accountant or superadmin only)"),
122 (status = 404, description = "Designated building does not exist"),
123 ),
124 security(("bearer_auth" = []))
125)]
126#[post("/journal-entries")]
127pub async fn create_journal_entry(
128 state: web::Data<AppState>,
129 user: AuthenticatedUser,
130 req: web::Json<CreateJournalEntryRequest>,
131) -> impl Responder {
132 if !matches!(user.role.as_str(), "accountant" | "superadmin") {
134 return HttpResponse::Forbidden().json(serde_json::json!({
135 "error": "Only accountants and superadmins can create journal entries"
136 }));
137 }
138
139 let organization_id = match user.require_organization() {
140 Ok(org_id) => org_id,
141 Err(e) => {
142 return HttpResponse::Unauthorized().json(serde_json::json!({
143 "error": e.to_string()
144 }))
145 }
146 };
147
148 if let Some(building_id) = req.building_id {
157 if let Err(err) = verify_building_org_access(
158 &user,
159 building_id,
160 &state.building_use_cases,
161 &state.acp_use_cases,
162 )
163 .await
164 {
165 return err.error_response();
166 }
167 }
168
169 let entry_date = match chrono::DateTime::parse_from_rfc3339(&req.entry_date) {
171 Ok(dt) => dt.with_timezone(&chrono::Utc),
172 Err(_) => {
173 return HttpResponse::BadRequest().json(serde_json::json!({
174 "error": "Invalid entry_date format. Use ISO 8601 (e.g., 2025-01-01T00:00:00Z)"
175 }))
176 }
177 };
178
179 let lines: Vec<(String, rust_decimal::Decimal, rust_decimal::Decimal, String)> = req
181 .lines
182 .iter()
183 .map(|l| {
184 (
185 l.account_code.clone(),
186 l.debit,
187 l.credit,
188 l.description.clone(),
189 )
190 })
191 .collect();
192
193 match state
194 .journal_entry_use_cases
195 .create_manual_entry(
196 organization_id,
197 req.building_id,
198 Some(req.journal_type.clone()),
199 entry_date,
200 Some(req.description.clone()),
201 req.document_ref.clone(),
202 lines,
203 )
204 .await
205 {
206 Ok(entry) => {
207 AuditLogEntry::new(
209 AuditEventType::JournalEntryCreated,
210 Some(user.user_id),
211 Some(organization_id),
212 )
213 .with_metadata(serde_json::json!({
214 "entity_type": "journal_entry",
215 "entry_id": entry.id.to_string(),
216 "journal_type": &req.journal_type
217 }))
218 .log();
219
220 let response = JournalEntryResponse {
221 id: entry.id.to_string(),
222 organization_id: entry.organization_id.to_string(),
223 building_id: entry.building_id.map(|id| id.to_string()),
224 journal_type: entry.journal_type,
225 entry_date: entry.entry_date.to_rfc3339(),
226 description: entry.description,
227 document_ref: entry.document_ref,
228 expense_id: entry.expense_id.map(|id| id.to_string()),
229 contribution_id: entry.contribution_id.map(|id| id.to_string()),
230 created_at: entry.created_at.to_rfc3339(),
231 updated_at: entry.updated_at.to_rfc3339(),
232 };
233
234 HttpResponse::Created().json(response)
235 }
236 Err(err) => {
237 AuditLogEntry::new(
239 AuditEventType::JournalEntryCreated,
240 Some(user.user_id),
241 Some(organization_id),
242 )
243 .with_metadata(serde_json::json!({
244 "entity_type": "journal_entry",
245 "journal_type": &req.journal_type
246 }))
247 .with_error(err.to_string())
248 .log();
249
250 err.error_response()
268 }
269 }
270}
271
272#[utoipa::path(
289 get,
290 path = "/journal-entries",
291 tag = "JournalEntries",
292 summary = "List journal entries (paginated, filterable)",
293 params(ListJournalEntriesQuery),
294 responses(
295 (status = 200, description = "Journal entries page", body = Vec<JournalEntryResponse>),
296 (status = 401, description = "User does not belong to an organization"),
297 (status = 403, description = "Forbidden (accountant, syndic or superadmin only)"),
298 ),
299 security(("bearer_auth" = []))
300)]
301#[get("/journal-entries")]
302pub async fn list_journal_entries(
303 state: web::Data<AppState>,
304 user: AuthenticatedUser,
305 query: web::Query<ListJournalEntriesQuery>,
306) -> impl Responder {
307 if !matches!(user.role.as_str(), "accountant" | "superadmin" | "syndic") {
309 return HttpResponse::Forbidden().json(serde_json::json!({
310 "error": "Only accountants, syndics, and superadmins can view journal entries"
311 }));
312 }
313
314 let organization_id = match user.require_organization() {
315 Ok(org_id) => org_id,
316 Err(e) => {
317 return HttpResponse::Unauthorized().json(serde_json::json!({
318 "error": e.to_string()
319 }))
320 }
321 };
322
323 let start_date = query.start_date.as_ref().and_then(|s| {
325 chrono::DateTime::parse_from_rfc3339(s)
326 .ok()
327 .map(|dt| dt.with_timezone(&chrono::Utc))
328 });
329
330 let end_date = query.end_date.as_ref().and_then(|s| {
331 chrono::DateTime::parse_from_rfc3339(s)
332 .ok()
333 .map(|dt| dt.with_timezone(&chrono::Utc))
334 });
335
336 let page = query.page.unwrap_or(1).max(1);
338 let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
339 let offset = (page - 1) * per_page;
340
341 match state
342 .journal_entry_use_cases
343 .list_entries(
344 organization_id,
345 query.building_id,
346 query.journal_type.clone(),
347 start_date,
348 end_date,
349 per_page,
350 offset,
351 )
352 .await
353 {
354 Ok(entries) => {
355 let responses: Vec<JournalEntryResponse> = entries
356 .into_iter()
357 .map(|entry| 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 .collect();
371
372 HttpResponse::Ok().json(serde_json::json!({
373 "data": responses,
374 "page": page,
375 "per_page": per_page
376 }))
377 }
378 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
379 "error": err
380 })),
381 }
382}
383
384#[utoipa::path(
393 get,
394 path = "/journal-entries/{id}",
395 tag = "JournalEntries",
396 summary = "Get a single journal entry with its lines",
397 params(("id" = Uuid, Path, description = "Journal entry identifier")),
398 responses(
399 (status = 200, description = "Journal entry with lines", body = JournalEntryWithLinesResponse),
400 (status = 401, description = "User does not belong to an organization"),
401 (status = 403, description = "Forbidden"),
402 (status = 404, description = "Journal entry not found"),
403 ),
404 security(("bearer_auth" = []))
405)]
406#[get("/journal-entries/{id}")]
407pub async fn get_journal_entry(
408 state: web::Data<AppState>,
409 user: AuthenticatedUser,
410 entry_id: web::Path<Uuid>,
411) -> impl Responder {
412 if !matches!(user.role.as_str(), "accountant" | "superadmin" | "syndic") {
413 return HttpResponse::Forbidden().json(serde_json::json!({
414 "error": "Only accountants, syndics, and superadmins can view journal entries"
415 }));
416 }
417
418 let organization_id = match user.require_organization() {
419 Ok(org_id) => org_id,
420 Err(e) => {
421 return HttpResponse::Unauthorized().json(serde_json::json!({
422 "error": e.to_string()
423 }))
424 }
425 };
426
427 match state
428 .journal_entry_use_cases
429 .get_entry_with_lines(*entry_id, organization_id)
430 .await
431 {
432 Ok((entry, lines)) => {
433 let entry_response = JournalEntryResponse {
434 id: entry.id.to_string(),
435 organization_id: entry.organization_id.to_string(),
436 building_id: entry.building_id.map(|id| id.to_string()),
437 journal_type: entry.journal_type,
438 entry_date: entry.entry_date.to_rfc3339(),
439 description: entry.description,
440 document_ref: entry.document_ref,
441 expense_id: entry.expense_id.map(|id| id.to_string()),
442 contribution_id: entry.contribution_id.map(|id| id.to_string()),
443 created_at: entry.created_at.to_rfc3339(),
444 updated_at: entry.updated_at.to_rfc3339(),
445 };
446
447 let lines_response: Vec<JournalEntryLineResponse> = lines
448 .into_iter()
449 .map(|line| JournalEntryLineResponse {
450 id: line.id.to_string(),
451 journal_entry_id: line.journal_entry_id.to_string(),
452 account_code: line.account_code,
453 debit: line.debit,
454 credit: line.credit,
455 description: line.description,
456 created_at: line.created_at.to_rfc3339(),
457 })
458 .collect();
459
460 HttpResponse::Ok().json(JournalEntryWithLinesResponse {
461 entry: entry_response,
462 lines: lines_response,
463 })
464 }
465 Err(err) => HttpResponse::NotFound().json(serde_json::json!({
466 "error": err
467 })),
468 }
469}
470
471#[utoipa::path(
482 delete,
483 path = "/journal-entries/{id}",
484 tag = "JournalEntries",
485 summary = "Delete a journal entry and its lines",
486 params(("id" = Uuid, Path, description = "Journal entry identifier")),
487 responses(
488 (status = 204, description = "Journal entry deleted"),
489 (status = 401, description = "User does not belong to an organization"),
490 (status = 403, description = "Forbidden (accountant or superadmin only)"),
491 (status = 404, description = "Journal entry not found"),
492 ),
493 security(("bearer_auth" = []))
494)]
495#[delete("/journal-entries/{id}")]
496pub async fn delete_journal_entry(
497 state: web::Data<AppState>,
498 user: AuthenticatedUser,
499 entry_id: web::Path<Uuid>,
500) -> impl Responder {
501 if !matches!(user.role.as_str(), "accountant" | "superadmin") {
502 return HttpResponse::Forbidden().json(serde_json::json!({
503 "error": "Only accountants and superadmins can delete journal entries"
504 }));
505 }
506
507 let organization_id = match user.require_organization() {
508 Ok(org_id) => org_id,
509 Err(e) => {
510 return HttpResponse::Unauthorized().json(serde_json::json!({
511 "error": e.to_string()
512 }))
513 }
514 };
515
516 match state
517 .journal_entry_use_cases
518 .delete_manual_entry(*entry_id, organization_id)
519 .await
520 {
521 Ok(_) => {
522 AuditLogEntry::new(
524 AuditEventType::JournalEntryDeleted,
525 Some(user.user_id),
526 Some(organization_id),
527 )
528 .with_metadata(serde_json::json!({
529 "entity_type": "journal_entry",
530 "entry_id": entry_id.to_string()
531 }))
532 .log();
533
534 HttpResponse::NoContent().finish()
535 }
536 Err(err) => {
537 AuditLogEntry::new(
539 AuditEventType::JournalEntryDeleted,
540 Some(user.user_id),
541 Some(organization_id),
542 )
543 .with_metadata(serde_json::json!({
544 "entity_type": "journal_entry",
545 "entry_id": entry_id.to_string()
546 }))
547 .with_error(err.clone())
548 .log();
549
550 HttpResponse::BadRequest().json(serde_json::json!({
551 "error": err
552 }))
553 }
554 }
555}