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

367 lines
16 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.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.bankfinder.BankInfo
import net.dankito.banking.util.*
import net.dankito.utils.ThreadPool
import net.dankito.utils.multiplatform.BigDecimal
import net.dankito.utils.multiplatform.Date
import net.dankito.utils.multiplatform.File
import net.dankito.utils.multiplatform.toBigDecimal
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(
bankInfo: BankInfo,
customerId: String,
pin: String,
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(bankInfo.bankCode, customerId, pin)
protected var customer = Customer(bankInfo.bankCode, customerId, pin,
bankInfo.pinTanAddress ?: "", bankInfo.name, bankInfo.bic, "")
protected val mapper = hbci4jModelMapper()
protected val accountTransactionMapper = AccountTransactionMapper()
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(false, "Keine Konten ermittelbar", customer) // TODO: translate
}
this.customer.accounts = mapper.mapBankAccounts(customer, accounts, passport)
return tryToRetrieveAccountTransactionsForAddedAccounts(customer)
}
}
return AddAccountResponse(false, null, customer, error = connection.error)
}
protected open fun tryToRetrieveAccountTransactionsForAddedAccounts(customer: Customer): AddAccountResponse {
val transactionsOfLast90DaysResponses = mutableListOf<GetTransactionsResponse>()
val balances = mutableMapOf<BankAccount, BigDecimal>()
val bookedTransactions = mutableMapOf<BankAccount, List<AccountTransaction>>()
val unbookedTransactions = mutableMapOf<BankAccount, List<Any>>()
customer.accounts.forEach { bankAccount ->
if (bankAccount.supportsRetrievingAccountTransactions) {
val response = getTransactionsOfLast90Days(bankAccount)
transactionsOfLast90DaysResponses.add(response)
bookedTransactions.put(bankAccount, response.bookedTransactions)
unbookedTransactions.put(bankAccount, response.unbookedTransactions)
balances.put(bankAccount, response.balance?.toBigDecimal() ?: BigDecimal.Zero) // TODO: really add BigDecimal.Zero if balance couldn't be retrieved?
}
}
val supportsRetrievingTransactionsOfLast90DaysWithoutTan = transactionsOfLast90DaysResponses.firstOrNull { it.isSuccessful } != null
return AddAccountResponse(true, null, customer, supportsRetrievingTransactionsOfLast90DaysWithoutTan,
bookedTransactions, unbookedTransactions, balances)
}
/**
* According to PSD2 for the accounting entries 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 accounting entries of the last 90 days and see if a second factor is required
* or not.
*/
open fun getTransactionsOfLast90DaysAsync(bankAccount: BankAccount, callback: (GetTransactionsResponse) -> Unit) {
asyncRunner.runAsync {
callback(getTransactionsOfLast90Days(bankAccount))
}
}
/**
* According to PSD2 for the accounting entries 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 accounting entries of the last 90 days and see if a second factor is required
* or not.
*/
open fun getTransactionsOfLast90Days(bankAccount: BankAccount): GetTransactionsResponse {
val ninetyDaysAgo = Date(Date().time - NinetyDaysInMilliseconds)
return getTransactions(bankAccount, GetTransactionsParameter(bankAccount.supportsRetrievingBalance, ninetyDaysAgo)) // TODO: implement abortIfTanIsRequired
}
override fun getTransactionsAsync(bankAccount: BankAccount, parameter: GetTransactionsParameter, callback: (GetTransactionsResponse) -> Unit) {
asyncRunner.runAsync {
callback(getTransactions(bankAccount, parameter))
}
}
protected open fun getTransactions(bankAccount: BankAccount, parameter: GetTransactionsParameter): GetTransactionsResponse {
val connection = connect()
connection.handle?.let { handle ->
try {
val (nullableBalanceJob, accountTransactionsJob, status) = executeJobsForGetAccountingEntries(handle, bankAccount, parameter)
// Pruefen, ob die Kommunikation mit der Bank grundsaetzlich geklappt hat
if (!status.isOK) {
log.error("Could not connect to bank ${credentials.bankCode} ${status.toString()}: ${status.errorString}")
return GetTransactionsResponse(bankAccount, false, null, error = Exception("Could not connect to bank ${credentials.bankCode}: ${status.toString()}"))
}
// 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 $bankAccount: $balanceResult", balanceResult.getJobStatus().exceptions)
return GetTransactionsResponse(bankAccount, false, null, error = Exception("Could not fetch balance of bank account $bankAccount: $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 $bankAccount: $result", result.getJobStatus().exceptions)
return GetTransactionsResponse(bankAccount, false, null, error = Exception("Could not fetch account transactions of bank account $bankAccount: $result"))
}
return GetTransactionsResponse(bankAccount, true, null, accountTransactionMapper.mapAccountTransactions(bankAccount, result),
listOf(), balance?.toBigDecimal())
}
catch(e: Exception) {
log.error("Could not get accounting details for bank ${credentials.bankCode}", e)
return GetTransactionsResponse(bankAccount, false, null, error = e)
}
finally {
closeConnection(connection)
}
}
closeConnection(connection)
return GetTransactionsResponse(bankAccount, false, null, error = connection.error)
}
protected open fun executeJobsForGetAccountingEntries(handle: HBCIHandler, bankAccount: BankAccount, parameter: GetTransactionsParameter): Triple<HBCIJob?, HBCIJob, HBCIExecStatus> {
val konto = mapper.mapToKonto(bankAccount)
// 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, bankAccount: BankAccount, callback: (BankingClientResponse) -> Unit) {
asyncRunner.runAsync {
callback(transferMoney(data, bankAccount))
}
}
open fun transferMoney(data: TransferMoneyData, bankAccount: BankAccount): BankingClientResponse {
val connection = connect()
connection.handle?.let { handle ->
try {
createTransferCashJob(handle, data, bankAccount)
val status = handle.execute()
return BankingClientResponse(status.isOK, status.toString())
} catch(e: Exception) {
log.error("Could not transfer cash for account $bankAccount" , e)
return BankingClientResponse(false, e.localizedMessage, e)
}
finally {
closeConnection(connection)
}
}
return BankingClientResponse(false, "Could not connect", connection.error)
}
protected open fun createTransferCashJob(handle: HBCIHandler, data: TransferMoneyData, bankAccount: BankAccount) {
// TODO: implement instant payment
val transferCashJob = handle.newJob("UebSEPA")
val source = mapper.mapToKonto(bankAccount)
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.usage)
transferCashJob.addToQueue()
}
override fun restoreData() {
// nothing to do for hbci4j
}
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, customer, 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 hbciClientFolder = File(dataFolder, "hbci4j-client")
hbciClientFolder.mkdirs()
val passportFile = File(hbciClientFolder, "passport_${credentials.bankCode}_${credentials.customerId}.dat")
// 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 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) }
}
}