libeufin

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

commit 90be1bee51c819afb9ce8928837f229eb343411a
parent 0bcadb6e7637a92744029f909f14c5df8dfe078a
Author: Antoine A <>
Date:   Wed,  8 Nov 2023 17:28:09 +0000

Improve /monitor

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 16+++++++++-------
Mbank/src/main/kotlin/tech/libeufin/bank/db/Database.kt | 38++++++++++++++++++++++++++------------
Mbank/src/test/kotlin/StatsTest.kt | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mdatabase-versioning/libeufin-bank-0001.sql | 12++++++++----
Mdatabase-versioning/libeufin-bank-procedures.sql | 134++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
5 files changed, 213 insertions(+), 73 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -127,31 +127,33 @@ data class TokenRequest( @Serializable sealed class MonitorResponse { abstract val talerInCount: Long - abstract val talerInInternalVolume: TalerAmount + abstract val talerInVolume: TalerAmount abstract val talerOutCount: Long - abstract val talerOutInternalVolume: TalerAmount + abstract val talerOutVolume: TalerAmount } @Serializable @SerialName("just-payouts") data class MonitorJustPayouts( override val talerInCount: Long, - override val talerInInternalVolume: TalerAmount, + override val talerInVolume: TalerAmount, override val talerOutCount: Long, - override val talerOutInternalVolume: TalerAmount + override val talerOutVolume: TalerAmount ) : MonitorResponse() @Serializable @SerialName("with-cashout") data class MonitorWithCashout( - val cashinCount: Long?, + val cashinCount: Long, + val cashinInternalVolume: TalerAmount, val cashinExternalVolume: TalerAmount, val cashoutCount: Long, + val cashoutInternalVolume: TalerAmount, val cashoutExternalVolume: TalerAmount, override val talerInCount: Long, - override val talerInInternalVolume: TalerAmount, + override val talerInVolume: TalerAmount, override val talerOutCount: Long, - override val talerOutInternalVolume: TalerAmount + override val talerOutVolume: TalerAmount ) : MonitorResponse() /** diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt @@ -847,11 +847,15 @@ class Database(dbConfig: String, internal val bankCurrency: String, internal val val stmt = conn.prepareStatement(""" SELECT cashin_count - ,(cashin_volume).val as cashin_volume_val - ,(cashin_volume).frac as cashin_volume_frac + ,(cashin_internal_volume).val as cashin_internal_volume_val + ,(cashin_internal_volume).frac as cashin_internal_volume_frac + ,(cashin_external_volume).val as cashin_external_volume_val + ,(cashin_external_volume).frac as cashin_external_volume_frac ,cashout_count - ,(cashout_volume).val as cashout_volume_val - ,(cashout_volume).frac as cashout_volume_frac + ,(cashout_internal_volume).val as cashout_internal_volume_val + ,(cashout_internal_volume).frac as cashout_internal_volume_frac + ,(cashout_external_volume).val as cashout_external_volume_val + ,(cashout_external_volume).frac as cashout_external_volume_frac ,taler_in_count ,(taler_in_volume).val as taler_in_volume_val ,(taler_in_volume).frac as taler_in_volume_frac @@ -870,25 +874,35 @@ class Database(dbConfig: String, internal val bankCurrency: String, internal val fiatCurrency?.run { MonitorWithCashout( cashinCount = it.getLong("cashin_count"), + cashinInternalVolume = TalerAmount( + value = it.getLong("cashin_internal_volume_val"), + frac = it.getInt("cashin_internal_volume_frac"), + currency = bankCurrency + ), cashinExternalVolume = TalerAmount( - value = it.getLong("cashin_volume_val"), - frac = it.getInt("cashin_volume_frac"), + value = it.getLong("cashin_external_volume_val"), + frac = it.getInt("cashin_external_volume_frac"), currency = this ), cashoutCount = it.getLong("cashout_count"), + cashoutInternalVolume = TalerAmount( + value = it.getLong("cashout_internal_volume_val"), + frac = it.getInt("cashout_internal_volume_frac"), + currency = bankCurrency + ), cashoutExternalVolume = TalerAmount( - value = it.getLong("cashout_volume_val"), - frac = it.getInt("cashout_volume_frac"), + value = it.getLong("cashout_external_volume_val"), + frac = it.getInt("cashout_external_volume_frac"), currency = this ), talerInCount = it.getLong("taler_in_count"), - talerInInternalVolume = TalerAmount( + talerInVolume = TalerAmount( value = it.getLong("taler_in_volume_val"), frac = it.getInt("taler_in_volume_frac"), currency = bankCurrency ), talerOutCount = it.getLong("taler_out_count"), - talerOutInternalVolume = TalerAmount( + talerOutVolume = TalerAmount( value = it.getLong("taler_out_volume_val"), frac = it.getInt("taler_out_volume_frac"), currency = bankCurrency @@ -896,13 +910,13 @@ class Database(dbConfig: String, internal val bankCurrency: String, internal val ) } ?: MonitorJustPayouts( talerInCount = it.getLong("taler_in_count"), - talerInInternalVolume = TalerAmount( + talerInVolume = TalerAmount( value = it.getLong("taler_in_volume_val"), frac = it.getInt("taler_in_volume_frac"), currency = bankCurrency ), talerOutCount = it.getLong("taler_out_count"), - talerOutInternalVolume = TalerAmount( + talerOutVolume = TalerAmount( value = it.getLong("taler_out_volume_val"), frac = it.getInt("taler_out_volume_frac"), currency = bankCurrency diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt @@ -23,9 +23,11 @@ import io.ktor.http.* import io.ktor.http.content.* import io.ktor.server.testing.* import java.time.* +import java.time.Instant import java.util.* import kotlin.test.* import kotlin.reflect.full.declaredMemberProperties +import kotlin.suspend import kotlinx.serialization.json.Json import org.junit.Test import tech.libeufin.bank.* @@ -33,7 +35,8 @@ import tech.libeufin.util.* class StatsTest { @Test - fun transfer() = bankSetup { _ -> + fun register() = bankSetup { db -> + setMaxDebt("merchant", TalerAmount("KUDOS:1000")) setMaxDebt("exchange", TalerAmount("KUDOS:1000")) setMaxDebt("customer", TalerAmount("KUDOS:1000")) client.patch("/accounts/customer") { @@ -46,25 +49,58 @@ class StatsTest { }) }.assertNoContent() - suspend fun monitor(countName: String, volumeName: String, count: Long, amount: String) { + suspend fun cashin(amount: String) { + db.conn { conn -> + val stmt = conn.prepareStatement("SELECT 0 FROM cashin(?, ?, (?, ?)::taler_amount, ?)") + stmt.setLong(1, Instant.now().toDbMicros()!!) + stmt.setString(2, IbanPayTo("payto://iban/CUSTOMER-IBAN-XYZ").canonical) + val amount = TalerAmount(amount) + stmt.setLong(3, amount.value) + stmt.setInt(4, amount.frac) + stmt.setString(5, "") + stmt.executeQueryCheck(); + } + } + + suspend fun monitor( + dbCount: (MonitorWithCashout) -> Long, + count: Long, + internalVolume: (MonitorWithCashout) -> TalerAmount, + internalAmount: String, + externalVolume: ((MonitorWithCashout) -> TalerAmount)? = null, + externalAmount: String? = null + ) { Timeframe.entries.forEach { timestamp -> client.get("/monitor?timestamp=${timestamp.name}") { basicAuth("admin", "admin-password") }.assertOk().run { - val resp = json<MonitorResponse>() - assertEquals(count, resp.javaClass.kotlin.declaredMemberProperties.first { it.name == countName }.get(resp)) - assertEquals(TalerAmount(amount), resp.javaClass.kotlin.declaredMemberProperties.first { it.name == volumeName }.get(resp)) + println(bodyAsText()) + val resp = json<MonitorResponse>() as MonitorWithCashout + assertEquals(count, dbCount(resp)) + assertEquals(TalerAmount(internalAmount), internalVolume(resp)) + externalVolume?.run { assertEquals(TalerAmount(externalAmount!!), this(resp)) } } } } - suspend fun monitorTalerOut(count: Long, amount: String) = monitor("talerOutCount" , "talerOutInternalVolume", count, amount) - suspend fun monitorTalerIn(count: Long, amount: String) = monitor("talerInCount" , "talerInInternalVolume", count, amount) - suspend fun monitorCashin(count: Long, amount: String) = monitor("cashinCount" , "cashinExternalVolume", count, amount) - suspend fun monitorCashout(count: Long, amount: String) = monitor("cashoutCount" , "cashoutExternalVolume", count, amount) + suspend fun monitorTalerIn(count: Long, amount: String) = + monitor({it.talerInCount}, count, {it.talerInVolume}, amount) + suspend fun monitorTalerOut(count: Long, amount: String) = + monitor({it.talerOutCount}, count, {it.talerOutVolume}, amount) + suspend fun monitorCashin(count: Long, internalAmount: String, externalAmount: String) = + monitor({it.cashinCount}, count, {it.cashinInternalVolume}, internalAmount, {it.cashinExternalVolume}, externalAmount) + suspend fun monitorCashout(count: Long, internalAmount: String, externalAmount: String) = + monitor({it.cashoutCount}, count, {it.cashoutInternalVolume}, internalAmount, {it.cashoutExternalVolume}, externalAmount) - monitorTalerOut(0, "KUDOS:0") monitorTalerIn(0, "KUDOS:0") - monitorCashin(0, "FIAT:0") - monitorCashout(0, "FIAT:0") + monitorTalerOut(0, "KUDOS:0") + monitorCashin(0, "KUDOS:0", "FIAT:0") + monitorCashout(0, "KUDOS:0", "FIAT:0") + + addIncoming("KUDOS:3") + monitorTalerIn(1, "KUDOS:3") + addIncoming("KUDOS:7.6") + monitorTalerIn(2, "KUDOS:10.6") + addIncoming("KUDOS:12.3") + monitorTalerIn(3, "KUDOS:22.9") transfer("KUDOS:10.0") monitorTalerOut(1, "KUDOS:10.0") @@ -73,24 +109,24 @@ class StatsTest { transfer("KUDOS:42") monitorTalerOut(3, "KUDOS:82.5") - addIncoming("KUDOS:3") - monitorTalerIn(1, "KUDOS:3") - addIncoming("KUDOS:7.6") - monitorTalerIn(2, "KUDOS:10.6") - addIncoming("KUDOS:12.3") - monitorTalerIn(3, "KUDOS:22.9") + cashin("FIAT:10") + monitorCashin(1, "KUDOS:7.98", "FIAT:10") + cashin("FIAT:20") + monitorCashin(2, "KUDOS:23.96", "FIAT:30") + cashin("FIAT:40") + monitorCashin(3, "KUDOS:55.94", "FIAT:70") cashout("KUDOS:3") - monitorCashout(1, "FIAT:3.747") + monitorCashout(1, "KUDOS:3", "FIAT:3.747") cashout("KUDOS:7.6") - monitorCashout(2, "FIAT:13.244") + monitorCashout(2, "KUDOS:10.6", "FIAT:13.244") cashout("KUDOS:12.3") - monitorCashout(3, "FIAT:28.616") + monitorCashout(3, "KUDOS:22.9", "FIAT:28.616") - monitorTalerOut(3, "KUDOS:82.5") monitorTalerIn(3, "KUDOS:22.9") - monitorCashin(0, "FIAT:0") - monitorCashout(3, "FIAT:28.616") + monitorTalerOut(3, "KUDOS:82.5") + monitorCashin(3, "KUDOS:55.94", "FIAT:70") + monitorCashout(3, "KUDOS:22.9", "FIAT:28.616") } @Test @@ -98,7 +134,7 @@ class StatsTest { db.conn { conn -> suspend fun register(now: OffsetDateTime, amount: TalerAmount) { val stmt = conn.prepareStatement( - "CALL stats_register_payment('taler_out', ?::timestamp, (?, ?)::taler_amount)" + "CALL stats_register_payment('taler_out', ?::timestamp, (?, ?)::taler_amount, null)" ) stmt.setObject(1, now) stmt.setLong(2, amount.value) diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql @@ -254,9 +254,11 @@ CREATE TABLE IF NOT EXISTS bank_stats ( timeframe stat_timeframe_enum NOT NULL ,start_time timestamp NOT NULL ,cashin_count BIGINT NOT NULL DEFAULT 0 - ,cashin_volume taler_amount NOT NULL DEFAULT (0, 0) + ,cashin_internal_volume taler_amount NOT NULL DEFAULT (0, 0) + ,cashin_external_volume taler_amount NOT NULL DEFAULT (0, 0) ,cashout_count BIGINT NOT NULL DEFAULT 0 - ,cashout_volume taler_amount NOT NULL DEFAULT (0, 0) + ,cashout_internal_volume taler_amount NOT NULL DEFAULT (0, 0) + ,cashout_external_volume taler_amount NOT NULL DEFAULT (0, 0) ,taler_in_count BIGINT NOT NULL DEFAULT 0 ,taler_in_volume taler_amount NOT NULL DEFAULT (0, 0) ,taler_out_count BIGINT NOT NULL DEFAULT 0 @@ -268,9 +270,11 @@ COMMENT ON TABLE bank_stats IS 'Stores statistics about the bank usage.'; COMMENT ON COLUMN bank_stats.timeframe IS 'particular timeframe that this row accounts for'; COMMENT ON COLUMN bank_stats.start_time IS 'timestamp of the start of the timeframe that this row accounts for, truncated according to the precision of the timeframe'; COMMENT ON COLUMN bank_stats.cashin_count IS 'how many cashin operations took place in the timeframe'; -COMMENT ON COLUMN bank_stats.cashin_volume IS 'how much fiat currency was cashed in in the timeframe'; +COMMENT ON COLUMN bank_stats.cashin_internal_volume IS 'how much internal currency was cashed in in the timeframe'; +COMMENT ON COLUMN bank_stats.cashin_external_volume IS 'how much external currency was cashed in in the timeframe'; COMMENT ON COLUMN bank_stats.cashout_count IS 'how many cashout operations took place in the timeframe'; -COMMENT ON COLUMN bank_stats.cashout_volume IS 'how much fiat currency was payed by the bank to customers in the timeframe'; +COMMENT ON COLUMN bank_stats.cashout_internal_volume IS 'how much internal currency was payed by the bank to customers in the timeframe'; +COMMENT ON COLUMN bank_stats.cashout_external_volume IS 'how much external currency was payed by the bank to customers in the timeframe'; COMMENT ON COLUMN bank_stats.taler_out_count IS 'how many internal payments were made by a Taler exchange'; COMMENT ON COLUMN bank_stats.taler_out_volume IS 'how much internal currency was paid by a Taler exchange'; COMMENT ON COLUMN bank_stats.taler_in_count IS 'how many internal payments were made to a Taler exchange'; diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -283,7 +283,7 @@ INSERT 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_payment('taler_out', now()::TIMESTAMP, local_amount); +CALL stats_register_payment('taler_out', now()::TIMESTAMP, local_amount, null); -- notify new transaction PERFORM pg_notify('outgoing_tx', local_bank_account_id || ' ' || in_tx_row_id); END $$; @@ -312,7 +312,7 @@ INSERT 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_payment('taler_in', now()::TIMESTAMP, local_amount); +CALL stats_register_payment('taler_in', now()::TIMESTAMP, local_amount, null); -- notify new transaction PERFORM pg_notify('incoming_tx', local_bank_account_id || ' ' || in_tx_row_id); END $$; @@ -1000,6 +1000,66 @@ WHERE bank_account_id=in_creditor_account_id; PERFORM pg_notify('bank_tx', in_debtor_account_id || ' ' || in_creditor_account_id || ' ' || out_debit_row_id || ' ' || out_credit_row_id); END $$; +CREATE OR REPLACE FUNCTION cashin( + IN in_now_date BIGINT, + IN in_payto_uri TEXT, + IN in_amount taler_amount, + IN in_subject TEXT, + -- Error status + OUT out_no_account BOOLEAN, + OUT out_too_small BOOLEAN, + OUT out_balance_insufficient BOOLEAN +) +LANGUAGE plpgsql AS $$ +DECLARE + converted_amount taler_amount; + admin_account_id BIGINT; + wallet_account_id BIGINT; +BEGIN +-- Recover account info +SELECT bank_account_id + INTO wallet_account_id + FROM bank_accounts + WHERE internal_payto_uri = in_payto_uri; + +-- Retrieve admin account id +SELECT bank_account_id + INTO admin_account_id + FROM bank_accounts + JOIN customers + ON customer_id=owning_customer_id + WHERE login = 'admin'; + +-- Perform conversion +SELECT (to_amount).val, (to_amount).frac, too_small + INTO converted_amount.val, converted_amount.frac, out_too_small + FROM conversion_to(in_amount, 'buy'::text); +IF out_too_small THEN + RETURN; +END IF; + +-- Perform bank wire transfer +SELECT transfer.out_balance_insufficient +INTO out_balance_insufficient +FROM bank_wire_transfer( + wallet_account_id, + admin_account_id, + in_subject, + converted_amount, + in_now_date, + 'not-used', + 'not-used', + 'not-used' +) as transfer; +IF out_balance_insufficient THEN + RETURN; +END IF; + +-- update stats +CALL stats_register_payment('cashin', now()::TIMESTAMP, converted_amount, in_amount); +END $$; + + CREATE OR REPLACE FUNCTION cashout_create( IN in_account_username TEXT, IN in_request_uid BYTEA, @@ -1194,7 +1254,7 @@ UPDATE cashout_operations WHERE cashout_id=in_cashout_id; -- update stats -CALL stats_register_payment('cashout', now()::TIMESTAMP, amount_credit_local); +CALL stats_register_payment('cashout', now()::TIMESTAMP, amount_debit_local, amount_credit_local); END $$; CREATE OR REPLACE FUNCTION challenge_create ( @@ -1289,9 +1349,11 @@ CREATE OR REPLACE FUNCTION stats_get_frame( IN in_timeframe stat_timeframe_enum, IN which INTEGER, OUT cashin_count BIGINT, - OUT cashin_volume taler_amount, + OUT cashin_internal_volume taler_amount, + OUT cashin_external_volume taler_amount, OUT cashout_count BIGINT, - OUT cashout_volume taler_amount, + OUT cashout_internal_volume taler_amount, + OUT cashout_external_volume taler_amount, OUT taler_in_count BIGINT, OUT taler_in_volume taler_amount, OUT taler_out_count BIGINT, @@ -1310,11 +1372,15 @@ BEGIN END; SELECT s.cashin_count - ,(s.cashin_volume).val - ,(s.cashin_volume).frac + ,(s.cashin_internal_volume).val + ,(s.cashin_internal_volume).frac + ,(s.cashin_external_volume).val + ,(s.cashin_external_volume).frac ,s.cashout_count - ,(s.cashout_volume).val - ,(s.cashout_volume).frac + ,(s.cashout_internal_volume).val + ,(s.cashout_internal_volume).frac + ,(s.cashout_external_volume).val + ,(s.cashout_external_volume).frac ,s.taler_in_count ,(s.taler_in_volume).val ,(s.taler_in_volume).frac @@ -1323,11 +1389,15 @@ BEGIN ,(s.taler_out_volume).frac INTO cashin_count - ,cashin_volume.val - ,cashin_volume.frac + ,cashin_internal_volume.val + ,cashin_internal_volume.frac + ,cashin_external_volume.val + ,cashin_external_volume.frac ,cashout_count - ,cashout_volume.val - ,cashout_volume.frac + ,cashout_internal_volume.val + ,cashout_internal_volume.frac + ,cashout_external_volume.val + ,cashout_external_volume.frac ,taler_in_count ,taler_in_volume.val ,taler_in_volume.frac @@ -1342,24 +1412,38 @@ END $$; CREATE OR REPLACE PROCEDURE stats_register_payment( IN name TEXT, IN now TIMESTAMP, - IN amount taler_amount + IN internal_amount taler_amount, + IN external_amount taler_amount ) LANGUAGE plpgsql AS $$ DECLARE frame stat_timeframe_enum; query TEXT; BEGIN - query = format('INSERT INTO bank_stats AS s ' - '(timeframe, start_time, %1$I_count, %1$I_volume) ' - 'VALUES ($1, $2, 1, $3) ' - 'ON CONFLICT (timeframe, start_time) DO UPDATE ' - 'SET %1$I_count=s.%1$I_count+1, ' - ' %1$I_volume=(SELECT amount_add(s.%1$I_volume, $3))', - name); - - FOREACH frame IN ARRAY enum_range(null::stat_timeframe_enum) LOOP - EXECUTE query USING frame, date_trunc(frame::text, now), amount; - END LOOP; + IF external_amount IS NULL THEN + query = format('INSERT INTO bank_stats AS s ' + '(timeframe, start_time, %1$I_count, %1$I_volume) ' + 'VALUES ($1, $2, 1, $3) ' + 'ON CONFLICT (timeframe, start_time) DO UPDATE ' + 'SET %1$I_count=s.%1$I_count+1 ' + ', %1$I_volume=(SELECT amount_add(s.%1$I_volume, $3))', + name); + FOREACH frame IN ARRAY enum_range(null::stat_timeframe_enum) LOOP + EXECUTE query USING frame, date_trunc(frame::text, now), internal_amount; + END LOOP; + ELSE + query = format('INSERT INTO bank_stats AS s ' + '(timeframe, start_time, %1$I_count, %1$I_internal_volume, %1$I_external_volume) ' + 'VALUES ($1, $2, 1, $3, $4)' + 'ON CONFLICT (timeframe, start_time) DO UPDATE ' + 'SET %1$I_count=s.%1$I_count+1 ' + ', %1$I_internal_volume=(SELECT amount_add(s.%1$I_internal_volume, $3))' + ', %1$I_external_volume=(SELECT amount_add(s.%1$I_external_volume, $4))', + name); + FOREACH frame IN ARRAY enum_range(null::stat_timeframe_enum) LOOP + EXECUTE query USING frame, date_trunc(frame::text, now), internal_amount, external_amount; + END LOOP; + END IF; END $$; CREATE OR REPLACE PROCEDURE config_set_amount(