Compare commits

...

90 commits

Author SHA1 Message Date
bf17bde9f5 Bumped version to 1.0.0-Alpha-16-SNAPSHOT 2024-10-16 01:36:35 +02:00
9f3e4eff4d Bumped version to 1.0.0-Alpha-15 2024-10-16 01:33:27 +02:00
f89de94aa7 Little refactoring 2024-10-15 21:55:16 +02:00
684b3fb40e Fixed that if lastCreatedMesssage is a DialogInit message, that we don't sent it again as we just initialized a new dialog with initDialogWithStrongCustomerAuthentication() 2024-10-15 21:53:02 +02:00
df692ea222 Renamed messageLogWithoutSensitiveData to messageLog 2024-10-15 13:30:18 +02:00
636963b3d4 Added and finTsModelOrDeserialized to 2024-10-15 13:29:25 +02:00
4802493886 Saving some CPU cycles, only serializing finTsModel if required 2024-10-15 13:28:35 +02:00
ecf930fcad Fixed that quantity is a floating point number 2024-10-15 09:29:52 +02:00
ce3b1d32d7 Renamed messageLogWithoutSensitiveData to messageLog 2024-10-15 02:35:14 +02:00
c39789dfde Fixed only adding -Xdebug if debugger is attached 2024-10-15 02:28:04 +02:00
ab0b676216 Added messageWithoutSensitiveData as extra field so that user can choose between them 2024-10-15 02:05:32 +02:00
20fe60d9f6 Also pretty printing error messages 2024-10-15 02:00:23 +02:00
529caeaa87 Fixed that imageBytes is now nullable 2024-10-14 22:13:32 +02:00
3d6c68e743 Implemented serializer for BankData 2024-10-14 22:12:36 +02:00
7cdb7247c8 Implemented serializing FinTS data 2024-10-14 22:09:14 +02:00
d7d2702869 Retrieving ChangeTanMediaParameters now from supportedJobs instead of storing it a second time 2024-10-14 20:44:31 +02:00
67b58117e1 Mapping PinInfo 2024-10-14 20:20:51 +02:00
66801a1c7a Implemented parsing HICAZS 2024-10-14 20:20:03 +02:00
2410504ede Setting jobsRequiringTan now directly, ignoring PinInfo 2024-10-14 15:45:28 +02:00
2a3b962af5 Made TanMedium serializable 2024-10-10 02:31:00 +02:00
8346fb5077 Using now nullable hashCode() method 2024-10-09 19:12:24 +02:00
8dc2174081 Added mediumName to hashCode() and equals() 2024-10-09 19:08:43 +02:00
05322aface Added toString() implementation 2024-10-04 18:04:36 +02:00
dcbbe043f0 Renamed dialogType to messageType 2024-10-04 18:04:12 +02:00
65d983a5e7 Added a check to determine HHD version 2024-09-26 14:21:10 +02:00
9aad2a5101 Made parsedDataSet, mimeType and imageBytes nullable, as in case of decoding error they are not set 2024-09-26 14:19:27 +02:00
be3a2df6d9 Bumped version to 1.0.0-Alpha-15-SNAPSHOT 2024-09-19 21:15:43 +02:00
dea8be3bfa Bumped version to 1.0.0-Alpha-14 2024-09-19 21:15:00 +02:00
cba2f25335 Updated klf version 2024-09-19 05:05:01 +02:00
825217ef88 Downgraded Kotlin to version 1.9.x so that more applications can use this library 2024-09-19 05:03:28 +02:00
b3cb76e77d Added option appendFinTsMessagesToLog to easily configure if FinTS messages should be added to log by default 2024-09-18 17:33:12 +02:00
b0c2f38bd6 Bumped version to 1.0.0-Alpha-14-SNAPSHOT 2024-09-17 17:38:36 +02:00
fca1542b5c Bumped version to 1.0.0-Alpha-13 2024-09-17 17:35:09 +02:00
07672d1189 Fixed marking accounts that support CustomerSegmentId.SecuritiesAccountBalance as supported account type 2024-09-17 04:20:58 +02:00
62aa04a667 Updating lastAccountUpdateTime also when have been retrieved 2024-09-11 23:18:27 +02:00
3aa0edfb34 Renamed lastTransactionsRetrievalTime to lastAccountUpdateTime 2024-09-11 23:05:26 +02:00
e4d605531e Added hint that amount strings use ',' as decimal separator 2024-09-11 22:59:51 +02:00
a42de32260 Implemented requesting and parsing securities account balance 2024-09-11 22:48:31 +02:00
95e60b2706 Implemented Mt535Parser 2024-09-11 05:50:31 +02:00
fd9eadf45e Removed fallback of using platform specific DateFormatter 2024-09-11 01:09:50 +02:00
7ddeb88475 Decided against parsing Mt942 creationTime and smallest amount (should rarely be used and may only cause parsing errors) 2024-09-11 00:25:22 +02:00
90a7543641 Implemented Mt942Parser 2024-09-10 23:40:22 +02:00
d1de7f5eb0 Extracted Mt94xParserBase 2024-09-10 21:29:13 +02:00
ef8045fa96 Extracted MtParserTestBase 2024-09-10 20:42:51 +02:00
2031cb9e9f Made remainder nullable to signal there is no remainder 2024-09-10 18:23:05 +02:00
e260eaa535 Added test for remainder 2024-09-10 18:17:26 +02:00
891641fc6f Found rule how four digit year are calculated from two digit years 2024-09-10 18:03:20 +02:00
c158097d3a Added tanMethodsNotSupportedByApplication to filter out TAN methods that client application does not support (e.g. chipTanUsb) 2024-09-10 03:20:50 +02:00
6908f52e48 Using now NonVisualOrImageBased as default to determine user's (suggested) TAN method as it provides a good default for most users 2024-09-10 02:47:23 +02:00
61d8f2c342 Added preferredTanMethods and preferredTanMedium to JobContext 2024-09-10 02:46:35 +02:00
6bf7fdcb44 Implemented passing default bank data to FinTsClient as e.g. bank names returned from bank server are often quite bad, e.g. DB24 for Deutsche Bank 2024-09-09 23:01:06 +02:00
fbafbb62e3 Added option closeDialogs 2024-09-09 17:06:29 +02:00
9372d17313 Avoiding concurrent modification exception 2024-09-09 17:02:47 +02:00
9b1a5fa929 Fixed not continuing to next account if user cancelled process 2024-09-09 17:01:32 +02:00
42bf002626 Added tanExpiredCallback, so that UI can react to when TAN expired 2024-09-09 03:36:23 +02:00
20f06387c5 Added check if tanExpirationTime is exceeded if set 2024-09-09 02:56:34 +02:00
75320da2be Changed type of tanExpirationTime to Instant so that UI can better convert it to user's time zone 2024-09-09 02:55:37 +02:00
be2908517f Fixed that validityDateTimeForChallenge has been renamed to tanExpirationTime 2024-09-09 00:45:30 +02:00
c4f504dd0a Added tanExpirationTime to TanChallenge 2024-09-08 22:40:05 +02:00
0848586894 Added timestamp at which TanChallenge was created 2024-09-08 22:36:39 +02:00
83c2882567 Added isReversal 2024-09-08 22:20:59 +02:00
f069f9155c Adjusted names to fints4k names 2024-09-08 22:19:38 +02:00
bf5ee4890e Renamed otherPartyBankCode to otherPartyBankId 2024-09-08 22:17:16 +02:00
ed4214fd49 Fixed calling the right Instant.now() method 2024-09-08 22:03:37 +02:00
b8fe9e78e1 Renamed transactionsRetentionDays to serverTransactionsRetentionDays 2024-09-08 22:01:28 +02:00
da2bf8d469 Terminate waiting for TAN input after a timeout 2024-09-08 20:38:20 +02:00
113b817627 Extracted Instant.nowExt() 2024-09-08 20:31:12 +02:00
bd18644c0d Calling mayRetrieveAutomaticallyIfUserEnteredDecoupledTan() out of loop. Should make no difference but should sound more logic 2024-09-08 20:22:18 +02:00
b32cf94e25 Using now isEnteringTanDone 2024-09-08 20:20:31 +02:00
8cc2f3bdcd Added timestamp at which TanChallenge was created 2024-09-08 18:31:02 +02:00
59b8213163 Extracted clearUserApprovedDecoupledTanCallbacks() and clearing callbacks also when user did not enter TAN or requested to change TAN method or medium to avoid memory leaks 2024-09-08 18:14:35 +02:00
cb34c86665 Changed order of opening and closing balance 2024-09-05 23:31:02 +02:00
70c1082531 Renamed countDaysForWhichTransactionsAreKept to transactionsRetentionDays 2024-09-05 21:53:00 +02:00
30e9a57b96 Fixed setting either sepaReference - in case of structured information - or unparsedReference - in case of unstructured reference. And that reference may is null 2024-09-05 19:36:03 +02:00
bf76de4f23 Applied adjusted values from MT 940 to AccountTransaction 2024-09-05 19:16:15 +02:00
47e2b851b9 Adjusted names according to English Translation of DFÜ-Abkommen Anlage_3_Datenformate_V3.8.pdf (Appendix_3-Data_Formats_V3-8.pdf) 2024-09-05 18:20:56 +02:00
f90e280b74 Adjusted names according to English Translation of DFÜ-Abkommen Anlage_3_Datenformate_V3.8.pdf (Appendix_3-Data_Formats_V3-8.pdf) 2024-09-05 18:15:42 +02:00
9600e2f11b Converted lastTransactionsRetrievalTime to Instant 2024-09-03 22:06:36 +02:00
b2fb04372f Updating BankAccount.bookedTransactions only if we retrieved transactions and adding it to existing list instead of replacing existing bookedTransactions list 2024-09-03 21:19:24 +02:00
3b05a8b9c8 Renamed lastTransactionRetrievalTime to lastTransactionsRetrievalTime 2024-09-03 21:15:41 +02:00
d689c7663f Fixed updating BankAccount.balance and .retrievedTransactionsFrom 2024-09-03 21:14:52 +02:00
6238b5abb2 Renamed europeBerlin to EuropeBerlin 2024-09-03 20:56:07 +02:00
1f8c1d303e Extracted getRequiredDataToSendUserJobs() 2024-09-03 01:06:27 +02:00
09c2080481 Added lastTransactionRetrievalTime to BankAccount, removed retrievedTransactionsTo for it 2024-09-03 01:05:04 +02:00
e36c27c0e0 Added Decoupled response code of Sparkasse 2024-09-02 19:36:43 +02:00
6865f64880 Added callback to get notified when user approved Decoupled TAN (e.g. to close a dialog) 2024-09-02 19:35:36 +02:00
3f9921a62e Mapped Decoupled TAN methods to their own types 2024-09-02 13:23:34 +02:00
504fbaf13b Implemented Decoupled TAN process (HKTAN 7) 2024-09-02 13:15:36 +02:00
fb70bcd443 Fixed mapping multiple HITANS segments in a message 2024-09-02 03:23:19 +02:00
952fa9c13a Bumped version to 1.0.0-Alpha-13-SNAPSHOT 2024-09-01 19:58:11 +02:00
121 changed files with 4594 additions and 1360 deletions

View file

@ -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")
}
```

View file

@ -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)
}

View file

@ -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
View 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) | payers/debtors reference party (for credit transfer / payees / creditors reference party (for a direct debit) |
| Abweichender Zahlungsempfänger (CT-AT28) / Abweichender Zahlungspflichtiger (DDAT15) | payees/creditors reference party / payers/debtors reference party |
| | |
| Überweisender | Payer, debtor |
| Zahlungsempfänger | Payee, creditor |
| Zahlungseingang | Payment receipt |
| Lastschrift | direct debit |
| | |
| | |
| Primanoten-Nr. | Journal no. |
| | |

View file

@ -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")
}

View file

@ -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 */

View file

@ -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)

View file

@ -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)
}

View file

@ -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.

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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) }

View file

@ -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)

View file

@ -12,7 +12,7 @@ fun LocalDate.Companion.todayAtSystemDefaultTimeZone(): LocalDate {
}
fun LocalDate.Companion.todayAtEuropeBerlin(): LocalDate {
return nowAt(TimeZone.europeBerlin)
return nowAt(TimeZone.EuropeBerlin)
}
@JsName("nowAtForDate")

View file

@ -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)
}

View file

@ -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()
}

View file

@ -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")

View file

@ -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)
}

View file

@ -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"
}

View file

@ -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)

View file

@ -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?
}

View file

@ -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 }
}

View file

@ -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? {

View file

@ -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>" } }}"
}

View file

@ -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 ?: ""
}
}

View file

@ -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 ?: "-"})"
}
}

View file

@ -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
}

View file

@ -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")
}

View file

@ -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")
}

View file

@ -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

View file

@ -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
))

View file

@ -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)
)) {

View file

@ -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>

View file

@ -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"
}
}

View file

@ -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
) {

View file

@ -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

View file

@ -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 =

View file

@ -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)"
}
}

View file

@ -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"
}

View file

@ -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"

View file

@ -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"

View file

@ -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)
}

View file

@ -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"
}
}

View file

@ -15,6 +15,8 @@ enum class MessageType {
SynchronizeCustomerSystemId,
CheckDecoupledTanStatus,
Tan,
GetBalance,
@ -23,6 +25,8 @@ enum class MessageType {
GetCreditCardTransactions,
GetSecuritiesAccountBalance,
TransferMoney
}

View file

@ -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?
)

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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
) {

View file

@ -19,6 +19,10 @@ enum class TanMethodType {
AppTan,
DecoupledTan,
DecoupledPushTan,
photoTan,
QrCode

View file

@ -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
)
}

View file

@ -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")
}

View file

@ -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)
}

View file

@ -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
) {

View file

@ -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())
}

View file

@ -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)

View file

@ -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
) {

View file

@ -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

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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"
}

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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()

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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"
}
}

View file

@ -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)
}
}

View file

@ -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?>
}

View file

@ -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,
)
}

View file

@ -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?>
}

View file

@ -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
usertouser 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 }
}
}
}

View file

@ -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)
}
}

View file

@ -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
usertouser 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
}
}

View file

@ -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()}"
}
}

View file

@ -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"
}
}

View file

@ -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"
}

View file

@ -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
*/

View file

@ -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"
}
}

View file

@ -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()}"
}
}

View file

@ -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)"
}

View file

@ -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"
}
}

View file

@ -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
) {

View file

@ -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
) {

View file

@ -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)
}
}

View file

@ -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 }
}
}
}

View file

@ -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")
}

View file

@ -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
)

View file

@ -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}" }}"
}

View file

@ -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}" }
}
}

View file

@ -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()
}
}

View file

@ -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"
}
}

View file

@ -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)"

View file

@ -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) }
}
}

View file

@ -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

View file

@ -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)

View file

@ -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) } }
}

View file

@ -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