Compare commits

...

13 Commits

43 changed files with 2428 additions and 825 deletions

View File

@ -202,6 +202,29 @@ open class FinTsJobExecutor(
var balance: Money? = balanceResponse?.getFirstSegmentById<BalanceSegment>(InstituteSegmentId.Balance)?.let {
Money(it.balance, it.currency)
}
// TODO: for larger portfolios there can be a Aufsetzpunkt, but for balances we currently do not support sending multiple messages
val statementOfHoldings = balanceResponse?.getFirstSegmentById<SecuritiesAccountBalanceSegment>(InstituteSegmentId.SecuritiesAccountBalance)?.let {
val statementOfHoldings = it.statementOfHoldings
val statementOfHolding = statementOfHoldings.firstOrNull { it.totalBalance != null }
if (statementOfHolding != null) {
balance = Money(statementOfHolding.totalBalance!!, statementOfHolding.currency ?: Currency.DefaultCurrencyCode)
}
statementOfHoldings
} ?: emptyList()
if (parameter.account.supportsRetrievingAccountTransactions == false) {
if (balanceResponse == null) {
return GetAccountTransactionsResponse(context, BankResponse(false, "Balance could not be retrieved"), RetrievedAccountData.unsuccessful(parameter.account))
} else {
val successful = balance != null || balanceResponse.tanRequiredButWeWereToldToAbortIfSo
val retrievedData = RetrievedAccountData(parameter.account, successful, balance, emptyList(), emptyList(), statementOfHoldings, Instant.nowExt(), null, null, balanceResponse?.internalError)
return GetAccountTransactionsResponse(context, balanceResponse, retrievedData)
}
}
val bookedTransactions = mutableSetOf<AccountTransaction>()
val unbookedTransactions = mutableSetOf<Any>()
@ -217,7 +240,7 @@ open class FinTsJobExecutor(
context.bank, parameter.account)
bookedTransactions.addAll(chunkTransaction)
remainingMt940String = remainder
remainingMt940String = remainder ?: ""
parameter.retrievedChunkListener?.invoke(bookedTransactions)
}
@ -236,10 +259,11 @@ open class FinTsJobExecutor(
val successful = response.tanRequiredButWeWereToldToAbortIfSo
|| (response.successful && (parameter.alsoRetrieveBalance == false || balance != null))
|| (parameter.account.supportsRetrievingAccountTransactions == false && balance != null)
val fromDate = parameter.fromDate
?: parameter.account.serverTransactionsRetentionDays?.let { LocalDate.todayAtSystemDefaultTimeZone().minusDays(it) }
?: bookedTransactions.minByOrNull { it.valueDate }?.valueDate
val retrievedData = RetrievedAccountData(parameter.account, successful, balance, bookedTransactions, unbookedTransactions, startTime, fromDate, parameter.toDate ?: LocalDate.todayAtEuropeBerlin(), response.internalError)
val retrievedData = RetrievedAccountData(parameter.account, successful, balance, bookedTransactions, unbookedTransactions, statementOfHoldings, startTime, fromDate, parameter.toDate ?: LocalDate.todayAtEuropeBerlin(), response.internalError)
return GetAccountTransactionsResponse(context, response, retrievedData,
if (parameter.maxCountEntries != null) parameter.isSettingMaxCountEntriesAllowedByBank else null)

View File

@ -5,6 +5,6 @@ import kotlin.reflect.KClass
interface IMessageLogAppender {
fun logError(loggingClass: KClass<*>, message: String, e: Exception? = null)
fun logError(loggingClass: KClass<*>, message: String, e: Throwable? = null)
}

View File

@ -69,7 +69,7 @@ open class MessageLogCollector(
addMessageLogEntry(type, context, messageTrace, prettyPrintMessage, null, parsedSegments)
}
open fun logError(loggingClass: KClass<*>, message: String, context: MessageContext, e: Exception? = null) {
open fun logError(loggingClass: KClass<*>, message: String, context: MessageContext, e: Throwable? = null) {
val type = MessageLogEntryType.Error
val messageTrace = createMessageTraceString(type, context)

View File

@ -1,12 +0,0 @@
package net.codinux.banking.fints.mapper
import kotlinx.datetime.LocalDate
/**
* Be aware that Java DateFormat is not thread safe!
*/
expect class DateFormatter constructor(pattern: String) {
fun parseDate(dateString: String): LocalDate?
}

View File

@ -92,10 +92,10 @@ open class FinTsModelMapper {
bankAccount.retrievedTransactionsFrom = accountTransactionsResponse.retrievedTransactionsFrom
}
val retrievalTime = if (retrieveTransactionsTo == null) accountTransactionsResponse.retrievalTime
else retrieveTransactionsTo.atTime(0, 0).toInstant(TimeZone.EuropeBerlin)
if (bankAccount.lastTransactionsRetrievalTime == null || bankAccount.lastTransactionsRetrievalTime!! <= retrievalTime) { // if retrieveTransactionsTo is set it may is older than current account's lastTransactionsRetrievalTime
bankAccount.lastTransactionsRetrievalTime = retrievalTime
val retrievalTime = accountTransactionsResponse.retrievalTime
if (retrieveTransactionsTo == null && (bankAccount.lastAccountUpdateTime == null || bankAccount.lastAccountUpdateTime!! <= retrievalTime || // if retrieveTransactionsTo is set, then we don't retrieve all current transactions -> don't set lastAccountUpdateTime
(bankAccount.supportsRetrievingTransactions == false && accountTransactionsResponse.statementOfHoldings.isNotEmpty()))) { // TODO: really check for supportsRetrievingTransactions == false if statementOfHoldings are set? Are there really accounts that support HKWPD and HKKAZ?
bankAccount.lastAccountUpdateTime = retrievalTime
}
if (accountTransactionsResponse.bookedTransactions.isNotEmpty()) {
@ -103,6 +103,8 @@ open class FinTsModelMapper {
addAll(map(accountTransactionsResponse))
}
}
bankAccount.statementOfHoldings = accountTransactionsResponse.statementOfHoldings
}
}

View File

@ -13,6 +13,7 @@ import net.codinux.banking.fints.messages.segmente.Synchronisierung
import net.codinux.banking.fints.messages.segmente.id.CustomerSegmentId
import net.codinux.banking.fints.messages.segmente.id.ISegmentId
import net.codinux.banking.fints.messages.segmente.implementierte.*
import net.codinux.banking.fints.messages.segmente.implementierte.depot.Depotaufstellung
import net.codinux.banking.fints.messages.segmente.implementierte.sepa.SepaBankTransferBase
import net.codinux.banking.fints.messages.segmente.implementierte.tan.TanGeneratorListeAnzeigen
import net.codinux.banking.fints.messages.segmente.implementierte.tan.TanGeneratorTanMediumAnOderUmmelden
@ -242,17 +243,38 @@ open class MessageBuilder(protected val utils: FinTsUtils = FinTsUtils()) {
return createSignedMessageBuilderResult(context, MessageType.GetBalance, segments)
}
val securitiesAccountResult = supportsGetSecuritiesAccountBalance(account)
if (securitiesAccountResult.isJobVersionSupported) {
return createGetSecuritiesAccountBalanceMessage(context, result, account)
}
return result
}
protected open fun createGetSecuritiesAccountBalanceMessage(context: JobContext, result: MessageBuilderResult,
account: AccountData): MessageBuilderResult {
val segments = mutableListOf<Segment>(Depotaufstellung(SignedMessagePayloadFirstSegmentNumber, account))
addTanSegmentIfRequired(context, CustomerSegmentId.SecuritiesAccountBalance, segments, SignedMessagePayloadFirstSegmentNumber + 1)
return createSignedMessageBuilderResult(context, MessageType.GetSecuritiesAccountBalance, segments)
}
open fun supportsGetBalance(account: AccountData): Boolean {
return supportsGetBalanceMessage(account).isJobVersionSupported
|| supportsGetSecuritiesAccountBalance(account).isJobVersionSupported
}
protected open fun supportsGetBalanceMessage(account: AccountData): MessageBuilderResult {
return getSupportedVersionsOfJobForAccount(CustomerSegmentId.Balance, account, listOf(5, 6, 7, 8))
}
protected open fun supportsGetSecuritiesAccountBalance(account: AccountData): MessageBuilderResult {
return getSupportedVersionsOfJobForAccount(CustomerSegmentId.SecuritiesAccountBalance, account, listOf(6))
}
open fun createGetTanMediaListMessage(context: JobContext,
tanMediaKind: TanMedienArtVersion = TanMedienArtVersion.Alle,

View File

@ -27,6 +27,11 @@ enum class CustomerSegmentId(override val id: String) : ISegmentId {
SepaRealTimeTransfer("HKIPZ"),
SepaAccountInfoParameters("HKSPA") // not implemented, retrieved automatically with UPD
SepaAccountInfoParameters("HKSPA"), // not implemented, retrieved automatically with UPD
/* Wertpapierdepot */
SecuritiesAccountBalance("HKWPD")
}

View File

@ -0,0 +1,37 @@
package net.codinux.banking.fints.messages.segmente.implementierte.depot
import net.codinux.banking.fints.messages.Existenzstatus
import net.codinux.banking.fints.messages.datenelemente.implementierte.Aufsetzpunkt
import net.codinux.banking.fints.messages.datenelemente.implementierte.account.MaximaleAnzahlEintraege
import net.codinux.banking.fints.messages.datenelementgruppen.implementierte.Segmentkopf
import net.codinux.banking.fints.messages.datenelementgruppen.implementierte.account.Kontoverbindung
import net.codinux.banking.fints.messages.segmente.Segment
import net.codinux.banking.fints.messages.segmente.id.CustomerSegmentId
import net.codinux.banking.fints.model.AccountData
/**
* Nr. Name Version Typ Format Länge Status Anzahl Restriktionen
1 Segmentkopf 1 DEG M 1
2 Depot 3 DEG ktv # M 1
3 Währung der Depotaufstellung 1 DE cur # C 1 O: Währung der Depotaufstellung wählbar (BPD) = J; N: sonst
4 Kursqualität 2 DE code 1 C 1 1,2 O: Kursqualität wählbar (BPD) = J; N: sonst
5 Maximale Anzahl Einträge 1 DE num ..4 C 1 >0 O: Eingabe Anzahl Einträge erlaubt (BPD) = J; N: sonst
6 Aufsetzpunkt 1 DE an ..35 C 1 M: vom Institut wurde ein Aufsetzpunkt rückgemeldet N: sonst
*/
class Depotaufstellung(
segmentNumber: Int,
account: AccountData,
// parameter: GetAccountTransactionsParameter
): Segment(listOf(
Segmentkopf(CustomerSegmentId.SecuritiesAccountBalance, 6, segmentNumber),
Kontoverbindung(account),
// TODO:
// 3. Währung der Depotaufstellung
// 4. Kursqualität
// 5. Maximale Anzahl Einträge
// 6. Aufsetzpunkt
// MaximaleAnzahlEintraege(parameter), // TODO: this is wrong, it only works for HKKAZ
MaximaleAnzahlEintraege(null, Existenzstatus.Optional),
Aufsetzpunkt(null, Existenzstatus.Optional) // will be set dynamically, see MessageBuilder.rebuildMessageWithContinuationId(); M: vom Institut wurde ein Aufsetzpunkt rückgemeldet. N: sonst
))

View File

@ -3,7 +3,9 @@ package net.codinux.banking.fints.model
import kotlinx.serialization.Serializable
import net.dankito.banking.client.model.serializer.AmountSerializer
/**
* Be aware: The decimal separator is as specified by FinTS standard ',', not '.'.
*/
@Serializable(with = AmountSerializer::class)
open class Amount(
val string: String

View File

@ -82,7 +82,7 @@ open class JobContext(
messageLogCollector.addMessageLog(type, message, createMessageContext(), parsedSegments)
}
override fun logError(loggingClass: KClass<*>, message: String, e: Exception?) {
override fun logError(loggingClass: KClass<*>, message: String, e: Throwable?) {
messageLogCollector.logError(loggingClass, message, createMessageContext(), e)
}

View File

@ -25,6 +25,8 @@ enum class MessageType {
GetCreditCardTransactions,
GetSecuritiesAccountBalance,
TransferMoney
}

View File

@ -2,6 +2,7 @@ package net.codinux.banking.fints.model
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import net.codinux.banking.fints.transactions.swift.model.StatementOfHoldings
open class RetrievedAccountData(
@ -10,6 +11,7 @@ open class RetrievedAccountData(
open val balance: Money?,
open var bookedTransactions: Collection<AccountTransaction>,
open var unbookedTransactions: Collection<Any>,
open var statementOfHoldings: List<StatementOfHoldings>,
open val retrievalTime: Instant,
open val retrievedTransactionsFrom: LocalDate?,
open val retrievedTransactionsTo: LocalDate?,
@ -19,7 +21,7 @@ open class RetrievedAccountData(
companion object {
fun unsuccessful(account: AccountData): RetrievedAccountData {
return RetrievedAccountData(account, false, null, listOf(), listOf(), Instant.DISTANT_PAST, null, null)
return RetrievedAccountData(account, false, null, listOf(), listOf(), listOf(), Instant.DISTANT_PAST, null, null)
}
}

View File

@ -43,6 +43,11 @@ enum class InstituteSegmentId(override val id: String) : ISegmentId {
CreditCardTransactions("DIKKU"),
CreditCardTransactionsParameters(CreditCardTransactions.id + "S")
CreditCardTransactionsParameters(CreditCardTransactions.id + "S"),
/* Wertpapierdepot */
SecuritiesAccountBalance("HIWPD")
}

View File

@ -24,11 +24,13 @@ import net.codinux.banking.fints.model.Money
import net.codinux.banking.fints.response.segments.*
import net.codinux.banking.fints.util.MessageUtils
import net.codinux.banking.fints.extensions.getAllExceptionMessagesJoined
import net.codinux.banking.fints.transactions.swift.Mt535Parser
open class ResponseParser(
protected open val messageUtils: MessageUtils = MessageUtils(),
open var logAppender: IMessageLogAppender? = null
open var logAppender: IMessageLogAppender? = null,
open var mt535Parser: Mt535Parser = Mt535Parser(logAppender)
) {
companion object {
@ -116,6 +118,7 @@ open class ResponseParser(
InstituteSegmentId.ChangeTanMediaParameters.id -> parseChangeTanMediaParameters(segment, segmentId, dataElementGroups)
InstituteSegmentId.Balance.id -> parseBalanceSegment(segment, dataElementGroups)
InstituteSegmentId.SecuritiesAccountBalance.id -> parseSecuritiesAccountBalanceSegment(segment, dataElementGroups)
InstituteSegmentId.AccountTransactionsMt940.id -> parseMt940AccountTransactions(segment, dataElementGroups)
InstituteSegmentId.AccountTransactionsMt940Parameters.id -> parseMt940AccountTransactionsParameters(segment, segmentId, dataElementGroups)
@ -690,6 +693,17 @@ open class ResponseParser(
)
}
protected open fun parseSecuritiesAccountBalanceSegment(segment: String, dataElementGroups: List<String>): SecuritiesAccountBalanceSegment {
// 1 Segmentkopf 1 DEG M 1
// 2 Depotaufstellung 1 DE bin .. M 1
val balancesMt535String = extractBinaryData(dataElementGroups[1])
// TODO: for larger portfolios there can be a Aufsetzpunkt, but for balances we currently do not support sending multiple messages
val statementOfHoldings = mt535Parser.parseMt535String(balancesMt535String)
return SecuritiesAccountBalanceSegment(statementOfHoldings, segment)
}
protected open fun parseBalanceToNullIfZeroOrNotSet(dataElementGroup: String): Balance? {
if (dataElementGroup.isEmpty()) {
return null

View File

@ -0,0 +1,9 @@
package net.codinux.banking.fints.response.segments
import net.codinux.banking.fints.transactions.swift.model.StatementOfHoldings
class SecuritiesAccountBalanceSegment(
val statementOfHoldings: List<StatementOfHoldings>,
segmentString: String
)
: ReceivedSegment(segmentString)

View File

@ -13,6 +13,6 @@ interface IAccountTransactionsParser {
fun parseTransactions(transactionsString: String, bank: BankData, account: AccountData): List<AccountTransaction>
fun parseTransactionsChunk(transactionsChunk: String, bank: BankData, account: AccountData): Pair<List<AccountTransaction>, String>
fun parseTransactionsChunk(transactionsChunk: String, bank: BankData, account: AccountData): Pair<List<AccountTransaction>, String?>
}

View File

@ -25,7 +25,7 @@ open class Mt940AccountTransactionsParser(
return accountStatements.flatMap { mapToAccountTransactions(it, bank, account) }
}
override fun parseTransactionsChunk(transactionsChunk: String, bank: BankData, account: AccountData): Pair<List<AccountTransaction>, String> {
override fun parseTransactionsChunk(transactionsChunk: String, bank: BankData, account: AccountData): Pair<List<AccountTransaction>, String?> {
val (accountStatements, remainder) = mt940Parser.parseMt940Chunk(transactionsChunk)
return Pair(accountStatements.flatMap { mapToAccountTransactions(it, bank, account) }, remainder)

View File

@ -25,6 +25,6 @@ interface IMt940Parser {
* be displayed immediately to user and the remainder then be passed together with next partial
* HKKAZ response to this method till this whole MT 940 statement is parsed.
*/
fun parseMt940Chunk(mt940Chunk: String): Pair<List<AccountStatement>, String>
fun parseMt940Chunk(mt940Chunk: String): Pair<List<AccountStatement>, String?>
}

View File

@ -1,94 +1,21 @@
package net.codinux.banking.fints.transactions.mt940
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import net.codinux.log.logger
import net.codinux.banking.fints.extensions.todayAtEuropeBerlin
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.mapper.DateFormatter
/*
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 Mt940Parser(
override var logAppender: IMessageLogAppender? = null
) : IMt940Parser {
companion object {
val AccountStatementsSeparatorRegex = Regex("^\\s*-\\s*\$", RegexOption.MULTILINE) // a line only with '-' and may other white space characters
// (?<!T\d\d(:\d\d)?) to filter that date time with format (yyyy-MM-dd)Thh:mm:ss(:SSS) is considered to be a field identifier
val AccountStatementFieldSeparatorRegex = Regex("(?<!T\\d\\d(:\\d\\d)?):\\d\\d\\w?:")
const val OrderReferenceNumberCode = "20"
const val ReferenceNumberCode = "21"
const val AccountIdentificationCode = "25"
const val StatementNumberCode = "28C"
const val OpeningBalanceCode = "60"
const val StatementLineCode = "61"
const val RemittanceInformationFieldCode = "86"
const val ClosingBalanceCode = "62"
val DateFormatter = DateFormatter("yyMMdd") // TODO: replace with LocalDate.Format { }
val CurrentYearTwoDigit = LocalDate.todayAtEuropeBerlin().year
val CreditDebitCancellationRegex = Regex("C|D|RC|RD")
val AmountRegex = Regex("\\d+,\\d*")
val ReferenceTypeRegex = Regex("[A-Z]{4}\\+")
val RemittanceInformationSubFieldRegex = Regex("\\?\\d\\d")
const val EndToEndReferenceKey = "EREF+"
const val CustomerReferenceKey = "KREF+"
const val MandateReferenceKey = "MREF+"
const val CreditorIdentifierKey = "CRED+"
const val OriginatorsIdentificationCodeKey = "DEBT+"
const val CompensationAmountKey = "COAM+"
const val OriginalAmountKey = "OAMT+"
const val SepaReferenceKey = "SVWZ+"
const val DeviantOriginatorKey = "ABWA+"
const val DeviantRecipientKey = "ABWE+"
}
private val log by logger()
) : Mt94xParserBase<AccountStatement>(logAppender), IMt940Parser {
/**
* Parses a whole MT 940 statements string, that is one that ends with a "-" line.
*/
override fun parseMt940String(mt940String: String): List<AccountStatement> {
return parseMt940Chunk(mt940String).first
}
override fun parseMt940String(mt940String: String): List<AccountStatement> =
super.parseMt94xString(mt940String)
/**
* Parses incomplete MT 940 statements string, that is ones that not end with a "-" line,
* as the they are returned e.g. if a HKKAZ response is dispersed over multiple messages.
* 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 940 statement (if any) as remainder.
@ -97,415 +24,31 @@ open class Mt940Parser(
* be displayed immediately to user and the remainder then be passed together with next partial
* HKKAZ response to this method till this whole MT 940 statement is parsed.
*/
override fun parseMt940Chunk(mt940Chunk: String): Pair<List<AccountStatement>, String> {
try {
val singleAccountStatementsStrings = splitIntoSingleAccountStatements(mt940Chunk).toMutableList()
var remainder = ""
if (singleAccountStatementsStrings.isNotEmpty() && singleAccountStatementsStrings.last().isEmpty() == false) {
remainder = singleAccountStatementsStrings.removeAt(singleAccountStatementsStrings.lastIndex)
}
val transactions = singleAccountStatementsStrings.mapNotNull { parseAccountStatement(it) }
return Pair(transactions, remainder)
} catch (e: Exception) {
logError("Could not parse account statements from MT940 string:\n$mt940Chunk", e)
}
return Pair(listOf(), "")
}
override fun parseMt940Chunk(mt940Chunk: String): Pair<List<AccountStatement>, String?> =
super.parseMt94xChunk(mt940Chunk)
protected open fun splitIntoSingleAccountStatements(mt940String: String): List<String> {
return mt940String.split(AccountStatementsSeparatorRegex)
.map { it.replace("\n", "").replace("\r", "") }
}
protected open fun parseAccountStatement(accountStatementString: String): AccountStatement? {
if (accountStatementString.isBlank()) {
return null
}
try {
val fieldsByCode = splitIntoFields(accountStatementString)
return parseAccountStatement(fieldsByCode)
} catch (e: Exception) {
logError("Could not parse account statement:\n$accountStatementString", e)
}
return null
}
protected open fun splitIntoFields(accountStatementString: String): List<Pair<String, String>> {
val result = mutableListOf<Pair<String, String>>()
var lastMatchEnd = 0
var lastMatchedCode = ""
AccountStatementFieldSeparatorRegex.findAll(accountStatementString).forEach { matchResult ->
if (lastMatchEnd > 0) {
val previousStatement = accountStatementString.substring(lastMatchEnd, matchResult.range.first)
result.add(Pair(lastMatchedCode, previousStatement))
}
lastMatchedCode = matchResult.value.replace(":", "")
lastMatchEnd = matchResult.range.last + 1
}
if (lastMatchEnd > 0) {
val previousStatement = accountStatementString.substring(lastMatchEnd, accountStatementString.length)
result.add(Pair(lastMatchedCode, previousStatement))
}
return result
}
protected open fun parseAccountStatement(fieldsByCode: List<Pair<String, String>>): AccountStatement? {
val statementAndMaySequenceNumber = getFieldValue(fieldsByCode, StatementNumberCode).split('/')
val accountIdentification = getFieldValue(fieldsByCode, AccountIdentificationCode).split('/')
override fun createAccountStatement(
orderReferenceNumber: String,
referenceNumber: String?,
bankCodeBicOrIban: String,
accountIdentifier: String?,
statementNumber: Int,
sheetNumber: Int?,
transactions: List<Transaction>,
fieldsByCode: List<Pair<String, String>>
): AccountStatement {
val openingBalancePair = fieldsByCode.first { it.first.startsWith(OpeningBalanceCode) }
val closingBalancePair = fieldsByCode.first { it.first.startsWith(ClosingBalanceCode) }
return AccountStatement(
getFieldValue(fieldsByCode, OrderReferenceNumberCode),
getOptionalFieldValue(fieldsByCode, ReferenceNumberCode),
accountIdentification[0],
if (accountIdentification.size > 1) accountIdentification[1] else null,
statementAndMaySequenceNumber[0].toInt(),
if (statementAndMaySequenceNumber.size > 1) statementAndMaySequenceNumber[1].toInt() else null,
orderReferenceNumber, referenceNumber,
bankCodeBicOrIban, accountIdentifier,
statementNumber, sheetNumber,
parseBalance(openingBalancePair.first, openingBalancePair.second),
parseAccountStatementTransactions(fieldsByCode),
transactions,
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 parseBalance(code: String, fieldValue: String): Balance {
val isIntermediate = code.endsWith("M")
val isDebit = fieldValue.startsWith("D")
val bookingDateString = fieldValue.substring(1, 7)
val statementDate = parseMt940Date(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 == StatementLineCode) {
val statementLine = parseStatementLine(pair.second)
val nextPair = if (index < fieldsByCode.size - 1) fieldsByCode.get(index + 1) else null
val information = if (nextPair?.first == RemittanceInformationFieldCode) parseNullableRemittanceInformationField(nextPair.second) else null
transactions.add(Transaction(statementLine, information))
}
}
return transactions
}
/**
* FORMAT
* 6!n[4!n]2a[1!a]15d1!a3!c16x[//16x]
* [34x]
*
* where subfields are:
* Subfield Format Name
* 1 6!n (Value Date)
* 2 [4!n] (Entry Date)
* 3 2a (Debit/Credit Mark)
* 4 [1!a] (Funds Code)
* 5 15d (Amount)
* 6 1!a3!c (Transaction Type)(Identification Code)
* 7 16x (Reference for the Account Owner)
* 8 [//16x] (Reference of the Account Servicing Institution)
* 9 [34x] (Supplementary Details)
*/
protected open fun parseStatementLine(fieldValue: String): StatementLine {
val valueDateString = fieldValue.substring(0, 6)
val valueDate = parseMt940Date(valueDateString)
val creditMarkMatchResult = CreditDebitCancellationRegex.find(fieldValue)
val isDebit = creditMarkMatchResult?.value?.endsWith('D') == true
val isCancellation = creditMarkMatchResult?.value?.startsWith('R') == true
val creditMarkEnd = (creditMarkMatchResult?.range?.last ?: -1) + 1
// booking date is the second field and is optional. It is normally only used when different from the value date.
val bookingDateString = if ((creditMarkMatchResult?.range?.start ?: 0) > 6) fieldValue.substring(6, 10) else null
val bookingDate = bookingDateString?.let { // bookingDateString has format MMdd -> add year from valueDateString
parseMt940BookingDate(bookingDateString, valueDateString, valueDate)
} ?: valueDate
val amountMatchResult = AmountRegex.find(fieldValue)!!
val amountString = amountMatchResult.value
val amount = parseAmount(amountString)
val amountEndIndex = amountMatchResult.range.last + 1
val fundsCode = if (amountMatchResult.range.start - creditMarkEnd > 1) fieldValue.substring(creditMarkEnd + 1, creditMarkEnd + 2) else null
/**
* 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 postingKeyStart = amountEndIndex + 1
val postingKey = fieldValue.substring(postingKeyStart, postingKeyStart + 3) // TODO: parse codes, p. 178
val customerAndBankReference = fieldValue.substring(postingKeyStart + 3).split("//")
val customerReference = customerAndBankReference[0].takeIf { it != "NONREF" }
/**
* The content of this subfield is the account servicing institution's own reference for the transaction.
* When the transaction has been initiated by the account servicing institution, this
* reference may be identical to subfield 7, Reference for the Account Owner. If this is
* the case, Reference of the Account Servicing Institution, subfield 8 may be omitted.
*/
var bankReference = if (customerAndBankReference.size > 1) customerAndBankReference[1] else null
var furtherInformation: String? = null
if (bankReference != null && bankReference.contains('\n')) {
val bankReferenceAndFurtherInformation = bankReference.split("\n")
bankReference = bankReferenceAndFurtherInformation[0].trim()
// TODO: parse /OCMT/ and /CHGS/, see page 518
furtherInformation = bankReferenceAndFurtherInformation[1].trim()
}
return StatementLine(!!!isDebit, isCancellation, valueDate, bookingDate, null, amount, postingKey,
customerReference, bankReference, furtherInformation)
}
protected open fun parseNullableRemittanceInformationField(remittanceInformationFieldString: String): RemittanceInformationField? {
try {
val information = parseRemittanceInformationField(remittanceInformationFieldString)
mapReference(information)
return information
} catch (e: Exception) {
logError("Could not parse RemittanceInformationField from field value '$remittanceInformationFieldString'", e)
}
return null
}
protected open fun parseRemittanceInformationField(remittanceInformationFieldString: String): RemittanceInformationField {
// e. g. starts with 0 -> Inlandszahlungsverkehr, starts with '3' -> Wertpapiergeschäft
// see Finanzdatenformate p. 209 - 215
val geschaeftsvorfallCode = remittanceInformationFieldString.substring(0, 2) // TODO: may map
val referenceParts = mutableListOf<String>()
val otherPartyName = StringBuilder()
var otherPartyBankId: String? = null
var otherPartyAccountId: String? = null
var bookingText: String? = null
var primaNotaNumber: String? = null
var textKeySupplement: String? = null
val subFieldMatches = RemittanceInformationSubFieldRegex.findAll(remittanceInformationFieldString).toList()
subFieldMatches.forEachIndexed { index, matchResult ->
val fieldCode = matchResult.value.substring(1, 3).toInt()
val endIndex = if (index + 1 < subFieldMatches.size) subFieldMatches[index + 1].range.start else remittanceInformationFieldString.length
val fieldValue = remittanceInformationFieldString.substring(matchResult.range.last + 1, endIndex)
when (fieldCode) {
0 -> bookingText = fieldValue
10 -> primaNotaNumber = fieldValue
in 20..29 -> referenceParts.add(fieldValue)
30 -> otherPartyBankId = fieldValue
31 -> otherPartyAccountId = fieldValue
32, 33 -> otherPartyName.append(fieldValue)
34 -> textKeySupplement = fieldValue
in 60..63 -> referenceParts.add(fieldValue)
}
}
val reference = if (isFormattedReference(referenceParts)) joinReferenceParts(referenceParts)
else referenceParts.joinToString(" ")
val otherPartyNameString = if (otherPartyName.isBlank()) null else otherPartyName.toString()
return RemittanceInformationField(
reference, otherPartyNameString, otherPartyBankId, otherPartyAccountId,
bookingText, primaNotaNumber, textKeySupplement
)
}
protected open fun joinReferenceParts(referenceParts: List<String>): String {
val reference = StringBuilder()
referenceParts.firstOrNull()?.let {
reference.append(it)
}
for (i in 1..referenceParts.size - 1) {
val part = referenceParts[i]
if (part.isNotEmpty() && part.first().isUpperCase() && referenceParts[i - 1].last().isUpperCase() == false) {
reference.append(" ")
}
reference.append(part)
}
return reference.toString()
}
protected open fun isFormattedReference(referenceParts: List<String>): Boolean {
return referenceParts.any { ReferenceTypeRegex.find(it) != null }
}
/**
* 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 mapReference(information: RemittanceInformationField) {
val referenceParts = getReferenceParts(information.unparsedReference)
referenceParts.forEach { entry ->
setReferenceLineValue(information, entry.key, entry.value)
}
}
open fun getReferenceParts(unparsedReference: String): Map<String, String> {
var previousMatchType = ""
var previousMatchEnd = 0
val referenceParts = mutableMapOf<String, String>()
ReferenceTypeRegex.findAll(unparsedReference).forEach { matchResult ->
if (previousMatchEnd > 0) {
val typeValue = unparsedReference.substring(previousMatchEnd, matchResult.range.first)
referenceParts[previousMatchType] = typeValue
}
previousMatchType = unparsedReference.substring(matchResult.range)
previousMatchEnd = matchResult.range.last + 1
}
if (previousMatchEnd > 0) {
val typeValue = unparsedReference.substring(previousMatchEnd, unparsedReference.length)
referenceParts[previousMatchType] = typeValue
}
return referenceParts
}
// TODO: there are more. See .pdf from Deutsche Bank
protected open fun setReferenceLineValue(information: RemittanceInformationField, referenceType: String, typeValue: String) {
when (referenceType) {
EndToEndReferenceKey -> information.endToEndReference = typeValue
CustomerReferenceKey -> information.customerReference = typeValue
MandateReferenceKey -> information.mandateReference = typeValue
CreditorIdentifierKey -> information.creditorIdentifier = typeValue
OriginatorsIdentificationCodeKey -> information.originatorsIdentificationCode = typeValue
CompensationAmountKey -> information.compensationAmount = typeValue
OriginalAmountKey -> information.originalAmount = typeValue
SepaReferenceKey -> information.sepaReference = typeValue
DeviantOriginatorKey -> information.deviantOriginator = typeValue
DeviantRecipientKey -> information.deviantRecipient = typeValue
else -> information.referenceWithNoSpecialType = typeValue
}
}
protected open fun parseMt940Date(dateString: String): LocalDate {
// TODO: this should be necessary anymore, isn't it?
// SimpleDateFormat is not thread-safe. Before adding another library i decided to parse
// this really simple date format on my own
if (dateString.length == 6) {
try {
var year = dateString.substring(0, 2).toInt() + 2000
val month = dateString.substring(2, 4).toInt()
val day = dateString.substring(4, 6).toInt()
if (year > CurrentYearTwoDigit + 1) { // should be rarely the case: years before 2000
year -= 100
}
// 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: Exception) {
logError("Could not parse dateString '$dateString'", e)
}
}
return DateFormatter.parseDate(dateString)!! // fallback to not thread-safe SimpleDateFormat. Works in most cases but not all
}
/**
* Booking date string consists only of MMDD -> we need to take the year from value date string.
*/
protected open fun parseMt940BookingDate(bookingDateString: String, valueDateString: String, valueDate: LocalDate): LocalDate {
val bookingDate = parseMt940Date(valueDateString.substring(0, 2) + bookingDateString)
// there are rare cases that booking date is e.g. on 31.12.2019 and value date on 01.01.2020 -> booking date would be on 31.12.2020 (and therefore in the future)
val bookingDateMonth = bookingDate.month
if (bookingDateMonth != valueDate.month && bookingDateMonth == Month.DECEMBER) {
return parseMt940Date("" + (valueDate.year - 1 - 2000) + bookingDateString)
}
return bookingDate
}
protected open fun parseAmount(amountString: String): Amount {
return Amount(amountString)
}
protected open fun logError(message: String, e: Exception?) {
logAppender?.let { logAppender ->
logAppender.logError(Mt940Parser::class, message, e)
}
?: run {
log.error(e) { message }
}
}
}

View File

@ -0,0 +1,85 @@
package net.codinux.banking.fints.transactions.mt940
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 {
// also decided against parsing smallest amounts, i don't think they ever going to be used
// 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) }
// decided against parsing creation time as there are so many non specification confirm time formats that parsing is likely to fail 'cause of this unused value
// val creationTime = parseDateTime(fieldsByCode.first { it.first == CreationTimeCode || it.first.startsWith(CreationTimeStartCode) }.second)
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,
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

@ -0,0 +1,480 @@
package net.codinux.banking.fints.transactions.mt940
import kotlinx.datetime.*
import net.codinux.banking.fints.log.IMessageLogAppender
import net.codinux.banking.fints.transactions.mt940.model.*
import net.codinux.banking.fints.transactions.swift.MtParserBase
/*
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.
*/
abstract class Mt94xParserBase<T: AccountStatementCommon>(
logAppender: IMessageLogAppender? = null
) : MtParserBase(logAppender) {
companion object {
val AccountStatementsSeparatorRegex = Regex("^\\s*-\\s*\$", RegexOption.MULTILINE) // a line only with '-' and may other white space characters
// (?<!T\d\d(:\d\d)?) to filter that date time with format (yyyy-MM-dd)Thh:mm:ss(:SSS) is considered to be a field identifier
val AccountStatementFieldSeparatorRegex = Regex("(?<!T\\d\\d(:\\d\\d)?):\\d\\d\\w?:")
const val OrderReferenceNumberCode = "20"
const val ReferenceNumberCode = "21"
const val AccountIdentificationCode = "25"
const val StatementNumberCode = "28C"
const val StatementLineCode = "61"
const val RemittanceInformationFieldCode = "86"
// MT 940 codes
const val OpeningBalanceCode = "60"
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 CreditDebitCancellationRegex = Regex("C|D|RC|RD")
val AmountRegex = Regex("\\d+,\\d*")
val ReferenceTypeRegex = Regex("[A-Z]{4}\\+")
val RemittanceInformationSubFieldRegex = Regex("\\?\\d\\d")
const val EndToEndReferenceKey = "EREF+"
const val CustomerReferenceKey = "KREF+"
const val MandateReferenceKey = "MREF+"
const val CreditorIdentifierKey = "CRED+"
const val OriginatorsIdentificationCodeKey = "DEBT+"
const val CompensationAmountKey = "COAM+"
const val OriginalAmountKey = "OAMT+"
const val SepaReferenceKey = "SVWZ+"
const val DeviantOriginatorKey = "ABWA+"
const val DeviantRecipientKey = "ABWE+"
}
protected abstract fun createAccountStatement(
orderReferenceNumber: String, referenceNumber: String?,
bankCodeBicOrIban: String, accountIdentifier: String?,
statementNumber: Int, sheetNumber: Int?,
transactions: List<Transaction>,
fieldsByCode: List<Pair<String, String>>
): T
/**
* Parses a whole MT 940 statements string, that is one that ends with a "-" line.
*/
protected open fun parseMt94xString(mt94xString: String): List<T> {
return parseMt94xChunk(mt94xString).first
}
/**
* Parses incomplete MT 940 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 940 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 940 statement is parsed.
*/
protected open fun parseMt94xChunk(mt94xChunk: String): Pair<List<T>, String?> {
try {
val singleAccountStatementsStrings = splitIntoSingleAccountStatements(mt94xChunk).toMutableList()
var remainder: String? = null
if (singleAccountStatementsStrings.isNotEmpty() && singleAccountStatementsStrings.last().isEmpty() == false) {
remainder = singleAccountStatementsStrings.removeAt(singleAccountStatementsStrings.lastIndex)
}
val transactions = singleAccountStatementsStrings.mapNotNull { parseAccountStatement(it) }
return Pair(transactions, remainder)
} catch (e: Exception) {
logError("Could not parse account statements from MT940 string:\n$mt94xChunk", e)
}
return Pair(listOf(), "")
}
protected open fun splitIntoSingleAccountStatements(mt940String: String): List<String> {
return mt940String.split(AccountStatementsSeparatorRegex)
.map { it.replace("\n", "").replace("\r", "") }
}
protected open fun parseAccountStatement(accountStatementString: String): T? {
if (accountStatementString.isBlank()) {
return null
}
try {
val fieldsByCode = splitIntoFields(accountStatementString)
return parseAccountStatement(fieldsByCode)
} catch (e: Exception) {
logError("Could not parse account statement:\n$accountStatementString", e)
}
return null
}
protected open fun splitIntoFields(accountStatementString: String): List<Pair<String, String>> {
val result = mutableListOf<Pair<String, String>>()
var lastMatchEnd = 0
var lastMatchedCode = ""
AccountStatementFieldSeparatorRegex.findAll(accountStatementString).forEach { matchResult ->
if (lastMatchEnd > 0) {
val previousStatement = accountStatementString.substring(lastMatchEnd, matchResult.range.first)
result.add(Pair(lastMatchedCode, previousStatement))
}
lastMatchedCode = matchResult.value.replace(":", "")
lastMatchEnd = matchResult.range.last + 1
}
if (lastMatchEnd > 0) {
val previousStatement = accountStatementString.substring(lastMatchEnd, accountStatementString.length)
result.add(Pair(lastMatchedCode, previousStatement))
}
return result
}
protected open fun parseAccountStatement(fieldsByCode: List<Pair<String, String>>): T? {
val orderReferenceNumber = getFieldValue(fieldsByCode, OrderReferenceNumberCode)
val referenceNumber = getOptionalFieldValue(fieldsByCode, ReferenceNumberCode)
val statementAndMaySequenceNumber = getFieldValue(fieldsByCode, StatementNumberCode).split('/')
val accountIdentification = getFieldValue(fieldsByCode, AccountIdentificationCode).split('/')
val transactions = parseAccountStatementTransactions(fieldsByCode)
return createAccountStatement(
orderReferenceNumber, referenceNumber,
accountIdentification[0], if (accountIdentification.size > 1) accountIdentification[1] else null,
statementAndMaySequenceNumber[0].toInt(), if (statementAndMaySequenceNumber.size > 1) statementAndMaySequenceNumber[1].toInt() else null,
transactions,
fieldsByCode
)
}
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 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 == StatementLineCode) {
val statementLine = parseStatementLine(pair.second)
val nextPair = if (index < fieldsByCode.size - 1) fieldsByCode.get(index + 1) else null
val information = if (nextPair?.first == RemittanceInformationFieldCode) parseNullableRemittanceInformationField(nextPair.second) else null
transactions.add(Transaction(statementLine, information))
}
}
return transactions
}
/**
* FORMAT
* 6!n[4!n]2a[1!a]15d1!a3!c16x[//16x]
* [34x]
*
* where subfields are:
* Subfield Format Name
* 1 6!n (Value Date)
* 2 [4!n] (Entry Date)
* 3 2a (Debit/Credit Mark)
* 4 [1!a] (Funds Code)
* 5 15d (Amount)
* 6 1!a3!c (Transaction Type)(Identification Code)
* 7 16x (Reference for the Account Owner)
* 8 [//16x] (Reference of the Account Servicing Institution)
* 9 [34x] (Supplementary Details)
*/
protected open fun parseStatementLine(fieldValue: String): StatementLine {
val valueDateString = fieldValue.substring(0, 6)
val valueDate = parseDate(valueDateString)
val creditMarkMatchResult = CreditDebitCancellationRegex.find(fieldValue)
val isDebit = creditMarkMatchResult?.value?.endsWith('D') == true
val isCancellation = creditMarkMatchResult?.value?.startsWith('R') == true
val creditMarkEnd = (creditMarkMatchResult?.range?.last ?: -1) + 1
// booking date is the second field and is optional. It is normally only used when different from the value date.
val bookingDateString = if ((creditMarkMatchResult?.range?.start ?: 0) > 6) fieldValue.substring(6, 10) else null
val bookingDate = bookingDateString?.let { // bookingDateString has format MMdd -> add year from valueDateString
parseMt940BookingDate(bookingDateString, valueDateString, valueDate)
} ?: valueDate
val amountMatchResult = AmountRegex.find(fieldValue)!!
val amountString = amountMatchResult.value
val amount = parseAmount(amountString)
val amountEndIndex = amountMatchResult.range.last + 1
val fundsCode = if (amountMatchResult.range.start - creditMarkEnd > 1) fieldValue.substring(creditMarkEnd + 1, creditMarkEnd + 2) else null
/**
* 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 postingKeyStart = amountEndIndex + 1
val postingKey = fieldValue.substring(postingKeyStart, postingKeyStart + 3) // TODO: parse codes, p. 178
val customerAndBankReference = fieldValue.substring(postingKeyStart + 3).split("//")
val customerReference = customerAndBankReference[0].takeIf { it != "NONREF" }
/**
* The content of this subfield is the account servicing institution's own reference for the transaction.
* When the transaction has been initiated by the account servicing institution, this
* reference may be identical to subfield 7, Reference for the Account Owner. If this is
* the case, Reference of the Account Servicing Institution, subfield 8 may be omitted.
*/
var bankReference = if (customerAndBankReference.size > 1) customerAndBankReference[1] else null
var furtherInformation: String? = null
if (bankReference != null && bankReference.contains('\n')) {
val bankReferenceAndFurtherInformation = bankReference.split("\n")
bankReference = bankReferenceAndFurtherInformation[0].trim()
// TODO: parse /OCMT/ and /CHGS/, see page 518
furtherInformation = bankReferenceAndFurtherInformation[1].trim()
}
return StatementLine(!!!isDebit, isCancellation, valueDate, bookingDate, null, amount, postingKey,
customerReference, bankReference, furtherInformation)
}
protected open fun parseNullableRemittanceInformationField(remittanceInformationFieldString: String): RemittanceInformationField? {
try {
val information = parseRemittanceInformationField(remittanceInformationFieldString)
mapReference(information)
return information
} catch (e: Exception) {
logError("Could not parse RemittanceInformationField from field value '$remittanceInformationFieldString'", e)
}
return null
}
protected open fun parseRemittanceInformationField(remittanceInformationFieldString: String): RemittanceInformationField {
// e. g. starts with 0 -> Inlandszahlungsverkehr, starts with '3' -> Wertpapiergeschäft
// see Finanzdatenformate p. 209 - 215
val geschaeftsvorfallCode = remittanceInformationFieldString.substring(0, 2) // TODO: may map
val referenceParts = mutableListOf<String>()
val otherPartyName = StringBuilder()
var otherPartyBankId: String? = null
var otherPartyAccountId: String? = null
var bookingText: String? = null
var primaNotaNumber: String? = null
var textKeySupplement: String? = null
val subFieldMatches = RemittanceInformationSubFieldRegex.findAll(remittanceInformationFieldString).toList()
subFieldMatches.forEachIndexed { index, matchResult ->
val fieldCode = matchResult.value.substring(1, 3).toInt()
val endIndex = if (index + 1 < subFieldMatches.size) subFieldMatches[index + 1].range.start else remittanceInformationFieldString.length
val fieldValue = remittanceInformationFieldString.substring(matchResult.range.last + 1, endIndex)
when (fieldCode) {
0 -> bookingText = fieldValue
10 -> primaNotaNumber = fieldValue
in 20..29 -> referenceParts.add(fieldValue)
30 -> otherPartyBankId = fieldValue
31 -> otherPartyAccountId = fieldValue
32, 33 -> otherPartyName.append(fieldValue)
34 -> textKeySupplement = fieldValue
in 60..63 -> referenceParts.add(fieldValue)
}
}
val reference = if (isFormattedReference(referenceParts)) joinReferenceParts(referenceParts)
else referenceParts.joinToString(" ")
val otherPartyNameString = if (otherPartyName.isBlank()) null else otherPartyName.toString()
return RemittanceInformationField(
reference, otherPartyNameString, otherPartyBankId, otherPartyAccountId,
bookingText, primaNotaNumber, textKeySupplement
)
}
protected open fun joinReferenceParts(referenceParts: List<String>): String {
val reference = StringBuilder()
referenceParts.firstOrNull()?.let {
reference.append(it)
}
for (i in 1..referenceParts.size - 1) {
val part = referenceParts[i]
if (part.isNotEmpty() && part.first().isUpperCase() && referenceParts[i - 1].last().isUpperCase() == false) {
reference.append(" ")
}
reference.append(part)
}
return reference.toString()
}
protected open fun isFormattedReference(referenceParts: List<String>): Boolean {
return referenceParts.any { ReferenceTypeRegex.find(it) != null }
}
/**
* 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 mapReference(information: RemittanceInformationField) {
val referenceParts = getReferenceParts(information.unparsedReference)
referenceParts.forEach { entry ->
setReferenceLineValue(information, entry.key, entry.value)
}
}
open fun getReferenceParts(unparsedReference: String): Map<String, String> {
var previousMatchType = ""
var previousMatchEnd = 0
val referenceParts = mutableMapOf<String, String>()
ReferenceTypeRegex.findAll(unparsedReference).forEach { matchResult ->
if (previousMatchEnd > 0) {
val typeValue = unparsedReference.substring(previousMatchEnd, matchResult.range.first)
referenceParts[previousMatchType] = typeValue
}
previousMatchType = unparsedReference.substring(matchResult.range)
previousMatchEnd = matchResult.range.last + 1
}
if (previousMatchEnd > 0) {
val typeValue = unparsedReference.substring(previousMatchEnd, unparsedReference.length)
referenceParts[previousMatchType] = typeValue
}
return referenceParts
}
// TODO: there are more. See .pdf from Deutsche Bank
protected open fun setReferenceLineValue(information: RemittanceInformationField, referenceType: String, typeValue: String) {
when (referenceType) {
EndToEndReferenceKey -> information.endToEndReference = typeValue
CustomerReferenceKey -> information.customerReference = typeValue
MandateReferenceKey -> information.mandateReference = typeValue
CreditorIdentifierKey -> information.creditorIdentifier = typeValue
OriginatorsIdentificationCodeKey -> information.originatorsIdentificationCode = typeValue
CompensationAmountKey -> information.compensationAmount = typeValue
OriginalAmountKey -> information.originalAmount = typeValue
SepaReferenceKey -> information.sepaReference = typeValue
DeviantOriginatorKey -> information.deviantOriginator = typeValue
DeviantRecipientKey -> information.deviantRecipient = typeValue
else -> information.referenceWithNoSpecialType = typeValue
}
}
/**
* Booking date string consists only of MMDD -> we need to take the year from value date string.
*/
protected open fun parseMt940BookingDate(bookingDateString: String, valueDateString: String, valueDate: LocalDate): LocalDate {
val bookingDate = parseDate(valueDateString.substring(0, 2) + bookingDateString)
// there are rare cases that booking date is e.g. on 31.12.2019 and value date on 01.01.2020 -> booking date would be on 31.12.2020 (and therefore in the future)
val bookingDateMonth = bookingDate.month
if (bookingDateMonth != valueDate.month && bookingDateMonth == Month.DECEMBER) {
return parseDate("" + (valueDate.year - 1 - 2000) + bookingDateString)
}
return bookingDate
}
}

View File

@ -1,60 +1,18 @@
package net.codinux.banking.fints.transactions.mt940.model
open class AccountStatement(
orderReferenceNumber: String,
referenceNumber: String?,
/**
* 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 orderReferenceNumber: String,
bankCodeBicOrIban: String,
accountIdentifier: String?,
/**
* Bezugsreferenz oder NONREF.
*
* Die Referenz darf nicht mit "/" starten oder enden; darf nicht "//" enthalten
*
* Max length = 16
*/
val referenceNumber: 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 accountIdentifier: 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 sheetNumber: Int?,
statementNumber: Int,
sheetNumber: Int?,
val openingBalance: Balance,
val transactions: List<Transaction>,
transactions: List<Transaction>,
val closingBalance: Balance,
@ -74,18 +32,14 @@ open class AccountStatement(
*/
val remittanceInformationField: String? = null
) {
) : AccountStatementCommon(orderReferenceNumber, referenceNumber, bankCodeBicOrIban, accountIdentifier, statementNumber, sheetNumber, transactions) {
// 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()
return "$closingBalance ${super.toString()}"
}
}

View File

@ -0,0 +1,70 @@
package net.codinux.banking.fints.transactions.mt940.model
open class AccountStatementCommon(
/**
* 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 orderReferenceNumber: String,
/**
* Bezugsreferenz oder NONREF.
*
* Die Referenz darf nicht mit "/" starten oder enden; darf nicht "//" enthalten
*
* Max length = 16
*/
val referenceNumber: 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 accountIdentifier: 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 sheetNumber: Int?,
val transactions: List<Transaction>,
) {
// for object deserializers
private constructor() : this("", "", "", null, 0, null, listOf())
val isStatementNumberSupported: Boolean
get() = statementNumber != 0
override fun toString(): String {
return "$bankCodeBicOrIban, ${transactions.size} transactions"
}
}

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,36 @@
package net.codinux.banking.fints.transactions.mt940.model
open class InterimAccountStatement(
orderReferenceNumber: String,
referenceNumber: String?,
bankCodeBicOrIban: String,
accountIdentifier: String?,
statementNumber: Int,
sheetNumber: Int?,
// decided against parsing them, see Mt942Parser
// 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, listOf())
override fun toString(): String {
return "${amountAndTotalOfDebitPostings?.amount} ${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,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)
}
}

View File

@ -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 }
}
}
}

View File

@ -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")
}

View File

@ -0,0 +1,37 @@
package net.codinux.banking.fints.transactions.swift.model
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import net.codinux.banking.fints.model.Amount
@Serializable
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
)

View File

@ -0,0 +1,36 @@
package net.codinux.banking.fints.transactions.swift.model
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
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)
*/
@Serializable
data class StatementOfHoldings(
val bankCode: String,
val accountIdentifier: String,
val holdings: List<Holding>,
val totalBalance: Amount? = null,
val currency: 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}" }}"
}

View File

@ -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}" }
}
}

View File

@ -5,6 +5,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import net.codinux.banking.fints.model.Currency
import net.codinux.banking.fints.model.Money
import net.codinux.banking.fints.transactions.swift.model.StatementOfHoldings
@Serializable
@ -36,10 +37,16 @@ open class BankAccount(
open var retrievedTransactionsFrom: LocalDate? = null
open var lastTransactionsRetrievalTime: Instant? = null
/**
* Gibt wider, wann zuletzt aktuelle Kontoumsätze, d.h. [net.dankito.banking.client.model.parameter.GetAccountDataParameter.retrieveTransactionsTo]
* war nicht gesetzt, oder aktuelle [StatementOfHoldings] empfangen wurden.
*/
open var lastAccountUpdateTime: Instant? = null
open var bookedTransactions: List<AccountTransaction> = listOf()
open var statementOfHoldings: List<StatementOfHoldings> = emptyList()
override fun toString(): String {
return "$productName ($identifier)"

View File

@ -1,46 +1,22 @@
package net.codinux.banking.fints.transactions
import net.codinux.banking.fints.FinTsTestBase
import net.codinux.banking.fints.transactions.mt940.Mt940Parser
import net.codinux.banking.fints.transactions.mt940.model.Balance
import net.codinux.banking.fints.transactions.mt940.model.RemittanceInformationField
import net.codinux.banking.fints.transactions.mt940.model.StatementLine
import kotlinx.datetime.LocalDate
import net.codinux.banking.fints.extensions.*
import net.codinux.banking.fints.model.Amount
import net.codinux.banking.fints.test.*
import net.codinux.banking.fints.test.assertEquals
import net.codinux.banking.fints.test.assertNotNull
import net.codinux.banking.fints.test.assertNull
import net.codinux.banking.fints.test.assertSize
import net.codinux.banking.fints.transactions.mt940.Mt940Parser
import net.codinux.banking.fints.transactions.swift.MtParserTestBase
import kotlin.test.Test
import kotlin.test.assertContains
class Mt940ParserTest : FinTsTestBase() {
companion object {
const val Currency = "EUR"
val AccountStatement1PreviousStatementBookingDate = LocalDate(1988, 2, 26)
val AccountStatement1BookingDate = LocalDate(1988, 2, 27)
val AccountStatement1OpeningBalanceAmount = Amount("12345,67")
val AccountStatement1Transaction1Amount = Amount("1234,56")
val AccountStatement1Transaction1OtherPartyName = "Sender1"
val AccountStatement1Transaction1OtherPartyBankId = "AAAADE12"
val AccountStatement1Transaction1OtherPartyAccountId = "DE99876543210987654321"
val AccountStatement1Transaction2Amount = Amount("432,10")
val AccountStatement1Transaction2OtherPartyName = "Receiver2"
val AccountStatement1Transaction2OtherPartyBankId = "BBBBDE56"
val AccountStatement1Transaction2OtherPartyAccountId = "DE77987654321234567890"
val AccountStatement1ClosingBalanceAmount = Amount("13580,23")
val AccountStatement1With2TransactionsClosingBalanceAmount = Amount("13148,13")
}
class Mt940ParserTest : MtParserTestBase() {
private val underTest = object : Mt940Parser() {
public override fun parseMt940Date(dateString: String): LocalDate {
return super.parseMt940Date(dateString)
public override fun parseDate(dateString: String): LocalDate {
return super.parseDate(dateString)
}
}
@ -106,13 +82,15 @@ class Mt940ParserTest : FinTsTestBase() {
fun accountStatementWithTwoTransactions() {
// when
val result = underTest.parseMt940String(AccountStatementWithTwoTransactions)
val (statements, remainder) = underTest.parseMt940Chunk(AccountStatementWithTwoTransactions)
// then
assertSize(1, result)
assertNull(remainder)
val statement = result.first()
assertSize(1, statements)
val statement = statements.first()
assertEquals(BankCode, statement.bankCodeBicOrIban)
assertEquals(CustomerId, statement.accountIdentifier)
@ -132,21 +110,37 @@ class Mt940ParserTest : FinTsTestBase() {
AccountStatement1Transaction2OtherPartyBankId, AccountStatement1Transaction2OtherPartyAccountId)
}
@Test
fun accountStatementWithPartialNextStatement() {
// when
val (statements, remainder) = underTest.parseMt940Chunk(AccountStatementWithSingleTransaction + "\r\n" + ":20:STARTUMSE")
// then
assertEquals(":20:STARTUMSE", remainder)
assertSize(1, statements)
val statement = statements.first()
assertEquals(BankCode, statement.bankCodeBicOrIban)
assertEquals(CustomerId, statement.accountIdentifier)
assertBalance(statement.openingBalance, true, AccountStatement1PreviousStatementBookingDate, AccountStatement1OpeningBalanceAmount)
assertBalance(statement.closingBalance, true, AccountStatement1BookingDate, AccountStatement1ClosingBalanceAmount)
assertSize(1, statement.transactions)
val transaction = statement.transactions.first()
assertTurnover(transaction.statementLine, AccountStatement1BookingDate, AccountStatement1Transaction1Amount)
assertTransactionDetails(transaction.information, AccountStatement1Transaction1OtherPartyName,
AccountStatement1Transaction1OtherPartyBankId, AccountStatement1Transaction1OtherPartyAccountId)
}
@Test
fun fixAnnualJumpFromBookingDateToValueDate() {
val transactionsString = ":20:STARTUMSE\n" +
":25:$BankCode/$CustomerId\n" +
":28C:00000/001\n" +
":60F:C191227EUR104501,86\n" +
":61:2001011230DR3,99N024NONREF\n" +
":86:809?00ENTGELTABSCHLUSS?106666?20Entgeltabrechnung?21siehe Anl\n" +
"age?30$BankCode\n" +
":61:2001011230CR0,00N066NONREF\n" +
":86:805?00ABSCHLUSS?106666?20Abrechnung 30.12.2019?21siehe Anlage\n" +
"?30$BankCode\n" +
":62F:C191230EUR104490,88\n" +
"-"
val transactionsString = AccountStatementWithAnnualJumpFromBookingDateToValueDate
// when
@ -168,57 +162,10 @@ class Mt940ParserTest : FinTsTestBase() {
}
@Test
fun fixLineStartsWithDashButIsNotASstatementSeparator() {
fun fixLineStartsWithDashThatIsNotAStatementSeparator() {
// given
val transactionsString = "\n" +
":20:MT940-2005200849\n" +
":21:NONREF\n" +
":25:20041111/369300900EUR\n" +
":28C:0/1\n" +
":60F:C200512EUR0,00\n" +
":61:2005120512CR100,00NMSCNONREF//POS 7\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21EROEFFNUNGSBETRAG?22END-TO-END-REF\n" +
".:?23NICHT ANGEGEBEN?24Ref. HW220133C3232360/15499?32DAN\n" +
"NKITO\n" +
":62M:C200513EUR100,00\n" +
"-\n" +
":20:MT940-2005200849\n" +
":21:NONREF\n" +
":25:20041111/369300900EUR\n" +
":28C:0/2\n" +
":60M:C200513EUR100,00\n" +
":61:2005130513CR0,10NMSCNONREF//POS 6\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21TEST?22END-TO-END-REF.:?23NICHT AN\n" +
"GEGEBEN?24Ref. 7T2C0YTD0BZL4V9S/1?32DANKITO\n" +
":61:2005130513CR0,15NMSCNONREF//POS 5\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21ECHTZEITUEBERWEISUNGSTEST?22END-TO\n" +
"-END-REF.:?23NICHT ANGEGEBEN?24Ref. 402C0YTD0GLPFDFV/1?32DANKI\n" +
"TO\n" +
":61:2005130513CR0,30NMSCNONREF//POS 4\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21UND NOCH EIN TEST FUER JAVA?22FX?2\n" +
"3END-TO-END-REF.:?24NICHT ANGEGEBEN?25Ref. 5D2C0YTD0HVAB3X3/1?32D\n" +
"ANKITO\n" +
":61:2005130513CR0,10NMSCNONREF//POS 3\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21LASS DIE KOHLE RUEBER WACHS?22EN?2\n" +
"3END-TO-END-REF.:?24NICHT ANGEGEBEN?25Ref. J3220134C3451151/6200?\n" +
"32DANKITO\n" +
":61:2005130513CR0,01NMSCNONREF//POS 2\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21TEST?22END-TO-END-REF.:?23NICHT AN\n" +
"GEGEBEN?24Ref. J3220134C3451151/6201?32DANKITO\n" +
":62M:C200514EUR100,66\n" +
"-\n" +
":20:MT940-2005200849\n" +
":21:NONREF\n" +
":25:20041111/369300900EUR\n" +
":28C:0/3\n" +
":60M:C200514EUR100,66\n" +
":61:2005140514DR0,01NMSCNONREF//POS 1\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21END-TO-END-REF.:?22NICHT ANGEGEBEN\n" +
"?23Ref. J022013510234936/2?30ABCDEFGHIJK?31DE1112345679876543210\n" +
"?32DANKITO\n" +
":62F:C200520EUR100,65\n" +
"-"
val transactionsString = AccountStatementWithLineStartsWithDashThatIsNotABlockSeparator
// when
@ -234,30 +181,7 @@ class Mt940ParserTest : FinTsTestBase() {
fun fixThatTimeGotDetectedAsFieldCode() {
// given
val transactionsString = "\n" +
":20:STARTUMS\n" +
":25:$BankCode/$CustomerId\n" +
":28C:0\n" +
":60F:D200514EUR15,00\n" +
":61:200514D0,02NMSCKREF+\n" +
":86:177?00SEPA Überweisung?10804?20KREF+2020-05-14T00:58:23:09\n" +
"?2193 ?22SVWZ+Test TAN1:Auftrag nich\n" +
"?23t TAN-pflichtig IBAN: DE111?23456780987654321 BIC: ABCD\n" +
"?25DEMM123 ?30$Bic?31$Iban\n" +
"?32DANKITO\n" +
":61:200514D0,05NMSCKREF+\n" +
":86:177?00SEPA Überweisung?10804?20KREF+2020-05-14T01:35:20.67\n" +
"?216 ?22SVWZ+Lass es endlich ruber?23wachsen TAN1:Auftrag nicht \n" +
"?24TAN-pflichtig IBAN: DE11123?25456780987654321 BIC: ABCDDE\n" +
"?26MM123 ?30$Bic?31$Iban\n" +
"?32DANKITO\n" +
":61:200514C0,01NMSC\n" +
":86:166?00SEPA Gutschrift?10804?20SVWZ+2020-05-14T13:10:34.09\n" +
"?211 Test transaction b0a557f2?22 f962-4608-9201-f890e1fc037\n" +
"?23b IBAN: DE11123456780987654?24321 BIC: $Bic \n" +
"?30$Bic?31$Iban?32DANKITO\n" +
":62F:C200514EUR84,28\n" +
"-"
val transactionsString = AccountStatementWithTimeThatGotDetectedAsFieldCode
// when
@ -281,20 +205,7 @@ class Mt940ParserTest : FinTsTestBase() {
@Test
fun fixThat_QuestionMarkComma_GetsDetectedAsFieldCode() {
val transactionsString = """
:20:STARTUMS
:25:$BankCode/$CustomerId
:28C:0
:60F:C200511EUR0,00
:61:200511D15,00NMSCNONREF
:86:105?00BASISLASTSCHRIFT?10931?20EREF+6MKL2OT30QENNLIU
?21MREF+?,3SQNdUbxm9z7dB)+gKYD?22JAKzCM0G?23CRED+DE94ZZZ00000123456
?24SVWZ+306-4991422-2405949 NI?25LE Mktp DE 6MKL2OT30QENNLIU?26
EREF: 6MKL2OT30QENNLIU MRE?27F: ?,3SQNdUbxm9z7dB)+gKYDJA?28KzCM0G
CRED: DE94ZZZ0000012?293456 IBAN: DE87300308801234?30TUBDDEDD?31DE87300308801234567890?32NILE PAYMENTS EUROPE S.C.?33A.?34992?60567890 BIC: TUBDDEDD
:62F:D200511EUR15,00
-
""".trimIndent()
val transactionsString = QuotationMarkCommaGetsDetectedAsFieldValue
// when
@ -318,113 +229,30 @@ class Mt940ParserTest : FinTsTestBase() {
@Test
fun parseDate() {
val result = underTest.parseMt940Date("240507")
val result = underTest.parseDate("240507")
assertEquals(LocalDate(2024, 5, 7), result)
}
@Test
fun parseDateBeforeYear2000() {
val result = underTest.parseMt940Date("990507")
val result = underTest.parseDate("990507")
assertEquals(LocalDate(1999, 5, 7), result)
}
@Test
fun parseDate_FixSparkasse29thOFFebruaryInNonLeapYearBug() {
val result = underTest.parseMt940Date("230229")
val result = underTest.parseDate("230229")
assertEquals(LocalDate(2023, 3, 1), result)
assertEquals(LocalDate(2023, 2, 28), result)
}
@Test
fun parseDate_FixSparkasse30thOfFebruaryBug() {
val result = underTest.parseMt940Date("230229")
val result = underTest.parseDate("230229")
assertEquals(LocalDate(2023, 3, 1), result)
}
private fun assertBalance(balance: Balance, isCredit: Boolean, bookingDate: LocalDate, amount: Amount) {
assertEquals(isCredit, balance.isCredit)
assertEquals(bookingDate, balance.bookingDate)
assertEquals(amount, balance.amount)
assertEquals(Currency, balance.currency)
}
private fun assertTurnover(statementLine: StatementLine, valueDate: LocalDate, amount: Amount, isCredit: Boolean = true,
bookingDate: LocalDate? = valueDate) {
assertEquals(isCredit, statementLine.isCredit)
assertFalse(statementLine.isReversal)
assertEquals(valueDate, statementLine.valueDate)
assertEquals(bookingDate, statementLine.bookingDate)
assertEquals(amount, statementLine.amount)
}
private fun assertTransactionDetails(details: RemittanceInformationField?, otherPartyName: String,
otherPartyBankId: String, otherPartyAccountId: String) {
assertNotNull(details)
assertEquals(otherPartyName, details.otherPartyName)
assertEquals(otherPartyBankId, details.otherPartyBankId)
assertEquals(otherPartyAccountId, details.otherPartyAccountId)
}
private val AccountStatementWithSingleTransaction = """
:20:STARTUMSE
:25:$BankCode/$CustomerId
:28C:00000/001
:60F:C${convertMt940Date(AccountStatement1PreviousStatementBookingDate)}EUR$AccountStatement1OpeningBalanceAmount
:61:${convertMt940Date(AccountStatement1BookingDate)}${convertToShortBookingDate(AccountStatement1BookingDate)}CR${AccountStatement1Transaction1Amount}N062NONREF
:86:166?00GUTSCHR. UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/
EUR ${AccountStatement1Transaction1Amount}/20?2219-10-02/...?30$AccountStatement1Transaction1OtherPartyBankId?31$AccountStatement1Transaction1OtherPartyAccountId
?32$AccountStatement1Transaction1OtherPartyName
:62F:C${convertMt940Date(AccountStatement1BookingDate)}EUR$AccountStatement1ClosingBalanceAmount
-
""".trimIndent()
private val AccountStatementWithTwoTransactions = """
:20:STARTUMSE
:25:$BankCode/$CustomerId
:28C:00000/001
:60F:C${convertMt940Date(AccountStatement1PreviousStatementBookingDate)}EUR$AccountStatement1OpeningBalanceAmount
:61:${convertMt940Date(AccountStatement1BookingDate)}${convertToShortBookingDate(AccountStatement1BookingDate)}CR${AccountStatement1Transaction1Amount}N062NONREF
:86:166?00GUTSCHR. UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/
EUR ${AccountStatement1Transaction1Amount}/20?2219-10-02/...?30$AccountStatement1Transaction1OtherPartyBankId?31$AccountStatement1Transaction1OtherPartyAccountId
?32$AccountStatement1Transaction1OtherPartyName
:61:${convertMt940Date(AccountStatement1BookingDate)}${convertToShortBookingDate(AccountStatement1BookingDate)}DR${AccountStatement1Transaction2Amount}N062NONREF
:86:166?00ONLINE-UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/
EUR ${AccountStatement1Transaction2Amount}/20?2219-10-02/...?30$AccountStatement1Transaction2OtherPartyBankId?31$AccountStatement1Transaction2OtherPartyAccountId
?32$AccountStatement1Transaction2OtherPartyName
:62F:C${convertMt940Date(AccountStatement1BookingDate)}EUR${AccountStatement1With2TransactionsClosingBalanceAmount}
-
""".trimIndent()
private val AccountStatementWithSingleTransaction_SheetNumberOmitted = """
:20:STARTUMS
:25:$BankCode/$CustomerId
:28C:0
:60F:C200511EUR0,00
:61:200511D15,00NMSCNONREF
:86:808?00Entgelt/Auslagen?10907?20Preis bezahlt bis 12.2020
?21Jahrespreis?22$AccountHolderName?23Folge-Nr. 0 Verfall 12.23
?30$BankCode?31$CustomerId?32Ausgabe einer Debitkarte
:62F:D200511EUR15,00
-
""".trimIndent()
private fun convertMt940Date(date: LocalDate): String {
// don't use DateFormatter for this as it's not implemented in Kotlin/Native
return (date.year % 100).toString() + date.monthNumber.toStringWithMinDigits(2) + date.dayOfMonth.toStringWithMinDigits(2)
}
private fun convertToShortBookingDate(date: LocalDate): String {
// don't use DateFormatter for this as it's not implemented in Kotlin/Native
return date.monthNumber.toStringWithMinDigits(2) + date.dayOfMonth.toStringWithMinDigits(2)
assertEquals(LocalDate(2023, 2, 28), result)
}
}

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)
}
}

View File

@ -0,0 +1,59 @@
package net.codinux.banking.fints.transactions
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import net.codinux.banking.fints.extensions.EuropeBerlin
import net.codinux.banking.fints.test.assertEquals
import net.codinux.banking.fints.transactions.mt940.Mt94xParserBase
import net.codinux.banking.fints.transactions.mt940.model.AccountStatement
import net.codinux.banking.fints.transactions.mt940.model.Transaction
import kotlin.test.Test
class Mt94xParserBaseTest {
private val underTest = object : Mt94xParserBase<AccountStatement>() {
override fun createAccountStatement(orderReferenceNumber: String, referenceNumber: String?, bankCodeBicOrIban: String, accountIdentifier: String?, statementNumber: Int, sheetNumber: Int?, transactions: List<Transaction>, fieldsByCode: List<Pair<String, String>>): AccountStatement {
throw IllegalStateException("We are testing base functionality, not parsing (Interim)AccountStatements")
}
}
@Test
fun parseDateTimeWithTimeZoneUtc() {
val result = underTest.parseDateTime("1311130945+0000")
val resultAtEuropeBerlin = result.toLocalDateTime(TimeZone.EuropeBerlin)
assertEquals(LocalDateTime(2013, 11, 13, 10, 45), resultAtEuropeBerlin)
}
@Test
fun parseDateTimeWithTimeZoneEuropeBerlin() {
val result = underTest.parseDateTime("2408210742+0200")
val resultAtEuropeBerlin = result.toLocalDateTime(TimeZone.EuropeBerlin)
assertEquals(LocalDateTime(2024, 8, 21, 7, 42), resultAtEuropeBerlin)
}
@Test
fun parseDateTimeWithoutTimeZone() { // actually the time zone is mandatory, but by far not all banks add it
val result = underTest.parseDateTime("2408232156")
val resultAtEuropeBerlin = result.toLocalDateTime(TimeZone.EuropeBerlin)
assertEquals(LocalDateTime(2024, 8, 23, 21, 56), resultAtEuropeBerlin)
}
// @Test
// fun parseDateTimeStartingWithCharacter() {
// // really don't know where's the 'C' at the start is coming from, but this is an example from DFÜ-Abkommen PDF, p. 674
// val result = underTest.parseDateTime("C1311130945+0000")
//
// val resultAtEuropeBerlin = result.toLocalDateTime(TimeZone.EuropeBerlin)
//
// assertEquals(LocalDateTime(2024, 8, 23, 21, 56), resultAtEuropeBerlin)
// }
}

View File

@ -0,0 +1,254 @@
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
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.currency)
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.currency)
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()
}

View File

@ -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()
)
}
}

View File

@ -0,0 +1,224 @@
package net.codinux.banking.fints.transactions.swift
import net.codinux.banking.fints.FinTsTestBase
import net.codinux.banking.fints.transactions.mt940.model.Balance
import net.codinux.banking.fints.transactions.mt940.model.RemittanceInformationField
import net.codinux.banking.fints.transactions.mt940.model.StatementLine
import kotlinx.datetime.LocalDate
import net.codinux.banking.fints.extensions.*
import net.codinux.banking.fints.model.Amount
import net.codinux.banking.fints.test.*
abstract class MtParserTestBase : FinTsTestBase() {
companion object {
const val Currency = "EUR"
val AccountStatement1PreviousStatementBookingDate = LocalDate(1988, 2, 26)
val AccountStatement1BookingDate = LocalDate(1988, 2, 27)
val AccountStatement1OpeningBalanceAmount = Amount("12345,67")
val AccountStatement1Transaction1Amount = Amount("1234,56")
val AccountStatement1Transaction1OtherPartyName = "Sender1"
val AccountStatement1Transaction1OtherPartyBankId = "AAAADE12"
val AccountStatement1Transaction1OtherPartyAccountId = "DE99876543210987654321"
val AccountStatement1Transaction2Amount = Amount("432,10")
val AccountStatement1Transaction2OtherPartyName = "Receiver2"
val AccountStatement1Transaction2OtherPartyBankId = "BBBBDE56"
val AccountStatement1Transaction2OtherPartyAccountId = "DE77987654321234567890"
val AccountStatement1ClosingBalanceAmount = Amount("13580,23")
val AccountStatement1With2TransactionsClosingBalanceAmount = Amount("13148,13")
}
protected fun assertBalance(balance: Balance, isCredit: Boolean, bookingDate: LocalDate, amount: Amount) {
assertEquals(isCredit, balance.isCredit)
assertEquals(bookingDate, balance.bookingDate)
assertEquals(amount, balance.amount)
assertEquals(Currency, balance.currency)
}
protected fun assertTurnover(statementLine: StatementLine, valueDate: LocalDate, amount: Amount, isCredit: Boolean = true,
bookingDate: LocalDate? = valueDate) {
assertEquals(isCredit, statementLine.isCredit)
assertFalse(statementLine.isReversal)
assertEquals(valueDate, statementLine.valueDate)
assertEquals(bookingDate, statementLine.bookingDate)
assertEquals(amount, statementLine.amount)
}
protected fun assertTransactionDetails(details: RemittanceInformationField?, otherPartyName: String,
otherPartyBankId: String, otherPartyAccountId: String) {
assertNotNull(details)
assertEquals(otherPartyName, details.otherPartyName)
assertEquals(otherPartyBankId, details.otherPartyBankId)
assertEquals(otherPartyAccountId, details.otherPartyAccountId)
}
protected val AccountStatementWithSingleTransaction = """
:20:STARTUMSE
:25:$BankCode/$CustomerId
:28C:00000/001
:60F:C${convertMt940Date(AccountStatement1PreviousStatementBookingDate)}EUR$AccountStatement1OpeningBalanceAmount
:61:${convertMt940Date(AccountStatement1BookingDate)}${convertToShortBookingDate(AccountStatement1BookingDate)}CR${AccountStatement1Transaction1Amount}N062NONREF
:86:166?00GUTSCHR. UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/
EUR $AccountStatement1Transaction1Amount/20?2219-10-02/...?30$AccountStatement1Transaction1OtherPartyBankId?31$AccountStatement1Transaction1OtherPartyAccountId
?32$AccountStatement1Transaction1OtherPartyName
:62F:C${convertMt940Date(AccountStatement1BookingDate)}EUR$AccountStatement1ClosingBalanceAmount
-
""".trimIndent()
protected val AccountStatementWithTwoTransactions = """
:20:STARTUMSE
:25:$BankCode/$CustomerId
:28C:00000/001
:60F:C${convertMt940Date(AccountStatement1PreviousStatementBookingDate)}EUR$AccountStatement1OpeningBalanceAmount
:61:${convertMt940Date(AccountStatement1BookingDate)}${convertToShortBookingDate(AccountStatement1BookingDate)}CR${AccountStatement1Transaction1Amount}N062NONREF
:86:166?00GUTSCHR. UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/
EUR $AccountStatement1Transaction1Amount/20?2219-10-02/...?30$AccountStatement1Transaction1OtherPartyBankId?31$AccountStatement1Transaction1OtherPartyAccountId
?32$AccountStatement1Transaction1OtherPartyName
:61:${convertMt940Date(AccountStatement1BookingDate)}${convertToShortBookingDate(AccountStatement1BookingDate)}DR${AccountStatement1Transaction2Amount}N062NONREF
:86:166?00ONLINE-UEBERWEISUNG?109249?20EREF+674?21SVWZ+1908301/
EUR $AccountStatement1Transaction2Amount/20?2219-10-02/...?30$AccountStatement1Transaction2OtherPartyBankId?31$AccountStatement1Transaction2OtherPartyAccountId
?32$AccountStatement1Transaction2OtherPartyName
:62F:C${convertMt940Date(AccountStatement1BookingDate)}EUR$AccountStatement1With2TransactionsClosingBalanceAmount
-
""".trimIndent()
protected val AccountStatementWithSingleTransaction_SheetNumberOmitted = """
:20:STARTUMS
:25:$BankCode/$CustomerId
:28C:0
:60F:C200511EUR0,00
:61:200511D15,00NMSCNONREF
:86:808?00Entgelt/Auslagen?10907?20Preis bezahlt bis 12.2020
?21Jahrespreis?22$AccountHolderName?23Folge-Nr. 0 Verfall 12.23
?30$BankCode?31$CustomerId?32Ausgabe einer Debitkarte
:62F:D200511EUR15,00
-
""".trimIndent()
protected val AccountStatementWithAnnualJumpFromBookingDateToValueDate =
":20:STARTUMSE\n" +
":25:$BankCode/$CustomerId\n" +
":28C:00000/001\n" +
":60F:C191227EUR104501,86\n" +
":61:2001011230DR3,99N024NONREF\n" +
":86:809?00ENTGELTABSCHLUSS?106666?20Entgeltabrechnung?21siehe Anl\n" +
"age?30$BankCode\n" +
":61:2001011230CR0,00N066NONREF\n" +
":86:805?00ABSCHLUSS?106666?20Abrechnung 30.12.2019?21siehe Anlage\n" +
"?30$BankCode\n" +
":62F:C191230EUR104490,88\n" +
"-"
protected val AccountStatementWithLineStartsWithDashThatIsNotABlockSeparator = "\n" +
":20:MT940-2005200849\n" +
":21:NONREF\n" +
":25:20041111/369300900EUR\n" +
":28C:0/1\n" +
":60F:C200512EUR0,00\n" +
":61:2005120512CR100,00NMSCNONREF//POS 7\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21EROEFFNUNGSBETRAG?22END-TO-END-REF\n" +
".:?23NICHT ANGEGEBEN?24Ref. HW220133C3232360/15499?32DAN\n" +
"NKITO\n" +
":62M:C200513EUR100,00\n" +
"-\n" +
":20:MT940-2005200849\n" +
":21:NONREF\n" +
":25:20041111/369300900EUR\n" +
":28C:0/2\n" +
":60M:C200513EUR100,00\n" +
":61:2005130513CR0,10NMSCNONREF//POS 6\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21TEST?22END-TO-END-REF.:?23NICHT AN\n" +
"GEGEBEN?24Ref. 7T2C0YTD0BZL4V9S/1?32DANKITO\n" +
":61:2005130513CR0,15NMSCNONREF//POS 5\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21ECHTZEITUEBERWEISUNGSTEST?22END-TO\n" +
"-END-REF.:?23NICHT ANGEGEBEN?24Ref. 402C0YTD0GLPFDFV/1?32DANKI\n" +
"TO\n" +
":61:2005130513CR0,30NMSCNONREF//POS 4\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21UND NOCH EIN TEST FUER JAVA?22FX?2\n" +
"3END-TO-END-REF.:?24NICHT ANGEGEBEN?25Ref. 5D2C0YTD0HVAB3X3/1?32D\n" +
"ANKITO\n" +
":61:2005130513CR0,10NMSCNONREF//POS 3\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21LASS DIE KOHLE RUEBER WACHS?22EN?2\n" +
"3END-TO-END-REF.:?24NICHT ANGEGEBEN?25Ref. J3220134C3451151/6200?\n" +
"32DANKITO\n" +
":61:2005130513CR0,01NMSCNONREF//POS 2\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21TEST?22END-TO-END-REF.:?23NICHT AN\n" +
"GEGEBEN?24Ref. J3220134C3451151/6201?32DANKITO\n" +
":62M:C200514EUR100,66\n" +
"-\n" +
":20:MT940-2005200849\n" +
":21:NONREF\n" +
":25:20041111/369300900EUR\n" +
":28C:0/3\n" +
":60M:C200514EUR100,66\n" +
":61:2005140514DR0,01NMSCNONREF//POS 1\n" +
":86:820?20ÜBERTRAG / ÜBERWEISUNG?21END-TO-END-REF.:?22NICHT ANGEGEBEN\n" +
"?23Ref. J022013510234936/2?30ABCDEFGHIJK?31DE1112345679876543210\n" +
"?32DANKITO\n" +
":62F:C200520EUR100,65\n" +
"-"
protected val AccountStatementWithTimeThatGotDetectedAsFieldCode = "\n" +
":20:STARTUMS\n" +
":25:$BankCode/$CustomerId\n" +
":28C:0\n" +
":60F:D200514EUR15,00\n" +
":61:200514D0,02NMSCKREF+\n" +
":86:177?00SEPA Überweisung?10804?20KREF+2020-05-14T00:58:23:09\n" +
"?2193 ?22SVWZ+Test TAN1:Auftrag nich\n" +
"?23t TAN-pflichtig IBAN: DE111?23456780987654321 BIC: ABCD\n" +
"?25DEMM123 ?30$Bic?31$Iban\n" +
"?32DANKITO\n" +
":61:200514D0,05NMSCKREF+\n" +
":86:177?00SEPA Überweisung?10804?20KREF+2020-05-14T01:35:20.67\n" +
"?216 ?22SVWZ+Lass es endlich ruber?23wachsen TAN1:Auftrag nicht \n" +
"?24TAN-pflichtig IBAN: DE11123?25456780987654321 BIC: ABCDDE\n" +
"?26MM123 ?30$Bic?31$Iban\n" +
"?32DANKITO\n" +
":61:200514C0,01NMSC\n" +
":86:166?00SEPA Gutschrift?10804?20SVWZ+2020-05-14T13:10:34.09\n" +
"?211 Test transaction b0a557f2?22 f962-4608-9201-f890e1fc037\n" +
"?23b IBAN: DE11123456780987654?24321 BIC: $Bic \n" +
"?30$Bic?31$Iban?32DANKITO\n" +
":62F:C200514EUR84,28\n" +
"-"
protected val QuotationMarkCommaGetsDetectedAsFieldValue = """
:20:STARTUMS
:25:$BankCode/$CustomerId
:28C:0
:60F:C200511EUR0,00
:61:200511D15,00NMSCNONREF
:86:105?00BASISLASTSCHRIFT?10931?20EREF+6MKL2OT30QENNLIU
?21MREF+?,3SQNdUbxm9z7dB)+gKYD?22JAKzCM0G?23CRED+DE94ZZZ00000123456
?24SVWZ+306-4991422-2405949 NI?25LE Mktp DE 6MKL2OT30QENNLIU?26
EREF: 6MKL2OT30QENNLIU MRE?27F: ?,3SQNdUbxm9z7dB)+gKYDJA?28KzCM0G
CRED: DE94ZZZ0000012?293456 IBAN: DE87300308801234?30TUBDDEDD?31DE87300308801234567890?32NILE PAYMENTS EUROPE S.C.?33A.?34992?60567890 BIC: TUBDDEDD
:62F:D200511EUR15,00
-
""".trimIndent()
protected fun convertMt940Date(date: LocalDate): String {
// don't use DateFormatter for this as it's not implemented in Kotlin/Native
return (date.year % 100).toString() + date.monthNumber.toStringWithMinDigits(2) + date.dayOfMonth.toStringWithMinDigits(2)
}
protected fun convertToShortBookingDate(date: LocalDate): String {
// don't use DateFormatter for this as it's not implemented in Kotlin/Native
return date.monthNumber.toStringWithMinDigits(2) + date.dayOfMonth.toStringWithMinDigits(2)
}
}

View File

@ -1,11 +0,0 @@
package net.codinux.banking.fints.mapper
import kotlinx.datetime.LocalDate
actual class DateFormatter actual constructor(pattern: String) {
actual fun parseDate(dateString: String): LocalDate? {
return null // is only used in rare cases, don't implement right now
}
}

View File

@ -1,16 +0,0 @@
package net.codinux.banking.fints.mapper
import kotlinx.datetime.LocalDate
import kotlinx.datetime.toKotlinLocalDate
import java.time.format.DateTimeFormatter
actual class DateFormatter actual constructor(pattern: String) {
private val formatter = DateTimeFormatter.ofPattern(pattern)
actual fun parseDate(dateString: String): LocalDate? {
return java.time.LocalDate.parse(dateString, formatter)?.toKotlinLocalDate()
}
}

View File

@ -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)
}
}

View File

@ -1,11 +0,0 @@
package net.codinux.banking.fints.mapper
import kotlinx.datetime.LocalDate
actual class DateFormatter actual constructor(pattern: String) {
actual fun parseDate(dateString: String): LocalDate? {
return null // is only used in rare cases, don't implement right now
}
}