koprogo_api/infrastructure/web/handlers/
account_handlers.rs

1// API Handlers for Account Management
2//
3// CREDITS: Handler patterns inspired by Noalyss API structure (GPL-2.0+)
4// https://gitlab.com/noalyss/noalyss
5
6use 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
17/// Convert Domain Account to DTO
18fn 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
32/// Parse AccountType from string
33fn 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// ============================================================================
45// POST /api/v1/accounts - Create a new account
46// ============================================================================
47
48#[post("/accounts")]
49pub async fn create_account(
50    state: web::Data<AppState>,
51    user: AuthenticatedUser,
52    dto: web::Json<CreateAccountDto>,
53) -> impl Responder {
54    // Permission: Accountant or SuperAdmin can create accounts
55    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    // Validate DTO
62    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    // Parse organization_id
70    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    // Authorization: Check user belongs to organization (unless SuperAdmin)
80    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    // Parse account_type
95    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    // Call use case
105    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            // Audit log
119            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            // Audit log failure
131            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// ============================================================================
147// GET /api/v1/accounts - List accounts (with optional filters)
148// ============================================================================
149
150#[get("/accounts")]
151pub async fn list_accounts(
152    state: web::Data<AppState>,
153    user: AuthenticatedUser,
154    query: web::Query<AccountSearchQuery>,
155) -> impl Responder {
156    // Get organization_id (SuperAdmin can query any org, others only their own)
157    let organization_id = if user.role == "superadmin" {
158        // TODO: SuperAdmin could pass org_id as query param
159        // For now, require organization
160        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    // Handle different query scenarios
180    let accounts_result = if let Some(ref code_pattern) = query.code_pattern {
181        // Search by code pattern
182        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        // Filter by account type
188        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        // Filter by parent code
202        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        // Only direct-use accounts
208        state
209            .account_use_cases
210            .list_direct_use_accounts(organization_id)
211            .await
212    } else {
213        // List all accounts
214        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// ============================================================================
229// GET /api/v1/accounts/:id - Get account by ID
230// ============================================================================
231
232#[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            // Authorization: Check user belongs to organization (unless SuperAdmin)
250            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// ============================================================================
272// GET /api/v1/accounts/code/:code - Get account by code
273// ============================================================================
274
275#[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// ============================================================================
306// PUT /api/v1/accounts/:id - Update account
307// ============================================================================
308
309#[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    // Permission: Accountant or SuperAdmin
317    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    // Validate DTO
324    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    // Check account exists and user has permission
341    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    // Authorization check
356    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    // Parse account_type if provided
367    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    // Call use case
381    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            // Audit log
394            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            // Audit log failure
406            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// ============================================================================
422// DELETE /api/v1/accounts/:id - Delete account
423// ============================================================================
424
425#[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    // Permission: Accountant or SuperAdmin
432    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    // Check account exists and user has permission
448    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    // Authorization check
463    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    // Call use case (validates no children, not used in expenses)
474    match state.account_use_cases.delete_account(account_id).await {
475        Ok(()) => {
476            // Audit log
477            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            // Audit log failure
489            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// ============================================================================
505// POST /api/v1/accounts/seed/belgian-pcmn - Seed Belgian PCMN
506// ============================================================================
507
508#[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    // Permission: SuperAdmin or Accountant
515    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    // Parse organization_id
522    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    // Authorization: Check user belongs to organization (unless SuperAdmin)
532    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    // Call use case
543    match state
544        .account_use_cases
545        .seed_belgian_pcmn(organization_id)
546        .await
547    {
548        Ok(count) => {
549            // Audit log
550            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            // Audit log failure
570            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// ============================================================================
586// GET /api/v1/accounts/count - Count accounts in organization
587// ============================================================================
588
589#[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}