Implemented Mt940AccountTransactionsParser

This commit is contained in:
dankl 2019-10-07 00:16:35 +02:00 committed by dankito
parent 5329cc8418
commit 149097fe33
9 changed files with 884 additions and 0 deletions

View File

@ -0,0 +1,14 @@
package net.dankito.fints.model
open class AccountTransaction(
var referenceNumber: String = "",
var bezugsReferenceNumber: String? = null,
var accountIdentification: String = "",
var statementNumber: String = "",
var openingBalance: String = "",
var statement: String = "",
var accountOwner: String = "",
var closingBalance: String = ""
) {
}

View File

@ -0,0 +1,10 @@
package net.dankito.fints.transactions.mt940
import net.dankito.fints.transactions.mt940.model.AccountStatement
interface IAccountTransactionsParser {
fun parseTransactions(transactionsString: String): List<AccountStatement>
}

View File

@ -0,0 +1,398 @@
package net.dankito.fints.transactions.mt940
import net.dankito.fints.transactions.mt940.model.*
import org.slf4j.LoggerFactory
import java.math.BigDecimal
import java.text.SimpleDateFormat
import java.util.*
import java.util.regex.Pattern
/*
4.1. SWIFT Supported Characters
a until z
A until Z
0 until 9
/ ? : ( ) . , ' + { }
CR LF Space
Although part of the character set, the curly brackets are permitted as delimiters and cannot be used within the text of
usertouser messages.
Character is not permitted as the first character of the line.
None of lines include only Space.
*/
open class Mt940AccountTransactionsParser : IAccountTransactionsParser {
companion object {
val AccountStatementsSeparatorPattern = Pattern.compile("^\\s*-\\s*\$") // a line only with '-' and may other white space characters
val AccountStatementFieldSeparatorPattern = Pattern.compile(":\\d\\d\\w?:")
val TransactionReferenceNumberCode = "20"
val ReferenceReferenceNumberCode = "21"
val AccountIdentificationCode = "25"
val StatementNumberCode = "28C"
val OpeningBalanceCode = "60"
val TransactionTurnoverCode = "61"
val TransactionDetailsCode = "86"
val ClosingBalanceCode = "62"
val DateFormat = SimpleDateFormat("yyMMdd")
val CreditDebitCancellationPattern = Pattern.compile("C|D|RC|RD")
val AmountPattern = Pattern.compile("\\d+,\\d*")
val UsageTypePattern = Pattern.compile("\\w{4}\\+")
private val log = LoggerFactory.getLogger(Mt940AccountTransactionsParser::class.java)
}
override fun parseTransactions(transactionsString: String): List<AccountStatement> {
try {
val singleAccountStatementsStrings = splitIntoSingleAccountStatements(transactionsString)
return singleAccountStatementsStrings.mapNotNull { parseAccountStatement(it) }
} catch (e: Exception) {
log.error("Could not parse account transactions from string:\n$transactionsString", e)
}
return listOf()
}
protected open fun splitIntoSingleAccountStatements(transactionsString: String): List<String> {
val accountStatements = mutableListOf<String>()
val lines = transactionsString.split("\n")
var lastMatchedLine = 0
lines.forEachIndexed { index, line ->
if (line == "-") {
val test = AccountStatementsSeparatorPattern.matcher(line).matches() // TODO
accountStatements.add(lines.subList(lastMatchedLine, index).joinToString("\n"))
lastMatchedLine = index + 1
}
}
return accountStatements
}
protected open fun parseAccountStatement(accountStatementString: String): AccountStatement? {
try {
val fieldsByCode = splitIntoFields(accountStatementString)
return parseAccountStatement(fieldsByCode)
} catch (e: Exception) {
log.error("Could not parse account statement:\n$accountStatementString", e)
}
return null
}
protected open fun splitIntoFields(accountStatementString: String): List<Pair<String, String>> {
val matcher = AccountStatementFieldSeparatorPattern.matcher(accountStatementString)
val result = mutableListOf<Pair<String, String>>()
var lastMatchEnd = 0
var lastMatchedCode = ""
while (matcher.find()) {
if (lastMatchEnd > 0) {
val previousStatement = accountStatementString.substring(lastMatchEnd, matcher.start()).replace("\n", "")
result.add(Pair(lastMatchedCode, previousStatement))
}
lastMatchedCode = matcher.group().replace(":", "")
lastMatchEnd = matcher.end()
}
if (lastMatchEnd > 0) {
val previousStatement = accountStatementString.substring(lastMatchEnd, accountStatementString.length).replace("\n", "")
result.add(Pair(lastMatchedCode, previousStatement))
}
return result
}
protected open fun parseAccountStatement(fieldsByCode: List<Pair<String, String>>): AccountStatement? {
val statementAndMaySequenceNumber = getFieldValue(fieldsByCode, StatementNumberCode)
val accountIdentification = getFieldValue(fieldsByCode, AccountIdentificationCode)
val openingBalancePair = fieldsByCode.first { it.first.startsWith(OpeningBalanceCode) }
val closingBalancePair = fieldsByCode.first { it.first.startsWith(ClosingBalanceCode) }
return AccountStatement(
getFieldValue(fieldsByCode, TransactionReferenceNumberCode),
getOptionalFieldValue(fieldsByCode, ReferenceReferenceNumberCode),
parseBankCodeSwiftCodeOrIban(accountIdentification),
parseAccountNumber(accountIdentification),
parseStatementNumber(statementAndMaySequenceNumber),
parseSheetNumber(statementAndMaySequenceNumber),
parseBalance(openingBalancePair.first, openingBalancePair.second),
parseAccountStatementTransactions(fieldsByCode),
parseBalance(closingBalancePair.first, closingBalancePair.second)
)
}
protected open fun getFieldValue(fieldsByCode: List<Pair<String, String>>, code: String): String {
return fieldsByCode.first { it.first == code }.second
}
protected open fun getOptionalFieldValue(fieldsByCode: List<Pair<String, String>>, code: String): String? {
return fieldsByCode.firstOrNull { it.first == code }?.second
}
protected open fun parseBankCodeSwiftCodeOrIban(accountIdentification: String): String {
val parts = accountIdentification.split('/')
return parts[0]
}
protected open fun parseAccountNumber(accountIdentification: String): String? {
val parts = accountIdentification.split('/')
if (parts.size > 0) {
return parts[1]
}
return null
}
protected open fun parseStatementNumber(statementAndMaySheetNumber: String): Int {
val parts = statementAndMaySheetNumber.split('/')
// val isSupported = statementNumber != "00000"
return parts[0].toInt()
}
protected open fun parseSheetNumber(statementAndMaySheetNumber: String): Int? {
val parts = statementAndMaySheetNumber.split('/')
if (parts.size > 0) {
return parts[1].toInt()
}
return null
}
protected open fun parseBalance(code: String, fieldValue: String): Balance {
val isIntermediate = code.endsWith("M")
val isDebit = fieldValue.startsWith("D")
val bookingDateString = fieldValue.substring(1, 7)
val statementDate = parseDate(bookingDateString)
val currency = fieldValue.substring(7, 10)
val amountString = fieldValue.substring(10)
val amount = parseAmount(amountString)
return Balance(isIntermediate, !!!isDebit, statementDate, currency, amount)
}
protected open fun parseAccountStatementTransactions(fieldsByCode: List<Pair<String, String>>): List<Transaction> {
val transactions = mutableListOf<Transaction>()
fieldsByCode.forEachIndexed { index, pair ->
if (pair.first == TransactionTurnoverCode) {
val turnover = parseTurnover(pair.second)
val nextPair = if (index < fieldsByCode.size - 1) fieldsByCode.get(index + 1) else null
val details = if (nextPair?.first == TransactionDetailsCode) parseNullableTransactionDetails(nextPair?.second) else null
transactions.add(Transaction(turnover, details))
}
}
return transactions
}
protected open fun parseTurnover(fieldValue: String): Turnover {
val valueDateString = fieldValue.substring(0, 6)
val valueDate = parseDate(valueDateString)
val creditMarkMatcher = CreditDebitCancellationPattern.matcher(fieldValue)
creditMarkMatcher.find()
val isDebit = creditMarkMatcher.group().endsWith('D')
val isCancellation = creditMarkMatcher.group().startsWith('R')
val bookingDateString = if (creditMarkMatcher.start() > 6) fieldValue.substring(6, 10) else null
val bookingDate = bookingDateString?.let { // bookingDateString has format MMdd -> add year from valueDateString
parseDate(valueDateString.substring(0, 2) + bookingDateString)
}
val amountMatcher = AmountPattern.matcher(fieldValue)
amountMatcher.find()
val amountString = amountMatcher.group()
val amount = parseAmount(amountString)
val amountEndIndex = amountMatcher.end()
/**
* S SWIFT transfer For entries related to SWIFT transfer instructions and subsequent charge messages.
*
* N Non-SWIFT For entries related to payment and transfer instructions, including transfer related charges messages, not sent through SWIFT or where an alpha description is preferred.
*
* F First advice For entries being first advised by the statement (items originated by the account servicing institution).
*/
val transactionType = fieldValue.substring(amountEndIndex, amountEndIndex + 1) // transaction type is 'N', 'S' or 'F'
val bookingKeyStart = amountEndIndex + 1
val bookingKey = fieldValue.substring(bookingKeyStart, bookingKeyStart + 3) // TODO: parse codes, p. 178
var customerReference = fieldValue.substring(bookingKeyStart + 3)
var bankReference: String? = null
if (customerReference.contains("//")) {
val indexOfDoubleSlash = customerReference.indexOf("//")
bankReference = customerReference.substring(indexOfDoubleSlash + 2)
customerReference = customerReference.substring(0, indexOfDoubleSlash)
}
return Turnover(!!!isDebit, isCancellation, valueDate, bookingDate, null, amount, bookingKey,
customerReference, bankReference)
}
protected open fun parseNullableTransactionDetails(fieldValue: String): TransactionDetails? {
try {
val details = parseTransactionDetails(fieldValue)
mapUsage(details)
return details
} catch (e: Exception) {
log.error("Could not parse transaction details from field value '$fieldValue'", e)
}
return null
}
private fun parseTransactionDetails(fieldValue: String): TransactionDetails {
val usage = StringBuilder("")
val otherPartyName = StringBuilder("")
var otherPartyBankCode: String? = null
var otherPartyAccountId: String? = null
var bookingText: String? = null
var primaNotaNumber: String? = null
var textKeySupplement: String? = null
fieldValue.replace("\r", "").replace("\n", "").substring(3).split('?').forEach { subField ->
if (subField.isNotEmpty()) {
val fieldCode = subField.substring(0, 2).toInt()
val fieldValue = subField.substring(2)
when (fieldCode) {
0 -> bookingText = fieldValue
10 -> primaNotaNumber = fieldValue
in 20..29 -> usage.append(fieldValue)
30 -> otherPartyBankCode = fieldValue
31 -> otherPartyAccountId = fieldValue
32, 33 -> otherPartyName.append(fieldValue)
34 -> textKeySupplement = fieldValue
in 60..63 -> usage.append(fieldValue)
}
}
}
val details = TransactionDetails(
usage.toString(), otherPartyName.toString(), otherPartyBankCode, otherPartyAccountId,
bookingText, primaNotaNumber, textKeySupplement
)
return details
}
/**
* Jeder Bezeichner [z.B. EREF+] muss am Anfang eines Subfeldes [z. B. ?21] stehen.
* Bei Längenüberschreitung wird im nachfolgenden Subfeld ohne Wiederholung des Bezeichners fortgesetzt. Bei Wechsel des Bezeichners ist ein neues Subfeld zu beginnen.
* Belegung in der nachfolgenden Reihenfolge, wenn vorhanden:
* EREF+[ Ende-zu-Ende Referenz ] (DD-AT10; CT-AT41 - Angabe verpflichtend; NOTPROVIDED wird nicht eingestellt.)
* KREF+[Kundenreferenz]
* MREF+[Mandatsreferenz] (DD-AT01 - Angabe verpflichtend)
* CRED+[Creditor Identifier] (DD-AT02 - Angabe verpflichtend bei SEPA-Lastschriften, nicht jedoch bei SEPA-Rücklastschriften)
* DEBT+[Originators Identification Code](CT-AT10- Angabe verpflichtend,)
* Entweder CRED oder DEBT
*
* optional zusätzlich zur Einstellung in Feld 61, Subfeld 9:
*
* COAM+ [Compensation Amount / Summe aus Auslagenersatz und Bearbeitungsprovision bei einer nationalen Rücklastschrift sowie optionalem Zinsausgleich.]
* OAMT+[Original Amount] Betrag der ursprünglichen Lastschrift
*
* SVWZ+[SEPA-Verwendungszweck] (DD-AT22; CT-AT05 -Angabe verpflichtend, nicht jedoch bei R-Transaktionen)
* ABWA+[Abweichender Überweisender] (CT-AT08) / Abweichender Zahlungsempfänger (DD-AT38) ] (optional)
* ABWE+[Abweichender Zahlungsemp-fänger (CT-AT28) / Abweichender Zahlungspflichtiger ((DD-AT15)] (optional)
*
* Weitere 4 Verwendungszwecke können zu den Feldschlüsseln 60 bis 63 eingestellt werden.
*/
protected open fun mapUsage(details: TransactionDetails) {
val usageParts = getUsageParts(details)
usageParts.forEach { pair ->
setUsageLineValue(details, pair.first, pair.second)
}
}
private fun getUsageParts(details: TransactionDetails): MutableList<Pair<String, String>> {
val usage = details.usage
var previousMatchType = ""
var previousMatchEnd = 0
val usageParts = mutableListOf<Pair<String, String>>()
val matcher = UsageTypePattern.matcher(details.usage)
while (matcher.find()) {
if (previousMatchEnd > 0) {
val typeValue = usage.substring(previousMatchEnd, matcher.start())
usageParts.add(Pair(previousMatchType, typeValue))
}
previousMatchType = usage.substring(matcher.start(), matcher.end())
previousMatchEnd = matcher.end()
}
if (previousMatchEnd > 0) {
val typeValue = usage.substring(previousMatchEnd, usage.length)
usageParts.add(Pair(previousMatchType, typeValue))
}
return usageParts
}
protected open fun setUsageLineValue(details: TransactionDetails, usageType: String, typeValue: String) {
when (usageType) {
"EREF+" -> details.endToEndReference = typeValue
"KREF+" -> details.customerReference = typeValue
"MREF+" -> details.mandateReference = typeValue
"CRED+" -> details.creditorIdentifier = typeValue
"DEBT+" -> details.originatorsIdentificationCode = typeValue
"COAM+" -> details.compensationAmount = typeValue
"OAMT+" -> details.originalAmount = typeValue
"SVWZ+" -> details.sepaUsage = typeValue
"ABWA+" -> details.deviantOriginator = typeValue
"ABWE+" -> details.deviantRecipient = typeValue
else -> details.usageWithNoSpecialType = typeValue
}
}
protected open fun parseDate(dateString: String): Date {
return DateFormat.parse(dateString)
}
protected open fun parseAmount(amountString: String): BigDecimal {
return amountString.replace(',', '.').toBigDecimal()
}
}

View File

@ -0,0 +1,91 @@
package net.dankito.fints.transactions.mt940.model
open class AccountStatement(
/**
* Referenznummer, die vom Sender als eindeutige Kennung für die Nachricht vergeben wurde
* (z.B. als Referenz auf stornierte Nachrichten).
*
* Die Referenz darf nicht mit "/" starten oder enden; darf nicht "//" enthalten
*
* Max length = 16
*/
val transactionReferenceNumber: String,
/**
* Bezugsreferenz oder NONREF.
*
* Die Referenz darf nicht mit "/" starten oder enden; darf nicht "//" enthalten
*
* Max length = 16
*/
val referenceReferenceNumber: String?,
/**
* xxxxxxxxxxx/Konto-Nr. oder yyyyyyyy/Konto-Nr.
* wobei xxxxxxxxxxx = S.W.I.F.T.-Code
* yyyyyyyy = Bankleitzahl
* Konto-Nr. = max. 23 Stellen (ggf. mit Währung)
*
* Zukünftig kann hier auch die IBAN angegeben werden.
*
* Max length = 35
*/
val bankCodeBicOrIban: String,
val accountNumber: String?,
/**
* Falls eine Auszugsnummer nicht unterstützt wird, ist 0 einzustellen.
*
* Max length = 5
*/
val statementNumber: Int,
/**
* / kommt nach statementNumber falls Blattnummer belegt.
*
* beginnend mit 1
*
* Max length = 5
*/
val sequenceNumber: Int?,
val openingBalance: Balance,
val transactions: List<Transaction>,
val closingBalance: Balance,
val currentValueBalance: Balance? = null,
val futureValueBalance: Balance? = null,
/**
* Mehrzweckfeld
*
* Es dürfen nur unstrukturierte Informationen eingestellt werden. Es dürfen keine Informationen,
* die auf einzelne Umsätze bezogen sind, eingestellt werden.
*
* Die Zeilen werden mit <CR><LF> getrennt.
*
* Max length = 65
*/
val multipurposeField: String? = null
) {
// for object deserializers
private constructor() : this("", "", "", null, 0, null, Balance(), listOf(), Balance())
val isStatementNumberSupported: Boolean
get() = statementNumber != 0
override fun toString(): String {
return closingBalance.toString()
}
}

View File

@ -0,0 +1,52 @@
package net.dankito.fints.transactions.mt940.model
import java.math.BigDecimal
import java.text.DateFormat
import java.util.*
open class Balance(
val isIntermediate: Boolean,
/**
* Soll/Haben-Kennung
*
* C = Credit (Habensaldo)
* D = Debit (Sollsaldo)
*
* Length = 1
*/
val isCredit: Boolean,
/**
* JJMMTT = Buchungsdatum des Saldos oder '0' beim ersten Auszug
*
* Max length = 6
*/
val bookingDate: Date,
/**
* Währungsschlüssel gem. ISO 4217
*
* Length = 3
*/
val currency: String,
/**
* Betrag
*
* Max Length = 15
*/
val amount: BigDecimal
) {
internal constructor() : this(false, false, Date(), "", 0.toBigDecimal()) // for object deserializers
override fun toString(): String {
return "${DateFormat.getDateInstance(DateFormat.MEDIUM).format(bookingDate)} ${if (isCredit) "+" else "-"}$amount $currency"
}
}

View File

@ -0,0 +1,15 @@
package net.dankito.fints.transactions.mt940.model
open class Transaction(
val turnover: Turnover,
val details: TransactionDetails? = null
) {
override fun toString(): String {
return "$turnover ($details)"
}
}

View File

@ -0,0 +1,41 @@
package net.dankito.fints.transactions.mt940.model
open class TransactionDetails(
val usage: String,
val otherPartyName: String,
val otherPartyBankCode: String?,
val otherPartyAccountId: String?,
val bookingText: String?,
val primaNotaNumber: String?,
val textKeySupplement: String?
) {
var endToEndReference: String? = null
var customerReference: String? = null
var mandateReference: String? = null
var creditorIdentifier: String? = null
var originatorsIdentificationCode: String? = null
var compensationAmount: String? = null
var originalAmount: String? = null
var sepaUsage: String? = null
var deviantOriginator: String? = null
var deviantRecipient: String? = null
var usageWithNoSpecialType: String? = null
override fun toString(): String {
return "$otherPartyName $usage"
}
}

View File

@ -0,0 +1,71 @@
package net.dankito.fints.transactions.mt940.model
import java.math.BigDecimal
import java.text.DateFormat
import java.util.*
open class Turnover(
/**
* Soll/Haben-Kennung
*
* C = Credit (Habensaldo)
* D = Debit (Sollsaldo)
* RC = Storno Haben
* RD = Storno Soll
*
* Max length = 2
*/
val isCredit: Boolean,
val isCancellation: Boolean,
/**
* Valuta (JJMMTT)
*
* Length = 6
*/
val valueDate: Date,
/**
* MMTT
*
* Length = 4
*/
val bookingDate: Date?,
/**
* dritte Stelle der Währungsbezeichnung, falls sie zur Unterscheidung notwendig ist
*
* Length = 1
*/
val currencyType: String?,
/**
* Codes see p. 177 bottom - 179
*
* After constant N
*
* Max length = 15
*/
val amount: BigDecimal,
/**
* in Kontowährung
*
* Length = 3
*/
val bookingKey: String,
var customerReference: String,
var bankReference: String?
) {
override fun toString(): String {
return "${DateFormat.getDateInstance(DateFormat.MEDIUM).format(valueDate)} ${if (isCredit) "+" else "-"}$amount"
}
}

View File

@ -0,0 +1,192 @@
package net.dankito.fints.transactions
import net.dankito.fints.transactions.mt940.Mt940AccountTransactionsParser
import net.dankito.fints.transactions.mt940.model.Balance
import net.dankito.fints.transactions.mt940.model.TransactionDetails
import net.dankito.fints.transactions.mt940.model.Turnover
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import java.math.BigDecimal
import java.text.SimpleDateFormat
import java.util.*
class Mt940AccountTransactionsParserTest {
companion object {
const val TestFilesFolderName = "test_files/"
const val TransactionsMt940FileRelativePath = TestFilesFolderName + "TransactionsMt940.txt"
const val BankCode = "12345678"
const val CustomerId = "0987654321"
const val Currency = "EUR"
val AccountStatement1PreviousStatementBookingDate = Date(88, 2, 26)
val AccountStatement1BookingDate = Date(88, 2, 27)
val AccountStatement1OpeningBalanceAmount = 12345.67.toBigDecimal()
val AccountStatement1Transaction1Amount = 1234.56.toBigDecimal()
val AccountStatement1Transaction1OtherPartyName = "Sender1"
val AccountStatement1Transaction1OtherPartyBankCode = "AAAADE12"
val AccountStatement1Transaction1OtherPartyAccountId = "DE99876543210987654321"
val AccountStatement1Transaction2Amount = 432.10.toBigDecimal()
val AccountStatement1Transaction2OtherPartyName = "Receiver2"
val AccountStatement1Transaction2OtherPartyBankCode = "BBBBDE56"
val AccountStatement1Transaction2OtherPartyAccountId = "DE77987654321234567890"
val AccountStatement1ClosingBalanceAmount = AccountStatement1OpeningBalanceAmount + AccountStatement1Transaction1Amount
val AccountStatement1With2TransactionsClosingBalanceAmount = AccountStatement1OpeningBalanceAmount + AccountStatement1Transaction1Amount - AccountStatement1Transaction2Amount
}
private val underTest = Mt940AccountTransactionsParser()
@Test
fun accountStatementWithSingleTransaction() {
// when
val result = underTest.parseTransactions(AccountStatementWithSingleTransaction)
// then
assertThat(result).hasSize(1)
val statement = result.first()
assertThat(statement.bankCodeBicOrIban).isEqualTo(BankCode)
assertThat(statement.accountNumber).isEqualTo(CustomerId)
assertBalance(statement.openingBalance, true, AccountStatement1PreviousStatementBookingDate, AccountStatement1OpeningBalanceAmount)
assertBalance(statement.closingBalance, true, AccountStatement1BookingDate, AccountStatement1ClosingBalanceAmount)
assertThat(statement.transactions).hasSize(1)
val transaction = statement.transactions.first()
assertTurnover(transaction.turnover, AccountStatement1BookingDate, AccountStatement1Transaction1Amount)
assertTransactionDetails(transaction.details, AccountStatement1Transaction1OtherPartyName,
AccountStatement1Transaction1OtherPartyBankCode, AccountStatement1Transaction1OtherPartyAccountId)
}
@Test
fun accountStatementWithTwoTransactions() {
// when
val result = underTest.parseTransactions(AccountStatementWithTwoTransactions)
// then
assertThat(result).hasSize(1)
val statement = result.first()
assertThat(statement.bankCodeBicOrIban).isEqualTo(BankCode)
assertThat(statement.accountNumber).isEqualTo(CustomerId)
assertBalance(statement.openingBalance, true, AccountStatement1PreviousStatementBookingDate, AccountStatement1OpeningBalanceAmount)
assertBalance(statement.closingBalance, true, AccountStatement1BookingDate, AccountStatement1With2TransactionsClosingBalanceAmount)
assertThat(statement.transactions).hasSize(2)
val firstTransaction = statement.transactions.first()
assertTurnover(firstTransaction.turnover, AccountStatement1BookingDate, AccountStatement1Transaction1Amount)
assertTransactionDetails(firstTransaction.details, AccountStatement1Transaction1OtherPartyName,
AccountStatement1Transaction1OtherPartyBankCode, AccountStatement1Transaction1OtherPartyAccountId)
val secondTransaction = statement.transactions[1]
assertTurnover(secondTransaction.turnover, AccountStatement1BookingDate, AccountStatement1Transaction2Amount, false)
assertTransactionDetails(secondTransaction.details, AccountStatement1Transaction2OtherPartyName,
AccountStatement1Transaction2OtherPartyBankCode, AccountStatement1Transaction2OtherPartyAccountId)
}
@Test
fun parseTransactions() {
// given
val fileStream = Mt940AccountTransactionsParserTest::class.java.classLoader.getResourceAsStream(TransactionsMt940FileRelativePath)
val transactionsString = fileStream.reader().readText()
// when
val result = underTest.parseTransactions(transactionsString)
// then
assertThat(result).hasSize(32)
}
private fun assertBalance(balance: Balance, isCredit: Boolean, bookingDate: Date, amount: BigDecimal) {
assertThat(balance.isCredit).isEqualTo(isCredit)
assertThat(balance.bookingDate).isEqualTo(bookingDate)
assertThat(balance.amount).isEqualTo(amount)
assertThat(balance.currency).isEqualTo(Currency)
}
private fun assertTurnover(turnover: Turnover, valueDate: Date, amount: BigDecimal, isCredit: Boolean = true,
bookingDate: Date? = valueDate) {
assertThat(turnover.isCredit).isEqualTo(isCredit)
assertThat(turnover.isCancellation).isFalse()
assertThat(turnover.valueDate).isEqualTo(valueDate)
assertThat(turnover.bookingDate).isEqualTo(bookingDate)
assertThat(turnover.amount).isEqualTo(amount)
}
private fun assertTransactionDetails(details: TransactionDetails?, otherPartyName: String,
otherPartyBankCode: String, otherPartyAccountId: String) {
assertThat(details).isNotNull()
assertThat(details?.otherPartyName).isEqualTo(otherPartyName)
assertThat(details?.otherPartyBankCode).isEqualTo(otherPartyBankCode)
assertThat(details?.otherPartyAccountId).isEqualTo(otherPartyAccountId)
}
private val AccountStatementWithSingleTransaction = """
:20:STARTUMSE
:25:$BankCode/$CustomerId
:28C:00000/001
:60F:C${convertDate(AccountStatement1PreviousStatementBookingDate)}EUR${convertAmount(AccountStatement1OpeningBalanceAmount)}
:61:${convertDate(AccountStatement1BookingDate)}${convertToShortBookingDate(AccountStatement1BookingDate)}CR${convertAmount(AccountStatement1Transaction1Amount)}N062NONREF
:86:166?00GUTSCHR. UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/
EUR ${convertAmount(AccountStatement1Transaction1Amount)}/20?2219-10-02/...?30$AccountStatement1Transaction1OtherPartyBankCode?31$AccountStatement1Transaction1OtherPartyAccountId
?32$AccountStatement1Transaction1OtherPartyName
:62F:C${convertDate(AccountStatement1BookingDate)}EUR${convertAmount(AccountStatement1ClosingBalanceAmount)}
-
""".trimIndent()
private val AccountStatementWithTwoTransactions = """
:20:STARTUMSE
:25:$BankCode/$CustomerId
:28C:00000/001
:60F:C${convertDate(AccountStatement1PreviousStatementBookingDate)}EUR${convertAmount(AccountStatement1OpeningBalanceAmount)}
:61:${convertDate(AccountStatement1BookingDate)}${convertToShortBookingDate(AccountStatement1BookingDate)}CR${convertAmount(AccountStatement1Transaction1Amount)}N062NONREF
:86:166?00GUTSCHR. UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/
EUR ${convertAmount(AccountStatement1Transaction1Amount)}/20?2219-10-02/...?30$AccountStatement1Transaction1OtherPartyBankCode?31$AccountStatement1Transaction1OtherPartyAccountId
?32$AccountStatement1Transaction1OtherPartyName
:61:${convertDate(AccountStatement1BookingDate)}${convertToShortBookingDate(AccountStatement1BookingDate)}DR${convertAmount(AccountStatement1Transaction2Amount)}N062NONREF
:86:166?00ONLINE-UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/
EUR ${convertAmount(AccountStatement1Transaction2Amount)}/20?2219-10-02/...?30$AccountStatement1Transaction2OtherPartyBankCode?31$AccountStatement1Transaction2OtherPartyAccountId
?32$AccountStatement1Transaction2OtherPartyName
:62F:C${convertDate(AccountStatement1BookingDate)}EUR${convertAmount(AccountStatement1With2TransactionsClosingBalanceAmount)}
-
""".trimIndent()
private fun convertAmount(amount: BigDecimal): String {
return amount.toString().replace('.', ',')
}
private fun convertDate(date: Date): String {
return Mt940AccountTransactionsParser.DateFormat.format(date)
}
private fun convertToShortBookingDate(date: Date): String {
return SimpleDateFormat("MMdd").format(date)
}
}