koprogo_api/infrastructure/web/handlers/
account_handlers.rs1use crate::application::dto::{
7 AccountResponseDto, AccountSearchQuery, CreateAccountDto, SeedBelgianPcmnDto,
8 SeedPcmnResponseDto, UpdateAccountDto,
9};
10use crate::domain::entities::AccountType;
11use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
12use crate::infrastructure::web::{AppState, AuthenticatedUser};
13use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
14use uuid::Uuid;
15use validator::Validate;
16
17fn account_to_dto(account: &crate::domain::entities::Account) -> AccountResponseDto {
19 AccountResponseDto {
20 id: account.id.to_string(),
21 code: account.code.clone(),
22 label: account.label.clone(),
23 parent_code: account.parent_code.clone(),
24 account_type: format!("{:?}", account.account_type).to_uppercase(),
25 direct_use: account.direct_use,
26 organization_id: account.organization_id.to_string(),
27 created_at: account.created_at.to_rfc3339(),
28 updated_at: account.updated_at.to_rfc3339(),
29 }
30}
31
32fn parse_account_type(type_str: &str) -> Result<AccountType, String> {
34 match type_str.to_uppercase().as_str() {
35 "ASSET" => Ok(AccountType::Asset),
36 "LIABILITY" => Ok(AccountType::Liability),
37 "EXPENSE" => Ok(AccountType::Expense),
38 "REVENUE" => Ok(AccountType::Revenue),
39 "OFF_BALANCE" | "OFFBALANCE" => Ok(AccountType::OffBalance),
40 _ => Err(format!("Invalid account type: {}", type_str)),
41 }
42}
43
44#[post("/accounts")]
49pub async fn create_account(
50 state: web::Data<AppState>,
51 user: AuthenticatedUser,
52 dto: web::Json<CreateAccountDto>,
53) -> impl Responder {
54 if user.role != "accountant" && user.role != "superadmin" {
56 return HttpResponse::Forbidden().json(serde_json::json!({
57 "error": "Only Accountant or SuperAdmin can create accounts"
58 }));
59 }
60
61 if let Err(errors) = dto.validate() {
63 return HttpResponse::BadRequest().json(serde_json::json!({
64 "error": "Validation failed",
65 "details": errors.to_string()
66 }));
67 }
68
69 let organization_id = match Uuid::parse_str(&dto.organization_id) {
71 Ok(id) => id,
72 Err(_) => {
73 return HttpResponse::BadRequest().json(serde_json::json!({
74 "error": "Invalid organization_id format"
75 }));
76 }
77 };
78
79 if user.role != "superadmin" {
81 if let Ok(user_org_id) = user.require_organization() {
82 if user_org_id != organization_id {
83 return HttpResponse::Forbidden().json(serde_json::json!({
84 "error": "You can only create accounts for your own organization"
85 }));
86 }
87 } else {
88 return HttpResponse::Unauthorized().json(serde_json::json!({
89 "error": "User has no organization"
90 }));
91 }
92 }
93
94 let account_type = match parse_account_type(&dto.account_type) {
96 Ok(at) => at,
97 Err(e) => {
98 return HttpResponse::BadRequest().json(serde_json::json!({
99 "error": e
100 }));
101 }
102 };
103
104 match state
106 .account_use_cases
107 .create_account(
108 dto.code.clone(),
109 dto.label.clone(),
110 dto.parent_code.clone(),
111 account_type,
112 dto.direct_use,
113 organization_id,
114 )
115 .await
116 {
117 Ok(account) => {
118 AuditLogEntry::new(
120 AuditEventType::AccountCreated,
121 Some(user.user_id),
122 Some(organization_id),
123 )
124 .with_resource("Account", account.id)
125 .log();
126
127 HttpResponse::Created().json(account_to_dto(&account))
128 }
129 Err(err) => {
130 AuditLogEntry::new(
132 AuditEventType::AccountCreated,
133 Some(user.user_id),
134 Some(organization_id),
135 )
136 .with_error(err.clone())
137 .log();
138
139 HttpResponse::BadRequest().json(serde_json::json!({
140 "error": err
141 }))
142 }
143 }
144}
145
146#[get("/accounts")]
151pub async fn list_accounts(
152 state: web::Data<AppState>,
153 user: AuthenticatedUser,
154 query: web::Query<AccountSearchQuery>,
155) -> impl Responder {
156 let organization_id = if user.role == "superadmin" {
158 match user.require_organization() {
161 Ok(id) => id,
162 Err(e) => {
163 return HttpResponse::Unauthorized().json(serde_json::json!({
164 "error": e.to_string()
165 }))
166 }
167 }
168 } else {
169 match user.require_organization() {
170 Ok(id) => id,
171 Err(e) => {
172 return HttpResponse::Unauthorized().json(serde_json::json!({
173 "error": e.to_string()
174 }))
175 }
176 }
177 };
178
179 let accounts_result = if let Some(ref code_pattern) = query.code_pattern {
181 state
183 .account_use_cases
184 .search_accounts(code_pattern, organization_id)
185 .await
186 } else if let Some(ref account_type_str) = query.account_type {
187 let account_type = match parse_account_type(account_type_str) {
189 Ok(at) => at,
190 Err(e) => {
191 return HttpResponse::BadRequest().json(serde_json::json!({
192 "error": e
193 }));
194 }
195 };
196 state
197 .account_use_cases
198 .list_accounts_by_type(account_type, organization_id)
199 .await
200 } else if let Some(ref parent_code) = query.parent_code {
201 state
203 .account_use_cases
204 .list_child_accounts(parent_code, organization_id)
205 .await
206 } else if query.direct_use_only.unwrap_or(false) {
207 state
209 .account_use_cases
210 .list_direct_use_accounts(organization_id)
211 .await
212 } else {
213 state.account_use_cases.list_accounts(organization_id).await
215 };
216
217 match accounts_result {
218 Ok(accounts) => {
219 let dtos: Vec<AccountResponseDto> = accounts.iter().map(account_to_dto).collect();
220 HttpResponse::Ok().json(dtos)
221 }
222 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
223 "error": err
224 })),
225 }
226}
227
228#[get("/accounts/{id}")]
233pub async fn get_account(
234 state: web::Data<AppState>,
235 user: AuthenticatedUser,
236 id: web::Path<String>,
237) -> impl Responder {
238 let account_id = match Uuid::parse_str(&id) {
239 Ok(uuid) => uuid,
240 Err(_) => {
241 return HttpResponse::BadRequest().json(serde_json::json!({
242 "error": "Invalid account ID format"
243 }))
244 }
245 };
246
247 match state.account_use_cases.get_account(account_id).await {
248 Ok(Some(account)) => {
249 if user.role != "superadmin" {
251 if let Ok(user_org_id) = user.require_organization() {
252 if user_org_id != account.organization_id {
253 return HttpResponse::Forbidden().json(serde_json::json!({
254 "error": "You can only view accounts from your organization"
255 }));
256 }
257 }
258 }
259
260 HttpResponse::Ok().json(account_to_dto(&account))
261 }
262 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
263 "error": "Account not found"
264 })),
265 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
266 "error": err
267 })),
268 }
269}
270
271#[get("/accounts/code/{code}")]
276pub async fn get_account_by_code(
277 state: web::Data<AppState>,
278 user: AuthenticatedUser,
279 code: web::Path<String>,
280) -> impl Responder {
281 let organization_id = match user.require_organization() {
282 Ok(id) => id,
283 Err(e) => {
284 return HttpResponse::Unauthorized().json(serde_json::json!({
285 "error": e.to_string()
286 }))
287 }
288 };
289
290 match state
291 .account_use_cases
292 .get_account_by_code(&code, organization_id)
293 .await
294 {
295 Ok(Some(account)) => HttpResponse::Ok().json(account_to_dto(&account)),
296 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
297 "error": "Account not found"
298 })),
299 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
300 "error": err
301 })),
302 }
303}
304
305#[put("/accounts/{id}")]
310pub async fn update_account(
311 state: web::Data<AppState>,
312 user: AuthenticatedUser,
313 id: web::Path<String>,
314 dto: web::Json<UpdateAccountDto>,
315) -> impl Responder {
316 if user.role != "accountant" && user.role != "superadmin" {
318 return HttpResponse::Forbidden().json(serde_json::json!({
319 "error": "Only Accountant or SuperAdmin can update accounts"
320 }));
321 }
322
323 if let Err(errors) = dto.validate() {
325 return HttpResponse::BadRequest().json(serde_json::json!({
326 "error": "Validation failed",
327 "details": errors.to_string()
328 }));
329 }
330
331 let account_id = match Uuid::parse_str(&id) {
332 Ok(uuid) => uuid,
333 Err(_) => {
334 return HttpResponse::BadRequest().json(serde_json::json!({
335 "error": "Invalid account ID format"
336 }))
337 }
338 };
339
340 let existing_account = match state.account_use_cases.get_account(account_id).await {
342 Ok(Some(acc)) => acc,
343 Ok(None) => {
344 return HttpResponse::NotFound().json(serde_json::json!({
345 "error": "Account not found"
346 }))
347 }
348 Err(err) => {
349 return HttpResponse::InternalServerError().json(serde_json::json!({
350 "error": err
351 }))
352 }
353 };
354
355 if user.role != "superadmin" {
357 if let Ok(user_org_id) = user.require_organization() {
358 if user_org_id != existing_account.organization_id {
359 return HttpResponse::Forbidden().json(serde_json::json!({
360 "error": "You can only update accounts in your organization"
361 }));
362 }
363 }
364 }
365
366 let account_type = if let Some(ref type_str) = dto.account_type {
368 Some(match parse_account_type(type_str) {
369 Ok(at) => at,
370 Err(e) => {
371 return HttpResponse::BadRequest().json(serde_json::json!({
372 "error": e
373 }));
374 }
375 })
376 } else {
377 None
378 };
379
380 match state
382 .account_use_cases
383 .update_account(
384 account_id,
385 dto.label.clone(),
386 dto.parent_code.clone(),
387 account_type,
388 dto.direct_use,
389 )
390 .await
391 {
392 Ok(account) => {
393 AuditLogEntry::new(
395 AuditEventType::AccountUpdated,
396 Some(user.user_id),
397 Some(existing_account.organization_id),
398 )
399 .with_resource("Account", account.id)
400 .log();
401
402 HttpResponse::Ok().json(account_to_dto(&account))
403 }
404 Err(err) => {
405 AuditLogEntry::new(
407 AuditEventType::AccountUpdated,
408 Some(user.user_id),
409 Some(existing_account.organization_id),
410 )
411 .with_error(err.clone())
412 .log();
413
414 HttpResponse::BadRequest().json(serde_json::json!({
415 "error": err
416 }))
417 }
418 }
419}
420
421#[delete("/accounts/{id}")]
426pub async fn delete_account(
427 state: web::Data<AppState>,
428 user: AuthenticatedUser,
429 id: web::Path<String>,
430) -> impl Responder {
431 if user.role != "accountant" && user.role != "superadmin" {
433 return HttpResponse::Forbidden().json(serde_json::json!({
434 "error": "Only Accountant or SuperAdmin can delete accounts"
435 }));
436 }
437
438 let account_id = match Uuid::parse_str(&id) {
439 Ok(uuid) => uuid,
440 Err(_) => {
441 return HttpResponse::BadRequest().json(serde_json::json!({
442 "error": "Invalid account ID format"
443 }))
444 }
445 };
446
447 let existing_account = match state.account_use_cases.get_account(account_id).await {
449 Ok(Some(acc)) => acc,
450 Ok(None) => {
451 return HttpResponse::NotFound().json(serde_json::json!({
452 "error": "Account not found"
453 }))
454 }
455 Err(err) => {
456 return HttpResponse::InternalServerError().json(serde_json::json!({
457 "error": err
458 }))
459 }
460 };
461
462 if user.role != "superadmin" {
464 if let Ok(user_org_id) = user.require_organization() {
465 if user_org_id != existing_account.organization_id {
466 return HttpResponse::Forbidden().json(serde_json::json!({
467 "error": "You can only delete accounts in your organization"
468 }));
469 }
470 }
471 }
472
473 match state.account_use_cases.delete_account(account_id).await {
475 Ok(()) => {
476 AuditLogEntry::new(
478 AuditEventType::AccountDeleted,
479 Some(user.user_id),
480 Some(existing_account.organization_id),
481 )
482 .with_resource("Account", account_id)
483 .log();
484
485 HttpResponse::NoContent().finish()
486 }
487 Err(err) => {
488 AuditLogEntry::new(
490 AuditEventType::AccountDeleted,
491 Some(user.user_id),
492 Some(existing_account.organization_id),
493 )
494 .with_error(err.clone())
495 .log();
496
497 HttpResponse::BadRequest().json(serde_json::json!({
498 "error": err
499 }))
500 }
501 }
502}
503
504#[post("/accounts/seed/belgian-pcmn")]
509pub async fn seed_belgian_pcmn(
510 state: web::Data<AppState>,
511 user: AuthenticatedUser,
512 dto: web::Json<SeedBelgianPcmnDto>,
513) -> impl Responder {
514 if user.role != "superadmin" && user.role != "accountant" {
516 return HttpResponse::Forbidden().json(serde_json::json!({
517 "error": "Only SuperAdmin or Accountant can seed PCMN"
518 }));
519 }
520
521 let organization_id = match Uuid::parse_str(&dto.organization_id) {
523 Ok(id) => id,
524 Err(_) => {
525 return HttpResponse::BadRequest().json(serde_json::json!({
526 "error": "Invalid organization_id format"
527 }));
528 }
529 };
530
531 if user.role != "superadmin" {
533 if let Ok(user_org_id) = user.require_organization() {
534 if user_org_id != organization_id {
535 return HttpResponse::Forbidden().json(serde_json::json!({
536 "error": "You can only seed PCMN for your own organization"
537 }));
538 }
539 }
540 }
541
542 match state
544 .account_use_cases
545 .seed_belgian_pcmn(organization_id)
546 .await
547 {
548 Ok(count) => {
549 AuditLogEntry::new(
551 AuditEventType::BelgianPCMNSeeded,
552 Some(user.user_id),
553 Some(organization_id),
554 )
555 .with_metadata(serde_json::json!({
556 "accounts_created": count
557 }))
558 .log();
559
560 HttpResponse::Ok().json(SeedPcmnResponseDto {
561 accounts_created: count,
562 message: format!(
563 "Successfully created {} Belgian PCMN accounts for organization",
564 count
565 ),
566 })
567 }
568 Err(err) => {
569 AuditLogEntry::new(
571 AuditEventType::BelgianPCMNSeeded,
572 Some(user.user_id),
573 Some(organization_id),
574 )
575 .with_error(err.clone())
576 .log();
577
578 HttpResponse::BadRequest().json(serde_json::json!({
579 "error": err
580 }))
581 }
582 }
583}
584
585#[get("/accounts/count")]
590pub async fn count_accounts(state: web::Data<AppState>, user: AuthenticatedUser) -> impl Responder {
591 let organization_id = match user.require_organization() {
592 Ok(id) => id,
593 Err(e) => {
594 return HttpResponse::Unauthorized().json(serde_json::json!({
595 "error": e.to_string()
596 }))
597 }
598 };
599
600 match state
601 .account_use_cases
602 .count_accounts(organization_id)
603 .await
604 {
605 Ok(count) => HttpResponse::Ok().json(serde_json::json!({
606 "count": count,
607 "organization_id": organization_id.to_string()
608 })),
609 Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
610 "error": err
611 })),
612 }
613}