Implemented requesting and parsing securities account balance

This commit is contained in:
dankito 2024-09-11 06:39:59 +02:00
parent 95e60b2706
commit a42de32260
14 changed files with 138 additions and 10 deletions

View File

@ -202,6 +202,29 @@ open class FinTsJobExecutor(
var balance: Money? = balanceResponse?.getFirstSegmentById<BalanceSegment>(InstituteSegmentId.Balance)?.let {
Money(it.balance, it.currency)
}
// TODO: for larger portfolios there can be a Aufsetzpunkt, but for balances we currently do not support sending multiple messages
val statementOfHoldings = balanceResponse?.getFirstSegmentById<SecuritiesAccountBalanceSegment>(InstituteSegmentId.SecuritiesAccountBalance)?.let {
val statementOfHoldings = it.statementOfHoldings
val statementOfHolding = statementOfHoldings.firstOrNull { it.totalBalance != null }
if (statementOfHolding != null) {
balance = Money(statementOfHolding.totalBalance!!, statementOfHolding.currency ?: Currency.DefaultCurrencyCode)
}
statementOfHoldings
} ?: emptyList()
if (parameter.account.supportsRetrievingAccountTransactions == false) {
if (balanceResponse == null) {
return GetAccountTransactionsResponse(context, BankResponse(false, "Balance could not be retrieved"), RetrievedAccountData.unsuccessful(parameter.account))
} else {
val successful = balance != null || balanceResponse.tanRequiredButWeWereToldToAbortIfSo
val retrievedData = RetrievedAccountData(parameter.account, successful, balance, emptyList(), emptyList(), statementOfHoldings, Instant.nowExt(), null, null, balanceResponse?.internalError)
return GetAccountTransactionsResponse(context, balanceResponse, retrievedData)
}
}
val bookedTransactions = mutableSetOf<AccountTransaction>()
val unbookedTransactions = mutableSetOf<Any>()
@ -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)

View File

@ -103,6 +103,8 @@ open class FinTsModelMapper {
addAll(map(accountTransactionsResponse))
}
}
bankAccount.statementOfHoldings = accountTransactionsResponse.statementOfHoldings
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,8 +2,10 @@ 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?,

View File

@ -1,6 +1,7 @@
package net.codinux.banking.fints.transactions.swift.model
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import net.codinux.banking.fints.model.Amount
/**
@ -8,6 +9,7 @@ import net.codinux.banking.fints.model.Amount
* 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,
@ -15,7 +17,7 @@ data class StatementOfHoldings(
val holdings: List<Holding>,
val totalBalance: Amount? = null,
val totalBalanceCurrency: String? = null,
val currency: String? = null,
/**
* The page number is actually mandatory, but to be prepared for surprises like for [statementDate] i added error

View File

@ -5,6 +5,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import net.codinux.banking.fints.model.Currency
import net.codinux.banking.fints.model.Money
import net.codinux.banking.fints.transactions.swift.model.StatementOfHoldings
@Serializable
@ -40,6 +41,8 @@ open class BankAccount(
open var bookedTransactions: List<AccountTransaction> = listOf()
open var statementOfHoldings: List<StatementOfHoldings> = emptyList()
override fun toString(): String {
return "$productName ($identifier)"

View File

@ -8,7 +8,6 @@ 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
import kotlin.test.assertNotNull
class Mt535ParserTest {
@ -37,7 +36,7 @@ class Mt535ParserTest {
assertEquals("1234567", statement.accountIdentifier)
assertEquals("17026,37", statement.totalBalance?.string)
assertEquals("EUR", statement.totalBalanceCurrency)
assertEquals("EUR", statement.currency)
assertEquals(1, statement.pageNumber)
assertEquals(ContinuationIndicator.SinglePage, statement.continuationIndicator)
@ -61,7 +60,7 @@ class Mt535ParserTest {
assertEquals(accountId, statement.accountIdentifier)
assertEquals(totalBalance, statement.totalBalance?.string)
assertEquals(totalBalanceCurrency, statement.totalBalanceCurrency)
assertEquals(totalBalanceCurrency, statement.currency)
assertEquals(pageNumber, statement.pageNumber)
assertEquals(continuationIndicator, statement.continuationIndicator)