diff --git a/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/Mt940Parser.kt b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/Mt940Parser.kt index 83c963f6..f346a77b 100644 --- a/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/Mt940Parser.kt +++ b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/Mt940Parser.kt @@ -1,92 +1,21 @@ package net.codinux.banking.fints.transactions.mt940 -import kotlinx.datetime.LocalDate -import kotlinx.datetime.Month -import net.codinux.log.logger -import net.codinux.banking.fints.extensions.todayAtEuropeBerlin import net.codinux.banking.fints.log.IMessageLogAppender -import net.codinux.banking.fints.model.Amount import net.codinux.banking.fints.transactions.mt940.model.* -import net.codinux.banking.fints.mapper.DateFormatter - -/* -4.1. SWIFT Supported Characters - -a until z -A until Z -0 until 9 -/ ‐ ? : ( ) . , ' + { } -CR LF Space -Although part of the character set, the curly brackets are permitted as delimiters and cannot be used within the text of -user‐to‐user messages. -Character ”‐” is not permitted as the first character of the line. -None of lines include only Space. - */ open class Mt940Parser( override var logAppender: IMessageLogAppender? = null -) : IMt940Parser { - - companion object { - val AccountStatementsSeparatorRegex = Regex("^\\s*-\\s*\$", RegexOption.MULTILINE) // a line only with '-' and may other white space characters - - // (?(logAppender), IMt940Parser { /** * Parses a whole MT 940 statements string, that is one that ends with a "-" line. */ - override fun parseMt940String(mt940String: String): List { - return parseMt940Chunk(mt940String).first - } + override fun parseMt940String(mt940String: String): List = + 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. @@ -95,426 +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, String?> { - try { - val singleAccountStatementsStrings = splitIntoSingleAccountStatements(mt940Chunk).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$mt940Chunk", e) - } - - return Pair(listOf(), "") - } + override fun parseMt940Chunk(mt940Chunk: String): Pair, String?> = + super.parseMt94xChunk(mt940Chunk) - protected open fun splitIntoSingleAccountStatements(mt940String: String): List { - 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> { - val result = mutableListOf>() - 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>): 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, + fieldsByCode: List> + ): AccountStatement { val openingBalancePair = fieldsByCode.first { it.first.startsWith(OpeningBalanceCode) } val closingBalancePair = fieldsByCode.first { it.first.startsWith(ClosingBalanceCode) } return AccountStatement( - getFieldValue(fieldsByCode, OrderReferenceNumberCode), - getOptionalFieldValue(fieldsByCode, ReferenceNumberCode), - 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>, code: String): String { - return fieldsByCode.first { it.first == code }.second - } - - protected open fun getOptionalFieldValue(fieldsByCode: List>, 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>): List { - val transactions = mutableListOf() - - 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 = 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 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() - 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 { - 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): 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 { - var previousMatchType = "" - var previousMatchEnd = 0 - - val referenceParts = mutableMapOf() - - 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 - } - } - - - 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() - 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 - } - - // 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 } - } - } - } \ No newline at end of file diff --git a/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/Mt94xParserBase.kt b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/Mt94xParserBase.kt new file mode 100644 index 00000000..53d9adbc --- /dev/null +++ b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/Mt94xParserBase.kt @@ -0,0 +1,527 @@ +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.log.IMessageLogAppender +import net.codinux.banking.fints.model.Amount +import net.codinux.banking.fints.transactions.mt940.model.* +import net.codinux.banking.fints.mapper.DateFormatter + + +/* +4.1. SWIFT Supported Characters + +a until z +A until Z +0 until 9 +/ ‐ ? : ( ) . , ' + { } +CR LF Space +Although part of the character set, the curly brackets are permitted as delimiters and cannot be used within the text of +user‐to‐user messages. +Character ”‐” is not permitted as the first character of the line. +None of lines include only Space. + */ +abstract class Mt94xParserBase( + open var logAppender: IMessageLogAppender? = null +) { + + companion object { + val AccountStatementsSeparatorRegex = Regex("^\\s*-\\s*\$", RegexOption.MULTILINE) // a line only with '-' and may other white space characters + + // (?, + fieldsByCode: List> + ): T + + + /** + * Parses a whole MT 940 statements string, that is one that ends with a "-" line. + */ + protected open fun parseMt94xString(mt94xString: String): List { + 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, 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 { + 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> { + val result = mutableListOf>() + 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>): 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>, code: String): String { + return fieldsByCode.first { it.first == code }.second + } + + protected open fun getOptionalFieldValue(fieldsByCode: List>, 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>): List { + val transactions = mutableListOf() + + 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 = 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 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() + 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 { + 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): 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 { + var previousMatchType = "" + var previousMatchEnd = 0 + + val referenceParts = mutableMapOf() + + 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 + } + } + + + 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() + 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 + } + + // 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(Mt94xParserBase::class, message, e) + } + ?: run { + log.error(e) { message } + } + } + +} \ No newline at end of file diff --git a/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/model/AccountStatement.kt b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/model/AccountStatement.kt index 5fbe650c..3446dfb4 100644 --- a/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/model/AccountStatement.kt +++ b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/model/AccountStatement.kt @@ -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 orderReferenceNumber: String, + bankCodeBicOrIban: String, + accountIdentifier: 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?, + statementNumber: Int, + sheetNumber: Int?, val openingBalance: Balance, - val transactions: List, + transactions: List, val closingBalance: Balance, @@ -74,18 +32,14 @@ open class AccountStatement( */ 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()}" } } \ No newline at end of file diff --git a/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/model/AccountStatementCommon.kt b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/model/AccountStatementCommon.kt new file mode 100644 index 00000000..af0256ef --- /dev/null +++ b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/mt940/model/AccountStatementCommon.kt @@ -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, + +) { + + // 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" + } + +} \ No newline at end of file