libeufin

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

commit 297228f904554b09e320fa490035a0d68450d746
parent 4f0135998694fd67f34e587bbdd0ad3411208819
Author: Antoine A <>
Date:   Mon, 23 Oct 2023 16:26:04 +0000

Fix /monitor

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 5++---
Mbank/src/test/kotlin/StatsTest.kt | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mdatabase-versioning/procedures.sql | 62+++++++++++++++++++++++++++++++++++++++++++-------------------
3 files changed, 180 insertions(+), 72 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -60,7 +60,7 @@ private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Databas private fun faultyTimestampByBank() = internalServerError("Bank took overflowing timestamp") private fun faultyDurationByClient() = badRequest("Overflowing duration, please specify 'forever' instead.") -private fun <T> PreparedStatement.all(lambda: (ResultSet) -> T): List<T> { +fun <T> PreparedStatement.all(lambda: (ResultSet) -> T): List<T> { executeQuery().use { val ret = mutableListOf<T>() while (it.next()) { @@ -621,11 +621,10 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f if (it.getBoolean("out_debtor_is_exchange")) { val rowId = it.getLong("out_debit_row_id") if (metadata is OutgoingTxMetadata) { - val stmt = conn.prepareStatement("CALL register_outgoing(NULL, ?, ?, ?, ?)") + val stmt = conn.prepareStatement("CALL register_outgoing(NULL, ?, ?, ?)") stmt.setBytes(1, metadata.wtid.raw) stmt.setString(2, metadata.exchangeBaseUrl.url) stmt.setLong(3, rowId) - stmt.setLong(4, debtorAccountId) stmt.executeUpdate() } else { logger.warn("exchange account $debtorAccountId sent a transaction $rowId with malformed metadata") diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt @@ -1,74 +1,160 @@ /* - * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. +* This file is part of LibEuFin. +* Copyright (C) 2019 Stanisci and Dold. - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. +* LibEuFin is free software; you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation; either version 3, or +* (at your option) any later version. - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. +* LibEuFin is distributed in the hope that it will be useful, but +* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +* Public License for more details. - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ +* You should have received a copy of the GNU Affero General Public +* License along with LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ -import org.junit.Test -import io.ktor.server.testing.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.http.content.* +import io.ktor.server.testing.* +import java.time.* +import java.util.* +import kotlin.test.* import kotlinx.serialization.json.Json +import org.junit.Test import tech.libeufin.bank.* import tech.libeufin.util.* -import kotlin.test.* -import java.time.Instant -import java.util.* class StatsTest { @Test - fun internalTalerPayment() = bankSetup { db -> + fun transfer() = bankSetup { db -> + assert(db.bankAccountSetMaxDebt( + 2L, + TalerAmount(1000, 0, "KUDOS") + )) + + suspend fun transfer(amount: TalerAmount) { + client.post("/accounts/exchange/taler-wire-gateway/transfer") { + basicAuth("exchange", "exchange-password") + jsonBody(json { + "request_uid" to randHashCode() + "amount" to amount + "exchange_base_url" to "http://exchange.example.com/" + "wtid" to randShortHashCode() + "credit_account" to "payto://iban/MERCHANT-IBAN-XYZ" + }) + }.assertOk() + } + + suspend fun monitor(count: Long, amount: TalerAmount) { + Timeframe.entries.forEach { timestamp -> + client.get("/monitor?timestamp=${timestamp.name}") { basicAuth("admin", "admin-password") }.assertOk().run { + val resp = Json.decodeFromString<MonitorResponse>(bodyAsText()) + assertEquals(count, resp.talerPayoutCount) + assertEquals(amount, resp.talerPayoutInternalVolume) + } + } + } + + monitor(0, TalerAmount("KUDOS:0")) + transfer(TalerAmount("KUDOS:10.0")) + monitor(1, TalerAmount("KUDOS:10.0")) + transfer(TalerAmount("KUDOS:30.5")) + monitor(2, TalerAmount("KUDOS:40.5")) + transfer(TalerAmount("KUDOS:42")) + monitor(3, TalerAmount("KUDOS:82.5")) + } + + @Test + fun timeframe() = bankSetup { db -> db.conn { conn -> - val stmt = conn.prepareStatement("CALL stats_register_internal_taler_payment(now()::timestamp, (?, ?)::taler_amount)") - - suspend fun register(amount: TalerAmount) { - stmt.setLong(1, amount.value) - stmt.setInt(2, amount.frac) + suspend fun register(now: OffsetDateTime, amount: TalerAmount) { + val stmt = + conn.prepareStatement( + "CALL stats_register_internal_taler_payment(?::timestamp, (?, ?)::taler_amount)" + ) + stmt.setObject(1, now) + stmt.setLong(2, amount.value) + stmt.setInt(3, amount.frac) stmt.executeUpdate() } - client.get("/monitor") { - basicAuth("admin", "admin-password") - }.assertOk().run { - val resp = Json.decodeFromString<MonitorResponse>(bodyAsText()) - assertEquals(0, resp.talerPayoutCount) - assertEquals(TalerAmount("KUDOS:0"), resp.talerPayoutInternalVolume) + suspend fun check( + now: OffsetDateTime, + timeframe: Timeframe, + which: Int?, + count: Long, + amount: TalerAmount + ) { + val stmt = conn.prepareStatement( + """ + SELECT + internal_taler_payments_count + ,(internal_taler_payments_volume).val as internal_taler_payments_volume_val + ,(internal_taler_payments_volume).frac as internal_taler_payments_volume_frac + FROM stats_get_frame(?::timestamp, ?::stat_timeframe_enum, ?) + """ + ) + stmt.setObject(1, now) + + stmt.setString(2, timeframe.name) + if (which != null) { + stmt.setInt(3, which) + } else { + stmt.setNull(3, java.sql.Types.INTEGER) + } + stmt.oneOrNull { + val talerPayoutCount = it.getLong("internal_taler_payments_count") + val talerPayoutInternalVolume = + TalerAmount( + value = it.getLong("internal_taler_payments_volume_val"), + frac = it.getInt("internal_taler_payments_volume_frac"), + currency = "KUDOS" + ) + println("$timeframe $talerPayoutCount $talerPayoutInternalVolume") + assertEquals(count, talerPayoutCount) + assertEquals(amount, talerPayoutInternalVolume) + }!! } - register(TalerAmount("KUDOS:10.0")) - client.get("/monitor") { - basicAuth("admin", "admin-password") - }.assertOk().run { - val resp = Json.decodeFromString<MonitorResponse>(bodyAsText()) - assertEquals(1, resp.talerPayoutCount) - assertEquals(TalerAmount("KUDOS:10"), resp.talerPayoutInternalVolume) - } + val now = OffsetDateTime.now(ZoneOffset.UTC) + val otherHour = now.withHour((now.hour + 1) % 24) + val otherDay = now.withDayOfMonth((now.dayOfMonth + 1) % 28) + val otherMonth = now.withMonth((now.monthValue + 1) % 12) + val otherYear = now.minusYears(1) - register(TalerAmount("KUDOS:30.5")) - client.get("/monitor") { - basicAuth("admin", "admin-password") - }.assertOk().run { - val resp = Json.decodeFromString<MonitorResponse>(bodyAsText()) - assertEquals(2, resp.talerPayoutCount) - assertEquals(TalerAmount("KUDOS:40.5"), resp.talerPayoutInternalVolume) - } + register(now, TalerAmount("KUDOS:10.0")) + register(otherHour, TalerAmount("KUDOS:20.0")) + register(otherDay, TalerAmount("KUDOS:35.0")) + register(otherMonth, TalerAmount("KUDOS:40.0")) + register(otherYear, TalerAmount("KUDOS:50.0")) + + // Check with timestamp and truncating + check(now, Timeframe.hour, null, 1, TalerAmount("KUDOS:10.0")) + check(otherHour, Timeframe.hour, null, 1, TalerAmount("KUDOS:20.0")) + check(otherDay, Timeframe.day, null, 1, TalerAmount("KUDOS:35.0")) + check(otherMonth, Timeframe.month, null, 1, TalerAmount("KUDOS:40.0")) + check(otherYear, Timeframe.year, null, 1, TalerAmount("KUDOS:50.0")) + + // Check with timestamp and intervals + check(now, Timeframe.hour, now.hour, 1, TalerAmount("KUDOS:10.0")) + check(now, Timeframe.hour, otherHour.hour, 1, TalerAmount("KUDOS:20.0")) + check(now, Timeframe.day, otherDay.dayOfMonth, 1, TalerAmount("KUDOS:35.0")) + check(now, Timeframe.month, otherMonth.monthValue, 1, TalerAmount("KUDOS:40.0")) + check(now, Timeframe.year, otherYear.year, 1, TalerAmount("KUDOS:50.0")) - // TODO Test timeframe logic with different timestamps + // Check timestamp aggregation + check(now, Timeframe.day, now.dayOfMonth, 2, TalerAmount("KUDOS:30.0")) + check(now, Timeframe.month, now.monthValue, 3, TalerAmount("KUDOS:65.0")) + check(now, Timeframe.year, now.year, 4, TalerAmount("KUDOS:105.0")) + check(now, Timeframe.day, null, 2, TalerAmount("KUDOS:30.0")) + check(now, Timeframe.month, null, 3, TalerAmount("KUDOS:65.0")) + check(now, Timeframe.year, null, 4, TalerAmount("KUDOS:105.0")) } } -} -\ No newline at end of file +} diff --git a/database-versioning/procedures.sql b/database-versioning/procedures.sql @@ -199,10 +199,12 @@ CREATE OR REPLACE PROCEDURE register_outgoing( IN in_request_uid BYTEA, IN in_wtid BYTEA, IN in_exchange_base_url TEXT, - IN in_tx_row_id BIGINT, - IN in_exchange_bank_account_id BIGINT + IN in_tx_row_id BIGINT ) LANGUAGE plpgsql AS $$ +DECLARE + local_amount taler_amount; + local_bank_account_id BIGINT; BEGIN -- Register outgoing transaction INSERT @@ -217,8 +219,13 @@ INSERT in_exchange_base_url, in_tx_row_id ); +-- TODO check if not drain +SELECT (amount).val, (amount).frac, bank_account_id +INTO local_amount.val, local_amount.frac, local_bank_account_id +FROM bank_account_transactions WHERE bank_transaction_id=in_tx_row_id; +CALL stats_register_internal_taler_payment(now()::TIMESTAMP, local_amount); -- notify new transaction -PERFORM pg_notify('outgoing_tx', in_exchange_bank_account_id || ' ' || in_tx_row_id); +PERFORM pg_notify('outgoing_tx', local_bank_account_id || ' ' || in_tx_row_id); END $$; COMMENT ON PROCEDURE register_outgoing IS 'Register a bank transaction as a taler outgoing transaction'; @@ -339,7 +346,7 @@ IF out_exchange_balance_insufficient THEN END IF; out_timestamp=in_timestamp; -- Register outgoing transaction -CALL register_outgoing(in_request_uid, in_wtid, in_exchange_base_url, out_tx_row_id, exchange_bank_account_id); +CALL register_outgoing(in_request_uid, in_wtid, in_exchange_base_url, out_tx_row_id); END $$; COMMENT ON FUNCTION taler_transfer( bytea, @@ -1021,7 +1028,7 @@ END $$; CREATE OR REPLACE FUNCTION stats_get_frame( IN now TIMESTAMP, - IN timeframe stat_timeframe_enum, + IN in_timeframe stat_timeframe_enum, IN which INTEGER, OUT cashin_count BIGINT, OUT cashin_volume_in_fiat taler_amount, @@ -1030,24 +1037,41 @@ CREATE OR REPLACE FUNCTION stats_get_frame( OUT internal_taler_payments_count BIGINT, OUT internal_taler_payments_volume taler_amount ) -LANGUAGE sql AS $$ +LANGUAGE plpgsql AS $$ +DECLARE + local_start_time TIMESTAMP; +BEGIN + local_start_time = CASE + WHEN which IS NULL THEN date_trunc(in_timeframe::text, now) + WHEN in_timeframe = 'hour' THEN date_trunc('day' , now) + make_interval(hours => which) + WHEN in_timeframe = 'day' THEN date_trunc('month', now) + make_interval(days => which-1) + WHEN in_timeframe = 'month' THEN date_trunc('year' , now) + make_interval(months => which-1) + WHEN in_timeframe = 'year' THEN make_date(which, 1, 1)::TIMESTAMP + END; SELECT s.cashin_count - ,s.cashin_volume_in_fiat + ,(s.cashin_volume_in_fiat).val + ,(s.cashin_volume_in_fiat).frac ,s.cashout_count - ,s.cashout_volume_in_fiat + ,(s.cashout_volume_in_fiat).val + ,(s.cashout_volume_in_fiat).frac ,s.internal_taler_payments_count - ,s.internal_taler_payments_volume - FROM regional_stats AS s - WHERE s.timeframe = timeframe - AND start_time = CASE - WHEN which IS NULL THEN date_trunc(timeframe::text, now) - WHEN timeframe = 'hour' THEN date_trunc('day', now) + '1 hour'::interval * which - WHEN timeframe = 'day' THEN date_trunc('month', now) + '1 day'::interval * which - WHEN timeframe = 'month' THEN date_trunc('year', now) + '1 month'::interval * which - WHEN timeframe = 'year' THEN make_date(which, 1, 1)::TIMESTAMP - END -$$; + ,(s.internal_taler_payments_volume).val + ,(s.internal_taler_payments_volume).frac + INTO + cashin_count + ,cashin_volume_in_fiat.val + ,cashin_volume_in_fiat.frac + ,cashout_count + ,cashout_volume_in_fiat.val + ,cashout_volume_in_fiat.frac + ,internal_taler_payments_count + ,internal_taler_payments_volume.val + ,internal_taler_payments_volume.frac + FROM regional_stats AS s + WHERE s.timeframe = in_timeframe + AND s.start_time = local_start_time; +END $$; CREATE OR REPLACE PROCEDURE stats_register_internal_taler_payment( IN now TIMESTAMP,