Implemented Mt535Parser

This commit is contained in:
dankito 2024-09-11 03:11:12 +02:00
parent fd9eadf45e
commit 95e60b2706
11 changed files with 984 additions and 80 deletions

View File

@ -1,11 +1,9 @@
package net.codinux.banking.fints.transactions.mt940 package net.codinux.banking.fints.transactions.mt940
import kotlinx.datetime.* 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.log.IMessageLogAppender
import net.codinux.banking.fints.model.Amount
import net.codinux.banking.fints.transactions.mt940.model.* 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. None of lines include only Space.
*/ */
abstract class Mt94xParserBase<T: AccountStatementCommon>( abstract class Mt94xParserBase<T: AccountStatementCommon>(
open var logAppender: IMessageLogAppender? = null logAppender: IMessageLogAppender? = null
) { ) : MtParserBase(logAppender) {
companion object { companion object {
val AccountStatementsSeparatorRegex = Regex("^\\s*-\\s*\$", RegexOption.MULTILINE) // a line only with '-' and may other white space characters val AccountStatementsSeparatorRegex = Regex("^\\s*-\\s*\$", RegexOption.MULTILINE) // a line only with '-' and may other white space characters
@ -86,8 +84,6 @@ abstract class Mt94xParserBase<T: AccountStatementCommon>(
const val DeviantRecipientKey = "ABWE+" const val DeviantRecipientKey = "ABWE+"
} }
private val log by logger()
protected abstract fun createAccountStatement( protected abstract fun createAccountStatement(
orderReferenceNumber: String, referenceNumber: String?, orderReferenceNumber: String, referenceNumber: String?,
@ -466,41 +462,6 @@ abstract class Mt94xParserBase<T: AccountStatementCommon>(
} }
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. * Booking date string consists only of MMDD -> we need to take the year from value date string.
*/ */
@ -516,40 +477,4 @@ abstract class Mt94xParserBase<T: AccountStatementCommon>(
return bookingDate 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 }
}
}
} }

View File

@ -0,0 +1,226 @@
package net.codinux.banking.fints.transactions.swift
import kotlinx.datetime.*
import net.codinux.banking.fints.extensions.EuropeBerlin
import net.codinux.banking.fints.log.IMessageLogAppender
import net.codinux.banking.fints.model.Amount
import net.codinux.banking.fints.transactions.swift.model.ContinuationIndicator
import net.codinux.banking.fints.transactions.swift.model.Holding
import net.codinux.banking.fints.transactions.swift.model.StatementOfHoldings
import net.codinux.banking.fints.transactions.swift.model.SwiftMessageBlock
open class Mt535Parser(
logAppender: IMessageLogAppender? = null
) : MtParserBase(logAppender) {
open fun parseMt535String(mt535String: String): List<StatementOfHoldings> {
val blocks = parseMtString(mt535String, true)
// should actually always be only one block, just to be on the safe side
return blocks.mapNotNull { parseStatementOfHoldings(it) }
}
protected open fun parseStatementOfHoldings(mt535Block: SwiftMessageBlock): StatementOfHoldings? {
try {
val containsHoldings = mt535Block.getMandatoryField("17B").endsWith("//Y")
val holdings = if (containsHoldings) parseHoldings(mt535Block) else emptyList()
return parseStatementOfHoldings(holdings, mt535Block)
} catch (e: Throwable) {
logError("Could not parse MT 535 block:\n$mt535Block", e)
}
return null
}
protected open fun parseStatementOfHoldings(holdings: List<Holding>, mt535Block: SwiftMessageBlock): StatementOfHoldings {
val totalBalance = parseBalance(mt535Block.getMandatoryRepeatableField("19A").last())
val accountStatement = mt535Block.getMandatoryField("97A")
val bankCode = accountStatement.substringAfter("//").substringBefore('/')
val accountIdentifier = accountStatement.substringAfterLast('/')
val (pageNumber, continuationIndicator) = parsePageNumber(mt535Block)
val (statementDate, preparationDate) = parseStatementAndPreparationDate(mt535Block)
return StatementOfHoldings(bankCode, accountIdentifier, holdings, totalBalance?.first, totalBalance?.second, pageNumber, continuationIndicator, statementDate, preparationDate)
}
// this is a MT5(35) specific balance format
protected open fun parseBalance(balanceString: String?): Pair<Amount, String>? {
if (balanceString != null) {
val balancePart = balanceString.substringAfterLast('/')
val amount = balancePart.substring(3)
val isNegative = amount.startsWith("N")
return Pair(Amount(if (isNegative) "-${amount.substring(1)}" else amount), balancePart.substring(0, 3))
}
return null
}
protected open fun parsePageNumber(mt535Block: SwiftMessageBlock): Pair<Int?, ContinuationIndicator> {
return try {
val pageNumberStatement = mt535Block.getMandatoryField("28E")
val pageNumber = pageNumberStatement.substringBefore('/').toInt()
val continuationIndicator = pageNumberStatement.substringAfter('/').let { indicatorString ->
ContinuationIndicator.entries.firstOrNull { it.mtValue == indicatorString } ?: ContinuationIndicator.Unknown
}
Pair(pageNumber, continuationIndicator)
} catch (e: Throwable) {
logError("Could not parse statement and preparation date of block:\n$mt535Block", e)
Pair(null, ContinuationIndicator.Unknown)
}
}
protected open fun parseStatementAndPreparationDate(mt535Block: SwiftMessageBlock): Pair<LocalDate?, LocalDate?> {
return try {
// TODO: differ between 98A (without time) and 98C (with time)
// TODO: ignore (before parsing?) 98A/C of holdings which start with ":PRIC//
val dates = mt535Block.getMandatoryRepeatableField("98").map { it.substringBefore("//") to parse4DigitYearDate(it.substringAfter("//").substring(0, 8)) } // if given we ignore time
val statementDate = dates.firstOrNull { it.first == ":STAT" }?.second // specifications and their implementations: the statement date is actually mandatory, but not all banks actually set it
val preparationDate = dates.firstOrNull { it.first == ":PREP" }?.second
Pair(statementDate, preparationDate)
} catch (e: Throwable) {
logError("Could not parse statement and preparation date of block:\n$mt535Block", e)
Pair(null, null)
}
}
protected open fun parseHoldings(mt535Block: SwiftMessageBlock): List<Holding> {
val blockLines = mt535Block.getFieldsInOrder()
val holdingBlocksStartIndices = blockLines.indices.filter { blockLines[it].first == "16R" && blockLines[it].second == "FIN" }
val holdingBlocksEndIndices = blockLines.indices.filter { blockLines[it].first == "16S" && blockLines[it].second == "FIN" }
val holdingBlocks = holdingBlocksStartIndices.mapIndexed { blockIndex, startIndex ->
val endIndex = holdingBlocksEndIndices[blockIndex]
val holdingBlockLines = blockLines.subList(startIndex + 1, endIndex)
SwiftMessageBlock(holdingBlockLines)
}
return holdingBlocks.mapNotNull { parseHolding(it) }
}
protected open fun parseHolding(holdingBlock: SwiftMessageBlock): Holding? =
try {
val nameStatementLines = holdingBlock.getMandatoryField("35B").split("\n")
val isinOrWkn = nameStatementLines.first()
val isin = if (isinOrWkn.startsWith("ISIN ")) {
isinOrWkn.substringAfter(' ')
} else {
null
}
val wkn = if (isin == null) {
isinOrWkn
} else if (nameStatementLines[1].startsWith("DE")) {
nameStatementLines[1]
} else {
null
}
val name = nameStatementLines.subList(if (isin == null || wkn == null) 1 else 2, nameStatementLines.size).joinToString(" ")
// TODO: check for optional code :90a: Preis
// TODO: check for optional code :94B: Herkunft von Preis / Kurs
// TODO: check for optional code :98A: Herkunft von Preis / Kurs
// TODO: check for optional code :99A: Anzahl der aufgelaufenen Tage
// TODO: check for optional code :92B: Exchange rate
val holdingTotalBalance = holdingBlock.getMandatoryField("93B")
val balanceIsQuantity = holdingTotalBalance.startsWith(":AGGR//UNIT") // == Die Stückzahl wird als Zahl (Zähler) ausgedrückt
// else it starts with "AGGR/FAMT" = Die Stückzahl wird als Nennbetrag ausgedrückt. Bei Nennbeträgen wird die Währung durch die „Depotwährung“ in Feld B:70E: bestimmt
val totalBalanceWithOptionalSign = holdingTotalBalance.substring(":AGGR//UNIT/".length)
val totalBalanceIsNegative = totalBalanceWithOptionalSign.first() == 'N'
val totalBalance = if (totalBalanceIsNegative) "-" + totalBalanceWithOptionalSign.substring(1) else totalBalanceWithOptionalSign
// there's a second ":HOLD//" entry if the currency if the security differs from portfolio's currency // TODO: the 3rd holding of the DK example has this, so implement it to display the correct value
val portfolioValueStatement = holdingBlock.getOptionalRepeatableField("19A")?.firstOrNull { it.startsWith(":HOLD//") }
val portfolioValue = parseBalance(portfolioValueStatement?.substringAfter(":HOLD//")) // Value for total balance from B:93B: in the same currency as C:19A:
val (buyingDate, averageCostPrice, averageCostPriceCurrency) = parseHoldingAdditionalInformation(holdingBlock)
val (marketValue, pricingTime, totalCostPrice) = parseMarketValue(holdingBlock)
val balance = portfolioValue?.first ?: (if (balanceIsQuantity == false) Amount(totalBalance) else null)
val quantity = if (balanceIsQuantity) totalBalance.replace(",", "").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<LocalDate?, Amount?, String?> {
try {
val additionalInformationLines = holdingBlock.getOptionalField("70E")?.split('\n')
if (additionalInformationLines != null) {
val firstLine = additionalInformationLines.first().substring(":HOLD//".length).let {
if (it.startsWith("1")) it.substring(1) else it // specifications and their implementations: line obligatory has to start with '1' but that's not always the case
}
val currencyOfSafekeepingAccountIsUnit = firstLine.startsWith("STK") // otherwise it's "KON“ = Contracts or ISO currency code of the category currency in the case of securities quoted in percentages
val firstLineParts = firstLine.split('+')
val buyingDate = if (firstLineParts.size > 4) parse4DigitYearDate(firstLineParts[4]) else null
val secondLine = if (additionalInformationLines.size > 1) additionalInformationLines[1].let { if (it.startsWith("2")) it.substring(1) else it } else "" // cut off "2"; the second line is actually mandatory, but to be on the safe side
val secondLineParts = secondLine.split('+')
val averageCostPriceAmount = if (secondLineParts.size > 0) secondLineParts[0] else null
val averageCostPriceCurrency = if (secondLineParts.size > 1) secondLineParts[1] else null
// third and fourth line are only filled in in the case of futures contracts
return Triple(buyingDate, averageCostPriceAmount?.let { Amount(it) }, averageCostPriceCurrency)
}
} catch (e: Throwable) {
logError("Could not parse additional information for holding:\n$holdingBlock", e)
}
return Triple(null, null, null)
}
private fun parseMarketValue(holdingBlock: SwiftMessageBlock): Triple<Amount?, Instant?, Amount?> {
try {
val subBalanceDetailsLines = holdingBlock.getOptionalField("70C")?.split('\n')
if (subBalanceDetailsLines != null) {
val thirdLine = if (subBalanceDetailsLines.size > 2) subBalanceDetailsLines[2].let { if (it.startsWith("3")) it.substring(1) else it }.trim() else null
val (marketValue, pricingTime) = if (thirdLine != null) {
val thirdLineParts = thirdLine.split(' ')
val marketValueAmountAndCurrency = if (thirdLineParts.size > 1) thirdLineParts[1].takeIf { it.isNotBlank() } else null
val marketValue = marketValueAmountAndCurrency?.let { Amount(it.replace('.', ',').replace("EUR", "")) } // TODO: also check for other currencies
val pricingTime = try {
if (thirdLineParts.size > 2) thirdLineParts[2].let { if (it.length > 18) LocalDateTime.parse(it.substring(0, 19)).toInstant(TimeZone.EuropeBerlin) else null } else null
} catch (e: Throwable) {
logError("Could not parse pricing time from line: $thirdLine", e)
null
}
marketValue to pricingTime
} else {
null to null
}
val fourthLine = if (subBalanceDetailsLines.size > 3) subBalanceDetailsLines[3].let { if (it.startsWith("4")) it.substring(1) else it }.trim() else null
val totalCostPrice = if (fourthLine != null) {
val fourthLineParts = fourthLine.split(' ')
val totalCostPriceAmountAndCurrency = if (fourthLineParts.size > 0) fourthLineParts[0] else null
totalCostPriceAmountAndCurrency?.let { Amount(it.replace('.', ',').replace("EUR", "")) } // TODO: also check for other currencies
} else {
null
}
return Triple(marketValue, pricingTime, totalCostPrice)
}
} catch (e: Throwable) {
logError("Could not map market value and total cost price, but is a non-standard anyway", e)
}
return Triple(null, null, null)
}
}

View File

@ -0,0 +1,154 @@
package net.codinux.banking.fints.transactions.swift
import kotlinx.datetime.*
import net.codinux.banking.fints.extensions.EuropeBerlin
import net.codinux.banking.fints.log.IMessageLogAppender
import net.codinux.banking.fints.model.Amount
import net.codinux.banking.fints.transactions.mt940.Mt94xParserBase
import net.codinux.banking.fints.transactions.swift.model.SwiftMessageBlock
import net.codinux.log.logger
open class MtParserBase(
open var logAppender: IMessageLogAppender? = null
) {
protected val log by logger()
fun parseMtString(mt: String, rememberOrderOfFields: Boolean = false): List<SwiftMessageBlock> {
val lines = mt.lines().filterNot { it.isBlank() }
return parseMtStringLines(lines, rememberOrderOfFields)
}
protected open fun parseMtStringLines(lines: List<String>, rememberOrderOfFields: Boolean = false): List<SwiftMessageBlock> {
val messageBlocks = mutableListOf<SwiftMessageBlock>()
var currentBlock = SwiftMessageBlock()
var fieldCode = ""
val fieldValueLines = mutableListOf<String>()
lines.forEach { line ->
// end of block
if (line.trim() == "-") {
if (fieldCode.isNotBlank()) {
currentBlock.addField(fieldCode, fieldValueLines, rememberOrderOfFields)
}
messageBlocks.add(currentBlock)
currentBlock = SwiftMessageBlock()
fieldCode = ""
fieldValueLines.clear() // actually not necessary
}
// start of a new field
else if (line.length > 5 && line[0] == ':' && line[1].isDigit() && line[2].isDigit() && (line[3] == ':' || line[3].isLetter() && line[4] == ':')) {
if (fieldCode.isNotBlank()) {
currentBlock.addField(fieldCode, fieldValueLines, rememberOrderOfFields)
}
val fieldCodeContainsLetter = line[3].isLetter()
fieldCode = line.substring(1, if (fieldCodeContainsLetter) 4 else 3)
fieldValueLines.clear()
fieldValueLines.add(if (fieldCodeContainsLetter) line.substring(5) else line.substring(4))
}
// a line that belongs to previous field value
else {
fieldValueLines.add(line)
}
}
if (fieldCode.isNotBlank()) {
currentBlock.addField(fieldCode, fieldValueLines, rememberOrderOfFields)
}
if (currentBlock.hasFields) {
messageBlocks.add(currentBlock)
}
return messageBlocks
}
open fun parse4DigitYearDate(dateString: String): LocalDate {
val year = dateString.substring(0, 4).toInt()
val month = dateString.substring(4, 6).toInt()
val day = dateString.substring(6, 8).toInt()
return LocalDate(year , month, fixDay(year, month, day))
}
open fun parseDate(dateString: String): LocalDate {
try {
var year = dateString.substring(0, 2).toInt()
val month = dateString.substring(2, 4).toInt()
val day = dateString.substring(4, 6).toInt()
/**
* Bei 6-stelligen Datumsangaben (d.h. JJMMTT) wird gemäß SWIFT zwischen dem 20. und 21.
* Jahrhundert wie folgt unterschieden:
* - Ist das Jahr (d.h. JJ) größer als 79, bezieht sich das Datum auf das 20. Jahrhundert. Ist
* das Jahr 79 oder kleiner, bezieht sich das Datum auf das 21. Jahrhundert.
* - Ist JJ > 79:JJMMTT = 19JJMMTT
* - sonst: JJMMTT = 20JJMMTT
* - Damit reicht die Spanne des sechsstelligen Datums von 1980 bis 2079.
*/
if (year > 79) {
year += 1900
} else {
year += 2000
}
return LocalDate(year , month, fixDay(year, month, day))
} catch (e: Throwable) {
logError("Could not parse dateString '$dateString'", e)
throw e
}
}
private fun fixDay(year: Int, month: Int, day: Int): Int {
// ah, here we go, banks (in Germany) calculate with 30 days each month, so yes, it can happen that dates
// like 30th of February or 29th of February in non-leap years occur, see:
// https://de.m.wikipedia.org/wiki/30._Februar#30._Februar_in_der_Zinsberechnung
if (month == 2 && (day > 29 || (day > 28 && year % 4 != 0))) { // fix that for banks each month has 30 days
return 28
}
return day
}
open fun parseTime(timeString: String): LocalTime {
val hour = timeString.substring(0, 2).toInt()
val minute = timeString.substring(2, 4).toInt()
return LocalTime(hour, minute)
}
open fun parseDateTime(dateTimeString: String): Instant {
val date = parseDate(dateTimeString.substring(0, 6))
val time = parseTime(dateTimeString.substring(6, 10))
val dateTime = LocalDateTime(date, time)
return if (dateTimeString.length == 15) { // actually mandatory, but by far not always stated: the time zone
val plus = dateTimeString[10] == '+'
val timeDifference = parseTime(dateTimeString.substring(11))
dateTime.toInstant(UtcOffset(if (plus) timeDifference.hour else timeDifference.hour * -1, timeDifference.minute))
} else { // we then assume the server states the DateTime in FinTS's default time zone, Europe/Berlin
dateTime.toInstant(TimeZone.EuropeBerlin)
}
}
protected open fun parseAmount(amountString: String): Amount {
return Amount(amountString)
}
protected open fun logError(message: String, e: Throwable?) {
logAppender?.logError(Mt94xParserBase::class, message, e)
?: run {
log.error(e) { message }
}
}
}

View File

@ -0,0 +1,21 @@
package net.codinux.banking.fints.transactions.swift.model
enum class ContinuationIndicator(internal val mtValue: String) {
/**
* The only page
*/
SinglePage("ONLY"),
/**
* Intermediate page, more pages follow
*/
IntermediatePage("MORE"),
/**
* Last page
*/
LastPage("LAST"),
Unknown("NotAMtValue")
}

View File

@ -0,0 +1,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
)

View File

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

View File

@ -0,0 +1,67 @@
package net.codinux.banking.fints.transactions.swift.model
class SwiftMessageBlock(
initialFields: List<Pair<String, String>>? = null
) {
private val fields = LinkedHashMap<String, MutableList<String>>()
private val fieldsInOrder = mutableListOf<Pair<String, String>>()
val hasFields: Boolean
get() = fields.isNotEmpty()
val fieldCodes: Collection<String>
get() = fields.keys
init {
initialFields?.forEach { (fieldCode, fieldValue) ->
addField(fieldCode, fieldValue)
}
}
fun addField(fieldCode: String, fieldValueLines: List<String>, rememberOrderOfFields: Boolean = false) {
val fieldValue = fieldValueLines.joinToString("\n")
addField(fieldCode, fieldValue, rememberOrderOfFields)
}
fun addField(fieldCode: String, fieldValue: String, rememberOrderOfFields: Boolean = false) {
fields.getOrPut(fieldCode) { mutableListOf() }.add(fieldValue)
if (rememberOrderOfFields) {
fieldsInOrder.add(Pair(fieldCode, fieldValue))
}
}
fun getFieldsInOrder(): List<Pair<String, String>> = fieldsInOrder.toList() // make a copy
fun getMandatoryField(fieldCode: String): String =
getMandatoryFieldValue(fieldCode).first()
fun getOptionalField(fieldCode: String): String? =
getOptionalFieldValue(fieldCode)?.first()
fun getMandatoryRepeatableField(fieldCode: String): List<String> =
getMandatoryFieldValue(fieldCode)
fun getOptionalRepeatableField(fieldCode: String): List<String>? =
getOptionalFieldValue(fieldCode)
private fun getMandatoryFieldValue(fieldCode: String): List<String> =
fields[fieldCode] ?: fields.entries.firstOrNull { it.key.startsWith(fieldCode) }?.value
?: throw IllegalStateException("Block contains no field with code '$fieldCode'. Available fields: ${fields.keys}")
private fun getOptionalFieldValue(fieldCode: String): List<String>? = fields[fieldCode]
override fun toString() =
if (fieldsInOrder.isNotEmpty()) {
fieldsInOrder.joinToString("\n")
} else {
fields.entries.joinToString("\n") { "${it.key}${it.value}" }
}
}

View File

@ -245,14 +245,14 @@ class Mt940ParserTest : MtParserTestBase() {
fun parseDate_FixSparkasse29thOFFebruaryInNonLeapYearBug() { fun parseDate_FixSparkasse29thOFFebruaryInNonLeapYearBug() {
val result = underTest.parseDate("230229") val result = underTest.parseDate("230229")
assertEquals(LocalDate(2023, 3, 1), result) assertEquals(LocalDate(2023, 2, 28), result)
} }
@Test @Test
fun parseDate_FixSparkasse30thOfFebruaryBug() { fun parseDate_FixSparkasse30thOfFebruaryBug() {
val result = underTest.parseDate("230229") val result = underTest.parseDate("230229")
assertEquals(LocalDate(2023, 3, 1), result) assertEquals(LocalDate(2023, 2, 28), result)
} }
} }

View File

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

View File

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

View File

@ -1,7 +1,9 @@
package net.codinux.banking.fints.transactions package net.codinux.banking.fints.transactions
import net.codinux.banking.fints.FinTsTestBaseJvm 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.mt940.Mt940Parser
import net.codinux.banking.fints.transactions.swift.MtParserBase
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -24,6 +26,28 @@ class Mt940ParserTestJvm : FinTsTestBaseJvm() {
// then // then
assertThat(result).hasSize(32) 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)
} }
} }