Implemented updating account's transactions

This commit is contained in:
dankito 2024-09-03 18:57:59 +02:00
parent 3eb3c488da
commit d3d90d4737
10 changed files with 259 additions and 15 deletions

View File

@ -1,7 +1,9 @@
package net.codinux.banking.dataaccess
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.UserAccount
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.dataaccess.entities.UserAccountEntity
import net.codinux.banking.ui.model.AccountTransactionViewModel
@ -11,9 +13,13 @@ interface BankingRepository {
suspend fun persistUserAccount(userAccount: UserAccount): UserAccountEntity
suspend fun persistTransactions(bankAccount: BankAccountEntity, transactions: List<AccountTransaction>): List<AccountTransactionEntity>
fun getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel>
fun getAllAccountTransactions(): List<AccountTransactionEntity>
fun getAllTransactionsOfUserAccount(userAccount: UserAccountEntity): List<AccountTransactionEntity>
}

View File

@ -3,6 +3,7 @@ package net.codinux.banking.dataaccess
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.UserAccount
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.dataaccess.entities.UserAccountEntity
import net.codinux.banking.ui.model.AccountTransactionViewModel
@ -26,12 +27,19 @@ class InMemoryBankingRepository(
return entity
}
override suspend fun persistTransactions(bankAccount: BankAccountEntity, transactions: List<AccountTransaction>): List<AccountTransactionEntity> {
throw NotImplementedError("Lazy developer, method is not implemented")
}
override fun getAllAccountTransactionsAsViewModel(): List<AccountTransactionViewModel> =
transactions.map { AccountTransactionViewModel(it) }
override fun getAllAccountTransactions(): List<AccountTransactionEntity> = transactions.toList()
override fun getAllTransactionsOfUserAccount(userAccount: UserAccountEntity): List<AccountTransactionEntity> =
getAllAccountTransactions().filter { it.userAccountId == userAccount.id }
private fun map(account: UserAccount) = UserAccountEntity(
nextId++,

View File

@ -139,8 +139,52 @@ open class SqliteBankingRepository(
}.executeAsList()
}
override fun getAllTransactionsOfUserAccount(userAccount: UserAccountEntity): List<AccountTransactionEntity> {
return accountTransactionQueries.selectAllTransactionsOfUserAccount(userAccount.id) { id, userAccountId, bankAccountId, amount, currency, reference, bookingDate, valueDate, otherPartyName, otherPartyBankCode, otherPartyAccountId, bookingText, userSetDisplayName, category, notes, information, statementNumber, sequenceNumber, openingBalance, closingBalance, endToEndReference, customerReference, mandateReference, creditorIdentifier, originatorsIdentificationCode, compensationAmount, originalAmount, sepaReference, deviantOriginator, deviantRecipient, referenceWithNoSpecialType, primaNotaNumber, textKeySupplement, currencyType, bookingKey, referenceForTheAccountOwner, referenceOfTheAccountServicingInstitution, supplementaryDetails, transactionReferenceNumber, relatedReferenceNumber ->
AccountTransactionEntity(
id,
userAccountId, bankAccountId,
Amount(amount), currency, reference,
mapToDate(bookingDate), mapToDate(valueDate),
otherPartyName, otherPartyBankCode, otherPartyAccountId,
bookingText,
userSetDisplayName, category, notes,
information,
statementNumber?.toInt(), sequenceNumber?.toInt(),
mapToAmount(openingBalance), mapToAmount(closingBalance),
endToEndReference, customerReference, mandateReference,
creditorIdentifier, originatorsIdentificationCode,
compensationAmount, originalAmount,
sepaReference,
deviantOriginator, deviantRecipient,
referenceWithNoSpecialType, primaNotaNumber,
textKeySupplement,
currencyType, bookingKey,
referenceForTheAccountOwner, referenceOfTheAccountServicingInstitution,
supplementaryDetails,
transactionReferenceNumber, relatedReferenceNumber
)
}.executeAsList()
}
override suspend fun persistTransactions(bankAccount: BankAccountEntity, transactions: List<AccountTransaction>): List<AccountTransactionEntity> {
return accountTransactionQueries.transactionWithResult {
transactions.map { transaction ->
persistTransaction(bankAccount.userAccountId, bankAccount.id, transaction)
}
}
}
/**
* Has to be executed in a transaction in order that getting persisted BankAccount's id works~
* Has to be executed in a transaction in order that getting persisted AccountTransaction's id works~
*/
protected open suspend fun persistTransaction(userAccountId: Long, bankAccountId: Long, transaction: AccountTransaction): AccountTransactionEntity {
accountTransactionQueries.insertTransaction(

View File

@ -32,7 +32,13 @@ private val typography = Typography(
fun App() {
LoggerFactory.defaultLoggerName = "net.codinux.banking.ui.Bankmeister"
val colors = MaterialTheme.colors.copy(primary = Colors.Primary, primaryVariant = Colors.PrimaryDark, secondary = Colors.Accent, onSecondary = Color.White)
val colors = MaterialTheme.colors.copy(primary = Colors.Primary, primaryVariant = Colors.PrimaryDark, onPrimary = Color.White,
secondary = Colors.Accent, secondaryVariant = Colors.Accent, onSecondary = Color.White)
val snackbarHostState = remember { SnackbarHostState() }
// the same values as in BottomBar, but LocalContentColor.current and LocalContentAlpha.current have a different value there
val snackbarTextColor = MaterialTheme.colors.onPrimary.copy(alpha = 0.74f) // 0.74f = ContentAlpha.HighContrastContentAlpha.medium
val coroutineScope = rememberCoroutineScope()
@ -51,6 +57,23 @@ fun App() {
TransactionsList(DI.uiState)
}
SnackbarHost(
hostState = snackbarHostState
) { data ->
Snackbar(
modifier = Modifier.padding(bottom = 18.dp).padding(12.dp),
action = { if (data.actionLabel == null) null else {
TextButton(
onClick = { data.performAction() },
content = { Text(data.actionLabel!!, color = Colors.CodinuxSecondaryColor) }
)
}
},
content = { Text(data.message, color = snackbarTextColor) },
backgroundColor = Colors.Primary
)
}
BottomBar()
}
@ -65,7 +88,7 @@ fun App() {
}
}
StateHandler(DI.uiState)
StateHandler(DI.uiState, snackbarHostState)
}
}
}

View File

@ -138,6 +138,12 @@ fun BottomBar() {
}
}
}
Row(Modifier.fillMaxHeight().widthIn(IconWidth.times(2), IconWidth.times(2)), verticalAlignment = Alignment.CenterVertically) {
IconButton({ coroutineScope.launch { DI.bankingService.updateAccountTransactions() } }, Modifier.width(IconWidth)) { // TODO: use sync, cached or autorenew as icon?
Icon(Icons.Filled.Refresh, contentDescription = "Neue Kontoumsätze abholen")
}
}
}
}
}

View File

@ -1,6 +1,10 @@
package net.codinux.banking.ui.composables
import androidx.compose.material.SnackbarDuration
import androidx.compose.material.SnackbarHostState
import androidx.compose.runtime.*
import kotlinx.coroutines.launch
import net.codinux.banking.ui.config.DI
import net.codinux.banking.ui.dialogs.AddAccountDialog
import net.codinux.banking.ui.dialogs.ApplicationErrorDialog
import net.codinux.banking.ui.dialogs.BankingClientErrorDialog
@ -9,7 +13,7 @@ import net.codinux.banking.ui.screens.ExportScreen
import net.codinux.banking.ui.state.UiState
@Composable
fun StateHandler(uiState: UiState) {
fun StateHandler(uiState: UiState, snackbarHostState: SnackbarHostState) {
val showAddAccountDialog by uiState.showAddAccountDialog.collectAsState()
val showExportScreen by uiState.showExportScreen.collectAsState()
@ -17,6 +21,8 @@ fun StateHandler(uiState: UiState) {
val bankingClientError by uiState.bankingClientErrorOccurred.collectAsState()
val applicationError by uiState.applicationErrorOccurred.collectAsState()
val coroutineScope = rememberCoroutineScope()
if (showAddAccountDialog) {
AddAccountDialog { uiState.showAddAccountDialog.value = false }
@ -45,4 +51,25 @@ fun StateHandler(uiState: UiState) {
}
}
LaunchedEffect(Unit) {
coroutineScope.launch {
DI.uiState.transactionsRetrievedEvents.collect { event ->
val messagePrefix = if (event.newTransactions.isEmpty()) {
"Keine neuen Umsätze"
} else if (event.newTransactions.size == 1) {
"1 Umsatz"
} else {
"${event.newTransactions.size} Umsätze"
}
snackbarHostState.showSnackbar(
message = "$messagePrefix für ${event.user.displayName} ${event.account.displayName}",
actionLabel = "Coolio",
duration = SnackbarDuration.Long
)
}
}
}
}

View File

@ -0,0 +1,11 @@
package net.codinux.banking.ui.model.events
import net.codinux.banking.client.model.BankAccount
import net.codinux.banking.client.model.UserAccount
import net.codinux.banking.ui.model.AccountTransactionViewModel
data class AccountTransactionsRetrieved(
val user: UserAccount,
val account: BankAccount,
val newTransactions: List<AccountTransactionViewModel>
)

View File

@ -1,24 +1,27 @@
package net.codinux.banking.ui.service
import bankmeister.composeapp.generated.resources.Res
import kotlinx.coroutines.*
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.SimpleBankingClientCallback
import net.codinux.banking.client.fints4k.FinTs4kBankingClient
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.*
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.options.RetrieveTransactions
import net.codinux.banking.client.model.request.GetAccountDataRequest
import net.codinux.banking.client.model.response.ErrorType
import net.codinux.banking.client.model.response.GetAccountDataResponse
import net.codinux.banking.client.model.response.Response
import net.codinux.banking.client.model.response.ResponseType
import net.codinux.banking.client.model.response.*
import net.codinux.banking.dataaccess.BankingRepository
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
import net.codinux.banking.dataaccess.entities.BankAccountEntity
import net.codinux.banking.dataaccess.entities.UserAccountEntity
import net.codinux.banking.fints.config.FinTsClientConfiguration
import net.codinux.banking.fints.config.FinTsClientOptions
import net.codinux.banking.ui.IOorDefault
import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.BankInfo
import net.codinux.banking.ui.model.TanChallengeReceived
import net.codinux.banking.ui.model.error.*
import net.codinux.banking.ui.model.events.AccountTransactionsRetrieved
import net.codinux.banking.ui.state.UiState
import net.codinux.csv.reader.CsvReader
import net.codinux.log.logger
@ -53,6 +56,8 @@ class BankingService(
fun getAllAccountTransactions() = bankingRepository.getAllAccountTransactions()
fun getAllTransactionsOfUserAccount(userAccount: UserAccountEntity) = bankingRepository.getAllTransactionsOfUserAccount(userAccount)
fun getAllAccountTransactionsAsViewModel() = bankingRepository.getAllAccountTransactionsAsViewModel()
@ -90,14 +95,109 @@ class BankingService(
userAccounts.add(newUserEntity)
uiState.userAccounts.value = userAccounts
val transactions = uiState.transactions.value.toMutableList()
transactions.addAll(newUserEntity.accounts.flatMap { it.bookedTransactionsEntities }.map { AccountTransactionViewModel(it) })
uiState.transactions.value = transactions.sortedByDescending { it.valueDate }
updateTransactionsInUi(newUserEntity.accounts.flatMap { it.bookedTransactionsEntities })
} catch (e: Throwable) {
log.error(e) { "Could not save user account ${response.user}" }
}
}
suspend fun updateAccountTransactions() {
val selectedAccount = uiState.transactionsFilter.value.selectedAccount
if (selectedAccount != null) {
updateAccountTransactions(selectedAccount.userAccount, selectedAccount.bankAccount)
} else {
uiState.userAccounts.value.forEach { user ->
updateAccountTransactions(user)
}
}
}
private suspend fun updateAccountTransactions(user: UserAccountEntity, bankAccount: BankAccountEntity? = null) {
withContext(Dispatchers.IOorDefault) {
try {
val response = client.updateAccountTransactionsAsync(user, bankAccount?.let { listOf(it) })
if (response.type == ResponseType.Success && response.data != null) {
handleSuccessfulUpdateAccountTransactionsResponse(user, response.data!!)
} else {
handleUnsuccessfulBankingClientResponse(BankingClientAction.UpdateAccountTransactions, response)
}
} catch (e: Throwable) {
log.error(e) { "Could not update account transactions for $user" }
}
}
}
private suspend fun handleSuccessfulUpdateAccountTransactionsResponse(user: UserAccountEntity, responses: List<GetTransactionsResponse>) {
try {
// TODO: when user gets updated by BankingClient, also update user in database
// val newUser = response.user
// val newUserEntity = bankingRepository.persistUserAccount(newUser)
//
// log.info { "Saved user account $newUserEntity with ${newUserEntity.accounts.flatMap { it.bookedTransactionsEntities }.size} transactions" }
val userAccountTransactions = getAllTransactionsOfUserAccount(user)
responses.forEach { response ->
val account = (response.account as? BankAccountEntity) ?: user.accounts.first { it.identifier == response.account.identifier && it.subAccountNumber == response.account.subAccountNumber }
// TODO: this should be done in BankingClient
if (account.lastTransactionRetrievalTime == null || account.lastTransactionRetrievalTime!! < response.transactionRetrievalTime) {
account.lastTransactionRetrievalTime = response.transactionRetrievalTime
}
if (account.retrievedTransactionsFrom == null || (response.retrievedTransactionsFrom != null && account.retrievedTransactionsFrom!! < response.retrievedTransactionsFrom!!)) {
account.retrievedTransactionsFrom = response.retrievedTransactionsFrom
}
// TODO: update BankAccount and may updated Transactions in database
val newTransactions = findNewTransactions(response.bookedTransactions, account, userAccountTransactions)
val newTransactionsEntities = if (newTransactions.isNotEmpty()) {
bankingRepository.persistTransactions(account, newTransactions)
} else {
emptyList()
}
val transactionsViewModel = updateTransactionsInUi(newTransactionsEntities)
uiState.fireNewTransactionsRetrieved(AccountTransactionsRetrieved(user, account, transactionsViewModel))
}
} catch (e: Throwable) {
log.error(e) { "Could not save updated account transactions for user $user" }
}
}
private fun findNewTransactions(retrievedTransactions: List<AccountTransaction>, account: BankAccountEntity, existingTransactions: List<AccountTransactionEntity>): List<AccountTransaction> {
val existingAccountTransactions = existingTransactions.filter { it.bankAccountId == account.id }
val accountId = account.id.toString()
val existingTransactionsByIdentifier = existingAccountTransactions.associateBy { getTransactionsIdentifier(it, accountId) }
val existingTransactionsIdentifiers = existingTransactionsByIdentifier.keys
// val (newTransactions, duplicateTransactions) = retrievedTransactions.partition {
// existingTransactionsIdentifiers.contains(getTransactionsIdentifier(it, accountId)) == false
// }
// if (duplicateTransactions.isEmpty() || newTransactions.isEmpty()) { }
return retrievedTransactions.filter { existingTransactionsIdentifiers.contains(getTransactionsIdentifier(it, accountId)) == false }
}
private fun getTransactionsIdentifier(transaction: AccountTransaction, accountId: String? = null): String =
with(transaction) {
"${accountId ?: ""} $amount $currency $bookingDate $valueDate $unparsedReference $sepaReference $otherPartyName $otherPartyBankCode $otherPartyAccountId"
}
private fun updateTransactionsInUi(addedTransactions: List<AccountTransactionEntity>): List<AccountTransactionViewModel> {
val transactionsViewModel = addedTransactions.map { AccountTransactionViewModel(it) }
val transactions = uiState.transactions.value.toMutableList()
transactions.addAll(transactionsViewModel)
uiState.transactions.value = transactions.sortedByDescending { it.valueDate }
return transactionsViewModel
}
private fun handleUnsuccessfulBankingClientResponse(action: BankingClientAction, response: Response<*>) {
log.error { "$action was not successful: $response" }
@ -108,6 +208,7 @@ class BankingService(
}
}
private suspend fun readTransactionsFromCsv(): List<AccountTransaction> {
val csv = Res.readBytes("files/transactions.csv").decodeToString()
val csvReader = CsvReader(hasHeaderRow = true, reuseRowInstance = true, skipEmptyRows = true).read(csv)

View File

@ -3,15 +3,18 @@ package net.codinux.banking.ui.state
import androidx.compose.material.DrawerState
import androidx.compose.material.DrawerValue
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import net.codinux.banking.dataaccess.entities.UserAccountEntity
import net.codinux.banking.ui.model.AccountTransactionViewModel
import net.codinux.banking.ui.model.AccountTransactionsFilter
import net.codinux.banking.ui.model.BankAccountFilter
import net.codinux.banking.ui.model.TanChallengeReceived
import net.codinux.banking.ui.model.error.ApplicationError
import net.codinux.banking.ui.model.error.BankingClientError
import net.codinux.banking.ui.model.error.ErroneousAction
import net.codinux.banking.ui.model.events.AccountTransactionsRetrieved
class UiState : ViewModel() {
@ -19,6 +22,16 @@ class UiState : ViewModel() {
val transactions = MutableStateFlow<List<AccountTransactionViewModel>>(emptyList())
val transactionsRetrievedEvents = MutableSharedFlow<AccountTransactionsRetrieved>()
suspend fun fireNewTransactionsRetrieved(event: AccountTransactionsRetrieved) {
coroutineScope {
launch {
transactionsRetrievedEvents.emit(event)
}
}
}
val drawerState = MutableStateFlow(DrawerState(DrawerValue.Closed))

View File

@ -119,4 +119,9 @@ FROM AccountTransaction;
selectAllTransactionsAsViewModel:
SELECT id, userAccountId, bankAccountId, amount, currency, unparsedReference, valueDate, otherPartyName, bookingText, sepaReference, userSetDisplayName, category
FROM AccountTransaction;
FROM AccountTransaction;
selectAllTransactionsOfUserAccount:
SELECT AccountTransaction.*
FROM AccountTransaction WHERE userAccountId = ?;