fints4k/ui/hbci4jBankingClient/src/main/kotlin/net/dankito/banking/hbci4jBankingClient.kt

374 lines
15 KiB
Kotlin

package net.dankito.banking
import net.dankito.banking.model.AccountCredentials
import net.dankito.banking.model.ConnectResult
import net.dankito.banking.ui.BankingClientCallback
import net.dankito.banking.ui.IBankingClient
import net.dankito.banking.ui.model.*
import net.dankito.banking.ui.model.mapper.IModelCreator
import net.dankito.banking.ui.model.parameters.GetTransactionsParameter
import net.dankito.banking.ui.model.parameters.TransferMoneyData
import net.dankito.banking.ui.model.responses.AddAccountResponse
import net.dankito.banking.ui.model.responses.BankingClientResponse
import net.dankito.banking.ui.model.responses.GetTransactionsResponse
import net.dankito.banking.util.AccountTransactionMapper
import net.dankito.banking.util.hbci4jModelMapper
import net.dankito.banking.util.*
import net.dankito.utils.ThreadPool
import net.dankito.utils.multiplatform.*
import net.dankito.utils.multiplatform.Date
import org.kapott.hbci.GV.HBCIJob
import org.kapott.hbci.GV_Result.GVRKUms
import org.kapott.hbci.GV_Result.GVRSaldoReq
import org.kapott.hbci.manager.HBCIHandler
import org.kapott.hbci.manager.HBCIUtils
import org.kapott.hbci.manager.HBCIVersion
import org.kapott.hbci.passport.AbstractHBCIPassport
import org.kapott.hbci.passport.HBCIPassport
import org.kapott.hbci.status.HBCIExecStatus
import org.kapott.hbci.structures.Value
import org.slf4j.LoggerFactory
import java.text.SimpleDateFormat
import java.util.*
open class hbci4jBankingClient(
protected val bank: TypedBankData,
modelCreator: IModelCreator,
protected val dataFolder: File,
protected val asyncRunner: IAsyncRunner = ThreadPoolAsyncRunner(ThreadPool()),
protected val callback: BankingClientCallback
) : IBankingClient {
companion object {
// the date format is hard coded in HBCIUtils.string2DateISO()
val HbciLibDateFormat = SimpleDateFormat("yyyy-MM-dd")
const val NinetyDaysInMilliseconds = 90 * 24 * 60 * 60 * 1000L
private val log = LoggerFactory.getLogger(hbci4jBankingClient::class.java)
}
protected val credentials = AccountCredentials(bank)
protected val mapper = hbci4jModelMapper(modelCreator)
protected val accountTransactionMapper = AccountTransactionMapper(modelCreator)
override val messageLogWithoutSensitiveData: List<MessageLogEntry> = listOf() // TODO: implement
override fun addAccountAsync(callback: (AddAccountResponse) -> Unit) {
asyncRunner.runAsync {
callback(addAccount())
}
}
open fun addAccount(): AddAccountResponse {
val connection = connect()
closeConnection(connection)
if(connection.successful) {
connection.passport?.let { passport ->
val accounts = passport.accounts
if (accounts == null || accounts.size == 0) {
log.error("Keine Konten ermittelbar")
return AddAccountResponse(bank, "Keine Konten ermittelbar") // TODO: translate
}
this.bank.accounts = mapper.mapAccounts(bank, accounts, passport)
return tryToRetrieveAccountTransactionsForAddedAccounts(bank)
}
}
return AddAccountResponse(bank, connection.error?.getInnerExceptionMessage() ?: "Could not connect")
}
protected open fun tryToRetrieveAccountTransactionsForAddedAccounts(bank: TypedBankData): AddAccountResponse {
var userCancelledAction = false
val retrievedData = bank.accounts.map { account ->
if (account.supportsRetrievingAccountTransactions) {
val response = getTransactionsOfLast90Days(account)
if (response.userCancelledAction) {
userCancelledAction = true
}
response.retrievedData.first()
}
else {
RetrievedAccountData.unsuccessful(account)
}
}
return AddAccountResponse(bank, retrievedData, null, false, userCancelledAction)
}
/**
* According to PSD2 for the account transactions of the last 90 days the two-factor authorization does not have to
* be applied. It depends on the bank if they request a second factor or not.
*
* So we simply try to retrieve at account transactions of the last 90 days and see if a second factor is required
* or not.
*/
open fun getTransactionsOfLast90DaysAsync(account: TypedBankAccount, callback: (GetTransactionsResponse) -> Unit) {
asyncRunner.runAsync {
callback(getTransactionsOfLast90Days(account))
}
}
/**
* According to PSD2 for the account transactions of the last 90 days the two-factor authorization does not have to
* be applied. It depends on the bank if they request a second factor or not.
*
* So we simply try to retrieve at account transactions of the last 90 days and see if a second factor is required
* or not.
*/
open fun getTransactionsOfLast90Days(account: TypedBankAccount): GetTransactionsResponse {
val ninetyDaysAgo = Date(Date.today.time - NinetyDaysInMilliseconds)
return getTransactions(GetTransactionsParameter(account, account.supportsRetrievingBalance, ninetyDaysAgo)) // TODO: implement abortIfTanIsRequired
}
override fun getTransactionsAsync(parameter: GetTransactionsParameter, callback: (GetTransactionsResponse) -> Unit) {
asyncRunner.runAsync {
callback(getTransactions(parameter))
}
}
protected open fun getTransactions(parameter: GetTransactionsParameter): GetTransactionsResponse {
val connection = connect()
val account = parameter.account
connection.handle?.let { handle ->
try {
val (nullableBalanceJob, accountTransactionsJob, status) = executeJobsForGetAccountTransactions(handle, parameter)
// Pruefen, ob die Kommunikation mit der Bank grundsaetzlich geklappt hat
if (!status.isOK) {
log.error("Could not connect to bank ${credentials.bankCode} $status: ${status.errorString}")
return GetTransactionsResponse(account,"Could not connect to bank ${credentials.bankCode}: $status")
}
// Auswertung des Saldo-Abrufs.
var balance = BigDecimal.Zero
if (parameter.alsoRetrieveBalance && nullableBalanceJob != null) {
val balanceResult = nullableBalanceJob.jobResult as GVRSaldoReq
if(balanceResult.isOK == false) {
log.error("Could not fetch balance of bank account $account: $balanceResult", balanceResult.getJobStatus().exceptions)
return GetTransactionsResponse(account,"Could not fetch balance of bank account $account: $balanceResult")
}
balance = balanceResult.entries[0].ready.value.bigDecimalValue.toBigDecimal()
}
// Das Ergebnis des Jobs koennen wir auf "GVRKUms" casten. Jobs des Typs "KUmsAll"
// liefern immer diesen Typ.
val result = accountTransactionsJob.jobResult as GVRKUms
// Pruefen, ob der Abruf der Umsaetze geklappt hat
if (result.isOK == false) {
log.error("Could not get fetch account transactions of bank account $account: $result", result.getJobStatus().exceptions)
return GetTransactionsResponse(account,"Could not fetch account transactions of bank account $account: $result")
}
return GetTransactionsResponse(RetrievedAccountData(account, true, balance.toBigDecimal(),
accountTransactionMapper.mapTransactions(account, result), listOf(), parameter.fromDate, parameter.toDate))
}
catch(e: Exception) {
log.error("Could not get account transactions for bank ${credentials.bankCode}", e)
return GetTransactionsResponse(account, e.getInnerExceptionMessage())
}
finally {
closeConnection(connection)
}
}
closeConnection(connection)
return GetTransactionsResponse(account, connection.error?.getInnerExceptionMessage() ?: "Could not connect")
}
protected open fun executeJobsForGetAccountTransactions(handle: HBCIHandler, parameter: GetTransactionsParameter): Triple<HBCIJob?, HBCIJob, HBCIExecStatus> {
val konto = mapper.mapToKonto(parameter.account)
// 1. Auftrag fuer das Abrufen des Saldos erzeugen
var balanceJob: HBCIJob? = null
if (parameter.alsoRetrieveBalance) {
val createdBalanceJob = handle.newJob("SaldoReq")
createdBalanceJob.setParam("my", konto) // festlegen, welches Konto abgefragt werden soll.
createdBalanceJob.addToQueue() // Zur Liste der auszufuehrenden Auftraege hinzufuegen
balanceJob = createdBalanceJob
}
// 2. Auftrag fuer das Abrufen der Umsaetze erzeugen
val accountTransactionsJob = handle.newJob("KUmsAll")
accountTransactionsJob.setParam("my", konto) // festlegen, welches Konto abgefragt werden soll.
// evtl. Datum setzen, ab welchem die Auszüge geholt werden sollen
parameter.fromDate?.let {
accountTransactionsJob.setParam("startdate", HbciLibDateFormat.format(it))
}
accountTransactionsJob.addToQueue() // Zur Liste der auszufuehrenden Auftraege hinzufuegen
// Hier koennen jetzt noch weitere Auftraege fuer diesen Bankzugang hinzugefuegt
// werden. Z.Bsp. Ueberweisungen.
// Alle Auftraege aus der Liste ausfuehren.
val status = handle.execute()
return Triple(balanceJob, accountTransactionsJob, status)
}
override fun transferMoneyAsync(data: TransferMoneyData, callback: (BankingClientResponse) -> Unit) {
asyncRunner.runAsync {
callback(transferMoney(data))
}
}
open fun transferMoney(data: TransferMoneyData): BankingClientResponse {
val connection = connect()
connection.handle?.let { handle ->
try {
createTransferCashJob(handle, data)
val status = handle.execute()
return BankingClientResponse(status.isOK, status.toString())
} catch(e: Exception) {
log.error("Could not transfer cash for account ${data.account}" , e)
return BankingClientResponse(false, e.getInnerExceptionMessage())
}
finally {
closeConnection(connection)
}
}
return BankingClientResponse(false, connection.error?.getInnerExceptionMessage() ?: "Could not connect")
}
protected open fun createTransferCashJob(handle: HBCIHandler, data: TransferMoneyData) {
// TODO: implement real-time transfer
val transferCashJob = handle.newJob("UebSEPA")
val source = mapper.mapToKonto(data.account)
val destination = mapper.mapToKonto(data)
val amount = Value(data.amount, "EUR")
transferCashJob.setParam("src", source)
transferCashJob.setParam("dst", destination)
transferCashJob.setParam("btg", amount)
transferCashJob.setParam("usage", data.reference)
transferCashJob.addToQueue()
}
override fun dataChanged(bank: TypedBankData) {
if (bank.bankCode != credentials.bankCode || bank.userName != credentials.customerId || bank.password != credentials.password) {
getPassportFile(credentials).delete()
}
credentials.bankCode = bank.bankCode
credentials.customerId = bank.userName
credentials.password = bank.password
}
override fun deletedBank(bank: TypedBankData, wasLastAccountWithThisCredentials: Boolean) {
getPassportFile(credentials).delete()
}
protected open fun connect(): ConnectResult {
return connect(credentials, HBCIVersion.HBCI_300)
}
protected open fun connect(credentials: AccountCredentials, version: HBCIVersion): ConnectResult {
// HBCI4Java initialisieren
// In "props" koennen optional Kernel-Parameter abgelegt werden, die in der Klasse
// org.kapott.hbci.manager.HBCIUtils (oben im Javadoc) beschrieben sind.
val props = Properties()
HBCIUtils.init(props, HbciCallback(credentials, bank, mapper, callback))
// In der Passport-Datei speichert HBCI4Java die Daten des Bankzugangs (Bankparameterdaten, Benutzer-Parameter, etc.).
// Die Datei kann problemlos geloescht werden. Sie wird beim naechsten mal automatisch neu erzeugt,
// wenn der Parameter "client.passport.PinTan.init" den Wert "1" hat (siehe unten).
// Wir speichern die Datei der Einfachheit halber im aktuellen Verzeichnis.
val passportFile = getPassportFile(credentials)
// Wir setzen die Kernel-Parameter zur Laufzeit. Wir koennten sie alternativ
// auch oben in "props" setzen.
HBCIUtils.setParam("client.passport.default", "PinTan") // Legt als Verfahren PIN/TAN fest.
HBCIUtils.setParam("client.passport.PinTan.filename", passportFile.absolutePath)
HBCIUtils.setParam("client.passport.PinTan.init", "1")
var handle: HBCIHandler? = null
var passport: HBCIPassport? = null
try {
// Erzeugen des Passport-Objektes.
passport = AbstractHBCIPassport.getInstance()
// Konfigurieren des Passport-Objektes.
// Das kann alternativ auch alles ueber den Callback unten geschehen
// Das Land.
passport.country = "DE"
// Server-Adresse angeben. Koennen wir entweder manuell eintragen oder direkt von HBCI4Java ermitteln lassen
val info = HBCIUtils.getBankInfo(credentials.bankCode)
passport.host = info.pinTanAddress
// TCP-Port des Servers. Bei PIN/TAN immer 443, da das ja ueber HTTPS laeuft.
passport.port = 443
// Art der Nachrichten-Codierung. Bei Chipkarte/Schluesseldatei wird
// "None" verwendet. Bei PIN/TAN kommt "Base64" zum Einsatz.
passport.filterType = "Base64"
// Verbindung zum Server aufbauen
handle = HBCIHandler(version.getId(), passport)
}
catch(e: Exception) {
log.error("Could not connect to bank ${credentials.bankCode}", e)
closeConnection(handle, passport)
return ConnectResult(false, e)
}
return ConnectResult(true, null, handle, passport)
}
protected open fun getPassportFile(credentials: AccountCredentials): File {
val hbciClientFolder = File(dataFolder, "hbci4j-client")
hbciClientFolder.mkdirs()
return File(hbciClientFolder, "passport_${credentials.bankCode}_${credentials.customerId}.dat")
}
protected open fun closeConnection(connection: ConnectResult) {
closeConnection(connection.handle, connection.passport)
}
protected open fun closeConnection(handle: HBCIHandler?, passport: HBCIPassport?) {
// Sicherstellen, dass sowohl Passport als auch Handle nach Beendigung geschlossen werden.
try {
handle?.close()
passport?.close()
HBCIUtils.doneThread() // i hate static variables, here's one of the reasons why: Old callbacks and therefore credentials get stored in static variables and therefor always the first entered credentials have been used
} catch(e: Exception) { log.error("Could not close connection", e) }
}
}