Persisting AccountTransactions in db

This commit is contained in:
dankito 2024-08-27 11:48:46 +02:00
parent 7495cb89c2
commit 6d35b9c64f
15 changed files with 478 additions and 14 deletions

2
.gitignore vendored
View File

@ -22,4 +22,4 @@ xcuserdata
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
composeApp/data/logs
composeApp/data/

View File

@ -11,6 +11,8 @@ plugins {
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinxSerialization)
alias(libs.plugins.sqldelight)
}
@ -63,6 +65,10 @@ kotlin {
implementation(libs.klf)
implementation(libs.kotlinx.serializable)
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.coroutines.extensions)
implementation(libs.sqldelight.paging.extensions)
// UI
implementation(compose.runtime)
implementation(compose.foundation)
@ -75,20 +81,50 @@ kotlin {
implementation(libs.androidx.lifecycle.runtime.compose)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.sqldelight.android.driver)
}
nativeMain.dependencies {
implementation(libs.sqldelight.native.driver)
}
jvmMain.dependencies {
implementation(libs.sqldelight.sqlite.driver)
}
jvmTest.dependencies {
implementation(libs.kotlin.test.junit)
}
desktopMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.sqldelight.sqlite.driver)
implementation(libs.logback)
}
}
}
sqldelight {
databases {
create("BankmeisterDb") {
packageName.set("net.codinux.banking.dataaccess")
generateAsync.set(true)
}
}
}
android {
namespace = "net.codinux.banking.ui"
compileSdk = libs.versions.android.compileSdk.get().toInt()

View File

@ -5,11 +5,17 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import net.codinux.banking.dataaccess.BankmeisterDb
import net.codinux.banking.ui.config.DI
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DI.setRepository(AndroidSqliteDriver(BankmeisterDb.Schema.synchronous(), this, "Bankmeister.db"))
setContent {
App()
}

View File

@ -0,0 +1,12 @@
package net.codinux.banking.dataaccess
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
interface BankingRepository {
fun getAllAccountTransactions(): List<AccountTransactionEntity>
suspend fun persistAccountTransactions(transactions: Collection<AccountTransaction>)
}

View File

@ -0,0 +1,30 @@
package net.codinux.banking.dataaccess
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
class InMemoryBankingRepository(
transactions: Collection<AccountTransaction>
) : BankingRepository {
private var nextId = 0L // TODO: make thread-safe
private val transactions = transactions.map { map(it) }.toMutableList()
override fun getAllAccountTransactions(): List<AccountTransactionEntity> = transactions.toList()
override suspend fun persistAccountTransactions(transactions: Collection<AccountTransaction>) {
this.transactions.addAll(transactions.map { map(it) })
}
private fun map(transaction: AccountTransaction) = AccountTransactionEntity(
nextId++,
transaction.amount, transaction.currency, transaction.reference,
transaction.bookingDate, transaction.valueDate,
transaction.otherPartyName, transaction.otherPartyBankCode, transaction.otherPartyAccountId,
transaction.bookingText
)
}

View File

@ -0,0 +1,92 @@
package net.codinux.banking.dataaccess
import app.cash.sqldelight.db.SqlDriver
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount
import net.codinux.banking.dataaccess.entities.AccountTransactionEntity
class SqliteBankingRepository(
sqlDriver: SqlDriver
) : BankingRepository {
private val database = BankmeisterDb(sqlDriver)
private val accountTransactionQueries = database.accountTransactionQueries
override fun getAllAccountTransactions(): List<AccountTransactionEntity> {
return accountTransactionQueries.selectAllTransactions { id, amount, currency, reference, bookingDate, valueDate, otherPartyName, otherPartyBankCode, otherPartyAccountId, bookingText, userSetDisplayName, 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,
Amount(amount), currency, reference,
mapToDate(bookingDate), mapToDate(valueDate),
otherPartyName, otherPartyBankCode, otherPartyAccountId,
bookingText,
userSetDisplayName, 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 persistAccountTransactions(transactions: Collection<AccountTransaction>) {
transactions.forEach {
saveAccountTransaction(it)
}
}
private suspend fun saveAccountTransaction(transaction: AccountTransaction) {
accountTransactionQueries.insertTransaction(
transaction.amount.amount, transaction.currency, transaction.reference,
mapDate(transaction.bookingDate), mapDate(transaction.valueDate),
transaction.otherPartyName, transaction.otherPartyBankCode, transaction.otherPartyAccountId,
transaction.bookingText,
transaction.userSetDisplayName, transaction.notes,
transaction.information,
transaction.statementNumber?.toLong(), transaction.sequenceNumber?.toLong(),
transaction.openingBalance?.amount, transaction.closingBalance?.amount,
transaction.endToEndReference, transaction.customerReference, transaction.mandateReference,
transaction.creditorIdentifier, transaction.originatorsIdentificationCode,
transaction.compensationAmount, transaction.originalAmount,
transaction.sepaReference,
transaction.deviantOriginator, transaction.deviantRecipient,
transaction.referenceWithNoSpecialType, transaction.primaNotaNumber, transaction.textKeySupplement,
transaction.currencyType, transaction.bookingKey,
transaction.referenceForTheAccountOwner, transaction.referenceOfTheAccountServicingInstitution,
transaction.supplementaryDetails,
transaction.transactionReferenceNumber, transaction.relatedReferenceNumber
)
}
private fun mapToAmount(serializedAmount: String?): Amount? = serializedAmount?.let { Amount(it) }
private fun mapDate(date: LocalDate): String = date.toString()
private fun mapToDate(serializedDate: String): LocalDate = LocalDate.parse(serializedDate)
}

View File

@ -0,0 +1,80 @@
package net.codinux.banking.dataaccess.entities
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount
class AccountTransactionEntity(
val id: Long,
amount: Amount,
currency: String,
reference: String,
bookingDate: LocalDate,
valueDate: LocalDate,
otherPartyName: String? = null,
otherPartyBankCode: String? = null,
otherPartyAccountId: String? = null,
bookingText: String? = null,
userSetDisplayName: String? = null,
notes: String? = null,
information: String? = null,
statementNumber: Int? = null,
sequenceNumber: Int? = null,
openingBalance: Amount? = null,
closingBalance: Amount? = null,
endToEndReference: String? = null,
customerReference: String? = null,
mandateReference: String? = null,
creditorIdentifier: String? = null,
originatorsIdentificationCode: String? = null,
compensationAmount: String? = null,
originalAmount: String? = null,
sepaReference: String? = null,
deviantOriginator: String? = null,
deviantRecipient: String? = null,
referenceWithNoSpecialType: String? = null,
primaNotaNumber: String? = null,
textKeySupplement: String? = null,
currencyType: String? = null,
bookingKey: String? = null,
referenceForTheAccountOwner: String? = null,
referenceOfTheAccountServicingInstitution: String? = null,
supplementaryDetails: String? = null,
transactionReferenceNumber: String? = null,
relatedReferenceNumber: String? = null
) : AccountTransaction(
amount, currency, reference,
bookingDate, valueDate,
otherPartyName, otherPartyBankCode, otherPartyAccountId,
bookingText,
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,
userSetDisplayName, notes
)

View File

@ -14,7 +14,6 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.ui.composables.StateHandler
import net.codinux.banking.ui.composables.TransactionsList
import net.codinux.banking.ui.dialogs.AddAccountDialog

View File

@ -1,10 +1,12 @@
package net.codinux.banking.ui.config
import app.cash.sqldelight.db.SqlDriver
import net.codinux.banking.dataaccess.BankingRepository
import net.codinux.banking.dataaccess.InMemoryBankingRepository
import net.codinux.banking.dataaccess.SqliteBankingRepository
import net.codinux.banking.ui.Platform
import net.codinux.banking.ui.getPlatform
import net.codinux.banking.ui.service.BankFinder
import net.codinux.banking.ui.service.BankingService
import net.codinux.banking.ui.service.FormatUtil
import net.codinux.banking.ui.service.*
import net.codinux.banking.ui.state.UiState
object DI {
@ -13,12 +15,24 @@ object DI {
val platform: Platform = getPlatform()
val bankFinder = BankFinder()
val bankingService = BankingService(uiState, bankFinder)
val formatUtil = FormatUtil()
val bankFinder = BankFinder()
var bankingRepository: BankingRepository = InMemoryBankingRepository(emptyList())
val bankingService by lazy {
BankingService(uiState, bankingRepository, bankFinder) }
fun setRepository(sqlDriver: SqlDriver) = setRepository(SqliteBankingRepository(sqlDriver))
fun setRepository(repository: BankingRepository) {
this.bankingRepository = repository
}
suspend fun init() {
bankingService.init()

View File

@ -10,6 +10,7 @@ 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.*
import net.codinux.banking.dataaccess.BankingRepository
import net.codinux.banking.fints.config.FinTsClientConfiguration
import net.codinux.banking.fints.config.FinTsClientOptions
import net.codinux.banking.ui.model.BankInfo
@ -25,6 +26,7 @@ import org.jetbrains.compose.resources.ExperimentalResourceApi
@OptIn(ExperimentalResourceApi::class)
class BankingService(
private val uiState: UiState,
private val bankingRepository: BankingRepository,
private val bankFinder: BankFinder
) {
@ -36,9 +38,11 @@ class BankingService(
suspend fun init() {
val transactions = readTransactionsFromCsv()
uiState.transactions.value = transactions
try {
uiState.transactions.value = bankingRepository.getAllAccountTransactions()
} catch (e: Throwable) {
log.error(e) { "Could not read all account transactions from repository" }
}
}
suspend fun findBanks(query: String): List<BankInfo> =
@ -64,13 +68,21 @@ class BankingService(
}
}
private fun handleSuccessfulGetAccountDataResponse(response: GetAccountDataResponse) {
private suspend fun handleSuccessfulGetAccountDataResponse(response: GetAccountDataResponse) {
// TODO: save customer
val transactions = uiState.transactions.value.toMutableList()
transactions.addAll(response.bookedTransactions)
uiState.transactions.value = transactions.sortedByDescending { it.valueDate }
try {
bankingRepository.persistAccountTransactions(response.bookedTransactions)
log.info { "Saved ${response.bookedTransactions.size} transactions" }
} catch (e: Throwable) {
log.error(e) { "Could not save account transactions ${response.bookedTransactions}" }
}
}
private fun handleUnsuccessfulBankingClientResponse(action: BankingClientAction, response: Response<*>) {

View File

@ -0,0 +1,110 @@
CREATE TABLE IF NOT EXISTS AccountTransaction (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount TEXT NOT NULL,
currency TEXT NOT NULL,
reference TEXT NOT NULL,
bookingDate TEXT NOT NULL,
valueDate TEXT NOT NULL,
otherPartyName TEXT,
otherPartyBankCode TEXT,
otherPartyAccountId TEXT,
bookingText TEXT,
userSetDisplayName TEXT,
notes TEXT,
information TEXT,
statementNumber INTEGER,
sequenceNumber INTEGER,
openingBalance TEXT,
closingBalance TEXT,
endToEndReference TEXT,
customerReference TEXT,
mandateReference TEXT,
creditorIdentifier TEXT,
originatorsIdentificationCode TEXT,
compensationAmount TEXT,
originalAmount TEXT,
sepaReference TEXT,
deviantOriginator TEXT,
deviantRecipient TEXT,
referenceWithNoSpecialType TEXT,
primaNotaNumber TEXT,
textKeySupplement TEXT,
currencyType TEXT,
bookingKey TEXT,
referenceForTheAccountOwner TEXT,
referenceOfTheAccountServicingInstitution TEXT,
supplementaryDetails TEXT,
transactionReferenceNumber TEXT,
relatedReferenceNumber TEXT
);
insertTransaction:
INSERT INTO AccountTransaction(
amount, currency, reference,
bookingDate, valueDate,
otherPartyName, otherPartyBankCode, otherPartyAccountId,
bookingText,
userSetDisplayName, 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
)
VALUES(
?, ?, ?,
?, ?,
?, ?, ?,
?,
?, ?,
?,
?, ?,
?, ?,
?, ?, ?,
?, ?,
?, ?,
?,
?, ?,
?,
?, ?,
?, ?,
?, ?,
?,
?, ?
);
selectAllTransactions:
SELECT AccountTransaction.*
FROM AccountTransaction;

View File

@ -4,9 +4,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import bankmeister.composeapp.generated.resources.AppIcon_svg
import bankmeister.composeapp.generated.resources.Res
import net.codinux.banking.dataaccess.BankmeisterDb
import net.codinux.banking.ui.config.DI
import net.codinux.log.Log
import org.jetbrains.compose.resources.painterResource
import java.io.File
fun main() = application {
Window(
@ -15,6 +21,11 @@ fun main() = application {
icon = painterResource(Res.drawable.AppIcon_svg),
state = WindowState(position = WindowPosition(Alignment.Center), size = DpSize(900.dp, 800.dp)),
) {
File("data/db").mkdirs()
DI.setRepository(JdbcSqliteDriver("jdbc:sqlite:data/db/Bankmeister.db").apply {
val schema = BankmeisterDb.Schema.synchronous().create(this)
})
App()
}
}

View File

@ -0,0 +1,43 @@
package net.codinux.banking.dataaccess
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class SqliteBankingRepositoryTest {
private val sqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY).apply {
BankmeisterDb.Schema.synchronous().create(this)
}
private val underTest = SqliteBankingRepository(sqlDriver)
@Test
fun saveTransaction() = runBlocking {
val transaction = AccountTransaction(Amount("12.45"), "EUR", "Lohn", LocalDate(2024, 5, 7), LocalDate(2024, 6, 15), "Dein Boss")
underTest.persistAccountTransactions(listOf(transaction))
val result = underTest.getAllAccountTransactions()
assertEquals(1, result.size)
val persisted = result.first()
assertNotNull(persisted.id)
assertEquals(transaction.amount, persisted.amount)
assertEquals(transaction.currency, persisted.currency)
assertEquals(transaction.reference, persisted.reference)
assertEquals(transaction.bookingDate, persisted.bookingDate)
assertEquals(transaction.valueDate, persisted.valueDate)
assertEquals(transaction.otherPartyName, persisted.otherPartyName)
}
}

View File

@ -1,5 +1,13 @@
package net.codinux.banking.ui
import androidx.compose.ui.window.ComposeUIViewController
import app.cash.sqldelight.async.coroutines.synchronous
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import net.codinux.banking.dataaccess.BankmeisterDb
import net.codinux.banking.ui.config.DI
fun MainViewController() = ComposeUIViewController { App() }
fun MainViewController() = ComposeUIViewController {
DI.setRepository(NativeSqliteDriver(Database.Schema.synchronous(), "Bankmeister.db"))
App()
}

View File

@ -10,6 +10,8 @@ kotlinx-serializable = "1.7.1"
klf = "1.6.0"
logback = "1.5.7"
sqlDelight = "2.0.2"
agp = "8.2.2"
android-compileSdk = "34"
android-minSdk = "24"
@ -36,6 +38,13 @@ kotlinx-serializable = { group = "org.jetbrains.kotlinx", name = "kotlinx-serial
klf = { group = "net.codinux.log", name = "klf", version.ref = "klf" }
logback = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" }
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqlDelight" }
sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqlDelight" }
sqldelight-paging-extensions = { module = "app.cash.sqldelight:androidx-paging3-extensions", version.ref = "sqlDelight" }
sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqlDelight" }
sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" }
sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" }
@ -57,4 +66,6 @@ kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidApplication = { id = "com.android.application", version.ref = "agp" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" }