Implemented Mt940AccountTransactionsParser
This commit is contained in:
parent
5329cc8418
commit
149097fe33
|
@ -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 = ""
|
||||||
|
) {
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
user‐to‐user 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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)"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue