Implemented Mt942Parser

This commit is contained in:
dankito 2024-09-10 23:16:12 +02:00
parent d1de7f5eb0
commit 90a7543641
6 changed files with 402 additions and 2 deletions

View File

@ -0,0 +1,86 @@
package net.codinux.banking.fints.transactions.mt940
import kotlinx.datetime.Instant
import net.codinux.banking.fints.log.IMessageLogAppender
import net.codinux.banking.fints.transactions.mt940.model.AmountAndCurrency
import net.codinux.banking.fints.transactions.mt940.model.InterimAccountStatement
import net.codinux.banking.fints.transactions.mt940.model.NumberOfPostingsAndAmount
import net.codinux.banking.fints.transactions.mt940.model.Transaction
open class Mt942Parser(
logAppender: IMessageLogAppender? = null
) : Mt94xParserBase<InterimAccountStatement>(logAppender) {
/**
* Parses a whole MT 942 statements string, that is one that ends with a "-" line.
*/
open fun parseMt942String(mt942String: String): List<InterimAccountStatement> =
super.parseMt94xString(mt942String)
/**
* Parses incomplete MT 942 statements string, that is ones that not end with a "-" line,
* as they are returned e.g. if a HKKAZ response is dispersed over multiple messages.
*
* Tries to parse all statements in the string except an incomplete last one and returns an
* incomplete last MT 942 statement (if any) as remainder.
*
* So each single HKKAZ partial response can be parsed immediately, its statements/transactions
* be displayed immediately to user and the remainder then be passed together with next partial
* HKKAZ response to this method till this whole MT 942 statement is parsed.
*/
open fun parseMt942Chunk(mt942Chunk: String): Pair<List<InterimAccountStatement>, String?> =
super.parseMt94xChunk(mt942Chunk)
override fun createAccountStatement(
orderReferenceNumber: String,
referenceNumber: String?,
bankCodeBicOrIban: String,
accountIdentifier: String?,
statementNumber: Int,
sheetNumber: Int?,
transactions: List<Transaction>,
fieldsByCode: List<Pair<String, String>>
): InterimAccountStatement {
val smallestAmounts = fieldsByCode.filter { it.first.startsWith(SmallestAmountCode) } // should we parse it? i see no use in it
.mapIndexed { index, field -> parseAmountAndCurrency(field.second, index == 0) }
val creationTime = fieldsByCode.first { it.first == CreationTimeCode || it.first.startsWith(CreationTimeStartCode) } // should we parse it? i see no use in it
val numberAndTotalOfDebitPostings = fieldsByCode.firstOrNull { it.first.equals(AmountOfDebitPostingsCode) }
?.let { parseNumberAndTotalOfPostings(it.second) }
val numberAndTotalOfCreditPostings = fieldsByCode.firstOrNull { it.first.equals(AmountOfCreditPostingsCode) }
?.let { parseNumberAndTotalOfPostings(it.second) }
return InterimAccountStatement(
orderReferenceNumber, referenceNumber,
bankCodeBicOrIban, accountIdentifier,
statementNumber, sheetNumber,
smallestAmounts.first(),
if (smallestAmounts.size > 1) smallestAmounts[1] else null,
Instant.DISTANT_PAST,
transactions,
numberAndTotalOfDebitPostings,
numberAndTotalOfCreditPostings
)
}
private fun parseAmountAndCurrency(fieldValue: String, isCreditCharOptional: Boolean = false): AmountAndCurrency {
val currency = fieldValue.substring(0, 3)
val hasCreditChar = isCreditCharOptional == false || fieldValue[3].isLetter()
val isCredit = if (hasCreditChar) fieldValue[3] == 'C' else false
val amount = fieldValue.substring(if (hasCreditChar) 4 else 3)
return AmountAndCurrency(amount, currency, isCredit)
}
protected open fun parseNumberAndTotalOfPostings(fieldValue: String): NumberOfPostingsAndAmount {
val currencyStartIndex = fieldValue.indexOfFirst { it.isLetter() }
val numberOfPostings = fieldValue.substring(0, currencyStartIndex).toInt()
val currency = fieldValue.substring(currencyStartIndex, currencyStartIndex + 3)
val amount = fieldValue.substring(currencyStartIndex + 3)
return NumberOfPostingsAndAmount(numberOfPostings, amount, currency)
}
}

View File

@ -41,15 +41,31 @@ abstract class Mt94xParserBase<T: AccountStatementCommon>(
const val StatementNumberCode = "28C" const val StatementNumberCode = "28C"
const val OpeningBalanceCode = "60"
const val StatementLineCode = "61" const val StatementLineCode = "61"
const val RemittanceInformationFieldCode = "86" const val RemittanceInformationFieldCode = "86"
// MT 940 codes
const val OpeningBalanceCode = "60"
const val ClosingBalanceCode = "62" const val ClosingBalanceCode = "62"
// MT 942 codes
const val SmallestAmountCode = "34F"
const val SmallestAmountStartCode = "34"
const val CreationTimeCode = "13D"
const val CreationTimeStartCode = "13" // Deutsche Bank and Sparkasse both use "13" instead of correct "13D"
const val AmountOfDebitPostingsCode = "90D"
const val AmountOfCreditPostingsCode = "90C"
val DateFormatter = DateFormatter("yyMMdd") // TODO: replace with LocalDate.Format { } val DateFormatter = DateFormatter("yyMMdd") // TODO: replace with LocalDate.Format { }
val CreditDebitCancellationRegex = Regex("C|D|RC|RD") val CreditDebitCancellationRegex = Regex("C|D|RC|RD")

View File

@ -0,0 +1,11 @@
package net.codinux.banking.fints.transactions.mt940.model
class AmountAndCurrency(
val amount: String,
val currency: String,
val isCredit: Boolean
) {
internal constructor() : this("not an amount", "not a currency", false) // for object deserializers
override fun toString() = "${if (isCredit == false) "-" else ""}$amount $currency"
}

View File

@ -0,0 +1,37 @@
package net.codinux.banking.fints.transactions.mt940.model
import kotlinx.datetime.Instant
open class InterimAccountStatement(
orderReferenceNumber: String,
referenceNumber: String?,
bankCodeBicOrIban: String,
accountIdentifier: String?,
statementNumber: Int,
sheetNumber: Int?,
val smallestAmountOfReportedTransactions: AmountAndCurrency,
val smallestAmountOfReportedCreditTransactions: AmountAndCurrency? = null,
val creationTime: Instant,
transactions: List<Transaction>,
val amountAndTotalOfDebitPostings: NumberOfPostingsAndAmount? = null,
val amountAndTotalOfCreditPostings: NumberOfPostingsAndAmount? = null,
) : AccountStatementCommon(orderReferenceNumber, referenceNumber, bankCodeBicOrIban, accountIdentifier, statementNumber, sheetNumber, transactions) {
// for object deserializers
private constructor() : this("", "", "", null, 0, null, AmountAndCurrency(), null, Instant.DISTANT_PAST, listOf())
override fun toString(): String {
return "$smallestAmountOfReportedTransactions ${super.toString()}"
}
}

View File

@ -0,0 +1,11 @@
package net.codinux.banking.fints.transactions.mt940.model
class NumberOfPostingsAndAmount(
val numberOfPostings: Int,
val amount: String,
val currency: String
) {
private constructor() : this(-1, "not an amount", "not a currency") // for object deserializers
override fun toString() = "$amount $currency, $numberOfPostings posting(s)"
}

View File

@ -0,0 +1,239 @@
package net.codinux.banking.fints.transactions
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.mt940.Mt942Parser
import net.codinux.banking.fints.transactions.mt940.model.InterimAccountStatement
import net.codinux.banking.fints.transactions.mt940.model.Transaction
import kotlin.test.Test
class Mt942ParserTest {
private val underTest = Mt942Parser()
@Test
fun parseNullValuesMt942String() {
// speciality of Deutsche Bank, it adds a MT942 if there are prebookings or not, so in most cases contains simply empty values
val mt942String = """
:20:DEUTDEFFXXXX
:25:70070024/01234560000
:28C:00000/001
:34F:EUR0,
:13:2408212359
:90D:0EUR0,
:90C:0EUR0,
-
:20:DEUTDEFFXXXX
:25:00000000/DE08700700240012345600
:28C:00000/001
:34F:EURC0,
:13D:2408210442+0200
:90D:0EUR0,
:90C:0EUR0,
-
""".trimIndent()
val result = underTest.parseMt942String(mt942String)
assertSize(2, result)
val firstStatement = result.first()
assertNullValuesStatement(firstStatement)
assertEquals("70070024", firstStatement.bankCodeBicOrIban)
assertEquals("01234560000", firstStatement.accountIdentifier)
val secondStatement = result[1]
assertNullValuesStatement(secondStatement)
assertEquals("00000000", secondStatement.bankCodeBicOrIban)
assertEquals("DE08700700240012345600", secondStatement.accountIdentifier)
}
@Test
fun parseDkExampleMt942String() {
// see
val mt942String = """
:20:1234567
:21:9876543210
:25:10020030/1234567
:28C:5/1
:34F:EURD20,50
:34F:EURC155,34
:13D:C1311130945+0000
:61:1311131113CR155,34NTRFNONREF//55555
:86:166?00SEPA-UEBERWEISUNG?109315
?20EREF+987654123456?21SVWZ+Invoice no.
123455056?22734 und 123455056735
?30COLSDE33XXX?31DE91370501980100558000
?32Max Mustermann
:61:1311131113DR20,50NDDTNONREF//55555
:86:105?00SEPA-BASIS-LASTSCHRIFT?109316
?20EREF+987654123497?21MREF+10023?22CRED+DE5
4ZZZ09999999999?23SVWZ+Insurance premium 2
?24013?30WELADED1MST?31DE87240501501234567890
?32XYZ Insurance limited?34991
:90D:1EUR20,50
:90C:1EUR155,34
-
""".trimIndent()
val result = underTest.parseMt942String(mt942String)
assertSize(1, result)
val statement = result.first()
assertEquals("1234567", statement.orderReferenceNumber)
assertEquals("9876543210", statement.referenceNumber)
assertEquals("10020030", statement.bankCodeBicOrIban)
assertEquals("1234567", statement.accountIdentifier)
assertEquals(5, statement.statementNumber)
assertEquals(1, statement.sheetNumber)
assertEquals("20,50", statement.smallestAmountOfReportedTransactions.amount)
assertEquals("EUR", statement.smallestAmountOfReportedTransactions.currency)
assertEquals(false, statement.smallestAmountOfReportedTransactions.isCredit)
assertEquals("155,34", statement.smallestAmountOfReportedCreditTransactions?.amount)
assertEquals("EUR", statement.smallestAmountOfReportedCreditTransactions?.currency)
assertEquals(true, statement.smallestAmountOfReportedCreditTransactions?.isCredit)
assertEquals(1, statement.amountAndTotalOfDebitPostings?.numberOfPostings)
assertEquals("20,50", statement.amountAndTotalOfDebitPostings?.amount)
assertEquals("EUR", statement.amountAndTotalOfDebitPostings?.currency)
assertEquals(1, statement.amountAndTotalOfCreditPostings?.numberOfPostings)
assertEquals("155,34", statement.amountAndTotalOfCreditPostings?.amount)
assertEquals("EUR", statement.amountAndTotalOfCreditPostings?.currency)
assertSize(2, statement.transactions)
val firstTransaction = statement.transactions.first()
assertTransactionStatementLine(firstTransaction, LocalDate(2013, 11, 13), LocalDate(2013, 11, 13), "155,34", true)
assertTransactionReference(firstTransaction, "SEPA-UEBERWEISUNG", "Max Mustermann", "COLSDE33XXX", "DE91370501980100558000", "Invoice no.123455056734 und 123455056735", "987654123456 ", null, null)
val secondTransaction = statement.transactions[1]
assertTransactionStatementLine(secondTransaction, LocalDate(2013, 11, 13), LocalDate(2013, 11, 13), "20,50", false)
assertTransactionReference(secondTransaction, "SEPA-BASIS-LASTSCHRIFT", "XYZ Insurance limited", "WELADED1MST", "DE87240501501234567890", "Insurance premium 2013", "987654123497 ", "10023 ", "DE54ZZZ09999999999 ")
}
@Test
fun parseSparkasseMt942String() {
val mt942String = """
:20:STARTDISPE
:25:70050000/0123456789
:28C:00000/001
:34F:EURD60,77
:13:2408232156
:61:2408260823DR60,77NDDTNONREF
:86:105?00FOLGELASTSCHRIFT?109248?20EREF+R0012345678?21MREF+M-K12
34567890-0001?22CRED+DE63ZZZ00000012345?23SVWZ+Rechnungsnr.. R001
2345?246789 - Kundennr.. K123456789?251?30HYVEDEMM406?31DE80765200
710123456789?32Dein Cloud Provider?34992
:90D:1EUR60,77
:90C:0EUR0,00
-
""".trimIndent()
val result = underTest.parseMt942String(mt942String)
assertSize(1, result)
val statement = result.first()
assertEquals("STARTDISPE", statement.orderReferenceNumber)
assertNull(statement.referenceNumber)
assertEquals("70050000", statement.bankCodeBicOrIban)
assertEquals("0123456789", statement.accountIdentifier)
assertEquals(0, statement.statementNumber)
assertEquals(1, statement.sheetNumber)
assertEquals("60,77", statement.smallestAmountOfReportedTransactions.amount)
assertEquals("EUR", statement.smallestAmountOfReportedTransactions.currency)
assertEquals(false, statement.smallestAmountOfReportedTransactions.isCredit)
assertNull(statement.smallestAmountOfReportedCreditTransactions)
assertEquals(1, statement.amountAndTotalOfDebitPostings?.numberOfPostings)
assertEquals("60,77", statement.amountAndTotalOfDebitPostings?.amount)
assertEquals("EUR", statement.amountAndTotalOfDebitPostings?.currency)
assertEquals(0, statement.amountAndTotalOfCreditPostings?.numberOfPostings)
assertEquals("0,00", statement.amountAndTotalOfCreditPostings?.amount)
assertEquals("EUR", statement.amountAndTotalOfCreditPostings?.currency)
assertSize(1, statement.transactions)
val transaction = statement.transactions.first()
assertTransactionStatementLine(transaction, LocalDate(2024, 8, 23), LocalDate(2024, 8, 26), "60,77", false)
assertTransactionReference(transaction, "FOLGELASTSCHRIFT", "Dein Cloud Provider", "HYVEDEMM406", "DE80765200710123456789",
"Rechnungsnr.. R00123456789 - Kundennr.. K1234567891", "R0012345678 ", "M-K1234567890-0001 ", "DE63ZZZ00000012345 ")
}
private fun assertNullValuesStatement(statement: InterimAccountStatement) {
assertEquals("DEUTDEFFXXXX", statement.orderReferenceNumber)
assertNull(statement.referenceNumber)
assertEquals(0, statement.statementNumber)
assertEquals(1, statement.sheetNumber)
assertEquals("0,", statement.smallestAmountOfReportedTransactions.amount)
assertEquals("EUR", statement.smallestAmountOfReportedTransactions.currency)
assertSize(0, statement.transactions)
assertEquals(0, statement.amountAndTotalOfDebitPostings?.numberOfPostings)
assertEquals("0,", statement.amountAndTotalOfDebitPostings?.amount)
assertEquals("EUR", statement.amountAndTotalOfDebitPostings?.currency)
assertEquals(0, statement.amountAndTotalOfCreditPostings?.numberOfPostings)
assertEquals("0,", statement.amountAndTotalOfCreditPostings?.amount)
assertEquals("EUR", statement.amountAndTotalOfCreditPostings?.currency)
}
private fun assertTransactionStatementLine(transaction: Transaction, bookingDate: LocalDate, valueDate: LocalDate, amount: String, isCredit: Boolean, isReversal: Boolean = false) {
assertEquals(bookingDate, transaction.statementLine.bookingDate)
assertEquals(valueDate, transaction.statementLine.valueDate)
assertEquals(amount, transaction.statementLine.amount.string)
assertEquals(isCredit, transaction.statementLine.isCredit)
assertEquals(isReversal, transaction.statementLine.isReversal)
}
private fun assertTransactionReference(transaction: Transaction,
postingText: String, otherPartyName: String?, otherPartyBankId: String?, otherPartyAccountId: String?,
sepaReference: String, endToEndReference: String? = null, mandateReference: String? = null, creditorIdentifier: String? = null
) {
assertEquals(postingText, transaction.information?.postingText)
assertEquals(otherPartyName, transaction.information?.otherPartyName)
assertEquals(otherPartyBankId, transaction.information?.otherPartyBankId)
assertEquals(otherPartyAccountId, transaction.information?.otherPartyAccountId)
assertEquals(sepaReference, transaction.information?.sepaReference)
assertEquals(endToEndReference, transaction.information?.endToEndReference)
assertEquals(mandateReference, transaction.information?.mandateReference)
assertEquals(creditorIdentifier, transaction.information?.creditorIdentifier)
}
}