libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 0dc33d16270b91eb0d2e3a78d08e319c5e4423cd
parent 8f18b1b5c872a6e83d52539e88fafc89d5dba7a9
Author: Antoine A <>
Date:   Fri,  3 May 2024 17:53:27 +0900

nexus: add list cmd

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt | 59-----------------------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt | 1-
Mbank/src/test/kotlin/AmountTest.kt | 1-
Mcommon/src/main/kotlin/TalerCommon.kt | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/src/main/kotlin/db/types.kt | 8++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 143++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtestbench/src/main/kotlin/Main.kt | 3+++
8 files changed, 329 insertions(+), 72 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt @@ -37,65 +37,6 @@ import java.time.Instant import java.time.temporal.ChronoUnit import java.util.concurrent.TimeUnit -@Serializable(with = DecimalNumber.Serializer::class) -class DecimalNumber { - val value: Long - val frac: Int - - constructor(value: Long, frac: Int) { - this.value = value - this.frac = frac - } - constructor(encoded: String) { - val match = PATTERN.matchEntire(encoded) ?: throw badRequest("Invalid decimal number format") - val (value, frac) = match.destructured - this.value = value.toLongOrNull() ?: throw badRequest("Invalid value") - if (this.value > TalerAmount.MAX_VALUE) throw badRequest("Value specified in decimal number is too large") - this.frac = if (frac.isEmpty()) { - 0 - } else { - var tmp = frac.toIntOrNull() ?: throw badRequest("Invalid fractional value") - repeat(8 - frac.length) { - tmp *= 10 - } - tmp - } - } - - override fun equals(other: Any?): Boolean { - return other is DecimalNumber && - other.value == this.value && - other.frac == this.frac - } - - override fun toString(): String { - if (frac == 0) { - return "$value" - } else { - return "$value.${frac.toString().padStart(8, '0')}" - .dropLastWhile { it == '0' } // Trim useless fractional trailing 0 - } - } - - internal object Serializer : KSerializer<DecimalNumber> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("DecimalNumber", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: DecimalNumber) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): DecimalNumber { - return DecimalNumber(decoder.decodeString()) - } - } - - companion object { - private val PATTERN = Regex("([0-9]+)(?:\\.([0-9]{1,8}))?") - } -} - - /** * Internal representation of relative times. The * "forever" case is represented with Long.MAX_VALUE. diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt @@ -20,7 +20,6 @@ package tech.libeufin.bank.db import tech.libeufin.bank.ConversionRate -import tech.libeufin.bank.DecimalNumber import tech.libeufin.bank.RoundingMode import tech.libeufin.common.* import tech.libeufin.common.db.* diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -18,7 +18,6 @@ */ import org.junit.Test -import tech.libeufin.bank.DecimalNumber import tech.libeufin.bank.db.TransactionDAO.BankTransactionResult import tech.libeufin.bank.db.WithdrawalDAO.WithdrawalCreationResult import tech.libeufin.common.* diff --git a/common/src/main/kotlin/TalerCommon.kt b/common/src/main/kotlin/TalerCommon.kt @@ -115,6 +115,64 @@ class ExchangeUrl { } } +@Serializable(with = DecimalNumber.Serializer::class) +class DecimalNumber { + val value: Long + val frac: Int + + constructor(value: Long, frac: Int) { + this.value = value + this.frac = frac + } + constructor(encoded: String) { + val match = PATTERN.matchEntire(encoded) ?: throw badRequest("Invalid decimal number format") + val (value, frac) = match.destructured + this.value = value.toLongOrNull() ?: throw badRequest("Invalid value") + if (this.value > TalerAmount.MAX_VALUE) throw badRequest("Value specified in decimal number is too large") + this.frac = if (frac.isEmpty()) { + 0 + } else { + var tmp = frac.toIntOrNull() ?: throw badRequest("Invalid fractional value") + repeat(8 - frac.length) { + tmp *= 10 + } + tmp + } + } + + override fun equals(other: Any?): Boolean { + return other is DecimalNumber && + other.value == this.value && + other.frac == this.frac + } + + override fun toString(): String { + if (frac == 0) { + return "$value" + } else { + return "$value.${frac.toString().padStart(8, '0')}" + .dropLastWhile { it == '0' } // Trim useless fractional trailing 0 + } + } + + internal object Serializer : KSerializer<DecimalNumber> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("DecimalNumber", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: DecimalNumber) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): DecimalNumber { + return DecimalNumber(decoder.decodeString()) + } + } + + companion object { + private val PATTERN = Regex("([0-9]+)(?:\\.([0-9]{1,8}))?") + } +} + @Serializable(with = TalerAmount.Serializer::class) class TalerAmount { val value: Long @@ -149,6 +207,8 @@ class TalerAmount { } } + fun number(): DecimalNumber = DecimalNumber(value, frac) + override fun equals(other: Any?): Boolean { return other is TalerAmount && other.value == this.value && diff --git a/common/src/main/kotlin/db/types.kt b/common/src/main/kotlin/db/types.kt @@ -22,6 +22,7 @@ package tech.libeufin.common.db import tech.libeufin.common.BankPaytoCtx import tech.libeufin.common.Payto import tech.libeufin.common.TalerAmount +import tech.libeufin.common.DecimalNumber import java.sql.ResultSet fun ResultSet.getAmount(name: String, currency: String): TalerAmount { @@ -32,6 +33,13 @@ fun ResultSet.getAmount(name: String, currency: String): TalerAmount { ) } +fun ResultSet.getDecimal(name: String): DecimalNumber { + return DecimalNumber( + getLong("${name}_val"), + getInt("${name}_frac") + ) +} + fun ResultSet.getBankPayto(payto: String, name: String, ctx: BankPaytoCtx): String { return Payto.parse(getString(payto)).bank(getString(name), ctx) } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -27,14 +27,14 @@ package tech.libeufin.nexus import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.subcommands -import com.github.ajalt.clikt.parameters.arguments.argument -import com.github.ajalt.clikt.parameters.arguments.convert -import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.arguments.* +import com.github.ajalt.clikt.parameters.groups.* import com.github.ajalt.clikt.parameters.options.* -import com.github.ajalt.clikt.parameters.types.path +import com.github.ajalt.clikt.parameters.types.* import kotlinx.serialization.json.Json import kotlinx.serialization.Serializable import kotlin.io.path.* +import kotlin.math.max import io.ktor.server.application.* import org.slf4j.Logger import org.slf4j.event.Level @@ -190,9 +190,142 @@ class FakeIncoming: CliktCommand("Genere a fake incoming payment") { } } +enum class ListKind { + incoming, + outgoing, + initiated; + + fun description(): String = when (this) { + incoming -> "Incoming transactions" + outgoing -> "Outgoing transactions" + initiated -> "Initiated transactions" + } +} + +class ListCmd: CliktCommand("List nexus transactions", name = "list") { + private val common by CommonOption() + private val kind: ListKind by argument( + help = "Which list to print", + helpTags = ListKind.entries.map { Pair(it.name, it.description()) }.toMap() + ).enum<ListKind>() + + override fun run() = cliCmd(logger, common.log) { + val cfg = loadConfig(common.config) + val dbCfg = cfg.dbConfig() + val currency = cfg.requireString("nexus-ebics", "currency") + + Database(dbCfg).use { db -> + fun fmtPayto(payto: String?): String { + if (payto == null) return "" + try { + val parsed = Payto.parse(payto).expectIban() + return buildString { + append(parsed.iban.toString()) + if (parsed.bic != null) append(" ${parsed.bic}") + if (parsed.receiverName != null) append(" ${parsed.receiverName}") + } + } catch (e: Exception) { + return payto + } + } + val (columnNames, rows) = when (kind) { + ListKind.incoming -> { + val txs = db.payment.metadataIncoming() + Pair( + listOf( + "transaction", "id", "reserve_pub", "debtor", "subject" + ), + txs.map { + listOf( + "${it.date} ${it.amount}", + it.id, + it.reservePub?.toString() ?: "", + fmtPayto(it.debtor), + it.subject + ) + } + ) + } + ListKind.outgoing -> { + val txs = db.payment.metadataOutgoing() + Pair( + listOf( + "transaction", "id", "creditor", "subject" + ), + txs.map { + listOf( + "${it.date} ${it.amount}", + it.id, + fmtPayto(it.creditor), + it.subject ?: "" + ) + } + ) + } + ListKind.initiated -> { + val txs = db.payment.metadataInitiated() + Pair( + listOf( + "transaction", "id", "submission", "creditor", "status", "subject" + ), + txs.map { + listOf( + "${it.date} ${it.amount}", + it.id, + "${it.submissionTime} ${it.submissionCounter}", + fmtPayto(it.creditor), + "${it.status} ${it.msg ?: ""}".trim(), + it.subject + ) + } + ) + } + } + val cols: List<Pair<String, Int>> = columnNames.mapIndexed { i, name -> + val maxRow: Int = rows.asSequence().map { it[i].length }.maxOrNull() ?: 0 + Pair(name, max(name.length, maxRow)) + } + val table = buildString { + fun padding(length: Int) { + repeat(length) { append (" ") } + } + var first = true + for ((name, len) in cols) { + if (!first) { + append("|") + } else { + first = false + } + val pad = len - name.length + padding(pad / 2) + append(name) + padding(pad / 2 + if (pad % 2 == 0) { 0 } else { 1 }) + } + append("\n") + for (row in rows) { + var first = true + for ((met, str) in cols.zip(row)) { + if (!first) { + append("|") + } else { + first = false + } + val (name, len) = met + val pad = len - str.length + append(str) + padding(pad) + } + append("\n") + } + } + print(table) + } + } +} + class TestingCmd : CliktCommand("Testing helper commands", name = "testing") { init { - subcommands(FakeIncoming()) + subcommands(FakeIncoming(), ListCmd()) } override fun run() = Unit diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -19,10 +19,8 @@ package tech.libeufin.nexus.db -import tech.libeufin.common.EddsaPublicKey -import tech.libeufin.common.TalerAmount -import tech.libeufin.common.db.one -import tech.libeufin.common.micros +import tech.libeufin.common.db.* +import tech.libeufin.common.* import tech.libeufin.nexus.IncomingPayment import tech.libeufin.nexus.OutgoingPayment import java.time.Instant @@ -128,4 +126,120 @@ class PaymentDAO(private val db: Database) { } } } -} -\ No newline at end of file + + /** List incoming transaction metadata for debugging */ + suspend fun metadataIncoming(): List<IncomingTxMetadata> = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + (amount).val as amount_val + ,(amount).frac AS amount_frac + ,wire_transfer_subject + ,execution_time + ,debit_payto_uri + ,bank_id + ,reserve_public_key + FROM incoming_transactions + LEFT OUTER JOIN talerable_incoming_transactions using (incoming_transaction_id) + ORDER BY execution_time + """) + stmt.all { + IncomingTxMetadata( + date = it.getLong("execution_time").asInstant(), + amount = it.getDecimal("amount"), + subject = it.getString("wire_transfer_subject"), + debtor = it.getString("debit_payto_uri"), + id = it.getString("bank_id"), + reservePub = it.getBytes("reserve_public_key")?.run { EddsaPublicKey(this) } + ) + } + } + + /** List outgoing transaction metadata for debugging */ + suspend fun metadataOutgoing(): List<OutgoingTxMetadata> = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + (amount).val as amount_val + ,(amount).frac AS amount_frac + ,wire_transfer_subject + ,execution_time + ,credit_payto_uri + ,message_id + FROM outgoing_transactions + ORDER BY execution_time + """) + stmt.all { + OutgoingTxMetadata( + date = it.getLong("execution_time").asInstant(), + amount = it.getDecimal("amount"), + subject = it.getString("wire_transfer_subject"), + creditor = it.getString("credit_payto_uri"), + id = it.getString("message_id") + ) + } + } + + /** List initiated transaction metadata for debugging */ + suspend fun metadataInitiated(): List<InitiatedTxMetadata> = db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + (amount).val as amount_val + ,(amount).frac AS amount_frac + ,wire_transfer_subject + ,initiation_time + ,last_submission_time + ,submission_counter + ,credit_payto_uri + ,submitted + ,request_uid + ,failure_message + FROM initiated_outgoing_transactions + ORDER BY initiation_time + """) + stmt.all { + InitiatedTxMetadata( + date = it.getLong("initiation_time").asInstant(), + amount = it.getDecimal("amount"), + subject = it.getString("wire_transfer_subject"), + creditor = it.getString("credit_payto_uri"), + id = it.getString("request_uid"), + status = it.getString("submitted"), + msg = it.getString("failure_message"), + submissionTime = it.getLong("last_submission_time").asInstant(), + submissionCounter = it.getInt("submission_counter") + ) + } + } +} + +/** Incoming transaction metadata for debugging */ +data class IncomingTxMetadata( + val date: Instant, + val amount: DecimalNumber, + val subject: String, + val debtor: String, + val id: String, + val reservePub: EddsaPublicKey? +) + +/** Outgoing transaction metadata for debugging */ +data class OutgoingTxMetadata( + val date: Instant, + val amount: DecimalNumber, + val subject: String?, + val creditor: String?, + val id: String, + // TODO when merged with v11 +) + +/** Initiated metadata for debugging */ +data class InitiatedTxMetadata( + val date: Instant, + val amount: DecimalNumber, + val subject: String, + val creditor: String, + val id: String, + val status: String, + val msg: String?, + val submissionTime: Instant, + val submissionCounter: Int +) +\ No newline at end of file diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt @@ -163,6 +163,9 @@ class Cli : CliktCommand("Run integration tests on banks provider") { put("report", "Fetch BankToCustomerAccountReport", "ebics-fetch $ebicsFlags report") put("notification", "Fetch BankToCustomerDebitCreditNotification", "ebics-fetch $ebicsFlags notification") put("statement", "Fetch BankToCustomerStatement", "ebics-fetch $ebicsFlags statement") + put("list-incoming", "List incoming transaction", "testing list $flags incoming") + put("list-outgoing", "List outgoing transaction", "testing list $flags outgoing") + put("list-initiated", "List initiated payments", "testing list $flags initiated") put("submit", "Submit pending transactions", "ebics-submit $ebicsFlags") put("setup", "Setup", "ebics-setup $flags") put("reset-keys", suspend {