package net.dankito.banking.fints import kotlinx.datetime.LocalDate import net.dankito.banking.client.model.parameter.FinTsClientParameter import net.dankito.banking.fints.callback.FinTsClientCallback import net.dankito.banking.fints.model.* import net.dankito.banking.client.model.parameter.GetAccountDataParameter import net.dankito.banking.client.model.parameter.RetrieveTransactions import net.dankito.banking.client.model.parameter.TransferMoneyParameter import net.dankito.banking.client.model.response.ErrorCode import net.dankito.banking.client.model.response.GetAccountDataResponse import net.dankito.banking.client.model.response.TransferMoneyResponse import net.dankito.banking.fints.mapper.FinTsModelMapper import net.dankito.banking.fints.response.client.FinTsClientResponse import net.dankito.banking.fints.response.client.GetAccountInfoResponse import net.dankito.banking.fints.response.client.GetAccountTransactionsResponse import net.dankito.banking.fints.response.segments.AccountType import net.dankito.banking.fints.util.BicFinder import net.dankito.banking.fints.util.FinTsServerAddressFinder import net.dankito.banking.fints.webclient.IWebClient import net.dankito.utils.multiplatform.extensions.minusDays import net.dankito.utils.multiplatform.extensions.todayAtEuropeBerlin import kotlin.jvm.JvmOverloads open class FinTsClient @JvmOverloads constructor( open var callback: FinTsClientCallback, protected open val jobExecutor: FinTsJobExecutor = FinTsJobExecutor(), protected open val finTsServerAddressFinder: FinTsServerAddressFinder = FinTsServerAddressFinder(), protected open val product: ProductData = ProductData("15E53C26816138699C7B6A3E8", "1.0.0") // TODO: get version dynamically ) { companion object { // TODO: use the English names val SupportedAccountTypes = listOf(AccountType.Girokonto, AccountType.Festgeldkonto, AccountType.Kreditkartenkonto, AccountType.Sparkonto) } constructor(callback: FinTsClientCallback, webClient: IWebClient) : this(callback, FinTsJobExecutor(RequestExecutor(webClient = webClient))) // Swift does not support default parameter values -> create constructor overloads protected open val mapper = FinTsModelMapper() protected open val bicFinder = BicFinder() open suspend fun getAccountDataAsync(bankCode: String, loginName: String, password: String): GetAccountDataResponse { return getAccountDataAsync(GetAccountDataParameter(bankCode, loginName, password)) } open suspend fun getAccountDataAsync(param: GetAccountDataParameter): GetAccountDataResponse { val finTsServerAddress = finTsServerAddressFinder.findFinTsServerAddress(param.bankCode) if (finTsServerAddress.isNullOrBlank()) { return GetAccountDataResponse(ErrorCode.BankDoesNotSupportFinTs3, "Either bank does not support FinTS 3.0 or we don't know its FinTS server address", null, listOf()) } val bank = mapper.mapToBankData(param, finTsServerAddress) val accounts = param.accounts if (accounts.isNullOrEmpty() || param.retrieveOnlyAccountInfo) { // then first retrieve customer's bank accounts val getAccountInfoResponse = getAccountInfo(param, bank) if (getAccountInfoResponse.successful == false || param.retrieveOnlyAccountInfo) { return GetAccountDataResponse(mapper.mapErrorCode(getAccountInfoResponse), mapper.mapErrorMessages(getAccountInfoResponse), null, getAccountInfoResponse.messageLogWithoutSensitiveData, bank) } else { return getAccountData(param, getAccountInfoResponse.bank, getAccountInfoResponse.bank.accounts, getAccountInfoResponse) } } else { return getAccountData(param, bank, accounts.map { mapper.mapToAccountData(it, param) }, null) } } protected open suspend fun getAccountData(param: GetAccountDataParameter, bank: BankData, accounts: List, previousJobResponse: FinTsClientResponse?): GetAccountDataResponse { val retrievedTransactionsResponses = mutableListOf() val accountsSupportingRetrievingTransactions = accounts.filter { it.supportsRetrievingBalance || it.supportsRetrievingAccountTransactions } if (accountsSupportingRetrievingTransactions.isEmpty()) { val errorMessage = "None of the accounts ${accounts.map { it.productName }} supports retrieving balance or transactions" // TODO: translate return GetAccountDataResponse(ErrorCode.NoneOfTheAccountsSupportsRetrievingData, errorMessage, mapper.map(bank), previousJobResponse?.messageLogWithoutSensitiveData ?: listOf(), bank) } accountsSupportingRetrievingTransactions.forEach { account -> retrievedTransactionsResponses.add(getAccountData(param, bank, account)) } val unsuccessfulJob = retrievedTransactionsResponses.firstOrNull { it.successful == false } val errorCode = unsuccessfulJob?.let { mapper.mapErrorCode(it) } ?: if (retrievedTransactionsResponses.size < accountsSupportingRetrievingTransactions.size) ErrorCode.DidNotRetrieveAllAccountData else null return GetAccountDataResponse(errorCode, mapper.mapErrorMessages(unsuccessfulJob), mapper.map(bank, retrievedTransactionsResponses), mapper.mergeMessageLog(previousJobResponse, *retrievedTransactionsResponses.toTypedArray()), bank) } protected open suspend fun getAccountData(param: GetAccountDataParameter, bank: BankData, account: AccountData): GetAccountTransactionsResponse { val context = JobContext(JobContextType.GetTransactions, this.callback, product, bank, account) return jobExecutor.getTransactionsAsync(context, mapper.toGetAccountTransactionsParameter(param, bank, account)) } open suspend fun transferMoneyAsync(bankCode: String, loginName: String, password: String, recipientName: String, recipientAccountIdentifier: String, amount: Money, reference: String? = null): TransferMoneyResponse { return transferMoneyAsync(TransferMoneyParameter(bankCode, loginName, password, null, recipientName, recipientAccountIdentifier, null, amount, reference)) } open suspend fun transferMoneyAsync(param: TransferMoneyParameter): TransferMoneyResponse { val finTsServerAddress = finTsServerAddressFinder.findFinTsServerAddress(param.bankCode) if (finTsServerAddress.isNullOrBlank()) { return TransferMoneyResponse(ErrorCode.BankDoesNotSupportFinTs3, "Either bank does not FinTS 3.0 or we don't know its FinTS server address", listOf(), null) } val recipientBankIdentifier = getRecipientBankCode(param) if (recipientBankIdentifier == null) { return TransferMoneyResponse(ErrorCode.CanNotDetermineBicForIban, "We can only determine recipient's BIC automatically for German IBANs. If it's a German IBAN, either we " + "cannot extract the bank code from IBAN ${param.recipientAccountIdentifier} (fourth to twelfth position) or don't know the BIC to this bank code. Please specify recipient's IBAN explicitly.", listOf()) } val bank = mapper.mapToBankData(param, finTsServerAddress) val remittanceAccount = param.remittanceAccount if (remittanceAccount == null) { // then first retrieve customer's bank accounts val getAccountInfoResponse = getAccountInfo(param, bank) if (getAccountInfoResponse.successful == false) { return TransferMoneyResponse(mapper.mapErrorCode(getAccountInfoResponse), mapper.mapErrorMessages(getAccountInfoResponse), getAccountInfoResponse.messageLogWithoutSensitiveData, bank) } else { return transferMoneyAsync(param, recipientBankIdentifier, getAccountInfoResponse.bank, getAccountInfoResponse.bank.accounts, getAccountInfoResponse) } } else { return transferMoneyAsync(param, recipientBankIdentifier, bank, listOf(mapper.mapToAccountData(remittanceAccount, param)), null) } } protected open suspend fun transferMoneyAsync(param: TransferMoneyParameter, recipientBankIdentifier: String, bank: BankData, accounts: List, previousJobResponse: FinTsClientResponse?): TransferMoneyResponse { val accountsSupportingTransfer = accounts.filter { it.supportsTransferringMoney } val accountToUse: AccountData if (accountsSupportingTransfer.isEmpty()) { return TransferMoneyResponse(ErrorCode.NoAccountSupportsMoneyTransfer, "None of the accounts $accounts supports money transfer", previousJobResponse?.messageLogWithoutSensitiveData ?: listOf(), bank) } else if (accountsSupportingTransfer.size == 1) { accountToUse = accountsSupportingTransfer.first() } else { val selectedAccount = param.selectAccountToUseForTransfer?.invoke(accountsSupportingTransfer) if (selectedAccount == null) { return TransferMoneyResponse(ErrorCode.MoreThanOneAccountSupportsMoneyTransfer, "More than one of the accounts $accountsSupportingTransfer supports money transfer, so we cannot clearly determine which one to use for this transfer", previousJobResponse?.messageLogWithoutSensitiveData ?: listOf(), bank) } accountToUse = selectedAccount } val context = JobContext(JobContextType.TransferMoney, this.callback, product, bank, accountToUse) val response = jobExecutor.transferMoneyAsync(context, BankTransferData(param.recipientName, param.recipientAccountIdentifier, recipientBankIdentifier, param.amount, param.reference, param.instantPayment)) return TransferMoneyResponse(mapper.mapErrorCode(response), mapper.mapErrorMessages(response), mapper.mergeMessageLog(previousJobResponse, response), bank) } private fun getRecipientBankCode(param: TransferMoneyParameter): String? { param.recipientBankIdentifier?.let { return it } val probablyIban = param.recipientAccountIdentifier.replace(" ", "") if (probablyIban.length > 12) { val bankCode = probablyIban.substring(4, 4 + 8) // extract bank code from IBAN. For German IBAN bank code starts at fourth position and has 8 digits bicFinder.findBic(bankCode)?.let { return it } } return null } protected open suspend fun getAccountInfo(param: FinTsClientParameter, bank: BankData): GetAccountInfoResponse { param.finTsModel?.let { // TODO: implement // return GetAccountInfoResponse(it) } val context = JobContext(JobContextType.AddAccount, this.callback, product, bank) // TODO: add / change JobContextType /* First dialog: Get user's basic data like BPD, customer system ID and her TAN methods */ val newUserInfoResponse = jobExecutor.retrieveBasicDataLikeUsersTanMethods(context, param.preferredTanMethods, param.preferredTanMedium) if (newUserInfoResponse.successful == false) { // bank parameter (FinTS server address, ...) already seem to be wrong return GetAccountInfoResponse(context, newUserInfoResponse) } /* Second dialog, executed in retrieveBasicDataLikeUsersTanMethods() if required: some banks require that in order to initialize a dialog with strong customer authorization TAN media is required */ val getAccountsResponse = jobExecutor.getAccounts(context) return GetAccountInfoResponse(context, getAccountsResponse) } }