From 950b7e4268de72c238257a6159b9d74a3aea2620 Mon Sep 17 00:00:00 2001 From: Amir Rajabi Date: Wed, 28 Oct 2026 17:53:38 +0330 Subject: [PATCH 1/2] Add bank account approval workflow for admin review --- .../api/core/inout/BankAccountApproval.kt | 49 +++++ .../api/core/inout/GatewayUpdateCommand.kt | 8 +- .../nilin/opex/api/core/spi/ProfileProxy.kt | 15 ++ .../controller/BankAccountAdminController.kt | 168 ++++++++++++++++++ .../opex/controller/BankAccountController.kt | 46 +++++ .../api/ports/proxy/impl/ProfileProxyImpl.kt | 58 ++++++ .../kotlin/co/nilin/opex/common/OpexError.kt | 30 ++++ docker-compose.yml | 1 + .../controller/BankAccountAdminController.kt | 45 +++++ .../app/controller/BankAccountController.kt | 14 ++ .../app/dto/UpdateBankAccountApprovalBody.kt | 10 ++ .../BankAccountApprovalRequestManagement.kt | 85 +++++++++ .../app/service/BankAccountManagement.kt | 150 ++++++++++++---- .../profile/app/service/ProfileManagement.kt | 81 ++++++--- .../src/main/resources/application.yml | 1 + .../BankAccountApprovalAdminResponse.kt | 23 +++ .../profile/BankAccountApprovalRequest.kt | 13 ++ .../BankAccountApprovalRequestFilter.kt | 11 ++ .../BankAccountApprovalRequestStatus.kt | 5 + .../BankAccountApprovalUserResponse.kt | 10 ++ .../BankAccountApprovalRequestPersister.kt | 14 ++ .../profile/core/spi/BankAccountPersister.kt | 4 +- .../profile/core/utils/InquiryErrorHandler.kt | 7 +- .../ports/inquiry/imp/InquiryProxyImp.kt | 33 +++- .../BankAccountApprovalRequestRepository.kt | 138 ++++++++++++++ ...BankAccountApprovalRequestManagementImp.kt | 87 +++++++++ .../postgres/imp/BankAccountManagementImp.kt | 17 +- .../model/base/BankAccountApprovalRequest.kt | 14 ++ .../entity/BankAccountApprovalRequestModel.kt | 10 ++ .../V10__bank_account_approval_request.sql | 19 ++ 30 files changed, 1092 insertions(+), 74 deletions(-) create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/BankAccountApproval.kt create mode 100644 api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/BankAccountAdminController.kt create mode 100644 profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/BankAccountAdminController.kt create mode 100644 profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/UpdateBankAccountApprovalBody.kt create mode 100644 profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/BankAccountApprovalRequestManagement.kt create mode 100644 profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalAdminResponse.kt create mode 100644 profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalRequest.kt create mode 100644 profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalRequestFilter.kt create mode 100644 profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalRequestStatus.kt create mode 100644 profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalUserResponse.kt create mode 100644 profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/BankAccountApprovalRequestPersister.kt create mode 100644 profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/BankAccountApprovalRequestRepository.kt create mode 100644 profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/BankAccountApprovalRequestManagementImp.kt create mode 100644 profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/BankAccountApprovalRequest.kt create mode 100644 profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/BankAccountApprovalRequestModel.kt create mode 100644 profile/profile-ports/profile-postgres/src/main/resources/db/migration/V10__bank_account_approval_request.sql diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/BankAccountApproval.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/BankAccountApproval.kt new file mode 100644 index 000000000..b799e988d --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/BankAccountApproval.kt @@ -0,0 +1,49 @@ +package co.nilin.opex.api.core.inout + +import java.time.LocalDateTime + +enum class BankAccountApprovalRequestStatus { + PENDING, APPROVED, REJECTED +} + +data class BankAccountApprovalRequestFilter( + val userId: String? = null, + val status: BankAccountApprovalRequestStatus? = null, + val createDateFrom: Long? = null, + val createDateTo: Long? = null, + val limit: Int = 10, + val offset: Int = 0, + val ascendingByTime: Boolean = false, +) + +data class BankAccountApprovalAdminResponse( + var id: Long, + var bankAccountId: Long, + var userId: String, + var status: BankAccountApprovalRequestStatus, + var createDate: LocalDateTime, + var updateDate: LocalDateTime? = null, + var updater: String? = null, + var description: String? = null, + var name: String? = null, + var cardNumber: String? = null, + var iban: String? = null, + var accountNumber: String? = null, + var bank: String? = null, + var bankAccountStatus: BankAccountStatus? = null, + var firstName: String? = null, + var lastName: String? = null +) + +data class BankAccountApprovalUserResponse( + var bankAccountId: Long, + var status: BankAccountApprovalRequestStatus, + var createDate: LocalDateTime, + var description: String? = null +) + +data class UpdateBankAccountApprovalBody( + val id: Long, + val description: String? = null, + val status: BankAccountApprovalRequestStatus +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayUpdateCommand.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayUpdateCommand.kt index c73735de0..bba4a646a 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayUpdateCommand.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/GatewayUpdateCommand.kt @@ -48,7 +48,7 @@ data class OffChainGatewayUpdateCommand( override var withdrawMax: BigDecimal? = BigDecimal.ZERO, override var displayOrder: Int? = null, - ) : CurrencyGatewayCommand( + ) : CurrencyGatewayUpdateCommand( currencySymbol, gatewayUuid, isDepositActive, @@ -60,8 +60,6 @@ data class OffChainGatewayUpdateCommand( depositMax, withdrawMin, withdrawMax, - null, - null, displayOrder ) @@ -85,7 +83,7 @@ data class OnChainGatewayUpdateCommand( override var withdrawMin: BigDecimal? = BigDecimal.ZERO, override var withdrawMax: BigDecimal? = BigDecimal.ZERO, override var displayOrder: Int? = null, -) : CurrencyGatewayCommand( +) : CurrencyGatewayUpdateCommand( currencySymbol, gatewayUuid, isDepositActive, @@ -97,8 +95,6 @@ data class OnChainGatewayUpdateCommand( depositMax, withdrawMin, withdrawMax, - null, - null, displayOrder ) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ProfileProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ProfileProxy.kt index c3bbba4a9..4b880c4b1 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ProfileProxy.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ProfileProxy.kt @@ -47,4 +47,19 @@ interface ProfileProxy { suspend fun addBankAccount(token: String, request: AddBankAccountRequest): BankAccountResponse suspend fun getBankAccounts(token: String): List suspend fun deleteBankAccount(token: String, id: Long) + + suspend fun getUserBankAccountApprovalRequest(token: String, bankAccountId: Long): BankAccountApprovalUserResponse + + //Bank Account approval requests + suspend fun getBankAccountApprovalRequests( + token: String, + request: BankAccountApprovalRequestFilter + ): List + + suspend fun getBankAccountApprovalRequest(token: String, requestId: Long): BankAccountApprovalAdminResponse + + suspend fun updateBankAccountApprovalRequest( + token: String, + request: UpdateBankAccountApprovalBody + ): BankAccountApprovalAdminResponse } \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/BankAccountAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/BankAccountAdminController.kt new file mode 100644 index 000000000..274a4c8f8 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/BankAccountAdminController.kt @@ -0,0 +1,168 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.BankAccountApprovalAdminResponse +import co.nilin.opex.api.core.inout.BankAccountApprovalRequestFilter +import co.nilin.opex.api.core.inout.UpdateBankAccountApprovalBody +import co.nilin.opex.api.core.spi.ProfileProxy +import co.nilin.opex.api.ports.opex.util.jwtAuthentication +import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.MediaType +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/opex/v1/admin/bank-account") +@Tag( + name = "Bank Account Admin", + description = "Admin review of bank accounts whose identity inquiry could not be completed." +) +class BankAccountAdminController( + private val profileProxy: ProfileProxy +) { + + @PostMapping("/approval-requests", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Get bank account approval requests", + description = """POST /opex/v1/admin/bank-account/approval-requests. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Behavior: Lists bank account review requests, newest first unless `ascendingByTime` is true. Requests exist only while admin approval is enabled: with automatic verification off every account creates one, and with it on only an inquiry the provider could not answer (timeout, provider down) does. A provider answer that rejects the user's data rejects the account instead of creating a request. Each row carries the account under review and the owner's name, so no second call is needed. +Filters: `userId`, `status`, `createDateFrom` and `createDateTo` (epoch millis), `limit`, `offset`, `ascendingByTime`. Every filter is optional. +Allowed values: +- status: PENDING, APPROVED, REJECTED. +- bankAccountStatus: WAITING, VERIFIED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = BankAccountApprovalAdminResponse::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) + suspend fun getApprovalRequests( + @RequestBody request: BankAccountApprovalRequestFilter, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): List { + return profileProxy.getBankAccountApprovalRequests( + securityContext.jwtAuthentication().tokenValue(), + request + ) + } + + @GetMapping("/approval-request/{id}", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Get bank account approval request", + description = """GET /opex/v1/admin/bank-account/approval-request/{id}. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Behavior: Returns one review request with the account under review and the owner's first and last name. +Allowed values: +- status: PENDING, APPROVED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = BankAccountApprovalAdminResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ), + ApiResponse(responseCode = "404", description = "No request with this id.", content = [Content()]) + ] + ) + suspend fun getApprovalRequest( + @Parameter(name = "id", description = "Approval request id.", required = true) + @PathVariable("id") id: Long, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): BankAccountApprovalAdminResponse { + return profileProxy.getBankAccountApprovalRequest(securityContext.jwtAuthentication().tokenValue(), id) + } + + @PutMapping("/approval-request", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Update bank account approval request status", + description = """PUT /opex/v1/admin/bank-account/approval-request. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Validation: `id` and `status` are required. `status` must be APPROVED or REJECTED, and only a PENDING request can be decided on. +Behavior: Closes the review and moves the bank account with it: APPROVED verifies the account, REJECTED rejects it. The acting admin is recorded. Only the status changes; the account data itself is never edited here. +Allowed values: +- status: APPROVED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = BankAccountApprovalAdminResponse::class) + )] + ), + ApiResponse( + responseCode = "400", + description = "The request is not pending, or the status is not APPROVED or REJECTED, or the same card or iban was verified for another user in the meantime.", + content = [Content()] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ), + ApiResponse(responseCode = "404", description = "No request with this id.", content = [Content()]) + ] + ) + suspend fun updateRequestStatus( + @RequestBody request: UpdateBankAccountApprovalBody, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): BankAccountApprovalAdminResponse { + return profileProxy.updateBankAccountApprovalRequest( + securityContext.jwtAuthentication().tokenValue(), + request + ) + } +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/BankAccountController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/BankAccountController.kt index 0d9b8140d..6ecdf2f12 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/BankAccountController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/BankAccountController.kt @@ -1,6 +1,7 @@ package co.nilin.opex.api.ports.opex.controller import co.nilin.opex.api.core.inout.AddBankAccountRequest +import co.nilin.opex.api.core.inout.BankAccountApprovalUserResponse import co.nilin.opex.api.core.inout.BankAccountResponse import co.nilin.opex.api.core.spi.ProfileProxy import co.nilin.opex.api.ports.opex.util.jwtAuthentication @@ -31,6 +32,7 @@ class BankAccountController( Security: Bearer user-token required. Requires authenticated user JWT. Validation: Exactly one of `cardNumber` or `iban` must be provided. Providing both or neither is invalid. +Behavior: How the account is verified depends on the deployment. With automatic verification on, the card/iban identity inquiry decides: a match returns VERIFIED, and an answer that rejects the user's data fails the request. With automatic verification off, the account is stored as WAITING for an admin. An inquiry the provider could not answer (timeout, provider down) returns WAITING when admin review is enabled, and fails the request otherwise. Follow a WAITING account with GET /opex/v1/bank-account/{id}/approval-request. Allowed values: - status: WAITING, VERIFIED, REJECTED.""", security = [SecurityRequirement(name = "bearerAuth")], @@ -88,4 +90,48 @@ Allowed values: ) { profileProxy.deleteBankAccount(securityContext.jwtAuthentication().tokenValue(), id) } + + @GetMapping("/{id}/approval-request") + @Operation( + summary = "Get the review of a bank account", + description = """GET /opex/v1/bank-account/{id}/approval-request. +Security: Bearer user-token required. Requires authenticated user JWT. +Behavior: Returns the admin review of the caller's own bank account, including the admin's note once a decision is made. A bank account only has one when its identity inquiry could not be completed. Reading another user's bank account is forbidden. +Allowed values: +- status: PENDING, APPROVED, REJECTED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = BankAccountApprovalUserResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. The bank account belongs to another user. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "404", + description = "The bank account does not exist or has no review.", + content = [Content()] + ) + ] + ) + suspend fun getApprovalRequest( + @Parameter(name = "id", description = "Bank account id.", required = true) + @PathVariable("id") id: Long, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): BankAccountApprovalUserResponse { + return profileProxy.getUserBankAccountApprovalRequest(securityContext.jwtAuthentication().tokenValue(), id) + } } diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ProfileProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ProfileProxyImpl.kt index 3788ef6d9..7aeeab864 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ProfileProxyImpl.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ProfileProxyImpl.kt @@ -224,6 +224,64 @@ class ProfileProxyImpl(@Qualifier("generalWebClient") private val webClient: Web .awaitBodilessEntity() } + override suspend fun getUserBankAccountApprovalRequest( + token: String, + bankAccountId: Long + ): BankAccountApprovalUserResponse { + return webClient.get() + .uri("$baseUrl/bank-account/$bankAccountId/approval-request") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get bank account approval request of $bankAccountId") } + } + + override suspend fun getBankAccountApprovalRequests( + token: String, + request: BankAccountApprovalRequestFilter + ): List { + return webClient.post() + .uri("$baseUrl/admin/bank-account/approval-requests") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono>() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get bank account approval requests") } + } + + override suspend fun getBankAccountApprovalRequest( + token: String, + requestId: Long + ): BankAccountApprovalAdminResponse { + return webClient.get() + .uri("$baseUrl/admin/bank-account/approval-request/$requestId") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get bank account approval request $requestId") } + } + + override suspend fun updateBankAccountApprovalRequest( + token: String, + request: UpdateBankAccountApprovalBody + ): BankAccountApprovalAdminResponse { + return webClient.put() + .uri("$baseUrl/admin/bank-account/approval-request") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to update bank account approval request ${request.id}") } + } + override suspend fun getProfile(token: String): Profile { return webClient.get() .uri("$baseUrl/personal-data") diff --git a/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt b/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt index 2ed67cebe..c32fbb809 100644 --- a/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt +++ b/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt @@ -218,6 +218,36 @@ enum class OpexError(val code: Int, val message: String?, val status: HttpStatus "Registration number and national id belong to different companies", HttpStatus.CONFLICT ), + BankAccountApprovalRequestNotFound( + 13053, + "Bank account approval request not found", + HttpStatus.NOT_FOUND + ), + InvalidBankAccountApprovalRequestStatus( + 13054, + "Invalid bank account approval request status", + HttpStatus.BAD_REQUEST + ), + BankAccountApprovalRequestAlreadyExists( + 13055, + "Bank account approval request already exists", + HttpStatus.BAD_REQUEST + ), + BankAccountVerificationNotConfigured( + 13056, + "Bank account verification is not configured", + HttpStatus.SERVICE_UNAVAILABLE + ), + BankAccountInquiryNotApplicable( + 13057, + "Bank account cannot be verified for this profile", + HttpStatus.BAD_REQUEST + ), + InquiryServiceUnavailable( + 13058, + "Identity inquiry service is unavailable", + HttpStatus.SERVICE_UNAVAILABLE + ), //Storage 14000 BucketNotFound(14001, "Bucket not found", HttpStatus.NOT_FOUND), diff --git a/docker-compose.yml b/docker-compose.yml index a796f3bb5..1d1360cc5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -617,6 +617,7 @@ services: - TOKEN_ISSUER_URL=${KC_ISSUER_URL} - MOBILE_IDENTITY_INQUIRY=${MOBILE_IDENTITY_INQUIRY} - PERSONAL_IDENTITY_INQUIRY=${PERSONAL_IDENTITY_INQUIRY} + - BANK_ACCOUNT_INQUIRY=${BANK_ACCOUNT_INQUIRY} - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/BankAccountAdminController.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/BankAccountAdminController.kt new file mode 100644 index 000000000..2d12878ba --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/BankAccountAdminController.kt @@ -0,0 +1,45 @@ +package co.nilin.opex.profile.app.controller + +import co.nilin.opex.profile.app.dto.UpdateBankAccountApprovalBody +import co.nilin.opex.profile.app.service.BankAccountApprovalRequestManagement +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalAdminResponse +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalRequestFilter +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/admin/bank-account") +class BankAccountAdminController( + val bankAccountApprovalRequestManagement: BankAccountApprovalRequestManagement +) { + + @PostMapping("/approval-requests") + suspend fun getApprovalRequests( + @RequestBody request: BankAccountApprovalRequestFilter + ): List { + return bankAccountApprovalRequestManagement.getApprovalRequests(request) + } + + @GetMapping("/approval-request/{id}") + suspend fun getApprovalRequest(@PathVariable("id") id: Long): BankAccountApprovalAdminResponse { + return bankAccountApprovalRequestManagement.getApprovalRequestById(id) + } + + @PutMapping("/approval-request") + suspend fun updateRequestStatus( + @RequestBody changeRequestStatusBody: UpdateBankAccountApprovalBody, + @CurrentSecurityContext securityContext: SecurityContext + ): BankAccountApprovalAdminResponse { + return bankAccountApprovalRequestManagement.changeRequestStatus( + changeRequestStatusBody, + securityContext.authentication.name + ) + } +} diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/BankAccountController.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/BankAccountController.kt index 08fd7fbff..fa4406e25 100644 --- a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/BankAccountController.kt +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/BankAccountController.kt @@ -3,7 +3,9 @@ package co.nilin.opex.profile.app.controller import co.nilin.opex.common.OpexError import co.nilin.opex.profile.app.dto.AddBankAccountRequest import co.nilin.opex.profile.app.dto.BankAccountResponse +import co.nilin.opex.profile.app.service.BankAccountApprovalRequestManagement import co.nilin.opex.profile.app.service.BankAccountManagement +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalUserResponse import co.nilin.opex.profile.ports.postgres.utils.RegexPatterns import org.springframework.security.core.annotation.CurrentSecurityContext import org.springframework.security.core.context.SecurityContext @@ -13,6 +15,7 @@ import org.springframework.web.bind.annotation.* @RequestMapping("/bank-account") class BankAccountController( val bankAccountManagement: BankAccountManagement, + val bankAccountApprovalRequestManagement: BankAccountApprovalRequestManagement, ) { @@ -38,6 +41,17 @@ class BankAccountController( bankAccountManagement.deleteBankAccount(id, securityContext.authentication.name) } + @GetMapping("/{id}/approval-request") + suspend fun getApprovalRequest( + @PathVariable("id") id: Long, + @CurrentSecurityContext securityContext: SecurityContext + ): BankAccountApprovalUserResponse { + return bankAccountApprovalRequestManagement.getApprovalRequestByBankAccountId( + id, + securityContext.authentication.name + ) + } + @GetMapping("/ownership") suspend fun isBankAccountOwnedByUser( @RequestParam cardNumber: String?, diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/UpdateBankAccountApprovalBody.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/UpdateBankAccountApprovalBody.kt new file mode 100644 index 000000000..0fa9474d6 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/UpdateBankAccountApprovalBody.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.app.dto + +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalRequestStatus + +data class UpdateBankAccountApprovalBody( + val id: Long, + val description: String?, + /** APPROVED or REJECTED. */ + val status: BankAccountApprovalRequestStatus +) diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/BankAccountApprovalRequestManagement.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/BankAccountApprovalRequestManagement.kt new file mode 100644 index 000000000..83c3e665c --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/BankAccountApprovalRequestManagement.kt @@ -0,0 +1,85 @@ +package co.nilin.opex.profile.app.service + +import co.nilin.opex.common.OpexError +import co.nilin.opex.profile.app.dto.UpdateBankAccountApprovalBody +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalAdminResponse +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalRequestFilter +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalRequestStatus +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalUserResponse +import co.nilin.opex.profile.core.data.profile.BankAccountStatus +import co.nilin.opex.profile.core.spi.BankAccountApprovalRequestPersister +import co.nilin.opex.profile.core.spi.BankAccountPersister +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class BankAccountApprovalRequestManagement( + private val bankAccountApprovalRequestPersister: BankAccountApprovalRequestPersister, + private val bankAccountPersister: BankAccountPersister, +) { + + suspend fun getApprovalRequests( + request: BankAccountApprovalRequestFilter + ): List { + return bankAccountApprovalRequestPersister.getRequests(request) + } + + suspend fun getApprovalRequestById(id: Long): BankAccountApprovalAdminResponse { + return bankAccountApprovalRequestPersister.getRequestById(id) + } + + suspend fun getApprovalRequestByBankAccountId( + bankAccountId: Long, + userId: String + ): BankAccountApprovalUserResponse { + // Reading the account first keeps one user from reading another user's review + val bankAccount = bankAccountPersister.findById(bankAccountId) + if (bankAccount.uuid != userId) throw OpexError.Forbidden.exception() + return bankAccountApprovalRequestPersister.getRequestByBankAccountId(bankAccountId) + } + + /** + * Closes a review and moves the bank account with it. Approving verifies the account, rejecting + * rejects it. The account's own data is never touched here. + */ + suspend fun changeRequestStatus( + body: UpdateBankAccountApprovalBody, + adminId: String + ): BankAccountApprovalAdminResponse { + val approvalRequest = bankAccountApprovalRequestPersister.getRequestById(body.id) + + if (approvalRequest.status != BankAccountApprovalRequestStatus.PENDING || + body.status == BankAccountApprovalRequestStatus.PENDING + ) { + throw OpexError.InvalidBankAccountApprovalRequestStatus.exception() + } + + val bankAccount = bankAccountPersister.findById(approvalRequest.bankAccountId) + + if (body.status == BankAccountApprovalRequestStatus.APPROVED) { + assertNotVerifiedForAnotherUser(bankAccount.uuid, bankAccount.cardNumber, bankAccount.iban) + bankAccount.status = BankAccountStatus.VERIFIED + } else { + bankAccount.status = BankAccountStatus.REJECTED + } + bankAccount.updateDate = LocalDateTime.now() + bankAccountPersister.update(bankAccount) + + approvalRequest.status = body.status + approvalRequest.updateDate = LocalDateTime.now() + approvalRequest.updater = adminId + approvalRequest.description = body.description + + return bankAccountApprovalRequestPersister.update(approvalRequest) + } + + private suspend fun assertNotVerifiedForAnotherUser(uuid: String, cardNumber: String?, iban: String?) { + val existing = bankAccountPersister.findAll(cardNumber, iban) + if (existing.any { it.uuid != uuid && it.status == BankAccountStatus.VERIFIED }) { + throw when { + !cardNumber.isNullOrBlank() -> OpexError.CardOwnershipMismatch.exception() + else -> OpexError.IbanOwnershipMismatch.exception() + } + } + } +} diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/BankAccountManagement.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/BankAccountManagement.kt index 7fdd8f377..3680e0fb3 100644 --- a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/BankAccountManagement.kt +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/BankAccountManagement.kt @@ -6,8 +6,11 @@ import co.nilin.opex.profile.app.dto.BankAccountResponse import co.nilin.opex.profile.app.utils.toBankAccountResponse import co.nilin.opex.profile.core.data.inquiry.IbanInfo import co.nilin.opex.profile.core.data.profile.BankAccount +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalRequest +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalRequestStatus import co.nilin.opex.profile.core.data.profile.BankAccountStatus import co.nilin.opex.profile.core.data.profile.NationalityType +import co.nilin.opex.profile.core.spi.BankAccountApprovalRequestPersister import co.nilin.opex.profile.core.spi.BankAccountPersister import co.nilin.opex.profile.core.spi.InquiryProxy import co.nilin.opex.profile.core.spi.ProfilePersister @@ -15,7 +18,9 @@ import co.nilin.opex.profile.core.utils.handleCardIbanInfoError import co.nilin.opex.profile.core.utils.handleCardOwnershipError import co.nilin.opex.profile.core.utils.handleIbanInfoError import co.nilin.opex.profile.core.utils.handleIbanOwnershipError -import kotlinx.coroutines.reactive.awaitFirstOrNull +import co.nilin.opex.utility.error.data.OpexException +import kotlinx.coroutines.CancellationException +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import java.time.LocalDateTime @@ -23,55 +28,68 @@ import java.time.LocalDateTime @Component class BankAccountManagement( private val bankAccountPersister: BankAccountPersister, + private val bankAccountApprovalRequestPersister: BankAccountApprovalRequestPersister, private val inquiryProxy: InquiryProxy, private val profilePersister: ProfilePersister, @Value("\${app.admin-approval.bank-account}") private var isAdminApprovalRequired: Boolean, + @Value("\${app.inquiry.bank-account}") + private var isAutomaticVerificationEnabled: Boolean, ) { + private val logger = LoggerFactory.getLogger(BankAccountManagement::class.java) + suspend fun addBankAccount(uuid: String, request: AddBankAccountRequest): BankAccountResponse { validateBankAccountOwnership(uuid, request.cardNumber, request.iban) val profile = profilePersister.getProfile(uuid) - var cardNumber: String? = request.cardNumber - var iban: String? = request.iban - var accountNumber: String? = null - var bank: String? = null - var status: BankAccountStatus? = null - - if (!isAdminApprovalRequired && profile.nationality!! == (NationalityType.IRANIAN)) { - if (!request.cardNumber.isNullOrBlank()) { - verifyCardOwnership(request.cardNumber, profile.identifier!!, profile.birthDate!!) - val ibanInfo = getIbanInfoByCardNumber(request.cardNumber) + if (request.cardNumber.isNullOrBlank() && request.iban.isNullOrBlank()) + throw OpexError.InvalidRequestBody.exception("Either Card Number or IBAN must be provided") - iban = ibanInfo.iban - accountNumber = ibanInfo.depositNumber - bank = ibanInfo.bank + val identifier = profile.identifier + val birthDate = profile.birthDate + val canInquire = + profile.nationality == NationalityType.IRANIAN && !identifier.isNullOrBlank() && birthDate != null + val verified = when { + !isAutomaticVerificationEnabled -> + VerificationResult.NEEDS_REVIEW - } else if (!request.iban.isNullOrBlank()) { + canInquire -> verifyWithInquiry(request, identifier!!, birthDate!!) - verifyIbanOwnership(request.iban, profile.identifier!!, profile.birthDate!!) - val ibanInfo = getIbanInfoByIban(request.iban) - accountNumber = ibanInfo.depositNumber - bank = ibanInfo.bank + isAdminApprovalRequired -> + VerificationResult.NEEDS_REVIEW - } else throw OpexError.InvalidRequestBody.exception("Either Card Number or IBAN must be provided") - status = BankAccountStatus.VERIFIED + else -> throw OpexError.BankAccountInquiryNotApplicable.exception() } + val bankAccount = BankAccount( uuid = uuid, name = request.name, - cardNumber = cardNumber, - iban = iban, - accountNumber = accountNumber, - bank = bank, - status = status ?: BankAccountStatus.WAITING, + cardNumber = request.cardNumber, + iban = verified.iban ?: request.iban, + accountNumber = verified.accountNumber, + bank = verified.bank, + status = if (verified.needsReview) BankAccountStatus.WAITING else BankAccountStatus.VERIFIED, createDate = LocalDateTime.now(), creator = "system" ) - return bankAccountPersister.save(bankAccount).toBankAccountResponse() + val saved = bankAccountPersister.save(bankAccount) + if (verified.needsReview) saveApprovalRequest(saved) + return saved.toBankAccountResponse() + } + + private suspend fun saveApprovalRequest(bankAccount: BankAccount) { + bankAccountApprovalRequestPersister.save( + BankAccountApprovalRequest( + bankAccountId = bankAccount.id + ?: throw OpexError.BadRequest.exception("Saved bank account has no id"), + userId = bankAccount.uuid, + status = BankAccountApprovalRequestStatus.PENDING, + createDate = LocalDateTime.now() + ) + ) } suspend fun getBankAccounts(uuid: String): List { @@ -93,13 +111,78 @@ class BankAccountManagement( ?: accounts.firstOrNull()?.uuid } + /** Either the data the inquiries resolved, or the fact that an admin has to take over. */ + private data class VerificationResult( + val iban: String? = null, + val accountNumber: String? = null, + val bank: String? = null, + val needsReview: Boolean = false + ) { + companion object { + val NEEDS_REVIEW = VerificationResult(needsReview = true) + } + } + + private suspend fun verifyWithInquiry( + request: AddBankAccountRequest, + nationalCode: String, + birthDate: LocalDateTime + ): VerificationResult { + return try { + if (!request.cardNumber.isNullOrBlank()) { + verifyCardOwnership(request.cardNumber, nationalCode, birthDate) + val ibanInfo = getIbanInfoByCardNumber(request.cardNumber) + VerificationResult(ibanInfo.iban, ibanInfo.depositNumber, ibanInfo.bank) + } else { + verifyIbanOwnership(request.iban!!, nationalCode, birthDate) + val ibanInfo = getIbanInfoByIban(request.iban) + VerificationResult(request.iban, ibanInfo.depositNumber, ibanInfo.bank) + } + } catch (e: InquiryUnavailableException) { + // The only reviewable outcome. Everything the provider actually answered — a mismatch, + // an invalid card, a code we do not recognise — leaves this method as an OpexException + // and stops the user right here + if (!isAdminApprovalRequired) throw OpexError.InquiryServiceUnavailable.exception() + logger.warn("Inquiry service unreachable, sending the account for admin review") + VerificationResult.NEEDS_REVIEW + } + } + + /** + * The inquiry service never answered. Kept separate from every [OpexException] an inquiry can + * raise, because those all describe the user's data and must not become a review request. + */ + private class InquiryUnavailableException(cause: Throwable) : RuntimeException(cause) + + /** + * A read timeout, a refused connection, a 5xx or an unreadable response never reaches + * [VerifyOwnershipResponse.isError]. That is the one case where nobody has judged the user's + * data yet, so it is the one case an admin can pick up. + */ + private suspend fun callInquiry(block: suspend () -> T): T { + return try { + block() + } catch (e: OpexException) { + // The proxy reports a server-side failure of the provider as unavailability. Anything + // else it raises is an answer about the user's data and has to stop the user + if (e.error == OpexError.InquiryServiceUnavailable) throw InquiryUnavailableException(e) + throw e + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.warn("Inquiry provider did not answer: ${e.javaClass.simpleName} ${e.message}") + throw InquiryUnavailableException(e) + } + } + private suspend fun verifyCardOwnership( cardNumber: String, nationalCode: String, birthDate: LocalDateTime ) { - val ownershipResponse = + val ownershipResponse = callInquiry { inquiryProxy.verifyCardOwnership(cardNumber, nationalCode, birthDate) + } if (ownershipResponse.isError()) handleCardOwnershipError(ownershipResponse.code) if (ownershipResponse.matched == false) throw OpexError.CardOwnershipMismatch.exception() } @@ -109,20 +192,25 @@ class BankAccountManagement( nationalCode: String, birthDate: LocalDateTime ) { - val ownershipResponse = + val ownershipResponse = callInquiry { inquiryProxy.verifyIbanOwnership(iban, nationalCode, birthDate) + } if (ownershipResponse.isError()) handleIbanOwnershipError(ownershipResponse.code) if (ownershipResponse.matched == false) throw OpexError.IbanOwnershipMismatch.exception() } private suspend fun getIbanInfoByCardNumber(cardNumber: String): IbanInfo { - val ibanInfoResponse = inquiryProxy.getIbanInfoByCardNumber(cardNumber) + val ibanInfoResponse = callInquiry { + inquiryProxy.getIbanInfoByCardNumber(cardNumber) + } if (ibanInfoResponse.isError()) handleCardIbanInfoError(ibanInfoResponse.code) return ibanInfoResponse.ibanInfo ?: throw OpexError.CardIbanInfoInquiryError.exception() } private suspend fun getIbanInfoByIban(iban: String): IbanInfo { - val ibanInfoResponse = inquiryProxy.getIbanInfoByIban(iban) + val ibanInfoResponse = callInquiry { + inquiryProxy.getIbanInfoByIban(iban) + } if (ibanInfoResponse.isError()) handleIbanInfoError(ibanInfoResponse.code) return ibanInfoResponse.ibanInfo ?: throw OpexError.IbanInfoInquiryError.exception() } diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/ProfileManagement.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/ProfileManagement.kt index 1b70c249d..f9afd589c 100644 --- a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/ProfileManagement.kt +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/ProfileManagement.kt @@ -13,7 +13,7 @@ import co.nilin.opex.profile.core.data.profile.* import co.nilin.opex.profile.core.spi.* import co.nilin.opex.profile.core.utils.handleComparativeError import co.nilin.opex.profile.core.utils.handleShahkarError -import co.nilin.opex.utility.error.data.OpexException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.reactive.awaitFirst import org.slf4j.LoggerFactory @@ -133,18 +133,34 @@ class ProfileManagement( val isIranian = request.nationality == NationalityType.IRANIAN - val shahkarResponse = if (isIranian && mobileIdentityEnabled) { - inquiryProxy.getShahkarInquiryResult(request.identifier, profile.mobile!!) - } else null + val shahkarRequested = isIranian && mobileIdentityEnabled + val comparativeRequested = isIranian && personalIdentityEnabled - val comparativeResponse = if (isIranian && personalIdentityEnabled) { - inquiryProxy.getComparativeInquiryResult( - request.identifier, - request.birthDate, - request.firstName, - request.lastName - ) - } else null + // A provider that could not answer — timeout, refused connection, 5xx, 429 — is a problem + // on their side, not a verdict on the user, so it comes back null instead of throwing and + // falls back to an admin below. Anything the provider does answer is judged as before. + val shahkarResponse = if (shahkarRequested) + callInquiry { inquiryProxy.getShahkarInquiryResult(request.identifier, profile.mobile!!) } + else null + + val comparativeResponse = if (comparativeRequested) + callInquiry { + inquiryProxy.getComparativeInquiryResult( + request.identifier, + request.birthDate, + request.firstName, + request.lastName + ) + } + else null + + val inquiryUnavailable = (shahkarRequested && shahkarResponse == null) || + (comparativeRequested && comparativeResponse == null) + + // With no admin to fall back on there is nothing that could verify this profile, and + // auto-approving an unverified profile is not an option. Fail before anything is written. + if (inquiryUnavailable && !isAdminApprovalRequired) + throw OpexError.InquiryServiceUnavailable.exception() val isMobileIdentityMatch = shahkarResponse?.let { !it.isError() && it.matched == true @@ -167,28 +183,39 @@ class ProfileManagement( ) ) - try { - validateInquiryResponses(shahkarResponse, comparativeResponse) - } catch (e: OpexException) { - if (!isAdminApprovalRequired || - (e.error != OpexError.ShahkarInquiryError && e.error != OpexError.ComparativeInquiryError) - ) throw e - } + // Anything the provider answered against the user stops here, including an error code we + // have not mapped yet. An admin is only a fallback for a provider that never answered. + validateInquiryResponses(shahkarResponse, comparativeResponse) - val shahkarRequested = isIranian && mobileIdentityEnabled - val comparativeRequested = isIranian && personalIdentityEnabled - - val shahkarOk = !shahkarRequested || (shahkarResponse != null && !shahkarResponse.isError()) - val comparativeOk = !comparativeRequested || (comparativeResponse != null && !comparativeResponse.isError()) + // A response that survived validation passed, so only an unavailable inquiry is left + val shahkarOk = !shahkarRequested || shahkarResponse != null + val comparativeOk = !comparativeRequested || comparativeResponse != null val allRequestedInquiriesOk = shahkarOk && comparativeOk val anyInquiryRequested = shahkarRequested || comparativeRequested return when { !isAdminApprovalRequired -> approveProfileAutomatically(userId, completedProfile) - isAdminApprovalRequired && !anyInquiryRequested -> requestAdminApproval(userId) - isAdminApprovalRequired && allRequestedInquiriesOk -> approveProfileAutomatically(userId, completedProfile) - else -> requestAdminApproval(userId) // At least one active inquiry has failed. + !anyInquiryRequested -> requestAdminApproval(userId) + allRequestedInquiriesOk -> approveProfileAutomatically(userId, completedProfile) + else -> requestAdminApproval(userId) // An active inquiry could not be reached. + } + } + + /** + * Runs an inquiry and returns null when the provider never answered. Only the call itself is + * wrapped: the proxy raises [OpexError.InquiryServiceUnavailable] for a server-side failure and + * transport errors for a timeout, while an answer about the user comes back as a response and + * is judged by [validateInquiryResponses]. + */ + private suspend fun callInquiry(block: suspend () -> T): T? { + return try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.warn("Inquiry provider did not answer: ${e.javaClass.simpleName} ${e.message}") + null } } diff --git a/profile/profile-app/src/main/resources/application.yml b/profile/profile-app/src/main/resources/application.yml index 6607b6a6e..fbdaae9b4 100644 --- a/profile/profile-app/src/main/resources/application.yml +++ b/profile/profile-app/src/main/resources/application.yml @@ -63,6 +63,7 @@ app: inquiry: mobile-identity: ${MOBILE_IDENTITY_INQUIRY} personal-identity: ${PERSONAL_IDENTITY_INQUIRY} + bank-account: ${BANK_ACCOUNT_INQUIRY:true} url: ${JIBIT_URL} api-key: ${JIBIT_API_KEY} secret-key: ${JIBIT_SECRET_KEY} diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalAdminResponse.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalAdminResponse.kt new file mode 100644 index 000000000..a8741b7e7 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalAdminResponse.kt @@ -0,0 +1,23 @@ +package co.nilin.opex.profile.core.data.profile + +import java.time.LocalDateTime + +data class BankAccountApprovalAdminResponse( + var id: Long, + var bankAccountId: Long, + var userId: String, + var status: BankAccountApprovalRequestStatus, + var createDate: LocalDateTime, + var updateDate: LocalDateTime? = null, + var updater: String? = null, + var description: String? = null, + // The account under review, so the admin does not need a second call to see what to decide on + var name: String? = null, + var cardNumber: String? = null, + var iban: String? = null, + var accountNumber: String? = null, + var bank: String? = null, + var bankAccountStatus: BankAccountStatus? = null, + var firstName: String? = null, + var lastName: String? = null +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalRequest.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalRequest.kt new file mode 100644 index 000000000..408de4a9a --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalRequest.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.profile.core.data.profile + +import java.time.LocalDateTime + +data class BankAccountApprovalRequest( + var bankAccountId: Long, + var userId: String, + var status: BankAccountApprovalRequestStatus? = BankAccountApprovalRequestStatus.PENDING, + var createDate: LocalDateTime? = null, + var updateDate: LocalDateTime? = null, + var updater: String? = null, + var description: String? = null +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalRequestFilter.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalRequestFilter.kt new file mode 100644 index 000000000..d4320f4de --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalRequestFilter.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.profile.core.data.profile + +data class BankAccountApprovalRequestFilter( + val userId: String? = null, + val status: BankAccountApprovalRequestStatus? = null, + val createDateFrom: Long? = null, + val createDateTo: Long? = null, + val limit: Int = 10, + val offset: Int = 0, + val ascendingByTime: Boolean = false, +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalRequestStatus.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalRequestStatus.kt new file mode 100644 index 000000000..d19fd7742 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalRequestStatus.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.profile.core.data.profile + +enum class BankAccountApprovalRequestStatus { + PENDING, APPROVED, REJECTED +} diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalUserResponse.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalUserResponse.kt new file mode 100644 index 000000000..a094b3e92 --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/data/profile/BankAccountApprovalUserResponse.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.core.data.profile + +import java.time.LocalDateTime + +data class BankAccountApprovalUserResponse( + var bankAccountId: Long, + var status: BankAccountApprovalRequestStatus, + var createDate: LocalDateTime, + var description: String? = null +) diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/BankAccountApprovalRequestPersister.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/BankAccountApprovalRequestPersister.kt new file mode 100644 index 000000000..83602397f --- /dev/null +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/BankAccountApprovalRequestPersister.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.profile.core.spi + +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalAdminResponse +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalRequest +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalRequestFilter +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalUserResponse + +interface BankAccountApprovalRequestPersister { + suspend fun save(request: BankAccountApprovalRequest): BankAccountApprovalRequest + suspend fun getRequests(request: BankAccountApprovalRequestFilter): List + suspend fun getRequestById(id: Long): BankAccountApprovalAdminResponse + suspend fun getRequestByBankAccountId(bankAccountId: Long): BankAccountApprovalUserResponse + suspend fun update(request: BankAccountApprovalAdminResponse): BankAccountApprovalAdminResponse +} diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/BankAccountPersister.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/BankAccountPersister.kt index 1b9c00b56..1bd3bcd1b 100644 --- a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/BankAccountPersister.kt +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/BankAccountPersister.kt @@ -9,4 +9,6 @@ interface BankAccountPersister { suspend fun findAll(uuid: String, cardNumber: String?, iban: String?): List suspend fun findByAccountNumber(accountNumber: String): List suspend fun delete(id: Long, uuid: String) -} \ No newline at end of file + suspend fun findById(id: Long): BankAccount + suspend fun update(bankAccount: BankAccount): BankAccount +} diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/InquiryErrorHandler.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/InquiryErrorHandler.kt index 590ea6eea..4ef37eb42 100644 --- a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/InquiryErrorHandler.kt +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/utils/InquiryErrorHandler.kt @@ -1,10 +1,15 @@ package co.nilin.opex.profile.core.utils import co.nilin.opex.common.OpexError +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger("co.nilin.opex.profile.core.utils.InquiryErrorHandler") private fun handleError(code: String?, mapping: Map, default: OpexError) { if (code.isNullOrEmpty()) return - throw mapping[code]?.exception() ?: default.exception() + mapping[code]?.let { throw it.exception() } + logger.error("Unmapped inquiry error code '$code', falling back to $default") + throw default.exception("Unmapped inquiry error code: $code") } object ErrorMappings { diff --git a/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/imp/InquiryProxyImp.kt b/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/imp/InquiryProxyImp.kt index 59e9bef1d..3a60b3d5a 100644 --- a/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/imp/InquiryProxyImp.kt +++ b/profile/profile-ports/profile-inquiry-proxy/src/main/kotlin/co/nilin/opex/profile/ports/inquiry/imp/InquiryProxyImp.kt @@ -1,5 +1,6 @@ package co.nilin.opex.profile.ports.inquiry.imp +import co.nilin.opex.common.OpexError import co.nilin.opex.profile.core.data.inquiry.ComparativeResponse import co.nilin.opex.profile.core.data.inquiry.IbanInfoResponse import co.nilin.opex.profile.core.data.inquiry.ShahkarResponse @@ -8,12 +9,16 @@ import co.nilin.opex.profile.core.spi.InquiryProxy import co.nilin.opex.profile.ports.inquiry.utils.TokenProvider import co.nilin.opex.profile.ports.inquiry.utils.toPersianDateFormatted import kotlinx.coroutines.reactor.awaitSingle +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.ClientResponse import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono import java.time.LocalDateTime @Component @@ -21,9 +26,25 @@ class InquiryProxyImp( @Qualifier("plainWebClient") private val webClient: WebClient, private val tokenProvider: TokenProvider ) : InquiryProxy { + private val logger = LoggerFactory.getLogger(InquiryProxyImp::class.java) + @Value("\${app.inquiry.url}") private lateinit var baseUrl: String + private fun ClientResponse.bodyOrUnavailable(type: Class): Mono { + val status = statusCode() + val serverSide = status.is5xxServerError || + status.value() == HttpStatus.TOO_MANY_REQUESTS.value() || + status.value() == HttpStatus.REQUEST_TIMEOUT.value() + + if (serverSide) { + logger.warn("Inquiry provider answered ${status.value()}") + return releaseBody().then(Mono.error(OpexError.InquiryServiceUnavailable.exception())) + } + return bodyToMono(type) + .switchIfEmpty(Mono.error(OpexError.InquiryServiceUnavailable.exception())) + } + override suspend fun getShahkarInquiryResult(identifier: String, mobile: String): ShahkarResponse { return webClient.get().uri("$baseUrl/v1/services/matching") { it.queryParam("nationalCode", identifier) @@ -31,7 +52,7 @@ class InquiryProxyImp( it.build() }.header(HttpHeaders.AUTHORIZATION, "Bearer ${tokenProvider.getToken()}").accept(MediaType.APPLICATION_JSON) .exchangeToMono { clientResponse -> - clientResponse.bodyToMono(ShahkarResponse::class.java) + clientResponse.bodyOrUnavailable(ShahkarResponse::class.java) }.awaitSingle() } @@ -48,7 +69,7 @@ class InquiryProxyImp( it.build() }.header(HttpHeaders.AUTHORIZATION, "Bearer ${tokenProvider.getToken()}").accept(MediaType.APPLICATION_JSON) .exchangeToMono { clientResponse -> - clientResponse.bodyToMono(ComparativeResponse::class.java) + clientResponse.bodyOrUnavailable(ComparativeResponse::class.java) }.awaitSingle() } @@ -64,7 +85,7 @@ class InquiryProxyImp( it.build() }.header(HttpHeaders.AUTHORIZATION, "Bearer ${tokenProvider.getToken()}").accept(MediaType.APPLICATION_JSON) .exchangeToMono { clientResponse -> - clientResponse.bodyToMono(VerifyOwnershipResponse::class.java) + clientResponse.bodyOrUnavailable(VerifyOwnershipResponse::class.java) }.awaitSingle() } @@ -81,7 +102,7 @@ class InquiryProxyImp( it.build() }.header(HttpHeaders.AUTHORIZATION, "Bearer ${tokenProvider.getToken()}").accept(MediaType.APPLICATION_JSON) .exchangeToMono { clientResponse -> - clientResponse.bodyToMono(VerifyOwnershipResponse::class.java) + clientResponse.bodyOrUnavailable(VerifyOwnershipResponse::class.java) }.awaitSingle() } @@ -92,7 +113,7 @@ class InquiryProxyImp( it.build() }.header(HttpHeaders.AUTHORIZATION, "Bearer ${tokenProvider.getToken()}").accept(MediaType.APPLICATION_JSON) .exchangeToMono { clientResponse -> - clientResponse.bodyToMono(IbanInfoResponse::class.java) + clientResponse.bodyOrUnavailable(IbanInfoResponse::class.java) }.awaitSingle() } @@ -102,7 +123,7 @@ class InquiryProxyImp( it.build() }.header(HttpHeaders.AUTHORIZATION, "Bearer ${tokenProvider.getToken()}").accept(MediaType.APPLICATION_JSON) .exchangeToMono { clientResponse -> - clientResponse.bodyToMono(IbanInfoResponse::class.java) + clientResponse.bodyOrUnavailable(IbanInfoResponse::class.java) }.awaitSingle() } } diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/BankAccountApprovalRequestRepository.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/BankAccountApprovalRequestRepository.kt new file mode 100644 index 000000000..c6f41f090 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/BankAccountApprovalRequestRepository.kt @@ -0,0 +1,138 @@ +package co.nilin.opex.profile.ports.postgres.dao + +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalAdminResponse +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalRequestStatus +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalUserResponse +import co.nilin.opex.profile.ports.postgres.model.entity.BankAccountApprovalRequestModel +import kotlinx.coroutines.flow.Flow +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Mono +import java.time.LocalDateTime + +@Repository +interface BankAccountApprovalRequestRepository : + ReactiveCrudRepository { + + fun findByBankAccountIdAndStatus( + bankAccountId: Long, + status: BankAccountApprovalRequestStatus + ): Mono + + @Query( + """ + SELECT + r.id, + r.bank_account_id, + r.user_id, + r.status, + r.create_date, + r.update_date, + r.updater, + r.description, + b.name, + b.card_number, + b.iban, + b.account_number, + b.bank, + b.status AS bank_account_status, + u.first_name, + u.last_name + FROM bank_account_approval_request r + LEFT JOIN bank_account b ON b.id = r.bank_account_id + LEFT JOIN profile u ON u.user_id = r.user_id + WHERE (:userId IS NULL OR r.user_id = :userId) + AND (:status IS NULL OR r.status = :status) + AND (:createDateFrom IS NULL OR r.create_date >= :createDateFrom) + AND (:createDateTo IS NULL OR r.create_date <= :createDateTo) + ORDER BY r.create_date + LIMIT :limit OFFSET :offset +""" + ) + fun findByCriteriaAsc( + userId: String?, + status: BankAccountApprovalRequestStatus?, + createDateFrom: LocalDateTime?, + createDateTo: LocalDateTime?, + limit: Int, + offset: Int, + ): Flow + + @Query( + """ + SELECT + r.id, + r.bank_account_id, + r.user_id, + r.status, + r.create_date, + r.update_date, + r.updater, + r.description, + b.name, + b.card_number, + b.iban, + b.account_number, + b.bank, + b.status AS bank_account_status, + u.first_name, + u.last_name + FROM bank_account_approval_request r + LEFT JOIN bank_account b ON b.id = r.bank_account_id + LEFT JOIN profile u ON u.user_id = r.user_id + WHERE (:userId IS NULL OR r.user_id = :userId) + AND (:status IS NULL OR r.status = :status) + AND (:createDateFrom IS NULL OR r.create_date >= :createDateFrom) + AND (:createDateTo IS NULL OR r.create_date <= :createDateTo) + ORDER BY r.create_date desc + LIMIT :limit OFFSET :offset +""" + ) + fun findByCriteriaDesc( + userId: String?, + status: BankAccountApprovalRequestStatus?, + createDateFrom: LocalDateTime?, + createDateTo: LocalDateTime?, + limit: Int, + offset: Int, + ): Flow + + @Query( + """ + SELECT + r.id, + r.bank_account_id, + r.user_id, + r.status, + r.create_date, + r.update_date, + r.updater, + r.description, + b.name, + b.card_number, + b.iban, + b.account_number, + b.bank, + b.status AS bank_account_status, + u.first_name, + u.last_name + FROM bank_account_approval_request r + LEFT JOIN bank_account b ON b.id = r.bank_account_id + LEFT JOIN profile u ON u.user_id = r.user_id + WHERE r.id = :id +""" + ) + fun findAdminResponseById(id: Long): Mono + + @Query( + """ + select r.bank_account_id, r.status, r.create_date, r.description + from bank_account_approval_request r + where r.bank_account_id = :bankAccountId + order by r.create_date desc + limit 1 +""" + ) + fun findByBankAccountId(bankAccountId: Long): Mono +} diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/BankAccountApprovalRequestManagementImp.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/BankAccountApprovalRequestManagementImp.kt new file mode 100644 index 000000000..b4ca343ee --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/BankAccountApprovalRequestManagementImp.kt @@ -0,0 +1,87 @@ +package co.nilin.opex.profile.ports.postgres.imp + +import co.nilin.opex.common.OpexError +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalAdminResponse +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalRequest +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalRequestFilter +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalRequestStatus +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalUserResponse +import co.nilin.opex.profile.core.spi.BankAccountApprovalRequestPersister +import co.nilin.opex.profile.core.utils.convert +import co.nilin.opex.profile.ports.postgres.dao.BankAccountApprovalRequestRepository +import co.nilin.opex.profile.ports.postgres.model.entity.BankAccountApprovalRequestModel +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.reactive.awaitFirst +import kotlinx.coroutines.reactive.awaitFirstOrNull +import org.springframework.stereotype.Service +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +@Service +class BankAccountApprovalRequestManagementImp( + private val bankAccountApprovalRequestRepository: BankAccountApprovalRequestRepository, +) : BankAccountApprovalRequestPersister { + + override suspend fun save(request: BankAccountApprovalRequest): BankAccountApprovalRequest { + bankAccountApprovalRequestRepository.findByBankAccountIdAndStatus( + request.bankAccountId, + BankAccountApprovalRequestStatus.PENDING + ).awaitFirstOrNull()?.let { + throw OpexError.BankAccountApprovalRequestAlreadyExists.exception() + } ?: run { + val model: BankAccountApprovalRequestModel = + request.convert(BankAccountApprovalRequestModel::class.java) + return bankAccountApprovalRequestRepository.save(model).awaitFirst() + .convert(BankAccountApprovalRequest::class.java) + } + } + + override suspend fun getRequests( + request: BankAccountApprovalRequestFilter + ): List { + val createDateFrom = request.createDateFrom?.toLocalDateTime() + val createDateTo = request.createDateTo?.toLocalDateTime() + + val approvalRequests = + if (request.ascendingByTime) + bankAccountApprovalRequestRepository.findByCriteriaAsc( + request.userId, + request.status, + createDateFrom, + createDateTo, + request.limit, + request.offset + ) else + bankAccountApprovalRequestRepository.findByCriteriaDesc( + request.userId, + request.status, + createDateFrom, + createDateTo, + request.limit, + request.offset + ) + return approvalRequests.toList() + } + + override suspend fun getRequestById(id: Long): BankAccountApprovalAdminResponse { + return bankAccountApprovalRequestRepository.findAdminResponseById(id).awaitFirstOrNull() + ?: throw OpexError.BankAccountApprovalRequestNotFound.exception() + } + + override suspend fun getRequestByBankAccountId(bankAccountId: Long): BankAccountApprovalUserResponse { + return bankAccountApprovalRequestRepository.findByBankAccountId(bankAccountId).awaitFirstOrNull() + ?: throw OpexError.BankAccountApprovalRequestNotFound.exception() + } + + override suspend fun update(request: BankAccountApprovalAdminResponse): BankAccountApprovalAdminResponse { + val model: BankAccountApprovalRequestModel = + request.convert(BankAccountApprovalRequestModel::class.java) + bankAccountApprovalRequestRepository.save(model).awaitFirst() + + return request + } + + private fun Long.toLocalDateTime(): LocalDateTime = + LocalDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneId.systemDefault()) +} diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/BankAccountManagementImp.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/BankAccountManagementImp.kt index 6fae40ee8..b064b582c 100644 --- a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/BankAccountManagementImp.kt +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/BankAccountManagementImp.kt @@ -19,7 +19,7 @@ class BankAccountManagementImp( val savedBankAccount = bankAccountRepository.save(bankAccount.convert(BankAccountModel::class.java)).awaitFirstOrNull() ?: throw OpexError.BadRequest.exception("Failed to save bank account") - return savedBankAccount.convert(BankAccount::class.java) + return savedBankAccount.convert(BankAccount::class.java).apply { id = savedBankAccount.id } } override suspend fun findAll(uuid: String): List { @@ -54,4 +54,17 @@ class BankAccountManagementImp( bankAccountRepository.deleteById(id).awaitFirstOrNull() else throw OpexError.Forbidden.exception() } -} \ No newline at end of file + + override suspend fun findById(id: Long): BankAccount { + val model = bankAccountRepository.findById(id).awaitFirstOrNull() + ?: throw OpexError.BankAccountNotFound.exception() + return model.convert(BankAccount::class.java) + } + + override suspend fun update(bankAccount: BankAccount): BankAccount { + val id = bankAccount.id ?: throw OpexError.BadRequest.exception("Bank account id is required") + val saved = bankAccountRepository.save(bankAccount.convert(BankAccountModel::class.java)).awaitFirstOrNull() + ?: throw OpexError.BadRequest.exception("Failed to update bank account $id") + return saved.convert(BankAccount::class.java) + } +} diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/BankAccountApprovalRequest.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/BankAccountApprovalRequest.kt new file mode 100644 index 000000000..1395e5ee3 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/base/BankAccountApprovalRequest.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.profile.ports.postgres.model.base + +import co.nilin.opex.profile.core.data.profile.BankAccountApprovalRequestStatus +import java.time.LocalDateTime + +open class BankAccountApprovalRequest { + var bankAccountId: Long = 0 + lateinit var userId: String + var status: BankAccountApprovalRequestStatus? = BankAccountApprovalRequestStatus.PENDING + var createDate: LocalDateTime? = null + var updateDate: LocalDateTime? = null + var updater: String? = null + var description: String? = null +} diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/BankAccountApprovalRequestModel.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/BankAccountApprovalRequestModel.kt new file mode 100644 index 000000000..1ded39ad0 --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/model/entity/BankAccountApprovalRequestModel.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.profile.ports.postgres.model.entity + +import co.nilin.opex.profile.ports.postgres.model.base.BankAccountApprovalRequest +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table("bank_account_approval_request") +data class BankAccountApprovalRequestModel( + @Id var id: Long +) : BankAccountApprovalRequest() diff --git a/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V10__bank_account_approval_request.sql b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V10__bank_account_approval_request.sql new file mode 100644 index 000000000..b97e6d1bc --- /dev/null +++ b/profile/profile-ports/profile-postgres/src/main/resources/db/migration/V10__bank_account_approval_request.sql @@ -0,0 +1,19 @@ + +CREATE TABLE IF NOT EXISTS bank_account_approval_request +( + id SERIAL PRIMARY KEY, + bank_account_id BIGINT NOT NULL REFERENCES bank_account (id) ON DELETE CASCADE, + user_id VARCHAR(36) NOT NULL, + status VARCHAR(100) NOT NULL, + create_date TIMESTAMP, + update_date TIMESTAMP, + updater VARCHAR(100), + description VARCHAR(255), + UNIQUE (bank_account_id, status) +); + +CREATE INDEX IF NOT EXISTS idx_bank_account_approval_request_user_id + ON bank_account_approval_request (user_id); + +CREATE INDEX IF NOT EXISTS idx_bank_account_approval_request_status_create_date + ON bank_account_approval_request (status, create_date DESC); From 7010bfe2be44e7ba7d5bd863e75303f14bcfcb83 Mon Sep 17 00:00:00 2001 From: Amir Rajabi Date: Thu, 29 Oct 2026 14:05:29 +0330 Subject: [PATCH 2/2] feat(inquiry): add manual identity inquiry endpoints for admins - Add ProfileProxy methods and ProfileProxyImpl REST clients (shahkar, identity-similarity, card/iban ownership, card/iban info) that forward admin-token requests to the profile service via new /admin/inquiry/* endpoints. - Add Inquiry.kt DTOs in api-core for the new request/response payloads shared by the proxy layer. - Add InquiryAdminController in api-opex-rest exposing /opex/v1/admin/inquiry/* endpoints with Swagger docs covering auth, validation, and error semantics for each inquiry type. - Add InquiryAdminController, InquiryManagement and InquiryRequests DTOs in profile-app to validate requests, log the acting admin, and delegate to InquiryProxy for the actual provider calls. These endpoints let admins manually trigger identity-verification provider lookups (shahkar, name similarity, card/iban ownership and info) and see the provider's raw answer, independent of the automated profile verification flow. --- .../co/nilin/opex/api/core/inout/Inquiry.kt | 64 ++++ .../nilin/opex/api/core/spi/ProfileProxy.kt | 22 ++ .../opex/controller/InquiryAdminController.kt | 279 ++++++++++++++++++ .../api/ports/proxy/impl/ProfileProxyImpl.kt | 90 ++++++ .../app/controller/InquiryAdminController.kt | 74 +++++ .../opex/profile/app/dto/InquiryRequests.kt | 33 +++ .../profile/app/service/InquiryManagement.kt | 92 ++++++ 7 files changed, 654 insertions(+) create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Inquiry.kt create mode 100644 api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/InquiryAdminController.kt create mode 100644 profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/InquiryAdminController.kt create mode 100644 profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/InquiryRequests.kt create mode 100644 profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/InquiryManagement.kt diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Inquiry.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Inquiry.kt new file mode 100644 index 000000000..e42871c75 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/Inquiry.kt @@ -0,0 +1,64 @@ +package co.nilin.opex.api.core.inout + +data class ShahkarInquiryRequest( + val nationalCode: String, + val mobile: String +) + +data class IdentitySimilarityInquiryRequest( + val nationalCode: String, + val birthDate: Long, + val firstName: String, + val lastName: String +) + +data class CardOwnershipInquiryRequest( + val cardNumber: String, + val nationalCode: String, + val birthDate: Long +) + +data class IbanOwnershipInquiryRequest( + val iban: String, + val nationalCode: String, + val birthDate: Long +) + +data class CardInfoInquiryRequest( + val cardNumber: String +) + +data class IbanInfoInquiryRequest( + val iban: String +) + +data class ShahkarInquiryResponse( + val matched: Boolean? = null, + val code: String? = null, + val message: String? = null +) + +data class IdentitySimilarityInquiryResponse( + val firstNameSimilarityPercentage: Int? = null, + val lastNameSimilarityPercentage: Int? = null, + val code: String? = null, + val message: String? = null +) + +data class VerifyOwnershipInquiryResponse( + val matched: Boolean? = null, + val code: String? = null, + val message: String? = null +) + +data class IbanInfoInquiryResponse( + val ibanInfo: IbanInfoDetails? = null, + val code: String? = null, + val message: String? = null +) + +data class IbanInfoDetails( + val bank: String? = null, + val depositNumber: String? = null, + val iban: String? = null +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ProfileProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ProfileProxy.kt index 4b880c4b1..e296f5260 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ProfileProxy.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ProfileProxy.kt @@ -62,4 +62,26 @@ interface ProfileProxy { token: String, request: UpdateBankAccountApprovalBody ): BankAccountApprovalAdminResponse + + //Manual identity inquiries + suspend fun shahkarInquiry(token: String, request: ShahkarInquiryRequest): ShahkarInquiryResponse + + suspend fun identitySimilarityInquiry( + token: String, + request: IdentitySimilarityInquiryRequest + ): IdentitySimilarityInquiryResponse + + suspend fun cardOwnershipInquiry( + token: String, + request: CardOwnershipInquiryRequest + ): VerifyOwnershipInquiryResponse + + suspend fun ibanOwnershipInquiry( + token: String, + request: IbanOwnershipInquiryRequest + ): VerifyOwnershipInquiryResponse + + suspend fun cardInfoInquiry(token: String, request: CardInfoInquiryRequest): IbanInfoInquiryResponse + + suspend fun ibanInfoInquiry(token: String, request: IbanInfoInquiryRequest): IbanInfoInquiryResponse } \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/InquiryAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/InquiryAdminController.kt new file mode 100644 index 000000000..f816ea0ad --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/InquiryAdminController.kt @@ -0,0 +1,279 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.CardInfoInquiryRequest +import co.nilin.opex.api.core.inout.CardOwnershipInquiryRequest +import co.nilin.opex.api.core.inout.IbanInfoInquiryRequest +import co.nilin.opex.api.core.inout.IbanInfoInquiryResponse +import co.nilin.opex.api.core.inout.IbanOwnershipInquiryRequest +import co.nilin.opex.api.core.inout.IdentitySimilarityInquiryRequest +import co.nilin.opex.api.core.inout.IdentitySimilarityInquiryResponse +import co.nilin.opex.api.core.inout.ShahkarInquiryRequest +import co.nilin.opex.api.core.inout.ShahkarInquiryResponse +import co.nilin.opex.api.core.inout.VerifyOwnershipInquiryResponse +import co.nilin.opex.api.core.spi.ProfileProxy +import co.nilin.opex.api.ports.opex.util.jwtAuthentication +import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.MediaType +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +private const val SHARED_NOTES = """ +Behavior: Calls the identity inquiry provider directly and returns its answer untouched. `code` and `message` are the provider's own, so an error code the platform does not map yet can be read here. Nothing is validated for format and nothing is stored or decided: this does not verify a profile or a bank account, it only reports what the provider says. +Note: Every call is a paid inquiry at the provider and is logged with the acting admin. +Errors: 503 with code 13058 means the provider did not answer at all (timeout, provider down). Any other failure is reported in the `code` field of a 200 response.""" + +@RestController +@RequestMapping("/opex/v1/admin/inquiry") +@Tag( + name = "Inquiry Admin", + description = "Run an identity inquiry by hand and see the provider's raw answer." +) +class InquiryAdminController( + private val profileProxy: ProfileProxy +) { + + @PostMapping("/shahkar", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Run a shahkar inquiry", + description = """POST /opex/v1/admin/inquiry/shahkar. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Validation: `nationalCode` and `mobile` are required.$SHARED_NOTES +Answer: `matched` tells whether the national code and the mobile number belong to each other.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ShahkarInquiryResponse::class) + )] + ), + ApiResponse(responseCode = "400", description = "A required field is missing.", content = [Content()]), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ), + ApiResponse(responseCode = "503", description = "The provider did not answer.", content = [Content()]) + ] + ) + suspend fun shahkar( + @RequestBody request: ShahkarInquiryRequest, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): ShahkarInquiryResponse { + return profileProxy.shahkarInquiry(securityContext.jwtAuthentication().tokenValue(), request) + } + + @PostMapping("/identity-similarity", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Run an identity similarity inquiry", + description = """POST /opex/v1/admin/inquiry/identity-similarity. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Validation: `nationalCode`, `firstName` and `lastName` are required. `birthDate` is epoch millis.$SHARED_NOTES +Answer: `firstNameSimilarityPercentage` and `lastNameSimilarityPercentage` are 0 to 100. The profile flow requires at least 95 for both.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = IdentitySimilarityInquiryResponse::class) + )] + ), + ApiResponse(responseCode = "400", description = "A required field is missing.", content = [Content()]), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ), + ApiResponse(responseCode = "503", description = "The provider did not answer.", content = [Content()]) + ] + ) + suspend fun identitySimilarity( + @RequestBody request: IdentitySimilarityInquiryRequest, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): IdentitySimilarityInquiryResponse { + return profileProxy.identitySimilarityInquiry(securityContext.jwtAuthentication().tokenValue(), request) + } + + @PostMapping("/card-ownership", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Run a card ownership inquiry", + description = """POST /opex/v1/admin/inquiry/card-ownership. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Validation: `cardNumber` and `nationalCode` are required. `birthDate` is epoch millis.$SHARED_NOTES +Answer: `matched` tells whether the card belongs to the given national code and birth date.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = VerifyOwnershipInquiryResponse::class) + )] + ), + ApiResponse(responseCode = "400", description = "A required field is missing.", content = [Content()]), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ), + ApiResponse(responseCode = "503", description = "The provider did not answer.", content = [Content()]) + ] + ) + suspend fun cardOwnership( + @RequestBody request: CardOwnershipInquiryRequest, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): VerifyOwnershipInquiryResponse { + return profileProxy.cardOwnershipInquiry(securityContext.jwtAuthentication().tokenValue(), request) + } + + @PostMapping("/iban-ownership", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Run an iban ownership inquiry", + description = """POST /opex/v1/admin/inquiry/iban-ownership. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Validation: `iban` and `nationalCode` are required. `birthDate` is epoch millis.$SHARED_NOTES +Answer: `matched` tells whether the iban belongs to the given national code and birth date.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = VerifyOwnershipInquiryResponse::class) + )] + ), + ApiResponse(responseCode = "400", description = "A required field is missing.", content = [Content()]), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ), + ApiResponse(responseCode = "503", description = "The provider did not answer.", content = [Content()]) + ] + ) + suspend fun ibanOwnership( + @RequestBody request: IbanOwnershipInquiryRequest, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): VerifyOwnershipInquiryResponse { + return profileProxy.ibanOwnershipInquiry(securityContext.jwtAuthentication().tokenValue(), request) + } + + @PostMapping("/card-info", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Look up the iban of a card", + description = """POST /opex/v1/admin/inquiry/card-info. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Validation: `cardNumber` is required.$SHARED_NOTES +Answer: `ibanInfo` carries the bank, the deposit number and the iban behind the card. This is the inquiry that fills `accountNumber` and `bank` on a bank account.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = IbanInfoInquiryResponse::class) + )] + ), + ApiResponse(responseCode = "400", description = "A required field is missing.", content = [Content()]), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ), + ApiResponse(responseCode = "503", description = "The provider did not answer.", content = [Content()]) + ] + ) + suspend fun cardInfo( + @RequestBody request: CardInfoInquiryRequest, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): IbanInfoInquiryResponse { + return profileProxy.cardInfoInquiry(securityContext.jwtAuthentication().tokenValue(), request) + } + + @PostMapping("/iban-info", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Look up the details of an iban", + description = """POST /opex/v1/admin/inquiry/iban-info. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Validation: `iban` is required.$SHARED_NOTES +Answer: `ibanInfo` carries the bank and the deposit number behind the iban.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = IbanInfoInquiryResponse::class) + )] + ), + ApiResponse(responseCode = "400", description = "A required field is missing.", content = [Content()]), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ), + ApiResponse(responseCode = "503", description = "The provider did not answer.", content = [Content()]) + ] + ) + suspend fun ibanInfo( + @RequestBody request: IbanInfoInquiryRequest, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): IbanInfoInquiryResponse { + return profileProxy.ibanInfoInquiry(securityContext.jwtAuthentication().tokenValue(), request) + } +} diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ProfileProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ProfileProxyImpl.kt index 7aeeab864..92f606f7d 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ProfileProxyImpl.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/ProfileProxyImpl.kt @@ -282,6 +282,96 @@ class ProfileProxyImpl(@Qualifier("generalWebClient") private val webClient: Web .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to update bank account approval request ${request.id}") } } + override suspend fun shahkarInquiry( + token: String, + request: ShahkarInquiryRequest + ): ShahkarInquiryResponse { + return webClient.post() + .uri("$baseUrl/admin/inquiry/shahkar") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to run the shahkar inquiry") } + } + + override suspend fun identitySimilarityInquiry( + token: String, + request: IdentitySimilarityInquiryRequest + ): IdentitySimilarityInquiryResponse { + return webClient.post() + .uri("$baseUrl/admin/inquiry/identity-similarity") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to run the identity similarity inquiry") } + } + + override suspend fun cardOwnershipInquiry( + token: String, + request: CardOwnershipInquiryRequest + ): VerifyOwnershipInquiryResponse { + return webClient.post() + .uri("$baseUrl/admin/inquiry/card-ownership") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to run the card ownership inquiry") } + } + + override suspend fun ibanOwnershipInquiry( + token: String, + request: IbanOwnershipInquiryRequest + ): VerifyOwnershipInquiryResponse { + return webClient.post() + .uri("$baseUrl/admin/inquiry/iban-ownership") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to run the iban ownership inquiry") } + } + + override suspend fun cardInfoInquiry( + token: String, + request: CardInfoInquiryRequest + ): IbanInfoInquiryResponse { + return webClient.post() + .uri("$baseUrl/admin/inquiry/card-info") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to run the card info inquiry") } + } + + override suspend fun ibanInfoInquiry( + token: String, + request: IbanInfoInquiryRequest + ): IbanInfoInquiryResponse { + return webClient.post() + .uri("$baseUrl/admin/inquiry/iban-info") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to run the iban info inquiry") } + } + override suspend fun getProfile(token: String): Profile { return webClient.get() .uri("$baseUrl/personal-data") diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/InquiryAdminController.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/InquiryAdminController.kt new file mode 100644 index 000000000..a0cd874ce --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/controller/InquiryAdminController.kt @@ -0,0 +1,74 @@ +package co.nilin.opex.profile.app.controller + +import co.nilin.opex.profile.app.dto.CardInfoInquiryRequest +import co.nilin.opex.profile.app.dto.CardOwnershipInquiryRequest +import co.nilin.opex.profile.app.dto.IbanInfoInquiryRequest +import co.nilin.opex.profile.app.dto.IbanOwnershipInquiryRequest +import co.nilin.opex.profile.app.dto.IdentitySimilarityInquiryRequest +import co.nilin.opex.profile.app.dto.ShahkarInquiryRequest +import co.nilin.opex.profile.app.service.InquiryManagement +import co.nilin.opex.profile.core.data.inquiry.ComparativeResponse +import co.nilin.opex.profile.core.data.inquiry.IbanInfoResponse +import co.nilin.opex.profile.core.data.inquiry.ShahkarResponse +import co.nilin.opex.profile.core.data.inquiry.VerifyOwnershipResponse +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/admin/inquiry") +class InquiryAdminController( + val inquiryManagement: InquiryManagement +) { + + @PostMapping("/shahkar") + suspend fun shahkar( + @RequestBody request: ShahkarInquiryRequest, + @CurrentSecurityContext securityContext: SecurityContext + ): ShahkarResponse { + return inquiryManagement.shahkar(request, securityContext.authentication.name) + } + + @PostMapping("/identity-similarity") + suspend fun identitySimilarity( + @RequestBody request: IdentitySimilarityInquiryRequest, + @CurrentSecurityContext securityContext: SecurityContext + ): ComparativeResponse { + return inquiryManagement.identitySimilarity(request, securityContext.authentication.name) + } + + @PostMapping("/card-ownership") + suspend fun cardOwnership( + @RequestBody request: CardOwnershipInquiryRequest, + @CurrentSecurityContext securityContext: SecurityContext + ): VerifyOwnershipResponse { + return inquiryManagement.cardOwnership(request, securityContext.authentication.name) + } + + @PostMapping("/iban-ownership") + suspend fun ibanOwnership( + @RequestBody request: IbanOwnershipInquiryRequest, + @CurrentSecurityContext securityContext: SecurityContext + ): VerifyOwnershipResponse { + return inquiryManagement.ibanOwnership(request, securityContext.authentication.name) + } + + @PostMapping("/card-info") + suspend fun cardInfo( + @RequestBody request: CardInfoInquiryRequest, + @CurrentSecurityContext securityContext: SecurityContext + ): IbanInfoResponse { + return inquiryManagement.cardInfo(request, securityContext.authentication.name) + } + + @PostMapping("/iban-info") + suspend fun ibanInfo( + @RequestBody request: IbanInfoInquiryRequest, + @CurrentSecurityContext securityContext: SecurityContext + ): IbanInfoResponse { + return inquiryManagement.ibanInfo(request, securityContext.authentication.name) + } +} diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/InquiryRequests.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/InquiryRequests.kt new file mode 100644 index 000000000..2bf1f4153 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/dto/InquiryRequests.kt @@ -0,0 +1,33 @@ +package co.nilin.opex.profile.app.dto + +data class ShahkarInquiryRequest( + val nationalCode: String, + val mobile: String +) + +data class IdentitySimilarityInquiryRequest( + val nationalCode: String, + val birthDate: Long, + val firstName: String, + val lastName: String +) + +data class CardOwnershipInquiryRequest( + val cardNumber: String, + val nationalCode: String, + val birthDate: Long +) + +data class IbanOwnershipInquiryRequest( + val iban: String, + val nationalCode: String, + val birthDate: Long +) + +data class CardInfoInquiryRequest( + val cardNumber: String +) + +data class IbanInfoInquiryRequest( + val iban: String +) diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/InquiryManagement.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/InquiryManagement.kt new file mode 100644 index 000000000..1c6a61cdc --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/InquiryManagement.kt @@ -0,0 +1,92 @@ +package co.nilin.opex.profile.app.service + +import co.nilin.opex.common.OpexError +import co.nilin.opex.profile.app.dto.CardInfoInquiryRequest +import co.nilin.opex.profile.app.dto.CardOwnershipInquiryRequest +import co.nilin.opex.profile.app.dto.IbanInfoInquiryRequest +import co.nilin.opex.profile.app.dto.IbanOwnershipInquiryRequest +import co.nilin.opex.profile.app.dto.IdentitySimilarityInquiryRequest +import co.nilin.opex.profile.app.dto.ShahkarInquiryRequest +import co.nilin.opex.profile.core.data.inquiry.ComparativeResponse +import co.nilin.opex.profile.core.data.inquiry.IbanInfoResponse +import co.nilin.opex.profile.core.data.inquiry.ShahkarResponse +import co.nilin.opex.profile.core.data.inquiry.VerifyOwnershipResponse +import co.nilin.opex.profile.core.spi.InquiryProxy +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class InquiryManagement( + private val inquiryProxy: InquiryProxy +) { + + private val logger = LoggerFactory.getLogger(InquiryManagement::class.java) + + suspend fun shahkar(request: ShahkarInquiryRequest, adminId: String): ShahkarResponse { + validate("nationalCode" to request.nationalCode, "mobile" to request.mobile) + logger.info("Admin $adminId is running a shahkar inquiry") + return inquiryProxy.getShahkarInquiryResult(request.nationalCode, request.mobile) + } + + suspend fun identitySimilarity( + request: IdentitySimilarityInquiryRequest, + adminId: String + ): ComparativeResponse { + validate( + "nationalCode" to request.nationalCode, + "firstName" to request.firstName, + "lastName" to request.lastName + ) + logger.info("Admin $adminId is running an identity similarity inquiry") + return inquiryProxy.getComparativeInquiryResult( + request.nationalCode, + request.birthDate, + request.firstName, + request.lastName + ) + } + + suspend fun cardOwnership(request: CardOwnershipInquiryRequest, adminId: String): VerifyOwnershipResponse { + validate("cardNumber" to request.cardNumber, "nationalCode" to request.nationalCode) + logger.info("Admin $adminId is running a card ownership inquiry") + return inquiryProxy.verifyCardOwnership( + request.cardNumber, + request.nationalCode, + request.birthDate.toLocalDateTime() + ) + } + + suspend fun ibanOwnership(request: IbanOwnershipInquiryRequest, adminId: String): VerifyOwnershipResponse { + validate("iban" to request.iban, "nationalCode" to request.nationalCode) + logger.info("Admin $adminId is running an iban ownership inquiry") + return inquiryProxy.verifyIbanOwnership( + request.iban, + request.nationalCode, + request.birthDate.toLocalDateTime() + ) + } + + suspend fun cardInfo(request: CardInfoInquiryRequest, adminId: String): IbanInfoResponse { + validate("cardNumber" to request.cardNumber) + logger.info("Admin $adminId is running a card to iban inquiry") + return inquiryProxy.getIbanInfoByCardNumber(request.cardNumber) + } + + suspend fun ibanInfo(request: IbanInfoInquiryRequest, adminId: String): IbanInfoResponse { + validate("iban" to request.iban) + logger.info("Admin $adminId is running an iban info inquiry") + return inquiryProxy.getIbanInfoByIban(request.iban) + } + + private fun validate(vararg fields: Pair) { + fields.firstOrNull { it.second.isBlank() }?.let { + throw OpexError.InvalidRequestBody.exception("${it.first} is required") + } + } + + private fun Long.toLocalDateTime(): LocalDateTime = + LocalDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneId.systemDefault()) +}