From deab262731224b20dc8e926ef66579c2f529ca85 Mon Sep 17 00:00:00 2001
From: Dmitry Marchuk <code@dmarchuk.com>
Date: Mon, 27 Nov 2023 21:37:24 +0200
Subject: [PATCH] Fix Jackson serialization, MonoApis definitions, other scope
 issues #dockerpush #latest

---
 build.gradle.kts                              |  1 +
 .../io/github/smaugfm/monobudget/Main.kt      |  2 +-
 .../lifecycle/StatementItemProcessor.kt       |  4 +-
 .../JacksonFileStatementRetryRepository.kt    | 36 ++++++++++++++-
 .../common/retry/StatementRetryRequest.kt     |  5 ++-
 .../monobudget/common/telegram/TelegramApi.kt |  2 +-
 .../TransactionMessageFormatter.kt            | 11 +++--
 .../LunchmoneyTransactionMessageFormatter.kt  | 13 +++---
 .../github/smaugfm/monobudget/mono/MonoApi.kt |  4 +-
 .../ynab/YnabTransactionMessageFormatter.kt   | 11 ++---
 ...JacksonFileStatementRetryRepositoryTest.kt | 45 +++++++++++++++++++
 .../JacksonRepositoryRetriesTest.kt           | 28 ------------
 12 files changed, 106 insertions(+), 56 deletions(-)
 create mode 100644 src/test/kotlin/io/github/smaugfm/monobudget/common/retry/JacksonFileStatementRetryRepositoryTest.kt
 delete mode 100644 src/test/kotlin/io/github/smaugfm/monobudget/integration/JacksonRepositoryRetriesTest.kt

diff --git a/build.gradle.kts b/build.gradle.kts
index 74fdee9..2257b09 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -86,6 +86,7 @@ dependencies {
 }
 
 ktlint {
+    ignoreFailures.set(true)
     version.set("1.0.1")
     enableExperimentalRules.set(true)
     filter {
diff --git a/src/main/kotlin/io/github/smaugfm/monobudget/Main.kt b/src/main/kotlin/io/github/smaugfm/monobudget/Main.kt
index 1a18bca..f1cd8e7 100644
--- a/src/main/kotlin/io/github/smaugfm/monobudget/Main.kt
+++ b/src/main/kotlin/io/github/smaugfm/monobudget/Main.kt
@@ -106,7 +106,7 @@ private fun runtimeModule(
     single { settings.retry }
     single { apiRetry() }
     settings.accounts.settings.filterIsInstance<MonoAccountSettings>()
-        .forEach { s -> single { MonoApi(s.token, s.accountId) } }
+        .forEach { s -> single(StringQualifier(s.alias)) { MonoApi(s.token, s.accountId, s.alias) } }
     settings.transfer.forEach { s ->
         single(qualifier = StringQualifier(s.descriptionRegex.pattern)) { s }
     }
diff --git a/src/main/kotlin/io/github/smaugfm/monobudget/common/lifecycle/StatementItemProcessor.kt b/src/main/kotlin/io/github/smaugfm/monobudget/common/lifecycle/StatementItemProcessor.kt
index 6c6b16c..3d08b37 100644
--- a/src/main/kotlin/io/github/smaugfm/monobudget/common/lifecycle/StatementItemProcessor.kt
+++ b/src/main/kotlin/io/github/smaugfm/monobudget/common/lifecycle/StatementItemProcessor.kt
@@ -28,7 +28,7 @@ abstract class StatementItemProcessor<TTransaction, TNewTransaction>(
             transferDetector.checkTransfer()
 
         val transaction = transactionFactory.create(maybeTransfer)
-        val message = messageFormatter.format(transaction)
+        val message = messageFormatter.format(ctx.item, transaction)
 
         telegramMessageSender.send(ctx.item.accountId, message)
     }
@@ -42,7 +42,7 @@ abstract class StatementItemProcessor<TTransaction, TNewTransaction>(
                         this.pp()
                     } else {
                         "\tAmount: ${amount}\n" +
-                            "\tDescription: $description" +
+                            "\tDescription: $description\n" +
                             "\tMemo: $comment"
                     }
             }
diff --git a/src/main/kotlin/io/github/smaugfm/monobudget/common/retry/JacksonFileStatementRetryRepository.kt b/src/main/kotlin/io/github/smaugfm/monobudget/common/retry/JacksonFileStatementRetryRepository.kt
index 22bfbb9..4100417 100644
--- a/src/main/kotlin/io/github/smaugfm/monobudget/common/retry/JacksonFileStatementRetryRepository.kt
+++ b/src/main/kotlin/io/github/smaugfm/monobudget/common/retry/JacksonFileStatementRetryRepository.kt
@@ -1,11 +1,19 @@
 package io.github.smaugfm.monobudget.common.retry
 
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.core.JsonParser
 import com.fasterxml.jackson.core.type.TypeReference
+import com.fasterxml.jackson.databind.DeserializationContext
 import com.fasterxml.jackson.databind.SerializationFeature
+import com.fasterxml.jackson.databind.SerializerProvider
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer
+import com.fasterxml.jackson.databind.module.SimpleModule
+import com.fasterxml.jackson.databind.ser.std.StdSerializer
 import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
 import com.fasterxml.jackson.module.kotlin.jsonMapper
 import com.fasterxml.jackson.module.kotlin.kotlinModule
 import io.github.smaugfm.monobudget.common.lifecycle.StatementProcessingContext
+import kotlinx.datetime.Instant
 import java.nio.file.Path
 import java.nio.file.StandardOpenOption
 import java.util.UUID
@@ -17,11 +25,17 @@ import kotlin.time.Duration
 class JacksonFileStatementRetryRepository(
     private val path: Path,
 ) : StatementRetryRepository {
-    val objectMapper =
+    internal val objectMapper =
         jsonMapper {
             enable(SerializationFeature.INDENT_OUTPUT)
             addModule(kotlinModule())
             addModule(JavaTimeModule())
+            addModule(
+                SimpleModule().also {
+                    it.addSerializer(KotlinxTimeInstantJacksonSerializer())
+                    it.addDeserializer(Instant::class.java, KotlinxTimeInstantJacksonDeserializer())
+                },
+            )
         }
 
     override suspend fun addRetryRequest(
@@ -50,7 +64,25 @@ class JacksonFileStatementRetryRepository(
         path.writeText(
             objectMapper.writeValueAsString(list),
             Charsets.UTF_8,
-            StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE
+            StandardOpenOption.TRUNCATE_EXISTING,
+            StandardOpenOption.CREATE,
         )
     }
+
+    private class KotlinxTimeInstantJacksonSerializer : StdSerializer<Instant>(Instant::class.java) {
+        override fun serialize(
+            value: Instant?,
+            gen: JsonGenerator?,
+            provider: SerializerProvider?,
+        ) {
+            gen?.writeString(value?.toString())
+        }
+    }
+
+    private class KotlinxTimeInstantJacksonDeserializer : StdDeserializer<Instant>(Instant::class.java) {
+        override fun deserialize(
+            p: JsonParser?,
+            ctxt: DeserializationContext?,
+        ): Instant = Instant.parse(p?.valueAsString!!)
+    }
 }
diff --git a/src/main/kotlin/io/github/smaugfm/monobudget/common/retry/StatementRetryRequest.kt b/src/main/kotlin/io/github/smaugfm/monobudget/common/retry/StatementRetryRequest.kt
index 68c2098..c918476 100644
--- a/src/main/kotlin/io/github/smaugfm/monobudget/common/retry/StatementRetryRequest.kt
+++ b/src/main/kotlin/io/github/smaugfm/monobudget/common/retry/StatementRetryRequest.kt
@@ -1,5 +1,6 @@
 package io.github.smaugfm.monobudget.common.retry
 
+import com.fasterxml.jackson.annotation.JsonIgnore
 import io.github.smaugfm.monobudget.common.lifecycle.StatementProcessingContext
 import kotlinx.datetime.Clock
 import kotlinx.datetime.Instant
@@ -16,5 +17,7 @@ data class StatementRetryRequest(
         Clock.System.now() + retryIn,
     )
 
-    val retryIn get() = (retryAt - Clock.System.now()).coerceAtLeast(Duration.ZERO)
+    val retryIn
+        @JsonIgnore
+        get() = (retryAt - Clock.System.now()).coerceAtLeast(Duration.ZERO)
 }
diff --git a/src/main/kotlin/io/github/smaugfm/monobudget/common/telegram/TelegramApi.kt b/src/main/kotlin/io/github/smaugfm/monobudget/common/telegram/TelegramApi.kt
index 9df7c54..58096b2 100644
--- a/src/main/kotlin/io/github/smaugfm/monobudget/common/telegram/TelegramApi.kt
+++ b/src/main/kotlin/io/github/smaugfm/monobudget/common/telegram/TelegramApi.kt
@@ -46,7 +46,7 @@ class TelegramApi(
             allowSendingWithoutReply = null,
             replyMarkup = replyMarkup,
         ).also {
-            log.debug { "Sending message. \n\tTo: $chatId\n\ttext: $text\n\tkeyboard: ${replyMarkup?.pp()}" }
+            log.debug { "Sent message. \n\tTo: $chatId\n\ttext: $text\n\tkeyboard: ${replyMarkup?.pp()}" }
         }
     }
 
diff --git a/src/main/kotlin/io/github/smaugfm/monobudget/common/transaction/TransactionMessageFormatter.kt b/src/main/kotlin/io/github/smaugfm/monobudget/common/transaction/TransactionMessageFormatter.kt
index 9bb4e22..619a37a 100644
--- a/src/main/kotlin/io/github/smaugfm/monobudget/common/transaction/TransactionMessageFormatter.kt
+++ b/src/main/kotlin/io/github/smaugfm/monobudget/common/transaction/TransactionMessageFormatter.kt
@@ -17,14 +17,16 @@ import kotlin.math.roundToLong
 
 private val log = KotlinLogging.logger {}
 
-abstract class TransactionMessageFormatter<TTransaction>(
-    protected val statementItem: StatementItem,
-) : KoinComponent {
+abstract class TransactionMessageFormatter<TTransaction> : KoinComponent {
     private val bankAccounts: BankAccountService by inject()
 
-    suspend fun format(transaction: TTransaction): MessageWithReplyKeyboard {
+    suspend fun format(
+        statementItem: StatementItem,
+        transaction: TTransaction,
+    ): MessageWithReplyKeyboard {
         val msg =
             formatHTMLStatementMessage(
+                statementItem,
                 bankAccounts.getAccountCurrency(statementItem.accountId)!!,
                 transaction,
             )
@@ -53,6 +55,7 @@ abstract class TransactionMessageFormatter<TTransaction>(
     protected abstract fun getReplyKeyboard(pressed: PressedButtons): InlineKeyboardMarkup
 
     protected abstract suspend fun formatHTMLStatementMessage(
+        statementItem: StatementItem,
         accountCurrency: Currency,
         transaction: TTransaction,
     ): String
diff --git a/src/main/kotlin/io/github/smaugfm/monobudget/lunchmoney/LunchmoneyTransactionMessageFormatter.kt b/src/main/kotlin/io/github/smaugfm/monobudget/lunchmoney/LunchmoneyTransactionMessageFormatter.kt
index b5caac9..e9e5b98 100644
--- a/src/main/kotlin/io/github/smaugfm/monobudget/lunchmoney/LunchmoneyTransactionMessageFormatter.kt
+++ b/src/main/kotlin/io/github/smaugfm/monobudget/lunchmoney/LunchmoneyTransactionMessageFormatter.kt
@@ -4,28 +4,24 @@ import com.elbekd.bot.types.InlineKeyboardMarkup
 import io.github.smaugfm.lunchmoney.model.LunchmoneyTransaction
 import io.github.smaugfm.lunchmoney.model.enumeration.LunchmoneyTransactionStatus
 import io.github.smaugfm.monobudget.common.category.CategoryService
-import io.github.smaugfm.monobudget.common.lifecycle.StatementProcessingContext
-import io.github.smaugfm.monobudget.common.lifecycle.StatementProcessingScopeComponent
 import io.github.smaugfm.monobudget.common.misc.MCC
 import io.github.smaugfm.monobudget.common.model.callback.ActionCallbackType
 import io.github.smaugfm.monobudget.common.model.callback.PressedButtons
 import io.github.smaugfm.monobudget.common.model.callback.TransactionUpdateType
+import io.github.smaugfm.monobudget.common.model.financial.StatementItem
 import io.github.smaugfm.monobudget.common.transaction.TransactionMessageFormatter
 import io.github.smaugfm.monobudget.common.util.formatW
 import io.github.smaugfm.monobudget.common.util.replaceNewLines
 import io.github.smaugfm.monobudget.common.util.toLocalDateTime
 import kotlinx.datetime.Clock
 import kotlinx.datetime.LocalDate
-import org.koin.core.annotation.Scope
-import org.koin.core.annotation.Scoped
+import org.koin.core.annotation.Single
 import java.util.Currency
 
-@Scoped
-@Scope(StatementProcessingScopeComponent::class)
+@Single
 class LunchmoneyTransactionMessageFormatter(
     private val categoryService: CategoryService,
-    private val ctx: StatementProcessingContext,
-) : TransactionMessageFormatter<LunchmoneyTransaction>(ctx.item) {
+) : TransactionMessageFormatter<LunchmoneyTransaction>() {
     private val shouldNotifyStatuses =
         setOf(
             LunchmoneyTransactionStatus.UNCLEARED,
@@ -34,6 +30,7 @@ class LunchmoneyTransactionMessageFormatter(
         )
 
     override suspend fun formatHTMLStatementMessage(
+        statementItem: StatementItem,
         accountCurrency: Currency,
         transaction: LunchmoneyTransaction,
     ): String {
diff --git a/src/main/kotlin/io/github/smaugfm/monobudget/mono/MonoApi.kt b/src/main/kotlin/io/github/smaugfm/monobudget/mono/MonoApi.kt
index e8ecabf..917fa1f 100644
--- a/src/main/kotlin/io/github/smaugfm/monobudget/mono/MonoApi.kt
+++ b/src/main/kotlin/io/github/smaugfm/monobudget/mono/MonoApi.kt
@@ -17,7 +17,7 @@ import java.net.URI
 
 private val log = KotlinLogging.logger {}
 
-class MonoApi(token: String, val accountId: BankAccountId) {
+class MonoApi(token: String, val accountId: BankAccountId, private val alias: String) {
     init {
         require(token.isNotBlank())
     }
@@ -37,7 +37,7 @@ class MonoApi(token: String, val accountId: BankAccountId) {
                     get(url.path) {
                         call.response.status(HttpStatusCode.OK)
                         call.respondText("OK\n", ContentType.Text.Plain)
-                        log.info { "Webhook setup successful: $url" }
+                        log.info { "Webhook setup for $alias successful: $url" }
                         waitForWebhook.complete(Unit)
                     }
                 }
diff --git a/src/main/kotlin/io/github/smaugfm/monobudget/ynab/YnabTransactionMessageFormatter.kt b/src/main/kotlin/io/github/smaugfm/monobudget/ynab/YnabTransactionMessageFormatter.kt
index a527b41..7819a16 100644
--- a/src/main/kotlin/io/github/smaugfm/monobudget/ynab/YnabTransactionMessageFormatter.kt
+++ b/src/main/kotlin/io/github/smaugfm/monobudget/ynab/YnabTransactionMessageFormatter.kt
@@ -2,7 +2,6 @@ package io.github.smaugfm.monobudget.ynab
 
 import com.elbekd.bot.types.InlineKeyboardMarkup
 import io.github.smaugfm.monobudget.common.category.CategoryService
-import io.github.smaugfm.monobudget.common.lifecycle.StatementProcessingScopeComponent
 import io.github.smaugfm.monobudget.common.misc.MCC
 import io.github.smaugfm.monobudget.common.model.callback.PressedButtons
 import io.github.smaugfm.monobudget.common.model.callback.TransactionUpdateType
@@ -11,17 +10,15 @@ import io.github.smaugfm.monobudget.common.transaction.TransactionMessageFormatt
 import io.github.smaugfm.monobudget.common.util.replaceNewLines
 import io.github.smaugfm.monobudget.ynab.model.YnabCleared
 import io.github.smaugfm.monobudget.ynab.model.YnabTransactionDetail
-import org.koin.core.annotation.Scope
-import org.koin.core.annotation.Scoped
+import org.koin.core.annotation.Single
 import java.util.Currency
 
-@Scoped
-@Scope(StatementProcessingScopeComponent::class)
+@Single
 class YnabTransactionMessageFormatter(
     private val categoryService: CategoryService,
-    statementItem: StatementItem,
-) : TransactionMessageFormatter<YnabTransactionDetail>(statementItem) {
+) : TransactionMessageFormatter<YnabTransactionDetail>() {
     override suspend fun formatHTMLStatementMessage(
+        statementItem: StatementItem,
         accountCurrency: Currency,
         transaction: YnabTransactionDetail,
     ): String {
diff --git a/src/test/kotlin/io/github/smaugfm/monobudget/common/retry/JacksonFileStatementRetryRepositoryTest.kt b/src/test/kotlin/io/github/smaugfm/monobudget/common/retry/JacksonFileStatementRetryRepositoryTest.kt
new file mode 100644
index 0000000..b383861
--- /dev/null
+++ b/src/test/kotlin/io/github/smaugfm/monobudget/common/retry/JacksonFileStatementRetryRepositoryTest.kt
@@ -0,0 +1,45 @@
+package io.github.smaugfm.monobudget.common.retry
+
+import assertk.assertThat
+import assertk.assertions.isEmpty
+import io.github.smaugfm.monobudget.common.lifecycle.StatementProcessingContext
+import io.github.smaugfm.monobudget.integration.RetriesTest
+import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.koin.core.KoinApplication
+import org.koin.dsl.bind
+import org.koin.dsl.module
+import java.nio.file.Files
+import java.nio.file.Paths
+import kotlin.time.Duration
+
+class JacksonFileStatementRetryRepositoryTest : RetriesTest() {
+    private val repo = JacksonFileStatementRetryRepository(Paths.get("retries.json"))
+
+    override fun testKoinApplication(app: KoinApplication) {
+        super.testKoinApplication(app)
+        app.modules(
+            module {
+                single { repo } bind StatementRetryRepository::class
+            },
+        )
+    }
+
+    @Test
+    fun `Mono statement item serializes & deserializes correctly`() {
+        val repo = JacksonFileStatementRetryRepository(Paths.get("retries.json"))
+        runBlocking {
+            val req1 = repo.addRetryRequest(StatementProcessingContext(statementItem1()), Duration.ZERO)
+            val req2 = repo.addRetryRequest(StatementProcessingContext(statementItem1()), Duration.ZERO)
+            repo.removeRetryRequest(req1.id)
+            repo.removeRetryRequest(req2.id)
+            assertThat(repo.getAllRequests()).isEmpty()
+        }
+    }
+
+    @BeforeEach
+    fun deleteFile() {
+        Files.deleteIfExists(Paths.get("retries.json"))
+    }
+}
diff --git a/src/test/kotlin/io/github/smaugfm/monobudget/integration/JacksonRepositoryRetriesTest.kt b/src/test/kotlin/io/github/smaugfm/monobudget/integration/JacksonRepositoryRetriesTest.kt
deleted file mode 100644
index e46a2a5..0000000
--- a/src/test/kotlin/io/github/smaugfm/monobudget/integration/JacksonRepositoryRetriesTest.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package io.github.smaugfm.monobudget.integration
-
-import io.github.smaugfm.monobudget.common.retry.JacksonFileStatementRetryRepository
-import io.github.smaugfm.monobudget.common.retry.StatementRetryRepository
-import org.junit.jupiter.api.BeforeEach
-import org.koin.core.KoinApplication
-import org.koin.dsl.bind
-import org.koin.dsl.module
-import java.nio.file.Files
-import java.nio.file.Paths
-
-class JacksonRepositoryRetriesTest : RetriesTest() {
-    private val repo = JacksonFileStatementRetryRepository(Paths.get("retries.json"))
-
-    override fun testKoinApplication(app: KoinApplication) {
-        super.testKoinApplication(app)
-        app.modules(
-            module {
-                single { repo } bind StatementRetryRepository::class
-            },
-        )
-    }
-
-    @BeforeEach
-    fun deleteFile() {
-        Files.deleteIfExists(Paths.get("retries.json"))
-    }
-}