From 95e60b27062f366759ad5cee6f9cba28b62d5de2 Mon Sep 17 00:00:00 2001 From: dankito Date: Wed, 11 Sep 2024 03:11:12 +0200 Subject: [PATCH] Implemented Mt535Parser --- .../transactions/mt940/Mt94xParserBase.kt | 81 +----- .../fints/transactions/swift/Mt535Parser.kt | 226 ++++++++++++++++ .../fints/transactions/swift/MtParserBase.kt | 154 +++++++++++ .../swift/model/ContinuationIndicator.kt | 21 ++ .../fints/transactions/swift/model/Holding.kt | 35 +++ .../swift/model/StatementOfHoldings.kt | 34 +++ .../swift/model/SwiftMessageBlock.kt | 67 +++++ .../fints/transactions/Mt940ParserTest.kt | 4 +- .../transactions/swift/Mt535ParserTest.kt | 255 ++++++++++++++++++ .../transactions/swift/MtParserBaseTest.kt | 163 +++++++++++ .../fints/transactions/Mt940ParserTestJvm.kt | 24 ++ 11 files changed, 984 insertions(+), 80 deletions(-) create mode 100644 fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/Mt535Parser.kt create mode 100644 fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/MtParserBase.kt create mode 100644 fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/ContinuationIndicator.kt create mode 100644 fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/Holding.kt create mode 100644 fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/StatementOfHoldings.kt create mode 100644 fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/SwiftMessageBlock.kt create mode 100644 fints4k/src/commonTest/kotlin/net/codinux/banking/fints/transactions/swift/Mt535ParserTest.kt create mode 100644 fints4k/src/commonTest/kotlin/net/codinux/banking/fints/transactions/swift/MtParserBaseTest.kt 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 index 88b836f9..ce19b61e 100644 --- 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 @@ -1,11 +1,9 @@ package net.codinux.banking.fints.transactions.mt940 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.model.Amount import net.codinux.banking.fints.transactions.mt940.model.* +import net.codinux.banking.fints.transactions.swift.MtParserBase /* @@ -22,8 +20,8 @@ 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 -) { + logAppender: IMessageLogAppender? = null +) : MtParserBase(logAppender) { companion object { val AccountStatementsSeparatorRegex = Regex("^\\s*-\\s*\$", RegexOption.MULTILINE) // a line only with '-' and may other white space characters @@ -86,8 +84,6 @@ abstract class Mt94xParserBase( const val DeviantRecipientKey = "ABWE+" } - private val log by logger() - protected abstract fun createAccountStatement( orderReferenceNumber: String, referenceNumber: String?, @@ -466,41 +462,6 @@ abstract class Mt94xParserBase( } - 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 - } - - // 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: Throwable) { - logError("Could not parse dateString '$dateString'", e) - throw e - } - } - /** * Booking date string consists only of MMDD -> we need to take the year from value date string. */ @@ -516,40 +477,4 @@ abstract class Mt94xParserBase( return bookingDate } - 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 } - } - } - } \ No newline at end of file diff --git a/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/Mt535Parser.kt b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/Mt535Parser.kt new file mode 100644 index 00000000..55a43b65 --- /dev/null +++ b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/Mt535Parser.kt @@ -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 { + 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, 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? { + 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 { + 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 { + 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 { + 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(",", "").toIntOrNull() 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 { + 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 { + 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) + } + +} \ No newline at end of file diff --git a/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/MtParserBase.kt b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/MtParserBase.kt new file mode 100644 index 00000000..4bee5b97 --- /dev/null +++ b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/MtParserBase.kt @@ -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 { + val lines = mt.lines().filterNot { it.isBlank() } + + return parseMtStringLines(lines, rememberOrderOfFields) + } + + protected open fun parseMtStringLines(lines: List, rememberOrderOfFields: Boolean = false): List { + val messageBlocks = mutableListOf() + var currentBlock = SwiftMessageBlock() + + var fieldCode = "" + val fieldValueLines = mutableListOf() + + 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 } + } + } + +} \ No newline at end of file diff --git a/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/ContinuationIndicator.kt b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/ContinuationIndicator.kt new file mode 100644 index 00000000..081d75d6 --- /dev/null +++ b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/ContinuationIndicator.kt @@ -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") + +} \ No newline at end of file diff --git a/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/Holding.kt b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/Holding.kt new file mode 100644 index 00000000..8a69fd6a --- /dev/null +++ b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/Holding.kt @@ -0,0 +1,35 @@ +package net.codinux.banking.fints.transactions.swift.model + +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import net.codinux.banking.fints.model.Amount + +data class Holding( + val name: String, + val isin: String?, + val wkn: String?, + val buyingDate: LocalDate?, + val quantity: Int?, + /** + * (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 +) \ No newline at end of file diff --git a/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/StatementOfHoldings.kt b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/StatementOfHoldings.kt new file mode 100644 index 00000000..ff59db50 --- /dev/null +++ b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/StatementOfHoldings.kt @@ -0,0 +1,34 @@ +package net.codinux.banking.fints.transactions.swift.model + +import kotlinx.datetime.LocalDate +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) + */ +data class StatementOfHoldings( + val bankCode: String, + val accountIdentifier: String, + + val holdings: List, + + val totalBalance: Amount? = null, + val totalBalanceCurrency: 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}" }}" +} \ No newline at end of file diff --git a/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/SwiftMessageBlock.kt b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/SwiftMessageBlock.kt new file mode 100644 index 00000000..78274ca9 --- /dev/null +++ b/fints4k/src/commonMain/kotlin/net/codinux/banking/fints/transactions/swift/model/SwiftMessageBlock.kt @@ -0,0 +1,67 @@ +package net.codinux.banking.fints.transactions.swift.model + +class SwiftMessageBlock( + initialFields: List>? = null +) { + + private val fields = LinkedHashMap>() + + private val fieldsInOrder = mutableListOf>() + + val hasFields: Boolean + get() = fields.isNotEmpty() + + val fieldCodes: Collection + get() = fields.keys + + init { + initialFields?.forEach { (fieldCode, fieldValue) -> + addField(fieldCode, fieldValue) + } + } + + + fun addField(fieldCode: String, fieldValueLines: List, 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> = 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 = + getMandatoryFieldValue(fieldCode) + + fun getOptionalRepeatableField(fieldCode: String): List? = + getOptionalFieldValue(fieldCode) + + private fun getMandatoryFieldValue(fieldCode: String): List = + 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? = fields[fieldCode] + + + override fun toString() = + if (fieldsInOrder.isNotEmpty()) { + fieldsInOrder.joinToString("\n") + } else { + fields.entries.joinToString("\n") { "${it.key}${it.value}" } + } + +} \ No newline at end of file diff --git a/fints4k/src/commonTest/kotlin/net/codinux/banking/fints/transactions/Mt940ParserTest.kt b/fints4k/src/commonTest/kotlin/net/codinux/banking/fints/transactions/Mt940ParserTest.kt index 2c041cc9..cf751bef 100644 --- a/fints4k/src/commonTest/kotlin/net/codinux/banking/fints/transactions/Mt940ParserTest.kt +++ b/fints4k/src/commonTest/kotlin/net/codinux/banking/fints/transactions/Mt940ParserTest.kt @@ -245,14 +245,14 @@ class Mt940ParserTest : MtParserTestBase() { fun parseDate_FixSparkasse29thOFFebruaryInNonLeapYearBug() { val result = underTest.parseDate("230229") - assertEquals(LocalDate(2023, 3, 1), result) + assertEquals(LocalDate(2023, 2, 28), result) } @Test fun parseDate_FixSparkasse30thOfFebruaryBug() { val result = underTest.parseDate("230229") - assertEquals(LocalDate(2023, 3, 1), result) + assertEquals(LocalDate(2023, 2, 28), result) } } \ No newline at end of file diff --git a/fints4k/src/commonTest/kotlin/net/codinux/banking/fints/transactions/swift/Mt535ParserTest.kt b/fints4k/src/commonTest/kotlin/net/codinux/banking/fints/transactions/swift/Mt535ParserTest.kt new file mode 100644 index 00000000..a2b35035 --- /dev/null +++ b/fints4k/src/commonTest/kotlin/net/codinux/banking/fints/transactions/swift/Mt535ParserTest.kt @@ -0,0 +1,255 @@ +package net.codinux.banking.fints.transactions.swift + +import kotlinx.datetime.LocalDate +import net.codinux.banking.fints.test.assertEquals +import net.codinux.banking.fints.test.assertNull +import net.codinux.banking.fints.test.assertSize +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 kotlin.test.Test +import kotlin.test.assertNotNull + +class Mt535ParserTest { + + private val underTest = Mt535Parser() + + + @Test + fun parseSimpleExample() { + val result = underTest.parseMt535String(SimpleExampleString) + + val statement = assertStatement(result, "70033100", "0123456789", "41377,72", LocalDate(2024, 8, 30), LocalDate(2024, 8, 30)) + + assertSize(2, statement.holdings) + + assertHolding(statement.holdings.first(), "MUL AMUN MSCI WLD ETF ACC MUL Amundi MSCI World V", "LU1781541179", null, LocalDate(2024, 6, 3), 1693, "16,828250257", "18531,08") + assertHolding(statement.holdings[1], "NVIDIA CORP. DL-,001 NVIDIA Corp.", "US67066G1040", null, LocalDate(2024, 8, 5), 214, "92,86", "22846,64") + } + + @Test + fun parseDkExample() { + val result = underTest.parseMt535String(DkMt535Example) + + val statement = assertStatement(result, "10020030", "1234567", "17026,37", null, LocalDate(1999, 5, 30)) + + assertEquals("10020030", statement.bankCode) + assertEquals("1234567", statement.accountIdentifier) + + assertEquals("17026,37", statement.totalBalance?.string) + assertEquals("EUR", statement.totalBalanceCurrency) + + assertEquals(1, statement.pageNumber) + assertEquals(ContinuationIndicator.SinglePage, statement.continuationIndicator) + + assertNull(statement.statementDate) + assertEquals(LocalDate(1999, 5, 30), statement.preparationDate) + + assertSize(3, statement.holdings) + + assertHolding(statement.holdings.first(), "/DE/123456 Mustermann AG, Stammaktien", "DE0123456789", null, LocalDate(1999, 8, 15), 100, "68,5", "5270,") + assertHolding(statement.holdings[1], "/DE/123457 Mustermann AG, Vorzugsaktien", "DE0123456790", null, LocalDate(1998, 10, 13), 100, "42,75", "5460,") + // TODO: these values are not correct. Implement taking foreign currencies into account to fix this + assertHolding(statement.holdings[2], "Australian Domestic Bonds 1993 (2003) Ser. 10", "AU9876543210", null, LocalDate(1999, 3, 15), null, "31", "6294,65") + } + + + private fun assertStatement(result: List, bankCode: String, accountId: String, totalBalance: String?, statementDate: LocalDate?, preparationDate: LocalDate?, totalBalanceCurrency: String? = "EUR", pageNumber: Int? = 1, continuationIndicator: ContinuationIndicator = ContinuationIndicator.SinglePage): StatementOfHoldings { + val statement = result.first() + + assertEquals(bankCode, statement.bankCode) + assertEquals(accountId, statement.accountIdentifier) + + assertEquals(totalBalance, statement.totalBalance?.string) + assertEquals(totalBalanceCurrency, statement.totalBalanceCurrency) + + assertEquals(pageNumber, statement.pageNumber) + assertEquals(continuationIndicator, statement.continuationIndicator) + + assertEquals(statementDate, statement.statementDate) + assertEquals(preparationDate, statement.preparationDate) + + return statement + } + + private fun assertHolding(holding: Holding, name: String, isin: String?, wkn: String?, buyingDate: LocalDate?, quantity: Int?, averagePrice: String?, balance: String?, currency: String? = "EUR") { + assertEquals(name, holding.name) + + assertEquals(isin, holding.isin) + assertEquals(wkn, holding.wkn) + + assertEquals(buyingDate, holding.buyingDate) + + assertEquals(quantity, holding.quantity) + assertEquals(averagePrice, holding.averageCostPrice?.string) + + assertEquals(balance, holding.totalBalance?.string) + assertEquals(currency, holding.currency) + } + + + private val SimpleExampleString = """ + :16R:GENL + :28E:1/ONLY + :20C::SEME//NONREF + :23G:NEWM + :98A::PREP//20240830 + :98A::STAT//20240830 + :22F::STTY//CUST + :97A::SAFE//70033100/0123456789 + :17B::ACTI//Y + :16S:GENL + + :16R:FIN + :35B:ISIN LU1781541179 + MUL AMUN MSCI WLD ETF ACC + MUL Amundi MSCI World V + :93B::AGGR//UNIT/1693, + + :16R:SUBBAL + :93C::TAVI//UNIT/AVAI/1693, + :70C::SUBB//1 MUL AMUN MSCI WLD ETF ACC + 2 + 3 EDE 17.332000000EUR 2024-08-30T18:50:35.76 + 4 17994.44EUR LU1781541179, 1/SO + :16S:SUBBAL + :19A::HOLD//EUR18531,08 + :70E::HOLD//1STK++++20240603+ + 216,828250257+EUR + :16S:FIN + + :16R:FIN + :35B:ISIN US67066G1040 + NVIDIA CORP. DL-,001 + NVIDIA Corp. + :93B::AGGR//UNIT/214, + :16R:SUBBAL + :93C::TAVI//UNIT/AVAI/214, + :70C::SUBB//1 NVIDIA CORP. DL-,001 + 2 + 3 EDE 106.760000000EUR 2024-08-30T18:53:05.04 + 4 19872.04EUR US67066G1040, 1/SHS + :16S:SUBBAL + :19A::HOLD//EUR22846,64 + :70E::HOLD//1STK++++20240805+ + 292,86+EUR + :16S:FIN + + :16R:ADDINFO + :19A::HOLP//EUR41377,72 + :16S:ADDINFO + - + """.trimIndent() + + /** + * See Anlage_3_Datenformate_V3.8, S. 317ff + * + * Bei der ersten Depotposition (Mustermann AG Stammaktien) liegt ein Bestand von 100 Stück + * vor. Die zweite Position (Mustermann AG Vorzugsaktien) setzt sich aus einem Guthaben von + * 130 Stück und einem schwebenden Abgang von 30 Stück zu einem Saldo von 100 Stück + * zusammen. Bei der dritten Position (Australian Domestic Bonds) ist im Gesamtsaldo von + * 10.000 Australischen Dollar ein Bestand von 2.500 Dollar als gesperrt gekennzeichnet. + */ + private val DkMt535Example = """ + :16R:GENL + :28E:1/ONLY + :13A::STAT//004 + :20C::SEME//NONREF + :23G:NEWM + :98C::PREP//19990530120538 + :98A::STAT//19990529 + :22F::STTY//CUST + :97A::SAFE//10020030/1234567 + :17B::ACTI//Y + :16S:GENL + + :16R:FIN + :35B:ISIN DE0123456789 + /DE/123456 + Mustermann AG, Stammaktien + :90B::MRKT//ACTU/EUR52,7 + :94B::PRIC//LMAR/XFRA + :98A::PRIC//19990529 + :93B::AGGR//UNIT/100, + + :16R:SUBBAL + :93C::TAVI//UNIT/AVAI/100, + :94C::SAFE//DE + :70C::SUBB//12345678901234567890 + 1 + :16S:SUBBAL + + :19A::HOLD//EUR5270, + :70E::HOLD//STK+511+00081+DE+19990815 + 68,5+EUR + :16S:FIN + + :16R:FIN + :35B:ISIN DE0123456790 + /DE/123457 + Mustermann AG, Vorzugsaktien + :90B::MRKT//ACTU/EUR54,6 + :94B::PRIC//LMAR/XFRA + :98A::PRIC//19990529 + :93B::AGGR//UNIT/100, + + :16R:SUBBAL + :93C::TAVI//UNIT/AVAI/130, + :94C::SAFE//DE + :70C::SUBB//123456799123456799 + 1 + :16S:SUBBAL + + :16R:SUBBAL + :93C::PEND//UNIT/NAVL/N30, + :94C::SAFE//DE + :70C::SUBB//123456799123456799 + 1 + :16S:SUBBAL + + :19A::HOLD//EUR5460, + :70E::HOLD//STK+512+00081+DE+19981013 + 42,75+EUR + :16S:FIN + + :16R:FIN + :35B:ISIN AU9876543210 + Australian Domestic Bonds + 1993 (2003) Ser. 10 + :90A::MRKT//PRCT/105, + :94B::PRIC//LMAR/XASX + :98A::PRIC//19990528 + :93B::AGGR//FAMT/10000, + + :16R:SUBBAL + :93C::TAVI//FAMT/AVAI/7500, + :94C::SAFE//AU + :70C::SUBB//98765432109876543210 + 4+Sydney + :16S:SUBBAL + + :16R:SUBBAL + :93C::BLOK//FAMT/NAVL/2500, + :94C::SAFE//AU + :70C::SUBB//98765432109876543210 + 4+Sydney+20021231 + :16S:SUBBAL + + :99A::DAAC//004 + :19A::HOLD//EUR6294,65 + :19A::HOLD//AUD10500, + :19A::ACRU//EUR1,72 + :19A::ACRU//AUD2,87 + :92B::EXCH//AUD/EUR/0,59949 + :70E::HOLD//AUD+525+00611+AU+19990315+200312 + 31 + 99,75++6,25 + :16S:FIN + + :16R:ADDINFO + :19A::HOLP//EUR17026,37 + :16S:ADDINFO + - + """.trimIndent() + +} \ No newline at end of file diff --git a/fints4k/src/commonTest/kotlin/net/codinux/banking/fints/transactions/swift/MtParserBaseTest.kt b/fints4k/src/commonTest/kotlin/net/codinux/banking/fints/transactions/swift/MtParserBaseTest.kt new file mode 100644 index 00000000..7e25002e --- /dev/null +++ b/fints4k/src/commonTest/kotlin/net/codinux/banking/fints/transactions/swift/MtParserBaseTest.kt @@ -0,0 +1,163 @@ +package net.codinux.banking.fints.transactions.swift + +import net.codinux.banking.fints.test.assertContains +import net.codinux.banking.fints.test.assertEquals +import net.codinux.banking.fints.test.assertNotNull +import net.codinux.banking.fints.test.assertSize +import net.codinux.banking.fints.transactions.swift.model.SwiftMessageBlock +import kotlin.test.Test + +class MtParserBaseTest : MtParserTestBase() { + + private val underTest = MtParserBase() + + + @Test + fun accountStatementWithSingleTransaction() { + + // when + val result = underTest.parseMtString(AccountStatementWithSingleTransaction) + + + // then + assertSize(1, result) + + val block = result.first() + + assertAccountStatementWithSingleTransaction(block) + } + + @Test + fun accountStatementWithTwoTransactions() { + + // when + val result = underTest.parseMtString(AccountStatementWithTwoTransactions) + + + // then + assertSize(1, result) + + val block = result.first() + + assertEquals("$BankCode/$CustomerId", block.getMandatoryField("25")) + assertEquals("00000/001", block.getMandatoryField("28C")) + assertEquals("C${convertMt940Date(AccountStatement1PreviousStatementBookingDate)}EUR${AccountStatement1OpeningBalanceAmount.string}", block.getMandatoryField("60F")) + + assertEquals("C${convertMt940Date(AccountStatement1BookingDate)}EUR${AccountStatement1With2TransactionsClosingBalanceAmount.string}", block.getMandatoryField("62F")) + + assertNotNull(block.getOptionalRepeatableField("61")) + assertSize(2, block.getOptionalRepeatableField("61")!!) + + assertNotNull(block.getOptionalRepeatableField("86")) + assertSize(2, block.getOptionalRepeatableField("86")!!) + + assertEquals("8802270227CR1234,56N062NONREF", block.getOptionalRepeatableField("61")?.get(0)) + assertEquals("166?00GUTSCHR. UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/\n" + + "EUR 1234,56/20?2219-10-02/...?30AAAADE12?31DE99876543210987654321\n" + + "?32Sender1", block.getOptionalRepeatableField("86")?.get(0)) + + assertEquals("8802270227DR432,10N062NONREF", block.getOptionalRepeatableField("61")?.get(1)) + assertEquals("166?00ONLINE-UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/\n" + + "EUR 432,10/20?2219-10-02/...?30BBBBDE56?31DE77987654321234567890\n" + + "?32Receiver2", block.getOptionalRepeatableField("86")?.get(1)) + } + + @Test + fun accountStatementWithPartialNextStatement() { + + // when + val result = underTest.parseMtString(AccountStatementWithSingleTransaction + "\r\n" + ":20:STARTUMSE") + + + // then + assertSize(2, result) + + val remainder = result.get(1) + assertSize(1, remainder.fieldCodes) + assertEquals("STARTUMSE", remainder.getMandatoryField("20")) + + assertAccountStatementWithSingleTransaction(result.first()) + } + + @Test + fun fixLineStartsWithDashThatIsNotABlockSeparator() { + + // when + val result = underTest.parseMtString(AccountStatementWithLineStartsWithDashThatIsNotABlockSeparator) + + + // then + assertSize(3, result) + + // the references field (86) contains a line that starts with "-End-Ref..." + val references = result.flatMap { it.getMandatoryRepeatableField("86") } + assertSize(7, references) + assertContains(references, "820?20ÜBERTRAG / ÜBERWEISUNG?21ECHTZEITUEBERWEISUNGSTEST?22END-TO\n" + + "-END-REF.:?23NICHT ANGEGEBEN?24Ref. 402C0YTD0GLPFDFV/1?32DANKI\n" + + "TO") + } + + @Test + fun fixThatTimeGotDetectedAsFieldCode() { + + // when + val result = underTest.parseMtString(AccountStatementWithTimeThatGotDetectedAsFieldCode) + + + // then + assertSize(1, result) + assertSize(3, result.flatMap { it.getMandatoryRepeatableField("86") }) + } + + @Test + fun fixThat_QuestionMarkComma_GetsDetectedAsFieldCode() { + + // when + val result = underTest.parseMtString(QuotationMarkCommaGetsDetectedAsFieldValue) + + + // then + assertSize(1, result) + + val references = result.flatMap { it.getMandatoryRepeatableField("86") } + assertSize(1, references) + + assertContains(references.first(), + "BASISLASTSCHRIFT", + "TUBDDEDD", + "DE87300308801234567890", + "6MKL2OT30QENNLIU", + "?,3SQNdUbxm9z7dB)+gKYDJA?28KzCM0G", + "IBAN: DE87300308801234?30TUBDDEDD" + ) + } + + + private fun assertAccountStatementWithSingleTransaction(block: SwiftMessageBlock) { + assertEquals("$BankCode/$CustomerId", block.getMandatoryField("25")) + assertEquals("00000/001", block.getMandatoryField("28C")) + assertEquals( + "C${convertMt940Date(AccountStatement1PreviousStatementBookingDate)}EUR${AccountStatement1OpeningBalanceAmount.string}", + block.getMandatoryField("60F") + ) + + assertEquals( + "C${convertMt940Date(AccountStatement1BookingDate)}EUR${AccountStatement1ClosingBalanceAmount.string}", + block.getMandatoryField("62F") + ) + + assertNotNull(block.getOptionalRepeatableField("61")) + assertSize(1, block.getOptionalRepeatableField("61")!!) + + assertNotNull(block.getOptionalRepeatableField("86")) + assertSize(1, block.getOptionalRepeatableField("86")!!) + + assertEquals("8802270227CR1234,56N062NONREF", block.getOptionalRepeatableField("61")?.first()) + assertEquals( + "166?00GUTSCHR. UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/\n" + + "EUR 1234,56/20?2219-10-02/...?30AAAADE12?31DE99876543210987654321\n" + + "?32Sender1", block.getOptionalRepeatableField("86")?.first() + ) + } + +} \ No newline at end of file diff --git a/fints4k/src/jvmTest/kotlin/net/codinux/banking/fints/transactions/Mt940ParserTestJvm.kt b/fints4k/src/jvmTest/kotlin/net/codinux/banking/fints/transactions/Mt940ParserTestJvm.kt index 3d87d079..e76fe5be 100644 --- a/fints4k/src/jvmTest/kotlin/net/codinux/banking/fints/transactions/Mt940ParserTestJvm.kt +++ b/fints4k/src/jvmTest/kotlin/net/codinux/banking/fints/transactions/Mt940ParserTestJvm.kt @@ -1,7 +1,9 @@ package net.codinux.banking.fints.transactions import net.codinux.banking.fints.FinTsTestBaseJvm +import net.codinux.banking.fints.test.assertSize import net.codinux.banking.fints.transactions.mt940.Mt940Parser +import net.codinux.banking.fints.transactions.swift.MtParserBase import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -24,6 +26,28 @@ class Mt940ParserTestJvm : FinTsTestBaseJvm() { // then assertThat(result).hasSize(32) + + val transactions = result.flatMap { it.transactions } + assertSize(55, transactions) + } + + + @Test + fun parseTransactionsMtParserBase() { + + // given + val transactionsString = loadTestFile(TransactionsMt940Filename) + + + // when + val result = MtParserBase().parseMtString(transactionsString) + + + // then + assertThat(result).hasSize(32) + + val references = result.flatMap { it.getMandatoryRepeatableField("86") } + assertSize(55, references) } } \ No newline at end of file