Implemented Mt535Parser
This commit is contained in:
parent
fd9eadf45e
commit
95e60b2706
|
@ -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<T: AccountStatementCommon>(
|
||||
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<T: AccountStatementCommon>(
|
|||
const val DeviantRecipientKey = "ABWE+"
|
||||
}
|
||||
|
||||
private val log by logger()
|
||||
|
||||
|
||||
protected abstract fun createAccountStatement(
|
||||
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.
|
||||
*/
|
||||
|
@ -516,40 +477,4 @@ abstract class Mt94xParserBase<T: AccountStatementCommon>(
|
|||
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() {
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue