Compare commits
90 commits
v1.0.0-Alp
...
main
Author | SHA1 | Date | |
---|---|---|---|
bf17bde9f5 | |||
9f3e4eff4d | |||
f89de94aa7 | |||
684b3fb40e | |||
df692ea222 | |||
636963b3d4 | |||
4802493886 | |||
ecf930fcad | |||
ce3b1d32d7 | |||
c39789dfde | |||
ab0b676216 | |||
20fe60d9f6 | |||
529caeaa87 | |||
3d6c68e743 | |||
7cdb7247c8 | |||
d7d2702869 | |||
67b58117e1 | |||
66801a1c7a | |||
2410504ede | |||
2a3b962af5 | |||
8346fb5077 | |||
8dc2174081 | |||
05322aface | |||
dcbbe043f0 | |||
65d983a5e7 | |||
9aad2a5101 | |||
be3a2df6d9 | |||
dea8be3bfa | |||
cba2f25335 | |||
825217ef88 | |||
b3cb76e77d | |||
b0c2f38bd6 | |||
fca1542b5c | |||
07672d1189 | |||
62aa04a667 | |||
3aa0edfb34 | |||
e4d605531e | |||
a42de32260 | |||
95e60b2706 | |||
fd9eadf45e | |||
7ddeb88475 | |||
90a7543641 | |||
d1de7f5eb0 | |||
ef8045fa96 | |||
2031cb9e9f | |||
e260eaa535 | |||
891641fc6f | |||
c158097d3a | |||
6908f52e48 | |||
61d8f2c342 | |||
6bf7fdcb44 | |||
fbafbb62e3 | |||
9372d17313 | |||
9b1a5fa929 | |||
42bf002626 | |||
20f06387c5 | |||
75320da2be | |||
be2908517f | |||
c4f504dd0a | |||
0848586894 | |||
83c2882567 | |||
f069f9155c | |||
bf5ee4890e | |||
ed4214fd49 | |||
b8fe9e78e1 | |||
da2bf8d469 | |||
113b817627 | |||
bd18644c0d | |||
b32cf94e25 | |||
8cc2f3bdcd | |||
59b8213163 | |||
cb34c86665 | |||
70c1082531 | |||
30e9a57b96 | |||
bf76de4f23 | |||
47e2b851b9 | |||
f90e280b74 | |||
9600e2f11b | |||
b2fb04372f | |||
3b05a8b9c8 | |||
d689c7663f | |||
6238b5abb2 | |||
1f8c1d303e | |||
09c2080481 | |||
e36c27c0e0 | |||
6865f64880 | |||
3f9921a62e | |||
504fbaf13b | |||
fb70bcd443 | |||
952fa9c13a |
121 changed files with 4594 additions and 1360 deletions
|
@ -26,7 +26,7 @@ repositories {
|
|||
|
||||
|
||||
dependencies {
|
||||
implementation("net.codinux.banking:fints4k:1.0.0-Alpha-11")
|
||||
implementation("net.codinux.banking:fints4k:1.0.0-Alpha-13")
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
@ -41,8 +41,8 @@ open class CsvWriter {
|
|||
protected open suspend fun writeToFile(stream: AsyncStream, valueSeparator: String, customer: CustomerAccount, account: BankAccount, transaction: AccountTransaction) {
|
||||
val amount = if (valueSeparator == ";") transaction.amount.amount.string.replace('.', ',') else transaction.amount.amount.string.replace(',', '.')
|
||||
|
||||
stream.writeString(listOf(customer.bankName, account.identifier, transaction.valueDate, amount, transaction.amount.currency, ensureNotNull(transaction.bookingText), wrap(transaction.reference),
|
||||
ensureNotNull(transaction.otherPartyName), ensureNotNull(transaction.otherPartyBankCode), ensureNotNull(transaction.otherPartyAccountId)).joinToString(valueSeparator))
|
||||
stream.writeString(listOf(customer.bankName, account.identifier, transaction.valueDate, amount, transaction.amount.currency, ensureNotNull(transaction.postingText), wrap(transaction.reference ?: ""),
|
||||
ensureNotNull(transaction.otherPartyName), ensureNotNull(transaction.otherPartyBankId), ensureNotNull(transaction.otherPartyAccountId)).joinToString(valueSeparator))
|
||||
|
||||
stream.writeString(NewLine)
|
||||
}
|
||||
|
|
10
build.gradle
10
build.gradle
|
@ -1,16 +1,16 @@
|
|||
// TODO: move to versions.gradle
|
||||
ext {
|
||||
appVersionName = '1.0.0-Alpha-12'
|
||||
appVersionName = "1.0.0-Alpha-16-SNAPSHOT"
|
||||
|
||||
|
||||
/* Test */
|
||||
|
||||
assertJVersion = '3.12.2'
|
||||
assertJVersion = "3.12.2"
|
||||
|
||||
mockitoVersion = '2.22.0'
|
||||
mockitoKotlinVersion = '1.6.0'
|
||||
mockitoVersion = "2.22.0"
|
||||
mockitoKotlinVersion = "1.6.0"
|
||||
|
||||
logbackVersion = '1.2.3'
|
||||
logbackVersion = "1.2.3"
|
||||
}
|
||||
|
||||
buildscript {
|
||||
|
|
26
docs/Vokabular.md
Normal file
26
docs/Vokabular.md
Normal file
|
@ -0,0 +1,26 @@
|
|||
| | |
|
||||
|--------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| Geschäftsvorfall | Business Transaction / Job |
|
||||
| Verwendungszweck | Remittance information, reference, (payment) purpose |
|
||||
| Überweisung | Remittance (techn.), money transfer, bank transfer, wire transfer (Amerik.), credit transfer |
|
||||
| Buchungsschlüssel | posting key |
|
||||
| Buchungstext | posting text |
|
||||
| | |
|
||||
| Ende-zu-Ende Referenz | End to End Reference |
|
||||
| Kundenreferenz | Reference of the submitting customer |
|
||||
| Mandatsreferenz | mandate reference |
|
||||
| Creditor Identifier | Creditor Identifier |
|
||||
| Originators Identification Code | Originators Identification Code |
|
||||
| Compensation Amount | Compensation Amount |
|
||||
| Original Amount | Original Amount |
|
||||
| Abweichender Überweisender (CT-AT08) / Abweichender Zahlungsempfänger (DD-AT38) | payer’s/debtor’s reference party (for credit transfer / payee’s / creditor’s reference party (for a direct debit) |
|
||||
| Abweichender Zahlungsempfänger (CT-AT28) / Abweichender Zahlungspflichtiger (DDAT15) | payee’s/creditor’s reference party / payer’s/debtor’s reference party |
|
||||
| | |
|
||||
| Überweisender | Payer, debtor |
|
||||
| Zahlungsempfänger | Payee, creditor |
|
||||
| Zahlungseingang | Payment receipt |
|
||||
| Lastschrift | direct debit |
|
||||
| | |
|
||||
| | |
|
||||
| Primanoten-Nr. | Journal no. |
|
||||
| | |
|
|
@ -13,6 +13,10 @@ kotlin {
|
|||
compilerOptions {
|
||||
// suppresses compiler warning: [EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING] 'expect'/'actual' classes (including interfaces, objects, annotations, enums, and 'actual' typealiases) are in Beta.
|
||||
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||
|
||||
if (System.getProperty("idea.debugger.dispatch.addr") != null) {
|
||||
freeCompilerArgs.add("-Xdebug")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -72,6 +76,7 @@ kotlin {
|
|||
implementation("io.ktor:ktor-client-core:$ktorVersion")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$kotlinxSerializationVersion")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion")
|
||||
|
||||
implementation("net.codinux.log:klf:$klfVersion")
|
||||
}
|
||||
|
|
|
@ -9,12 +9,18 @@ import net.dankito.banking.client.model.response.TransferMoneyResponse
|
|||
import net.codinux.banking.fints.callback.FinTsClientCallback
|
||||
import net.codinux.banking.fints.config.FinTsClientConfiguration
|
||||
import net.codinux.banking.fints.mapper.FinTsModelMapper
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.KundensystemID
|
||||
import net.codinux.banking.fints.model.*
|
||||
import net.codinux.banking.fints.response.client.FinTsClientResponse
|
||||
import net.codinux.banking.fints.response.client.GetAccountInfoResponse
|
||||
import net.codinux.banking.fints.response.client.GetAccountTransactionsResponse
|
||||
import net.codinux.banking.fints.response.segments.AccountType
|
||||
import net.codinux.banking.fints.response.segments.BankParameters
|
||||
import net.codinux.banking.fints.util.BicFinder
|
||||
import net.codinux.log.LogLevel
|
||||
import net.codinux.log.LoggerFactory
|
||||
import kotlin.js.JsName
|
||||
import kotlin.jvm.JvmName
|
||||
|
||||
|
||||
open class FinTsClient(
|
||||
|
@ -35,56 +41,59 @@ open class FinTsClient(
|
|||
protected open val bicFinder = BicFinder()
|
||||
|
||||
|
||||
init {
|
||||
LoggerFactory.getLogger("net.codinux.banking.fints.log.MessageLogCollector").level = if (config.options.appendFinTsMessagesToLog) {
|
||||
LogLevel.Debug
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 = config.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 basicAccountDataResponse = getRequiredDataToSendUserJobs(param)
|
||||
val bank = basicAccountDataResponse.finTsModel
|
||||
|
||||
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.messageLog, bank)
|
||||
} else {
|
||||
return getAccountData(param, getAccountInfoResponse.bank, getAccountInfoResponse.bank.accounts, getAccountInfoResponse)
|
||||
}
|
||||
if (basicAccountDataResponse.successful == false || param.retrieveOnlyAccountInfo || bank == null) {
|
||||
return GetAccountDataResponse(basicAccountDataResponse.error, basicAccountDataResponse.errorMessage, null,
|
||||
basicAccountDataResponse.messageLog, bank)
|
||||
} else {
|
||||
return getAccountData(param, bank, accounts.map { mapper.mapToAccountData(it, param) }, null)
|
||||
return getAccountData(param, bank, bank.accounts, basicAccountDataResponse.messageLog)
|
||||
}
|
||||
}
|
||||
|
||||
protected open suspend fun getAccountData(param: GetAccountDataParameter, bank: BankData, accounts: List<AccountData>, previousJobResponse: FinTsClientResponse?): GetAccountDataResponse {
|
||||
protected open suspend fun getAccountData(param: GetAccountDataParameter, bank: BankData, accounts: List<AccountData>, previousJobMessageLog: List<MessageLogEntry>?): GetAccountDataResponse {
|
||||
val retrievedTransactionsResponses = mutableListOf<GetAccountTransactionsResponse>()
|
||||
|
||||
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?.messageLog ?: listOf(), bank)
|
||||
return GetAccountDataResponse(ErrorCode.NoneOfTheAccountsSupportsRetrievingData, errorMessage, mapper.map(bank), previousJobMessageLog ?: listOf(), bank)
|
||||
}
|
||||
|
||||
accountsSupportingRetrievingTransactions.forEach { account ->
|
||||
retrievedTransactionsResponses.add(getAccountData(param, bank, account))
|
||||
for (account in accountsSupportingRetrievingTransactions) {
|
||||
val response = getAccountTransactions(param, bank, account)
|
||||
retrievedTransactionsResponses.add(response)
|
||||
|
||||
if (response.tanRequiredButWeWereToldToAbortIfSo || response.userCancelledAction) { // if user cancelled action or TAN is required but we were told to abort then, then don't continue with next account
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return GetAccountDataResponse(errorCode, mapper.mapErrorMessages(unsuccessfulJob), mapper.map(bank, retrievedTransactionsResponses, param.retrieveTransactionsTo),
|
||||
mapper.mergeMessageLog(previousJobMessageLog, *retrievedTransactionsResponses.map { it.messageLog }.toTypedArray()), bank)
|
||||
}
|
||||
|
||||
protected open suspend fun getAccountData(param: GetAccountDataParameter, bank: BankData, account: AccountData): GetAccountTransactionsResponse {
|
||||
val context = JobContext(JobContextType.GetTransactions, this.callback, config, bank, account)
|
||||
protected open suspend fun getAccountTransactions(param: GetAccountDataParameter, bank: BankData, account: AccountData): GetAccountTransactionsResponse {
|
||||
val context = JobContext(JobContextType.GetTransactions, this.callback, config, bank, account, param.preferredTanMethods, param.tanMethodsNotSupportedByApplication, param.preferredTanMedium)
|
||||
|
||||
return config.jobExecutor.getTransactionsAsync(context, mapper.toGetAccountTransactionsParameter(param, bank, account))
|
||||
}
|
||||
|
@ -142,7 +151,7 @@ open class FinTsClient(
|
|||
accountToUse = selectedAccount
|
||||
}
|
||||
|
||||
val context = JobContext(JobContextType.TransferMoney, this.callback, config, bank, accountToUse)
|
||||
val context = JobContext(JobContextType.TransferMoney, this.callback, config, bank, accountToUse, param.preferredTanMethods, param.tanMethodsNotSupportedByApplication, param.preferredTanMedium)
|
||||
|
||||
val response = config.jobExecutor.transferMoneyAsync(context, BankTransferData(param.recipientName, param.recipientAccountIdentifier, recipientBankIdentifier,
|
||||
param.amount, param.reference, param.instantPayment))
|
||||
|
@ -163,17 +172,57 @@ open class FinTsClient(
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures all basic data to initialize a dialog with strong customer authorization is retrieved so you can send your
|
||||
* actual jobs (Geschäftsvorfälle) to your bank's FinTS server.
|
||||
*
|
||||
* These data include:
|
||||
* - Bank communication data like FinTS server address, BIC, bank name, bank code used for FinTS.
|
||||
* - BPD (BankParameterDaten): bank name, BPD version, supported languages, supported HBCI versions, supported TAN methods,
|
||||
* max count jobs per message (Anzahl Geschäftsvorfallsarten) (see [BankParameters] [BankParameters](src/commonMain/kotlin/net/codinux/banking/fints/response/segmentsBankParameters) ).
|
||||
* - Min and max online banking password length, min TAN length, hint for login name (for all: if available)
|
||||
* - UPD (UserParameterDaten): username, UPD version.
|
||||
* - Customer system ID (Kundensystem-ID, see [KundensystemID]), TAN methods available for user and may user's TAN media.
|
||||
* - Which jobs the bank supports and which jobs need strong customer authorization (= require HKTAN segment).
|
||||
* - Which jobs the user is allowed to use.
|
||||
* - Which jobs can be called for a specific bank account.
|
||||
*
|
||||
* When implementing your own jobs, call this method first, then send an init dialog message and in next message your actual jobs.
|
||||
*
|
||||
* More or less implements everything of 02 FinTS_3.0_Formals.pdf so that you can start directly with the jobs from
|
||||
* 04 FinTS_3.0_Messages_Geschaeftsvorfaelle.pdf
|
||||
*/
|
||||
open suspend fun getRequiredDataToSendUserJobs(param: FinTsClientParameter): net.dankito.banking.client.model.response.FinTsClientResponse {
|
||||
param.finTsModelOrDeserialized?.let { finTsModel ->
|
||||
return net.dankito.banking.client.model.response.FinTsClientResponse(null, null, emptyList(), finTsModel)
|
||||
}
|
||||
|
||||
val defaultValues = (param as? GetAccountDataParameter)?.defaultBankValues
|
||||
|
||||
val finTsServerAddress = defaultValues?.finTs3ServerAddress ?: config.finTsServerAddressFinder.findFinTsServerAddress(param.bankCode)
|
||||
if (finTsServerAddress.isNullOrBlank()) {
|
||||
return net.dankito.banking.client.model.response.FinTsClientResponse(ErrorCode.BankDoesNotSupportFinTs3, "Either bank does not support FinTS 3.0 or we don't know its FinTS server address", emptyList(), null)
|
||||
}
|
||||
|
||||
val bank = mapper.mapToBankData(param, finTsServerAddress, defaultValues)
|
||||
|
||||
val getAccountInfoResponse = getAccountInfo(param, bank)
|
||||
|
||||
return net.dankito.banking.client.model.response.FinTsClientResponse(mapper.mapErrorCode(getAccountInfoResponse), mapper.mapErrorMessages(getAccountInfoResponse),
|
||||
getAccountInfoResponse.messageLog, bank)
|
||||
}
|
||||
|
||||
protected open suspend fun getAccountInfo(param: FinTsClientParameter, bank: BankData): GetAccountInfoResponse {
|
||||
param.finTsModel?.let {
|
||||
param.finTsModelOrDeserialized?.let {
|
||||
// TODO: implement
|
||||
// return GetAccountInfoResponse(it)
|
||||
}
|
||||
|
||||
val context = JobContext(JobContextType.GetAccountInfo, this.callback, config, bank)
|
||||
val context = JobContext(JobContextType.GetAccountInfo, this.callback, config, bank, null, param.preferredTanMethods, param.tanMethodsNotSupportedByApplication, param.preferredTanMedium)
|
||||
|
||||
/* First dialog: Get user's basic data like BPD, customer system ID and her TAN methods */
|
||||
|
||||
val newUserInfoResponse = config.jobExecutor.retrieveBasicDataLikeUsersTanMethods(context, param.preferredTanMethods, param.preferredTanMedium)
|
||||
val newUserInfoResponse = config.jobExecutor.retrieveBasicDataLikeUsersTanMethods(context)
|
||||
|
||||
/* 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 */
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
package net.codinux.banking.fints
|
||||
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.*
|
||||
import net.codinux.banking.fints.callback.FinTsClientCallback
|
||||
import net.codinux.banking.fints.config.FinTsClientConfiguration
|
||||
|
@ -39,13 +37,13 @@ open class FinTsClientDeprecated(
|
|||
}
|
||||
|
||||
|
||||
open suspend fun addAccountAsync(parameter: AddAccountParameter): AddAccountResponse {
|
||||
val bank = parameter.bank
|
||||
val context = JobContext(JobContextType.AddAccount, this.callback, config, bank)
|
||||
open suspend fun addAccountAsync(param: AddAccountParameter): AddAccountResponse {
|
||||
val bank = param.bank
|
||||
val context = JobContext(JobContextType.AddAccount, this.callback, config, bank, null, param.preferredTanMethods, param.tanMethodsNotSupportedByApplication, param.preferredTanMedium)
|
||||
|
||||
/* First dialog: Get user's basic data like BPD, customer system ID and her TAN methods */
|
||||
|
||||
val newUserInfoResponse = config.jobExecutor.retrieveBasicDataLikeUsersTanMethods(context, parameter.preferredTanMethods, parameter.preferredTanMedium)
|
||||
val newUserInfoResponse = config.jobExecutor.retrieveBasicDataLikeUsersTanMethods(context)
|
||||
|
||||
if (newUserInfoResponse.successful == false) { // bank parameter (FinTS server address, ...) already seem to be wrong
|
||||
return AddAccountResponse(context, newUserInfoResponse)
|
||||
|
@ -54,7 +52,7 @@ open class FinTsClientDeprecated(
|
|||
/* 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 */
|
||||
|
||||
return addAccountGetAccountsAndTransactions(context, parameter)
|
||||
return addAccountGetAccountsAndTransactions(context, param)
|
||||
}
|
||||
|
||||
protected open suspend fun addAccountGetAccountsAndTransactions(context: JobContext, parameter: AddAccountParameter): AddAccountResponse {
|
||||
|
@ -120,11 +118,11 @@ open class FinTsClientDeprecated(
|
|||
return GetAccountTransactionsParameter(bank, account, account.supportsRetrievingBalance, ninetyDaysAgo, abortIfTanIsRequired = true)
|
||||
}
|
||||
|
||||
open suspend fun getAccountTransactionsAsync(parameter: GetAccountTransactionsParameter): GetAccountTransactionsResponse {
|
||||
open suspend fun getAccountTransactionsAsync(param: GetAccountTransactionsParameter): GetAccountTransactionsResponse {
|
||||
|
||||
val context = JobContext(JobContextType.GetTransactions, this.callback, config, parameter.bank, parameter.account)
|
||||
val context = JobContext(JobContextType.GetTransactions, this.callback, config, param.bank, param.account)
|
||||
|
||||
return config.jobExecutor.getTransactionsAsync(context, parameter)
|
||||
return config.jobExecutor.getTransactionsAsync(context, param)
|
||||
}
|
||||
|
||||
|
||||
|
@ -137,7 +135,7 @@ open class FinTsClientDeprecated(
|
|||
}
|
||||
|
||||
|
||||
open suspend fun changeTanMedium(newActiveTanMedium: TanGeneratorTanMedium, bank: BankData): FinTsClientResponse {
|
||||
open suspend fun changeTanMedium(newActiveTanMedium: TanMedium, bank: BankData): FinTsClientResponse {
|
||||
val context = JobContext(JobContextType.ChangeTanMedium, this.callback, config, bank)
|
||||
|
||||
val response = config.jobExecutor.changeTanMedium(context, newActiveTanMedium)
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package net.codinux.banking.fints
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
||||
import net.codinux.banking.fints.extensions.*
|
||||
import net.codinux.log.logger
|
||||
import net.codinux.banking.fints.messages.MessageBuilder
|
||||
import net.codinux.banking.fints.messages.MessageBuilderResult
|
||||
|
@ -18,9 +20,9 @@ import net.codinux.banking.fints.response.segments.*
|
|||
import net.codinux.banking.fints.tan.FlickerCodeDecoder
|
||||
import net.codinux.banking.fints.tan.TanImageDecoder
|
||||
import net.codinux.banking.fints.util.TanMethodSelector
|
||||
import net.codinux.banking.fints.extensions.minusDays
|
||||
import net.codinux.banking.fints.extensions.todayAtEuropeBerlin
|
||||
import net.codinux.banking.fints.extensions.todayAtSystemDefaultTimeZone
|
||||
import net.codinux.log.Log
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
||||
/**
|
||||
|
@ -72,8 +74,7 @@ open class FinTsJobExecutor(
|
|||
*
|
||||
* Be aware this method resets BPD, UPD and selected TAN method!
|
||||
*/
|
||||
open suspend fun retrieveBasicDataLikeUsersTanMethods(context: JobContext, preferredTanMethods: List<TanMethodType>? = null, preferredTanMedium: String? = null,
|
||||
closeDialog: Boolean = false): BankResponse {
|
||||
open suspend fun retrieveBasicDataLikeUsersTanMethods(context: JobContext): BankResponse {
|
||||
val bank = context.bank
|
||||
|
||||
// just to ensure settings are in its initial state and that bank sends us bank parameter (BPD),
|
||||
|
@ -89,7 +90,7 @@ open class FinTsJobExecutor(
|
|||
bank.resetSelectedTanMethod()
|
||||
|
||||
// this is the only case where Einschritt-TAN-Verfahren is accepted: to get user's TAN methods
|
||||
context.startNewDialog(closeDialog, versionOfSecurityProcedure = VersionDesSicherheitsverfahrens.Version_1)
|
||||
context.startNewDialog(versionOfSecurityProcedure = VersionDesSicherheitsverfahrens.Version_1)
|
||||
|
||||
val message = messageBuilder.createInitDialogMessage(context)
|
||||
|
||||
|
@ -102,12 +103,10 @@ open class FinTsJobExecutor(
|
|||
if (bank.tanMethodsAvailableForUser.isEmpty()) { // could not retrieve supported tan methods for user
|
||||
return getTanMethodsResponse
|
||||
} else {
|
||||
getUsersTanMethod(context, preferredTanMethods)
|
||||
getUsersTanMethod(context)
|
||||
|
||||
if (bank.isTanMethodSelected == false) {
|
||||
return getTanMethodsResponse
|
||||
} else if (bank.tanMedia.isEmpty() && isJobSupported(bank, CustomerSegmentId.TanMediaList)) { // tan media not retrieved yet
|
||||
getTanMediaList(context, TanMedienArtVersion.Alle, TanMediumKlasse.AlleMedien, preferredTanMedium)
|
||||
if (bank.isTanMethodSelected && bank.tanMedia.isEmpty() && bank.tanMethodsAvailableForUser.any { it.nameOfTanMediumRequired } && isJobSupported(bank, CustomerSegmentId.TanMediaList)) { // tan media not retrieved yet
|
||||
getTanMediaList(context, TanMedienArtVersion.Alle, TanMediumKlasse.AlleMedien, context.preferredTanMedium)
|
||||
|
||||
return getTanMethodsResponse // TODO: judge if bank requires selecting TAN media and if though evaluate getTanMediaListResponse
|
||||
} else {
|
||||
|
@ -146,6 +145,7 @@ open class FinTsJobExecutor(
|
|||
return BankResponse(true, internalError = "Die TAN Verfahren der Bank konnten nicht ermittelt werden") // TODO: translate
|
||||
} else {
|
||||
bank.tanMethodsAvailableForUser = bank.tanMethodsSupportedByBank
|
||||
.filterNot { context.tanMethodsNotSupportedByApplication.contains(it.type) }
|
||||
|
||||
val didSelectTanMethod = getUsersTanMethod(context)
|
||||
|
||||
|
@ -202,6 +202,29 @@ open class FinTsJobExecutor(
|
|||
var balance: Money? = balanceResponse?.getFirstSegmentById<BalanceSegment>(InstituteSegmentId.Balance)?.let {
|
||||
Money(it.balance, it.currency)
|
||||
}
|
||||
|
||||
// TODO: for larger portfolios there can be a Aufsetzpunkt, but for balances we currently do not support sending multiple messages
|
||||
val statementOfHoldings = balanceResponse?.getFirstSegmentById<SecuritiesAccountBalanceSegment>(InstituteSegmentId.SecuritiesAccountBalance)?.let {
|
||||
val statementOfHoldings = it.statementOfHoldings
|
||||
val statementOfHolding = statementOfHoldings.firstOrNull { it.totalBalance != null }
|
||||
if (statementOfHolding != null) {
|
||||
balance = Money(statementOfHolding.totalBalance!!, statementOfHolding.currency ?: Currency.DefaultCurrencyCode)
|
||||
}
|
||||
statementOfHoldings
|
||||
} ?: emptyList()
|
||||
|
||||
if (parameter.account.supportsRetrievingAccountTransactions == false) {
|
||||
if (balanceResponse == null) {
|
||||
return GetAccountTransactionsResponse(context, BankResponse(false, "Balance could not be retrieved"), RetrievedAccountData.unsuccessful(parameter.account))
|
||||
} else {
|
||||
val successful = balance != null || balanceResponse.tanRequiredButWeWereToldToAbortIfSo
|
||||
val retrievedData = RetrievedAccountData(parameter.account, successful, balance, emptyList(), emptyList(), statementOfHoldings, Instant.nowExt(), null, null, balanceResponse?.internalError)
|
||||
|
||||
return GetAccountTransactionsResponse(context, balanceResponse, retrievedData)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val bookedTransactions = mutableSetOf<AccountTransaction>()
|
||||
val unbookedTransactions = mutableSetOf<Any>()
|
||||
|
||||
|
@ -217,27 +240,30 @@ open class FinTsJobExecutor(
|
|||
context.bank, parameter.account)
|
||||
|
||||
bookedTransactions.addAll(chunkTransaction)
|
||||
remainingMt940String = remainder
|
||||
remainingMt940String = remainder ?: ""
|
||||
|
||||
parameter.retrievedChunkListener?.invoke(bookedTransactions)
|
||||
}
|
||||
|
||||
response.getFirstSegmentById<ReceivedCreditCardTransactionsAndBalance>(InstituteSegmentId.CreditCardTransactions)?.let { creditCardTransactionsSegment ->
|
||||
balance = Money(creditCardTransactionsSegment.balance.amount, creditCardTransactionsSegment.balance.currency ?: "EUR")
|
||||
bookedTransactions.addAll(creditCardTransactionsSegment.transactions.map { AccountTransaction(parameter.account, it.amount, it.description, it.bookingDate, it.transactionDescriptionBase ?: "", null, null, "", it.valueDate) })
|
||||
bookedTransactions.addAll(creditCardTransactionsSegment.transactions.map { AccountTransaction(parameter.account, it.amount, it.description, it.bookingDate, it.valueDate, it.transactionDescriptionBase ?: "", null, null) })
|
||||
}
|
||||
}
|
||||
|
||||
val startTime = Instant.nowExt()
|
||||
|
||||
val response = getAndHandleResponseForMessage(context, message)
|
||||
|
||||
closeDialog(context)
|
||||
|
||||
val successful = response.tanRequiredButWeWereToldToAbortIfSo
|
||||
|| (response.successful && (parameter.alsoRetrieveBalance == false || balance != null))
|
||||
|| (response.successful && (parameter.alsoRetrieveBalance == false || balance != null))
|
||||
|| (parameter.account.supportsRetrievingAccountTransactions == false && balance != null)
|
||||
val fromDate = parameter.fromDate
|
||||
?: parameter.account.countDaysForWhichTransactionsAreKept?.let { LocalDate.todayAtSystemDefaultTimeZone().minusDays(it) }
|
||||
?: parameter.account.serverTransactionsRetentionDays?.let { LocalDate.todayAtSystemDefaultTimeZone().minusDays(it) }
|
||||
?: bookedTransactions.minByOrNull { it.valueDate }?.valueDate
|
||||
val retrievedData = RetrievedAccountData(parameter.account, successful, balance, bookedTransactions, unbookedTransactions, fromDate, parameter.toDate ?: LocalDate.todayAtEuropeBerlin(), response.internalError)
|
||||
val retrievedData = RetrievedAccountData(parameter.account, successful, balance, bookedTransactions, unbookedTransactions, statementOfHoldings, startTime, fromDate, parameter.toDate ?: LocalDate.todayAtEuropeBerlin(), response.internalError)
|
||||
|
||||
return GetAccountTransactionsResponse(context, response, retrievedData,
|
||||
if (parameter.maxCountEntries != null) parameter.isSettingMaxCountEntriesAllowedByBank else null)
|
||||
|
@ -315,7 +341,7 @@ open class FinTsJobExecutor(
|
|||
}
|
||||
|
||||
|
||||
open suspend fun changeTanMedium(context: JobContext, newActiveTanMedium: TanGeneratorTanMedium): BankResponse {
|
||||
open suspend fun changeTanMedium(context: JobContext, newActiveTanMedium: TanMedium): BankResponse {
|
||||
val bank = context.bank
|
||||
|
||||
if (bank.changeTanMediumParameters?.enteringAtcAndTanRequired == true) {
|
||||
|
@ -332,7 +358,7 @@ open class FinTsJobExecutor(
|
|||
}
|
||||
}
|
||||
|
||||
protected open suspend fun sendChangeTanMediumMessage(context: JobContext, newActiveTanMedium: TanGeneratorTanMedium, enteredAtc: EnterTanGeneratorAtcResult?): BankResponse {
|
||||
protected open suspend fun sendChangeTanMediumMessage(context: JobContext, newActiveTanMedium: TanMedium, enteredAtc: EnterTanGeneratorAtcResult?): BankResponse {
|
||||
|
||||
return sendMessageInNewDialogAndHandleResponse(context, null, true) {
|
||||
messageBuilder.createChangeTanMediumMessage(context, newActiveTanMedium, enteredAtc?.tan, enteredAtc?.atc)
|
||||
|
@ -375,20 +401,36 @@ open class FinTsJobExecutor(
|
|||
protected open suspend fun handleEnteringTanRequired(context: JobContext, tanResponse: TanResponse, response: BankResponse): BankResponse {
|
||||
// on all platforms run on Dispatchers.Main, but on iOS skip this (or wrap in withContext(Dispatchers.IO) )
|
||||
// val enteredTanResult = GlobalScope.async {
|
||||
val tanChallenge = createTanChallenge(tanResponse, modelMapper.mapToActionRequiringTan(context.type), context.bank, context.account)
|
||||
val tanChallenge = createTanChallenge(tanResponse, modelMapper.mapToActionRequiringTan(context.type), context.bank, context.account)
|
||||
|
||||
context.callback.enterTan(tanChallenge)
|
||||
context.callback.enterTan(tanChallenge)
|
||||
|
||||
while (tanChallenge.enterTanResult == null) {
|
||||
delay(250)
|
||||
mayRetrieveAutomaticallyIfUserEnteredDecoupledTan(context, tanChallenge, tanResponse)
|
||||
|
||||
mayRetrieveAutomaticallyIfUserEnteredDecoupledTan(context, tanChallenge, tanResponse)
|
||||
var invocationCount = 0 // TODO: remove again
|
||||
|
||||
// TODO: add a timeout of e.g. 30 min
|
||||
while (tanChallenge.isEnteringTanDone == false) {
|
||||
delay(500)
|
||||
|
||||
if (++invocationCount % 10 == 0) {
|
||||
Log.info { "Waiting for TAN input invocation count: $invocationCount" }
|
||||
}
|
||||
|
||||
val now = Instant.nowExt()
|
||||
if ((tanChallenge.tanExpirationTime != null && now > tanChallenge.tanExpirationTime) ||
|
||||
// most TANs a valid 5 - 15 minutes. So terminate wait process after that time
|
||||
(tanChallenge.tanExpirationTime == null && now > tanChallenge.challengeCreationTimestamp.plusMinutes(15))) {
|
||||
if (tanChallenge.isEnteringTanDone == false) {
|
||||
Log.info { "Terminating waiting for TAN input" } // TODO: remove again
|
||||
|
||||
tanChallenge.tanExpired()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val enteredTanResult = tanChallenge.enterTanResult!!
|
||||
// }
|
||||
|
||||
return handleEnterTanResult(context, enteredTanResult, tanResponse, response)
|
||||
}
|
||||
|
@ -402,27 +444,82 @@ open class FinTsJobExecutor(
|
|||
return when (tanMethod.type) {
|
||||
TanMethodType.ChipTanFlickercode ->
|
||||
FlickerCodeTanChallenge(
|
||||
FlickerCodeDecoder().decodeChallenge(challenge, tanMethod.hhdVersion ?: HHDVersion.HHD_1_4), // HHD 1.4 is currently the most used version
|
||||
forAction, messageToShowToUser, challenge, tanMethod, tanResponse.tanMediaIdentifier, bank, account)
|
||||
FlickerCodeDecoder().decodeChallenge(challenge, tanMethod.hhdVersion ?: getFallbackHhdVersion(challenge)),
|
||||
forAction, messageToShowToUser, challenge, tanMethod, tanResponse.tanMediaIdentifier, bank, account, tanResponse.tanExpirationTime)
|
||||
|
||||
TanMethodType.ChipTanQrCode, TanMethodType.ChipTanPhotoTanMatrixCode,
|
||||
TanMethodType.QrCode, TanMethodType.photoTan ->
|
||||
ImageTanChallenge(TanImageDecoder().decodeChallenge(challenge), forAction, messageToShowToUser, challenge, tanMethod, tanResponse.tanMediaIdentifier, bank, account)
|
||||
ImageTanChallenge(TanImageDecoder().decodeChallenge(challenge), forAction, messageToShowToUser, challenge, tanMethod, tanResponse.tanMediaIdentifier, bank, account, tanResponse.tanExpirationTime)
|
||||
|
||||
else -> TanChallenge(forAction, messageToShowToUser, challenge, tanMethod, tanResponse.tanMediaIdentifier, bank, account)
|
||||
else -> TanChallenge(forAction, messageToShowToUser, challenge, tanMethod, tanResponse.tanMediaIdentifier, bank, account, tanResponse.tanExpirationTime)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun mayRetrieveAutomaticallyIfUserEnteredDecoupledTan(context: JobContext, tanChallenge: TanChallenge, tanResponse: TanResponse) {
|
||||
protected open fun getFallbackHhdVersion(challenge: String): HHDVersion {
|
||||
if (challenge.length <= 35) { // is this true in all circumstances?
|
||||
return HHDVersion.HHD_1_3
|
||||
}
|
||||
|
||||
return HHDVersion.HHD_1_4 // HHD 1.4 is currently the most used version
|
||||
}
|
||||
|
||||
protected open suspend fun mayRetrieveAutomaticallyIfUserEnteredDecoupledTan(context: JobContext, tanChallenge: TanChallenge, tanResponse: TanResponse) {
|
||||
context.bank.selectedTanMethod.decoupledParameters?.let { decoupledTanMethodParameters ->
|
||||
if (tanResponse.tanProcess == TanProcess.AppTan && decoupledTanMethodParameters.periodicStateRequestsAllowed) {
|
||||
automaticallyRetrieveIfUserEnteredDecoupledTan(context, tanChallenge)
|
||||
if (decoupledTanMethodParameters.periodicStateRequestsAllowed) {
|
||||
val responseAfterApprovingDecoupledTan =
|
||||
automaticallyRetrieveIfUserEnteredDecoupledTan(context, tanChallenge, tanResponse, decoupledTanMethodParameters)
|
||||
|
||||
if (responseAfterApprovingDecoupledTan != null) {
|
||||
tanChallenge.userApprovedDecoupledTan(responseAfterApprovingDecoupledTan)
|
||||
} else {
|
||||
tanChallenge.userDidNotEnterTan()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun automaticallyRetrieveIfUserEnteredDecoupledTan(context: JobContext, tanChallenge: TanChallenge) {
|
||||
protected open suspend fun automaticallyRetrieveIfUserEnteredDecoupledTan(context: JobContext, tanChallenge: TanChallenge, tanResponse: TanResponse, parameters: DecoupledTanMethodParameters): BankResponse? {
|
||||
log.info { "automaticallyRetrieveIfUserEnteredDecoupledTan() called for $tanChallenge" }
|
||||
|
||||
delay(max(5, parameters.initialDelayInSecondsForStateRequest).seconds)
|
||||
|
||||
var iteration = 0
|
||||
val minWaitTime = when {
|
||||
parameters.maxNumberOfStateRequests <= 10 -> 30
|
||||
parameters.maxNumberOfStateRequests <= 24 -> 10
|
||||
else -> 3
|
||||
}
|
||||
val delayForNextStateRequest = max(minWaitTime, parameters.delayInSecondsForNextStateRequest).seconds
|
||||
|
||||
while (iteration < parameters.maxNumberOfStateRequests) {
|
||||
try {
|
||||
val message = messageBuilder.createDecoupledTanStatusMessage(context, tanResponse)
|
||||
|
||||
val response = getAndHandleResponseForMessage(context, message)
|
||||
|
||||
val tanFeedbacks = response.segmentFeedbacks.filter { it.referenceSegmentNumber == MessageBuilder.SignedMessagePayloadFirstSegmentNumber }
|
||||
if (tanFeedbacks.isNotEmpty()) {
|
||||
// new feedback code for Decoupled TAN: 0900 Sicherheitsfreigabe gültig
|
||||
// Sparkasse responds for pushTan with: HIRMS:4:2:3+0020::Der Auftrag wurde ausgeführt.+0020::Die gebuchten Umsätze wurden übermittelt.'
|
||||
val isTanApproved = tanFeedbacks.any { it.feedbacks.any { it.responseCode == 900 || it.responseCode == 20 } }
|
||||
if (isTanApproved) {
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
iteration++
|
||||
// sometimes delayInSecondsForNextStateRequests is only 1 or 2 seconds, that's too fast i think
|
||||
delay(delayForNextStateRequest)
|
||||
} catch (e: Throwable) {
|
||||
log.error(e) { "Could not check status of Decoupled TAN" }
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
tanChallenge.tanExpired()
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
protected open suspend fun handleEnterTanResult(context: JobContext, enteredTanResult: EnterTanResult, tanResponse: TanResponse,
|
||||
|
@ -430,19 +527,18 @@ open class FinTsJobExecutor(
|
|||
|
||||
if (enteredTanResult.changeTanMethodTo != null) {
|
||||
return handleUserAsksToChangeTanMethodAndResendLastMessage(context, enteredTanResult.changeTanMethodTo)
|
||||
}
|
||||
else if (enteredTanResult.changeTanMediumTo is TanGeneratorTanMedium) {
|
||||
} else if (enteredTanResult.changeTanMediumTo != null) {
|
||||
return handleUserAsksToChangeTanMediumAndResendLastMessage(context, enteredTanResult.changeTanMediumTo,
|
||||
enteredTanResult.changeTanMediumResultCallback)
|
||||
}
|
||||
else if (enteredTanResult.enteredTan == null) {
|
||||
} else if (enteredTanResult.userApprovedDecoupledTan == true && enteredTanResult.responseAfterApprovingDecoupledTan != null) {
|
||||
return enteredTanResult.responseAfterApprovingDecoupledTan
|
||||
} else if (enteredTanResult.enteredTan == null) {
|
||||
// i tried to send a HKTAN with cancelJob = true but then i saw there are no tan methods that support cancellation (at least not at my bank)
|
||||
// but it's not required anyway, tan times out after some time. Simply don't respond anything and close dialog
|
||||
response.tanRequiredButUserDidNotEnterOne = true
|
||||
|
||||
return response
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
return sendTanToBank(context, enteredTanResult.enteredTan, tanResponse)
|
||||
}
|
||||
}
|
||||
|
@ -466,7 +562,7 @@ open class FinTsJobExecutor(
|
|||
return resendMessageInNewDialog(context, lastCreatedMessage)
|
||||
}
|
||||
|
||||
protected open suspend fun handleUserAsksToChangeTanMediumAndResendLastMessage(context: JobContext, changeTanMediumTo: TanGeneratorTanMedium,
|
||||
protected open suspend fun handleUserAsksToChangeTanMediumAndResendLastMessage(context: JobContext, changeTanMediumTo: TanMedium,
|
||||
changeTanMediumResultCallback: ((FinTsClientResponse) -> Unit)?): BankResponse {
|
||||
|
||||
val lastCreatedMessage = context.dialog.currentMessage
|
||||
|
@ -494,7 +590,8 @@ open class FinTsJobExecutor(
|
|||
|
||||
val initDialogResponse = initDialogWithStrongCustomerAuthentication(context)
|
||||
|
||||
if (initDialogResponse.successful == false) {
|
||||
// if lastCreatedMessage was a dialog init message, there's no need to send this message again, we just initialized a new dialog in initDialogWithStrongCustomerAuthentication()
|
||||
if (initDialogResponse.successful == false || lastCreatedMessage.isDialogInitMessage()) {
|
||||
return initDialogResponse
|
||||
} else {
|
||||
val newMessage = messageBuilder.rebuildMessage(context, lastCreatedMessage)
|
||||
|
@ -567,7 +664,7 @@ open class FinTsJobExecutor(
|
|||
|
||||
protected open suspend fun initDialogWithStrongCustomerAuthenticationAfterSuccessfulPreconditionChecks(context: JobContext): BankResponse {
|
||||
|
||||
context.startNewDialog(false) // don't know if it's ok for all invocations of this method to set closeDialog to false (was actually only set in getAccounts())
|
||||
context.startNewDialog() // don't know if it's ok for all invocations of this method to set closeDialog to false (was actually only set in getAccounts())
|
||||
|
||||
val message = messageBuilder.createInitDialogMessage(context)
|
||||
|
||||
|
@ -643,7 +740,7 @@ open class FinTsJobExecutor(
|
|||
return BankResponse(true, noTanMethodSelected = noTanMethodSelected, internalError = errorMessage)
|
||||
}
|
||||
|
||||
open suspend fun getUsersTanMethod(context: JobContext, preferredTanMethods: List<TanMethodType>? = null): Boolean {
|
||||
open suspend fun getUsersTanMethod(context: JobContext): Boolean {
|
||||
val bank = context.bank
|
||||
|
||||
if (bank.tanMethodsAvailableForUser.size == 1) { // user has only one TAN method -> set it and we're done
|
||||
|
@ -651,13 +748,13 @@ open class FinTsJobExecutor(
|
|||
return true
|
||||
}
|
||||
else {
|
||||
tanMethodSelector.findPreferredTanMethod(bank.tanMethodsAvailableForUser, preferredTanMethods)?.let {
|
||||
tanMethodSelector.findPreferredTanMethod(bank.tanMethodsAvailableForUser, context.preferredTanMethods, context.tanMethodsNotSupportedByApplication)?.let {
|
||||
bank.selectedTanMethod = it
|
||||
return true
|
||||
}
|
||||
|
||||
// we know user's supported tan methods, now ask user which one to select
|
||||
val suggestedTanMethod = tanMethodSelector.getSuggestedTanMethod(bank.tanMethodsAvailableForUser)
|
||||
val suggestedTanMethod = tanMethodSelector.getSuggestedTanMethod(bank.tanMethodsAvailableForUser, context.tanMethodsNotSupportedByApplication)
|
||||
|
||||
val selectedTanMethod = context.callback.askUserForTanMethod(bank.tanMethodsAvailableForUser, suggestedTanMethod)
|
||||
|
||||
|
@ -678,14 +775,14 @@ open class FinTsJobExecutor(
|
|||
|
||||
protected open fun updateBankAndCustomerDataIfResponseSuccessful(context: JobContext, response: BankResponse) {
|
||||
if (response.successful) {
|
||||
updateBankAndCustomerData(context.bank, response)
|
||||
updateBankAndCustomerData(context.bank, response, context)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun updateBankAndCustomerData(bank: BankData, response: BankResponse) {
|
||||
protected open fun updateBankAndCustomerData(bank: BankData, response: BankResponse, context: JobContext) {
|
||||
updateBankData(bank, response)
|
||||
|
||||
modelMapper.updateCustomerData(bank, response)
|
||||
modelMapper.updateCustomerData(bank, response, context)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package net.codinux.banking.fints.callback
|
||||
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
|
||||
import net.codinux.banking.fints.model.*
|
||||
|
||||
|
||||
|
@ -25,7 +25,7 @@ interface FinTsClientCallback {
|
|||
*
|
||||
* If you do not support entering TAN generator ATC, return [EnterTanGeneratorAtcResult.userDidNotEnterAtc]
|
||||
*/
|
||||
suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanGeneratorTanMedium): EnterTanGeneratorAtcResult
|
||||
suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanMedium): EnterTanGeneratorAtcResult
|
||||
|
||||
/**
|
||||
* Gets fired when a FinTS message get sent to bank server, a FinTS message is received from bank server or an error occurred.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package net.codinux.banking.fints.callback
|
||||
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
|
||||
import net.codinux.banking.fints.model.*
|
||||
|
||||
|
||||
|
@ -14,7 +14,7 @@ open class NoOpFinTsClientCallback : FinTsClientCallback {
|
|||
return tanChallenge.userDidNotEnterTan()
|
||||
}
|
||||
|
||||
override suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanGeneratorTanMedium): EnterTanGeneratorAtcResult {
|
||||
override suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanMedium): EnterTanGeneratorAtcResult {
|
||||
return EnterTanGeneratorAtcResult.userDidNotEnterAtc()
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
package net.codinux.banking.fints.callback
|
||||
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
|
||||
import net.codinux.banking.fints.model.*
|
||||
|
||||
|
||||
open class SimpleFinTsClientCallback(
|
||||
protected open val askUserForTanMethod: ((supportedTanMethods: List<TanMethod>, suggestedTanMethod: TanMethod?) -> TanMethod?)? = null,
|
||||
protected open val messageLogAdded: ((MessageLogEntry) -> Unit)? = null,
|
||||
protected open val enterTanGeneratorAtc: ((bank: BankData, tanMedium: TanGeneratorTanMedium) -> EnterTanGeneratorAtcResult)? = null,
|
||||
protected open val enterTanGeneratorAtc: ((bank: BankData, tanMedium: TanMedium) -> EnterTanGeneratorAtcResult)? = null,
|
||||
protected open val enterTan: ((tanChallenge: TanChallenge) -> Unit)? = null
|
||||
) : FinTsClientCallback {
|
||||
|
||||
|
@ -25,7 +25,7 @@ open class SimpleFinTsClientCallback(
|
|||
enterTan?.invoke(tanChallenge) ?: run { tanChallenge.userDidNotEnterTan() }
|
||||
}
|
||||
|
||||
override suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanGeneratorTanMedium): EnterTanGeneratorAtcResult {
|
||||
override suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanMedium): EnterTanGeneratorAtcResult {
|
||||
return enterTanGeneratorAtc?.invoke(bank, tanMedium) ?: EnterTanGeneratorAtcResult.userDidNotEnterAtc()
|
||||
}
|
||||
|
||||
|
|
|
@ -26,8 +26,13 @@ data class FinTsClientOptions(
|
|||
* Defaults to true.
|
||||
*/
|
||||
val removeSensitiveDataFromMessageLog: Boolean = true,
|
||||
|
||||
val appendFinTsMessagesToLog: Boolean = false,
|
||||
|
||||
val closeDialogs: Boolean = true,
|
||||
|
||||
val version: String = "1.0.0", // TODO: get version dynamically
|
||||
val productName: String = "15E53C26816138699C7B6A3E8"
|
||||
val productName: String = "15E53C26816138699C7B6A3E8" // TODO: extract constant // TODO: get product number for fints4k and Bankmeister (if we stick with that name)
|
||||
) {
|
||||
|
||||
val product: ProductData by lazy { ProductData(productName, version) }
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
package net.codinux.banking.fints.extensions
|
||||
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
|
||||
// should actually be named `now()`, but that name is already shadowed by deprecated Instant.Companion.now() method
|
||||
fun Instant.Companion.nowExt(): Instant = Clock.System.now()
|
||||
|
||||
fun Instant.plusMinutes(minutes: Int) = this.plus(minutes, DateTimeUnit.MINUTE)
|
|
@ -12,7 +12,7 @@ fun LocalDate.Companion.todayAtSystemDefaultTimeZone(): LocalDate {
|
|||
}
|
||||
|
||||
fun LocalDate.Companion.todayAtEuropeBerlin(): LocalDate {
|
||||
return nowAt(TimeZone.europeBerlin)
|
||||
return nowAt(TimeZone.EuropeBerlin)
|
||||
}
|
||||
|
||||
@JsName("nowAtForDate")
|
||||
|
|
|
@ -9,9 +9,9 @@ fun LocalDateTime.Companion.nowAtUtc(): LocalDateTime {
|
|||
}
|
||||
|
||||
fun LocalDateTime.Companion.nowAtEuropeBerlin(): LocalDateTime {
|
||||
return nowAt(TimeZone.europeBerlin)
|
||||
return nowAt(TimeZone.EuropeBerlin)
|
||||
}
|
||||
|
||||
fun LocalDateTime.Companion.nowAt(timeZone: TimeZone): LocalDateTime {
|
||||
return Clock.System.now().toLocalDateTime(timeZone)
|
||||
return Instant.nowExt().toLocalDateTime(timeZone)
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
package net.codinux.banking.fints.extensions
|
||||
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.random.Random
|
||||
|
||||
|
||||
fun randomWithSeed(): Random = Random(randomSeed())
|
||||
|
||||
fun randomSeed(): Long {
|
||||
return Clock.System.now().nanosecondsOfSecond.toLong() + Clock.System.now().toEpochMilliseconds()
|
||||
return Instant.nowExt().nanosecondsOfSecond.toLong() + Instant.nowExt().toEpochMilliseconds()
|
||||
}
|
|
@ -3,5 +3,5 @@ package net.codinux.banking.fints.extensions
|
|||
import kotlinx.datetime.TimeZone
|
||||
|
||||
|
||||
val TimeZone.Companion.europeBerlin: TimeZone
|
||||
val TimeZone.Companion.EuropeBerlin: TimeZone
|
||||
get() = TimeZone.of("Europe/Berlin")
|
|
@ -5,6 +5,6 @@ import kotlin.reflect.KClass
|
|||
|
||||
interface IMessageLogAppender {
|
||||
|
||||
fun logError(loggingClass: KClass<*>, message: String, e: Exception? = null)
|
||||
fun logError(loggingClass: KClass<*>, message: String, e: Throwable? = null)
|
||||
|
||||
}
|
|
@ -8,10 +8,12 @@ import net.codinux.banking.fints.model.JobContextType
|
|||
|
||||
class MessageContext(
|
||||
val jobType: JobContextType,
|
||||
val dialogType: MessageType,
|
||||
val messageType: MessageType,
|
||||
val jobNumber: Int,
|
||||
val dialogNumber: Int,
|
||||
val messageNumber: Int,
|
||||
val bank: BankData,
|
||||
val account: AccountData?
|
||||
)
|
||||
) {
|
||||
override fun toString() = "${jobNumber}_${dialogNumber}_$messageNumber ${bank.bankCode} $jobType $messageType"
|
||||
}
|
|
@ -37,50 +37,48 @@ open class MessageLogCollector(
|
|||
|
||||
// in either case remove sensitive data after response is parsed as otherwise some information like account holder name and accounts may is not set yet on BankData
|
||||
open val messageLog: List<MessageLogEntry>
|
||||
// safe CPU cycles by only formatting and removing sensitive data if messageLog is really requested
|
||||
get() = _messageLog.map { MessageLogEntry(it.type, it.context, it.messageTrace, createMessageForLog(it), it.error, it.parsedSegments, it.time) }
|
||||
// safe CPU cycles by only removing sensitive data if messageLog is really requested
|
||||
get() = _messageLog.map {
|
||||
val message = createMessageForLog(it)
|
||||
val messageWithoutSensitiveData = if (options.removeSensitiveDataFromMessageLog) {
|
||||
safelyRemoveSensitiveDataFromMessage(message, it.context.bank)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
|
||||
private fun createMessageForLog(logEntry: MessageLogEntry): String {
|
||||
val message = if (logEntry.type == MessageLogEntryType.Error) {
|
||||
MessageLogEntry(it.type, it.context, it.messageTrace, message, messageWithoutSensitiveData, it.error, it.parsedSegments, it.time)
|
||||
}
|
||||
|
||||
private fun createMessageForLog(logEntry: MessageLogEntry): String =
|
||||
if (logEntry.type == MessageLogEntryType.Error) {
|
||||
logEntry.message + (if (logEntry.error != null) NewLine + getStackTrace(logEntry.error!!) else "")
|
||||
} else {
|
||||
logEntry.message
|
||||
}
|
||||
|
||||
return if (options.removeSensitiveDataFromMessageLog) {
|
||||
safelyRemoveSensitiveDataFromMessage(message, logEntry.context.bank)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
open fun addMessageLog(type: MessageLogEntryType, message: String, context: MessageContext, parsedSegments: List<ReceivedSegment> = emptyList()) {
|
||||
val messageTrace = createMessageTraceString(type, context)
|
||||
|
||||
val prettyPrintMessage = if (options.collectMessageLog || options.fireCallbackOnMessageLogs || log.isDebugEnabled) { // only use CPU cycles if message will ever be used / displayed
|
||||
prettyPrintFinTsMessage(message)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
val prettyPrintMessage = prettyPrintMessageIfRequired(message)
|
||||
|
||||
log.debug { "$messageTrace\n$prettyPrintMessage" }
|
||||
|
||||
addMessageLogEntry(type, context, messageTrace, prettyPrintMessage, null, parsedSegments)
|
||||
}
|
||||
|
||||
open fun logError(loggingClass: KClass<*>, message: String, context: MessageContext, e: Exception? = null) {
|
||||
open fun logError(loggingClass: KClass<*>, message: String, context: MessageContext, e: Throwable? = null) {
|
||||
val type = MessageLogEntryType.Error
|
||||
val messageTrace = createMessageTraceString(type, context)
|
||||
val prettyPrintMessage = prettyPrintFinTsMessage(message) // error messages almost always get logged / displayed -> pretty print
|
||||
|
||||
LoggerFactory.getLogger(loggingClass).error(e) { messageTrace + message }
|
||||
LoggerFactory.getLogger(loggingClass).error(e) { "$messageTrace\n$prettyPrintMessage" }
|
||||
|
||||
addMessageLogEntry(type, context, messageTrace, message, e)
|
||||
addMessageLogEntry(type, context, messageTrace, prettyPrintMessage, e)
|
||||
}
|
||||
|
||||
protected open fun addMessageLogEntry(type: MessageLogEntryType, context: MessageContext, messageTrace: String, message: String, error: Throwable? = null, parsedSegments: List<ReceivedSegment> = emptyList()) {
|
||||
if (options.collectMessageLog || options.fireCallbackOnMessageLogs) {
|
||||
val newEntry = MessageLogEntry(type, context, messageTrace, message, error, parsedSegments)
|
||||
val newEntry = MessageLogEntry(type, context, messageTrace, message, null, error, parsedSegments)
|
||||
|
||||
if (options.collectMessageLog) {
|
||||
_messageLog.add(newEntry)
|
||||
|
@ -97,7 +95,7 @@ open class MessageLogCollector(
|
|||
return "${twoDigits(context.jobNumber)}_${twoDigits(context.dialogNumber)}_${twoDigits(context.messageNumber)}_" +
|
||||
"${context.bank.bankCode}_${context.bank.customerId}" +
|
||||
"${ context.account?.let { "_${it.accountIdentifier}" } ?: "" }_" +
|
||||
"${context.jobType.name}_${context.dialogType.name} " +
|
||||
"${context.jobType.name}_${context.messageType.name} " +
|
||||
"${getMessageTypeString(type)}:"
|
||||
}
|
||||
|
||||
|
@ -113,6 +111,13 @@ open class MessageLogCollector(
|
|||
}
|
||||
}
|
||||
|
||||
protected open fun prettyPrintMessageIfRequired(message: String): String =
|
||||
if (options.collectMessageLog || options.fireCallbackOnMessageLogs || log.isDebugEnabled) { // only use CPU cycles if message will ever be used / displayed
|
||||
prettyPrintFinTsMessage(message)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
|
||||
protected open fun prettyPrintFinTsMessage(message: String): String =
|
||||
finTsUtils.prettyPrintFinTsMessage(message)
|
||||
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
package net.codinux.banking.fints.mapper
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* Be aware that Java DateFormat is not thread safe!
|
||||
*/
|
||||
expect class DateFormatter constructor(pattern: String) {
|
||||
|
||||
fun parseDate(dateString: String): LocalDate?
|
||||
|
||||
}
|
|
@ -22,8 +22,11 @@ open class FinTsModelMapper {
|
|||
protected open val bicFinder = BicFinder()
|
||||
|
||||
|
||||
open fun mapToBankData(param: FinTsClientParameter, finTsServerAddress: String): BankData {
|
||||
return BankData(param.bankCode, param.loginName, param.password, finTsServerAddress, bicFinder.findBic(param.bankCode) ?: "")
|
||||
open fun mapToBankData(param: FinTsClientParameter, finTsServerAddress: String, defaultValues: BankData? = null): BankData {
|
||||
return BankData(
|
||||
param.bankCode, param.loginName, param.password, finTsServerAddress,
|
||||
defaultValues?.bic ?: bicFinder.findBic(param.bankCode) ?: "", defaultValues?.bankName ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
open fun mapToAccountData(credentials: BankAccountIdentifier, param: FinTsClientParameter): AccountData {
|
||||
|
@ -51,7 +54,7 @@ open class FinTsModelMapper {
|
|||
|
||||
open fun map(account: AccountData): BankAccount {
|
||||
return BankAccount(account.accountIdentifier, account.subAccountAttribute, account.iban, account.accountHolderName, map(account.accountType), account.productName,
|
||||
account.currency ?: Currency.DefaultCurrencyCode, account.accountLimit, account.countDaysForWhichTransactionsAreKept, account.isAccountTypeSupportedByApplication,
|
||||
account.currency ?: Currency.DefaultCurrencyCode, account.accountLimit, account.serverTransactionsRetentionDays, account.isAccountTypeSupportedByApplication,
|
||||
account.supportsRetrievingAccountTransactions, account.supportsRetrievingBalance, account.supportsTransferringMoney, account.supportsRealTimeTransfer)
|
||||
}
|
||||
|
||||
|
@ -70,16 +73,34 @@ open class FinTsModelMapper {
|
|||
}
|
||||
}
|
||||
|
||||
open fun map(bank: BankData, retrievedTransactionsResponses: List<GetAccountTransactionsResponse>): CustomerAccount {
|
||||
open fun map(bank: BankData, retrievedTransactionsResponses: List<GetAccountTransactionsResponse>, retrieveTransactionsTo: LocalDate? = null): CustomerAccount {
|
||||
val customerAccount = map(bank)
|
||||
val retrievedData = retrievedTransactionsResponses.mapNotNull { it.retrievedData }
|
||||
|
||||
customerAccount.accounts.forEach { bankAccount ->
|
||||
retrievedData.firstOrNull { it.account.accountIdentifier == bankAccount.identifier }?.let { accountTransactionsResponse ->
|
||||
bankAccount.balance = accountTransactionsResponse.balance ?: Money.Zero
|
||||
bankAccount.retrievedTransactionsFrom = accountTransactionsResponse.retrievedTransactionsFrom
|
||||
bankAccount.retrievedTransactionsTo = accountTransactionsResponse.retrievedTransactionsTo
|
||||
bankAccount.bookedTransactions = map(accountTransactionsResponse)
|
||||
accountTransactionsResponse.balance?.let { balance ->
|
||||
bankAccount.balance = balance
|
||||
}
|
||||
|
||||
if (accountTransactionsResponse.retrievedTransactionsFrom != null && (bankAccount.retrievedTransactionsFrom == null ||
|
||||
accountTransactionsResponse.retrievedTransactionsFrom!! < bankAccount.retrievedTransactionsFrom!!)) {
|
||||
bankAccount.retrievedTransactionsFrom = accountTransactionsResponse.retrievedTransactionsFrom
|
||||
}
|
||||
|
||||
val retrievalTime = accountTransactionsResponse.retrievalTime
|
||||
if (retrieveTransactionsTo == null && (bankAccount.lastAccountUpdateTime == null || bankAccount.lastAccountUpdateTime!! <= retrievalTime || // if retrieveTransactionsTo is set, then we don't retrieve all current transactions -> don't set lastAccountUpdateTime
|
||||
(bankAccount.supportsRetrievingTransactions == false && accountTransactionsResponse.statementOfHoldings.isNotEmpty()))) { // TODO: really check for supportsRetrievingTransactions == false if statementOfHoldings are set? Are there really accounts that support HKWPD and HKKAZ?
|
||||
bankAccount.lastAccountUpdateTime = retrievalTime
|
||||
}
|
||||
|
||||
if (accountTransactionsResponse.bookedTransactions.isNotEmpty()) {
|
||||
bankAccount.bookedTransactions = bankAccount.bookedTransactions.toMutableList().apply {
|
||||
addAll(map(accountTransactionsResponse))
|
||||
}
|
||||
}
|
||||
|
||||
bankAccount.statementOfHoldings = accountTransactionsResponse.statementOfHoldings
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -91,14 +112,28 @@ open class FinTsModelMapper {
|
|||
}
|
||||
|
||||
open fun map(transaction: net.codinux.banking.fints.model.AccountTransaction): AccountTransaction {
|
||||
return AccountTransaction(transaction.amount, transaction.unparsedReference, transaction.bookingDate,
|
||||
transaction.otherPartyName, transaction.otherPartyBankCode, transaction.otherPartyAccountId, transaction.bookingText, transaction.valueDate,
|
||||
transaction.statementNumber, transaction.sequenceNumber, transaction.openingBalance, transaction.closingBalance,
|
||||
transaction.endToEndReference, transaction.customerReference, transaction.mandateReference, transaction.creditorIdentifier, transaction.originatorsIdentificationCode,
|
||||
transaction.compensationAmount, transaction.originalAmount, transaction.sepaReference, transaction.deviantOriginator, transaction.deviantRecipient,
|
||||
transaction.referenceWithNoSpecialType, transaction.primaNotaNumber, transaction.textKeySupplement,
|
||||
transaction.currencyType, transaction.bookingKey, transaction.referenceForTheAccountOwner, transaction.referenceOfTheAccountServicingInstitution, transaction.supplementaryDetails,
|
||||
transaction.transactionReferenceNumber, transaction.relatedReferenceNumber)
|
||||
return AccountTransaction(
|
||||
transaction.amount, transaction.reference,
|
||||
transaction.bookingDate, transaction.valueDate,
|
||||
transaction.otherPartyName, transaction.otherPartyBankId, transaction.otherPartyAccountId,
|
||||
|
||||
transaction.postingText,
|
||||
|
||||
transaction.openingBalance, transaction.closingBalance,
|
||||
|
||||
transaction.statementNumber, transaction.sheetNumber,
|
||||
|
||||
transaction.customerReference, transaction.bankReference, transaction.furtherInformation,
|
||||
|
||||
transaction.endToEndReference, transaction.mandateReference, transaction.creditorIdentifier, transaction.originatorsIdentificationCode,
|
||||
transaction.compensationAmount, transaction.originalAmount, transaction.deviantOriginator, transaction.deviantRecipient,
|
||||
transaction.referenceWithNoSpecialType,
|
||||
|
||||
transaction.journalNumber, transaction.textKeyAddition,
|
||||
transaction.orderReferenceNumber, transaction.referenceNumber,
|
||||
|
||||
transaction.isReversal
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
@ -157,6 +192,10 @@ open class FinTsModelMapper {
|
|||
else errorMessages.joinToString("\r\n")
|
||||
}
|
||||
|
||||
open fun mergeMessageLog(vararg messageLogs: List<MessageLogEntry>?): List<MessageLogEntry> {
|
||||
return messageLogs.filterNotNull().flatten()
|
||||
}
|
||||
|
||||
open fun mergeMessageLog(vararg responses: FinTsClientResponse?): List<MessageLogEntry> {
|
||||
return responses.filterNotNull().flatMap { it.messageLog }
|
||||
}
|
||||
|
|
|
@ -4,15 +4,13 @@ import net.codinux.banking.fints.extensions.randomWithSeed
|
|||
import net.codinux.banking.fints.messages.datenelemente.implementierte.Aufsetzpunkt
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.KundensystemID
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.Synchronisierungsmodus
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedienArtVersion
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMediumKlasse
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanProcess
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.*
|
||||
import net.codinux.banking.fints.messages.segmente.Segment
|
||||
import net.codinux.banking.fints.messages.segmente.Synchronisierung
|
||||
import net.codinux.banking.fints.messages.segmente.id.CustomerSegmentId
|
||||
import net.codinux.banking.fints.messages.segmente.id.ISegmentId
|
||||
import net.codinux.banking.fints.messages.segmente.implementierte.*
|
||||
import net.codinux.banking.fints.messages.segmente.implementierte.depot.Depotaufstellung
|
||||
import net.codinux.banking.fints.messages.segmente.implementierte.sepa.SepaBankTransferBase
|
||||
import net.codinux.banking.fints.messages.segmente.implementierte.tan.TanGeneratorListeAnzeigen
|
||||
import net.codinux.banking.fints.messages.segmente.implementierte.tan.TanGeneratorTanMediumAnOderUmmelden
|
||||
|
@ -41,7 +39,7 @@ open class MessageBuilder(protected val utils: FinTsUtils = FinTsUtils()) {
|
|||
|
||||
private const val SignatureHeaderSegmentNumber = MessageHeaderSegmentNumber + 1
|
||||
|
||||
private const val SignedMessagePayloadFirstSegmentNumber = SignatureHeaderSegmentNumber + 1
|
||||
const val SignedMessagePayloadFirstSegmentNumber = SignatureHeaderSegmentNumber + 1
|
||||
}
|
||||
|
||||
|
||||
|
@ -242,17 +240,38 @@ open class MessageBuilder(protected val utils: FinTsUtils = FinTsUtils()) {
|
|||
return createSignedMessageBuilderResult(context, MessageType.GetBalance, segments)
|
||||
}
|
||||
|
||||
val securitiesAccountResult = supportsGetSecuritiesAccountBalance(account)
|
||||
|
||||
if (securitiesAccountResult.isJobVersionSupported) {
|
||||
return createGetSecuritiesAccountBalanceMessage(context, result, account)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
protected open fun createGetSecuritiesAccountBalanceMessage(context: JobContext, result: MessageBuilderResult,
|
||||
account: AccountData): MessageBuilderResult {
|
||||
|
||||
val segments = mutableListOf<Segment>(Depotaufstellung(SignedMessagePayloadFirstSegmentNumber, account))
|
||||
|
||||
addTanSegmentIfRequired(context, CustomerSegmentId.SecuritiesAccountBalance, segments, SignedMessagePayloadFirstSegmentNumber + 1)
|
||||
|
||||
return createSignedMessageBuilderResult(context, MessageType.GetSecuritiesAccountBalance, segments)
|
||||
}
|
||||
|
||||
open fun supportsGetBalance(account: AccountData): Boolean {
|
||||
return supportsGetBalanceMessage(account).isJobVersionSupported
|
||||
|| supportsGetSecuritiesAccountBalance(account).isJobVersionSupported
|
||||
}
|
||||
|
||||
protected open fun supportsGetBalanceMessage(account: AccountData): MessageBuilderResult {
|
||||
return getSupportedVersionsOfJobForAccount(CustomerSegmentId.Balance, account, listOf(5, 6, 7, 8))
|
||||
}
|
||||
|
||||
protected open fun supportsGetSecuritiesAccountBalance(account: AccountData): MessageBuilderResult {
|
||||
return getSupportedVersionsOfJobForAccount(CustomerSegmentId.SecuritiesAccountBalance, account, listOf(6))
|
||||
}
|
||||
|
||||
|
||||
open fun createGetTanMediaListMessage(context: JobContext,
|
||||
tanMediaKind: TanMedienArtVersion = TanMedienArtVersion.Alle,
|
||||
|
@ -272,7 +291,7 @@ open class MessageBuilder(protected val utils: FinTsUtils = FinTsUtils()) {
|
|||
}
|
||||
|
||||
// TODO: no HKTAN needed?
|
||||
open fun createChangeTanMediumMessage(context: JobContext, newActiveTanMedium: TanGeneratorTanMedium,
|
||||
open fun createChangeTanMediumMessage(context: JobContext, newActiveTanMedium: TanMedium,
|
||||
tan: String? = null, atc: Int? = null): MessageBuilderResult {
|
||||
|
||||
val result = getSupportedVersionsOfJobForBank(CustomerSegmentId.ChangeTanMedium, context.bank, listOf(1, 2, 3))
|
||||
|
@ -295,12 +314,22 @@ open class MessageBuilder(protected val utils: FinTsUtils = FinTsUtils()) {
|
|||
|
||||
val segments = listOf(
|
||||
ZweiSchrittTanEinreichung(SignedMessagePayloadFirstSegmentNumber, tanProcess, null,
|
||||
tanResponse.jobHashValue, tanResponse.jobReference, false, null, tanResponse.tanMediaIdentifier)
|
||||
tanResponse.jobHashValue, tanResponse.jobReference, false, null, tanResponse.tanMediaIdentifier, tanResponse.segmentVersion)
|
||||
)
|
||||
|
||||
return createSignedMessageBuilderResult(context, MessageType.Tan, createSignedMessage(context, enteredTan, segments), segments)
|
||||
}
|
||||
|
||||
open fun createDecoupledTanStatusMessage(context: JobContext, tanResponse: TanResponse): MessageBuilderResult {
|
||||
|
||||
val segments = listOf(
|
||||
ZweiSchrittTanEinreichung(SignedMessagePayloadFirstSegmentNumber, TanProcess.AppTan,
|
||||
jobReference = tanResponse.jobReference, furtherTanFollows = false, segmentVersion = 7, tanMediaIdentifier = tanResponse.tanMediaIdentifier)
|
||||
)
|
||||
|
||||
return createSignedMessageBuilderResult(context, MessageType.CheckDecoupledTanStatus, createSignedMessage(context, null, segments), segments)
|
||||
}
|
||||
|
||||
|
||||
open fun createBankTransferMessage(context: JobContext, data: BankTransferData, account: AccountData): MessageBuilderResult {
|
||||
|
||||
|
@ -521,8 +550,10 @@ open class MessageBuilder(protected val utils: FinTsUtils = FinTsUtils()) {
|
|||
}
|
||||
|
||||
protected open fun createTwoStepTanSegment(context: JobContext, segmentId: CustomerSegmentId, segmentNumber: Int): ZweiSchrittTanEinreichung {
|
||||
val bank = context.bank
|
||||
|
||||
return ZweiSchrittTanEinreichung(segmentNumber, TanProcess.TanProcess4, segmentId,
|
||||
tanMediaIdentifier = getTanMediaIdentifierIfRequired(context))
|
||||
tanMediaIdentifier = getTanMediaIdentifierIfRequired(context), segmentVersion = bank.selectedTanMethod.hktanVersion)
|
||||
}
|
||||
|
||||
protected open fun getTanMediaIdentifierIfRequired(context: JobContext): String? {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package net.codinux.banking.fints.messages
|
||||
|
||||
import net.codinux.banking.fints.messages.datenelementgruppen.implementierte.Segmentkopf
|
||||
import net.codinux.banking.fints.messages.segmente.Segment
|
||||
import net.codinux.banking.fints.messages.segmente.implementierte.Verarbeitungsvorbereitung
|
||||
import net.codinux.banking.fints.messages.segmente.implementierte.ZweiSchrittTanEinreichung
|
||||
|
||||
|
||||
|
@ -32,4 +34,10 @@ open class MessageBuilderResult(
|
|||
&& messageBodySegments.first() is ZweiSchrittTanEinreichung
|
||||
}
|
||||
|
||||
open fun isDialogInitMessage(): Boolean =
|
||||
messageBodySegments.any { it is Verarbeitungsvorbereitung }
|
||||
|
||||
|
||||
override fun toString() = "${messageBodySegments.joinToString { (it.dataElementsAndGroups.firstOrNull() as? Segmentkopf)?.let { "${it.identifier}:${it.segmentVersion}" } ?: "<No Segment header>" } }}"
|
||||
|
||||
}
|
|
@ -1,24 +1,19 @@
|
|||
package net.codinux.banking.fints.messages.datenelemente.implementierte.tan
|
||||
|
||||
import net.codinux.banking.fints.messages.datenelementgruppen.implementierte.account.KontoverbindungInternational
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.dankito.banking.client.model.BankAccountIdentifier
|
||||
|
||||
@Serializable
|
||||
open class MobilePhoneTanMedium(
|
||||
mediumClass: TanMediumKlasse,
|
||||
status: TanMediumStatus,
|
||||
override val mediumName: String,
|
||||
val concealedPhoneNumber: String?,
|
||||
val phoneNumber: String?,
|
||||
val smsDebitAccount: KontoverbindungInternational? = null
|
||||
) : TanMedium(mediumClass, status, mediumName) {
|
||||
|
||||
val smsDebitAccount: BankAccountIdentifier? = null
|
||||
) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is MobilePhoneTanMedium) return false
|
||||
if (!super.equals(other)) return false
|
||||
|
||||
if (mediumName != other.mediumName) return false
|
||||
if (concealedPhoneNumber != other.concealedPhoneNumber) return false
|
||||
if (phoneNumber != other.phoneNumber) return false
|
||||
if (smsDebitAccount != other.smsDebitAccount) return false
|
||||
|
@ -27,17 +22,15 @@ open class MobilePhoneTanMedium(
|
|||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result = 31 * result + (mediumName.hashCode())
|
||||
result = 31 * result + (concealedPhoneNumber?.hashCode() ?: 0)
|
||||
result = 31 * result + (phoneNumber?.hashCode() ?: 0)
|
||||
result = 31 * result + (smsDebitAccount?.hashCode() ?: 0)
|
||||
var result = concealedPhoneNumber.hashCode()
|
||||
result = 31 * result + phoneNumber.hashCode()
|
||||
result = 31 * result + smsDebitAccount.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return super.toString() + " $mediumName ${phoneNumber ?: concealedPhoneNumber ?: ""}"
|
||||
return phoneNumber ?: concealedPhoneNumber ?: ""
|
||||
}
|
||||
|
||||
}
|
|
@ -1,24 +1,21 @@
|
|||
package net.codinux.banking.fints.messages.datenelemente.implementierte.tan
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
||||
@Serializable
|
||||
open class TanGeneratorTanMedium(
|
||||
mediumClass: TanMediumKlasse,
|
||||
status: TanMediumStatus,
|
||||
val cardNumber: String,
|
||||
val cardSequenceNumber: String?,
|
||||
val cardType: Int?,
|
||||
val validFrom: LocalDate?,
|
||||
val validTo: LocalDate?,
|
||||
mediumName: String?
|
||||
) : TanMedium(mediumClass, status, mediumName) {
|
||||
val validTo: LocalDate?
|
||||
) {
|
||||
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || this::class != other::class) return false
|
||||
if (!super.equals(other)) return false
|
||||
|
||||
other as TanGeneratorTanMedium
|
||||
|
||||
|
@ -27,25 +24,22 @@ open class TanGeneratorTanMedium(
|
|||
if (cardType != other.cardType) return false
|
||||
if (validFrom != other.validFrom) return false
|
||||
if (validTo != other.validTo) return false
|
||||
if (mediumName != other.mediumName) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result = 31 * result + cardNumber.hashCode()
|
||||
var result = cardNumber.hashCode()
|
||||
result = 31 * result + cardSequenceNumber.hashCode()
|
||||
result = 31 * result + (cardType?.hashCode() ?: 0)
|
||||
result = 31 * result + (validFrom?.hashCode() ?: 0)
|
||||
result = 31 * result + (validTo?.hashCode() ?: 0)
|
||||
result = 31 * result + (mediumName?.hashCode() ?: 0)
|
||||
result = 31 * result + cardType.hashCode()
|
||||
result = 31 * result + validFrom.hashCode()
|
||||
result = 31 * result + validTo.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return super.toString() + " $mediumName $cardNumber (card sequence number: ${cardSequenceNumber ?: "-"})"
|
||||
return "$cardNumber (card sequence number: ${cardSequenceNumber ?: "-"})"
|
||||
}
|
||||
|
||||
}
|
|
@ -14,13 +14,20 @@ import kotlinx.serialization.Serializable
|
|||
open class TanMedium(
|
||||
open val mediumClass: TanMediumKlasse,
|
||||
open val status: TanMediumStatus,
|
||||
open val mediumName: String?
|
||||
open val mediumName: String?,
|
||||
open val tanGenerator: TanGeneratorTanMedium? = null,
|
||||
open val mobilePhone: MobilePhoneTanMedium? = null
|
||||
) {
|
||||
|
||||
|
||||
internal constructor() : this(TanMediumKlasse.AlleMedien, TanMediumStatus.Verfuegbar, null) // for object deserializers
|
||||
|
||||
|
||||
val identifier: String by lazy {
|
||||
"$mediumClass $mediumName $status ${tanGenerator?.cardNumber} ${mobilePhone?.concealedPhoneNumber ?: mobilePhone?.concealedPhoneNumber}"
|
||||
}
|
||||
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || this::class != other::class) return false
|
||||
|
@ -29,6 +36,9 @@ open class TanMedium(
|
|||
|
||||
if (mediumClass != other.mediumClass) return false
|
||||
if (status != other.status) return false
|
||||
if (mediumName != other.mediumName) return false
|
||||
if (tanGenerator != other.tanGenerator) return false
|
||||
if (mobilePhone != other.mobilePhone) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
@ -36,6 +46,9 @@ open class TanMedium(
|
|||
override fun hashCode(): Int {
|
||||
var result = mediumClass.hashCode()
|
||||
result = 31 * result + status.hashCode()
|
||||
result = 31 * result + mediumName.hashCode()
|
||||
result = 31 * result + tanGenerator.hashCode()
|
||||
result = 31 * result + mobilePhone.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
|
@ -50,6 +50,11 @@ enum class TanProcess(override val code: String) : ICodeEnum {
|
|||
*/
|
||||
TanProcess4("4"),
|
||||
|
||||
AppTan("S") // TODO: what is this?
|
||||
/**
|
||||
* kann nur nach dem ersten Schritt auftreten. Er dient im DecoupledVerfahren der Statusabfrage der vom Kunden zu
|
||||
* tätigenden Sicherheitsfreigabe auf einem anderen Gerät mittels HKTAN. Dieser Geschäftsvorfall wird mit HITAN,
|
||||
* TAN-Prozess=S beantwortet.
|
||||
*/
|
||||
AppTan("S")
|
||||
|
||||
}
|
|
@ -27,6 +27,11 @@ enum class CustomerSegmentId(override val id: String) : ISegmentId {
|
|||
|
||||
SepaRealTimeTransfer("HKIPZ"),
|
||||
|
||||
SepaAccountInfoParameters("HKSPA") // not implemented, retrieved automatically with UPD
|
||||
SepaAccountInfoParameters("HKSPA"), // not implemented, retrieved automatically with UPD
|
||||
|
||||
|
||||
/* Wertpapierdepot */
|
||||
|
||||
SecuritiesAccountBalance("HKWPD")
|
||||
|
||||
}
|
|
@ -19,16 +19,17 @@ open class ZweiSchrittTanEinreichung(
|
|||
jobReference: String? = null,
|
||||
furtherTanFollows: Boolean? = false,
|
||||
cancelJob: Boolean? = null,
|
||||
tanMediaIdentifier: String? = null
|
||||
tanMediaIdentifier: String? = null,
|
||||
segmentVersion: Int = 6
|
||||
|
||||
) : Segment(listOf(
|
||||
Segmentkopf(CustomerSegmentId.Tan, 6, segmentNumber),
|
||||
Segmentkopf(CustomerSegmentId.Tan, segmentVersion, segmentNumber),
|
||||
TANProzessDatenelement(process),
|
||||
Segmentkennung(segmentIdForWhichTanShouldGetGenerated?.id ?: ""), // M: bei TAN-Prozess=1. M: bei TAN-Prozess=4 und starker Authentifizierung. N: sonst
|
||||
NotAllowedDatenelement(), // Kontoverbindung // M: bei TAN-Prozess=1 und "Auftraggeberkonto erforderlich"=2 und Kontoverbindung im Auftrag enthalten. N: sonst
|
||||
AuftragsHashwert(jobHashValue ?: "", Existenzstatus.NotAllowed), // M: bei AuftragsHashwertverfahren<>0 und TAN-Prozess=1. N: sonst
|
||||
Auftragsreferenz(jobReference ?: "", Existenzstatus.Mandatory), // M: bei TAN-Prozess=2, 3, 4. O: bei TAN-Prozess=1
|
||||
JaNein(furtherTanFollows, if (process == TanProcess.TanProcess1 || process == TanProcess.TanProcess2) Existenzstatus.Mandatory else Existenzstatus.NotAllowed), // M: bei TAN-Prozess=1, 2. N: bei TAN-Prozess=3, 4
|
||||
Auftragsreferenz(jobReference ?: "", if (process == TanProcess.TanProcess2 || process == TanProcess.TanProcess3 || process == TanProcess.AppTan) Existenzstatus.Mandatory else Existenzstatus.Optional), // M: bei TAN-Prozess=2, 3, 4. O: bei TAN-Prozess=1
|
||||
JaNein(furtherTanFollows, if (process == TanProcess.TanProcess1 || process == TanProcess.TanProcess2 || process == TanProcess.AppTan) Existenzstatus.Mandatory else Existenzstatus.NotAllowed), // M: bei TAN-Prozess=1, 2. N: bei TAN-Prozess=3, 4
|
||||
JaNein(cancelJob, if (process == TanProcess.TanProcess2 && cancelJob != null) Existenzstatus.Optional else Existenzstatus.NotAllowed), // O: bei TAN-Prozess=2 und „Auftragsstorno erlaubt“=J. N: sonst
|
||||
NotAllowedDatenelement(), // TODO: SMS-Abbuchungskonto // M: Bei TAN-Process=1, 3, 4 und „SMS-Abbuchungskonto erforderlich“=2. O: sonst
|
||||
NotAllowedDatenelement(), // TODO: Challenge-Klasse // M: bei TAN-Prozess=1 und „Challenge-Klasse erforderlich“=J. N: sonst
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
package net.codinux.banking.fints.messages.segmente.implementierte.depot
|
||||
|
||||
import net.codinux.banking.fints.messages.Existenzstatus
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.Aufsetzpunkt
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.account.MaximaleAnzahlEintraege
|
||||
import net.codinux.banking.fints.messages.datenelementgruppen.implementierte.Segmentkopf
|
||||
import net.codinux.banking.fints.messages.datenelementgruppen.implementierte.account.Kontoverbindung
|
||||
import net.codinux.banking.fints.messages.segmente.Segment
|
||||
import net.codinux.banking.fints.messages.segmente.id.CustomerSegmentId
|
||||
import net.codinux.banking.fints.model.AccountData
|
||||
|
||||
/**
|
||||
* Nr. Name Version Typ Format Länge Status Anzahl Restriktionen
|
||||
1 Segmentkopf 1 DEG M 1
|
||||
2 Depot 3 DEG ktv # M 1
|
||||
3 Währung der Depotaufstellung 1 DE cur # C 1 O: „Währung der Depotaufstellung wählbar“ (BPD) = „J“; N: sonst
|
||||
4 Kursqualität 2 DE code 1 C 1 1,2 O: „Kursqualität wählbar“ (BPD) = „J“; N: sonst
|
||||
5 Maximale Anzahl Einträge 1 DE num ..4 C 1 >0 O: „Eingabe Anzahl Einträge erlaubt“ (BPD) = „J“; N: sonst
|
||||
6 Aufsetzpunkt 1 DE an ..35 C 1 M: vom Institut wurde ein Aufsetzpunkt rückgemeldet N: sonst
|
||||
*/
|
||||
class Depotaufstellung(
|
||||
segmentNumber: Int,
|
||||
account: AccountData,
|
||||
// parameter: GetAccountTransactionsParameter
|
||||
): Segment(listOf(
|
||||
Segmentkopf(CustomerSegmentId.SecuritiesAccountBalance, 6, segmentNumber),
|
||||
Kontoverbindung(account),
|
||||
// TODO:
|
||||
// 3. Währung der Depotaufstellung
|
||||
// 4. Kursqualität
|
||||
// 5. Maximale Anzahl Einträge
|
||||
// 6. Aufsetzpunkt
|
||||
|
||||
// MaximaleAnzahlEintraege(parameter), // TODO: this is wrong, it only works for HKKAZ
|
||||
MaximaleAnzahlEintraege(null, Existenzstatus.Optional),
|
||||
Aufsetzpunkt(null, Existenzstatus.Optional) // will be set dynamically, see MessageBuilder.rebuildMessageWithContinuationId(); M: vom Institut wurde ein Aufsetzpunkt rückgemeldet. N: sonst
|
||||
))
|
|
@ -8,7 +8,7 @@ import net.codinux.banking.fints.messages.datenelemente.basisformate.Numerisches
|
|||
import net.codinux.banking.fints.messages.datenelemente.implementierte.DoNotPrintDatenelement
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.NotAllowedDatenelement
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.allCodes
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMediumKlasse
|
||||
import net.codinux.banking.fints.messages.datenelementgruppen.implementierte.Segmentkopf
|
||||
import net.codinux.banking.fints.messages.datenelementgruppen.implementierte.account.Kontoverbindung
|
||||
|
@ -34,13 +34,13 @@ import net.codinux.banking.fints.response.segments.ChangeTanMediaParameters
|
|||
* chipTAN-Verfahren:
|
||||
* Steht beim chipTAN-Verfahren ein Kartenwechsel an, so kann der Kunde mit diesem Geschäftsvorfall seine Karte bzw.
|
||||
* Folgekarte aktivieren. Kann der Kunde mehrere Karten verwenden, dann kann mit diesem GV die Ummeldung auf eine
|
||||
* andere Karte erfolgen. Das Kreditinstitut entscheidet selbst, ob dieser GV TAN-pflichtig istoder nicht.
|
||||
* andere Karte erfolgen. Das Kreditinstitut entscheidet selbst, ob dieser GV TAN-pflichtig ist oder nicht.
|
||||
*/
|
||||
open class TanGeneratorTanMediumAnOderUmmelden(
|
||||
segmentVersion: Int,
|
||||
segmentNumber: Int,
|
||||
bank: BankData,
|
||||
newActiveTanMedium: TanGeneratorTanMedium,
|
||||
newActiveTanMedium: TanMedium,
|
||||
/**
|
||||
* Has to be set if „Eingabe von ATC und TAN erforderlich“ (BPD)=“J“
|
||||
*/
|
||||
|
@ -57,17 +57,17 @@ open class TanGeneratorTanMediumAnOderUmmelden(
|
|||
)
|
||||
: Segment(listOf(
|
||||
Segmentkopf(CustomerSegmentId.ChangeTanMedium, segmentVersion, segmentNumber),
|
||||
Code(TanMediumKlasse.TanGenerator, allCodes<TanMediumKlasse>(), Existenzstatus.Mandatory),
|
||||
AlphanumerischesDatenelement(newActiveTanMedium.cardNumber, Existenzstatus.Mandatory),
|
||||
AlphanumerischesDatenelement(newActiveTanMedium.cardSequenceNumber, if (parameters.enteringCardSequenceNumberRequired) Existenzstatus.Mandatory else Existenzstatus.NotAllowed),
|
||||
if (segmentVersion > 1) NumerischesDatenelement(newActiveTanMedium.cardType, 2, if (parameters.enteringCardTypeAllowed) Existenzstatus.Optional else Existenzstatus.NotAllowed) else DoNotPrintDatenelement(),
|
||||
Code(newActiveTanMedium.mediumClass, allCodes<TanMediumKlasse>(), Existenzstatus.Mandatory),
|
||||
AlphanumerischesDatenelement(newActiveTanMedium.tanGenerator?.cardNumber, if (newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator) Existenzstatus.Mandatory else Existenzstatus.NotAllowed),
|
||||
AlphanumerischesDatenelement(newActiveTanMedium.tanGenerator?.cardSequenceNumber, if (newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator && parameters.enteringCardSequenceNumberRequired) Existenzstatus.Mandatory else Existenzstatus.NotAllowed),
|
||||
if (segmentVersion > 1) NumerischesDatenelement(newActiveTanMedium.tanGenerator?.cardType, 2, if (newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator && parameters.enteringCardTypeAllowed) Existenzstatus.Optional else Existenzstatus.NotAllowed) else DoNotPrintDatenelement(),
|
||||
if (segmentVersion == 2) Kontoverbindung(bank.accounts.first()) else DoNotPrintDatenelement(),
|
||||
if (segmentVersion >= 3 && parameters.accountInfoRequired) KontoverbindungInternational(bank.accounts.first(), bank) else DoNotPrintDatenelement(),
|
||||
if (segmentVersion >= 2) Datum(newActiveTanMedium.validFrom, Existenzstatus.Optional) else DoNotPrintDatenelement(),
|
||||
if (segmentVersion >= 2) Datum(newActiveTanMedium.validTo, Existenzstatus.Optional) else DoNotPrintDatenelement(),
|
||||
if (segmentVersion >= 3) AlphanumerischesDatenelement(iccsn, Existenzstatus.Optional, 19) else DoNotPrintDatenelement(),
|
||||
if (segmentVersion >= 2 && newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator) Datum(newActiveTanMedium.tanGenerator?.validFrom, Existenzstatus.Optional) else DoNotPrintDatenelement(),
|
||||
if (segmentVersion >= 2 && newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator) Datum(newActiveTanMedium.tanGenerator?.validTo, Existenzstatus.Optional) else DoNotPrintDatenelement(),
|
||||
if (segmentVersion >= 3 && newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator) AlphanumerischesDatenelement(iccsn, Existenzstatus.Optional, 19) else DoNotPrintDatenelement(),
|
||||
NotAllowedDatenelement(), // TAN-Listennummer not supported anymore
|
||||
NumerischesDatenelement(atc, 5, if (parameters.enteringAtcAndTanRequired) Existenzstatus.Mandatory else Existenzstatus.NotAllowed),
|
||||
NumerischesDatenelement(atc, 5, if (newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator && parameters.enteringAtcAndTanRequired) Existenzstatus.Mandatory else Existenzstatus.NotAllowed),
|
||||
AlphanumerischesDatenelement(tan, if (parameters.enteringAtcAndTanRequired) Existenzstatus.Mandatory else Existenzstatus.NotAllowed, 99)
|
||||
)) {
|
||||
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
package net.codinux.banking.fints.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import net.codinux.banking.fints.FinTsClient
|
||||
import net.codinux.banking.fints.messages.datenelemente.abgeleiteteformate.Laenderkennzeichen
|
||||
import net.codinux.banking.fints.messages.segmente.id.CustomerSegmentId
|
||||
import net.codinux.banking.fints.response.segments.AccountType
|
||||
import net.codinux.banking.fints.response.segments.JobParameters
|
||||
|
||||
|
||||
@Serializable
|
||||
open class AccountData(
|
||||
open val accountIdentifier: String,
|
||||
open val subAccountAttribute: String?,
|
||||
|
@ -20,6 +23,7 @@ open class AccountData(
|
|||
open val productName: String?,
|
||||
open val accountLimit: String?,
|
||||
open val allowedJobNames: List<String>,
|
||||
@Transient // can be restored from bank.supportedJobs and this.allowedJobNames
|
||||
open var allowedJobs: List<JobParameters> = listOf()
|
||||
) {
|
||||
|
||||
|
@ -30,11 +34,17 @@ open class AccountData(
|
|||
get() = FinTsClient.SupportedAccountTypes.contains(accountType)
|
||||
|| allowedJobNames.contains(CustomerSegmentId.Balance.id)
|
||||
|| allowedJobNames.contains(CustomerSegmentId.AccountTransactionsMt940.id)
|
||||
|| allowedJobNames.contains(CustomerSegmentId.SecuritiesAccountBalance.id)
|
||||
|
||||
|
||||
open var countDaysForWhichTransactionsAreKept: Int? = null
|
||||
|
||||
/**
|
||||
* Count days for which transactions are stored on bank server (if available).
|
||||
*/
|
||||
open var serverTransactionsRetentionDays: Int? = null
|
||||
|
||||
|
||||
@SerialName("supportedFeatures")
|
||||
protected open val _supportedFeatures = mutableSetOf<AccountFeature>()
|
||||
|
||||
open val supportedFeatures: Collection<AccountFeature>
|
||||
|
|
|
@ -7,59 +7,124 @@ import net.codinux.banking.fints.extensions.UnixEpochStart
|
|||
open class AccountTransaction(
|
||||
val account: AccountData,
|
||||
val amount: Money,
|
||||
val isReversal: Boolean,
|
||||
val unparsedReference: String,
|
||||
val reference: String?, // that was also new to me that reference may is null
|
||||
|
||||
val bookingDate: LocalDate,
|
||||
val otherPartyName: String?,
|
||||
val otherPartyBankCode: String?,
|
||||
val otherPartyAccountId: String?,
|
||||
val bookingText: String?,
|
||||
val valueDate: LocalDate,
|
||||
val statementNumber: Int,
|
||||
val sequenceNumber: Int?,
|
||||
|
||||
/**
|
||||
* Name des Überweisenden oder Zahlungsempfängers
|
||||
*/
|
||||
val otherPartyName: String?,
|
||||
/**
|
||||
* BIC des Überweisenden / Zahlungsempfängers
|
||||
*/
|
||||
val otherPartyBankId: String?,
|
||||
/**
|
||||
* IBAN des Überweisenden oder Zahlungsempfängers
|
||||
*/
|
||||
val otherPartyAccountId: String?,
|
||||
|
||||
/**
|
||||
* Buchungstext, z. B. DAUERAUFTRAG, BARGELDAUSZAHLUNG, ONLINE-UEBERWEISUNG, FOLGELASTSCHRIFT, ...
|
||||
*/
|
||||
val postingText: String?,
|
||||
|
||||
val openingBalance: Money?,
|
||||
val closingBalance: Money?,
|
||||
|
||||
val endToEndReference: String?,
|
||||
/**
|
||||
* Auszugsnummer
|
||||
*/
|
||||
val statementNumber: Int,
|
||||
/**
|
||||
* Blattnummer
|
||||
*/
|
||||
val sheetNumber: Int?,
|
||||
|
||||
/**
|
||||
* Kundenreferenz.
|
||||
*/
|
||||
val customerReference: String?,
|
||||
/**
|
||||
* Bankreferenz
|
||||
*/
|
||||
val bankReference: String?,
|
||||
/**
|
||||
* Währungsart und Umsatzbetrag in Ursprungswährung
|
||||
*/
|
||||
val furtherInformation: String?,
|
||||
|
||||
|
||||
/* Remittance information */
|
||||
|
||||
val endToEndReference: String?,
|
||||
val mandateReference: String?,
|
||||
val creditorIdentifier: String?,
|
||||
val originatorsIdentificationCode: String?,
|
||||
|
||||
/**
|
||||
* Summe aus Auslagenersatz und Bearbeitungsprovision bei einer nationalen Rücklastschrift
|
||||
* sowie optionalem Zinsausgleich.
|
||||
*/
|
||||
val compensationAmount: String?,
|
||||
/**
|
||||
* Betrag der ursprünglichen Lastschrift
|
||||
*/
|
||||
val originalAmount: String?,
|
||||
val sepaReference: String?,
|
||||
/**
|
||||
* Abweichender Überweisender oder Zahlungsempfänger
|
||||
*/
|
||||
val deviantOriginator: String?,
|
||||
/**
|
||||
* Abweichender Zahlungsempfänger oder Zahlungspflichtiger
|
||||
*/
|
||||
val deviantRecipient: String?,
|
||||
val referenceWithNoSpecialType: String?,
|
||||
val primaNotaNumber: String?,
|
||||
val textKeySupplement: String?,
|
||||
|
||||
val currencyType: String?,
|
||||
val bookingKey: String,
|
||||
val referenceForTheAccountOwner: String,
|
||||
val referenceOfTheAccountServicingInstitution: String?,
|
||||
val supplementaryDetails: String?,
|
||||
/**
|
||||
* Primanoten-Nr.
|
||||
*/
|
||||
val journalNumber: String?,
|
||||
/**
|
||||
* Bei R-Transaktionen siehe Tabelle der
|
||||
* SEPA-Rückgabecodes, bei SEPALastschriften siehe optionale Belegung
|
||||
* bei GVC 104 und GVC 105 (GVC = Geschäftsvorfallcode)
|
||||
*/
|
||||
val textKeyAddition: String?,
|
||||
|
||||
val transactionReferenceNumber: String,
|
||||
val relatedReferenceNumber: String?
|
||||
/**
|
||||
* Referenznummer, die vom Sender als eindeutige Kennung für die Nachricht vergeben wurde
|
||||
* (z.B. als Referenz auf stornierte Nachrichten).
|
||||
*/
|
||||
val orderReferenceNumber: String?,
|
||||
/**
|
||||
* Bezugsreferenz
|
||||
*/
|
||||
val referenceNumber: String?,
|
||||
|
||||
/**
|
||||
* Storno, ob die Buchung storniert wurde(?).
|
||||
* Aus:
|
||||
* „RC“ = Storno Haben
|
||||
* „RD“ = Storno Soll
|
||||
*/
|
||||
val isReversal: Boolean
|
||||
) {
|
||||
|
||||
// for object deserializers
|
||||
internal constructor() : this(AccountData(), Money(Amount.Zero, ""), "", UnixEpochStart, null, null, null, null, UnixEpochStart)
|
||||
internal constructor() : this(AccountData(), Money(Amount.Zero, ""), "", UnixEpochStart, UnixEpochStart, null, null, null, null)
|
||||
|
||||
constructor(account: AccountData, amount: Money, unparsedReference: String, bookingDate: LocalDate, otherPartyName: String?, otherPartyBankCode: String?, otherPartyAccountId: String?, bookingText: String?, valueDate: LocalDate)
|
||||
: this(account, amount, false, unparsedReference, bookingDate, otherPartyName, otherPartyBankCode, otherPartyAccountId, bookingText, valueDate,
|
||||
0, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, "", "", null, null, "", null)
|
||||
constructor(account: AccountData, amount: Money, unparsedReference: String, bookingDate: LocalDate, valueDate: LocalDate, otherPartyName: String?, otherPartyBankId: String?, otherPartyAccountId: String?, postingText: String? = null)
|
||||
: this(account, amount, unparsedReference, bookingDate, valueDate, otherPartyName, otherPartyBankId, otherPartyAccountId, postingText,
|
||||
null, null, 0, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
"", null, null, "", null, false)
|
||||
|
||||
|
||||
open val showOtherPartyName: Boolean
|
||||
get() = otherPartyName.isNullOrBlank() == false /* && type != "ENTGELTABSCHLUSS" && type != "AUSZAHLUNG" */ // TODO
|
||||
|
||||
val reference: String
|
||||
get() = sepaReference ?: unparsedReference
|
||||
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
@ -67,12 +132,12 @@ open class AccountTransaction(
|
|||
|
||||
if (account != other.account) return false
|
||||
if (amount != other.amount) return false
|
||||
if (unparsedReference != other.unparsedReference) return false
|
||||
if (reference != other.reference) return false
|
||||
if (bookingDate != other.bookingDate) return false
|
||||
if (otherPartyName != other.otherPartyName) return false
|
||||
if (otherPartyBankCode != other.otherPartyBankCode) return false
|
||||
if (otherPartyBankId != other.otherPartyBankId) return false
|
||||
if (otherPartyAccountId != other.otherPartyAccountId) return false
|
||||
if (bookingText != other.bookingText) return false
|
||||
if (postingText != other.postingText) return false
|
||||
if (valueDate != other.valueDate) return false
|
||||
|
||||
return true
|
||||
|
@ -81,19 +146,19 @@ open class AccountTransaction(
|
|||
override fun hashCode(): Int {
|
||||
var result = account.hashCode()
|
||||
result = 31 * result + amount.hashCode()
|
||||
result = 31 * result + unparsedReference.hashCode()
|
||||
result = 31 * result + reference.hashCode()
|
||||
result = 31 * result + bookingDate.hashCode()
|
||||
result = 31 * result + (otherPartyName?.hashCode() ?: 0)
|
||||
result = 31 * result + (otherPartyBankCode?.hashCode() ?: 0)
|
||||
result = 31 * result + (otherPartyAccountId?.hashCode() ?: 0)
|
||||
result = 31 * result + (bookingText?.hashCode() ?: 0)
|
||||
result = 31 * result + otherPartyName.hashCode()
|
||||
result = 31 * result + otherPartyBankId.hashCode()
|
||||
result = 31 * result + otherPartyAccountId.hashCode()
|
||||
result = 31 * result + postingText.hashCode()
|
||||
result = 31 * result + valueDate.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return "$valueDate $amount $otherPartyName: $unparsedReference"
|
||||
return "$valueDate $amount $otherPartyName: $reference"
|
||||
}
|
||||
|
||||
}
|
|
@ -7,6 +7,7 @@ open class AddAccountParameter @JvmOverloads constructor(
|
|||
open val bank: BankData,
|
||||
open val fetchBalanceAndTransactions: Boolean = true,
|
||||
open val preferredTanMethods: List<TanMethodType>? = null,
|
||||
open val tanMethodsNotSupportedByApplication: List<TanMethodType>? = null,
|
||||
open val preferredTanMedium: String? = null
|
||||
) {
|
||||
|
||||
|
|
|
@ -3,7 +3,9 @@ package net.codinux.banking.fints.model
|
|||
import kotlinx.serialization.Serializable
|
||||
import net.dankito.banking.client.model.serializer.AmountSerializer
|
||||
|
||||
|
||||
/**
|
||||
* Be aware: The decimal separator is as specified by FinTS standard ',', not '.'.
|
||||
*/
|
||||
@Serializable(with = AmountSerializer::class)
|
||||
open class Amount(
|
||||
val string: String
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package net.codinux.banking.fints.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.codinux.banking.fints.messages.datenelemente.abgeleiteteformate.Laenderkennzeichen
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.*
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.signatur.Sicherheitsfunktion
|
||||
|
@ -7,9 +8,9 @@ import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMe
|
|||
import net.codinux.banking.fints.messages.segmente.id.ISegmentId
|
||||
import net.codinux.banking.fints.response.segments.ChangeTanMediaParameters
|
||||
import net.codinux.banking.fints.response.segments.JobParameters
|
||||
import net.codinux.banking.fints.response.segments.PinInfo
|
||||
|
||||
import net.codinux.banking.fints.serialization.BankDataSerializer
|
||||
|
||||
@Serializable(with = BankDataSerializer::class)
|
||||
open class BankData(
|
||||
open var bankCode: String,
|
||||
open var customerId: String,
|
||||
|
@ -30,7 +31,6 @@ open class BankData(
|
|||
open var selectedTanMethod: TanMethod = TanMethodNotSelected,
|
||||
open var tanMedia: List<TanMedium> = listOf(),
|
||||
open var selectedTanMedium: TanMedium? = null,
|
||||
open var changeTanMediumParameters: ChangeTanMediaParameters? = null,
|
||||
|
||||
open var supportedLanguages: List<Dialogsprache> = listOf(),
|
||||
open var selectedLanguage: Dialogsprache = Dialogsprache.Default,
|
||||
|
@ -44,7 +44,8 @@ open class BankData(
|
|||
open var countMaxJobsPerMessage: Int = 0,
|
||||
|
||||
open var supportedHbciVersions: List<HbciVersion> = listOf(),
|
||||
open var supportedJobs: List<JobParameters> = listOf()
|
||||
open var supportedJobs: List<JobParameters> = listOf(),
|
||||
open var jobsRequiringTan: Set<String> = emptySet()
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
@ -62,6 +63,11 @@ open class BankData(
|
|||
internal constructor() : this("", "", "", "", "") // for object deserializers
|
||||
|
||||
|
||||
open var pinInfo: PinInfo? = null
|
||||
|
||||
open val changeTanMediumParameters: ChangeTanMediaParameters?
|
||||
get() = supportedJobs.filterIsInstance<ChangeTanMediaParameters>().firstOrNull()
|
||||
|
||||
|
||||
protected open val _accounts = mutableListOf<AccountData>()
|
||||
|
||||
|
@ -82,16 +88,6 @@ open class BankData(
|
|||
}
|
||||
|
||||
|
||||
open var jobsRequiringTan: Set<String> = emptySet()
|
||||
protected set
|
||||
|
||||
open var pinInfo: PinInfo? = null
|
||||
set(value) {
|
||||
field = value
|
||||
// TODO: in case of null: actually in this case it's not allowed to execute job via PIN/TAN at all
|
||||
jobsRequiringTan = value?.jobTanConfiguration.orEmpty().filter { it.tanRequired }.map { it.segmentId }.toSet()
|
||||
}
|
||||
|
||||
open fun doesJobRequireTan(segmentId: ISegmentId): Boolean = doesJobRequireTan(segmentId.id)
|
||||
|
||||
open fun doesJobRequireTan(segmentId: String): Boolean =
|
||||
|
|
|
@ -8,12 +8,12 @@ open class DecoupledTanMethodParameters(
|
|||
open val manualConfirmationAllowed: Boolean,
|
||||
open val periodicStateRequestsAllowed: Boolean,
|
||||
open val maxNumberOfStateRequests: Int,
|
||||
open val initialDelayInSecondsForStateRequests: Int,
|
||||
open val delayInSecondsForNextStateRequests: Int
|
||||
open val initialDelayInSecondsForStateRequest: Int,
|
||||
open val delayInSecondsForNextStateRequest: Int
|
||||
) {
|
||||
|
||||
override fun toString(): String {
|
||||
return "DecoupledTanMethodParameters(manualConfirmationAllowed=$manualConfirmationAllowed, periodicStateRequestsAllowed=$periodicStateRequestsAllowed, maxNumberOfStateRequests=$maxNumberOfStateRequests, initialDelayInSecondsForStateRequests=$initialDelayInSecondsForStateRequests, delayInSecondsForNextStateRequests=$delayInSecondsForNextStateRequests)"
|
||||
return "DecoupledTanMethodParameters(manualConfirmationAllowed=$manualConfirmationAllowed, periodicStateRequestsAllowed=$periodicStateRequestsAllowed, maxNumberOfStateRequests=$maxNumberOfStateRequests, initialDelayInSecondsForStateRequests=$initialDelayInSecondsForStateRequest, delayInSecondsForNextStateRequests=$delayInSecondsForNextStateRequest)"
|
||||
}
|
||||
|
||||
}
|
|
@ -1,17 +1,24 @@
|
|||
package net.codinux.banking.fints.model
|
||||
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
|
||||
import net.codinux.banking.fints.response.BankResponse
|
||||
import net.codinux.banking.fints.response.client.FinTsClientResponse
|
||||
|
||||
|
||||
open class EnterTanResult(
|
||||
val enteredTan: String?,
|
||||
val userApprovedDecoupledTan: Boolean? = null,
|
||||
val responseAfterApprovingDecoupledTan: BankResponse? = null,
|
||||
val changeTanMethodTo: TanMethod? = null,
|
||||
val changeTanMediumTo: TanMedium? = null,
|
||||
val changeTanMediumResultCallback: ((FinTsClientResponse) -> Unit)? = null
|
||||
) {
|
||||
|
||||
override fun toString(): String {
|
||||
if (userApprovedDecoupledTan == true) {
|
||||
return "User approved Decoupled TAN"
|
||||
}
|
||||
|
||||
if (changeTanMethodTo != null) {
|
||||
return "User asks to change TAN method to $changeTanMethodTo"
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package net.codinux.banking.fints.model
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import net.codinux.banking.fints.tan.FlickerCode
|
||||
|
||||
|
||||
|
@ -11,8 +12,9 @@ open class FlickerCodeTanChallenge(
|
|||
tanMethod: TanMethod,
|
||||
tanMediaIdentifier: String?,
|
||||
bank: BankData,
|
||||
account: AccountData? = null
|
||||
) : TanChallenge(forAction, messageToShowToUser, challenge, tanMethod, tanMediaIdentifier, bank, account) {
|
||||
account: AccountData? = null,
|
||||
tanExpirationTime: Instant? = null
|
||||
) : TanChallenge(forAction, messageToShowToUser, challenge, tanMethod, tanMediaIdentifier, bank, account, tanExpirationTime) {
|
||||
|
||||
override fun toString(): String {
|
||||
return "$tanMethod (medium: $tanMediaIdentifier) $flickerCode: $messageToShowToUser"
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package net.codinux.banking.fints.model
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import net.codinux.banking.fints.tan.TanImage
|
||||
|
||||
|
||||
|
@ -11,8 +12,9 @@ open class ImageTanChallenge(
|
|||
tanMethod: TanMethod,
|
||||
tanMediaIdentifier: String?,
|
||||
bank: BankData,
|
||||
account: AccountData? = null
|
||||
) : TanChallenge(forAction, messageToShowToUser, challenge, tanMethod, tanMediaIdentifier, bank, account) {
|
||||
account: AccountData? = null,
|
||||
tanExpirationTime: Instant? = null
|
||||
) : TanChallenge(forAction, messageToShowToUser, challenge, tanMethod, tanMediaIdentifier, bank, account, tanExpirationTime) {
|
||||
|
||||
override fun toString(): String {
|
||||
return "$tanMethod (medium: $tanMediaIdentifier) $image: $messageToShowToUser"
|
||||
|
|
|
@ -25,6 +25,9 @@ open class JobContext(
|
|||
* Only set if the current context is for a specific account (like get account's transactions).
|
||||
*/
|
||||
open val account: AccountData? = null,
|
||||
open val preferredTanMethods: List<TanMethodType>? = null,
|
||||
tanMethodsNotSupportedByApplication: List<TanMethodType>? = null,
|
||||
open val preferredTanMedium: String? = null,
|
||||
protected open val messageLogCollector: MessageLogCollector = MessageLogCollector(callback, config.options)
|
||||
) : MessageBaseData(bank, config.options.product), IMessageLogAppender {
|
||||
|
||||
|
@ -35,6 +38,8 @@ open class JobContext(
|
|||
|
||||
protected open val _dialogs = mutableListOf<DialogContext>()
|
||||
|
||||
open val tanMethodsNotSupportedByApplication: List<TanMethodType> = tanMethodsNotSupportedByApplication ?: emptyList()
|
||||
|
||||
open val mt940Parser: IAccountTransactionsParser = Mt940AccountTransactionsParser(Mt940Parser(this), this)
|
||||
|
||||
open val responseParser: ResponseParser = ResponseParser(logAppender = this)
|
||||
|
@ -55,7 +60,7 @@ open class JobContext(
|
|||
protected open var dialogNumber: Int = 0
|
||||
|
||||
|
||||
open fun startNewDialog(closeDialog: Boolean = true, dialogId: String = DialogContext.InitialDialogId,
|
||||
open fun startNewDialog(closeDialog: Boolean = config.options.closeDialogs, dialogId: String = DialogContext.InitialDialogId,
|
||||
versionOfSecurityProcedure: VersionDesSicherheitsverfahrens = VersionDesSicherheitsverfahrens.Version_2,
|
||||
chunkedResponseHandler: ((BankResponse) -> Unit)? = dialog.chunkedResponseHandler) : DialogContext {
|
||||
|
||||
|
@ -77,7 +82,7 @@ open class JobContext(
|
|||
messageLogCollector.addMessageLog(type, message, createMessageContext(), parsedSegments)
|
||||
}
|
||||
|
||||
override fun logError(loggingClass: KClass<*>, message: String, e: Exception?) {
|
||||
override fun logError(loggingClass: KClass<*>, message: String, e: Throwable?) {
|
||||
messageLogCollector.logError(loggingClass, message, createMessageContext(), e)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package net.codinux.banking.fints.model
|
||||
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import net.codinux.banking.fints.extensions.nowExt
|
||||
import net.codinux.banking.fints.log.MessageContext
|
||||
import net.codinux.banking.fints.response.segments.ReceivedSegment
|
||||
|
||||
|
@ -11,6 +11,7 @@ open class MessageLogEntry(
|
|||
open val context: MessageContext,
|
||||
open val messageTrace: String,
|
||||
open val message: String,
|
||||
open val messageWithoutSensitiveData: String? = null,
|
||||
open val error: Throwable? = null,
|
||||
/**
|
||||
* Parsed received segments.
|
||||
|
@ -18,14 +19,14 @@ open class MessageLogEntry(
|
|||
* Is only set if [type] is set to [MessageLogEntryType.Received] and response parsing was successful.
|
||||
*/
|
||||
open val parsedSegments: List<ReceivedSegment> = emptyList(),
|
||||
open val time: Instant = Clock.System.now()
|
||||
open val time: Instant = Instant.nowExt()
|
||||
) {
|
||||
|
||||
val messageIncludingMessageTrace: String
|
||||
get() = messageTrace + "\n" + message
|
||||
|
||||
override fun toString(): String {
|
||||
return "$type $message"
|
||||
return "$context $type $message"
|
||||
}
|
||||
|
||||
}
|
|
@ -15,6 +15,8 @@ enum class MessageType {
|
|||
|
||||
SynchronizeCustomerSystemId,
|
||||
|
||||
CheckDecoupledTanStatus,
|
||||
|
||||
Tan,
|
||||
|
||||
GetBalance,
|
||||
|
@ -23,6 +25,8 @@ enum class MessageType {
|
|||
|
||||
GetCreditCardTransactions,
|
||||
|
||||
GetSecuritiesAccountBalance,
|
||||
|
||||
TransferMoney
|
||||
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package net.codinux.banking.fints.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
open class PinInfo(
|
||||
val minPinLength: Int?,
|
||||
val maxPinLength: Int?,
|
||||
val minTanLength: Int?,
|
||||
val userIdHint: String?,
|
||||
val customerIdHint: String?
|
||||
)
|
|
@ -1,6 +1,8 @@
|
|||
package net.codinux.banking.fints.model
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
||||
import net.codinux.banking.fints.transactions.swift.model.StatementOfHoldings
|
||||
|
||||
|
||||
open class RetrievedAccountData(
|
||||
|
@ -9,6 +11,8 @@ open class RetrievedAccountData(
|
|||
open val balance: Money?,
|
||||
open var bookedTransactions: Collection<AccountTransaction>,
|
||||
open var unbookedTransactions: Collection<Any>,
|
||||
open var statementOfHoldings: List<StatementOfHoldings>,
|
||||
open val retrievalTime: Instant,
|
||||
open val retrievedTransactionsFrom: LocalDate?,
|
||||
open val retrievedTransactionsTo: LocalDate?,
|
||||
open val errorMessage: String? = null
|
||||
|
@ -17,7 +21,7 @@ open class RetrievedAccountData(
|
|||
companion object {
|
||||
|
||||
fun unsuccessful(account: AccountData): RetrievedAccountData {
|
||||
return RetrievedAccountData(account, false, null, listOf(), listOf(), null, null)
|
||||
return RetrievedAccountData(account, false, null, listOf(), listOf(), listOf(), Instant.DISTANT_PAST, null, null)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
package net.codinux.banking.fints.model
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import net.codinux.banking.fints.extensions.nowExt
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
|
||||
import net.codinux.banking.fints.response.BankResponse
|
||||
import net.codinux.banking.fints.response.client.FinTsClientResponse
|
||||
import net.codinux.log.Log
|
||||
|
||||
|
||||
open class TanChallenge(
|
||||
|
@ -11,7 +15,14 @@ open class TanChallenge(
|
|||
val tanMethod: TanMethod,
|
||||
val tanMediaIdentifier: String?,
|
||||
val bank: BankData,
|
||||
val account: AccountData? = null
|
||||
val account: AccountData? = null,
|
||||
/**
|
||||
* Datum und Uhrzeit, bis zu welchem Zeitpunkt eine TAN auf Basis der gesendeten Challenge gültig ist. Nach Ablauf der Gültigkeitsdauer wird die entsprechende TAN entwertet.
|
||||
*
|
||||
* In server's time zone, that is Europe/Berlin.
|
||||
*/
|
||||
val tanExpirationTime: Instant? = null,
|
||||
val challengeCreationTimestamp: Instant = Instant.nowExt()
|
||||
) {
|
||||
|
||||
var enterTanResult: EnterTanResult? = null
|
||||
|
@ -20,21 +31,81 @@ open class TanChallenge(
|
|||
open val isEnteringTanDone: Boolean
|
||||
get() = enterTanResult != null
|
||||
|
||||
private val tanExpiredCallbacks = mutableListOf<() -> Unit>()
|
||||
|
||||
private val userApprovedDecoupledTanCallbacks = mutableListOf<() -> Unit>()
|
||||
|
||||
|
||||
fun userEnteredTan(enteredTan: String) {
|
||||
this.enterTanResult = EnterTanResult(enteredTan.replace(" ", ""))
|
||||
}
|
||||
|
||||
internal fun userApprovedDecoupledTan(responseAfterApprovingDecoupledTan: BankResponse) {
|
||||
this.enterTanResult = EnterTanResult(null, true, responseAfterApprovingDecoupledTan)
|
||||
|
||||
userApprovedDecoupledTanCallbacks.toTypedArray().forEach { // copy to avoid ConcurrentModificationException
|
||||
try {
|
||||
it.invoke()
|
||||
} catch (e: Throwable) {
|
||||
Log.error(e) { "Could not call userApprovedDecoupledTanCallback" }
|
||||
}
|
||||
}
|
||||
clearUserApprovedDecoupledTanCallbacks()
|
||||
}
|
||||
|
||||
fun userDidNotEnterTan() {
|
||||
clearUserApprovedDecoupledTanCallbacks()
|
||||
|
||||
this.enterTanResult = EnterTanResult(null)
|
||||
}
|
||||
|
||||
internal fun tanExpired() {
|
||||
tanExpiredCallbacks.toTypedArray().forEach {
|
||||
try {
|
||||
it.invoke()
|
||||
} catch (e: Throwable) {
|
||||
Log.error(e) { "Could not call tanExpiredCallback" }
|
||||
}
|
||||
}
|
||||
|
||||
clearTanExpiredCallbacks()
|
||||
|
||||
userDidNotEnterTan()
|
||||
}
|
||||
|
||||
fun userAsksToChangeTanMethod(changeTanMethodTo: TanMethod) {
|
||||
this.enterTanResult = EnterTanResult(null, changeTanMethodTo)
|
||||
clearUserApprovedDecoupledTanCallbacks()
|
||||
|
||||
this.enterTanResult = EnterTanResult(null, changeTanMethodTo = changeTanMethodTo)
|
||||
}
|
||||
|
||||
fun userAsksToChangeTanMedium(changeTanMediumTo: TanMedium, changeTanMediumResultCallback: ((FinTsClientResponse) -> Unit)?) {
|
||||
this.enterTanResult = EnterTanResult(null, null, changeTanMediumTo, changeTanMediumResultCallback)
|
||||
clearUserApprovedDecoupledTanCallbacks()
|
||||
|
||||
this.enterTanResult = EnterTanResult(null, changeTanMediumTo = changeTanMediumTo, changeTanMediumResultCallback = changeTanMediumResultCallback)
|
||||
}
|
||||
|
||||
|
||||
fun addTanExpiredCallback(callback: () -> Unit) {
|
||||
if (isEnteringTanDone == false) {
|
||||
this.tanExpiredCallbacks.add(callback)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun clearTanExpiredCallbacks() {
|
||||
tanExpiredCallbacks.clear()
|
||||
}
|
||||
|
||||
fun addUserApprovedDecoupledTanCallback(callback: () -> Unit) {
|
||||
if (isEnteringTanDone == false) {
|
||||
this.userApprovedDecoupledTanCallbacks.add(callback)
|
||||
} else if (enterTanResult != null && enterTanResult!!.userApprovedDecoupledTan == true) {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun clearUserApprovedDecoupledTanCallbacks() {
|
||||
userApprovedDecoupledTanCallbacks.clear()
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ open class TanMethod(
|
|||
open val maxTanInputLength: Int? = null,
|
||||
open val allowedTanFormat: AllowedTanFormat? = null,
|
||||
open val nameOfTanMediumRequired: Boolean = false,
|
||||
open val hktanVersion: Int = 6,
|
||||
open val decoupledParameters: DecoupledTanMethodParameters? = null
|
||||
) {
|
||||
|
||||
|
|
|
@ -19,6 +19,10 @@ enum class TanMethodType {
|
|||
|
||||
AppTan,
|
||||
|
||||
DecoupledTan,
|
||||
|
||||
DecoupledPushTan,
|
||||
|
||||
photoTan,
|
||||
|
||||
QrCode
|
||||
|
|
|
@ -11,6 +11,7 @@ import net.codinux.banking.fints.model.*
|
|||
import net.codinux.banking.fints.response.BankResponse
|
||||
import net.codinux.banking.fints.response.InstituteSegmentId
|
||||
import net.codinux.banking.fints.response.segments.*
|
||||
import net.codinux.banking.fints.response.segments.PinInfo
|
||||
|
||||
|
||||
open class ModelMapper(
|
||||
|
@ -34,11 +35,13 @@ open class ModelMapper(
|
|||
}
|
||||
|
||||
response.getFirstSegmentById<PinInfo>(InstituteSegmentId.PinInfo)?.let { pinInfo ->
|
||||
bank.pinInfo = pinInfo
|
||||
bank.pinInfo = net.codinux.banking.fints.model.PinInfo(pinInfo.minPinLength, pinInfo.maxPinLength, pinInfo.minTanLength, pinInfo.userIdHint, pinInfo.customerIdHint)
|
||||
bank.jobsRequiringTan = pinInfo.jobTanConfiguration.filter { it.tanRequired }.map { it.segmentId }.toHashSet()
|
||||
}
|
||||
|
||||
response.getFirstSegmentById<TanInfo>(InstituteSegmentId.TanInfo)?.let { tanInfo ->
|
||||
bank.tanMethodsSupportedByBank = mapToTanMethods(tanInfo)
|
||||
val tanInfos = response.getSegmentsById<TanInfo>(InstituteSegmentId.TanInfo)
|
||||
if (tanInfos.isNotEmpty()) {
|
||||
bank.tanMethodsSupportedByBank = tanInfos.flatMap { tanInfo -> mapToTanMethods(tanInfo) }
|
||||
}
|
||||
|
||||
response.getFirstSegmentById<CommunicationInfo>(InstituteSegmentId.CommunicationInfo)?.let { communicationInfo ->
|
||||
|
@ -53,16 +56,12 @@ open class ModelMapper(
|
|||
}
|
||||
}
|
||||
|
||||
response.getFirstSegmentById<ChangeTanMediaParameters>(InstituteSegmentId.ChangeTanMediaParameters)?.let { parameters ->
|
||||
bank.changeTanMediumParameters = parameters
|
||||
}
|
||||
|
||||
if (response.supportedJobs.isNotEmpty()) {
|
||||
bank.supportedJobs = response.supportedJobs
|
||||
}
|
||||
}
|
||||
|
||||
open fun updateCustomerData(bank: BankData, response: BankResponse) {
|
||||
open fun updateCustomerData(bank: BankData, response: BankResponse, context: JobContext) {
|
||||
response.getFirstSegmentById<BankParameters>(InstituteSegmentId.BankParameters)?.let { bankParameters ->
|
||||
// TODO: ask user if there is more than one supported language? But it seems that almost all banks only support German.
|
||||
if (bank.selectedLanguage == Dialogsprache.Default && bankParameters.supportedLanguages.isNotEmpty()) {
|
||||
|
@ -101,7 +100,7 @@ open class ModelMapper(
|
|||
accountInfo.accountLimit, accountInfo.allowedJobNames)
|
||||
|
||||
bank.supportedJobs.filterIsInstance<RetrieveAccountTransactionsParameters>().sortedByDescending { it.segmentVersion }.firstOrNull { newAccount.allowedJobNames.contains(it.jobName) }?.let { transactionsParameters ->
|
||||
newAccount.countDaysForWhichTransactionsAreKept = transactionsParameters.countDaysForWhichTransactionsAreKept
|
||||
newAccount.serverTransactionsRetentionDays = transactionsParameters.serverTransactionsRetentionDays
|
||||
}
|
||||
|
||||
bank.addAccount(newAccount)
|
||||
|
@ -145,6 +144,7 @@ open class ModelMapper(
|
|||
|
||||
if (response.supportedTanMethodsForUser.isNotEmpty()) {
|
||||
bank.tanMethodsAvailableForUser = response.supportedTanMethodsForUser.mapNotNull { findTanMethod(it, bank) }
|
||||
.filterNot { context.tanMethodsNotSupportedByApplication.contains(it.type) }
|
||||
|
||||
if (bank.tanMethodsAvailableForUser.firstOrNull { it.securityFunction == bank.selectedTanMethod.securityFunction } == null) { // supportedTanMethods don't contain selectedTanMethod anymore
|
||||
bank.resetSelectedTanMethod()
|
||||
|
@ -152,7 +152,7 @@ open class ModelMapper(
|
|||
}
|
||||
}
|
||||
|
||||
protected open fun findTanMethod(securityFunction: Sicherheitsfunktion, bank: BankData): TanMethod? {
|
||||
open fun findTanMethod(securityFunction: Sicherheitsfunktion, bank: BankData): TanMethod? {
|
||||
return bank.tanMethodsSupportedByBank.firstOrNull { it.securityFunction == securityFunction }
|
||||
}
|
||||
|
||||
|
@ -173,13 +173,13 @@ open class ModelMapper(
|
|||
account.setSupportsFeature(AccountFeature.RealTimeTransfer, messageBuilder.supportsSepaRealTimeTransfer(bank, account))
|
||||
}
|
||||
|
||||
protected open fun mapToTanMethods(tanInfo: TanInfo): List<TanMethod> {
|
||||
open fun mapToTanMethods(tanInfo: TanInfo): List<TanMethod> {
|
||||
return tanInfo.tanProcedureParameters.methodParameters.mapNotNull {
|
||||
mapToTanMethod(it)
|
||||
mapToTanMethod(it, tanInfo.segmentVersion)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun mapToTanMethod(parameters: TanMethodParameters): TanMethod? {
|
||||
protected open fun mapToTanMethod(parameters: TanMethodParameters, hktanVersion: Int): TanMethod? {
|
||||
val methodName = parameters.methodName
|
||||
|
||||
// we filter out iTAN and Einschritt-Verfahren as they are not permitted anymore according to PSD2
|
||||
|
@ -191,7 +191,7 @@ open class ModelMapper(
|
|||
mapToTanMethodType(parameters) ?: TanMethodType.EnterTan, mapHhdVersion(parameters),
|
||||
parameters.maxTanInputLength, parameters.allowedTanFormat,
|
||||
parameters.nameOfTanMediumRequired == BezeichnungDesTanMediumsErforderlich.BezeichnungDesTanMediumsMussAngegebenWerden,
|
||||
mapDecoupledTanMethodParameters(parameters))
|
||||
hktanVersion, mapDecoupledTanMethodParameters(parameters))
|
||||
}
|
||||
|
||||
protected open fun mapToTanMethodType(parameters: TanMethodParameters): TanMethodType? {
|
||||
|
@ -229,6 +229,10 @@ open class ModelMapper(
|
|||
|
||||
tanMethodNameContains(name, "SMS", "mobile", "mTAN") -> TanMethodType.SmsTan
|
||||
|
||||
parameters.dkTanMethod == DkTanMethod.Decoupled -> TanMethodType.DecoupledTan
|
||||
|
||||
parameters.dkTanMethod == DkTanMethod.DecoupledPush -> TanMethodType.DecoupledPushTan
|
||||
|
||||
// 'flateXSecure' identifies itself as 'PPTAN' instead of 'AppTAN'
|
||||
// 'activeTAN-Verfahren' can actually be used either with an app or a reader; it's like chipTAN QR but without a chip card
|
||||
parameters.dkTanMethod == DkTanMethod.App
|
||||
|
@ -275,10 +279,10 @@ open class ModelMapper(
|
|||
parameters.manualConfirmationAllowedForDecoupled?.let { manualConfirmationAllowed ->
|
||||
return DecoupledTanMethodParameters(
|
||||
manualConfirmationAllowed,
|
||||
parameters.periodicStateRequestsAllowedForDecoupled ?: false, // this and the following values are all set when manualConfirmationAllowedForDecoupled is set
|
||||
parameters.periodicDecoupledStateRequestsAllowed ?: false, // this and the following values are all set when manualConfirmationAllowedForDecoupled is set
|
||||
parameters.maxNumberOfStateRequestsForDecoupled ?: 0,
|
||||
parameters.initialDelayInSecondsForStateRequestsForDecoupled ?: Int.MAX_VALUE,
|
||||
parameters.delayInSecondsForNextStateRequestsForDecoupled ?: Int.MAX_VALUE
|
||||
parameters.initialDelayInSecondsForDecoupledStateRequest ?: Int.MAX_VALUE,
|
||||
parameters.delayInSecondsForNextDecoupledStateRequests ?: Int.MAX_VALUE
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -41,8 +41,17 @@ enum class InstituteSegmentId(override val id: String) : ISegmentId {
|
|||
|
||||
AccountTransactionsMt940Parameters(AccountTransactionsMt940.id + "S"),
|
||||
|
||||
AccountTransactionsCamt("HICAZ"),
|
||||
|
||||
AccountTransactionsCamtParameters(AccountTransactionsCamt.id + "S"),
|
||||
|
||||
CreditCardTransactions("DIKKU"),
|
||||
|
||||
CreditCardTransactionsParameters(CreditCardTransactions.id + "S")
|
||||
CreditCardTransactionsParameters(CreditCardTransactions.id + "S"),
|
||||
|
||||
|
||||
/* Wertpapierdepot */
|
||||
|
||||
SecuritiesAccountBalance("HIWPD")
|
||||
|
||||
}
|
|
@ -1,9 +1,7 @@
|
|||
package net.codinux.banking.fints.response
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.*
|
||||
import net.codinux.banking.fints.extensions.EuropeBerlin
|
||||
import net.codinux.log.logger
|
||||
import net.codinux.banking.fints.log.IMessageLogAppender
|
||||
import net.codinux.banking.fints.messages.Separators
|
||||
|
@ -26,11 +24,14 @@ import net.codinux.banking.fints.model.Money
|
|||
import net.codinux.banking.fints.response.segments.*
|
||||
import net.codinux.banking.fints.util.MessageUtils
|
||||
import net.codinux.banking.fints.extensions.getAllExceptionMessagesJoined
|
||||
import net.codinux.banking.fints.transactions.swift.Mt535Parser
|
||||
import net.dankito.banking.client.model.BankAccountIdentifier
|
||||
|
||||
|
||||
open class ResponseParser(
|
||||
protected open val messageUtils: MessageUtils = MessageUtils(),
|
||||
open var logAppender: IMessageLogAppender? = null
|
||||
open var logAppender: IMessageLogAppender? = null,
|
||||
open var mt535Parser: Mt535Parser = Mt535Parser(logAppender)
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
@ -118,10 +119,14 @@ open class ResponseParser(
|
|||
InstituteSegmentId.ChangeTanMediaParameters.id -> parseChangeTanMediaParameters(segment, segmentId, dataElementGroups)
|
||||
|
||||
InstituteSegmentId.Balance.id -> parseBalanceSegment(segment, dataElementGroups)
|
||||
InstituteSegmentId.SecuritiesAccountBalance.id -> parseSecuritiesAccountBalanceSegment(segment, dataElementGroups)
|
||||
|
||||
InstituteSegmentId.AccountTransactionsMt940.id -> parseMt940AccountTransactions(segment, dataElementGroups)
|
||||
InstituteSegmentId.AccountTransactionsMt940Parameters.id -> parseMt940AccountTransactionsParameters(segment, segmentId, dataElementGroups)
|
||||
|
||||
// InstituteSegmentId.AccountTransactionsCamt.id -> parseCamtAccountTransactions(segment, dataElementGroups)
|
||||
InstituteSegmentId.AccountTransactionsCamtParameters.id -> parseCamtAccountTransactionsParameters(segment, segmentId, dataElementGroups)
|
||||
|
||||
InstituteSegmentId.CreditCardTransactions.id -> parseCreditCardTransactions(segment, dataElementGroups)
|
||||
InstituteSegmentId.CreditCardTransactionsParameters.id -> parseCreditCardTransactionsParameters(segment, segmentId, dataElementGroups)
|
||||
|
||||
|
@ -431,7 +436,7 @@ open class ResponseParser(
|
|||
}
|
||||
|
||||
if (parsedMethodParameters.dkTanMethod == DkTanMethod.Decoupled) {
|
||||
if (parsedMethodParameters.periodicStateRequestsAllowedForDecoupled != null) {
|
||||
if (parsedMethodParameters.periodicDecoupledStateRequestsAllowed != null) {
|
||||
return 26
|
||||
}
|
||||
else if (parsedMethodParameters.manualConfirmationAllowedForDecoupled != null) {
|
||||
|
@ -577,7 +582,7 @@ open class ResponseParser(
|
|||
if (dataElementGroups.size > 3) parseStringToNullIfEmpty(dataElementGroups[3]) else null,
|
||||
if (dataElementGroups.size > 4) parseStringToNullIfEmpty(dataElementGroups[4]) else null,
|
||||
binaryChallengeHHD_UC?.let { extractBinaryData(it) },
|
||||
if (dataElementGroups.size > 6) parseNullableDateTime(dataElementGroups[6]) else null,
|
||||
if (dataElementGroups.size > 6) parseNullableDateTime(dataElementGroups[6])?.toInstant(TimeZone.EuropeBerlin) else null,
|
||||
if (dataElementGroups.size > 7) parseStringToNullIfEmpty(dataElementGroups[7]) else null,
|
||||
segment
|
||||
)
|
||||
|
@ -612,34 +617,32 @@ open class ResponseParser(
|
|||
|
||||
val mediumName = if (hitabVersion < 2) null else parseStringToNullIfEmpty(remainingDataElements[10])
|
||||
|
||||
return when (mediumClass) {
|
||||
TanMediumKlasse.TanGenerator -> parseTanGeneratorTanMedium(mediumClass, status, mediumName, hitabVersion, remainingDataElements)
|
||||
TanMediumKlasse.MobiltelefonMitMobileTan -> parseMobilePhoneTanMedium(mediumClass, status, mediumName, hitabVersion, remainingDataElements)
|
||||
else -> TanMedium(mediumClass, status, mediumName) // Sparkasse sends for pushTan now class 'AlleMedien' -> set medium name and everything just works fine
|
||||
}
|
||||
val tanGenerator = if (mediumClass == TanMediumKlasse.TanGenerator) parseTanGeneratorTanMedium(hitabVersion, remainingDataElements)
|
||||
else null
|
||||
val mobilePhone = if (mediumClass == TanMediumKlasse.MobiltelefonMitMobileTan) parseMobilePhoneTanMedium(hitabVersion, remainingDataElements)
|
||||
else null
|
||||
|
||||
return TanMedium(mediumClass, status, mediumName, tanGenerator, mobilePhone) // Sparkasse sends for pushTan now class 'AlleMedien' -> set medium name and everything just works fine
|
||||
}
|
||||
|
||||
protected open fun parseTanGeneratorTanMedium(mediumClass: TanMediumKlasse, status: TanMediumStatus, mediumName: String?,
|
||||
hitabVersion: Int, dataElements: List<String>): TanGeneratorTanMedium {
|
||||
protected open fun parseTanGeneratorTanMedium(hitabVersion: Int, dataElements: List<String>): TanGeneratorTanMedium {
|
||||
|
||||
val cardType = if (hitabVersion < 2) null else parseNullableInt(dataElements[2])
|
||||
// TODO: may also parse account info
|
||||
val validFrom = if (hitabVersion < 2) null else parseNullableDate(dataElements[8])
|
||||
val validTo = if (hitabVersion < 2) null else parseNullableDate(dataElements[9])
|
||||
|
||||
return TanGeneratorTanMedium(mediumClass, status, parseString(dataElements[0]), parseStringToNullIfEmpty(dataElements[1]),
|
||||
cardType, validFrom, validTo, mediumName)
|
||||
return TanGeneratorTanMedium(parseString(dataElements[0]), parseStringToNullIfEmpty(dataElements[1]),
|
||||
cardType, validFrom, validTo)
|
||||
}
|
||||
|
||||
protected open fun parseMobilePhoneTanMedium(mediumClass: TanMediumKlasse, status: TanMediumStatus, mediumName: String?,
|
||||
hitabVersion: Int, dataElements: List<String>): MobilePhoneTanMedium {
|
||||
protected open fun parseMobilePhoneTanMedium(hitabVersion: Int, dataElements: List<String>): MobilePhoneTanMedium {
|
||||
|
||||
val concealedPhoneNumber = if (hitabVersion < 2) null else parseStringToNullIfEmpty(dataElements[11])
|
||||
val phoneNumber = if (hitabVersion < 2) null else parseStringToNullIfEmpty(dataElements[12])
|
||||
val smsDebitAccount: KontoverbindungInternational? = null // TODO: may parse 13th data element to KontoverbindungInternational
|
||||
val smsDebitAccount: BankAccountIdentifier? = null // TODO: may parse 13th data element to KontoverbindungInternational and map to BankAccountIdentifier
|
||||
|
||||
// mediumName should actually never be unset according to spec
|
||||
return MobilePhoneTanMedium(mediumClass, status, mediumName ?: "", concealedPhoneNumber, phoneNumber, smsDebitAccount)
|
||||
return MobilePhoneTanMedium(concealedPhoneNumber, phoneNumber, smsDebitAccount)
|
||||
}
|
||||
|
||||
|
||||
|
@ -692,6 +695,17 @@ open class ResponseParser(
|
|||
)
|
||||
}
|
||||
|
||||
protected open fun parseSecuritiesAccountBalanceSegment(segment: String, dataElementGroups: List<String>): SecuritiesAccountBalanceSegment {
|
||||
// 1 Segmentkopf 1 DEG M 1
|
||||
// 2 Depotaufstellung 1 DE bin .. M 1
|
||||
|
||||
val balancesMt535String = extractBinaryData(dataElementGroups[1])
|
||||
// TODO: for larger portfolios there can be a Aufsetzpunkt, but for balances we currently do not support sending multiple messages
|
||||
val statementOfHoldings = mt535Parser.parseMt535String(balancesMt535String)
|
||||
|
||||
return SecuritiesAccountBalanceSegment(statementOfHoldings, segment)
|
||||
}
|
||||
|
||||
protected open fun parseBalanceToNullIfZeroOrNotSet(dataElementGroup: String): Balance? {
|
||||
if (dataElementGroup.isEmpty()) {
|
||||
return null
|
||||
|
@ -748,11 +762,25 @@ open class ResponseParser(
|
|||
val transactionsParameterIndex = if (jobParameters.segmentVersion >= 6) 4 else 3
|
||||
val dataElements = getDataElements(dataElementGroups[transactionsParameterIndex])
|
||||
|
||||
val countDaysForWhichTransactionsAreKept = parseInt(dataElements[0])
|
||||
val serverTransactionsRetentionDays = parseInt(dataElements[0])
|
||||
val settingCountEntriesAllowed = parseBoolean(dataElements[1])
|
||||
val settingAllAccountAllowed = if (dataElements.size > 2) parseBoolean(dataElements[2]) else false
|
||||
|
||||
return RetrieveAccountTransactionsParameters(jobParameters, countDaysForWhichTransactionsAreKept, settingCountEntriesAllowed, settingAllAccountAllowed)
|
||||
return RetrieveAccountTransactionsParameters(jobParameters, serverTransactionsRetentionDays, settingCountEntriesAllowed, settingAllAccountAllowed)
|
||||
}
|
||||
|
||||
protected open fun parseCamtAccountTransactionsParameters(segment: String, segmentId: String, dataElementGroups: List<String>): RetrieveAccountTransactionsParameters {
|
||||
val jobParameters = parseJobParameters(segment, segmentId, dataElementGroups)
|
||||
|
||||
val dataElements = getDataElements(dataElementGroups[4])
|
||||
|
||||
val serverTransactionsRetentionDays = parseInt(dataElements[0])
|
||||
val settingCountEntriesAllowed = parseBoolean(dataElements[1])
|
||||
val settingAllAccountAllowed = parseBoolean(dataElements[2])
|
||||
|
||||
val supportedCamtDataFormats = dataElements.subList(3, dataElements.size)
|
||||
|
||||
return RetrieveAccountTransactionsParameters(jobParameters, serverTransactionsRetentionDays, settingCountEntriesAllowed, settingAllAccountAllowed, supportedCamtDataFormats)
|
||||
}
|
||||
|
||||
|
||||
|
@ -805,11 +833,11 @@ open class ResponseParser(
|
|||
val transactionsParameterIndex = if (jobParameters.segmentVersion >= 2) 4 else 3 // TODO: check if at segment version 1 the transactions parameter are the third data elements group
|
||||
val dataElements = getDataElements(dataElementGroups[transactionsParameterIndex])
|
||||
|
||||
val countDaysForWhichTransactionsAreKept = parseInt(dataElements[0])
|
||||
val serverTransactionsRetentionDays = parseInt(dataElements[0])
|
||||
val settingCountEntriesAllowed = parseBoolean(dataElements[1])
|
||||
val settingAllAccountAllowed = if (dataElements.size > 2) parseBoolean(dataElements[2]) else false
|
||||
|
||||
return RetrieveAccountTransactionsParameters(jobParameters, countDaysForWhichTransactionsAreKept, settingCountEntriesAllowed, settingAllAccountAllowed)
|
||||
return RetrieveAccountTransactionsParameters(jobParameters, serverTransactionsRetentionDays, settingCountEntriesAllowed, settingAllAccountAllowed)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,17 +1,13 @@
|
|||
package net.codinux.banking.fints.response.segments
|
||||
|
||||
import net.codinux.banking.fints.messages.Separators
|
||||
import kotlin.jvm.Transient
|
||||
|
||||
|
||||
open class ReceivedSegment(
|
||||
open val segmentId: String,
|
||||
@Transient
|
||||
open val segmentNumber: Int,
|
||||
open val segmentVersion: Int,
|
||||
@Transient
|
||||
open val referenceSegmentNumber: Int? = null,
|
||||
@Transient
|
||||
open val segmentString: String
|
||||
) {
|
||||
|
||||
|
|
|
@ -3,11 +3,16 @@ package net.codinux.banking.fints.response.segments
|
|||
|
||||
open class RetrieveAccountTransactionsParameters(
|
||||
parameters: JobParameters,
|
||||
open val countDaysForWhichTransactionsAreKept: Int,
|
||||
open val serverTransactionsRetentionDays: Int,
|
||||
open val settingCountEntriesAllowed: Boolean,
|
||||
open val settingAllAccountAllowed: Boolean
|
||||
open val settingAllAccountAllowed: Boolean,
|
||||
open val supportedCamtDataFormats: List<String> = emptyList()
|
||||
) : JobParameters(parameters) {
|
||||
|
||||
internal constructor() : this(JobParameters(), -1, false, false) // for object deserializers
|
||||
|
||||
// for languages not supporting default parameters
|
||||
constructor(parameters: JobParameters, serverTransactionsRetentionDays: Int, settingCountEntriesAllowed: Boolean, settingAllAccountAllowed: Boolean) :
|
||||
this(parameters, serverTransactionsRetentionDays, settingCountEntriesAllowed, settingAllAccountAllowed, emptyList())
|
||||
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package net.codinux.banking.fints.response.segments
|
||||
|
||||
import net.codinux.banking.fints.transactions.swift.model.StatementOfHoldings
|
||||
|
||||
class SecuritiesAccountBalanceSegment(
|
||||
val statementOfHoldings: List<StatementOfHoldings>,
|
||||
segmentString: String
|
||||
)
|
||||
: ReceivedSegment(segmentString)
|
|
@ -27,10 +27,10 @@ open class TanMethodParameters(
|
|||
val hhdUcResponseRequired: Boolean, // TODO: wird hierueber gesteuert ob eine TAN eingegeben werden muss (z. B. beim EasyTAN Verfahren muss ja keine eingegeben werden)
|
||||
val countSupportedActiveTanMedia: Int?,
|
||||
val maxNumberOfStateRequestsForDecoupled: Int? = null,
|
||||
val initialDelayInSecondsForStateRequestsForDecoupled: Int? = null,
|
||||
val delayInSecondsForNextStateRequestsForDecoupled: Int? = null,
|
||||
val initialDelayInSecondsForDecoupledStateRequest: Int? = null,
|
||||
val delayInSecondsForNextDecoupledStateRequests: Int? = null,
|
||||
val manualConfirmationAllowedForDecoupled: Boolean? = null,
|
||||
val periodicStateRequestsAllowedForDecoupled: Boolean? = null
|
||||
val periodicDecoupledStateRequestsAllowed: Boolean? = null
|
||||
) {
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package net.codinux.banking.fints.response.segments
|
||||
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.Instant
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanProcess
|
||||
|
||||
|
||||
|
@ -31,7 +31,13 @@ open class TanResponse(
|
|||
val challenge: String?, // M: bei TAN-Prozess=1, 3, 4. O: bei TAN-Prozess=2
|
||||
|
||||
val challengeHHD_UC: String?,
|
||||
val validityDateTimeForChallenge: LocalDateTime?,
|
||||
|
||||
/**
|
||||
* Datum und Uhrzeit, bis zu welchem Zeitpunkt eine TAN auf Basis der gesendeten Challenge gültig ist. Nach Ablauf der Gültigkeitsdauer wird die entsprechende TAN entwertet.
|
||||
*
|
||||
* In server's time zone, that is Europe/Berlin.
|
||||
*/
|
||||
val tanExpirationTime: Instant?,
|
||||
val tanMediaIdentifier: String? = null, // M: bei TAN-Prozess=1, 3, 4 und „Anzahl unterstützter aktiver TAN-Medien“ nicht vorhanden. O: sonst
|
||||
|
||||
segmentString: String
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
package net.codinux.banking.fints.serialization
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import net.codinux.banking.fints.model.BankData
|
||||
|
||||
object BankDataSerializer : KSerializer<BankData> {
|
||||
|
||||
private val serializer = SerializedFinTsData.serializer()
|
||||
|
||||
private val mapper = SerializedFinTsDataMapper()
|
||||
|
||||
|
||||
override val descriptor: SerialDescriptor = serializer.descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: BankData) {
|
||||
val surrogate = mapper.map(value)
|
||||
|
||||
encoder.encodeSerializableValue(serializer, surrogate)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): BankData {
|
||||
val surrogate = decoder.decodeSerializableValue(serializer)
|
||||
|
||||
return mapper.map(surrogate)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
package net.codinux.banking.fints.serialization
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import net.codinux.banking.fints.model.BankData
|
||||
import net.codinux.log.logger
|
||||
|
||||
object FinTsModelSerializer {
|
||||
|
||||
private val json: Json by lazy {
|
||||
Json { this.ignoreUnknownKeys = true }
|
||||
}
|
||||
|
||||
private val prettyPrintJson by lazy {
|
||||
Json {
|
||||
this.ignoreUnknownKeys = true
|
||||
this.prettyPrint = true
|
||||
}
|
||||
}
|
||||
|
||||
private val mapper = SerializedFinTsDataMapper()
|
||||
|
||||
private val log by logger()
|
||||
|
||||
|
||||
fun serializeToJson(bank: BankData, prettyPrint: Boolean = false): String? {
|
||||
return try {
|
||||
val serializableData = mapper.map(bank)
|
||||
|
||||
val json = if (prettyPrint) prettyPrintJson else json
|
||||
|
||||
json.encodeToString(serializableData)
|
||||
} catch (e: Throwable) {
|
||||
log.error(e) { "Could not map fints4k model to JSON" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun deserializeFromJson(serializedFinTsData: String): BankData? = try {
|
||||
val serializedData = json.decodeFromString<SerializedFinTsData>(serializedFinTsData)
|
||||
|
||||
mapper.map(serializedData)
|
||||
} catch (e: Throwable) {
|
||||
log.error(e) { "Could not deserialize BankData from JSON:\n$serializedFinTsData"}
|
||||
null
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package net.codinux.banking.fints.serialization
|
||||
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.*
|
||||
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
|
||||
import net.codinux.banking.fints.model.AccountData
|
||||
import net.codinux.banking.fints.model.PinInfo
|
||||
import net.codinux.banking.fints.model.TanMethod
|
||||
import net.codinux.banking.fints.serialization.jobparameter.DetailedSerializableJobParameters
|
||||
import net.codinux.banking.fints.serialization.jobparameter.SerializableJobParameters
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
class SerializedFinTsData(
|
||||
val bankCode: String,
|
||||
val customerId: String,
|
||||
val pin: String,
|
||||
val finTs3ServerAddress: String,
|
||||
val bic: String,
|
||||
|
||||
val bankName: String,
|
||||
val countryCode: Int,
|
||||
val bpdVersion: Int,
|
||||
|
||||
val userId: String,
|
||||
val customerName: String,
|
||||
val updVersion: Int,
|
||||
|
||||
val tanMethodsSupportedByBank: List<TanMethod>,
|
||||
val identifierOfTanMethodsAvailableForUser: List<String> = listOf(),
|
||||
val selectedTanMethodIdentifier: String,
|
||||
val tanMedia: List<TanMedium> = listOf(),
|
||||
val selectedTanMediumIdentifier: String? = null,
|
||||
|
||||
val supportedLanguages: List<Dialogsprache> = listOf(),
|
||||
val selectedLanguage: Dialogsprache = Dialogsprache.Default,
|
||||
val customerSystemId: String = KundensystemID.Anonymous,
|
||||
val customerSystemStatus: KundensystemStatusWerte = KundensystemStatus.SynchronizingCustomerSystemId,
|
||||
|
||||
val countMaxJobsPerMessage: Int = 0,
|
||||
|
||||
val supportedHbciVersions: List<HbciVersion> = listOf(),
|
||||
val supportedJobs: List<SerializableJobParameters> = listOf(),
|
||||
val supportedDetailedJobs: List<DetailedSerializableJobParameters> = listOf(),
|
||||
val jobsRequiringTan: Set<String> = emptySet(),
|
||||
|
||||
val pinInfo: PinInfo? = null,
|
||||
|
||||
val accounts: List<AccountData>
|
||||
) {
|
||||
|
||||
@EncodeDefault
|
||||
private val modelVersion: String = "0.6.0"
|
||||
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
package net.codinux.banking.fints.serialization
|
||||
|
||||
import net.codinux.banking.fints.model.BankData
|
||||
import net.codinux.banking.fints.response.segments.ChangeTanMediaParameters
|
||||
import net.codinux.banking.fints.response.segments.JobParameters
|
||||
import net.codinux.banking.fints.response.segments.RetrieveAccountTransactionsParameters
|
||||
import net.codinux.banking.fints.response.segments.SepaAccountInfoParameters
|
||||
import net.codinux.banking.fints.serialization.jobparameter.*
|
||||
import net.codinux.log.logger
|
||||
|
||||
class SerializedFinTsDataMapper {
|
||||
|
||||
private val log by logger()
|
||||
|
||||
|
||||
fun map(bank: BankData) = SerializedFinTsData(
|
||||
bank.bankCode,
|
||||
bank.customerId,
|
||||
bank.pin,
|
||||
bank.finTs3ServerAddress,
|
||||
bank.bic,
|
||||
|
||||
bank.bankName,
|
||||
bank.countryCode,
|
||||
bank.bpdVersion,
|
||||
|
||||
bank.userId,
|
||||
bank.customerName,
|
||||
bank.updVersion,
|
||||
|
||||
bank.tanMethodsSupportedByBank,
|
||||
bank.tanMethodsAvailableForUser.map { it.securityFunction.code },
|
||||
bank.selectedTanMethod.securityFunction.code,
|
||||
bank.tanMedia,
|
||||
bank.selectedTanMedium?.identifier,
|
||||
|
||||
bank.supportedLanguages,
|
||||
bank.selectedLanguage,
|
||||
bank.customerSystemId,
|
||||
bank.customerSystemStatus,
|
||||
|
||||
bank.countMaxJobsPerMessage,
|
||||
|
||||
bank.supportedHbciVersions,
|
||||
bank.supportedJobs.filterNot { isDetailedJobParameters(it) }.map { mapJobParameters(it) },
|
||||
bank.supportedJobs.filter { isDetailedJobParameters(it) }.mapNotNull { mapDetailedJobParameters(it) },
|
||||
bank.jobsRequiringTan,
|
||||
|
||||
bank.pinInfo,
|
||||
|
||||
bank.accounts
|
||||
)
|
||||
|
||||
private fun isDetailedJobParameters(parameters: JobParameters): Boolean =
|
||||
parameters is RetrieveAccountTransactionsParameters
|
||||
|| parameters is SepaAccountInfoParameters
|
||||
|| parameters is ChangeTanMediaParameters
|
||||
|
||||
private fun mapJobParameters(parameters: JobParameters) = SerializableJobParameters(
|
||||
parameters.jobName,
|
||||
parameters.maxCountJobs,
|
||||
parameters.minimumCountSignatures,
|
||||
parameters.securityClass,
|
||||
|
||||
parameters.segmentId,
|
||||
parameters.segmentNumber,
|
||||
parameters.segmentVersion,
|
||||
|
||||
parameters.segmentString
|
||||
)
|
||||
|
||||
private fun mapDetailedJobParameters(parameters: JobParameters): DetailedSerializableJobParameters? = when (parameters) {
|
||||
is RetrieveAccountTransactionsParameters -> SerializableRetrieveAccountTransactionsParameters(mapJobParameters(parameters), parameters.serverTransactionsRetentionDays, parameters.settingCountEntriesAllowed, parameters.settingAllAccountAllowed, parameters.supportedCamtDataFormats)
|
||||
is SepaAccountInfoParameters -> SerializableSepaAccountInfoParameters(mapJobParameters(parameters), parameters.retrieveSingleAccountAllowed, parameters.nationalAccountRelationshipAllowed, parameters.structuredReferenceAllowed, parameters.settingMaxAllowedEntriesAllowed, parameters.countReservedReferenceLength, parameters.supportedSepaFormats)
|
||||
is ChangeTanMediaParameters -> SerializableChangeTanMediaParameters(mapJobParameters(parameters), parameters.enteringTanListNumberRequired, parameters.enteringCardSequenceNumberRequired, parameters.enteringAtcAndTanRequired, parameters.enteringCardTypeAllowed, parameters.accountInfoRequired, parameters.allowedCardTypes)
|
||||
else -> {
|
||||
log.warn { "${parameters::class} is said to be a DetailedJobParameters class, but found no mapping code for it" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun map(bank: SerializedFinTsData) = BankData(
|
||||
bank.bankCode,
|
||||
bank.customerId,
|
||||
bank.pin,
|
||||
bank.finTs3ServerAddress,
|
||||
bank.bic,
|
||||
|
||||
bank.bankName,
|
||||
bank.countryCode,
|
||||
bank.bpdVersion,
|
||||
|
||||
bank.userId,
|
||||
bank.customerName,
|
||||
bank.updVersion,
|
||||
|
||||
bank.tanMethodsSupportedByBank,
|
||||
bank.tanMethodsSupportedByBank.filter { it.securityFunction.code in bank.identifierOfTanMethodsAvailableForUser },
|
||||
bank.tanMethodsSupportedByBank.first { it.securityFunction.code == bank.selectedTanMethodIdentifier },
|
||||
bank.tanMedia,
|
||||
bank.selectedTanMediumIdentifier?.let { id -> bank.tanMedia.firstOrNull { it.identifier == id } },
|
||||
|
||||
bank.supportedLanguages,
|
||||
bank.selectedLanguage,
|
||||
bank.customerSystemId,
|
||||
bank.customerSystemStatus,
|
||||
|
||||
bank.countMaxJobsPerMessage,
|
||||
|
||||
bank.supportedHbciVersions,
|
||||
bank.supportedJobs.map { mapJobParameters(it) } + bank.supportedDetailedJobs.map { mapDetailedJobParameters(it) },
|
||||
bank.jobsRequiringTan
|
||||
).apply {
|
||||
pinInfo = bank.pinInfo
|
||||
|
||||
bank.accounts.forEach { account ->
|
||||
account.allowedJobs = this.supportedJobs.filter { it.jobName in account.allowedJobNames }
|
||||
this.addAccount(account)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapJobParameters(parameters: SerializableJobParameters) = JobParameters(
|
||||
parameters.jobName,
|
||||
parameters.maxCountJobs,
|
||||
parameters.minimumCountSignatures,
|
||||
parameters.securityClass,
|
||||
|
||||
parameters.segmentString
|
||||
)
|
||||
|
||||
private fun mapDetailedJobParameters(parameters: DetailedSerializableJobParameters): JobParameters = when (parameters) {
|
||||
is SerializableRetrieveAccountTransactionsParameters -> RetrieveAccountTransactionsParameters(mapJobParameters(parameters.jobParameters), parameters.serverTransactionsRetentionDays, parameters.settingCountEntriesAllowed, parameters.settingAllAccountAllowed, parameters.supportedCamtDataFormats)
|
||||
is SerializableSepaAccountInfoParameters -> SepaAccountInfoParameters(mapJobParameters(parameters.jobParameters), parameters.retrieveSingleAccountAllowed, parameters.nationalAccountRelationshipAllowed, parameters.structuredReferenceAllowed, parameters.settingMaxAllowedEntriesAllowed, parameters.countReservedReferenceLength, parameters.supportedSepaFormats)
|
||||
is SerializableChangeTanMediaParameters -> ChangeTanMediaParameters(mapJobParameters(parameters.jobParameters), parameters.enteringTanListNumberRequired, parameters.enteringCardSequenceNumberRequired, parameters.enteringAtcAndTanRequired, parameters.enteringCardTypeAllowed, parameters.accountInfoRequired, parameters.allowedCardTypes)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package net.codinux.banking.fints.serialization.jobparameter
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed class DetailedSerializableJobParameters {
|
||||
|
||||
abstract val jobParameters: SerializableJobParameters
|
||||
|
||||
|
||||
override fun toString() = jobParameters.toString()
|
||||
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package net.codinux.banking.fints.serialization.jobparameter
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@SerialName("ChangeTanMediaParameters")
|
||||
class SerializableChangeTanMediaParameters(
|
||||
override val jobParameters: SerializableJobParameters,
|
||||
|
||||
val enteringTanListNumberRequired: Boolean,
|
||||
val enteringCardSequenceNumberRequired: Boolean,
|
||||
val enteringAtcAndTanRequired: Boolean,
|
||||
val enteringCardTypeAllowed: Boolean,
|
||||
val accountInfoRequired: Boolean,
|
||||
val allowedCardTypes: List<Int>
|
||||
) : DetailedSerializableJobParameters()
|
|
@ -0,0 +1,19 @@
|
|||
package net.codinux.banking.fints.serialization.jobparameter
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class SerializableJobParameters(
|
||||
val jobName: String,
|
||||
val maxCountJobs: Int,
|
||||
val minimumCountSignatures: Int,
|
||||
val securityClass: Int?,
|
||||
|
||||
val segmentId: String,
|
||||
val segmentNumber: Int,
|
||||
val segmentVersion: Int,
|
||||
|
||||
val segmentString: String
|
||||
) {
|
||||
override fun toString() = "$jobName $segmentVersion"
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package net.codinux.banking.fints.serialization.jobparameter
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@SerialName("RetrieveAccountTransactionsParameters")
|
||||
class SerializableRetrieveAccountTransactionsParameters(
|
||||
override val jobParameters: SerializableJobParameters,
|
||||
|
||||
val serverTransactionsRetentionDays: Int,
|
||||
val settingCountEntriesAllowed: Boolean,
|
||||
val settingAllAccountAllowed: Boolean,
|
||||
val supportedCamtDataFormats: List<String> = emptyList()
|
||||
) : DetailedSerializableJobParameters() {
|
||||
override fun toString() = "${super.toString()}, serverTransactionsRetentionDays = $serverTransactionsRetentionDays, supportedCamtDataFormats = $supportedCamtDataFormats"
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package net.codinux.banking.fints.serialization.jobparameter
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@SerialName("SepaAccountInfoParameters")
|
||||
class SerializableSepaAccountInfoParameters(
|
||||
override val jobParameters: SerializableJobParameters,
|
||||
|
||||
val retrieveSingleAccountAllowed: Boolean,
|
||||
val nationalAccountRelationshipAllowed: Boolean,
|
||||
val structuredReferenceAllowed: Boolean,
|
||||
val settingMaxAllowedEntriesAllowed: Boolean,
|
||||
val countReservedReferenceLength: Int,
|
||||
val supportedSepaFormats: List<String>
|
||||
) : DetailedSerializableJobParameters() {
|
||||
override fun toString() = "${super.toString()}, supportedSepaFormats = $supportedSepaFormats"
|
||||
}
|
|
@ -3,12 +3,12 @@ package net.codinux.banking.fints.tan
|
|||
|
||||
open class FlickerCode(
|
||||
val challengeHHD_UC: String,
|
||||
val parsedDataSet: String,
|
||||
val parsedDataSet: String? = null,
|
||||
val decodingError: Exception? = null
|
||||
) {
|
||||
|
||||
val decodingSuccessful: Boolean
|
||||
get() = decodingError == null
|
||||
get() = parsedDataSet != null
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
|
|
|
@ -45,7 +45,7 @@ open class FlickerCodeDecoder {
|
|||
} catch (e: Exception) {
|
||||
log.error(e) { "Could not decode challenge $challengeHHD_UC" }
|
||||
|
||||
return FlickerCode(challengeHHD_UC, "", e)
|
||||
return FlickerCode(challengeHHD_UC, null, e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,13 +2,13 @@ package net.codinux.banking.fints.tan
|
|||
|
||||
|
||||
open class TanImage(
|
||||
val mimeType: String,
|
||||
val imageBytes: ByteArray,
|
||||
val mimeType: String? = null,
|
||||
val imageBytes: ByteArray? = null,
|
||||
val decodingError: Exception? = null
|
||||
) {
|
||||
|
||||
val decodingSuccessful: Boolean
|
||||
get() = decodingError == null
|
||||
get() = mimeType != null && imageBytes != null
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
|
@ -16,7 +16,7 @@ open class TanImage(
|
|||
return "Decoding error: $decodingError"
|
||||
}
|
||||
|
||||
return "$mimeType ${imageBytes.size} bytes"
|
||||
return "$mimeType ${imageBytes?.size} bytes"
|
||||
}
|
||||
|
||||
}
|
|
@ -29,7 +29,7 @@ open class TanImageDecoder {
|
|||
} catch (e: Exception) {
|
||||
log.error(e) { "Could not decode challenge HHD_UC to TanImage: $challengeHHD_UC" }
|
||||
|
||||
return TanImage("", ByteArray(0), e)
|
||||
return TanImage(null, null, e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,6 @@ interface IAccountTransactionsParser {
|
|||
|
||||
fun parseTransactions(transactionsString: String, bank: BankData, account: AccountData): List<AccountTransaction>
|
||||
|
||||
fun parseTransactionsChunk(transactionsChunk: String, bank: BankData, account: AccountData): Pair<List<AccountTransaction>, String>
|
||||
fun parseTransactionsChunk(transactionsChunk: String, bank: BankData, account: AccountData): Pair<List<AccountTransaction>, String?>
|
||||
|
||||
}
|
|
@ -25,7 +25,7 @@ open class Mt940AccountTransactionsParser(
|
|||
return accountStatements.flatMap { mapToAccountTransactions(it, bank, account) }
|
||||
}
|
||||
|
||||
override fun parseTransactionsChunk(transactionsChunk: String, bank: BankData, account: AccountData): Pair<List<AccountTransaction>, String> {
|
||||
override fun parseTransactionsChunk(transactionsChunk: String, bank: BankData, account: AccountData): Pair<List<AccountTransaction>, String?> {
|
||||
val (accountStatements, remainder) = mt940Parser.parseMt940Chunk(transactionsChunk)
|
||||
|
||||
return Pair(accountStatements.flatMap { mapToAccountTransactions(it, bank, account) }, remainder)
|
||||
|
@ -44,44 +44,50 @@ open class Mt940AccountTransactionsParser(
|
|||
protected open fun mapToAccountTransaction(statement: AccountStatement, transaction: Transaction, account: AccountData): AccountTransaction {
|
||||
val currency = statement.closingBalance.currency
|
||||
|
||||
// may parse postingKey to postingText (Buchungstext) according to table in 8.2.3 Buchungsschlüssel (Feld 61), S. 654 ff.
|
||||
|
||||
return AccountTransaction(
|
||||
account,
|
||||
Money(mapAmount(transaction.statementLine), currency),
|
||||
transaction.statementLine.isReversal,
|
||||
transaction.information?.unparsedReference ?: "",
|
||||
// either field :86: contains structured information, then sepaReference is a mandatory field, or :86: is unstructured, then the whole field value is the reference
|
||||
transaction.information?.sepaReference ?: transaction.information?.unparsedReference ?: "",
|
||||
|
||||
transaction.statementLine.bookingDate ?: statement.closingBalance.bookingDate,
|
||||
transaction.information?.otherPartyName,
|
||||
transaction.information?.otherPartyBankCode,
|
||||
transaction.information?.otherPartyAccountId,
|
||||
transaction.information?.bookingText,
|
||||
transaction.statementLine.valueDate,
|
||||
|
||||
transaction.information?.otherPartyName,
|
||||
transaction.information?.otherPartyBankId,
|
||||
transaction.information?.otherPartyAccountId,
|
||||
|
||||
transaction.information?.postingText,
|
||||
|
||||
Money(mapAmount(statement.openingBalance), currency),
|
||||
Money(mapAmount(statement.closingBalance), currency),
|
||||
|
||||
statement.statementNumber,
|
||||
statement.sequenceNumber,
|
||||
Money(mapAmount(statement.openingBalance), currency), // TODO: that's not true, these are the opening and closing balance of
|
||||
Money(mapAmount(statement.closingBalance), currency), // all transactions of this day, not this specific transaction's ones
|
||||
statement.sheetNumber,
|
||||
|
||||
// :60: customer reference: Wenn „KREF+“ eingestellt ist, dann erfolgt die Angabe der Referenznummer in Tag :86: .
|
||||
transaction.information?.customerReference ?: transaction.statementLine.customerReference,
|
||||
transaction.statementLine.bankReference,
|
||||
transaction.statementLine.furtherInformationOriginalAmountAndCharges,
|
||||
|
||||
transaction.information?.endToEndReference,
|
||||
transaction.information?.customerReference,
|
||||
transaction.information?.mandateReference,
|
||||
transaction.information?.creditorIdentifier,
|
||||
transaction.information?.originatorsIdentificationCode,
|
||||
transaction.information?.compensationAmount,
|
||||
transaction.information?.originalAmount,
|
||||
transaction.information?.sepaReference,
|
||||
transaction.information?.deviantOriginator,
|
||||
transaction.information?.deviantRecipient,
|
||||
transaction.information?.referenceWithNoSpecialType,
|
||||
transaction.information?.primaNotaNumber,
|
||||
transaction.information?.textKeySupplement,
|
||||
transaction.information?.journalNumber,
|
||||
transaction.information?.textKeyAddition,
|
||||
|
||||
transaction.statementLine.currencyType,
|
||||
transaction.statementLine.bookingKey,
|
||||
transaction.statementLine.referenceForTheAccountOwner,
|
||||
transaction.statementLine.referenceOfTheAccountServicingInstitution,
|
||||
transaction.statementLine.supplementaryDetails,
|
||||
statement.orderReferenceNumber,
|
||||
statement.referenceNumber,
|
||||
|
||||
statement.transactionReferenceNumber,
|
||||
statement.relatedReferenceNumber
|
||||
transaction.statementLine.isReversal,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,6 @@ interface IMt940Parser {
|
|||
* be displayed immediately to user and the remainder then be passed together with next partial
|
||||
* HKKAZ response to this method till this whole MT 940 statement is parsed.
|
||||
*/
|
||||
fun parseMt940Chunk(mt940Chunk: String): Pair<List<AccountStatement>, String>
|
||||
fun parseMt940Chunk(mt940Chunk: String): Pair<List<AccountStatement>, String?>
|
||||
|
||||
}
|
|
@ -1,94 +1,21 @@
|
|||
package net.codinux.banking.fints.transactions.mt940
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import net.codinux.log.logger
|
||||
import net.codinux.banking.fints.extensions.todayAtEuropeBerlin
|
||||
import net.codinux.banking.fints.log.IMessageLogAppender
|
||||
import net.codinux.banking.fints.model.Amount
|
||||
import net.codinux.banking.fints.transactions.mt940.model.*
|
||||
import net.codinux.banking.fints.mapper.DateFormatter
|
||||
|
||||
|
||||
/*
|
||||
4.1. SWIFT Supported Characters
|
||||
|
||||
a until z
|
||||
A until Z
|
||||
0 until 9
|
||||
/ ‐ ? : ( ) . , ' + { }
|
||||
CR LF Space
|
||||
Although part of the character set, the curly brackets are permitted as delimiters and cannot be used within the text of
|
||||
user‐to‐user messages.
|
||||
Character ”‐” is not permitted as the first character of the line.
|
||||
None of lines include only Space.
|
||||
*/
|
||||
open class Mt940Parser(
|
||||
override var logAppender: IMessageLogAppender? = null
|
||||
) : IMt940Parser {
|
||||
|
||||
companion object {
|
||||
val AccountStatementsSeparatorRegex = Regex("^\\s*-\\s*\$", RegexOption.MULTILINE) // a line only with '-' and may other white space characters
|
||||
|
||||
// (?<!T\d\d(:\d\d)?) to filter that date time with format (yyyy-MM-dd)Thh:mm:ss(:SSS) is considered to be a field identifier
|
||||
val AccountStatementFieldSeparatorRegex = Regex("(?<!T\\d\\d(:\\d\\d)?):\\d\\d\\w?:")
|
||||
|
||||
|
||||
const val TransactionReferenceNumberCode = "20"
|
||||
|
||||
const val RelatedReferenceNumberCode = "21"
|
||||
|
||||
const val AccountIdentificationCode = "25"
|
||||
|
||||
const val StatementNumberCode = "28C"
|
||||
|
||||
const val OpeningBalanceCode = "60"
|
||||
|
||||
const val StatementLineCode = "61"
|
||||
|
||||
const val InformationToAccountOwnerCode = "86"
|
||||
|
||||
const val ClosingBalanceCode = "62"
|
||||
|
||||
|
||||
val DateFormatter = DateFormatter("yyMMdd") // TODO: replace with LocalDate.Format { }
|
||||
|
||||
val CurrentYearTwoDigit = LocalDate.todayAtEuropeBerlin().year
|
||||
|
||||
val CreditDebitCancellationRegex = Regex("C|D|RC|RD")
|
||||
|
||||
val AmountRegex = Regex("\\d+,\\d*")
|
||||
|
||||
val ReferenceTypeRegex = Regex("[A-Z]{4}\\+")
|
||||
|
||||
val InformationToAccountOwnerSubFieldRegex = Regex("\\?\\d\\d")
|
||||
|
||||
|
||||
const val EndToEndReferenceKey = "EREF+"
|
||||
const val CustomerReferenceKey = "KREF+"
|
||||
const val MandateReferenceKey = "MREF+"
|
||||
const val CreditorIdentifierKey = "CRED+"
|
||||
const val OriginatorsIdentificationCodeKey = "DEBT+"
|
||||
const val CompensationAmountKey = "COAM+"
|
||||
const val OriginalAmountKey = "OAMT+"
|
||||
const val SepaReferenceKey = "SVWZ+"
|
||||
const val DeviantOriginatorKey = "ABWA+"
|
||||
const val DeviantRecipientKey = "ABWE+"
|
||||
}
|
||||
|
||||
private val log by logger()
|
||||
|
||||
) : Mt94xParserBase<AccountStatement>(logAppender), IMt940Parser {
|
||||
|
||||
/**
|
||||
* Parses a whole MT 940 statements string, that is one that ends with a "-" line.
|
||||
*/
|
||||
override fun parseMt940String(mt940String: String): List<AccountStatement> {
|
||||
return parseMt940Chunk(mt940String).first
|
||||
}
|
||||
override fun parseMt940String(mt940String: String): List<AccountStatement> =
|
||||
super.parseMt94xString(mt940String)
|
||||
|
||||
/**
|
||||
* Parses incomplete MT 940 statements string, that is ones that not end with a "-" line,
|
||||
* as the they are returned e.g. if a HKKAZ response is dispersed over multiple messages.
|
||||
* as they are returned e.g. if a HKKAZ response is dispersed over multiple messages.
|
||||
*
|
||||
* Tries to parse all statements in the string except an incomplete last one and returns an
|
||||
* incomplete last MT 940 statement (if any) as remainder.
|
||||
|
@ -97,415 +24,31 @@ open class Mt940Parser(
|
|||
* be displayed immediately to user and the remainder then be passed together with next partial
|
||||
* HKKAZ response to this method till this whole MT 940 statement is parsed.
|
||||
*/
|
||||
override fun parseMt940Chunk(mt940Chunk: String): Pair<List<AccountStatement>, String> {
|
||||
try {
|
||||
val singleAccountStatementsStrings = splitIntoSingleAccountStatements(mt940Chunk).toMutableList()
|
||||
|
||||
var remainder = ""
|
||||
if (singleAccountStatementsStrings.isNotEmpty() && singleAccountStatementsStrings.last().isEmpty() == false) {
|
||||
remainder = singleAccountStatementsStrings.removeAt(singleAccountStatementsStrings.lastIndex)
|
||||
}
|
||||
|
||||
val transactions = singleAccountStatementsStrings.mapNotNull { parseAccountStatement(it) }
|
||||
|
||||
return Pair(transactions, remainder)
|
||||
} catch (e: Exception) {
|
||||
logError("Could not parse account statements from MT940 string:\n$mt940Chunk", e)
|
||||
}
|
||||
|
||||
return Pair(listOf(), "")
|
||||
}
|
||||
override fun parseMt940Chunk(mt940Chunk: String): Pair<List<AccountStatement>, String?> =
|
||||
super.parseMt94xChunk(mt940Chunk)
|
||||
|
||||
|
||||
protected open fun splitIntoSingleAccountStatements(mt940String: String): List<String> {
|
||||
return mt940String.split(AccountStatementsSeparatorRegex)
|
||||
.map { it.replace("\n", "").replace("\r", "") }
|
||||
}
|
||||
|
||||
|
||||
protected open fun parseAccountStatement(accountStatementString: String): AccountStatement? {
|
||||
if (accountStatementString.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
val fieldsByCode = splitIntoFields(accountStatementString)
|
||||
|
||||
return parseAccountStatement(fieldsByCode)
|
||||
} catch (e: Exception) {
|
||||
logError("Could not parse account statement:\n$accountStatementString", e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
protected open fun splitIntoFields(accountStatementString: String): List<Pair<String, String>> {
|
||||
val result = mutableListOf<Pair<String, String>>()
|
||||
var lastMatchEnd = 0
|
||||
var lastMatchedCode = ""
|
||||
|
||||
AccountStatementFieldSeparatorRegex.findAll(accountStatementString).forEach { matchResult ->
|
||||
if (lastMatchEnd > 0) {
|
||||
val previousStatement = accountStatementString.substring(lastMatchEnd, matchResult.range.first)
|
||||
result.add(Pair(lastMatchedCode, previousStatement))
|
||||
}
|
||||
|
||||
lastMatchedCode = matchResult.value.replace(":", "")
|
||||
lastMatchEnd = matchResult.range.last + 1
|
||||
}
|
||||
|
||||
if (lastMatchEnd > 0) {
|
||||
val previousStatement = accountStatementString.substring(lastMatchEnd, accountStatementString.length)
|
||||
result.add(Pair(lastMatchedCode, previousStatement))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
protected open fun parseAccountStatement(fieldsByCode: List<Pair<String, String>>): AccountStatement? {
|
||||
val statementAndMaySequenceNumber = getFieldValue(fieldsByCode, StatementNumberCode).split('/')
|
||||
val accountIdentification = getFieldValue(fieldsByCode, AccountIdentificationCode).split('/')
|
||||
override fun createAccountStatement(
|
||||
orderReferenceNumber: String,
|
||||
referenceNumber: String?,
|
||||
bankCodeBicOrIban: String,
|
||||
accountIdentifier: String?,
|
||||
statementNumber: Int,
|
||||
sheetNumber: Int?,
|
||||
transactions: List<Transaction>,
|
||||
fieldsByCode: List<Pair<String, String>>
|
||||
): AccountStatement {
|
||||
val openingBalancePair = fieldsByCode.first { it.first.startsWith(OpeningBalanceCode) }
|
||||
val closingBalancePair = fieldsByCode.first { it.first.startsWith(ClosingBalanceCode) }
|
||||
|
||||
return AccountStatement(
|
||||
getFieldValue(fieldsByCode, TransactionReferenceNumberCode),
|
||||
getOptionalFieldValue(fieldsByCode, RelatedReferenceNumberCode),
|
||||
accountIdentification[0],
|
||||
if (accountIdentification.size > 1) accountIdentification[1] else null,
|
||||
statementAndMaySequenceNumber[0].toInt(),
|
||||
if (statementAndMaySequenceNumber.size > 1) statementAndMaySequenceNumber[1].toInt() else null,
|
||||
orderReferenceNumber, referenceNumber,
|
||||
bankCodeBicOrIban, accountIdentifier,
|
||||
statementNumber, sheetNumber,
|
||||
parseBalance(openingBalancePair.first, openingBalancePair.second),
|
||||
parseAccountStatementTransactions(fieldsByCode),
|
||||
transactions,
|
||||
parseBalance(closingBalancePair.first, closingBalancePair.second)
|
||||
)
|
||||
}
|
||||
|
||||
protected open fun getFieldValue(fieldsByCode: List<Pair<String, String>>, code: String): String {
|
||||
return fieldsByCode.first { it.first == code }.second
|
||||
}
|
||||
|
||||
protected open fun getOptionalFieldValue(fieldsByCode: List<Pair<String, String>>, code: String): String? {
|
||||
return fieldsByCode.firstOrNull { it.first == code }?.second
|
||||
}
|
||||
|
||||
protected open fun parseBalance(code: String, fieldValue: String): Balance {
|
||||
val isIntermediate = code.endsWith("M")
|
||||
|
||||
val isDebit = fieldValue.startsWith("D")
|
||||
val bookingDateString = fieldValue.substring(1, 7)
|
||||
val statementDate = parseMt940Date(bookingDateString)
|
||||
val currency = fieldValue.substring(7, 10)
|
||||
val amountString = fieldValue.substring(10)
|
||||
val amount = parseAmount(amountString)
|
||||
|
||||
return Balance(isIntermediate, !!!isDebit, statementDate, currency, amount)
|
||||
}
|
||||
|
||||
protected open fun parseAccountStatementTransactions(fieldsByCode: List<Pair<String, String>>): List<Transaction> {
|
||||
val transactions = mutableListOf<Transaction>()
|
||||
|
||||
fieldsByCode.forEachIndexed { index, pair ->
|
||||
if (pair.first == StatementLineCode) {
|
||||
val statementLine = parseStatementLine(pair.second)
|
||||
|
||||
val nextPair = if (index < fieldsByCode.size - 1) fieldsByCode.get(index + 1) else null
|
||||
val information = if (nextPair?.first == InformationToAccountOwnerCode) parseNullableInformationToAccountOwner(nextPair.second) else null
|
||||
|
||||
transactions.add(Transaction(statementLine, information))
|
||||
}
|
||||
}
|
||||
|
||||
return transactions
|
||||
}
|
||||
|
||||
/**
|
||||
* FORMAT
|
||||
* 6!n[4!n]2a[1!a]15d1!a3!c16x[//16x]
|
||||
* [34x]
|
||||
*
|
||||
* where subfields are:
|
||||
* Subfield Format Name
|
||||
* 1 6!n (Value Date)
|
||||
* 2 [4!n] (Entry Date)
|
||||
* 3 2a (Debit/Credit Mark)
|
||||
* 4 [1!a] (Funds Code)
|
||||
* 5 15d (Amount)
|
||||
* 6 1!a3!c (Transaction Type)(Identification Code)
|
||||
* 7 16x (Reference for the Account Owner)
|
||||
* 8 [//16x] (Reference of the Account Servicing Institution)
|
||||
* 9 [34x] (Supplementary Details)
|
||||
*/
|
||||
protected open fun parseStatementLine(fieldValue: String): StatementLine {
|
||||
val valueDateString = fieldValue.substring(0, 6)
|
||||
val valueDate = parseMt940Date(valueDateString)
|
||||
|
||||
val creditMarkMatchResult = CreditDebitCancellationRegex.find(fieldValue)
|
||||
val isDebit = creditMarkMatchResult?.value?.endsWith('D') == true
|
||||
val isCancellation = creditMarkMatchResult?.value?.startsWith('R') == true
|
||||
|
||||
val creditMarkEnd = (creditMarkMatchResult?.range?.last ?: -1) + 1
|
||||
|
||||
// booking date is the second field and is optional. It is normally only used when different from the value date.
|
||||
val bookingDateString = if ((creditMarkMatchResult?.range?.start ?: 0) > 6) fieldValue.substring(6, 10) else null
|
||||
val bookingDate = bookingDateString?.let { // bookingDateString has format MMdd -> add year from valueDateString
|
||||
parseMt940BookingDate(bookingDateString, valueDateString, valueDate)
|
||||
} ?: valueDate
|
||||
|
||||
val amountMatchResult = AmountRegex.find(fieldValue)!!
|
||||
val amountString = amountMatchResult.value
|
||||
val amount = parseAmount(amountString)
|
||||
|
||||
val amountEndIndex = amountMatchResult.range.last + 1
|
||||
|
||||
val fundsCode = if (amountMatchResult.range.start - creditMarkEnd > 1) fieldValue.substring(creditMarkEnd + 1, creditMarkEnd + 2) else null
|
||||
|
||||
/**
|
||||
* S SWIFT transfer For entries related to SWIFT transfer instructions and subsequent charge messages.
|
||||
*
|
||||
* N Non-SWIFT For entries related to payment and transfer instructions, including transfer related charges messages, not sent through SWIFT or where an alpha description is preferred.
|
||||
*
|
||||
* F First advice For entries being first advised by the statement (items originated by the account servicing institution).
|
||||
*/
|
||||
val transactionType = fieldValue.substring(amountEndIndex, amountEndIndex + 1) // transaction type is 'N', 'S' or 'F'
|
||||
|
||||
val bookingKeyStart = amountEndIndex + 1
|
||||
val bookingKey = fieldValue.substring(bookingKeyStart, bookingKeyStart + 3) // TODO: parse codes, p. 178
|
||||
|
||||
val customerAndBankReference = fieldValue.substring(bookingKeyStart + 3).split("//")
|
||||
val customerReference = customerAndBankReference[0]
|
||||
|
||||
/**
|
||||
* The content of this subfield is the account servicing institution's own reference for the transaction.
|
||||
* When the transaction has been initiated by the account servicing institution, this
|
||||
* reference may be identical to subfield 7, Reference for the Account Owner. If this is
|
||||
* the case, Reference of the Account Servicing Institution, subfield 8 may be omitted.
|
||||
*/
|
||||
var bankReference = if (customerAndBankReference.size > 1) customerAndBankReference[1] else customerReference // TODO: or use null?
|
||||
var supplementaryDetails: String? = null
|
||||
|
||||
val bankReferenceAndSupplementaryDetails = bankReference.split("\n")
|
||||
if (bankReferenceAndSupplementaryDetails.size > 1) {
|
||||
bankReference = bankReferenceAndSupplementaryDetails[0].trim()
|
||||
// TODO: parse /OCMT/ and /CHGS/, see page 518
|
||||
supplementaryDetails = bankReferenceAndSupplementaryDetails[1].trim()
|
||||
}
|
||||
|
||||
return StatementLine(!!!isDebit, isCancellation, valueDate, bookingDate, null, amount, bookingKey,
|
||||
customerReference, bankReference, supplementaryDetails)
|
||||
}
|
||||
|
||||
protected open fun parseNullableInformationToAccountOwner(informationToAccountOwnerString: String): InformationToAccountOwner? {
|
||||
try {
|
||||
val information = parseInformationToAccountOwner(informationToAccountOwnerString)
|
||||
|
||||
mapReference(information)
|
||||
|
||||
return information
|
||||
} catch (e: Exception) {
|
||||
logError("Could not parse InformationToAccountOwner from field value '$informationToAccountOwnerString'", e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
protected open fun parseInformationToAccountOwner(informationToAccountOwnerString: String): InformationToAccountOwner {
|
||||
// e. g. starts with 0 -> Inlandszahlungsverkehr, starts with '3' -> Wertpapiergeschäft
|
||||
// see Finanzdatenformate p. 209 - 215
|
||||
val geschaeftsvorfallCode = informationToAccountOwnerString.substring(0, 2) // TODO: may map
|
||||
|
||||
val referenceParts = mutableListOf<String>()
|
||||
val otherPartyName = StringBuilder()
|
||||
var otherPartyBankCode: String? = null
|
||||
var otherPartyAccountId: String? = null
|
||||
var bookingText: String? = null
|
||||
var primaNotaNumber: String? = null
|
||||
var textKeySupplement: String? = null
|
||||
|
||||
val subFieldMatches = InformationToAccountOwnerSubFieldRegex.findAll(informationToAccountOwnerString).toList()
|
||||
subFieldMatches.forEachIndexed { index, matchResult ->
|
||||
val fieldCode = matchResult.value.substring(1, 3).toInt()
|
||||
val endIndex = if (index + 1 < subFieldMatches.size) subFieldMatches[index + 1].range.start else informationToAccountOwnerString.length
|
||||
val fieldValue = informationToAccountOwnerString.substring(matchResult.range.last + 1, endIndex)
|
||||
|
||||
when (fieldCode) {
|
||||
0 -> bookingText = fieldValue
|
||||
10 -> primaNotaNumber = fieldValue
|
||||
in 20..29 -> referenceParts.add(fieldValue)
|
||||
30 -> otherPartyBankCode = fieldValue
|
||||
31 -> otherPartyAccountId = fieldValue
|
||||
32, 33 -> otherPartyName.append(fieldValue)
|
||||
34 -> textKeySupplement = fieldValue
|
||||
in 60..63 -> referenceParts.add(fieldValue)
|
||||
}
|
||||
}
|
||||
|
||||
val reference = if (isFormattedReference(referenceParts)) joinReferenceParts(referenceParts)
|
||||
else referenceParts.joinToString(" ")
|
||||
|
||||
val otherPartyNameString = if (otherPartyName.isBlank()) null else otherPartyName.toString()
|
||||
|
||||
return InformationToAccountOwner(
|
||||
reference, otherPartyNameString, otherPartyBankCode, otherPartyAccountId,
|
||||
bookingText, primaNotaNumber, textKeySupplement
|
||||
)
|
||||
}
|
||||
|
||||
protected open fun joinReferenceParts(referenceParts: List<String>): String {
|
||||
val reference = StringBuilder()
|
||||
|
||||
referenceParts.firstOrNull()?.let {
|
||||
reference.append(it)
|
||||
}
|
||||
|
||||
for (i in 1..referenceParts.size - 1) {
|
||||
val part = referenceParts[i]
|
||||
if (part.isNotEmpty() && part.first().isUpperCase() && referenceParts[i - 1].last().isUpperCase() == false) {
|
||||
reference.append(" ")
|
||||
}
|
||||
|
||||
reference.append(part)
|
||||
}
|
||||
|
||||
return reference.toString()
|
||||
}
|
||||
|
||||
protected open fun isFormattedReference(referenceParts: List<String>): Boolean {
|
||||
return referenceParts.any { ReferenceTypeRegex.find(it) != null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeder Bezeichner [z.B. EREF+] muss am Anfang eines Subfeldes [z. B. ?21] stehen.
|
||||
* Bei Längenüberschreitung wird im nachfolgenden Subfeld ohne Wiederholung des Bezeichners fortgesetzt. Bei Wechsel des Bezeichners ist ein neues Subfeld zu beginnen.
|
||||
* Belegung in der nachfolgenden Reihenfolge, wenn vorhanden:
|
||||
* EREF+[ Ende-zu-Ende Referenz ] (DD-AT10; CT-AT41 - Angabe verpflichtend; NOTPROVIDED wird nicht eingestellt.)
|
||||
* KREF+[Kundenreferenz]
|
||||
* MREF+[Mandatsreferenz] (DD-AT01 - Angabe verpflichtend)
|
||||
* CRED+[Creditor Identifier] (DD-AT02 - Angabe verpflichtend bei SEPA-Lastschriften, nicht jedoch bei SEPA-Rücklastschriften)
|
||||
* DEBT+[Originators Identification Code](CT-AT10- Angabe verpflichtend,)
|
||||
* Entweder CRED oder DEBT
|
||||
*
|
||||
* optional zusätzlich zur Einstellung in Feld 61, Subfeld 9:
|
||||
*
|
||||
* COAM+ [Compensation Amount / Summe aus Auslagenersatz und Bearbeitungsprovision bei einer nationalen Rücklastschrift sowie optionalem Zinsausgleich.]
|
||||
* OAMT+[Original Amount] Betrag der ursprünglichen Lastschrift
|
||||
*
|
||||
* SVWZ+[SEPA-Verwendungszweck] (DD-AT22; CT-AT05 -Angabe verpflichtend, nicht jedoch bei R-Transaktionen)
|
||||
* ABWA+[Abweichender Überweisender] (CT-AT08) / Abweichender Zahlungsempfänger (DD-AT38) ] (optional)
|
||||
* ABWE+[Abweichender Zahlungsemp-fänger (CT-AT28) / Abweichender Zahlungspflichtiger ((DD-AT15)] (optional)
|
||||
*
|
||||
* Weitere 4 Verwendungszwecke können zu den Feldschlüsseln 60 bis 63 eingestellt werden.
|
||||
*/
|
||||
protected open fun mapReference(information: InformationToAccountOwner) {
|
||||
val referenceParts = getReferenceParts(information.unparsedReference)
|
||||
|
||||
referenceParts.forEach { entry ->
|
||||
setReferenceLineValue(information, entry.key, entry.value)
|
||||
}
|
||||
}
|
||||
|
||||
open fun getReferenceParts(unparsedReference: String): Map<String, String> {
|
||||
var previousMatchType = ""
|
||||
var previousMatchEnd = 0
|
||||
|
||||
val referenceParts = mutableMapOf<String, String>()
|
||||
|
||||
ReferenceTypeRegex.findAll(unparsedReference).forEach { matchResult ->
|
||||
if (previousMatchEnd > 0) {
|
||||
val typeValue = unparsedReference.substring(previousMatchEnd, matchResult.range.first)
|
||||
|
||||
referenceParts[previousMatchType] = typeValue
|
||||
}
|
||||
|
||||
previousMatchType = unparsedReference.substring(matchResult.range)
|
||||
previousMatchEnd = matchResult.range.last + 1
|
||||
}
|
||||
|
||||
if (previousMatchEnd > 0) {
|
||||
val typeValue = unparsedReference.substring(previousMatchEnd, unparsedReference.length)
|
||||
|
||||
referenceParts[previousMatchType] = typeValue
|
||||
}
|
||||
|
||||
return referenceParts
|
||||
}
|
||||
|
||||
// TODO: there are more. See .pdf from Deutsche Bank
|
||||
protected open fun setReferenceLineValue(information: InformationToAccountOwner, referenceType: String, typeValue: String) {
|
||||
when (referenceType) {
|
||||
EndToEndReferenceKey -> information.endToEndReference = typeValue
|
||||
CustomerReferenceKey -> information.customerReference = typeValue
|
||||
MandateReferenceKey -> information.mandateReference = typeValue
|
||||
CreditorIdentifierKey -> information.creditorIdentifier = typeValue
|
||||
OriginatorsIdentificationCodeKey -> information.originatorsIdentificationCode = typeValue
|
||||
CompensationAmountKey -> information.compensationAmount = typeValue
|
||||
OriginalAmountKey -> information.originalAmount = typeValue
|
||||
SepaReferenceKey -> information.sepaReference = typeValue
|
||||
DeviantOriginatorKey -> information.deviantOriginator = typeValue
|
||||
DeviantRecipientKey -> information.deviantRecipient = typeValue
|
||||
else -> information.referenceWithNoSpecialType = typeValue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected open fun parseMt940Date(dateString: String): LocalDate {
|
||||
// TODO: this should be necessary anymore, isn't it?
|
||||
|
||||
// SimpleDateFormat is not thread-safe. Before adding another library i decided to parse
|
||||
// this really simple date format on my own
|
||||
if (dateString.length == 6) {
|
||||
try {
|
||||
var year = dateString.substring(0, 2).toInt() + 2000
|
||||
val month = dateString.substring(2, 4).toInt()
|
||||
val day = dateString.substring(4, 6).toInt()
|
||||
|
||||
if (year > CurrentYearTwoDigit + 1) { // should be rarely the case: years before 2000
|
||||
year -= 100
|
||||
}
|
||||
|
||||
// ah, here we go, banks (in Germany) calculate with 30 days each month, so yes, it can happen that dates
|
||||
// like 30th of February or 29th of February in non-leap years occur, see:
|
||||
// https://de.m.wikipedia.org/wiki/30._Februar#30._Februar_in_der_Zinsberechnung
|
||||
if (month == 2 && (day > 29 || (day > 28 && year % 4 != 0))) { // fix that for banks each month has 30 days
|
||||
return LocalDate(year, 3, 1)
|
||||
}
|
||||
|
||||
return LocalDate(year , month, day)
|
||||
} catch (e: Exception) {
|
||||
logError("Could not parse dateString '$dateString'", e)
|
||||
}
|
||||
}
|
||||
|
||||
return DateFormatter.parseDate(dateString)!! // fallback to not thread-safe SimpleDateFormat. Works in most cases but not all
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking date string consists only of MMDD -> we need to take the year from value date string.
|
||||
*/
|
||||
protected open fun parseMt940BookingDate(bookingDateString: String, valueDateString: String, valueDate: LocalDate): LocalDate {
|
||||
val bookingDate = parseMt940Date(valueDateString.substring(0, 2) + bookingDateString)
|
||||
|
||||
// there are rare cases that booking date is e.g. on 31.12.2019 and value date on 01.01.2020 -> booking date would be on 31.12.2020 (and therefore in the future)
|
||||
val bookingDateMonth = bookingDate.month
|
||||
if (bookingDateMonth != valueDate.month && bookingDateMonth == Month.DECEMBER) {
|
||||
return parseMt940Date("" + (valueDate.year - 1 - 2000) + bookingDateString)
|
||||
}
|
||||
|
||||
return bookingDate
|
||||
}
|
||||
|
||||
protected open fun parseAmount(amountString: String): Amount {
|
||||
return Amount(amountString)
|
||||
}
|
||||
|
||||
|
||||
protected open fun logError(message: String, e: Exception?) {
|
||||
logAppender?.let { logAppender ->
|
||||
logAppender.logError(Mt940Parser::class, message, e)
|
||||
}
|
||||
?: run {
|
||||
log.error(e) { message }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
package net.codinux.banking.fints.transactions.mt940
|
||||
|
||||
import net.codinux.banking.fints.log.IMessageLogAppender
|
||||
import net.codinux.banking.fints.transactions.mt940.model.AmountAndCurrency
|
||||
import net.codinux.banking.fints.transactions.mt940.model.InterimAccountStatement
|
||||
import net.codinux.banking.fints.transactions.mt940.model.NumberOfPostingsAndAmount
|
||||
import net.codinux.banking.fints.transactions.mt940.model.Transaction
|
||||
|
||||
open class Mt942Parser(
|
||||
logAppender: IMessageLogAppender? = null
|
||||
) : Mt94xParserBase<InterimAccountStatement>(logAppender) {
|
||||
|
||||
/**
|
||||
* Parses a whole MT 942 statements string, that is one that ends with a "-" line.
|
||||
*/
|
||||
open fun parseMt942String(mt942String: String): List<InterimAccountStatement> =
|
||||
super.parseMt94xString(mt942String)
|
||||
|
||||
/**
|
||||
* Parses incomplete MT 942 statements string, that is ones that not end with a "-" line,
|
||||
* as they are returned e.g. if a HKKAZ response is dispersed over multiple messages.
|
||||
*
|
||||
* Tries to parse all statements in the string except an incomplete last one and returns an
|
||||
* incomplete last MT 942 statement (if any) as remainder.
|
||||
*
|
||||
* So each single HKKAZ partial response can be parsed immediately, its statements/transactions
|
||||
* be displayed immediately to user and the remainder then be passed together with next partial
|
||||
* HKKAZ response to this method till this whole MT 942 statement is parsed.
|
||||
*/
|
||||
open fun parseMt942Chunk(mt942Chunk: String): Pair<List<InterimAccountStatement>, String?> =
|
||||
super.parseMt94xChunk(mt942Chunk)
|
||||
|
||||
|
||||
override fun createAccountStatement(
|
||||
orderReferenceNumber: String,
|
||||
referenceNumber: String?,
|
||||
bankCodeBicOrIban: String,
|
||||
accountIdentifier: String?,
|
||||
statementNumber: Int,
|
||||
sheetNumber: Int?,
|
||||
transactions: List<Transaction>,
|
||||
fieldsByCode: List<Pair<String, String>>
|
||||
): InterimAccountStatement {
|
||||
// also decided against parsing smallest amounts, i don't think they ever going to be used
|
||||
// val smallestAmounts = fieldsByCode.filter { it.first.startsWith(SmallestAmountCode) } // should we parse it? i see no use in it
|
||||
// .mapIndexed { index, field -> parseAmountAndCurrency(field.second, index == 0) }
|
||||
|
||||
// decided against parsing creation time as there are so many non specification confirm time formats that parsing is likely to fail 'cause of this unused value
|
||||
// val creationTime = parseDateTime(fieldsByCode.first { it.first == CreationTimeCode || it.first.startsWith(CreationTimeStartCode) }.second)
|
||||
|
||||
val numberAndTotalOfDebitPostings = fieldsByCode.firstOrNull { it.first.equals(AmountOfDebitPostingsCode) }
|
||||
?.let { parseNumberAndTotalOfPostings(it.second) }
|
||||
val numberAndTotalOfCreditPostings = fieldsByCode.firstOrNull { it.first.equals(AmountOfCreditPostingsCode) }
|
||||
?.let { parseNumberAndTotalOfPostings(it.second) }
|
||||
|
||||
return InterimAccountStatement(
|
||||
orderReferenceNumber, referenceNumber,
|
||||
bankCodeBicOrIban, accountIdentifier,
|
||||
statementNumber, sheetNumber,
|
||||
transactions,
|
||||
numberAndTotalOfDebitPostings,
|
||||
numberAndTotalOfCreditPostings
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAmountAndCurrency(fieldValue: String, isCreditCharOptional: Boolean = false): AmountAndCurrency {
|
||||
val currency = fieldValue.substring(0, 3)
|
||||
val hasCreditChar = isCreditCharOptional == false || fieldValue[3].isLetter()
|
||||
val isCredit = if (hasCreditChar) fieldValue[3] == 'C' else false
|
||||
val amount = fieldValue.substring(if (hasCreditChar) 4 else 3)
|
||||
|
||||
return AmountAndCurrency(amount, currency, isCredit)
|
||||
}
|
||||
|
||||
protected open fun parseNumberAndTotalOfPostings(fieldValue: String): NumberOfPostingsAndAmount {
|
||||
val currencyStartIndex = fieldValue.indexOfFirst { it.isLetter() }
|
||||
|
||||
val numberOfPostings = fieldValue.substring(0, currencyStartIndex).toInt()
|
||||
val currency = fieldValue.substring(currencyStartIndex, currencyStartIndex + 3)
|
||||
val amount = fieldValue.substring(currencyStartIndex + 3)
|
||||
|
||||
return NumberOfPostingsAndAmount(numberOfPostings, amount, currency)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,480 @@
|
|||
package net.codinux.banking.fints.transactions.mt940
|
||||
|
||||
import kotlinx.datetime.*
|
||||
import net.codinux.banking.fints.log.IMessageLogAppender
|
||||
import net.codinux.banking.fints.transactions.mt940.model.*
|
||||
import net.codinux.banking.fints.transactions.swift.MtParserBase
|
||||
|
||||
|
||||
/*
|
||||
4.1. SWIFT Supported Characters
|
||||
|
||||
a until z
|
||||
A until Z
|
||||
0 until 9
|
||||
/ ‐ ? : ( ) . , ' + { }
|
||||
CR LF Space
|
||||
Although part of the character set, the curly brackets are permitted as delimiters and cannot be used within the text of
|
||||
user‐to‐user messages.
|
||||
Character ”‐” is not permitted as the first character of the line.
|
||||
None of lines include only Space.
|
||||
*/
|
||||
abstract class Mt94xParserBase<T: AccountStatementCommon>(
|
||||
logAppender: IMessageLogAppender? = null
|
||||
) : MtParserBase(logAppender) {
|
||||
|
||||
companion object {
|
||||
val AccountStatementsSeparatorRegex = Regex("^\\s*-\\s*\$", RegexOption.MULTILINE) // a line only with '-' and may other white space characters
|
||||
|
||||
// (?<!T\d\d(:\d\d)?) to filter that date time with format (yyyy-MM-dd)Thh:mm:ss(:SSS) is considered to be a field identifier
|
||||
val AccountStatementFieldSeparatorRegex = Regex("(?<!T\\d\\d(:\\d\\d)?):\\d\\d\\w?:")
|
||||
|
||||
|
||||
const val OrderReferenceNumberCode = "20"
|
||||
|
||||
const val ReferenceNumberCode = "21"
|
||||
|
||||
const val AccountIdentificationCode = "25"
|
||||
|
||||
const val StatementNumberCode = "28C"
|
||||
|
||||
const val StatementLineCode = "61"
|
||||
|
||||
const val RemittanceInformationFieldCode = "86"
|
||||
|
||||
|
||||
// MT 940 codes
|
||||
|
||||
const val OpeningBalanceCode = "60"
|
||||
|
||||
const val ClosingBalanceCode = "62"
|
||||
|
||||
|
||||
// MT 942 codes
|
||||
|
||||
const val SmallestAmountCode = "34F"
|
||||
const val SmallestAmountStartCode = "34"
|
||||
|
||||
const val CreationTimeCode = "13D"
|
||||
const val CreationTimeStartCode = "13" // Deutsche Bank and Sparkasse both use "13" instead of correct "13D"
|
||||
|
||||
const val AmountOfDebitPostingsCode = "90D"
|
||||
|
||||
const val AmountOfCreditPostingsCode = "90C"
|
||||
|
||||
|
||||
val CreditDebitCancellationRegex = Regex("C|D|RC|RD")
|
||||
|
||||
val AmountRegex = Regex("\\d+,\\d*")
|
||||
|
||||
val ReferenceTypeRegex = Regex("[A-Z]{4}\\+")
|
||||
|
||||
val RemittanceInformationSubFieldRegex = Regex("\\?\\d\\d")
|
||||
|
||||
|
||||
const val EndToEndReferenceKey = "EREF+"
|
||||
const val CustomerReferenceKey = "KREF+"
|
||||
const val MandateReferenceKey = "MREF+"
|
||||
const val CreditorIdentifierKey = "CRED+"
|
||||
const val OriginatorsIdentificationCodeKey = "DEBT+"
|
||||
const val CompensationAmountKey = "COAM+"
|
||||
const val OriginalAmountKey = "OAMT+"
|
||||
const val SepaReferenceKey = "SVWZ+"
|
||||
const val DeviantOriginatorKey = "ABWA+"
|
||||
const val DeviantRecipientKey = "ABWE+"
|
||||
}
|
||||
|
||||
|
||||
protected abstract fun createAccountStatement(
|
||||
orderReferenceNumber: String, referenceNumber: String?,
|
||||
bankCodeBicOrIban: String, accountIdentifier: String?,
|
||||
statementNumber: Int, sheetNumber: Int?,
|
||||
transactions: List<Transaction>,
|
||||
fieldsByCode: List<Pair<String, String>>
|
||||
): T
|
||||
|
||||
|
||||
/**
|
||||
* Parses a whole MT 940 statements string, that is one that ends with a "-" line.
|
||||
*/
|
||||
protected open fun parseMt94xString(mt94xString: String): List<T> {
|
||||
return parseMt94xChunk(mt94xString).first
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses incomplete MT 940 statements string, that is ones that not end with a "-" line,
|
||||
* as they are returned e.g. if a HKKAZ response is dispersed over multiple messages.
|
||||
*
|
||||
* Tries to parse all statements in the string except an incomplete last one and returns an
|
||||
* incomplete last MT 940 statement (if any) as remainder.
|
||||
*
|
||||
* So each single HKKAZ partial response can be parsed immediately, its statements/transactions
|
||||
* be displayed immediately to user and the remainder then be passed together with next partial
|
||||
* HKKAZ response to this method till this whole MT 940 statement is parsed.
|
||||
*/
|
||||
protected open fun parseMt94xChunk(mt94xChunk: String): Pair<List<T>, String?> {
|
||||
try {
|
||||
val singleAccountStatementsStrings = splitIntoSingleAccountStatements(mt94xChunk).toMutableList()
|
||||
|
||||
var remainder: String? = null
|
||||
if (singleAccountStatementsStrings.isNotEmpty() && singleAccountStatementsStrings.last().isEmpty() == false) {
|
||||
remainder = singleAccountStatementsStrings.removeAt(singleAccountStatementsStrings.lastIndex)
|
||||
}
|
||||
|
||||
val transactions = singleAccountStatementsStrings.mapNotNull { parseAccountStatement(it) }
|
||||
|
||||
return Pair(transactions, remainder)
|
||||
} catch (e: Exception) {
|
||||
logError("Could not parse account statements from MT940 string:\n$mt94xChunk", e)
|
||||
}
|
||||
|
||||
return Pair(listOf(), "")
|
||||
}
|
||||
|
||||
|
||||
protected open fun splitIntoSingleAccountStatements(mt940String: String): List<String> {
|
||||
return mt940String.split(AccountStatementsSeparatorRegex)
|
||||
.map { it.replace("\n", "").replace("\r", "") }
|
||||
}
|
||||
|
||||
|
||||
protected open fun parseAccountStatement(accountStatementString: String): T? {
|
||||
if (accountStatementString.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
val fieldsByCode = splitIntoFields(accountStatementString)
|
||||
|
||||
return parseAccountStatement(fieldsByCode)
|
||||
} catch (e: Exception) {
|
||||
logError("Could not parse account statement:\n$accountStatementString", e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
protected open fun splitIntoFields(accountStatementString: String): List<Pair<String, String>> {
|
||||
val result = mutableListOf<Pair<String, String>>()
|
||||
var lastMatchEnd = 0
|
||||
var lastMatchedCode = ""
|
||||
|
||||
AccountStatementFieldSeparatorRegex.findAll(accountStatementString).forEach { matchResult ->
|
||||
if (lastMatchEnd > 0) {
|
||||
val previousStatement = accountStatementString.substring(lastMatchEnd, matchResult.range.first)
|
||||
result.add(Pair(lastMatchedCode, previousStatement))
|
||||
}
|
||||
|
||||
lastMatchedCode = matchResult.value.replace(":", "")
|
||||
lastMatchEnd = matchResult.range.last + 1
|
||||
}
|
||||
|
||||
if (lastMatchEnd > 0) {
|
||||
val previousStatement = accountStatementString.substring(lastMatchEnd, accountStatementString.length)
|
||||
result.add(Pair(lastMatchedCode, previousStatement))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
protected open fun parseAccountStatement(fieldsByCode: List<Pair<String, String>>): T? {
|
||||
val orderReferenceNumber = getFieldValue(fieldsByCode, OrderReferenceNumberCode)
|
||||
val referenceNumber = getOptionalFieldValue(fieldsByCode, ReferenceNumberCode)
|
||||
|
||||
val statementAndMaySequenceNumber = getFieldValue(fieldsByCode, StatementNumberCode).split('/')
|
||||
val accountIdentification = getFieldValue(fieldsByCode, AccountIdentificationCode).split('/')
|
||||
|
||||
val transactions = parseAccountStatementTransactions(fieldsByCode)
|
||||
|
||||
return createAccountStatement(
|
||||
orderReferenceNumber, referenceNumber,
|
||||
accountIdentification[0], if (accountIdentification.size > 1) accountIdentification[1] else null,
|
||||
statementAndMaySequenceNumber[0].toInt(), if (statementAndMaySequenceNumber.size > 1) statementAndMaySequenceNumber[1].toInt() else null,
|
||||
transactions,
|
||||
fieldsByCode
|
||||
)
|
||||
}
|
||||
|
||||
protected open fun getFieldValue(fieldsByCode: List<Pair<String, String>>, code: String): String {
|
||||
return fieldsByCode.first { it.first == code }.second
|
||||
}
|
||||
|
||||
protected open fun getOptionalFieldValue(fieldsByCode: List<Pair<String, String>>, code: String): String? {
|
||||
return fieldsByCode.firstOrNull { it.first == code }?.second
|
||||
}
|
||||
|
||||
protected open fun parseBalance(code: String, fieldValue: String): Balance {
|
||||
val isIntermediate = code.endsWith("M")
|
||||
|
||||
val isDebit = fieldValue.startsWith("D")
|
||||
val bookingDateString = fieldValue.substring(1, 7)
|
||||
val statementDate = parseDate(bookingDateString)
|
||||
val currency = fieldValue.substring(7, 10)
|
||||
val amountString = fieldValue.substring(10)
|
||||
val amount = parseAmount(amountString)
|
||||
|
||||
return Balance(isIntermediate, !!!isDebit, statementDate, currency, amount)
|
||||
}
|
||||
|
||||
protected open fun parseAccountStatementTransactions(fieldsByCode: List<Pair<String, String>>): List<Transaction> {
|
||||
val transactions = mutableListOf<Transaction>()
|
||||
|
||||
fieldsByCode.forEachIndexed { index, pair ->
|
||||
if (pair.first == StatementLineCode) {
|
||||
val statementLine = parseStatementLine(pair.second)
|
||||
|
||||
val nextPair = if (index < fieldsByCode.size - 1) fieldsByCode.get(index + 1) else null
|
||||
val information = if (nextPair?.first == RemittanceInformationFieldCode) parseNullableRemittanceInformationField(nextPair.second) else null
|
||||
|
||||
transactions.add(Transaction(statementLine, information))
|
||||
}
|
||||
}
|
||||
|
||||
return transactions
|
||||
}
|
||||
|
||||
/**
|
||||
* FORMAT
|
||||
* 6!n[4!n]2a[1!a]15d1!a3!c16x[//16x]
|
||||
* [34x]
|
||||
*
|
||||
* where subfields are:
|
||||
* Subfield Format Name
|
||||
* 1 6!n (Value Date)
|
||||
* 2 [4!n] (Entry Date)
|
||||
* 3 2a (Debit/Credit Mark)
|
||||
* 4 [1!a] (Funds Code)
|
||||
* 5 15d (Amount)
|
||||
* 6 1!a3!c (Transaction Type)(Identification Code)
|
||||
* 7 16x (Reference for the Account Owner)
|
||||
* 8 [//16x] (Reference of the Account Servicing Institution)
|
||||
* 9 [34x] (Supplementary Details)
|
||||
*/
|
||||
protected open fun parseStatementLine(fieldValue: String): StatementLine {
|
||||
val valueDateString = fieldValue.substring(0, 6)
|
||||
val valueDate = parseDate(valueDateString)
|
||||
|
||||
val creditMarkMatchResult = CreditDebitCancellationRegex.find(fieldValue)
|
||||
val isDebit = creditMarkMatchResult?.value?.endsWith('D') == true
|
||||
val isCancellation = creditMarkMatchResult?.value?.startsWith('R') == true
|
||||
|
||||
val creditMarkEnd = (creditMarkMatchResult?.range?.last ?: -1) + 1
|
||||
|
||||
// booking date is the second field and is optional. It is normally only used when different from the value date.
|
||||
val bookingDateString = if ((creditMarkMatchResult?.range?.start ?: 0) > 6) fieldValue.substring(6, 10) else null
|
||||
val bookingDate = bookingDateString?.let { // bookingDateString has format MMdd -> add year from valueDateString
|
||||
parseMt940BookingDate(bookingDateString, valueDateString, valueDate)
|
||||
} ?: valueDate
|
||||
|
||||
val amountMatchResult = AmountRegex.find(fieldValue)!!
|
||||
val amountString = amountMatchResult.value
|
||||
val amount = parseAmount(amountString)
|
||||
|
||||
val amountEndIndex = amountMatchResult.range.last + 1
|
||||
|
||||
val fundsCode = if (amountMatchResult.range.start - creditMarkEnd > 1) fieldValue.substring(creditMarkEnd + 1, creditMarkEnd + 2) else null
|
||||
|
||||
/**
|
||||
* S SWIFT transfer For entries related to SWIFT transfer instructions and subsequent charge messages.
|
||||
*
|
||||
* N Non-SWIFT For entries related to payment and transfer instructions, including transfer related charges messages, not sent through SWIFT or where an alpha description is preferred.
|
||||
*
|
||||
* F First advice For entries being first advised by the statement (items originated by the account servicing institution).
|
||||
*/
|
||||
val transactionType = fieldValue.substring(amountEndIndex, amountEndIndex + 1) // transaction type is 'N', 'S' or 'F'
|
||||
|
||||
val postingKeyStart = amountEndIndex + 1
|
||||
val postingKey = fieldValue.substring(postingKeyStart, postingKeyStart + 3) // TODO: parse codes, p. 178
|
||||
|
||||
val customerAndBankReference = fieldValue.substring(postingKeyStart + 3).split("//")
|
||||
val customerReference = customerAndBankReference[0].takeIf { it != "NONREF" }
|
||||
|
||||
/**
|
||||
* The content of this subfield is the account servicing institution's own reference for the transaction.
|
||||
* When the transaction has been initiated by the account servicing institution, this
|
||||
* reference may be identical to subfield 7, Reference for the Account Owner. If this is
|
||||
* the case, Reference of the Account Servicing Institution, subfield 8 may be omitted.
|
||||
*/
|
||||
var bankReference = if (customerAndBankReference.size > 1) customerAndBankReference[1] else null
|
||||
var furtherInformation: String? = null
|
||||
|
||||
if (bankReference != null && bankReference.contains('\n')) {
|
||||
val bankReferenceAndFurtherInformation = bankReference.split("\n")
|
||||
bankReference = bankReferenceAndFurtherInformation[0].trim()
|
||||
// TODO: parse /OCMT/ and /CHGS/, see page 518
|
||||
furtherInformation = bankReferenceAndFurtherInformation[1].trim()
|
||||
}
|
||||
|
||||
return StatementLine(!!!isDebit, isCancellation, valueDate, bookingDate, null, amount, postingKey,
|
||||
customerReference, bankReference, furtherInformation)
|
||||
}
|
||||
|
||||
protected open fun parseNullableRemittanceInformationField(remittanceInformationFieldString: String): RemittanceInformationField? {
|
||||
try {
|
||||
val information = parseRemittanceInformationField(remittanceInformationFieldString)
|
||||
|
||||
mapReference(information)
|
||||
|
||||
return information
|
||||
} catch (e: Exception) {
|
||||
logError("Could not parse RemittanceInformationField from field value '$remittanceInformationFieldString'", e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
protected open fun parseRemittanceInformationField(remittanceInformationFieldString: String): RemittanceInformationField {
|
||||
// e. g. starts with 0 -> Inlandszahlungsverkehr, starts with '3' -> Wertpapiergeschäft
|
||||
// see Finanzdatenformate p. 209 - 215
|
||||
val geschaeftsvorfallCode = remittanceInformationFieldString.substring(0, 2) // TODO: may map
|
||||
|
||||
val referenceParts = mutableListOf<String>()
|
||||
val otherPartyName = StringBuilder()
|
||||
var otherPartyBankId: String? = null
|
||||
var otherPartyAccountId: String? = null
|
||||
var bookingText: String? = null
|
||||
var primaNotaNumber: String? = null
|
||||
var textKeySupplement: String? = null
|
||||
|
||||
val subFieldMatches = RemittanceInformationSubFieldRegex.findAll(remittanceInformationFieldString).toList()
|
||||
subFieldMatches.forEachIndexed { index, matchResult ->
|
||||
val fieldCode = matchResult.value.substring(1, 3).toInt()
|
||||
val endIndex = if (index + 1 < subFieldMatches.size) subFieldMatches[index + 1].range.start else remittanceInformationFieldString.length
|
||||
val fieldValue = remittanceInformationFieldString.substring(matchResult.range.last + 1, endIndex)
|
||||
|
||||
when (fieldCode) {
|
||||
0 -> bookingText = fieldValue
|
||||
10 -> primaNotaNumber = fieldValue
|
||||
in 20..29 -> referenceParts.add(fieldValue)
|
||||
30 -> otherPartyBankId = fieldValue
|
||||
31 -> otherPartyAccountId = fieldValue
|
||||
32, 33 -> otherPartyName.append(fieldValue)
|
||||
34 -> textKeySupplement = fieldValue
|
||||
in 60..63 -> referenceParts.add(fieldValue)
|
||||
}
|
||||
}
|
||||
|
||||
val reference = if (isFormattedReference(referenceParts)) joinReferenceParts(referenceParts)
|
||||
else referenceParts.joinToString(" ")
|
||||
|
||||
val otherPartyNameString = if (otherPartyName.isBlank()) null else otherPartyName.toString()
|
||||
|
||||
return RemittanceInformationField(
|
||||
reference, otherPartyNameString, otherPartyBankId, otherPartyAccountId,
|
||||
bookingText, primaNotaNumber, textKeySupplement
|
||||
)
|
||||
}
|
||||
|
||||
protected open fun joinReferenceParts(referenceParts: List<String>): String {
|
||||
val reference = StringBuilder()
|
||||
|
||||
referenceParts.firstOrNull()?.let {
|
||||
reference.append(it)
|
||||
}
|
||||
|
||||
for (i in 1..referenceParts.size - 1) {
|
||||
val part = referenceParts[i]
|
||||
if (part.isNotEmpty() && part.first().isUpperCase() && referenceParts[i - 1].last().isUpperCase() == false) {
|
||||
reference.append(" ")
|
||||
}
|
||||
|
||||
reference.append(part)
|
||||
}
|
||||
|
||||
return reference.toString()
|
||||
}
|
||||
|
||||
protected open fun isFormattedReference(referenceParts: List<String>): Boolean {
|
||||
return referenceParts.any { ReferenceTypeRegex.find(it) != null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeder Bezeichner [z.B. EREF+] muss am Anfang eines Subfeldes [z. B. ?21] stehen.
|
||||
* Bei Längenüberschreitung wird im nachfolgenden Subfeld ohne Wiederholung des Bezeichners fortgesetzt. Bei Wechsel des Bezeichners ist ein neues Subfeld zu beginnen.
|
||||
* Belegung in der nachfolgenden Reihenfolge, wenn vorhanden:
|
||||
* EREF+[ Ende-zu-Ende Referenz ] (DD-AT10; CT-AT41 - Angabe verpflichtend; NOTPROVIDED wird nicht eingestellt.)
|
||||
* KREF+[Kundenreferenz]
|
||||
* MREF+[Mandatsreferenz] (DD-AT01 - Angabe verpflichtend)
|
||||
* CRED+[Creditor Identifier] (DD-AT02 - Angabe verpflichtend bei SEPA-Lastschriften, nicht jedoch bei SEPA-Rücklastschriften)
|
||||
* DEBT+[Originators Identification Code](CT-AT10- Angabe verpflichtend,)
|
||||
* Entweder CRED oder DEBT
|
||||
*
|
||||
* optional zusätzlich zur Einstellung in Feld 61, Subfeld 9:
|
||||
*
|
||||
* COAM+ [Compensation Amount / Summe aus Auslagenersatz und Bearbeitungsprovision bei einer nationalen Rücklastschrift sowie optionalem Zinsausgleich.]
|
||||
* OAMT+[Original Amount] Betrag der ursprünglichen Lastschrift
|
||||
*
|
||||
* SVWZ+[SEPA-Verwendungszweck] (DD-AT22; CT-AT05 -Angabe verpflichtend, nicht jedoch bei R-Transaktionen)
|
||||
* ABWA+[Abweichender Überweisender] (CT-AT08) / Abweichender Zahlungsempfänger (DD-AT38) ] (optional)
|
||||
* ABWE+[Abweichender Zahlungsemp-fänger (CT-AT28) / Abweichender Zahlungspflichtiger ((DD-AT15)] (optional)
|
||||
*
|
||||
* Weitere 4 Verwendungszwecke können zu den Feldschlüsseln 60 bis 63 eingestellt werden.
|
||||
*/
|
||||
protected open fun mapReference(information: RemittanceInformationField) {
|
||||
val referenceParts = getReferenceParts(information.unparsedReference)
|
||||
|
||||
referenceParts.forEach { entry ->
|
||||
setReferenceLineValue(information, entry.key, entry.value)
|
||||
}
|
||||
}
|
||||
|
||||
open fun getReferenceParts(unparsedReference: String): Map<String, String> {
|
||||
var previousMatchType = ""
|
||||
var previousMatchEnd = 0
|
||||
|
||||
val referenceParts = mutableMapOf<String, String>()
|
||||
|
||||
ReferenceTypeRegex.findAll(unparsedReference).forEach { matchResult ->
|
||||
if (previousMatchEnd > 0) {
|
||||
val typeValue = unparsedReference.substring(previousMatchEnd, matchResult.range.first)
|
||||
|
||||
referenceParts[previousMatchType] = typeValue
|
||||
}
|
||||
|
||||
previousMatchType = unparsedReference.substring(matchResult.range)
|
||||
previousMatchEnd = matchResult.range.last + 1
|
||||
}
|
||||
|
||||
if (previousMatchEnd > 0) {
|
||||
val typeValue = unparsedReference.substring(previousMatchEnd, unparsedReference.length)
|
||||
|
||||
referenceParts[previousMatchType] = typeValue
|
||||
}
|
||||
|
||||
return referenceParts
|
||||
}
|
||||
|
||||
// TODO: there are more. See .pdf from Deutsche Bank
|
||||
protected open fun setReferenceLineValue(information: RemittanceInformationField, referenceType: String, typeValue: String) {
|
||||
when (referenceType) {
|
||||
EndToEndReferenceKey -> information.endToEndReference = typeValue
|
||||
CustomerReferenceKey -> information.customerReference = typeValue
|
||||
MandateReferenceKey -> information.mandateReference = typeValue
|
||||
CreditorIdentifierKey -> information.creditorIdentifier = typeValue
|
||||
OriginatorsIdentificationCodeKey -> information.originatorsIdentificationCode = typeValue
|
||||
CompensationAmountKey -> information.compensationAmount = typeValue
|
||||
OriginalAmountKey -> information.originalAmount = typeValue
|
||||
SepaReferenceKey -> information.sepaReference = typeValue
|
||||
DeviantOriginatorKey -> information.deviantOriginator = typeValue
|
||||
DeviantRecipientKey -> information.deviantRecipient = typeValue
|
||||
else -> information.referenceWithNoSpecialType = typeValue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Booking date string consists only of MMDD -> we need to take the year from value date string.
|
||||
*/
|
||||
protected open fun parseMt940BookingDate(bookingDateString: String, valueDateString: String, valueDate: LocalDate): LocalDate {
|
||||
val bookingDate = parseDate(valueDateString.substring(0, 2) + bookingDateString)
|
||||
|
||||
// there are rare cases that booking date is e.g. on 31.12.2019 and value date on 01.01.2020 -> booking date would be on 31.12.2020 (and therefore in the future)
|
||||
val bookingDateMonth = bookingDate.month
|
||||
if (bookingDateMonth != valueDate.month && bookingDateMonth == Month.DECEMBER) {
|
||||
return parseDate("" + (valueDate.year - 1 - 2000) + bookingDateString)
|
||||
}
|
||||
|
||||
return bookingDate
|
||||
}
|
||||
|
||||
}
|
|
@ -1,60 +1,18 @@
|
|||
package net.codinux.banking.fints.transactions.mt940.model
|
||||
|
||||
|
||||
open class AccountStatement(
|
||||
orderReferenceNumber: String,
|
||||
referenceNumber: String?,
|
||||
|
||||
/**
|
||||
* Referenznummer, die vom Sender als eindeutige Kennung für die Nachricht vergeben wurde
|
||||
* (z.B. als Referenz auf stornierte Nachrichten).
|
||||
*
|
||||
* Die Referenz darf nicht mit "/" starten oder enden; darf nicht "//" enthalten
|
||||
*
|
||||
* Max length = 16
|
||||
*/
|
||||
val transactionReferenceNumber: String,
|
||||
bankCodeBicOrIban: String,
|
||||
accountIdentifier: String?,
|
||||
|
||||
/**
|
||||
* Bezugsreferenz oder „NONREF“.
|
||||
*
|
||||
* Die Referenz darf nicht mit "/" starten oder enden; darf nicht "//" enthalten
|
||||
*
|
||||
* Max length = 16
|
||||
*/
|
||||
val relatedReferenceNumber: String?,
|
||||
|
||||
/**
|
||||
* xxxxxxxxxxx/Konto-Nr. oder yyyyyyyy/Konto-Nr.
|
||||
* wobei xxxxxxxxxxx = S.W.I.F.T.-Code
|
||||
* yyyyyyyy = Bankleitzahl
|
||||
* Konto-Nr. = max. 23 Stellen (ggf. mit Währung)
|
||||
*
|
||||
* Zukünftig kann hier auch die IBAN angegeben werden.
|
||||
*
|
||||
* Max length = 35
|
||||
*/
|
||||
val bankCodeBicOrIban: String,
|
||||
|
||||
val accountIdentifier: String?,
|
||||
|
||||
/**
|
||||
* Falls eine Auszugsnummer nicht unterstützt wird, ist „0“ einzustellen.
|
||||
*
|
||||
* Max length = 5
|
||||
*/
|
||||
val statementNumber: Int,
|
||||
|
||||
/**
|
||||
* „/“ kommt nach statementNumber falls Blattnummer belegt.
|
||||
*
|
||||
* beginnend mit „1“
|
||||
*
|
||||
* Max length = 5
|
||||
*/
|
||||
val sequenceNumber: Int?,
|
||||
statementNumber: Int,
|
||||
sheetNumber: Int?,
|
||||
|
||||
val openingBalance: Balance,
|
||||
|
||||
val transactions: List<Transaction>,
|
||||
transactions: List<Transaction>,
|
||||
|
||||
val closingBalance: Balance,
|
||||
|
||||
|
@ -72,20 +30,16 @@ open class AccountStatement(
|
|||
*
|
||||
* Max length = 65
|
||||
*/
|
||||
val multipurposeField: String? = null
|
||||
val remittanceInformationField: String? = null
|
||||
|
||||
) {
|
||||
) : AccountStatementCommon(orderReferenceNumber, referenceNumber, bankCodeBicOrIban, accountIdentifier, statementNumber, sheetNumber, transactions) {
|
||||
|
||||
// for object deserializers
|
||||
private constructor() : this("", "", "", null, 0, null, Balance(), listOf(), Balance())
|
||||
|
||||
|
||||
val isStatementNumberSupported: Boolean
|
||||
get() = statementNumber != 0
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return closingBalance.toString()
|
||||
return "$closingBalance ${super.toString()}"
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package net.codinux.banking.fints.transactions.mt940.model
|
||||
|
||||
open class AccountStatementCommon(
|
||||
|
||||
/**
|
||||
* Referenznummer, die vom Sender als eindeutige Kennung für die Nachricht vergeben wurde
|
||||
* (z.B. als Referenz auf stornierte Nachrichten).
|
||||
*
|
||||
* Die Referenz darf nicht mit "/" starten oder enden; darf nicht "//" enthalten
|
||||
*
|
||||
* Max length = 16
|
||||
*/
|
||||
val orderReferenceNumber: String,
|
||||
|
||||
/**
|
||||
* Bezugsreferenz oder „NONREF“.
|
||||
*
|
||||
* Die Referenz darf nicht mit "/" starten oder enden; darf nicht "//" enthalten
|
||||
*
|
||||
* Max length = 16
|
||||
*/
|
||||
val referenceNumber: String?,
|
||||
|
||||
/**
|
||||
* xxxxxxxxxxx/Konto-Nr. oder yyyyyyyy/Konto-Nr.
|
||||
* wobei xxxxxxxxxxx = S.W.I.F.T.-Code
|
||||
* yyyyyyyy = Bankleitzahl
|
||||
* Konto-Nr. = max. 23 Stellen (ggf. mit Währung)
|
||||
*
|
||||
* Zukünftig kann hier auch die IBAN angegeben werden.
|
||||
*
|
||||
* Max length = 35
|
||||
*/
|
||||
val bankCodeBicOrIban: String,
|
||||
|
||||
val accountIdentifier: String?,
|
||||
|
||||
/**
|
||||
* Falls eine Auszugsnummer nicht unterstützt wird, ist „0“ einzustellen.
|
||||
*
|
||||
* Max length = 5
|
||||
*/
|
||||
val statementNumber: Int,
|
||||
|
||||
/**
|
||||
* „/“ kommt nach statementNumber falls Blattnummer belegt.
|
||||
*
|
||||
* beginnend mit „1“
|
||||
*
|
||||
* Max length = 5
|
||||
*/
|
||||
val sheetNumber: Int?,
|
||||
|
||||
val transactions: List<Transaction>,
|
||||
|
||||
) {
|
||||
|
||||
// for object deserializers
|
||||
private constructor() : this("", "", "", null, 0, null, listOf())
|
||||
|
||||
|
||||
val isStatementNumberSupported: Boolean
|
||||
get() = statementNumber != 0
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return "$bankCodeBicOrIban, ${transactions.size} transactions"
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package net.codinux.banking.fints.transactions.mt940.model
|
||||
|
||||
class AmountAndCurrency(
|
||||
val amount: String,
|
||||
val currency: String,
|
||||
val isCredit: Boolean
|
||||
) {
|
||||
internal constructor() : this("not an amount", "not a currency", false) // for object deserializers
|
||||
|
||||
override fun toString() = "${if (isCredit == false) "-" else ""}$amount $currency"
|
||||
}
|
|
@ -20,7 +20,7 @@ open class Balance(
|
|||
val isCredit: Boolean,
|
||||
|
||||
/**
|
||||
* JJMMTT = Buchungsdatum des Saldos oder '0' beim ersten Auszug
|
||||
* JJMMTT = Buchungsdatum des Saldos oder '000000' beim ersten Auszug
|
||||
*
|
||||
* Max length = 6
|
||||
*/
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
package net.codinux.banking.fints.transactions.mt940.model
|
||||
|
||||
|
||||
open class InformationToAccountOwner(
|
||||
val unparsedReference: String,
|
||||
val otherPartyName: String?,
|
||||
val otherPartyBankCode: String?,
|
||||
val otherPartyAccountId: String?,
|
||||
val bookingText: String?,
|
||||
val primaNotaNumber: String?,
|
||||
val textKeySupplement: String?
|
||||
) {
|
||||
|
||||
var endToEndReference: String? = null
|
||||
|
||||
var customerReference: String? = null
|
||||
|
||||
var mandateReference: String? = null
|
||||
|
||||
var creditorIdentifier: String? = null
|
||||
|
||||
var originatorsIdentificationCode: String? = null
|
||||
|
||||
var compensationAmount: String? = null
|
||||
|
||||
var originalAmount: String? = null
|
||||
|
||||
var sepaReference: String? = null
|
||||
|
||||
var deviantOriginator: String? = null
|
||||
|
||||
var deviantRecipient: String? = null
|
||||
|
||||
var referenceWithNoSpecialType: String? = null
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return "$otherPartyName $unparsedReference"
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package net.codinux.banking.fints.transactions.mt940.model
|
||||
|
||||
open class InterimAccountStatement(
|
||||
orderReferenceNumber: String,
|
||||
referenceNumber: String?,
|
||||
|
||||
bankCodeBicOrIban: String,
|
||||
accountIdentifier: String?,
|
||||
|
||||
statementNumber: Int,
|
||||
sheetNumber: Int?,
|
||||
|
||||
// decided against parsing them, see Mt942Parser
|
||||
// val smallestAmountOfReportedTransactions: AmountAndCurrency,
|
||||
//
|
||||
// val smallestAmountOfReportedCreditTransactions: AmountAndCurrency? = null,
|
||||
//
|
||||
// val creationTime: Instant,
|
||||
|
||||
transactions: List<Transaction>,
|
||||
|
||||
val amountAndTotalOfDebitPostings: NumberOfPostingsAndAmount? = null,
|
||||
|
||||
val amountAndTotalOfCreditPostings: NumberOfPostingsAndAmount? = null,
|
||||
|
||||
) : AccountStatementCommon(orderReferenceNumber, referenceNumber, bankCodeBicOrIban, accountIdentifier, statementNumber, sheetNumber, transactions) {
|
||||
|
||||
// for object deserializers
|
||||
private constructor() : this("", "", "", null, 0, null, listOf())
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return "${amountAndTotalOfDebitPostings?.amount} ${super.toString()}"
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package net.codinux.banking.fints.transactions.mt940.model
|
||||
|
||||
class NumberOfPostingsAndAmount(
|
||||
val numberOfPostings: Int,
|
||||
val amount: String,
|
||||
val currency: String
|
||||
) {
|
||||
private constructor() : this(-1, "not an amount", "not a currency") // for object deserializers
|
||||
|
||||
override fun toString() = "$amount $currency, $numberOfPostings posting(s)"
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
package net.codinux.banking.fints.transactions.mt940.model
|
||||
|
||||
open class RemittanceInformationField(
|
||||
val unparsedReference: String,
|
||||
/**
|
||||
* AT 02 Name des Überweisenden
|
||||
* AT 03 Name des Zahlungsempfängers (bei mehr als 54 Zeichen wird der Name gekürzt)
|
||||
*/
|
||||
val otherPartyName: String?,
|
||||
/**
|
||||
* BLZ Überweisender / Zahlungsempfänger
|
||||
* Bei SEPA-Zahlungen BIC des Überweisenden / Zahlungsempfängers.
|
||||
*/
|
||||
val otherPartyBankId: String?,
|
||||
/**
|
||||
* AT 01 IBAN des Überweisenden (Zahlungseingang Überweisung)
|
||||
* AT 04 IBAN des Zahlungsempfängers (Eingang Lastschrift)
|
||||
*/
|
||||
val otherPartyAccountId: String?,
|
||||
/**
|
||||
* Buchungstext, z. B. DAUERAUFTRAG, BARGELDAUSZAHLUNG, ONLINE-UEBERWEISUNG, FOLGELASTSCHRIFT, ...
|
||||
*/
|
||||
val postingText: String?,
|
||||
/**
|
||||
* Primanoten-Nr.
|
||||
*/
|
||||
val journalNumber: String?,
|
||||
/**
|
||||
* Bei R-Transaktionen siehe Tabelle der
|
||||
* SEPA-Rückgabecodes, bei SEPALastschriften siehe optionale Belegung
|
||||
* bei GVC 104 und GVC 105 (GVC = Geschäftsvorfallcode)
|
||||
*/
|
||||
val textKeyAddition: String?
|
||||
) {
|
||||
|
||||
/**
|
||||
* (DDAT10; CT-AT41 - Angabe verpflichtend)
|
||||
* (NOTPROVIDED wird nicht eingestellt.
|
||||
* Im Falle von Schecks wird hinter EREF+ die Konstante „SCHECK-NR. “, gefolgt von der Schecknummer angegeben (erst
|
||||
* nach Migration Scheckvordruck auf ISO 20022; November 2016, entspricht dem Inhalt der EndToEndId des
|
||||
* entsprechenden Scheckumsatzes).
|
||||
*/
|
||||
var endToEndReference: String? = null
|
||||
|
||||
var customerReference: String? = null
|
||||
|
||||
/**
|
||||
* (DD-AT01 - Angabe verpflichtend)
|
||||
*/
|
||||
var mandateReference: String? = null
|
||||
|
||||
/**
|
||||
* (DD-AT02 - Angabe verpflichtend bei SEPALastschriften, nicht jedoch bei SEPARücklastschriften)
|
||||
*/
|
||||
var creditorIdentifier: String? = null
|
||||
|
||||
/**
|
||||
* (CT-AT10- Angabe verpflichtend,)
|
||||
* Entweder CRED oder DEBT
|
||||
*/
|
||||
var originatorsIdentificationCode: String? = null
|
||||
|
||||
/**
|
||||
* Summe aus Auslagenersatz und Bearbeitungsprovision bei einer nationalen Rücklastschrift
|
||||
* sowie optionalem Zinsausgleich.
|
||||
*/
|
||||
var compensationAmount: String? = null
|
||||
|
||||
/**
|
||||
* Betrag der ursprünglichen Lastschrift
|
||||
*/
|
||||
var originalAmount: String? = null
|
||||
|
||||
/**
|
||||
* (DD-AT22; CT-AT05 -Angabe verpflichtend, nicht jedoch bei RTransaktionen52)
|
||||
*/
|
||||
var sepaReference: String? = null
|
||||
|
||||
/**
|
||||
* Abweichender Überweisender (CT-AT08) / Abweichender Zahlungsempfänger (DD-AT38)
|
||||
* (optional)53
|
||||
*/
|
||||
var deviantOriginator: String? = null
|
||||
|
||||
/**
|
||||
* Abweichender Zahlungsempfänger (CT-AT28) /
|
||||
* Abweichender Zahlungspflichtiger ((DDAT15)
|
||||
* (optional)53
|
||||
*/
|
||||
var deviantRecipient: String? = null
|
||||
|
||||
var referenceWithNoSpecialType: String? = null
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return "$otherPartyName $unparsedReference"
|
||||
}
|
||||
|
||||
}
|
|
@ -42,26 +42,57 @@ open class StatementLine(
|
|||
val currencyType: String?,
|
||||
|
||||
/**
|
||||
* Codes see p. 177 bottom - 179
|
||||
*
|
||||
* After constant „N“
|
||||
* in Kontowährung
|
||||
*
|
||||
* Max length = 15
|
||||
*/
|
||||
val amount: Amount,
|
||||
|
||||
/**
|
||||
* in Kontowährung
|
||||
* Codes see p. 177 bottom - 179
|
||||
*
|
||||
* After constant „N“
|
||||
*
|
||||
* Length = 3
|
||||
*/
|
||||
val bookingKey: String,
|
||||
val postingKey: String,
|
||||
|
||||
val referenceForTheAccountOwner: String,
|
||||
/**
|
||||
* Kundenreferenz.
|
||||
* Bei Nichtbelegung wird „NONREF“ eingestellt, zum Beispiel bei Schecknummer
|
||||
* Wenn „KREF+“ eingestellt ist, dann erfolgt die Angabe der Referenznummer in Tag :86: .
|
||||
*/
|
||||
val customerReference: String?,
|
||||
|
||||
val referenceOfTheAccountServicingInstitution: String?,
|
||||
/**
|
||||
* Bankreferenz
|
||||
*/
|
||||
val bankReference: String?,
|
||||
|
||||
val supplementaryDetails: String? = null
|
||||
/**
|
||||
* Währungsart und Umsatzbetrag in Ursprungswährung (original currency
|
||||
* amount) in folgendem
|
||||
* Format:
|
||||
* /OCMT/3a..15d/
|
||||
* sowie Währungsart und
|
||||
* Gebührenbetrag
|
||||
* (charges) in folgendem
|
||||
* Format:
|
||||
* /CHGS/3a..15d/
|
||||
* 3a = 3-stelliger
|
||||
* Währungscode gemäß
|
||||
* ISO 4217
|
||||
* ..15d = Betrag mit Komma
|
||||
* als Dezimalzeichen (gemäß SWIFT-Konvention).
|
||||
* Im Falle von SEPALastschriftrückgaben ist
|
||||
* das Feld /OCMT/ mit dem
|
||||
* Originalbetrag und das
|
||||
* Feld /CHGS/ mit der
|
||||
* Summe aus Entgelten
|
||||
* sowie Zinsausgleich zu
|
||||
* belegen.
|
||||
*/
|
||||
val furtherInformationOriginalAmountAndCharges: String? = null
|
||||
|
||||
) {
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ package net.codinux.banking.fints.transactions.mt940.model
|
|||
open class Transaction(
|
||||
|
||||
val statementLine: StatementLine,
|
||||
val information: InformationToAccountOwner? = null
|
||||
val information: RemittanceInformationField? = null
|
||||
|
||||
) {
|
||||
|
||||
|
|
|
@ -0,0 +1,226 @@
|
|||
package net.codinux.banking.fints.transactions.swift
|
||||
|
||||
import kotlinx.datetime.*
|
||||
import net.codinux.banking.fints.extensions.EuropeBerlin
|
||||
import net.codinux.banking.fints.log.IMessageLogAppender
|
||||
import net.codinux.banking.fints.model.Amount
|
||||
import net.codinux.banking.fints.transactions.swift.model.ContinuationIndicator
|
||||
import net.codinux.banking.fints.transactions.swift.model.Holding
|
||||
import net.codinux.banking.fints.transactions.swift.model.StatementOfHoldings
|
||||
import net.codinux.banking.fints.transactions.swift.model.SwiftMessageBlock
|
||||
|
||||
open class Mt535Parser(
|
||||
logAppender: IMessageLogAppender? = null
|
||||
) : MtParserBase(logAppender) {
|
||||
|
||||
open fun parseMt535String(mt535String: String): List<StatementOfHoldings> {
|
||||
val blocks = parseMtString(mt535String, true)
|
||||
|
||||
// should actually always be only one block, just to be on the safe side
|
||||
return blocks.mapNotNull { parseStatementOfHoldings(it) }
|
||||
}
|
||||
|
||||
protected open fun parseStatementOfHoldings(mt535Block: SwiftMessageBlock): StatementOfHoldings? {
|
||||
try {
|
||||
val containsHoldings = mt535Block.getMandatoryField("17B").endsWith("//Y")
|
||||
val holdings = if (containsHoldings) parseHoldings(mt535Block) else emptyList()
|
||||
|
||||
return parseStatementOfHoldings(holdings, mt535Block)
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse MT 535 block:\n$mt535Block", e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
protected open fun parseStatementOfHoldings(holdings: List<Holding>, mt535Block: SwiftMessageBlock): StatementOfHoldings {
|
||||
val totalBalance = parseBalance(mt535Block.getMandatoryRepeatableField("19A").last())
|
||||
|
||||
val accountStatement = mt535Block.getMandatoryField("97A")
|
||||
val bankCode = accountStatement.substringAfter("//").substringBefore('/')
|
||||
val accountIdentifier = accountStatement.substringAfterLast('/')
|
||||
|
||||
val (pageNumber, continuationIndicator) = parsePageNumber(mt535Block)
|
||||
|
||||
val (statementDate, preparationDate) = parseStatementAndPreparationDate(mt535Block)
|
||||
|
||||
return StatementOfHoldings(bankCode, accountIdentifier, holdings, totalBalance?.first, totalBalance?.second, pageNumber, continuationIndicator, statementDate, preparationDate)
|
||||
}
|
||||
|
||||
// this is a MT5(35) specific balance format
|
||||
protected open fun parseBalance(balanceString: String?): Pair<Amount, String>? {
|
||||
if (balanceString != null) {
|
||||
val balancePart = balanceString.substringAfterLast('/')
|
||||
val amount = balancePart.substring(3)
|
||||
val isNegative = amount.startsWith("N")
|
||||
return Pair(Amount(if (isNegative) "-${amount.substring(1)}" else amount), balancePart.substring(0, 3))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
protected open fun parsePageNumber(mt535Block: SwiftMessageBlock): Pair<Int?, ContinuationIndicator> {
|
||||
return try {
|
||||
val pageNumberStatement = mt535Block.getMandatoryField("28E")
|
||||
val pageNumber = pageNumberStatement.substringBefore('/').toInt()
|
||||
val continuationIndicator = pageNumberStatement.substringAfter('/').let { indicatorString ->
|
||||
ContinuationIndicator.entries.firstOrNull { it.mtValue == indicatorString } ?: ContinuationIndicator.Unknown
|
||||
}
|
||||
|
||||
Pair(pageNumber, continuationIndicator)
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse statement and preparation date of block:\n$mt535Block", e)
|
||||
|
||||
Pair(null, ContinuationIndicator.Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun parseStatementAndPreparationDate(mt535Block: SwiftMessageBlock): Pair<LocalDate?, LocalDate?> {
|
||||
return try {
|
||||
// TODO: differ between 98A (without time) and 98C (with time)
|
||||
// TODO: ignore (before parsing?) 98A/C of holdings which start with ":PRIC//
|
||||
val dates = mt535Block.getMandatoryRepeatableField("98").map { it.substringBefore("//") to parse4DigitYearDate(it.substringAfter("//").substring(0, 8)) } // if given we ignore time
|
||||
val statementDate = dates.firstOrNull { it.first == ":STAT" }?.second // specifications and their implementations: the statement date is actually mandatory, but not all banks actually set it
|
||||
val preparationDate = dates.firstOrNull { it.first == ":PREP" }?.second
|
||||
|
||||
Pair(statementDate, preparationDate)
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse statement and preparation date of block:\n$mt535Block", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun parseHoldings(mt535Block: SwiftMessageBlock): List<Holding> {
|
||||
val blockLines = mt535Block.getFieldsInOrder()
|
||||
val holdingBlocksStartIndices = blockLines.indices.filter { blockLines[it].first == "16R" && blockLines[it].second == "FIN" }
|
||||
val holdingBlocksEndIndices = blockLines.indices.filter { blockLines[it].first == "16S" && blockLines[it].second == "FIN" }
|
||||
|
||||
val holdingBlocks = holdingBlocksStartIndices.mapIndexed { blockIndex, startIndex ->
|
||||
val endIndex = holdingBlocksEndIndices[blockIndex]
|
||||
val holdingBlockLines = blockLines.subList(startIndex + 1, endIndex)
|
||||
SwiftMessageBlock(holdingBlockLines)
|
||||
}
|
||||
|
||||
return holdingBlocks.mapNotNull { parseHolding(it) }
|
||||
}
|
||||
|
||||
protected open fun parseHolding(holdingBlock: SwiftMessageBlock): Holding? =
|
||||
try {
|
||||
val nameStatementLines = holdingBlock.getMandatoryField("35B").split("\n")
|
||||
val isinOrWkn = nameStatementLines.first()
|
||||
val isin = if (isinOrWkn.startsWith("ISIN ")) {
|
||||
isinOrWkn.substringAfter(' ')
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val wkn = if (isin == null) {
|
||||
isinOrWkn
|
||||
} else if (nameStatementLines[1].startsWith("DE")) {
|
||||
nameStatementLines[1]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val name = nameStatementLines.subList(if (isin == null || wkn == null) 1 else 2, nameStatementLines.size).joinToString(" ")
|
||||
|
||||
// TODO: check for optional code :90a: Preis
|
||||
// TODO: check for optional code :94B: Herkunft von Preis / Kurs
|
||||
// TODO: check for optional code :98A: Herkunft von Preis / Kurs
|
||||
// TODO: check for optional code :99A: Anzahl der aufgelaufenen Tage
|
||||
// TODO: check for optional code :92B: Exchange rate
|
||||
|
||||
val holdingTotalBalance = holdingBlock.getMandatoryField("93B")
|
||||
val balanceIsQuantity = holdingTotalBalance.startsWith(":AGGR//UNIT") // == Die Stückzahl wird als Zahl (Zähler) ausgedrückt
|
||||
// else it starts with "AGGR/FAMT" = Die Stückzahl wird als Nennbetrag ausgedrückt. Bei Nennbeträgen wird die Währung durch die „Depotwährung“ in Feld B:70E: bestimmt
|
||||
val totalBalanceWithOptionalSign = holdingTotalBalance.substring(":AGGR//UNIT/".length)
|
||||
val totalBalanceIsNegative = totalBalanceWithOptionalSign.first() == 'N'
|
||||
val totalBalance = if (totalBalanceIsNegative) "-" + totalBalanceWithOptionalSign.substring(1) else totalBalanceWithOptionalSign
|
||||
|
||||
// there's a second ":HOLD//" entry if the currency if the security differs from portfolio's currency // TODO: the 3rd holding of the DK example has this, so implement it to display the correct value
|
||||
val portfolioValueStatement = holdingBlock.getOptionalRepeatableField("19A")?.firstOrNull { it.startsWith(":HOLD//") }
|
||||
val portfolioValue = parseBalance(portfolioValueStatement?.substringAfter(":HOLD//")) // Value for total balance from B:93B: in the same currency as C:19A:
|
||||
|
||||
val (buyingDate, averageCostPrice, averageCostPriceCurrency) = parseHoldingAdditionalInformation(holdingBlock)
|
||||
|
||||
val (marketValue, pricingTime, totalCostPrice) = parseMarketValue(holdingBlock)
|
||||
|
||||
val balance = portfolioValue?.first ?: (if (balanceIsQuantity == false) Amount(totalBalance) else null)
|
||||
val quantity = if (balanceIsQuantity) totalBalance.replace(",", ".").toDoubleOrNull() else null
|
||||
|
||||
Holding(name, isin, wkn, buyingDate, quantity, averageCostPrice, balance, portfolioValue?.second ?: averageCostPriceCurrency, marketValue, pricingTime, totalCostPrice)
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse MT 535 holding block:\n$holdingBlock", e)
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
protected open fun parseHoldingAdditionalInformation(holdingBlock: SwiftMessageBlock): Triple<LocalDate?, Amount?, String?> {
|
||||
try {
|
||||
val additionalInformationLines = holdingBlock.getOptionalField("70E")?.split('\n')
|
||||
if (additionalInformationLines != null) {
|
||||
val firstLine = additionalInformationLines.first().substring(":HOLD//".length).let {
|
||||
if (it.startsWith("1")) it.substring(1) else it // specifications and their implementations: line obligatory has to start with '1' but that's not always the case
|
||||
}
|
||||
val currencyOfSafekeepingAccountIsUnit = firstLine.startsWith("STK") // otherwise it's "KON“ = Contracts or ISO currency code of the category currency in the case of securities quoted in percentages
|
||||
|
||||
val firstLineParts = firstLine.split('+')
|
||||
val buyingDate = if (firstLineParts.size > 4) parse4DigitYearDate(firstLineParts[4]) else null
|
||||
|
||||
val secondLine = if (additionalInformationLines.size > 1) additionalInformationLines[1].let { if (it.startsWith("2")) it.substring(1) else it } else "" // cut off "2"; the second line is actually mandatory, but to be on the safe side
|
||||
val secondLineParts = secondLine.split('+')
|
||||
val averageCostPriceAmount = if (secondLineParts.size > 0) secondLineParts[0] else null
|
||||
val averageCostPriceCurrency = if (secondLineParts.size > 1) secondLineParts[1] else null
|
||||
|
||||
// third and fourth line are only filled in in the case of futures contracts
|
||||
|
||||
return Triple(buyingDate, averageCostPriceAmount?.let { Amount(it) }, averageCostPriceCurrency)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse additional information for holding:\n$holdingBlock", e)
|
||||
}
|
||||
|
||||
return Triple(null, null, null)
|
||||
}
|
||||
|
||||
private fun parseMarketValue(holdingBlock: SwiftMessageBlock): Triple<Amount?, Instant?, Amount?> {
|
||||
try {
|
||||
val subBalanceDetailsLines = holdingBlock.getOptionalField("70C")?.split('\n')
|
||||
if (subBalanceDetailsLines != null) {
|
||||
val thirdLine = if (subBalanceDetailsLines.size > 2) subBalanceDetailsLines[2].let { if (it.startsWith("3")) it.substring(1) else it }.trim() else null
|
||||
val (marketValue, pricingTime) = if (thirdLine != null) {
|
||||
val thirdLineParts = thirdLine.split(' ')
|
||||
val marketValueAmountAndCurrency = if (thirdLineParts.size > 1) thirdLineParts[1].takeIf { it.isNotBlank() } else null
|
||||
val marketValue = marketValueAmountAndCurrency?.let { Amount(it.replace('.', ',').replace("EUR", "")) } // TODO: also check for other currencies
|
||||
val pricingTime = try {
|
||||
if (thirdLineParts.size > 2) thirdLineParts[2].let { if (it.length > 18) LocalDateTime.parse(it.substring(0, 19)).toInstant(TimeZone.EuropeBerlin) else null } else null
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse pricing time from line: $thirdLine", e)
|
||||
null
|
||||
}
|
||||
|
||||
marketValue to pricingTime
|
||||
} else {
|
||||
null to null
|
||||
}
|
||||
|
||||
val fourthLine = if (subBalanceDetailsLines.size > 3) subBalanceDetailsLines[3].let { if (it.startsWith("4")) it.substring(1) else it }.trim() else null
|
||||
|
||||
val totalCostPrice = if (fourthLine != null) {
|
||||
val fourthLineParts = fourthLine.split(' ')
|
||||
val totalCostPriceAmountAndCurrency = if (fourthLineParts.size > 0) fourthLineParts[0] else null
|
||||
|
||||
totalCostPriceAmountAndCurrency?.let { Amount(it.replace('.', ',').replace("EUR", "")) } // TODO: also check for other currencies
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return Triple(marketValue, pricingTime, totalCostPrice)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not map market value and total cost price, but is a non-standard anyway", e)
|
||||
}
|
||||
|
||||
return Triple(null, null, null)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,154 @@
|
|||
package net.codinux.banking.fints.transactions.swift
|
||||
|
||||
import kotlinx.datetime.*
|
||||
import net.codinux.banking.fints.extensions.EuropeBerlin
|
||||
import net.codinux.banking.fints.log.IMessageLogAppender
|
||||
import net.codinux.banking.fints.model.Amount
|
||||
import net.codinux.banking.fints.transactions.mt940.Mt94xParserBase
|
||||
import net.codinux.banking.fints.transactions.swift.model.SwiftMessageBlock
|
||||
import net.codinux.log.logger
|
||||
|
||||
open class MtParserBase(
|
||||
open var logAppender: IMessageLogAppender? = null
|
||||
) {
|
||||
|
||||
protected val log by logger()
|
||||
|
||||
|
||||
fun parseMtString(mt: String, rememberOrderOfFields: Boolean = false): List<SwiftMessageBlock> {
|
||||
val lines = mt.lines().filterNot { it.isBlank() }
|
||||
|
||||
return parseMtStringLines(lines, rememberOrderOfFields)
|
||||
}
|
||||
|
||||
protected open fun parseMtStringLines(lines: List<String>, rememberOrderOfFields: Boolean = false): List<SwiftMessageBlock> {
|
||||
val messageBlocks = mutableListOf<SwiftMessageBlock>()
|
||||
var currentBlock = SwiftMessageBlock()
|
||||
|
||||
var fieldCode = ""
|
||||
val fieldValueLines = mutableListOf<String>()
|
||||
|
||||
lines.forEach { line ->
|
||||
// end of block
|
||||
if (line.trim() == "-") {
|
||||
if (fieldCode.isNotBlank()) {
|
||||
currentBlock.addField(fieldCode, fieldValueLines, rememberOrderOfFields)
|
||||
}
|
||||
messageBlocks.add(currentBlock)
|
||||
|
||||
currentBlock = SwiftMessageBlock()
|
||||
fieldCode = ""
|
||||
fieldValueLines.clear() // actually not necessary
|
||||
}
|
||||
// start of a new field
|
||||
else if (line.length > 5 && line[0] == ':' && line[1].isDigit() && line[2].isDigit() && (line[3] == ':' || line[3].isLetter() && line[4] == ':')) {
|
||||
if (fieldCode.isNotBlank()) {
|
||||
currentBlock.addField(fieldCode, fieldValueLines, rememberOrderOfFields)
|
||||
}
|
||||
|
||||
val fieldCodeContainsLetter = line[3].isLetter()
|
||||
fieldCode = line.substring(1, if (fieldCodeContainsLetter) 4 else 3)
|
||||
fieldValueLines.clear()
|
||||
fieldValueLines.add(if (fieldCodeContainsLetter) line.substring(5) else line.substring(4))
|
||||
}
|
||||
// a line that belongs to previous field value
|
||||
else {
|
||||
fieldValueLines.add(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldCode.isNotBlank()) {
|
||||
currentBlock.addField(fieldCode, fieldValueLines, rememberOrderOfFields)
|
||||
}
|
||||
if (currentBlock.hasFields) {
|
||||
messageBlocks.add(currentBlock)
|
||||
}
|
||||
|
||||
return messageBlocks
|
||||
}
|
||||
|
||||
|
||||
open fun parse4DigitYearDate(dateString: String): LocalDate {
|
||||
val year = dateString.substring(0, 4).toInt()
|
||||
val month = dateString.substring(4, 6).toInt()
|
||||
val day = dateString.substring(6, 8).toInt()
|
||||
|
||||
return LocalDate(year , month, fixDay(year, month, day))
|
||||
}
|
||||
|
||||
open fun parseDate(dateString: String): LocalDate {
|
||||
try {
|
||||
var year = dateString.substring(0, 2).toInt()
|
||||
val month = dateString.substring(2, 4).toInt()
|
||||
val day = dateString.substring(4, 6).toInt()
|
||||
|
||||
/**
|
||||
* Bei 6-stelligen Datumsangaben (d.h. JJMMTT) wird gemäß SWIFT zwischen dem 20. und 21.
|
||||
* Jahrhundert wie folgt unterschieden:
|
||||
* - Ist das Jahr (d.h. JJ) größer als 79, bezieht sich das Datum auf das 20. Jahrhundert. Ist
|
||||
* das Jahr 79 oder kleiner, bezieht sich das Datum auf das 21. Jahrhundert.
|
||||
* - Ist JJ > 79:JJMMTT = 19JJMMTT
|
||||
* - sonst: JJMMTT = 20JJMMTT
|
||||
* - Damit reicht die Spanne des sechsstelligen Datums von 1980 bis 2079.
|
||||
*/
|
||||
if (year > 79) {
|
||||
year += 1900
|
||||
} else {
|
||||
year += 2000
|
||||
}
|
||||
|
||||
return LocalDate(year , month, fixDay(year, month, day))
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse dateString '$dateString'", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixDay(year: Int, month: Int, day: Int): Int {
|
||||
// ah, here we go, banks (in Germany) calculate with 30 days each month, so yes, it can happen that dates
|
||||
// like 30th of February or 29th of February in non-leap years occur, see:
|
||||
// https://de.m.wikipedia.org/wiki/30._Februar#30._Februar_in_der_Zinsberechnung
|
||||
if (month == 2 && (day > 29 || (day > 28 && year % 4 != 0))) { // fix that for banks each month has 30 days
|
||||
return 28
|
||||
}
|
||||
|
||||
return day
|
||||
}
|
||||
|
||||
open fun parseTime(timeString: String): LocalTime {
|
||||
val hour = timeString.substring(0, 2).toInt()
|
||||
val minute = timeString.substring(2, 4).toInt()
|
||||
|
||||
return LocalTime(hour, minute)
|
||||
}
|
||||
|
||||
open fun parseDateTime(dateTimeString: String): Instant {
|
||||
val date = parseDate(dateTimeString.substring(0, 6))
|
||||
|
||||
val time = parseTime(dateTimeString.substring(6, 10))
|
||||
|
||||
val dateTime = LocalDateTime(date, time)
|
||||
|
||||
return if (dateTimeString.length == 15) { // actually mandatory, but by far not always stated: the time zone
|
||||
val plus = dateTimeString[10] == '+'
|
||||
val timeDifference = parseTime(dateTimeString.substring(11))
|
||||
|
||||
dateTime.toInstant(UtcOffset(if (plus) timeDifference.hour else timeDifference.hour * -1, timeDifference.minute))
|
||||
} else { // we then assume the server states the DateTime in FinTS's default time zone, Europe/Berlin
|
||||
dateTime.toInstant(TimeZone.EuropeBerlin)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun parseAmount(amountString: String): Amount {
|
||||
return Amount(amountString)
|
||||
}
|
||||
|
||||
|
||||
protected open fun logError(message: String, e: Throwable?) {
|
||||
logAppender?.logError(Mt94xParserBase::class, message, e)
|
||||
?: run {
|
||||
log.error(e) { message }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package net.codinux.banking.fints.transactions.swift.model
|
||||
|
||||
enum class ContinuationIndicator(internal val mtValue: String) {
|
||||
/**
|
||||
* The only page
|
||||
*/
|
||||
SinglePage("ONLY"),
|
||||
|
||||
/**
|
||||
* Intermediate page, more pages follow
|
||||
*/
|
||||
IntermediatePage("MORE"),
|
||||
|
||||
/**
|
||||
* Last page
|
||||
*/
|
||||
LastPage("LAST"),
|
||||
|
||||
Unknown("NotAMtValue")
|
||||
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package net.codinux.banking.fints.transactions.swift.model
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.codinux.banking.fints.model.Amount
|
||||
|
||||
@Serializable
|
||||
data class Holding(
|
||||
val name: String,
|
||||
val isin: String?,
|
||||
val wkn: String?,
|
||||
val buyingDate: LocalDate?,
|
||||
val quantity: Double?,
|
||||
/**
|
||||
* (Durchschnittlicher) Einstandspreis/-kurs einer Einheit des Wertpapiers
|
||||
*/
|
||||
val averageCostPrice: Amount?,
|
||||
/**
|
||||
* Gesamter Kurswert aller Einheiten des Wertpapiers
|
||||
*/
|
||||
val totalBalance: Amount?,
|
||||
val currency: String? = null,
|
||||
|
||||
/**
|
||||
* Aktueller Kurswert einer einzelnen Einheit des Wertpapiers
|
||||
*/
|
||||
val marketValue: Amount? = null,
|
||||
/**
|
||||
* Zeitpunkt zu dem der Kurswert bestimmt wurde
|
||||
*/
|
||||
val pricingTime: Instant? = null,
|
||||
/**
|
||||
* Gesamter Einstandspreis
|
||||
*/
|
||||
val totalCostPrice: Amount? = null
|
||||
)
|
|
@ -0,0 +1,36 @@
|
|||
package net.codinux.banking.fints.transactions.swift.model
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.codinux.banking.fints.model.Amount
|
||||
|
||||
/**
|
||||
* 4.3 MT 535 Depotaufstellung
|
||||
* „Statement of Holdings“; basiert auf SWIFT „Standards Release Guide“
|
||||
* (letzte berücksichtigte Änderung SRG 1998)
|
||||
*/
|
||||
@Serializable
|
||||
data class StatementOfHoldings(
|
||||
val bankCode: String,
|
||||
val accountIdentifier: String,
|
||||
|
||||
val holdings: List<Holding>,
|
||||
|
||||
val totalBalance: Amount? = null,
|
||||
val currency: String? = null,
|
||||
|
||||
/**
|
||||
* The page number is actually mandatory, but to be prepared for surprises like for [statementDate] i added error
|
||||
* handling and made it optional.
|
||||
*/
|
||||
val pageNumber: Int? = null,
|
||||
val continuationIndicator: ContinuationIndicator = ContinuationIndicator.Unknown,
|
||||
|
||||
/**
|
||||
* The statement date is actually mandatory, but not all banks actually set it.
|
||||
*/
|
||||
val statementDate: LocalDate? = null,
|
||||
val preparationDate: LocalDate? = null
|
||||
) {
|
||||
override fun toString() = "$bankCode ${holdings.size} holdings: ${holdings.joinToString { "{${it.name} ${it.totalBalance}" }}"
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
package net.codinux.banking.fints.transactions.swift.model
|
||||
|
||||
class SwiftMessageBlock(
|
||||
initialFields: List<Pair<String, String>>? = null
|
||||
) {
|
||||
|
||||
private val fields = LinkedHashMap<String, MutableList<String>>()
|
||||
|
||||
private val fieldsInOrder = mutableListOf<Pair<String, String>>()
|
||||
|
||||
val hasFields: Boolean
|
||||
get() = fields.isNotEmpty()
|
||||
|
||||
val fieldCodes: Collection<String>
|
||||
get() = fields.keys
|
||||
|
||||
init {
|
||||
initialFields?.forEach { (fieldCode, fieldValue) ->
|
||||
addField(fieldCode, fieldValue)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun addField(fieldCode: String, fieldValueLines: List<String>, rememberOrderOfFields: Boolean = false) {
|
||||
val fieldValue = fieldValueLines.joinToString("\n")
|
||||
|
||||
addField(fieldCode, fieldValue, rememberOrderOfFields)
|
||||
}
|
||||
|
||||
fun addField(fieldCode: String, fieldValue: String, rememberOrderOfFields: Boolean = false) {
|
||||
fields.getOrPut(fieldCode) { mutableListOf() }.add(fieldValue)
|
||||
|
||||
if (rememberOrderOfFields) {
|
||||
fieldsInOrder.add(Pair(fieldCode, fieldValue))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getFieldsInOrder(): List<Pair<String, String>> = fieldsInOrder.toList() // make a copy
|
||||
|
||||
fun getMandatoryField(fieldCode: String): String =
|
||||
getMandatoryFieldValue(fieldCode).first()
|
||||
|
||||
fun getOptionalField(fieldCode: String): String? =
|
||||
getOptionalFieldValue(fieldCode)?.first()
|
||||
|
||||
fun getMandatoryRepeatableField(fieldCode: String): List<String> =
|
||||
getMandatoryFieldValue(fieldCode)
|
||||
|
||||
fun getOptionalRepeatableField(fieldCode: String): List<String>? =
|
||||
getOptionalFieldValue(fieldCode)
|
||||
|
||||
private fun getMandatoryFieldValue(fieldCode: String): List<String> =
|
||||
fields[fieldCode] ?: fields.entries.firstOrNull { it.key.startsWith(fieldCode) }?.value
|
||||
?: throw IllegalStateException("Block contains no field with code '$fieldCode'. Available fields: ${fields.keys}")
|
||||
|
||||
private fun getOptionalFieldValue(fieldCode: String): List<String>? = fields[fieldCode]
|
||||
|
||||
|
||||
override fun toString() =
|
||||
if (fieldsInOrder.isNotEmpty()) {
|
||||
fieldsInOrder.joinToString("\n")
|
||||
} else {
|
||||
fields.entries.joinToString("\n") { "${it.key}${it.value}" }
|
||||
}
|
||||
|
||||
}
|
|
@ -8,66 +8,40 @@ open class TanMethodSelector {
|
|||
|
||||
companion object {
|
||||
|
||||
val NonVisual = listOf(TanMethodType.AppTan, TanMethodType.SmsTan, TanMethodType.ChipTanManuell, TanMethodType.EnterTan)
|
||||
val NonVisual = listOf(TanMethodType.DecoupledTan, TanMethodType.DecoupledPushTan, TanMethodType.AppTan, TanMethodType.SmsTan, TanMethodType.ChipTanManuell, TanMethodType.EnterTan)
|
||||
|
||||
val ImageBased = listOf(TanMethodType.QrCode, TanMethodType.ChipTanQrCode, TanMethodType.photoTan, TanMethodType.ChipTanPhotoTanMatrixCode)
|
||||
|
||||
/**
|
||||
* NonVisualOrImageBased is a good default for most users as it lists the most simplistic ones (which also work with
|
||||
* the command line) first and then continues with image based TAN methods, which for UI applications are easily to display.
|
||||
*/
|
||||
val NonVisualOrImageBased = buildList {
|
||||
// decoupled TAN method is the most simplistic TAN method, user only has to confirm the action in her TAN app, no manual TAN entering required
|
||||
// AppTan is the second most simplistic TAN method: user has to confirm action in her TAN app and then enter the displayed TAN
|
||||
addAll(listOf(TanMethodType.DecoupledTan, TanMethodType.DecoupledPushTan, TanMethodType.AppTan, TanMethodType.SmsTan, TanMethodType.EnterTan))
|
||||
addAll(ImageBased)
|
||||
addAll(listOf(TanMethodType.ChipTanManuell)) // this is quite inconvenient for user, so i added it as last
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
open fun getSuggestedTanMethod(tanMethods: List<TanMethod>): TanMethod? {
|
||||
return tanMethods.firstOrNull { it.type != TanMethodType.ChipTanUsb && it.type != TanMethodType.SmsTan && it.type != TanMethodType.ChipTanManuell }
|
||||
?: tanMethods.firstOrNull { it.type != TanMethodType.ChipTanUsb && it.type != TanMethodType.SmsTan }
|
||||
?: tanMethods.firstOrNull { it.type != TanMethodType.ChipTanUsb }
|
||||
?: first(tanMethods)
|
||||
open fun getSuggestedTanMethod(tanMethods: List<TanMethod>, tanMethodsNotSupportedByApplication: List<TanMethodType> = emptyList()): TanMethod? {
|
||||
return findPreferredTanMethod(tanMethods, NonVisualOrImageBased, tanMethodsNotSupportedByApplication) // we use NonVisualOrImageBased as it provides a good default for most users
|
||||
?: tanMethods.firstOrNull { it.type !in tanMethodsNotSupportedByApplication }
|
||||
}
|
||||
|
||||
open fun findPreferredTanMethod(tanMethods: List<TanMethod>, preferredTanMethods: List<TanMethodType>?): TanMethod? {
|
||||
open fun findPreferredTanMethod(tanMethods: List<TanMethod>, preferredTanMethods: List<TanMethodType>?, tanMethodsNotSupportedByApplication: List<TanMethodType> = emptyList()): TanMethod? {
|
||||
preferredTanMethods?.forEach { preferredTanMethodType ->
|
||||
tanMethods.firstOrNull { it.type == preferredTanMethodType }?.let {
|
||||
return it
|
||||
if (preferredTanMethodType !in tanMethodsNotSupportedByApplication) {
|
||||
tanMethods.firstOrNull { it.type == preferredTanMethodType }?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
open fun nonVisual(tanMethods: List<TanMethod>): TanMethod? {
|
||||
return findPreferredTanMethod(tanMethods, NonVisual)
|
||||
?: tanMethods.firstOrNull { it.displayName.contains("manuell", true) }
|
||||
}
|
||||
|
||||
open fun nonVisualOrFirst(tanMethods: List<TanMethod>): TanMethod? {
|
||||
return nonVisual(tanMethods)
|
||||
?: first(tanMethods)
|
||||
}
|
||||
|
||||
|
||||
open fun imageBased(tanMethods: List<TanMethod>): TanMethod? {
|
||||
return findPreferredTanMethod(tanMethods, ImageBased)
|
||||
}
|
||||
|
||||
open fun imageBasedOrFirst(tanMethods: List<TanMethod>): TanMethod? {
|
||||
return imageBased(tanMethods)
|
||||
?: first(tanMethods)
|
||||
}
|
||||
|
||||
|
||||
open fun nonVisualOrImageBased(tanMethods: List<TanMethod>): TanMethod? {
|
||||
return nonVisual(tanMethods)
|
||||
?: imageBased(tanMethods)
|
||||
}
|
||||
|
||||
open fun nonVisualOrImageBasedOrFirst(tanMethods: List<TanMethod>): TanMethod? {
|
||||
return nonVisual(tanMethods)
|
||||
?: imageBased(tanMethods)
|
||||
?: first(tanMethods)
|
||||
}
|
||||
|
||||
|
||||
open fun first(tanMethods: List<TanMethod>): TanMethod? {
|
||||
return tanMethods.firstOrNull()
|
||||
}
|
||||
|
||||
}
|
|
@ -10,70 +10,72 @@ import net.codinux.banking.fints.extensions.UnixEpochStart
|
|||
@Serializable
|
||||
open class AccountTransaction(
|
||||
val amount: Money, // TODO: if we decide to stick with Money, create own type, don't use that one from fints.model (or move over from)
|
||||
val unparsedReference: String, // alternative names: purpose, reason
|
||||
val reference: String?, // alternative names: purpose, reason
|
||||
|
||||
val bookingDate: LocalDate,
|
||||
val otherPartyName: String?,
|
||||
val otherPartyBankCode: String?,
|
||||
val otherPartyAccountId: String?,
|
||||
val bookingText: String?,
|
||||
val valueDate: LocalDate,
|
||||
val statementNumber: Int,
|
||||
val sequenceNumber: Int?,
|
||||
|
||||
val otherPartyName: String?,
|
||||
val otherPartyBankId: String?,
|
||||
val otherPartyAccountId: String?,
|
||||
|
||||
val postingText: String?,
|
||||
|
||||
val openingBalance: Money?,
|
||||
val closingBalance: Money?,
|
||||
|
||||
val endToEndReference: String?,
|
||||
val statementNumber: Int,
|
||||
val sheetNumber: Int?,
|
||||
|
||||
val customerReference: String?,
|
||||
val bankReference: String?,
|
||||
val furtherInformation: String?,
|
||||
|
||||
val endToEndReference: String?,
|
||||
val mandateReference: String?,
|
||||
val creditorIdentifier: String?,
|
||||
val originatorsIdentificationCode: String?,
|
||||
|
||||
val compensationAmount: String?,
|
||||
val originalAmount: String?,
|
||||
val sepaReference: String?,
|
||||
val deviantOriginator: String?,
|
||||
val deviantRecipient: String?,
|
||||
val referenceWithNoSpecialType: String?,
|
||||
val primaNotaNumber: String?,
|
||||
val textKeySupplement: String?,
|
||||
|
||||
val currencyType: String?,
|
||||
val bookingKey: String,
|
||||
val referenceForTheAccountOwner: String,
|
||||
val referenceOfTheAccountServicingInstitution: String?,
|
||||
val supplementaryDetails: String?,
|
||||
val journalNumber: String?,
|
||||
val textKeyAddition: String?,
|
||||
|
||||
val transactionReferenceNumber: String,
|
||||
val relatedReferenceNumber: String?
|
||||
val orderReferenceNumber: String?,
|
||||
val referenceNumber: String?,
|
||||
|
||||
val isReversal: Boolean
|
||||
) {
|
||||
|
||||
// for object deserializers
|
||||
internal constructor() : this(Money(Amount.Zero, ""), "", UnixEpochStart, null, null, null, null, UnixEpochStart)
|
||||
internal constructor() : this(Money(Amount.Zero, ""), "", UnixEpochStart, UnixEpochStart, null, null, null, null)
|
||||
|
||||
constructor(amount: Money, unparsedReference: String, bookingDate: LocalDate, otherPartyName: String?, otherPartyBankCode: String?, otherPartyAccountId: String?, bookingText: String?, valueDate: LocalDate)
|
||||
: this(amount, unparsedReference, bookingDate, otherPartyName, otherPartyBankCode, otherPartyAccountId, bookingText, valueDate,
|
||||
0, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, "", "", null, null, "", null)
|
||||
constructor(amount: Money, unparsedReference: String, bookingDate: LocalDate, valueDate: LocalDate, otherPartyName: String?, otherPartyBankId: String?, otherPartyAccountId: String?, postingText: String?)
|
||||
: this(amount, unparsedReference, bookingDate, valueDate, otherPartyName, otherPartyBankId, otherPartyAccountId, postingText,
|
||||
null, null, 0, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, false)
|
||||
|
||||
|
||||
open val showOtherPartyName: Boolean
|
||||
get() = otherPartyName.isNullOrBlank() == false /* && type != "ENTGELTABSCHLUSS" && type != "AUSZAHLUNG" */ // TODO
|
||||
|
||||
val reference: String
|
||||
get() = sepaReference ?: unparsedReference
|
||||
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AccountTransaction) return false
|
||||
|
||||
if (amount != other.amount) return false
|
||||
if (unparsedReference != other.unparsedReference) return false
|
||||
if (reference != other.reference) return false
|
||||
if (bookingDate != other.bookingDate) return false
|
||||
if (otherPartyName != other.otherPartyName) return false
|
||||
if (otherPartyBankCode != other.otherPartyBankCode) return false
|
||||
if (otherPartyBankId != other.otherPartyBankId) return false
|
||||
if (otherPartyAccountId != other.otherPartyAccountId) return false
|
||||
if (bookingText != other.bookingText) return false
|
||||
if (postingText != other.postingText) return false
|
||||
if (valueDate != other.valueDate) return false
|
||||
|
||||
return true
|
||||
|
@ -81,19 +83,19 @@ open class AccountTransaction(
|
|||
|
||||
override fun hashCode(): Int {
|
||||
var result = amount.hashCode()
|
||||
result = 31 * result + unparsedReference.hashCode()
|
||||
result = 31 * result + reference.hashCode()
|
||||
result = 31 * result + bookingDate.hashCode()
|
||||
result = 31 * result + (otherPartyName?.hashCode() ?: 0)
|
||||
result = 31 * result + (otherPartyBankCode?.hashCode() ?: 0)
|
||||
result = 31 * result + (otherPartyAccountId?.hashCode() ?: 0)
|
||||
result = 31 * result + (bookingText?.hashCode() ?: 0)
|
||||
result = 31 * result + otherPartyName.hashCode()
|
||||
result = 31 * result + otherPartyBankId.hashCode()
|
||||
result = 31 * result + otherPartyAccountId.hashCode()
|
||||
result = 31 * result + postingText.hashCode()
|
||||
result = 31 * result + valueDate.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return "$valueDate $amount $otherPartyName: $unparsedReference"
|
||||
return "$valueDate $amount $otherPartyName: $reference"
|
||||
}
|
||||
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
package net.dankito.banking.client.model
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.codinux.banking.fints.model.Currency
|
||||
import net.codinux.banking.fints.model.Money
|
||||
import net.codinux.banking.fints.transactions.swift.model.StatementOfHoldings
|
||||
|
||||
|
||||
@Serializable
|
||||
|
@ -17,7 +19,7 @@ open class BankAccount(
|
|||
open val currency: String = Currency.DefaultCurrencyCode, // TODO: may parse to a value object
|
||||
open val accountLimit: String? = null,
|
||||
|
||||
open val countDaysForWhichTransactionsAreKept: Int? = null,
|
||||
open val serverTransactionsRetentionDays: Int? = null,
|
||||
open val isAccountTypeSupportedByApplication: Boolean = false,
|
||||
// TODO: create an enum AccountCapabilities [ RetrieveBalance, RetrieveTransactions, TransferMoney / MoneyTransfer(?), InstantPayment ]
|
||||
open val supportsRetrievingTransactions: Boolean = false,
|
||||
|
@ -35,10 +37,16 @@ open class BankAccount(
|
|||
|
||||
open var retrievedTransactionsFrom: LocalDate? = null
|
||||
|
||||
open var retrievedTransactionsTo: LocalDate? = null
|
||||
/**
|
||||
* Gibt wider, wann zuletzt aktuelle Kontoumsätze, d.h. [net.dankito.banking.client.model.parameter.GetAccountDataParameter.retrieveTransactionsTo]
|
||||
* war nicht gesetzt, oder aktuelle [StatementOfHoldings] empfangen wurden.
|
||||
*/
|
||||
open var lastAccountUpdateTime: Instant? = null
|
||||
|
||||
open var bookedTransactions: List<AccountTransaction> = listOf()
|
||||
|
||||
open var statementOfHoldings: List<StatementOfHoldings> = emptyList()
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return "$productName ($identifier)"
|
||||
|
|
|
@ -2,6 +2,7 @@ package net.dankito.banking.client.model.parameter
|
|||
|
||||
import net.codinux.banking.fints.model.BankData
|
||||
import net.codinux.banking.fints.model.TanMethodType
|
||||
import net.codinux.banking.fints.serialization.FinTsModelSerializer
|
||||
import net.dankito.banking.client.model.CustomerCredentials
|
||||
|
||||
|
||||
|
@ -12,7 +13,15 @@ open class FinTsClientParameter(
|
|||
password: String,
|
||||
|
||||
open val preferredTanMethods: List<TanMethodType>? = null,
|
||||
open val tanMethodsNotSupportedByApplication: List<TanMethodType>? = null,
|
||||
open val preferredTanMedium: String? = null, // the ID of the medium
|
||||
open val abortIfTanIsRequired: Boolean = false,
|
||||
open val finTsModel: BankData? = null
|
||||
) : CustomerCredentials(bankCode, loginName, password)
|
||||
open val finTsModel: BankData? = null,
|
||||
open val serializedFinTsModel: String? = null
|
||||
) : CustomerCredentials(bankCode, loginName, password) {
|
||||
|
||||
open val finTsModelOrDeserialized: BankData? by lazy {
|
||||
finTsModel ?: serializedFinTsModel?.let { FinTsModelSerializer.deserializeFromJson(it) }
|
||||
}
|
||||
|
||||
}
|
|
@ -21,10 +21,13 @@ open class GetAccountDataParameter(
|
|||
open val retrieveTransactionsTo: LocalDate? = null,
|
||||
|
||||
preferredTanMethods: List<TanMethodType>? = null,
|
||||
tanMethodsNotSupportedByApplication: List<TanMethodType>? = null,
|
||||
preferredTanMedium: String? = null,
|
||||
abortIfTanIsRequired: Boolean = false,
|
||||
finTsModel: BankData? = null
|
||||
) : FinTsClientParameter(bankCode, loginName, password, preferredTanMethods, preferredTanMedium, abortIfTanIsRequired, finTsModel) {
|
||||
finTsModel: BankData? = null,
|
||||
serializedFinTsModel: String? = null,
|
||||
open val defaultBankValues: BankData? = null
|
||||
) : FinTsClientParameter(bankCode, loginName, password, preferredTanMethods, tanMethodsNotSupportedByApplication, preferredTanMedium, abortIfTanIsRequired, finTsModel, serializedFinTsModel) {
|
||||
|
||||
open val retrieveOnlyAccountInfo: Boolean
|
||||
get() = retrieveBalance == false && retrieveTransactions == RetrieveTransactions.No
|
||||
|
|
|
@ -34,10 +34,12 @@ open class TransferMoneyParameter(
|
|||
open val instantPayment: Boolean = false,
|
||||
|
||||
preferredTanMethods: List<TanMethodType>? = null,
|
||||
tanMethodsNotSupportedByApplication: List<TanMethodType>? = null,
|
||||
preferredTanMedium: String? = null,
|
||||
abortIfTanIsRequired: Boolean = false,
|
||||
finTsModel: BankData? = null,
|
||||
serializedFinTsModel: String? = null,
|
||||
|
||||
open val selectAccountToUseForTransfer: ((List<AccountData>) -> AccountData?)? = null // TODO: use BankAccount instead of AccountData
|
||||
|
||||
) : FinTsClientParameter(bankCode, loginName, password, preferredTanMethods, preferredTanMedium, abortIfTanIsRequired, finTsModel)
|
||||
) : FinTsClientParameter(bankCode, loginName, password, preferredTanMethods, tanMethodsNotSupportedByApplication, preferredTanMedium, abortIfTanIsRequired, finTsModel, serializedFinTsModel)
|
|
@ -2,13 +2,14 @@ package net.dankito.banking.client.model.response
|
|||
|
||||
import net.codinux.banking.fints.model.BankData
|
||||
import net.codinux.banking.fints.model.MessageLogEntry
|
||||
import net.codinux.banking.fints.serialization.FinTsModelSerializer
|
||||
|
||||
|
||||
// TODO: rename to BankingClientResponse?
|
||||
open class FinTsClientResponse(
|
||||
open val error: ErrorCode?,
|
||||
open val errorMessage: String?,
|
||||
open val messageLogWithoutSensitiveData: List<MessageLogEntry>,
|
||||
open val messageLog: List<MessageLogEntry>,
|
||||
open val finTsModel: BankData? = null
|
||||
) {
|
||||
|
||||
|
@ -21,4 +22,7 @@ open class FinTsClientResponse(
|
|||
open val errorCodeAndMessage: String
|
||||
get() = "$error${errorMessage?.let { " $it" }}"
|
||||
|
||||
// save some CPU cycles, only serialize finTsModel if required
|
||||
open val serializedFinTsModel: String? by lazy { finTsModel?.let { FinTsModelSerializer.serializeToJson(it) } }
|
||||
|
||||
}
|
|
@ -9,9 +9,9 @@ open class GetAccountDataResponse(
|
|||
error: ErrorCode?,
|
||||
errorMessage: String?,
|
||||
open val customerAccount: CustomerAccount?,
|
||||
messageLogWithoutSensitiveData: List<MessageLogEntry>,
|
||||
messageLog: List<MessageLogEntry>,
|
||||
finTsModel: BankData? = null
|
||||
) : FinTsClientResponse(error, errorMessage, messageLogWithoutSensitiveData, finTsModel) {
|
||||
) : FinTsClientResponse(error, errorMessage, messageLog, finTsModel) {
|
||||
|
||||
internal constructor() : this(null, null, null, listOf()) // for object deserializers
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue