Implemented Mt535Parser
This commit is contained in:
parent
fd9eadf45e
commit
95e60b2706
|
@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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")
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
|
@ -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}" }}"
|
||||||
|
}
|
|
@ -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}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -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()
|
||||||
|
|
||||||
|
}
|
|
@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
Loading…
Reference in New Issue