Compare commits
13 Commits
c158097d3a
...
62aa04a667
Author | SHA1 | Date |
---|---|---|
dankito | 62aa04a667 | |
dankito | 3aa0edfb34 | |
dankito | e4d605531e | |
dankito | a42de32260 | |
dankito | 95e60b2706 | |
dankito | fd9eadf45e | |
dankito | 7ddeb88475 | |
dankito | 90a7543641 | |
dankito | d1de7f5eb0 | |
dankito | ef8045fa96 | |
dankito | 2031cb9e9f | |
dankito | e260eaa535 | |
dankito | 891641fc6f |
|
@ -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)
|
||||
}
|
||||
|
@ -235,11 +258,12 @@ open class FinTsJobExecutor(
|
|||
closeDialog(context)
|
||||
|
||||
val successful = response.tanRequiredButWeWereToldToAbortIfSo
|
||||
|| (response.successful && (parameter.alsoRetrieveBalance == false || balance != null))
|
||||
|| (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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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?
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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")
|
||||
|
||||
}
|
|
@ -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
|
||||
))
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,8 @@ enum class MessageType {
|
|||
|
||||
GetCreditCardTransactions,
|
||||
|
||||
GetSecuritiesAccountBalance,
|
||||
|
||||
TransferMoney
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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?>
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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?>
|
||||
|
||||
}
|
|
@ -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
|
||||
user‐to‐user 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 }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
user‐to‐user 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
|
||||
}
|
||||
|
||||
}
|
|
@ -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()}"
|
||||
}
|
||||
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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()}"
|
||||
}
|
||||
|
||||
}
|
|
@ -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)"
|
||||
}
|
|
@ -0,0 +1,226 @@
|
|||
package net.codinux.banking.fints.transactions.swift
|
||||
|
||||
import kotlinx.datetime.*
|
||||
import net.codinux.banking.fints.extensions.EuropeBerlin
|
||||
import net.codinux.banking.fints.log.IMessageLogAppender
|
||||
import net.codinux.banking.fints.model.Amount
|
||||
import net.codinux.banking.fints.transactions.swift.model.ContinuationIndicator
|
||||
import net.codinux.banking.fints.transactions.swift.model.Holding
|
||||
import net.codinux.banking.fints.transactions.swift.model.StatementOfHoldings
|
||||
import net.codinux.banking.fints.transactions.swift.model.SwiftMessageBlock
|
||||
|
||||
open class Mt535Parser(
|
||||
logAppender: IMessageLogAppender? = null
|
||||
) : MtParserBase(logAppender) {
|
||||
|
||||
open fun parseMt535String(mt535String: String): List<StatementOfHoldings> {
|
||||
val blocks = parseMtString(mt535String, true)
|
||||
|
||||
// should actually always be only one block, just to be on the safe side
|
||||
return blocks.mapNotNull { parseStatementOfHoldings(it) }
|
||||
}
|
||||
|
||||
protected open fun parseStatementOfHoldings(mt535Block: SwiftMessageBlock): StatementOfHoldings? {
|
||||
try {
|
||||
val containsHoldings = mt535Block.getMandatoryField("17B").endsWith("//Y")
|
||||
val holdings = if (containsHoldings) parseHoldings(mt535Block) else emptyList()
|
||||
|
||||
return parseStatementOfHoldings(holdings, mt535Block)
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse MT 535 block:\n$mt535Block", e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
protected open fun parseStatementOfHoldings(holdings: List<Holding>, mt535Block: SwiftMessageBlock): StatementOfHoldings {
|
||||
val totalBalance = parseBalance(mt535Block.getMandatoryRepeatableField("19A").last())
|
||||
|
||||
val accountStatement = mt535Block.getMandatoryField("97A")
|
||||
val bankCode = accountStatement.substringAfter("//").substringBefore('/')
|
||||
val accountIdentifier = accountStatement.substringAfterLast('/')
|
||||
|
||||
val (pageNumber, continuationIndicator) = parsePageNumber(mt535Block)
|
||||
|
||||
val (statementDate, preparationDate) = parseStatementAndPreparationDate(mt535Block)
|
||||
|
||||
return StatementOfHoldings(bankCode, accountIdentifier, holdings, totalBalance?.first, totalBalance?.second, pageNumber, continuationIndicator, statementDate, preparationDate)
|
||||
}
|
||||
|
||||
// this is a MT5(35) specific balance format
|
||||
protected open fun parseBalance(balanceString: String?): Pair<Amount, String>? {
|
||||
if (balanceString != null) {
|
||||
val balancePart = balanceString.substringAfterLast('/')
|
||||
val amount = balancePart.substring(3)
|
||||
val isNegative = amount.startsWith("N")
|
||||
return Pair(Amount(if (isNegative) "-${amount.substring(1)}" else amount), balancePart.substring(0, 3))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
protected open fun parsePageNumber(mt535Block: SwiftMessageBlock): Pair<Int?, ContinuationIndicator> {
|
||||
return try {
|
||||
val pageNumberStatement = mt535Block.getMandatoryField("28E")
|
||||
val pageNumber = pageNumberStatement.substringBefore('/').toInt()
|
||||
val continuationIndicator = pageNumberStatement.substringAfter('/').let { indicatorString ->
|
||||
ContinuationIndicator.entries.firstOrNull { it.mtValue == indicatorString } ?: ContinuationIndicator.Unknown
|
||||
}
|
||||
|
||||
Pair(pageNumber, continuationIndicator)
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse statement and preparation date of block:\n$mt535Block", e)
|
||||
|
||||
Pair(null, ContinuationIndicator.Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun parseStatementAndPreparationDate(mt535Block: SwiftMessageBlock): Pair<LocalDate?, LocalDate?> {
|
||||
return try {
|
||||
// TODO: differ between 98A (without time) and 98C (with time)
|
||||
// TODO: ignore (before parsing?) 98A/C of holdings which start with ":PRIC//
|
||||
val dates = mt535Block.getMandatoryRepeatableField("98").map { it.substringBefore("//") to parse4DigitYearDate(it.substringAfter("//").substring(0, 8)) } // if given we ignore time
|
||||
val statementDate = dates.firstOrNull { it.first == ":STAT" }?.second // specifications and their implementations: the statement date is actually mandatory, but not all banks actually set it
|
||||
val preparationDate = dates.firstOrNull { it.first == ":PREP" }?.second
|
||||
|
||||
Pair(statementDate, preparationDate)
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse statement and preparation date of block:\n$mt535Block", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun parseHoldings(mt535Block: SwiftMessageBlock): List<Holding> {
|
||||
val blockLines = mt535Block.getFieldsInOrder()
|
||||
val holdingBlocksStartIndices = blockLines.indices.filter { blockLines[it].first == "16R" && blockLines[it].second == "FIN" }
|
||||
val holdingBlocksEndIndices = blockLines.indices.filter { blockLines[it].first == "16S" && blockLines[it].second == "FIN" }
|
||||
|
||||
val holdingBlocks = holdingBlocksStartIndices.mapIndexed { blockIndex, startIndex ->
|
||||
val endIndex = holdingBlocksEndIndices[blockIndex]
|
||||
val holdingBlockLines = blockLines.subList(startIndex + 1, endIndex)
|
||||
SwiftMessageBlock(holdingBlockLines)
|
||||
}
|
||||
|
||||
return holdingBlocks.mapNotNull { parseHolding(it) }
|
||||
}
|
||||
|
||||
protected open fun parseHolding(holdingBlock: SwiftMessageBlock): Holding? =
|
||||
try {
|
||||
val nameStatementLines = holdingBlock.getMandatoryField("35B").split("\n")
|
||||
val isinOrWkn = nameStatementLines.first()
|
||||
val isin = if (isinOrWkn.startsWith("ISIN ")) {
|
||||
isinOrWkn.substringAfter(' ')
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val wkn = if (isin == null) {
|
||||
isinOrWkn
|
||||
} else if (nameStatementLines[1].startsWith("DE")) {
|
||||
nameStatementLines[1]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val name = nameStatementLines.subList(if (isin == null || wkn == null) 1 else 2, nameStatementLines.size).joinToString(" ")
|
||||
|
||||
// TODO: check for optional code :90a: Preis
|
||||
// TODO: check for optional code :94B: Herkunft von Preis / Kurs
|
||||
// TODO: check for optional code :98A: Herkunft von Preis / Kurs
|
||||
// TODO: check for optional code :99A: Anzahl der aufgelaufenen Tage
|
||||
// TODO: check for optional code :92B: Exchange rate
|
||||
|
||||
val holdingTotalBalance = holdingBlock.getMandatoryField("93B")
|
||||
val balanceIsQuantity = holdingTotalBalance.startsWith(":AGGR//UNIT") // == Die Stückzahl wird als Zahl (Zähler) ausgedrückt
|
||||
// else it starts with "AGGR/FAMT" = Die Stückzahl wird als Nennbetrag ausgedrückt. Bei Nennbeträgen wird die Währung durch die „Depotwährung“ in Feld B:70E: bestimmt
|
||||
val totalBalanceWithOptionalSign = holdingTotalBalance.substring(":AGGR//UNIT/".length)
|
||||
val totalBalanceIsNegative = totalBalanceWithOptionalSign.first() == 'N'
|
||||
val totalBalance = if (totalBalanceIsNegative) "-" + totalBalanceWithOptionalSign.substring(1) else totalBalanceWithOptionalSign
|
||||
|
||||
// there's a second ":HOLD//" entry if the currency if the security differs from portfolio's currency // TODO: the 3rd holding of the DK example has this, so implement it to display the correct value
|
||||
val portfolioValueStatement = holdingBlock.getOptionalRepeatableField("19A")?.firstOrNull { it.startsWith(":HOLD//") }
|
||||
val portfolioValue = parseBalance(portfolioValueStatement?.substringAfter(":HOLD//")) // Value for total balance from B:93B: in the same currency as C:19A:
|
||||
|
||||
val (buyingDate, averageCostPrice, averageCostPriceCurrency) = parseHoldingAdditionalInformation(holdingBlock)
|
||||
|
||||
val (marketValue, pricingTime, totalCostPrice) = parseMarketValue(holdingBlock)
|
||||
|
||||
val balance = portfolioValue?.first ?: (if (balanceIsQuantity == false) Amount(totalBalance) else null)
|
||||
val quantity = if (balanceIsQuantity) totalBalance.replace(",", "").toIntOrNull() else null
|
||||
|
||||
Holding(name, isin, wkn, buyingDate, quantity, averageCostPrice, balance, portfolioValue?.second ?: averageCostPriceCurrency, marketValue, pricingTime, totalCostPrice)
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse MT 535 holding block:\n$holdingBlock", e)
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
protected open fun parseHoldingAdditionalInformation(holdingBlock: SwiftMessageBlock): Triple<LocalDate?, Amount?, String?> {
|
||||
try {
|
||||
val additionalInformationLines = holdingBlock.getOptionalField("70E")?.split('\n')
|
||||
if (additionalInformationLines != null) {
|
||||
val firstLine = additionalInformationLines.first().substring(":HOLD//".length).let {
|
||||
if (it.startsWith("1")) it.substring(1) else it // specifications and their implementations: line obligatory has to start with '1' but that's not always the case
|
||||
}
|
||||
val currencyOfSafekeepingAccountIsUnit = firstLine.startsWith("STK") // otherwise it's "KON“ = Contracts or ISO currency code of the category currency in the case of securities quoted in percentages
|
||||
|
||||
val firstLineParts = firstLine.split('+')
|
||||
val buyingDate = if (firstLineParts.size > 4) parse4DigitYearDate(firstLineParts[4]) else null
|
||||
|
||||
val secondLine = if (additionalInformationLines.size > 1) additionalInformationLines[1].let { if (it.startsWith("2")) it.substring(1) else it } else "" // cut off "2"; the second line is actually mandatory, but to be on the safe side
|
||||
val secondLineParts = secondLine.split('+')
|
||||
val averageCostPriceAmount = if (secondLineParts.size > 0) secondLineParts[0] else null
|
||||
val averageCostPriceCurrency = if (secondLineParts.size > 1) secondLineParts[1] else null
|
||||
|
||||
// third and fourth line are only filled in in the case of futures contracts
|
||||
|
||||
return Triple(buyingDate, averageCostPriceAmount?.let { Amount(it) }, averageCostPriceCurrency)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse additional information for holding:\n$holdingBlock", e)
|
||||
}
|
||||
|
||||
return Triple(null, null, null)
|
||||
}
|
||||
|
||||
private fun parseMarketValue(holdingBlock: SwiftMessageBlock): Triple<Amount?, Instant?, Amount?> {
|
||||
try {
|
||||
val subBalanceDetailsLines = holdingBlock.getOptionalField("70C")?.split('\n')
|
||||
if (subBalanceDetailsLines != null) {
|
||||
val thirdLine = if (subBalanceDetailsLines.size > 2) subBalanceDetailsLines[2].let { if (it.startsWith("3")) it.substring(1) else it }.trim() else null
|
||||
val (marketValue, pricingTime) = if (thirdLine != null) {
|
||||
val thirdLineParts = thirdLine.split(' ')
|
||||
val marketValueAmountAndCurrency = if (thirdLineParts.size > 1) thirdLineParts[1].takeIf { it.isNotBlank() } else null
|
||||
val marketValue = marketValueAmountAndCurrency?.let { Amount(it.replace('.', ',').replace("EUR", "")) } // TODO: also check for other currencies
|
||||
val pricingTime = try {
|
||||
if (thirdLineParts.size > 2) thirdLineParts[2].let { if (it.length > 18) LocalDateTime.parse(it.substring(0, 19)).toInstant(TimeZone.EuropeBerlin) else null } else null
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse pricing time from line: $thirdLine", e)
|
||||
null
|
||||
}
|
||||
|
||||
marketValue to pricingTime
|
||||
} else {
|
||||
null to null
|
||||
}
|
||||
|
||||
val fourthLine = if (subBalanceDetailsLines.size > 3) subBalanceDetailsLines[3].let { if (it.startsWith("4")) it.substring(1) else it }.trim() else null
|
||||
|
||||
val totalCostPrice = if (fourthLine != null) {
|
||||
val fourthLineParts = fourthLine.split(' ')
|
||||
val totalCostPriceAmountAndCurrency = if (fourthLineParts.size > 0) fourthLineParts[0] else null
|
||||
|
||||
totalCostPriceAmountAndCurrency?.let { Amount(it.replace('.', ',').replace("EUR", "")) } // TODO: also check for other currencies
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return Triple(marketValue, pricingTime, totalCostPrice)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not map market value and total cost price, but is a non-standard anyway", e)
|
||||
}
|
||||
|
||||
return Triple(null, null, null)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,154 @@
|
|||
package net.codinux.banking.fints.transactions.swift
|
||||
|
||||
import kotlinx.datetime.*
|
||||
import net.codinux.banking.fints.extensions.EuropeBerlin
|
||||
import net.codinux.banking.fints.log.IMessageLogAppender
|
||||
import net.codinux.banking.fints.model.Amount
|
||||
import net.codinux.banking.fints.transactions.mt940.Mt94xParserBase
|
||||
import net.codinux.banking.fints.transactions.swift.model.SwiftMessageBlock
|
||||
import net.codinux.log.logger
|
||||
|
||||
open class MtParserBase(
|
||||
open var logAppender: IMessageLogAppender? = null
|
||||
) {
|
||||
|
||||
protected val log by logger()
|
||||
|
||||
|
||||
fun parseMtString(mt: String, rememberOrderOfFields: Boolean = false): List<SwiftMessageBlock> {
|
||||
val lines = mt.lines().filterNot { it.isBlank() }
|
||||
|
||||
return parseMtStringLines(lines, rememberOrderOfFields)
|
||||
}
|
||||
|
||||
protected open fun parseMtStringLines(lines: List<String>, rememberOrderOfFields: Boolean = false): List<SwiftMessageBlock> {
|
||||
val messageBlocks = mutableListOf<SwiftMessageBlock>()
|
||||
var currentBlock = SwiftMessageBlock()
|
||||
|
||||
var fieldCode = ""
|
||||
val fieldValueLines = mutableListOf<String>()
|
||||
|
||||
lines.forEach { line ->
|
||||
// end of block
|
||||
if (line.trim() == "-") {
|
||||
if (fieldCode.isNotBlank()) {
|
||||
currentBlock.addField(fieldCode, fieldValueLines, rememberOrderOfFields)
|
||||
}
|
||||
messageBlocks.add(currentBlock)
|
||||
|
||||
currentBlock = SwiftMessageBlock()
|
||||
fieldCode = ""
|
||||
fieldValueLines.clear() // actually not necessary
|
||||
}
|
||||
// start of a new field
|
||||
else if (line.length > 5 && line[0] == ':' && line[1].isDigit() && line[2].isDigit() && (line[3] == ':' || line[3].isLetter() && line[4] == ':')) {
|
||||
if (fieldCode.isNotBlank()) {
|
||||
currentBlock.addField(fieldCode, fieldValueLines, rememberOrderOfFields)
|
||||
}
|
||||
|
||||
val fieldCodeContainsLetter = line[3].isLetter()
|
||||
fieldCode = line.substring(1, if (fieldCodeContainsLetter) 4 else 3)
|
||||
fieldValueLines.clear()
|
||||
fieldValueLines.add(if (fieldCodeContainsLetter) line.substring(5) else line.substring(4))
|
||||
}
|
||||
// a line that belongs to previous field value
|
||||
else {
|
||||
fieldValueLines.add(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldCode.isNotBlank()) {
|
||||
currentBlock.addField(fieldCode, fieldValueLines, rememberOrderOfFields)
|
||||
}
|
||||
if (currentBlock.hasFields) {
|
||||
messageBlocks.add(currentBlock)
|
||||
}
|
||||
|
||||
return messageBlocks
|
||||
}
|
||||
|
||||
|
||||
open fun parse4DigitYearDate(dateString: String): LocalDate {
|
||||
val year = dateString.substring(0, 4).toInt()
|
||||
val month = dateString.substring(4, 6).toInt()
|
||||
val day = dateString.substring(6, 8).toInt()
|
||||
|
||||
return LocalDate(year , month, fixDay(year, month, day))
|
||||
}
|
||||
|
||||
open fun parseDate(dateString: String): LocalDate {
|
||||
try {
|
||||
var year = dateString.substring(0, 2).toInt()
|
||||
val month = dateString.substring(2, 4).toInt()
|
||||
val day = dateString.substring(4, 6).toInt()
|
||||
|
||||
/**
|
||||
* Bei 6-stelligen Datumsangaben (d.h. JJMMTT) wird gemäß SWIFT zwischen dem 20. und 21.
|
||||
* Jahrhundert wie folgt unterschieden:
|
||||
* - Ist das Jahr (d.h. JJ) größer als 79, bezieht sich das Datum auf das 20. Jahrhundert. Ist
|
||||
* das Jahr 79 oder kleiner, bezieht sich das Datum auf das 21. Jahrhundert.
|
||||
* - Ist JJ > 79:JJMMTT = 19JJMMTT
|
||||
* - sonst: JJMMTT = 20JJMMTT
|
||||
* - Damit reicht die Spanne des sechsstelligen Datums von 1980 bis 2079.
|
||||
*/
|
||||
if (year > 79) {
|
||||
year += 1900
|
||||
} else {
|
||||
year += 2000
|
||||
}
|
||||
|
||||
return LocalDate(year , month, fixDay(year, month, day))
|
||||
} catch (e: Throwable) {
|
||||
logError("Could not parse dateString '$dateString'", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixDay(year: Int, month: Int, day: Int): Int {
|
||||
// ah, here we go, banks (in Germany) calculate with 30 days each month, so yes, it can happen that dates
|
||||
// like 30th of February or 29th of February in non-leap years occur, see:
|
||||
// https://de.m.wikipedia.org/wiki/30._Februar#30._Februar_in_der_Zinsberechnung
|
||||
if (month == 2 && (day > 29 || (day > 28 && year % 4 != 0))) { // fix that for banks each month has 30 days
|
||||
return 28
|
||||
}
|
||||
|
||||
return day
|
||||
}
|
||||
|
||||
open fun parseTime(timeString: String): LocalTime {
|
||||
val hour = timeString.substring(0, 2).toInt()
|
||||
val minute = timeString.substring(2, 4).toInt()
|
||||
|
||||
return LocalTime(hour, minute)
|
||||
}
|
||||
|
||||
open fun parseDateTime(dateTimeString: String): Instant {
|
||||
val date = parseDate(dateTimeString.substring(0, 6))
|
||||
|
||||
val time = parseTime(dateTimeString.substring(6, 10))
|
||||
|
||||
val dateTime = LocalDateTime(date, time)
|
||||
|
||||
return if (dateTimeString.length == 15) { // actually mandatory, but by far not always stated: the time zone
|
||||
val plus = dateTimeString[10] == '+'
|
||||
val timeDifference = parseTime(dateTimeString.substring(11))
|
||||
|
||||
dateTime.toInstant(UtcOffset(if (plus) timeDifference.hour else timeDifference.hour * -1, timeDifference.minute))
|
||||
} else { // we then assume the server states the DateTime in FinTS's default time zone, Europe/Berlin
|
||||
dateTime.toInstant(TimeZone.EuropeBerlin)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun parseAmount(amountString: String): Amount {
|
||||
return Amount(amountString)
|
||||
}
|
||||
|
||||
|
||||
protected open fun logError(message: String, e: Throwable?) {
|
||||
logAppender?.logError(Mt94xParserBase::class, message, e)
|
||||
?: run {
|
||||
log.error(e) { message }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package net.codinux.banking.fints.transactions.swift.model
|
||||
|
||||
enum class ContinuationIndicator(internal val mtValue: String) {
|
||||
/**
|
||||
* The only page
|
||||
*/
|
||||
SinglePage("ONLY"),
|
||||
|
||||
/**
|
||||
* Intermediate page, more pages follow
|
||||
*/
|
||||
IntermediatePage("MORE"),
|
||||
|
||||
/**
|
||||
* Last page
|
||||
*/
|
||||
LastPage("LAST"),
|
||||
|
||||
Unknown("NotAMtValue")
|
||||
|
||||
}
|
|
@ -0,0 +1,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
|
||||
)
|
|
@ -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}" }}"
|
||||
}
|
|
@ -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}" }
|
||||
}
|
||||
|
||||
}
|
|
@ -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)"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
// }
|
||||
|
||||
}
|
|
@ -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()
|
||||
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue