diff --git a/fints4javaLib/src/main/kotlin/net/dankito/fints/model/AccountTransaction.kt b/fints4javaLib/src/main/kotlin/net/dankito/fints/model/AccountTransaction.kt new file mode 100644 index 00000000..2e9b742f --- /dev/null +++ b/fints4javaLib/src/main/kotlin/net/dankito/fints/model/AccountTransaction.kt @@ -0,0 +1,14 @@ +package net.dankito.fints.model + + +open class AccountTransaction( + var referenceNumber: String = "", + var bezugsReferenceNumber: String? = null, + var accountIdentification: String = "", + var statementNumber: String = "", + var openingBalance: String = "", + var statement: String = "", + var accountOwner: String = "", + var closingBalance: String = "" +) { +} \ No newline at end of file diff --git a/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/IAccountTransactionsParser.kt b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/IAccountTransactionsParser.kt new file mode 100644 index 00000000..99b8fcfa --- /dev/null +++ b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/IAccountTransactionsParser.kt @@ -0,0 +1,10 @@ +package net.dankito.fints.transactions.mt940 + +import net.dankito.fints.transactions.mt940.model.AccountStatement + + +interface IAccountTransactionsParser { + + fun parseTransactions(transactionsString: String): List + +} \ No newline at end of file diff --git a/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/Mt940AccountTransactionsParser.kt b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/Mt940AccountTransactionsParser.kt new file mode 100644 index 00000000..4f2b2edd --- /dev/null +++ b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/Mt940AccountTransactionsParser.kt @@ -0,0 +1,398 @@ +package net.dankito.fints.transactions.mt940 + +import net.dankito.fints.transactions.mt940.model.* +import org.slf4j.LoggerFactory +import java.math.BigDecimal +import java.text.SimpleDateFormat +import java.util.* +import java.util.regex.Pattern + + +/* +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 Mt940AccountTransactionsParser : IAccountTransactionsParser { + + companion object { + val AccountStatementsSeparatorPattern = Pattern.compile("^\\s*-\\s*\$") // a line only with '-' and may other white space characters + + val AccountStatementFieldSeparatorPattern = Pattern.compile(":\\d\\d\\w?:") + + + val TransactionReferenceNumberCode = "20" + + val ReferenceReferenceNumberCode = "21" + + val AccountIdentificationCode = "25" + + val StatementNumberCode = "28C" + + val OpeningBalanceCode = "60" + + val TransactionTurnoverCode = "61" + + val TransactionDetailsCode = "86" + + val ClosingBalanceCode = "62" + + + val DateFormat = SimpleDateFormat("yyMMdd") + + val CreditDebitCancellationPattern = Pattern.compile("C|D|RC|RD") + + val AmountPattern = Pattern.compile("\\d+,\\d*") + + val UsageTypePattern = Pattern.compile("\\w{4}\\+") + + + private val log = LoggerFactory.getLogger(Mt940AccountTransactionsParser::class.java) + } + + + override fun parseTransactions(transactionsString: String): List { + try { + val singleAccountStatementsStrings = splitIntoSingleAccountStatements(transactionsString) + + return singleAccountStatementsStrings.mapNotNull { parseAccountStatement(it) } + } catch (e: Exception) { + log.error("Could not parse account transactions from string:\n$transactionsString", e) + } + + return listOf() + } + + + protected open fun splitIntoSingleAccountStatements(transactionsString: String): List { + val accountStatements = mutableListOf() + + val lines = transactionsString.split("\n") + var lastMatchedLine = 0 + lines.forEachIndexed { index, line -> + if (line == "-") { + val test = AccountStatementsSeparatorPattern.matcher(line).matches() // TODO + + accountStatements.add(lines.subList(lastMatchedLine, index).joinToString("\n")) + + lastMatchedLine = index + 1 + } + } + + return accountStatements + } + + + protected open fun parseAccountStatement(accountStatementString: String): AccountStatement? { + try { + val fieldsByCode = splitIntoFields(accountStatementString) + + return parseAccountStatement(fieldsByCode) + } catch (e: Exception) { + log.error("Could not parse account statement:\n$accountStatementString", e) + } + + return null + } + + protected open fun splitIntoFields(accountStatementString: String): List> { + val matcher = AccountStatementFieldSeparatorPattern.matcher(accountStatementString) + + val result = mutableListOf>() + var lastMatchEnd = 0 + var lastMatchedCode = "" + + while (matcher.find()) { + if (lastMatchEnd > 0) { + val previousStatement = accountStatementString.substring(lastMatchEnd, matcher.start()).replace("\n", "") + result.add(Pair(lastMatchedCode, previousStatement)) + } + + lastMatchedCode = matcher.group().replace(":", "") + lastMatchEnd = matcher.end() + } + + if (lastMatchEnd > 0) { + val previousStatement = accountStatementString.substring(lastMatchEnd, accountStatementString.length).replace("\n", "") + result.add(Pair(lastMatchedCode, previousStatement)) + } + + return result + } + + protected open fun parseAccountStatement(fieldsByCode: List>): AccountStatement? { + val statementAndMaySequenceNumber = getFieldValue(fieldsByCode, StatementNumberCode) + val accountIdentification = getFieldValue(fieldsByCode, AccountIdentificationCode) + val openingBalancePair = fieldsByCode.first { it.first.startsWith(OpeningBalanceCode) } + val closingBalancePair = fieldsByCode.first { it.first.startsWith(ClosingBalanceCode) } + + return AccountStatement( + getFieldValue(fieldsByCode, TransactionReferenceNumberCode), + getOptionalFieldValue(fieldsByCode, ReferenceReferenceNumberCode), + parseBankCodeSwiftCodeOrIban(accountIdentification), + parseAccountNumber(accountIdentification), + parseStatementNumber(statementAndMaySequenceNumber), + parseSheetNumber(statementAndMaySequenceNumber), + parseBalance(openingBalancePair.first, openingBalancePair.second), + parseAccountStatementTransactions(fieldsByCode), + 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 parseBankCodeSwiftCodeOrIban(accountIdentification: String): String { + val parts = accountIdentification.split('/') + + return parts[0] + } + + protected open fun parseAccountNumber(accountIdentification: String): String? { + val parts = accountIdentification.split('/') + + if (parts.size > 0) { + return parts[1] + } + + return null + } + + protected open fun parseStatementNumber(statementAndMaySheetNumber: String): Int { + val parts = statementAndMaySheetNumber.split('/') + + // val isSupported = statementNumber != "00000" + return parts[0].toInt() + } + + protected open fun parseSheetNumber(statementAndMaySheetNumber: String): Int? { + val parts = statementAndMaySheetNumber.split('/') + + if (parts.size > 0) { + return parts[1].toInt() + } + + return null + } + + 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>): List { + val transactions = mutableListOf() + + fieldsByCode.forEachIndexed { index, pair -> + if (pair.first == TransactionTurnoverCode) { + val turnover = parseTurnover(pair.second) + + val nextPair = if (index < fieldsByCode.size - 1) fieldsByCode.get(index + 1) else null + val details = if (nextPair?.first == TransactionDetailsCode) parseNullableTransactionDetails(nextPair?.second) else null + + transactions.add(Transaction(turnover, details)) + } + } + + return transactions + } + + protected open fun parseTurnover(fieldValue: String): Turnover { + val valueDateString = fieldValue.substring(0, 6) + val valueDate = parseDate(valueDateString) + + val creditMarkMatcher = CreditDebitCancellationPattern.matcher(fieldValue) + creditMarkMatcher.find() + val isDebit = creditMarkMatcher.group().endsWith('D') + val isCancellation = creditMarkMatcher.group().startsWith('R') + + val bookingDateString = if (creditMarkMatcher.start() > 6) fieldValue.substring(6, 10) else null + val bookingDate = bookingDateString?.let { // bookingDateString has format MMdd -> add year from valueDateString + parseDate(valueDateString.substring(0, 2) + bookingDateString) + } + + val amountMatcher = AmountPattern.matcher(fieldValue) + amountMatcher.find() + val amountString = amountMatcher.group() + val amount = parseAmount(amountString) + + val amountEndIndex = amountMatcher.end() + + /** + * 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 + + var customerReference = fieldValue.substring(bookingKeyStart + 3) + var bankReference: String? = null + if (customerReference.contains("//")) { + val indexOfDoubleSlash = customerReference.indexOf("//") + + bankReference = customerReference.substring(indexOfDoubleSlash + 2) + customerReference = customerReference.substring(0, indexOfDoubleSlash) + } + + return Turnover(!!!isDebit, isCancellation, valueDate, bookingDate, null, amount, bookingKey, + customerReference, bankReference) + } + + protected open fun parseNullableTransactionDetails(fieldValue: String): TransactionDetails? { + try { + val details = parseTransactionDetails(fieldValue) + + mapUsage(details) + + return details + } catch (e: Exception) { + log.error("Could not parse transaction details from field value '$fieldValue'", e) + } + + return null + } + + private fun parseTransactionDetails(fieldValue: String): TransactionDetails { + val usage = StringBuilder("") + val otherPartyName = StringBuilder("") + var otherPartyBankCode: String? = null + var otherPartyAccountId: String? = null + var bookingText: String? = null + var primaNotaNumber: String? = null + var textKeySupplement: String? = null + + fieldValue.replace("\r", "").replace("\n", "").substring(3).split('?').forEach { subField -> + if (subField.isNotEmpty()) { + val fieldCode = subField.substring(0, 2).toInt() + val fieldValue = subField.substring(2) + + when (fieldCode) { + 0 -> bookingText = fieldValue + 10 -> primaNotaNumber = fieldValue + in 20..29 -> usage.append(fieldValue) + 30 -> otherPartyBankCode = fieldValue + 31 -> otherPartyAccountId = fieldValue + 32, 33 -> otherPartyName.append(fieldValue) + 34 -> textKeySupplement = fieldValue + in 60..63 -> usage.append(fieldValue) + } + } + } + + val details = TransactionDetails( + usage.toString(), otherPartyName.toString(), otherPartyBankCode, otherPartyAccountId, + bookingText, primaNotaNumber, textKeySupplement + ) + return details + } + + /** + * 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 mapUsage(details: TransactionDetails) { + val usageParts = getUsageParts(details) + + usageParts.forEach { pair -> + setUsageLineValue(details, pair.first, pair.second) + } + } + + private fun getUsageParts(details: TransactionDetails): MutableList> { + val usage = details.usage + var previousMatchType = "" + var previousMatchEnd = 0 + + val usageParts = mutableListOf>() + val matcher = UsageTypePattern.matcher(details.usage) + + while (matcher.find()) { + if (previousMatchEnd > 0) { + val typeValue = usage.substring(previousMatchEnd, matcher.start()) + + usageParts.add(Pair(previousMatchType, typeValue)) + } + + previousMatchType = usage.substring(matcher.start(), matcher.end()) + previousMatchEnd = matcher.end() + } + + if (previousMatchEnd > 0) { + val typeValue = usage.substring(previousMatchEnd, usage.length) + + usageParts.add(Pair(previousMatchType, typeValue)) + } + + return usageParts + } + + protected open fun setUsageLineValue(details: TransactionDetails, usageType: String, typeValue: String) { + when (usageType) { + "EREF+" -> details.endToEndReference = typeValue + "KREF+" -> details.customerReference = typeValue + "MREF+" -> details.mandateReference = typeValue + "CRED+" -> details.creditorIdentifier = typeValue + "DEBT+" -> details.originatorsIdentificationCode = typeValue + "COAM+" -> details.compensationAmount = typeValue + "OAMT+" -> details.originalAmount = typeValue + "SVWZ+" -> details.sepaUsage = typeValue + "ABWA+" -> details.deviantOriginator = typeValue + "ABWE+" -> details.deviantRecipient = typeValue + else -> details.usageWithNoSpecialType = typeValue + } + } + + + protected open fun parseDate(dateString: String): Date { + return DateFormat.parse(dateString) + } + + protected open fun parseAmount(amountString: String): BigDecimal { + return amountString.replace(',', '.').toBigDecimal() + } + +} \ No newline at end of file diff --git a/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/AccountStatement.kt b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/AccountStatement.kt new file mode 100644 index 00000000..c09e85fd --- /dev/null +++ b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/AccountStatement.kt @@ -0,0 +1,91 @@ +package net.dankito.fints.transactions.mt940.model + + +open class AccountStatement( + + /** + * 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, + + /** + * Bezugsreferenz oder „NONREF“. + * + * Die Referenz darf nicht mit "/" starten oder enden; darf nicht "//" enthalten + * + * Max length = 16 + */ + val referenceReferenceNumber: 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 accountNumber: 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?, + + val openingBalance: Balance, + + val transactions: List, + + val closingBalance: Balance, + + val currentValueBalance: Balance? = null, + + val futureValueBalance: Balance? = null, + + /** + * Mehrzweckfeld + * + * Es dürfen nur unstrukturierte Informationen eingestellt werden. Es dürfen keine Informationen, + * die auf einzelne Umsätze bezogen sind, eingestellt werden. + * + * Die Zeilen werden mit getrennt. + * + * Max length = 65 + */ + val multipurposeField: String? = null + +) { + + // 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() + } + +} \ No newline at end of file diff --git a/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/Balance.kt b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/Balance.kt new file mode 100644 index 00000000..cd4ce40b --- /dev/null +++ b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/Balance.kt @@ -0,0 +1,52 @@ +package net.dankito.fints.transactions.mt940.model + +import java.math.BigDecimal +import java.text.DateFormat +import java.util.* + + +open class Balance( + + val isIntermediate: Boolean, + + /** + * Soll/Haben-Kennung + * + * “C” = Credit (Habensaldo) + * ”D” = Debit (Sollsaldo) + * + * Length = 1 + */ + val isCredit: Boolean, + + /** + * JJMMTT = Buchungsdatum des Saldos oder '0' beim ersten Auszug + * + * Max length = 6 + */ + val bookingDate: Date, + + /** + * Währungsschlüssel gem. ISO 4217 + * + * Length = 3 + */ + val currency: String, + + /** + * Betrag + * + * Max Length = 15 + */ + val amount: BigDecimal + +) { + + internal constructor() : this(false, false, Date(), "", 0.toBigDecimal()) // for object deserializers + + + override fun toString(): String { + return "${DateFormat.getDateInstance(DateFormat.MEDIUM).format(bookingDate)} ${if (isCredit) "+" else "-"}$amount $currency" + } + +} \ No newline at end of file diff --git a/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/Transaction.kt b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/Transaction.kt new file mode 100644 index 00000000..6ee4c3d9 --- /dev/null +++ b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/Transaction.kt @@ -0,0 +1,15 @@ +package net.dankito.fints.transactions.mt940.model + + +open class Transaction( + + val turnover: Turnover, + val details: TransactionDetails? = null + +) { + + override fun toString(): String { + return "$turnover ($details)" + } + +} \ No newline at end of file diff --git a/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/TransactionDetails.kt b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/TransactionDetails.kt new file mode 100644 index 00000000..ab23376d --- /dev/null +++ b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/TransactionDetails.kt @@ -0,0 +1,41 @@ +package net.dankito.fints.transactions.mt940.model + + +open class TransactionDetails( + val usage: 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 sepaUsage: String? = null + + var deviantOriginator: String? = null + + var deviantRecipient: String? = null + + var usageWithNoSpecialType: String? = null + + + override fun toString(): String { + return "$otherPartyName $usage" + } + +} \ No newline at end of file diff --git a/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/Turnover.kt b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/Turnover.kt new file mode 100644 index 00000000..4a2357e2 --- /dev/null +++ b/fints4javaLib/src/main/kotlin/net/dankito/fints/transactions/mt940/model/Turnover.kt @@ -0,0 +1,71 @@ +package net.dankito.fints.transactions.mt940.model + +import java.math.BigDecimal +import java.text.DateFormat +import java.util.* + + +open class Turnover( + + /** + * Soll/Haben-Kennung + * + * “C” = Credit (Habensaldo) + * ”D” = Debit (Sollsaldo) + * „RC“ = Storno Haben + * „RD“ = Storno Soll + * + * Max length = 2 + */ + val isCredit: Boolean, + + val isCancellation: Boolean, + + /** + * Valuta (JJMMTT) + * + * Length = 6 + */ + val valueDate: Date, + + /** + * MMTT + * + * Length = 4 + */ + val bookingDate: Date?, + + /** + * dritte Stelle der Währungsbezeichnung, falls sie zur Unterscheidung notwendig ist + * + * Length = 1 + */ + val currencyType: String?, + + /** + * Codes see p. 177 bottom - 179 + * + * After constant „N“ + * + * Max length = 15 + */ + val amount: BigDecimal, + + /** + * in Kontowährung + * + * Length = 3 + */ + val bookingKey: String, + + var customerReference: String, + + var bankReference: String? + +) { + + override fun toString(): String { + return "${DateFormat.getDateInstance(DateFormat.MEDIUM).format(valueDate)} ${if (isCredit) "+" else "-"}$amount" + } + +} \ No newline at end of file diff --git a/fints4javaLib/src/test/kotlin/net/dankito/fints/transactions/Mt940AccountTransactionsParserTest.kt b/fints4javaLib/src/test/kotlin/net/dankito/fints/transactions/Mt940AccountTransactionsParserTest.kt new file mode 100644 index 00000000..a9af46e1 --- /dev/null +++ b/fints4javaLib/src/test/kotlin/net/dankito/fints/transactions/Mt940AccountTransactionsParserTest.kt @@ -0,0 +1,192 @@ +package net.dankito.fints.transactions + +import net.dankito.fints.transactions.mt940.Mt940AccountTransactionsParser +import net.dankito.fints.transactions.mt940.model.Balance +import net.dankito.fints.transactions.mt940.model.TransactionDetails +import net.dankito.fints.transactions.mt940.model.Turnover +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.math.BigDecimal +import java.text.SimpleDateFormat +import java.util.* + + +class Mt940AccountTransactionsParserTest { + + companion object { + const val TestFilesFolderName = "test_files/" + + const val TransactionsMt940FileRelativePath = TestFilesFolderName + "TransactionsMt940.txt" + + const val BankCode = "12345678" + + const val CustomerId = "0987654321" + + const val Currency = "EUR" + + val AccountStatement1PreviousStatementBookingDate = Date(88, 2, 26) + val AccountStatement1BookingDate = Date(88, 2, 27) + + val AccountStatement1OpeningBalanceAmount = 12345.67.toBigDecimal() + + val AccountStatement1Transaction1Amount = 1234.56.toBigDecimal() + val AccountStatement1Transaction1OtherPartyName = "Sender1" + val AccountStatement1Transaction1OtherPartyBankCode = "AAAADE12" + val AccountStatement1Transaction1OtherPartyAccountId = "DE99876543210987654321" + + val AccountStatement1Transaction2Amount = 432.10.toBigDecimal() + val AccountStatement1Transaction2OtherPartyName = "Receiver2" + val AccountStatement1Transaction2OtherPartyBankCode = "BBBBDE56" + val AccountStatement1Transaction2OtherPartyAccountId = "DE77987654321234567890" + + val AccountStatement1ClosingBalanceAmount = AccountStatement1OpeningBalanceAmount + AccountStatement1Transaction1Amount + val AccountStatement1With2TransactionsClosingBalanceAmount = AccountStatement1OpeningBalanceAmount + AccountStatement1Transaction1Amount - AccountStatement1Transaction2Amount + } + + private val underTest = Mt940AccountTransactionsParser() + + + @Test + fun accountStatementWithSingleTransaction() { + + // when + val result = underTest.parseTransactions(AccountStatementWithSingleTransaction) + + + // then + assertThat(result).hasSize(1) + + val statement = result.first() + + assertThat(statement.bankCodeBicOrIban).isEqualTo(BankCode) + assertThat(statement.accountNumber).isEqualTo(CustomerId) + assertBalance(statement.openingBalance, true, AccountStatement1PreviousStatementBookingDate, AccountStatement1OpeningBalanceAmount) + assertBalance(statement.closingBalance, true, AccountStatement1BookingDate, AccountStatement1ClosingBalanceAmount) + + assertThat(statement.transactions).hasSize(1) + + val transaction = statement.transactions.first() + assertTurnover(transaction.turnover, AccountStatement1BookingDate, AccountStatement1Transaction1Amount) + assertTransactionDetails(transaction.details, AccountStatement1Transaction1OtherPartyName, + AccountStatement1Transaction1OtherPartyBankCode, AccountStatement1Transaction1OtherPartyAccountId) + } + + @Test + fun accountStatementWithTwoTransactions() { + + // when + val result = underTest.parseTransactions(AccountStatementWithTwoTransactions) + + + // then + assertThat(result).hasSize(1) + + val statement = result.first() + + assertThat(statement.bankCodeBicOrIban).isEqualTo(BankCode) + assertThat(statement.accountNumber).isEqualTo(CustomerId) + assertBalance(statement.openingBalance, true, AccountStatement1PreviousStatementBookingDate, AccountStatement1OpeningBalanceAmount) + assertBalance(statement.closingBalance, true, AccountStatement1BookingDate, AccountStatement1With2TransactionsClosingBalanceAmount) + + assertThat(statement.transactions).hasSize(2) + + val firstTransaction = statement.transactions.first() + assertTurnover(firstTransaction.turnover, AccountStatement1BookingDate, AccountStatement1Transaction1Amount) + assertTransactionDetails(firstTransaction.details, AccountStatement1Transaction1OtherPartyName, + AccountStatement1Transaction1OtherPartyBankCode, AccountStatement1Transaction1OtherPartyAccountId) + + val secondTransaction = statement.transactions[1] + assertTurnover(secondTransaction.turnover, AccountStatement1BookingDate, AccountStatement1Transaction2Amount, false) + assertTransactionDetails(secondTransaction.details, AccountStatement1Transaction2OtherPartyName, + AccountStatement1Transaction2OtherPartyBankCode, AccountStatement1Transaction2OtherPartyAccountId) + } + + @Test + fun parseTransactions() { + + // given + val fileStream = Mt940AccountTransactionsParserTest::class.java.classLoader.getResourceAsStream(TransactionsMt940FileRelativePath) + val transactionsString = fileStream.reader().readText() + + + // when + val result = underTest.parseTransactions(transactionsString) + + + // then + assertThat(result).hasSize(32) + } + + + private fun assertBalance(balance: Balance, isCredit: Boolean, bookingDate: Date, amount: BigDecimal) { + assertThat(balance.isCredit).isEqualTo(isCredit) + assertThat(balance.bookingDate).isEqualTo(bookingDate) + assertThat(balance.amount).isEqualTo(amount) + assertThat(balance.currency).isEqualTo(Currency) + } + + private fun assertTurnover(turnover: Turnover, valueDate: Date, amount: BigDecimal, isCredit: Boolean = true, + bookingDate: Date? = valueDate) { + + assertThat(turnover.isCredit).isEqualTo(isCredit) + assertThat(turnover.isCancellation).isFalse() + assertThat(turnover.valueDate).isEqualTo(valueDate) + assertThat(turnover.bookingDate).isEqualTo(bookingDate) + assertThat(turnover.amount).isEqualTo(amount) + } + + private fun assertTransactionDetails(details: TransactionDetails?, otherPartyName: String, + otherPartyBankCode: String, otherPartyAccountId: String) { + + assertThat(details).isNotNull() + + assertThat(details?.otherPartyName).isEqualTo(otherPartyName) + assertThat(details?.otherPartyBankCode).isEqualTo(otherPartyBankCode) + assertThat(details?.otherPartyAccountId).isEqualTo(otherPartyAccountId) + } + + + private val AccountStatementWithSingleTransaction = """ + :20:STARTUMSE + :25:$BankCode/$CustomerId + :28C:00000/001 + :60F:C${convertDate(AccountStatement1PreviousStatementBookingDate)}EUR${convertAmount(AccountStatement1OpeningBalanceAmount)} + :61:${convertDate(AccountStatement1BookingDate)}${convertToShortBookingDate(AccountStatement1BookingDate)}CR${convertAmount(AccountStatement1Transaction1Amount)}N062NONREF + :86:166?00GUTSCHR. UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/ + EUR ${convertAmount(AccountStatement1Transaction1Amount)}/20?2219-10-02/...?30$AccountStatement1Transaction1OtherPartyBankCode?31$AccountStatement1Transaction1OtherPartyAccountId + ?32$AccountStatement1Transaction1OtherPartyName + :62F:C${convertDate(AccountStatement1BookingDate)}EUR${convertAmount(AccountStatement1ClosingBalanceAmount)} + - + """.trimIndent() + + private val AccountStatementWithTwoTransactions = """ + :20:STARTUMSE + :25:$BankCode/$CustomerId + :28C:00000/001 + :60F:C${convertDate(AccountStatement1PreviousStatementBookingDate)}EUR${convertAmount(AccountStatement1OpeningBalanceAmount)} + :61:${convertDate(AccountStatement1BookingDate)}${convertToShortBookingDate(AccountStatement1BookingDate)}CR${convertAmount(AccountStatement1Transaction1Amount)}N062NONREF + :86:166?00GUTSCHR. UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/ + EUR ${convertAmount(AccountStatement1Transaction1Amount)}/20?2219-10-02/...?30$AccountStatement1Transaction1OtherPartyBankCode?31$AccountStatement1Transaction1OtherPartyAccountId + ?32$AccountStatement1Transaction1OtherPartyName + :61:${convertDate(AccountStatement1BookingDate)}${convertToShortBookingDate(AccountStatement1BookingDate)}DR${convertAmount(AccountStatement1Transaction2Amount)}N062NONREF + :86:166?00ONLINE-UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/ + EUR ${convertAmount(AccountStatement1Transaction2Amount)}/20?2219-10-02/...?30$AccountStatement1Transaction2OtherPartyBankCode?31$AccountStatement1Transaction2OtherPartyAccountId + ?32$AccountStatement1Transaction2OtherPartyName + :62F:C${convertDate(AccountStatement1BookingDate)}EUR${convertAmount(AccountStatement1With2TransactionsClosingBalanceAmount)} + - + """.trimIndent() + + + private fun convertAmount(amount: BigDecimal): String { + return amount.toString().replace('.', ',') + } + + private fun convertDate(date: Date): String { + return Mt940AccountTransactionsParser.DateFormat.format(date) + } + + private fun convertToShortBookingDate(date: Date): String { + return SimpleDateFormat("MMdd").format(date) + } + +} \ No newline at end of file