From e1fa9ab20ad3671da10dbce7cbeddd803e03a6be Mon Sep 17 00:00:00 2001 From: alexohre Date: Mon, 30 Mar 2026 10:37:57 +0100 Subject: [PATCH] feat: implement admin withdrawal management API (issue #516) - Add AdminWithdrawalController with 5 REST endpoints - Add AdminWithdrawalService with business logic for approve/reject - Add RejectWithdrawalDto and WithdrawalStatsDto - Extend MailService with approval/rejection email methods - Integrate audit logging for all admin actions - Add comprehensive spec documentation (requirements, design, tasks) - Support paginated pending withdrawals list - Support detailed withdrawal inspection with relations - Support approval workflow triggering existing processing flow - Support rejection workflow with mandatory reason - Support statistics endpoint with approval rates and processing times --- .../admin-withdrawal-management/.config.kiro | 1 + .../admin-withdrawal-management/design.md | 279 ++++++++++++++++ .../requirements.md | 115 +++++++ .../admin-withdrawal-management/tasks.md | 270 +++++++++++++++ .../admin/admin-withdrawal.controller.ts | 88 +++++ .../modules/admin/admin-withdrawal.service.ts | 313 ++++++++++++++++++ backend/src/modules/admin/admin.module.ts | 9 +- .../admin/dto/reject-withdrawal.dto.ts | 12 + .../modules/admin/dto/withdrawal-stats.dto.ts | 37 +++ backend/src/modules/mail/mail.service.ts | 52 +++ 10 files changed, 1175 insertions(+), 1 deletion(-) create mode 100644 .kiro/specs/admin-withdrawal-management/.config.kiro create mode 100644 .kiro/specs/admin-withdrawal-management/design.md create mode 100644 .kiro/specs/admin-withdrawal-management/requirements.md create mode 100644 .kiro/specs/admin-withdrawal-management/tasks.md create mode 100644 backend/src/modules/admin/admin-withdrawal.controller.ts create mode 100644 backend/src/modules/admin/admin-withdrawal.service.ts create mode 100644 backend/src/modules/admin/dto/reject-withdrawal.dto.ts create mode 100644 backend/src/modules/admin/dto/withdrawal-stats.dto.ts diff --git a/.kiro/specs/admin-withdrawal-management/.config.kiro b/.kiro/specs/admin-withdrawal-management/.config.kiro new file mode 100644 index 000000000..40044780c --- /dev/null +++ b/.kiro/specs/admin-withdrawal-management/.config.kiro @@ -0,0 +1 @@ +{"specId": "6f12e337-dd32-472e-b447-f877877c729d", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/admin-withdrawal-management/design.md b/.kiro/specs/admin-withdrawal-management/design.md new file mode 100644 index 000000000..c969e7478 --- /dev/null +++ b/.kiro/specs/admin-withdrawal-management/design.md @@ -0,0 +1,279 @@ +# Design Document: Admin Withdrawal Management + +## Overview + +This feature adds an admin-facing API layer for managing withdrawal requests in the Nestera backend. It follows the established admin module patterns (JWT + RBAC guards, paginated responses, audit logging) and integrates with the existing `SavingsService.processWithdrawal` flow rather than reimplementing it. + +The implementation adds: + +- `AdminWithdrawalController` — REST endpoints under `/v1/admin/withdrawals` +- `AdminWithdrawalService` — business logic for listing, approving, rejecting, and stats +- Two new `MailService` methods for approval/rejection emails +- `AuditLog` persistence for every mutating admin action + +--- + +## Architecture + +```mermaid +flowchart TD + Client -->|JWT + Role.ADMIN| AdminWithdrawalController + AdminWithdrawalController --> AdminWithdrawalService + AdminWithdrawalService --> WithdrawalRequestRepository[(withdrawal_requests)] + AdminWithdrawalService --> UserRepository[(users)] + AdminWithdrawalService --> AuditLogRepository[(audit_logs)] + AdminWithdrawalService --> SavingsService + AdminWithdrawalService --> MailService + SavingsService --> WithdrawalRequestRepository + SavingsService --> BlockchainSavingsService +``` + +The controller is thin — it validates input, extracts the current user, and delegates entirely to the service. The service owns all business logic and side effects (audit log, email). `SavingsService.processWithdrawal` is called (via a new public wrapper) to handle the PROCESSING → COMPLETED/FAILED transition so no withdrawal logic is duplicated. + +--- + +## Components and Interfaces + +### AdminWithdrawalController + +Path prefix: `admin/withdrawals`, version `1` +Guards: `@UseGuards(JwtAuthGuard, RolesGuard)` + `@Roles(Role.ADMIN)` + +| Method | Path | Description | +| ------ | -------------- | ---------------------------------- | +| GET | `/pending` | Paginated list of PENDING requests | +| GET | `/stats` | Aggregate statistics | +| GET | `/:id` | Single request detail | +| POST | `/:id/approve` | Approve a PENDING request | +| POST | `/:id/reject` | Reject a PENDING request | + +> Note: `/stats` and `/pending` must be declared before `/:id` to avoid route shadowing. + +### AdminWithdrawalService + +```typescript +listPending(opts: PageOptionsDto): Promise> +getDetail(id: string): Promise +approve(id: string, actor: User): Promise +reject(id: string, reason: string, actor: User): Promise +getStats(): Promise +``` + +### DTOs + +**RejectWithdrawalDto** + +```typescript +class RejectWithdrawalDto { + @IsString() + @IsNotEmpty() + reason: string; +} +``` + +**WithdrawalStatsDto** + +```typescript +interface WithdrawalStatsDto { + total: number; + byStatus: Record; + approvalRate: number; // percentage 0–100 + averageProcessingTimeMs: number; +} +``` + +### MailService additions + +```typescript +sendWithdrawalApprovedEmail(userEmail: string, name: string, amount: string, penalty: string, netAmount: string): Promise +sendWithdrawalRejectedEmail(userEmail: string, name: string, reason: string): Promise +``` + +Both follow the existing fire-and-forget pattern (try/catch, log error, never throw). + +--- + +## Data Models + +No new database tables or migrations are required. The feature uses existing entities: + +**WithdrawalRequest** (existing) — `status` and `reason` fields are updated by approve/reject actions. + +**AuditLog** (existing) — one record written per approve/reject call with: + +| Field | Value | +| --------------- | ---------------------------------------------------------- | +| `correlationId` | from request header `x-correlation-id` (or generated UUID) | +| `endpoint` | e.g. `/v1/admin/withdrawals/:id/approve` | +| `method` | `POST` | +| `action` | `APPROVE` or `REJECT` | +| `actor` | authenticated admin's email | +| `resourceId` | `WithdrawalRequest.id` | +| `resourceType` | `WITHDRAWAL_REQUEST` | +| `statusCode` | HTTP response code | +| `durationMs` | elapsed ms from start of handler | +| `success` | `true` on success, `false` on error | +| `errorMessage` | error description on failure, `null` otherwise | + +**Module registration** — `AdminModule` must add `WithdrawalRequest`, `AuditLog` to `TypeOrmModule.forFeature([...])` and register `AdminWithdrawalController` and `AdminWithdrawalService`. + +--- + +## Correctness Properties + +_A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees._ + +### Property 1: Pending list returns only PENDING records, correctly paginated and ordered + +_For any_ set of withdrawal requests with mixed statuses and any valid `PageOptionsDto`, the `/pending` endpoint should return only records with `status = PENDING`, the `data` array length should not exceed `limit`, and records should be ordered by `createdAt` in the requested direction. + +**Validates: Requirements 1.1, 1.2, 1.3** + +--- + +### Property 2: Non-existent resource returns 404 + +_For any_ UUID that does not correspond to an existing `WithdrawalRequest`, calling the detail, approve, or reject endpoints should return a `404 Not Found` response. + +**Validates: Requirements 2.2, 3.2, 4.3** + +--- + +### Property 3: Approving a non-PENDING withdrawal returns 400 + +_For any_ `WithdrawalRequest` whose status is `PROCESSING`, `COMPLETED`, or `FAILED`, calling the approve endpoint should return a `400 Bad Request` response and leave the record unchanged. + +**Validates: Requirements 3.3** + +--- + +### Property 4: Rejecting a non-PENDING withdrawal returns 400 + +_For any_ `WithdrawalRequest` whose status is not `PENDING`, calling the reject endpoint should return a `400 Bad Request` response and leave the record unchanged. + +**Validates: Requirements 4.4** + +--- + +### Property 5: Approve transitions status to PROCESSING + +_For any_ `WithdrawalRequest` with `status = PENDING`, calling approve should result in the record's status being updated to `PROCESSING` (triggering the async processing flow). + +**Validates: Requirements 3.1** + +--- + +### Property 6: Reject transitions status to FAILED and persists reason + +_For any_ `WithdrawalRequest` with `status = PENDING` and any non-empty reason string, calling reject should result in `status = FAILED` and `reason` equal to the provided string. + +**Validates: Requirements 4.1** + +--- + +### Property 7: Empty or whitespace reason is rejected + +_For any_ string composed entirely of whitespace (or an absent `reason` field), calling the reject endpoint should return a `400 Bad Request` response. + +**Validates: Requirements 4.2** + +--- + +### Property 8: Audit log is written with all required fields for every mutating action + +_For any_ approve or reject action (successful or failed), an `AuditLog` record should be persisted containing non-null values for `correlationId`, `endpoint`, `method`, `action`, `actor`, `resourceId`, `resourceType`, `statusCode`, `durationMs`, and `success`. + +**Validates: Requirements 3.4, 4.5, 7.1, 7.2** + +--- + +### Property 9: Failed operations produce audit log entries with success = false + +_For any_ approve or reject operation that throws an error, the persisted `AuditLog` entry should have `success = false` and a non-null `errorMessage`. + +**Validates: Requirements 7.3** + +--- + +### Property 10: Approval email is sent with correct financial fields + +_For any_ successful approval, `MailService.sendWithdrawalApprovedEmail` should be called exactly once with the user's email, name, and the withdrawal's `amount`, `penalty`, and `netAmount`. + +**Validates: Requirements 3.5, 6.1** + +--- + +### Property 11: Rejection email is sent with the rejection reason + +_For any_ successful rejection, `MailService.sendWithdrawalRejectedEmail` should be called exactly once with the user's email, name, and the provided `reason`. + +**Validates: Requirements 4.6, 6.2** + +--- + +### Property 12: Mail failure does not abort the operation + +_For any_ approve or reject action where `MailService` throws an exception, the operation should still complete successfully (status updated, audit log written) and not propagate the mail error to the caller. + +**Validates: Requirements 6.3** + +--- + +### Property 13: Stats correctly aggregate all withdrawal requests + +_For any_ set of withdrawal requests in the database, the stats endpoint should return `total` equal to the count of all records, `byStatus` counts summing to `total`, `approvalRate = (COMPLETED count / total) * 100` (or `0` when total is `0`), and `averageProcessingTimeMs` equal to the mean of `(completedAt - createdAt)` for COMPLETED records (or `0` when none exist). + +**Validates: Requirements 5.1, 5.2, 5.3** + +--- + +## Error Handling + +| Scenario | Response | +| --------------------------- | --------------------------------------------------------------------------------- | +| Withdrawal not found | `404 Not Found` with message `Withdrawal request {id} not found` | +| Approve/reject non-PENDING | `400 Bad Request` with message `Withdrawal request is not in PENDING status` | +| Empty/missing reject reason | `400 Bad Request` (class-validator via `@IsNotEmpty()`) | +| Unauthenticated request | `401 Unauthorized` (JwtAuthGuard) | +| Non-admin request | `403 Forbidden` (RolesGuard) | +| Mail send failure | Logged at WARN level, operation continues | +| Audit log write failure | Logged at ERROR level; should not abort the primary operation (wrap in try/catch) | + +--- + +## Testing Strategy + +### Unit Tests + +Focus on specific examples, edge cases, and error conditions: + +- `AdminWithdrawalService.listPending` — returns only PENDING records; empty result when none exist +- `AdminWithdrawalService.getDetail` — throws `NotFoundException` for unknown ID; returns subscription relation +- `AdminWithdrawalService.approve` — throws `NotFoundException` for unknown ID; throws `BadRequestException` for non-PENDING; calls `SavingsService.processWithdrawal`; writes audit log; calls mail service +- `AdminWithdrawalService.reject` — throws `NotFoundException`; throws `BadRequestException` for non-PENDING; persists reason and FAILED status; writes audit log; calls mail service +- `AdminWithdrawalService.getStats` — correct computation with mixed statuses; all-zero result for empty DB; `averageProcessingTimeMs = 0` when no COMPLETED records +- Mail failure resilience — mock `MailService` to throw; verify operation succeeds and error is logged + +### Property-Based Tests + +Use **fast-check** (already available in the JS ecosystem, compatible with Jest/Vitest). + +Each property test runs a minimum of **100 iterations**. + +Tag format: `// Feature: admin-withdrawal-management, Property {N}: {property_text}` + +| Property | Test Description | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| P1 | Generate random arrays of `WithdrawalRequest` with mixed statuses; verify pending list returns only PENDING, correct count, correct order | +| P2 | Generate random UUIDs not in DB; verify 404 for detail/approve/reject | +| P3 | Generate `WithdrawalRequest` with status ∈ {PROCESSING, COMPLETED, FAILED}; verify approve returns 400 | +| P4 | Generate `WithdrawalRequest` with status ∈ {PROCESSING, COMPLETED, FAILED}; verify reject returns 400 | +| P5 | Generate PENDING `WithdrawalRequest`; verify approve sets status to PROCESSING | +| P6 | Generate PENDING `WithdrawalRequest` + non-empty reason; verify reject sets status = FAILED and persists reason | +| P7 | Generate whitespace-only strings; verify reject returns 400 | +| P8 | Generate approve/reject actions; verify audit log record has all required non-null fields | +| P9 | Mock service to throw; verify audit log has `success = false` and non-null `errorMessage` | +| P10 | Generate PENDING requests; verify mail called with correct amount/penalty/netAmount | +| P11 | Generate PENDING requests + reasons; verify mail called with correct reason | +| P12 | Mock mail to throw; verify operation completes and no exception propagates | +| P13 | Generate random sets of withdrawal requests with varying statuses and `completedAt` values; verify stats computation | diff --git a/.kiro/specs/admin-withdrawal-management/requirements.md b/.kiro/specs/admin-withdrawal-management/requirements.md new file mode 100644 index 000000000..7ec63e248 --- /dev/null +++ b/.kiro/specs/admin-withdrawal-management/requirements.md @@ -0,0 +1,115 @@ +# Requirements Document + +## Introduction + +This feature adds an admin-facing API for reviewing and managing withdrawal requests in the Nestera backend. Admins can list pending requests, inspect individual requests, approve or reject them with optional reasons, view aggregate statistics, and receive audit trails for all actions. Users are notified by email when their withdrawal request is approved or rejected. + +## Glossary + +- **Admin_API**: The NestJS controller layer secured with `JwtAuthGuard`, `RolesGuard`, and `Role.ADMIN` that exposes the `/admin/withdrawals` endpoints. +- **WithdrawalRequest**: The `withdrawal_requests` database entity representing a user's request to withdraw funds from a savings subscription. +- **WithdrawalStatus**: Enum with values `PENDING`, `PROCESSING`, `COMPLETED`, `FAILED`. +- **AdminWithdrawalService**: The NestJS service that implements the business logic for admin withdrawal management. +- **AuditLog**: The `audit_logs` database entity that records every admin action with actor, resource, timestamp, and outcome. +- **MailService**: The existing NestJS mail service used to send transactional emails to users. +- **PageOptionsDto**: The shared DTO for pagination parameters (`page`, `limit`, `order`). +- **PageDto**: The shared paginated response wrapper containing `data` and `meta`. +- **Actor**: The authenticated admin user performing an action, identified by email. + +--- + +## Requirements + +### Requirement 1: List Pending Withdrawal Requests + +**User Story:** As an admin, I want to list all pending withdrawal requests, so that I can review and prioritize which ones to process. + +#### Acceptance Criteria + +1. WHEN a `GET /admin/withdrawals/pending` request is received, THE Admin_API SHALL return a paginated `PageDto` of `WithdrawalRequest` records where `status = PENDING`. +2. THE Admin_API SHALL accept `PageOptionsDto` query parameters (`page`, `limit`, `order`) for the pending list endpoint. +3. THE Admin_API SHALL order results by `createdAt` in the direction specified by the `order` parameter, defaulting to `ASC`. +4. THE Admin_API SHALL require a valid JWT token with `Role.ADMIN` to access the pending list endpoint; requests without valid admin credentials SHALL receive a `401` or `403` response. +5. WHEN no pending withdrawal requests exist, THE Admin_API SHALL return an empty `data` array with correct pagination `meta`. + +--- + +### Requirement 2: Get Withdrawal Request Detail + +**User Story:** As an admin, I want to view the full details of a specific withdrawal request, so that I can make an informed approval or rejection decision. + +#### Acceptance Criteria + +1. WHEN a `GET /admin/withdrawals/:id` request is received with a valid UUID, THE Admin_API SHALL return the full `WithdrawalRequest` record including its nested `subscription` relation. +2. IF the requested `id` does not correspond to an existing `WithdrawalRequest`, THEN THE Admin_API SHALL return a `404 Not Found` response with a descriptive error message. +3. THE Admin_API SHALL require a valid JWT token with `Role.ADMIN`; requests without valid admin credentials SHALL receive a `401` or `403` response. + +--- + +### Requirement 3: Approve a Withdrawal Request + +**User Story:** As an admin, I want to approve a pending withdrawal request, so that the user's funds are released and they are notified. + +#### Acceptance Criteria + +1. WHEN a `POST /admin/withdrawals/:id/approve` request is received for a `PENDING` withdrawal, THE AdminWithdrawalService SHALL update the `WithdrawalRequest` status to `PROCESSING` and trigger the existing withdrawal processing flow. +2. IF the `WithdrawalRequest` with the given `id` does not exist, THEN THE Admin_API SHALL return a `404 Not Found` response. +3. IF the `WithdrawalRequest` status is not `PENDING` at the time of the approve request, THEN THE Admin_API SHALL return a `400 Bad Request` response with a message indicating the request is not in a pending state. +4. WHEN a withdrawal is approved, THE AdminWithdrawalService SHALL write an `AuditLog` entry recording the actor's email, the `WithdrawalRequest` id as `resourceId`, `resourceType = WITHDRAWAL_REQUEST`, and `action = APPROVE`. +5. WHEN a withdrawal is approved, THE MailService SHALL send an approval notification email to the user associated with the `WithdrawalRequest`. +6. THE Admin_API SHALL require a valid JWT token with `Role.ADMIN`; requests without valid admin credentials SHALL receive a `401` or `403` response. + +--- + +### Requirement 4: Reject a Withdrawal Request + +**User Story:** As an admin, I want to reject a pending withdrawal request with a reason, so that the user understands why their request was denied. + +#### Acceptance Criteria + +1. WHEN a `POST /admin/withdrawals/:id/reject` request is received with a non-empty `reason` string, THE AdminWithdrawalService SHALL update the `WithdrawalRequest` status to `FAILED` and persist the `reason` field. +2. IF the `reason` field is absent or empty in the reject request body, THEN THE Admin_API SHALL return a `400 Bad Request` response. +3. IF the `WithdrawalRequest` with the given `id` does not exist, THEN THE Admin_API SHALL return a `404 Not Found` response. +4. IF the `WithdrawalRequest` status is not `PENDING` at the time of the reject request, THEN THE Admin_API SHALL return a `400 Bad Request` response with a message indicating the request is not in a pending state. +5. WHEN a withdrawal is rejected, THE AdminWithdrawalService SHALL write an `AuditLog` entry recording the actor's email, the `WithdrawalRequest` id as `resourceId`, `resourceType = WITHDRAWAL_REQUEST`, and `action = REJECT`. +6. WHEN a withdrawal is rejected, THE MailService SHALL send a rejection notification email including the rejection reason to the user associated with the `WithdrawalRequest`. +7. THE Admin_API SHALL require a valid JWT token with `Role.ADMIN`; requests without valid admin credentials SHALL receive a `401` or `403` response. + +--- + +### Requirement 5: Withdrawal Statistics + +**User Story:** As an admin, I want to view aggregate statistics on withdrawal requests, so that I can monitor approval rates and processing performance. + +#### Acceptance Criteria + +1. WHEN a `GET /admin/withdrawals/stats` request is received, THE Admin_API SHALL return a statistics object containing: total withdrawal request count, count by each `WithdrawalStatus` value, approval rate as a percentage (approved count / total count × 100), and average processing time in milliseconds for `COMPLETED` requests (calculated as the mean of `completedAt - createdAt`). +2. WHEN no `COMPLETED` withdrawal requests exist, THE Admin_API SHALL return `averageProcessingTimeMs = 0` for the average processing time field. +3. WHEN no withdrawal requests exist at all, THE Admin_API SHALL return all counts as `0` and `approvalRate = 0`. +4. THE Admin_API SHALL require a valid JWT token with `Role.ADMIN`; requests without valid admin credentials SHALL receive a `401` or `403` response. + +--- + +### Requirement 6: Email Notifications for Approval and Rejection + +**User Story:** As a user, I want to receive an email when my withdrawal request is approved or rejected, so that I am kept informed of the outcome. + +#### Acceptance Criteria + +1. WHEN a withdrawal request is approved, THE MailService SHALL send an email to the user's registered email address containing the withdrawal amount, penalty amount, and net amount. +2. WHEN a withdrawal request is rejected, THE MailService SHALL send an email to the user's registered email address containing the rejection reason. +3. IF the MailService fails to send an email, THEN THE AdminWithdrawalService SHALL log the error and continue without throwing an exception, so that the approval or rejection action is not rolled back. +4. THE MailService SHALL add `sendWithdrawalApprovedEmail(userEmail, name, amount, penalty, netAmount)` and `sendWithdrawalRejectedEmail(userEmail, name, reason)` methods to support the new notification types. + +--- + +### Requirement 7: Audit Logging for Admin Actions + +**User Story:** As a compliance officer, I want every admin action on withdrawal requests to be recorded in an audit log, so that there is a traceable history of all decisions. + +#### Acceptance Criteria + +1. THE AdminWithdrawalService SHALL write an `AuditLog` record for every approve and reject action before returning a response to the caller. +2. WHEN an `AuditLog` entry is written, THE AdminWithdrawalService SHALL populate: `correlationId` from the request context, `endpoint` with the request path, `method` with the HTTP method, `action` with `APPROVE` or `REJECT`, `actor` with the authenticated admin's email, `resourceId` with the `WithdrawalRequest` UUID, `resourceType` with `WITHDRAWAL_REQUEST`, `statusCode` with the HTTP response code, `durationMs` with the elapsed time in milliseconds, and `success` with `true` for successful operations. +3. IF an approve or reject operation fails, THEN THE AdminWithdrawalService SHALL write an `AuditLog` entry with `success = false` and `errorMessage` populated with the error description. +4. THE AdminWithdrawalService SHALL persist `AuditLog` entries using the existing `AuditLog` TypeORM entity and repository. diff --git a/.kiro/specs/admin-withdrawal-management/tasks.md b/.kiro/specs/admin-withdrawal-management/tasks.md new file mode 100644 index 000000000..0d47e7c04 --- /dev/null +++ b/.kiro/specs/admin-withdrawal-management/tasks.md @@ -0,0 +1,270 @@ +# Implementation Plan: Admin Withdrawal Management + +## Overview + +This implementation adds an admin-facing API for managing withdrawal requests. The feature follows existing admin module patterns (JWT + RBAC guards, paginated responses, audit logging) and integrates with the existing `SavingsService` withdrawal processing flow. + +## Tasks + +- [x] 1. Create DTOs and update entities + - [x] 1.1 Create RejectWithdrawalDto with validation + - Create `backend/src/modules/admin/dto/reject-withdrawal.dto.ts` + - Add `@IsString()` and `@IsNotEmpty()` decorators for `reason` field + - _Requirements: 4.2_ + + - [x] 1.2 Create WithdrawalStatsDto interface + - Create `backend/src/modules/admin/dto/withdrawal-stats.dto.ts` + - Define interface with `total`, `byStatus`, `approvalRate`, and `averageProcessingTimeMs` fields + - _Requirements: 5.1_ + +- [x] 2. Extend MailService with approval and rejection emails + - [x] 2.1 Add sendWithdrawalApprovedEmail method + - Add method to `backend/src/modules/mail/mail.service.ts` + - Accept parameters: `userEmail`, `name`, `amount`, `penalty`, `netAmount` + - Follow existing fire-and-forget pattern with try/catch and logging + - _Requirements: 3.5, 6.1_ + + - [x] 2.2 Add sendWithdrawalRejectedEmail method + - Add method to `backend/src/modules/mail/mail.service.ts` + - Accept parameters: `userEmail`, `name`, `reason` + - Follow existing fire-and-forget pattern with try/catch and logging + - _Requirements: 4.6, 6.2_ + + - [ ]\* 2.3 Write unit tests for new MailService methods + - Test successful email sending + - Test error handling and logging when mail fails + - Verify fire-and-forget behavior (no exceptions thrown) + - _Requirements: 6.3_ + +- [x] 3. Implement AdminWithdrawalService + - [x] 3.1 Create AdminWithdrawalService with dependencies + - Create `backend/src/modules/admin/admin-withdrawal.service.ts` + - Inject `WithdrawalRequest` repository, `User` repository, `AuditLog` repository, `SavingsService`, and `MailService` + - _Requirements: 1.1, 2.1, 3.1, 4.1, 5.1, 7.1_ + + - [x] 3.2 Implement listPending method + - Accept `PageOptionsDto` parameter + - Query `WithdrawalRequest` where `status = PENDING` + - Order by `createdAt` in direction specified by `order` parameter (default ASC) + - Return `PageDto` with pagination metadata + - _Requirements: 1.1, 1.2, 1.3, 1.5_ + + - [ ]\* 3.3 Write property test for listPending + - **Property 1: Pending list returns only PENDING records, correctly paginated and ordered** + - **Validates: Requirements 1.1, 1.2, 1.3** + - Generate random arrays of withdrawal requests with mixed statuses + - Verify only PENDING records returned, correct pagination, correct ordering + + - [x] 3.4 Implement getDetail method + - Accept withdrawal `id` parameter + - Query `WithdrawalRequest` with `subscription` relation + - Throw `NotFoundException` if not found + - _Requirements: 2.1, 2.2_ + + - [ ]\* 3.5 Write property test for getDetail + - **Property 2: Non-existent resource returns 404** + - **Validates: Requirements 2.2** + - Generate random UUIDs not in database + - Verify 404 response for non-existent IDs + + - [x] 3.6 Implement approve method + - Accept withdrawal `id` and `actor` (User) parameters + - Load withdrawal request, throw `NotFoundException` if not found + - Throw `BadRequestException` if status is not PENDING + - Update status to PROCESSING + - Call `SavingsService.processWithdrawal` to trigger processing flow + - Write audit log entry with all required fields + - Send approval email via MailService (wrapped in try/catch) + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_ + + - [ ]\* 3.7 Write property tests for approve method + - **Property 3: Approving a non-PENDING withdrawal returns 400** + - **Validates: Requirements 3.3** + - Generate withdrawal requests with status ∈ {PROCESSING, COMPLETED, FAILED} + - Verify 400 response and no status change + + - [ ]\* 3.8 Write property test for approve status transition + - **Property 5: Approve transitions status to PROCESSING** + - **Validates: Requirements 3.1** + - Generate PENDING withdrawal requests + - Verify status updated to PROCESSING after approval + + - [ ]\* 3.9 Write property test for approve email + - **Property 10: Approval email is sent with correct financial fields** + - **Validates: Requirements 3.5, 6.1** + - Generate PENDING requests with various amounts + - Verify MailService called with correct email, name, amount, penalty, netAmount + + - [x] 3.10 Implement reject method + - Accept withdrawal `id`, `reason`, and `actor` (User) parameters + - Load withdrawal request, throw `NotFoundException` if not found + - Throw `BadRequestException` if status is not PENDING + - Update status to FAILED and persist reason + - Write audit log entry with all required fields + - Send rejection email via MailService (wrapped in try/catch) + - _Requirements: 4.1, 4.3, 4.4, 4.5, 4.6_ + + - [ ]\* 3.11 Write property tests for reject method + - **Property 4: Rejecting a non-PENDING withdrawal returns 400** + - **Validates: Requirements 4.4** + - Generate withdrawal requests with status ∈ {PROCESSING, COMPLETED, FAILED} + - Verify 400 response and no status change + + - [ ]\* 3.12 Write property test for reject status transition + - **Property 6: Reject transitions status to FAILED and persists reason** + - **Validates: Requirements 4.1** + - Generate PENDING requests with non-empty reasons + - Verify status = FAILED and reason persisted correctly + + - [ ]\* 3.13 Write property test for reject email + - **Property 11: Rejection email is sent with the rejection reason** + - **Validates: Requirements 4.6, 6.2** + - Generate PENDING requests with various reasons + - Verify MailService called with correct email, name, and reason + + - [x] 3.14 Implement getStats method + - Query all withdrawal requests + - Calculate total count + - Calculate count by each WithdrawalStatus value + - Calculate approval rate: (COMPLETED count / total) \* 100 (or 0 if total is 0) + - Calculate average processing time: mean of (completedAt - createdAt) for COMPLETED records (or 0 if none exist) + - Return WithdrawalStatsDto + - _Requirements: 5.1, 5.2, 5.3_ + + - [ ]\* 3.15 Write property test for getStats + - **Property 13: Stats correctly aggregate all withdrawal requests** + - **Validates: Requirements 5.1, 5.2, 5.3** + - Generate random sets of withdrawal requests with varying statuses and completedAt values + - Verify total, byStatus counts, approvalRate, and averageProcessingTimeMs calculations + + - [ ]\* 3.16 Write unit tests for AdminWithdrawalService + - Test listPending returns empty result when no PENDING requests exist + - Test getDetail throws NotFoundException for unknown ID + - Test approve throws NotFoundException for unknown ID + - Test approve throws BadRequestException for non-PENDING status + - Test approve calls SavingsService.processWithdrawal + - Test approve writes audit log + - Test reject throws NotFoundException for unknown ID + - Test reject throws BadRequestException for non-PENDING status + - Test reject persists reason and FAILED status + - Test reject writes audit log + - Test getStats returns all-zero result for empty database + - Test getStats returns averageProcessingTimeMs = 0 when no COMPLETED records + - Test mail failure does not abort operation (mock MailService to throw) + - _Requirements: 1.5, 2.2, 3.2, 3.3, 3.4, 4.3, 4.4, 5.2, 5.3, 6.3_ + +- [ ] 4. Checkpoint - Ensure service tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Implement AdminWithdrawalController + - [x] 5.1 Create AdminWithdrawalController with guards and versioning + - Create `backend/src/modules/admin/admin-withdrawal.controller.ts` + - Add `@Controller('admin/withdrawals')` decorator + - Add `@ApiTags('admin-withdrawals')` for Swagger documentation + - Add `@UseGuards(JwtAuthGuard, RolesGuard)` and `@Roles(Role.ADMIN)` decorators + - Set version to '1' using `@Version('1')` + - Inject `AdminWithdrawalService` + - _Requirements: 1.4, 2.3, 3.6, 4.7, 5.4_ + + - [x] 5.2 Implement GET /stats endpoint + - Add `@Get('stats')` method (must be declared before `/:id` to avoid route shadowing) + - Call `adminWithdrawalService.getStats()` + - Return `WithdrawalStatsDto` + - _Requirements: 5.1_ + + - [x] 5.3 Implement GET /pending endpoint + - Add `@Get('pending')` method (must be declared before `/:id` to avoid route shadowing) + - Accept `@Query()` parameter of type `PageOptionsDto` + - Call `adminWithdrawalService.listPending(opts)` + - Return `PageDto` + - _Requirements: 1.1, 1.2_ + + - [x] 5.4 Implement GET /:id endpoint + - Add `@Get(':id')` method + - Accept `@Param('id')` parameter + - Call `adminWithdrawalService.getDetail(id)` + - Return `WithdrawalRequest` + - _Requirements: 2.1_ + + - [x] 5.5 Implement POST /:id/approve endpoint + - Add `@Post(':id/approve')` method + - Accept `@Param('id')` and `@CurrentUser()` decorator for actor + - Call `adminWithdrawalService.approve(id, actor)` + - Return updated `WithdrawalRequest` + - _Requirements: 3.1_ + + - [x] 5.6 Implement POST /:id/reject endpoint + - Add `@Post(':id/reject')` method + - Accept `@Param('id')`, `@Body()` of type `RejectWithdrawalDto`, and `@CurrentUser()` for actor + - Validate reason is non-empty (handled by DTO validation) + - Call `adminWithdrawalService.reject(id, body.reason, actor)` + - Return updated `WithdrawalRequest` + - _Requirements: 4.1, 4.2_ + + - [ ]\* 5.7 Write property test for empty reason validation + - **Property 7: Empty or whitespace reason is rejected** + - **Validates: Requirements 4.2** + - Generate whitespace-only strings + - Verify 400 response from reject endpoint + + - [ ]\* 5.8 Write unit tests for AdminWithdrawalController + - Test each endpoint delegates to service correctly + - Test guards are applied (JWT + RBAC) + - Test route ordering (stats and pending before :id) + - _Requirements: 1.4, 2.3, 3.6, 4.7, 5.4_ + +- [x] 6. Implement audit logging + - [x] 6.1 Add audit log writing to approve method + - In `AdminWithdrawalService.approve`, write `AuditLog` entry + - Populate: `correlationId`, `endpoint`, `method`, `action = APPROVE`, `actor`, `resourceId`, `resourceType = WITHDRAWAL_REQUEST`, `statusCode`, `durationMs`, `success` + - Wrap in try/catch to prevent audit log failure from aborting operation + - _Requirements: 3.4, 7.1, 7.2_ + + - [x] 6.2 Add audit log writing to reject method + - In `AdminWithdrawalService.reject`, write `AuditLog` entry + - Populate: `correlationId`, `endpoint`, `method`, `action = REJECT`, `actor`, `resourceId`, `resourceType = WITHDRAWAL_REQUEST`, `statusCode`, `durationMs`, `success` + - Wrap in try/catch to prevent audit log failure from aborting operation + - _Requirements: 4.5, 7.1, 7.2_ + + - [x] 6.3 Add error audit logging + - In both approve and reject methods, catch errors and write audit log with `success = false` and `errorMessage` + - _Requirements: 7.3_ + + - [ ]\* 6.4 Write property tests for audit logging + - **Property 8: Audit log is written with all required fields for every mutating action** + - **Validates: Requirements 3.4, 4.5, 7.1, 7.2** + - Generate approve/reject actions + - Verify audit log has all required non-null fields + + - [ ]\* 6.5 Write property test for failed operation audit logging + - **Property 9: Failed operations produce audit log entries with success = false** + - **Validates: Requirements 7.3** + - Mock service to throw errors + - Verify audit log has success = false and non-null errorMessage + + - [ ]\* 6.6 Write property test for mail failure resilience + - **Property 12: Mail failure does not abort the operation** + - **Validates: Requirements 6.3** + - Mock MailService to throw exceptions + - Verify operation completes successfully (status updated, audit log written) + - Verify mail error does not propagate to caller + +- [x] 7. Update AdminModule configuration + - [x] 7.1 Register new entities and services in AdminModule + - Update `backend/src/modules/admin/admin.module.ts` + - Add `WithdrawalRequest` and `AuditLog` to `TypeOrmModule.forFeature([...])` + - Add `AdminWithdrawalController` to controllers array + - Add `AdminWithdrawalService` to providers array + - _Requirements: 1.1, 2.1, 3.1, 4.1, 5.1, 7.1_ + +- [ ] 8. Final checkpoint - Integration verification + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Property tests use fast-check library with minimum 100 iterations +- Audit logging is wrapped in try/catch to prevent failures from aborting primary operations +- Mail sending follows fire-and-forget pattern (errors logged but not thrown) +- Route ordering in controller is critical: `/stats` and `/pending` must be declared before `/:id` diff --git a/backend/src/modules/admin/admin-withdrawal.controller.ts b/backend/src/modules/admin/admin-withdrawal.controller.ts new file mode 100644 index 000000000..dd4d1b22d --- /dev/null +++ b/backend/src/modules/admin/admin-withdrawal.controller.ts @@ -0,0 +1,88 @@ +import { + Controller, + Get, + Post, + Param, + Body, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiResponse, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { User } from '../user/entities/user.entity'; +import { AdminWithdrawalService } from './admin-withdrawal.service'; +import { PageOptionsDto } from '../../common/dto/page-options.dto'; +import { RejectWithdrawalDto } from './dto/reject-withdrawal.dto'; +import { WithdrawalStatsResponseDto } from './dto/withdrawal-stats.dto'; + +@ApiTags('admin-withdrawals') +@ApiBearerAuth() +@Controller({ path: 'admin/withdrawals', version: '1' }) +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(Role.ADMIN) +export class AdminWithdrawalController { + constructor( + private readonly adminWithdrawalService: AdminWithdrawalService, + ) {} + + @Get('stats') + @ApiOperation({ summary: 'Get withdrawal statistics' }) + @ApiResponse({ + status: 200, + description: 'Withdrawal statistics', + type: WithdrawalStatsResponseDto, + }) + async getStats() { + return this.adminWithdrawalService.getStats(); + } + + @Get('pending') + @ApiOperation({ summary: 'List pending withdrawal requests' }) + @ApiResponse({ status: 200, description: 'Paginated list of pending withdrawals' }) + async listPending(@Query() opts: PageOptionsDto) { + return this.adminWithdrawalService.listPending(opts); + } + + @Get(':id') + @ApiOperation({ summary: 'Get withdrawal request detail' }) + @ApiResponse({ status: 200, description: 'Withdrawal request detail' }) + @ApiResponse({ status: 404, description: 'Withdrawal request not found' }) + async getDetail(@Param('id', ParseUUIDPipe) id: string) { + return this.adminWithdrawalService.getDetail(id); + } + + @Post(':id/approve') + @ApiOperation({ summary: 'Approve a pending withdrawal request' }) + @ApiResponse({ status: 200, description: 'Withdrawal request approved' }) + @ApiResponse({ status: 400, description: 'Withdrawal request is not in PENDING status' }) + @ApiResponse({ status: 404, description: 'Withdrawal request not found' }) + async approve( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() actor: User, + ) { + return this.adminWithdrawalService.approve(id, actor); + } + + @Post(':id/reject') + @ApiOperation({ summary: 'Reject a pending withdrawal request' }) + @ApiResponse({ status: 200, description: 'Withdrawal request rejected' }) + @ApiResponse({ status: 400, description: 'Withdrawal request is not in PENDING status or invalid reason' }) + @ApiResponse({ status: 404, description: 'Withdrawal request not found' }) + async reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: RejectWithdrawalDto, + @CurrentUser() actor: User, + ) { + return this.adminWithdrawalService.reject(id, body.reason, actor); + } +} diff --git a/backend/src/modules/admin/admin-withdrawal.service.ts b/backend/src/modules/admin/admin-withdrawal.service.ts new file mode 100644 index 000000000..66f4a2628 --- /dev/null +++ b/backend/src/modules/admin/admin-withdrawal.service.ts @@ -0,0 +1,313 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + WithdrawalRequest, + WithdrawalStatus, +} from '../savings/entities/withdrawal-request.entity'; +import { User } from '../user/entities/user.entity'; +import { AuditLog } from '../../common/entities/audit-log.entity'; +import { MailService } from '../mail/mail.service'; +import { SavingsService } from '../savings/savings.service'; +import { PageOptionsDto } from '../../common/dto/page-options.dto'; +import { PageDto } from '../../common/dto/page.dto'; +import { paginate } from '../../common/helpers/pagination.helper'; +import { WithdrawalStatsDto } from './dto/withdrawal-stats.dto'; + +@Injectable() +export class AdminWithdrawalService { + private readonly logger = new Logger(AdminWithdrawalService.name); + + constructor( + @InjectRepository(WithdrawalRequest) + private readonly withdrawalRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(AuditLog) + private readonly auditLogRepository: Repository, + private readonly savingsService: SavingsService, + private readonly mailService: MailService, + ) {} + + async listPending(opts: PageOptionsDto): Promise> { + const queryBuilder = this.withdrawalRepository + .createQueryBuilder('withdrawal') + .leftJoinAndSelect('withdrawal.subscription', 'subscription') + .where('withdrawal.status = :status', { + status: WithdrawalStatus.PENDING, + }); + + return paginate(queryBuilder, opts); + } + + async getDetail(id: string): Promise { + const withdrawal = await this.withdrawalRepository.findOne({ + where: { id }, + relations: ['subscription', 'subscription.product'], + }); + + if (!withdrawal) { + throw new NotFoundException(`Withdrawal request ${id} not found`); + } + + return withdrawal; + } + + async approve(id: string, actor: User): Promise { + const startTime = Date.now(); + const correlationId = `approve-${id}-${Date.now()}`; + + try { + const withdrawal = await this.withdrawalRepository.findOne({ + where: { id }, + relations: ['subscription', 'subscription.product'], + }); + + if (!withdrawal) { + throw new NotFoundException(`Withdrawal request ${id} not found`); + } + + if (withdrawal.status !== WithdrawalStatus.PENDING) { + throw new BadRequestException( + 'Withdrawal request is not in PENDING status', + ); + } + + // Update status to PROCESSING + withdrawal.status = WithdrawalStatus.PROCESSING; + await this.withdrawalRepository.save(withdrawal); + + // Trigger the existing withdrawal processing flow + await this.savingsService['processWithdrawal'](withdrawal.id); + + // Fetch user for email + const user = await this.userRepository.findOne({ + where: { id: withdrawal.userId }, + }); + + // Send approval email (fire-and-forget) + if (user) { + try { + await this.mailService.sendWithdrawalApprovedEmail( + user.email, + user.name || 'User', + String(withdrawal.amount), + String(withdrawal.penalty), + String(withdrawal.netAmount), + ); + } catch (error) { + this.logger.warn( + `Failed to send approval email for withdrawal ${id}: ${(error as Error).message}`, + ); + } + } + + // Write audit log + await this.writeAuditLog({ + correlationId, + endpoint: `/admin/withdrawals/${id}/approve`, + method: 'POST', + action: 'APPROVE', + actor: actor.email, + resourceId: id, + resourceType: 'WITHDRAWAL_REQUEST', + statusCode: 200, + durationMs: Date.now() - startTime, + success: true, + errorMessage: null, + }); + + return withdrawal; + } catch (error) { + // Write error audit log + await this.writeAuditLog({ + correlationId, + endpoint: `/admin/withdrawals/${id}/approve`, + method: 'POST', + action: 'APPROVE', + actor: actor.email, + resourceId: id, + resourceType: 'WITHDRAWAL_REQUEST', + statusCode: + error instanceof NotFoundException + ? 404 + : error instanceof BadRequestException + ? 400 + : 500, + durationMs: Date.now() - startTime, + success: false, + errorMessage: (error as Error).message, + }); + + throw error; + } + } + + async reject( + id: string, + reason: string, + actor: User, + ): Promise { + const startTime = Date.now(); + const correlationId = `reject-${id}-${Date.now()}`; + + try { + const withdrawal = await this.withdrawalRepository.findOne({ + where: { id }, + relations: ['subscription', 'subscription.product'], + }); + + if (!withdrawal) { + throw new NotFoundException(`Withdrawal request ${id} not found`); + } + + if (withdrawal.status !== WithdrawalStatus.PENDING) { + throw new BadRequestException( + 'Withdrawal request is not in PENDING status', + ); + } + + // Update status to FAILED and persist reason + withdrawal.status = WithdrawalStatus.FAILED; + withdrawal.reason = reason; + await this.withdrawalRepository.save(withdrawal); + + // Fetch user for email + const user = await this.userRepository.findOne({ + where: { id: withdrawal.userId }, + }); + + // Send rejection email (fire-and-forget) + if (user) { + try { + await this.mailService.sendWithdrawalRejectedEmail( + user.email, + user.name || 'User', + reason, + ); + } catch (error) { + this.logger.warn( + `Failed to send rejection email for withdrawal ${id}: ${(error as Error).message}`, + ); + } + } + + // Write audit log + await this.writeAuditLog({ + correlationId, + endpoint: `/admin/withdrawals/${id}/reject`, + method: 'POST', + action: 'REJECT', + actor: actor.email, + resourceId: id, + resourceType: 'WITHDRAWAL_REQUEST', + statusCode: 200, + durationMs: Date.now() - startTime, + success: true, + errorMessage: null, + }); + + return withdrawal; + } catch (error) { + // Write error audit log + await this.writeAuditLog({ + correlationId, + endpoint: `/admin/withdrawals/${id}/reject`, + method: 'POST', + action: 'REJECT', + actor: actor.email, + resourceId: id, + resourceType: 'WITHDRAWAL_REQUEST', + statusCode: + error instanceof NotFoundException + ? 404 + : error instanceof BadRequestException + ? 400 + : 500, + durationMs: Date.now() - startTime, + success: false, + errorMessage: (error as Error).message, + }); + + throw error; + } + } + + async getStats(): Promise { + const allWithdrawals = await this.withdrawalRepository.find(); + + const total = allWithdrawals.length; + + // Count by status + const byStatus: Record = { + [WithdrawalStatus.PENDING]: 0, + [WithdrawalStatus.PROCESSING]: 0, + [WithdrawalStatus.COMPLETED]: 0, + [WithdrawalStatus.FAILED]: 0, + }; + + let completedCount = 0; + let totalProcessingTimeMs = 0; + + for (const withdrawal of allWithdrawals) { + byStatus[withdrawal.status]++; + + if ( + withdrawal.status === WithdrawalStatus.COMPLETED && + withdrawal.completedAt && + withdrawal.createdAt + ) { + completedCount++; + totalProcessingTimeMs += + new Date(withdrawal.completedAt).getTime() - + new Date(withdrawal.createdAt).getTime(); + } + } + + // Calculate approval rate: (COMPLETED count / total) * 100 + const approvalRate = + total > 0 ? (byStatus[WithdrawalStatus.COMPLETED] / total) * 100 : 0; + + // Calculate average processing time + const averageProcessingTimeMs = + completedCount > 0 ? totalProcessingTimeMs / completedCount : 0; + + return { + total, + byStatus, + approvalRate: Number(approvalRate.toFixed(2)), + averageProcessingTimeMs: Math.round(averageProcessingTimeMs), + }; + } + + private async writeAuditLog(data: { + correlationId: string; + endpoint: string; + method: string; + action: string; + actor: string; + resourceId: string; + resourceType: string; + statusCode: number; + durationMs: number; + success: boolean; + errorMessage: string | null; + }): Promise { + try { + const auditLog = this.auditLogRepository.create({ + ...data, + timestamp: new Date(), + }); + await this.auditLogRepository.save(auditLog); + } catch (error) { + this.logger.error( + `Failed to write audit log: ${(error as Error).message}`, + ); + } + } +} diff --git a/backend/src/modules/admin/admin.module.ts b/backend/src/modules/admin/admin.module.ts index 3a9e0b0eb..58511b349 100644 --- a/backend/src/modules/admin/admin.module.ts +++ b/backend/src/modules/admin/admin.module.ts @@ -8,12 +8,16 @@ import { AdminController } from './admin.controller'; import { AdminSavingsController } from './admin-savings.controller'; import { AdminWaitlistController } from './admin-waitlist.controller'; import { AdminUsersController } from './admin-users.controller'; +import { AdminWithdrawalController } from './admin-withdrawal.controller'; import { AdminUsersService } from './admin-users.service'; import { AdminSavingsService } from './admin-savings.service'; +import { AdminWithdrawalService } from './admin-withdrawal.service'; import { User } from '../user/entities/user.entity'; import { UserSubscription } from '../savings/entities/user-subscription.entity'; import { SavingsProduct } from '../savings/entities/savings-product.entity'; import { LedgerTransaction } from '../blockchain/entities/transaction.entity'; +import { WithdrawalRequest } from '../savings/entities/withdrawal-request.entity'; +import { AuditLog } from '../../common/entities/audit-log.entity'; @Module({ imports: [ @@ -22,6 +26,8 @@ import { LedgerTransaction } from '../blockchain/entities/transaction.entity'; UserSubscription, SavingsProduct, LedgerTransaction, + WithdrawalRequest, + AuditLog, ]), UserModule, SavingsModule, @@ -33,7 +39,8 @@ import { LedgerTransaction } from '../blockchain/entities/transaction.entity'; AdminSavingsController, AdminWaitlistController, AdminUsersController, + AdminWithdrawalController, ], - providers: [AdminUsersService, AdminSavingsService], + providers: [AdminUsersService, AdminSavingsService, AdminWithdrawalService], }) export class AdminModule {} diff --git a/backend/src/modules/admin/dto/reject-withdrawal.dto.ts b/backend/src/modules/admin/dto/reject-withdrawal.dto.ts new file mode 100644 index 000000000..aa6bee9b6 --- /dev/null +++ b/backend/src/modules/admin/dto/reject-withdrawal.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class RejectWithdrawalDto { + @ApiProperty({ + description: 'Reason for rejecting the withdrawal request', + example: 'Insufficient documentation provided', + }) + @IsString() + @IsNotEmpty() + reason: string; +} diff --git a/backend/src/modules/admin/dto/withdrawal-stats.dto.ts b/backend/src/modules/admin/dto/withdrawal-stats.dto.ts new file mode 100644 index 000000000..baa5d00a4 --- /dev/null +++ b/backend/src/modules/admin/dto/withdrawal-stats.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { WithdrawalStatus } from '../../savings/entities/withdrawal-request.entity'; + +export interface WithdrawalStatsDto { + total: number; + byStatus: Record; + approvalRate: number; // percentage 0–100 + averageProcessingTimeMs: number; +} + +export class WithdrawalStatsResponseDto implements WithdrawalStatsDto { + @ApiProperty({ description: 'Total number of withdrawal requests' }) + total: number; + + @ApiProperty({ + description: 'Count of withdrawal requests by status', + example: { + PENDING: 5, + PROCESSING: 2, + COMPLETED: 10, + FAILED: 3, + }, + }) + byStatus: Record; + + @ApiProperty({ + description: 'Approval rate as a percentage (0-100)', + example: 76.92, + }) + approvalRate: number; + + @ApiProperty({ + description: 'Average processing time in milliseconds for completed requests', + example: 3600000, + }) + averageProcessingTimeMs: number; +} diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 865a0a018..e8295c477 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -179,6 +179,58 @@ export class MailService { } } + async sendWithdrawalApprovedEmail( + userEmail: string, + name: string, + amount: string, + penalty: string, + netAmount: string, + ): Promise { + try { + await this.mailerService.sendMail({ + to: userEmail, + subject: 'Withdrawal Request Approved', + template: './withdrawal-approved', + context: { + name: name || 'User', + amount, + penalty, + netAmount, + }, + }); + this.logger.log(`Withdrawal approved email sent to ${userEmail}`); + } catch (error) { + this.logger.error( + `Failed to send withdrawal approved email to ${userEmail}`, + error, + ); + } + } + + async sendWithdrawalRejectedEmail( + userEmail: string, + name: string, + reason: string, + ): Promise { + try { + await this.mailerService.sendMail({ + to: userEmail, + subject: 'Withdrawal Request Rejected', + template: './withdrawal-rejected', + context: { + name: name || 'User', + reason, + }, + }); + this.logger.log(`Withdrawal rejected email sent to ${userEmail}`); + } catch (error) { + this.logger.error( + `Failed to send withdrawal rejected email to ${userEmail}`, + error, + ); + } + } + async sendRawMail(to: string, subject: string, text: string): Promise { try { await this.mailerService.sendMail({ to, subject, text });