From d3d90d473757aaaf23073763772a3c7df11bd1f4 Mon Sep 17 00:00:00 2001 From: dankito Date: Tue, 3 Sep 2024 18:57:59 +0200 Subject: [PATCH] Implemented updating account's transactions --- .../banking/dataaccess/BankingRepository.kt | 6 + .../dataaccess/InMemoryBankingRepository.kt | 8 ++ .../dataaccess/SqliteBankingRepository.kt | 46 ++++++- .../kotlin/net/codinux/banking/ui/App.kt | 27 +++- .../banking/ui/appskeleton/BottomBar.kt | 6 + .../banking/ui/composables/StateHandler.kt | 29 ++++- .../events/AccountTransactionsRetrieved.kt | 11 ++ .../banking/ui/service/BankingService.kt | 119 ++++++++++++++++-- .../net/codinux/banking/ui/state/UiState.kt | 15 ++- .../codinux/banking/ui/AccountTransaction.sq | 7 +- 10 files changed, 259 insertions(+), 15 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/events/AccountTransactionsRetrieved.kt diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/dataaccess/BankingRepository.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/dataaccess/BankingRepository.kt index 2117033..8c05670 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/dataaccess/BankingRepository.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/dataaccess/BankingRepository.kt @@ -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): List + fun getAllAccountTransactionsAsViewModel(): List fun getAllAccountTransactions(): List + fun getAllTransactionsOfUserAccount(userAccount: UserAccountEntity): List + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/dataaccess/InMemoryBankingRepository.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/dataaccess/InMemoryBankingRepository.kt index fcb2641..976b89c 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/dataaccess/InMemoryBankingRepository.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/dataaccess/InMemoryBankingRepository.kt @@ -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): List { + throw NotImplementedError("Lazy developer, method is not implemented") + } + override fun getAllAccountTransactionsAsViewModel(): List = transactions.map { AccountTransactionViewModel(it) } override fun getAllAccountTransactions(): List = transactions.toList() + override fun getAllTransactionsOfUserAccount(userAccount: UserAccountEntity): List = + getAllAccountTransactions().filter { it.userAccountId == userAccount.id } + private fun map(account: UserAccount) = UserAccountEntity( nextId++, diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/dataaccess/SqliteBankingRepository.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/dataaccess/SqliteBankingRepository.kt index a0ca67a..980e390 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/dataaccess/SqliteBankingRepository.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/dataaccess/SqliteBankingRepository.kt @@ -139,8 +139,52 @@ open class SqliteBankingRepository( }.executeAsList() } + override fun getAllTransactionsOfUserAccount(userAccount: UserAccountEntity): List { + 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): List { + 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( diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/App.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/App.kt index ae3ce4c..267e819 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/App.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/App.kt @@ -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) } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/appskeleton/BottomBar.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/appskeleton/BottomBar.kt index 9cf4d1c..6b1b49a 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/appskeleton/BottomBar.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/appskeleton/BottomBar.kt @@ -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") + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/StateHandler.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/StateHandler.kt index e9b7fdf..214b651 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/StateHandler.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/StateHandler.kt @@ -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 + ) + } + } + } + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/events/AccountTransactionsRetrieved.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/events/AccountTransactionsRetrieved.kt new file mode 100644 index 0000000..3dd2d42 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/model/events/AccountTransactionsRetrieved.kt @@ -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 +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/BankingService.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/BankingService.kt index 0666886..d3418f6 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/BankingService.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/BankingService.kt @@ -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) { + 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, account: BankAccountEntity, existingTransactions: List): List { + 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): List { + 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 { val csv = Res.readBytes("files/transactions.csv").decodeToString() val csvReader = CsvReader(hasHeaderRow = true, reuseRowInstance = true, skipEmptyRows = true).read(csv) diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/state/UiState.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/state/UiState.kt index a02fb99..73f9a33 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/state/UiState.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/state/UiState.kt @@ -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>(emptyList()) + val transactionsRetrievedEvents = MutableSharedFlow() + + suspend fun fireNewTransactionsRetrieved(event: AccountTransactionsRetrieved) { + coroutineScope { + launch { + transactionsRetrievedEvents.emit(event) + } + } + } + val drawerState = MutableStateFlow(DrawerState(DrawerValue.Closed)) diff --git a/composeApp/src/commonMain/sqldelight/net/codinux/banking/ui/AccountTransaction.sq b/composeApp/src/commonMain/sqldelight/net/codinux/banking/ui/AccountTransaction.sq index d3b8970..4479a87 100644 --- a/composeApp/src/commonMain/sqldelight/net/codinux/banking/ui/AccountTransaction.sq +++ b/composeApp/src/commonMain/sqldelight/net/codinux/banking/ui/AccountTransaction.sq @@ -119,4 +119,9 @@ FROM AccountTransaction; selectAllTransactionsAsViewModel: SELECT id, userAccountId, bankAccountId, amount, currency, unparsedReference, valueDate, otherPartyName, bookingText, sepaReference, userSetDisplayName, category -FROM AccountTransaction; \ No newline at end of file +FROM AccountTransaction; + + +selectAllTransactionsOfUserAccount: +SELECT AccountTransaction.* +FROM AccountTransaction WHERE userAccountId = ?; \ No newline at end of file