Implemented handling enter TAN requests

This commit is contained in:
dankito 2021-04-18 22:22:12 +02:00
parent 356b0f7823
commit 1216267fec
11 changed files with 267 additions and 70 deletions

View File

@ -1,16 +1,25 @@
package net.dankito.banking.fints.rest package net.dankito.banking.fints.rest
import net.dankito.banking.fints.response.client.AddAccountResponse
import net.dankito.banking.fints.response.client.GetTransactionsResponse
import net.dankito.banking.fints.rest.model.dto.request.AddAccountRequestDto import net.dankito.banking.fints.rest.model.dto.request.AddAccountRequestDto
import net.dankito.banking.fints.rest.mapper.DtoMapper import net.dankito.banking.fints.rest.mapper.DtoMapper
import net.dankito.banking.fints.rest.model.dto.request.GetAccountsTransactionsRequestDto import net.dankito.banking.fints.rest.model.dto.request.GetAccountsTransactionsRequestDto
import net.dankito.banking.fints.rest.model.dto.request.TanResponseDto
import net.dankito.banking.fints.rest.model.dto.response.AddAccountResponseDto
import net.dankito.banking.fints.rest.model.dto.response.GetAccountsTransactionsResponseDto import net.dankito.banking.fints.rest.model.dto.response.GetAccountsTransactionsResponseDto
import net.dankito.banking.fints.rest.model.dto.response.RestResponse
import net.dankito.banking.fints.rest.service.fints4kService import net.dankito.banking.fints.rest.service.fints4kService
import net.dankito.banking.fints.rest.service.model.GetAccountsTransactionsResponse
import org.slf4j.LoggerFactory
import javax.inject.Inject import javax.inject.Inject
import javax.ws.rs.* import javax.ws.rs.*
import javax.ws.rs.core.MediaType import javax.ws.rs.core.MediaType
@Path("/fints/v1") @Path("/fints/v1")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
class fints4kResource { class fints4kResource {
@Inject @Inject
@ -20,31 +29,44 @@ class fints4kResource {
@POST @POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Path("addaccount") @Path("addaccount")
fun addAccount( fun addAccount(request: AddAccountRequestDto): RestResponse<AddAccountResponseDto> {
request: AddAccountRequestDto, val response = service.getAddAccountResponse(request)
@DefaultValue("false") @QueryParam("showRawResponse") showRawResponse: Boolean
): Any {
val clientResponse = service.getAddAccountResponse(request)
if (showRawResponse) { return mapper.createRestResponse(response) { successResponse -> mapper.map(successResponse) }
return clientResponse
}
return mapper.map(clientResponse)
} }
@POST @POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Path("transactions") @Path("transactions")
fun getAccountTransactions(request: GetAccountsTransactionsRequestDto): GetAccountsTransactionsResponseDto { fun getAccountTransactions(request: GetAccountsTransactionsRequestDto): GetAccountsTransactionsResponseDto {
val accountsTransactions = service.getAccountTransactions(request) val response = service.getAccountTransactions(request)
return mapper.mapTransactions(accountsTransactions) return mapper.map(response)
}
@POST
@Path("tanresponse")
fun tanResponse(dto: TanResponseDto): RestResponse<Any> {
val response = service.handleTanResponse(dto)
// couldn't make it that compiler access ResponseHolder<*> for mapper.createRestResponse(), resulted in very cryptic "{"arity":0}" response -> handle it manually
response.response?.let { successResponse ->
return RestResponse.success(mapSuccessResponse(successResponse))
}
// all other cases map here, the responseMapper callback has no function
return mapper.createRestResponse(response) { it!! }
}
private fun mapSuccessResponse(successResponse: Any): Any {
return when (successResponse) {
is AddAccountResponse -> mapper.map(successResponse)
is GetAccountsTransactionsResponse -> mapper.map(successResponse)
is GetTransactionsResponse -> mapper.mapTransactions(successResponse)
else -> successResponse // add others / new ones here
}
} }
} }

View File

@ -4,13 +4,28 @@ import net.dankito.banking.fints.model.*
import net.dankito.banking.fints.response.client.AddAccountResponse import net.dankito.banking.fints.response.client.AddAccountResponse
import net.dankito.banking.fints.response.client.FinTsClientResponse import net.dankito.banking.fints.response.client.FinTsClientResponse
import net.dankito.banking.fints.response.client.GetTransactionsResponse import net.dankito.banking.fints.response.client.GetTransactionsResponse
import net.dankito.banking.fints.rest.model.ResponseHolder
import net.dankito.banking.fints.rest.model.dto.response.* import net.dankito.banking.fints.rest.model.dto.response.*
import net.dankito.banking.fints.rest.service.model.GetAccountsTransactionsResponse
import java.math.BigDecimal import java.math.BigDecimal
import javax.ws.rs.InternalServerErrorException import javax.ws.rs.InternalServerErrorException
open class DtoMapper { open class DtoMapper {
fun <DomainType, DtoType> createRestResponse(responseHolder: ResponseHolder<DomainType>, responseMapper: (DomainType) -> DtoType): RestResponse<DtoType> {
responseHolder.response?.let { response ->
return RestResponse.success(responseMapper(response))
}
responseHolder.enterTanRequest?.let { enterTanRequest ->
return RestResponse.requiresTan(enterTanRequest)
}
return RestResponse.error(responseHolder.error ?: "Unknown error")
}
open fun map(response: AddAccountResponse): AddAccountResponseDto { open fun map(response: AddAccountResponse): AddAccountResponseDto {
return AddAccountResponseDto( return AddAccountResponseDto(
response.successful, response.successful,
@ -38,20 +53,21 @@ open class DtoMapper {
} }
open fun mapTransactions(accountsTransactions: List<GetTransactionsResponse>?): GetAccountsTransactionsResponseDto { open fun map(response: GetAccountsTransactionsResponse?): GetAccountsTransactionsResponseDto {
// TODO: is this still the case?
// TODO: if a TAN is required then accountsTransactions contains null value(s) (but why?) -> application crashes // TODO: if a TAN is required then accountsTransactions contains null value(s) (but why?) -> application crashes
if (accountsTransactions == null) { if (response == null) {
throw InternalServerErrorException("Could not fetch account transactions. Either TAN hasn't been entered or developers made a mistake.") throw InternalServerErrorException("Could not fetch account transactions. Either TAN hasn't been entered or developers made a mistake.")
} }
return GetAccountsTransactionsResponseDto(accountsTransactions.mapNotNull { map(it) }) // TODO: is this correct removing accounts from result for which no transactions have been retrieved? return GetAccountsTransactionsResponseDto(
// TODO: is this correct removing accounts from result for which no transactions have been retrieved?
response.transactionsPerAccount.filter { it.response?.retrievedData?.isNotEmpty() != false }
.map { createRestResponse(it) { transactionsResponse -> mapTransactions(transactionsResponse) } }
)
} }
open fun map(accountTransactions: GetTransactionsResponse): GetAccountTransactionsResponseDto? { open fun mapTransactions(accountTransactions: GetTransactionsResponse): GetAccountTransactionsResponseDto {
if (accountTransactions.retrievedData.isEmpty()) {
return null
}
val retrievedData = accountTransactions.retrievedData.first() val retrievedData = accountTransactions.retrievedData.first()
val balance = mapNullable(retrievedData.balance) val balance = mapNullable(retrievedData.balance)
val bookedTransactions = map(retrievedData.bookedTransactions) val bookedTransactions = map(retrievedData.bookedTransactions)

View File

@ -6,8 +6,9 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
open class EnterTanContext( class EnterTanContext(
open val enterTanResult: AtomicReference<EnterTanResult>, val enterTanResult: AtomicReference<EnterTanResult>,
open val countDownLatch: CountDownLatch, val responseHolder: ResponseHolder<*>,
open val tanRequestedTimeStamp: Date = Date() val countDownLatch: CountDownLatch,
val tanRequestedTimeStamp: Date = Date()
) )

View File

@ -1,11 +1,9 @@
package net.dankito.banking.fints.rest.model package net.dankito.banking.fints.rest.model
import net.dankito.banking.fints.model.BankData
import net.dankito.banking.fints.model.TanChallenge import net.dankito.banking.fints.model.TanChallenge
open class EnteringTanRequested( class EnteringTanRequested(
open val tanRequestId: String, val tanRequestId: String,
open val bank: BankData, val tanChallenge: TanChallenge
open val tanChallenge: TanChallenge
) )

View File

@ -0,0 +1,63 @@
package net.dankito.banking.fints.rest.model
import java.util.concurrent.CountDownLatch
class ResponseHolder<T>() {
private var responseReceivedLatch = CountDownLatch(1)
constructor(error: String) : this() {
setError(error)
}
var response: T? = null
private set
var error: String? = null
private set
var enterTanRequest: EnteringTanRequested? = null
private set
fun setResponse(response: T) {
this.response = response
signalResponseReceived()
}
fun setError(error: String) {
this.error = error
signalResponseReceived()
}
fun setEnterTanRequest(enterTanRequest: EnteringTanRequested) {
this.enterTanRequest = enterTanRequest
signalResponseReceived()
}
fun waitForResponse() {
responseReceivedLatch.await()
}
fun resetAfterEnteringTan() {
this.enterTanRequest = null
responseReceivedLatch = CountDownLatch(1)
}
private fun signalResponseReceived() {
responseReceivedLatch.countDown()
}
override fun toString(): String {
return "Error: $error, TAN requested: $enterTanRequest, success: $response"
}
}

View File

@ -0,0 +1,9 @@
package net.dankito.banking.fints.rest.model.dto.request
import net.dankito.banking.fints.model.EnterTanResult
class TanResponseDto(
val tanRequestId: String,
val enterTanResult: EnterTanResult
)

View File

@ -2,5 +2,5 @@ package net.dankito.banking.fints.rest.model.dto.response
open class GetAccountsTransactionsResponseDto( open class GetAccountsTransactionsResponseDto(
open val accounts: List<GetAccountTransactionsResponseDto> open val transactionsPerAccount: List<RestResponse<GetAccountTransactionsResponseDto>>
) )

View File

@ -0,0 +1,12 @@
package net.dankito.banking.fints.rest.model.dto.response
enum class ResponseType {
Success,
Error,
TanRequired
}

View File

@ -0,0 +1,29 @@
package net.dankito.banking.fints.rest.model.dto.response
import net.dankito.banking.fints.rest.model.EnteringTanRequested
class RestResponse<T>(
val status: ResponseType,
val errorMessage: String?,
val successResponse: T?,
val enteringTanRequested: EnteringTanRequested? = null
) {
companion object {
fun <T> success(result: T): RestResponse<T> {
return RestResponse(ResponseType.Success, null, result, null)
}
fun <T> error(errorMessage: String): RestResponse<T> {
return RestResponse(ResponseType.Error, errorMessage, null, null)
}
fun <T> requiresTan(enteringTanRequested: EnteringTanRequested): RestResponse<T> {
return RestResponse(ResponseType.TanRequired, null, null, enteringTanRequested)
}
}
}

View File

@ -2,15 +2,18 @@ package net.dankito.banking.fints.rest.service
import net.dankito.banking.bankfinder.InMemoryBankFinder import net.dankito.banking.bankfinder.InMemoryBankFinder
import net.dankito.banking.fints.FinTsClientForCustomer import net.dankito.banking.fints.FinTsClientForCustomer
import net.dankito.banking.fints.callback.FinTsClientCallback
import net.dankito.banking.fints.callback.SimpleFinTsClientCallback import net.dankito.banking.fints.callback.SimpleFinTsClientCallback
import net.dankito.banking.fints.model.* import net.dankito.banking.fints.model.*
import net.dankito.banking.fints.response.BankResponse
import net.dankito.banking.fints.response.client.AddAccountResponse import net.dankito.banking.fints.response.client.AddAccountResponse
import net.dankito.banking.fints.response.client.GetTransactionsResponse import net.dankito.banking.fints.response.client.GetTransactionsResponse
import net.dankito.banking.fints.rest.model.BankAccessData import net.dankito.banking.fints.rest.model.BankAccessData
import net.dankito.banking.fints.rest.model.EnterTanContext import net.dankito.banking.fints.rest.model.EnterTanContext
import net.dankito.banking.fints.rest.model.dto.request.AccountRequestDto import net.dankito.banking.fints.rest.model.EnteringTanRequested
import net.dankito.banking.fints.rest.model.ResponseHolder
import net.dankito.banking.fints.rest.model.dto.request.GetAccountsTransactionsRequestDto import net.dankito.banking.fints.rest.model.dto.request.GetAccountsTransactionsRequestDto
import net.dankito.banking.fints.rest.model.dto.request.TanResponseDto
import net.dankito.banking.fints.rest.service.model.GetAccountsTransactionsResponse
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
@ -25,21 +28,21 @@ class fints4kService {
protected val clientCache = ConcurrentHashMap<String, FinTsClientForCustomer>() protected val clientCache = ConcurrentHashMap<String, FinTsClientForCustomer>()
protected val tanRequests = mutableMapOf<String, EnterTanContext>() // TODO: create clean up job for timed out TAN requests
protected val tanRequests = ConcurrentHashMap<String, EnterTanContext>()
fun getAddAccountResponse(accessData: BankAccessData): AddAccountResponse { fun getAddAccountResponse(accessData: BankAccessData): ResponseHolder<AddAccountResponse> {
val (bank, errorMessage) = mapToBankData(accessData) val (bank, errorMessage) = mapToBankData(accessData)
if (errorMessage != null) { if (errorMessage != null) {
return AddAccountResponse(BankResponse(false, errorMessage = errorMessage), bank) return ResponseHolder(errorMessage)
} }
return getAccountData(bank) return getAccountData(bank)
} }
// TODO: as in most cases we really just want the account data, so just retrieve these without balances and transactions protected fun getAccountData(bank: BankData): ResponseHolder<AddAccountResponse> {
protected fun getAccountData(bank: BankData): AddAccountResponse {
return getAsyncResponse(bank) { client, responseRetrieved -> return getAsyncResponse(bank) { client, responseRetrieved ->
client.addAccountAsync(AddAccountParameter(bank)) { response -> client.addAccountAsync(AddAccountParameter(bank)) { response ->
responseRetrieved(response) responseRetrieved(response)
@ -48,28 +51,32 @@ class fints4kService {
} }
fun getAccountTransactions(dto: GetAccountsTransactionsRequestDto): List<GetTransactionsResponse> { fun getAccountTransactions(dto: GetAccountsTransactionsRequestDto): GetAccountsTransactionsResponse {
val (bank, errorMessage) = mapToBankData(dto.credentials) val (bank, errorMessage) = mapToBankData(dto.credentials)
if (errorMessage != null) { if (errorMessage != null) {
return listOf(GetTransactionsResponse(BankResponse(false, errorMessage = errorMessage))) return GetAccountsTransactionsResponse(listOf(ResponseHolder(errorMessage)))
} }
return dto.accounts.map { accountDto -> val retrievedAccounts = getAccounts(bank)
val account = findAccount(dto.credentials, accountDto)
val transactionsPerAccount = dto.accounts.map { accountDto ->
val account = retrievedAccounts?.firstOrNull { it.accountIdentifier == accountDto.identifier }
return@map if (account != null) { return@map if (account != null) {
val parameter = GetTransactionsParameter(account, dto.alsoRetrieveBalance, dto.fromDate, dto.toDate, abortIfTanIsRequired = dto.abortIfTanIsRequired) val parameter = GetTransactionsParameter(account, dto.alsoRetrieveBalance, dto.fromDate, dto.toDate, abortIfTanIsRequired = dto.abortIfTanIsRequired)
getAccountTransactions(bank, parameter) getAccountTransactions(bank, parameter)
} }
else { else {
GetTransactionsResponse(BankResponse(false, errorMessage = "Account with identifier '${accountDto.identifier}' not found. Available accounts: " + ResponseHolder("Account with identifier '${accountDto.identifier}' not found. Available accounts: " +
"${getCachedClient(dto.credentials)?.bank?.accounts?.map { it.accountIdentifier }?.joinToString(", ")}")) "${retrievedAccounts?.joinToString(", ") { it.accountIdentifier }}")
} }
} }
return GetAccountsTransactionsResponse(transactionsPerAccount)
} }
fun getAccountTransactions(bank: BankData, parameter: GetTransactionsParameter): GetTransactionsResponse { fun getAccountTransactions(bank: BankData, parameter: GetTransactionsParameter): ResponseHolder<GetTransactionsResponse> {
return getAsyncResponse(bank) { client, responseRetrieved -> return getAsyncResponse(bank) { client, responseRetrieved ->
client.getTransactionsAsync(parameter) { response -> client.getTransactionsAsync(parameter) { response ->
responseRetrieved(response) responseRetrieved(response)
@ -78,50 +85,68 @@ class fints4kService {
} }
protected fun <T> getAsyncResponse(bank: BankData, executeRequest: (FinTsClientForCustomer, ((T) -> Unit)) -> Unit): T { fun handleTanResponse(dto: TanResponseDto): ResponseHolder<*> {
val result = AtomicReference<T>() tanRequests.remove(dto.tanRequestId)?.let { enterTanContext ->
val countDownLatch = CountDownLatch(1) val responseHolder = enterTanContext.responseHolder
responseHolder.resetAfterEnteringTan()
val client = getClient(bank, result, countDownLatch) enterTanContext.enterTanResult.set(dto.enterTanResult)
enterTanContext.countDownLatch.countDown()
executeRequest(client) { response -> responseHolder.waitForResponse()
result.set(response)
countDownLatch.countDown() return responseHolder
} }
countDownLatch.await() return ResponseHolder<Any>("No TAN request found for TAN Request ID '${dto.tanRequestId}'")
return result.get()
} }
private fun <T> getClient(bank: BankData, result: AtomicReference<T>, countDownLatch: CountDownLatch): FinTsClientForCustomer {
protected fun <T> getAsyncResponse(bank: BankData, executeRequest: (FinTsClientForCustomer, ((T) -> Unit)) -> Unit): ResponseHolder<T> {
val responseHolder = ResponseHolder<T>()
val client = getClient(bank, responseHolder)
executeRequest(client) { response ->
responseHolder.setResponse(response)
}
responseHolder.waitForResponse()
return responseHolder
}
private fun <T> getClient(bank: BankData, responseHolder: ResponseHolder<T>): FinTsClientForCustomer {
val cacheKey = getCacheKey(bank.bankCode, bank.customerId) val cacheKey = getCacheKey(bank.bankCode, bank.customerId)
clientCache[cacheKey]?.let { clientCache[cacheKey]?.let {
// TODO: this will not work for two parallel calls for the same account if both calls require entering a TAN as second one overwrites callback and ResponseHolder of first one -> first one blocks forever
it.setCallback(createFinTsClientCallback(responseHolder)) // we have to newly create callback otherwise ResponseHolder instance of when client was created is used -> its CountDownLatch would never signal
return it return it
} }
// val client = FinTsClient(SimpleFinTsClientCallback { supportedTanMethods: List<TanMethod>, suggestedTanMethod: TanMethod? -> val client = FinTsClientForCustomer(bank, createFinTsClientCallback(responseHolder))
val client = FinTsClientForCustomer(bank, SimpleFinTsClientCallback({ bank, tanChallenge -> handleEnterTan(bank, tanChallenge, countDownLatch, result) }) { supportedTanMethods, suggestedTanMethod ->
suggestedTanMethod
})
clientCache[cacheKey] = client clientCache[cacheKey] = client
return client return client
} }
protected fun <T> handleEnterTan(bank: BankData, tanChallenge: TanChallenge, originatingRequestLatch: CountDownLatch, originatingRequestResult: AtomicReference<T>): EnterTanResult { private fun <T> createFinTsClientCallback(responseHolder: ResponseHolder<T>): FinTsClientCallback {
return SimpleFinTsClientCallback({ bank, tanChallenge -> handleEnterTan(bank, tanChallenge, responseHolder) }) { supportedTanMethods, suggestedTanMethod ->
suggestedTanMethod
}
}
protected fun <T> handleEnterTan(bank: BankData, tanChallenge: TanChallenge, responseHolder: ResponseHolder<T>): EnterTanResult {
val enterTanResult = AtomicReference<EnterTanResult>() val enterTanResult = AtomicReference<EnterTanResult>()
val enterTanLatch = CountDownLatch(1) val enterTanLatch = CountDownLatch(1)
val tanRequestId = UUID.randomUUID().toString() val tanRequestId = UUID.randomUUID().toString()
// TODO: find a solution for returning TAN challenge to caller tanRequests.put(tanRequestId, EnterTanContext(enterTanResult, responseHolder, enterTanLatch))
//originatingRequestResult.set(EnteringTanRequested(tanRequestId, bank, tanChallenge))
originatingRequestLatch.countDown()
tanRequests.put(tanRequestId, EnterTanContext(enterTanResult, enterTanLatch)) responseHolder.setEnterTanRequest(EnteringTanRequested(tanRequestId, tanChallenge))
enterTanLatch.await() enterTanLatch.await()
@ -146,11 +171,24 @@ class fints4kService {
return Pair(bank, null) return Pair(bank, null)
} }
protected fun findAccount(credentials: BankAccessData, accountDto: AccountRequestDto): AccountData? {
return getCachedClient(credentials)?.bank?.accounts?.firstOrNull { it.accountIdentifier == accountDto.identifier } protected fun getAccounts(bank: BankData): List<AccountData>? {
getCachedClient(bank)?.bank?.accounts?.let {
return it
}
val addAccountResponse = getAccountData(bank)
return addAccountResponse.response?.bank?.accounts
} }
private fun getCachedClient(bank: BankData): FinTsClientForCustomer? {
val cacheKey = getCacheKey(bank.bankCode, bank.customerId)
return clientCache[cacheKey]
}
private fun getCachedClient(credentials: BankAccessData): FinTsClientForCustomer? { private fun getCachedClient(credentials: BankAccessData): FinTsClientForCustomer? {
val cacheKey = getCacheKey(credentials.bankCode, credentials.loginName) val cacheKey = getCacheKey(credentials.bankCode, credentials.loginName)

View File

@ -0,0 +1,9 @@
package net.dankito.banking.fints.rest.service.model
import net.dankito.banking.fints.response.client.GetTransactionsResponse
import net.dankito.banking.fints.rest.model.ResponseHolder
class GetAccountsTransactionsResponse(
val transactionsPerAccount: List<ResponseHolder<GetTransactionsResponse>>
)