libeufin

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

commit 39c74e88e8f9c0ddff1ce085880906bc0c4f181c
parent bc5b8d15033975f777cc2bf938ea6bc729a703b6
Author: Antoine A <>
Date:   Sat, 23 May 2026 15:02:30 +0200

bank: add monitor endpoint

Diffstat:
Mcrates/libeufin-bank/src/api.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/libeufin-bank/src/db.rs | 93++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mdatabase-versioning/libeufin-bank-procedures.sql | 71++++++++++++++++++++++++-----------------------------------------------
3 files changed, 204 insertions(+), 54 deletions(-)

diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs @@ -25,9 +25,11 @@ use axum::{ response::{IntoResponse, Redirect, Response}, routing::get, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; -use taler_api::{api::TalerRouter as _, notification::NotificationChannel}; +use taler_api::{ + api::TalerRouter as _, error::ApiResult, extract::Query, notification::NotificationChannel, +}; use taler_common::{ api::LibtoolVersion, types::amount::{Amount, Currency}, @@ -50,8 +52,9 @@ use crate::{ wire::wire_api, withdrawal::{WithdrawalStatus, withdrawal_api}, }, + auth::AdminRAuth, config::{BankCfg, CurrencySpecification}, - db::notification_listener, + db::{monitor, notification_listener}, }; pub mod account; @@ -127,6 +130,49 @@ pub struct Config<'a> { pub max_wire_transfer_amount: Amount, } +#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "stat_timeframe_enum")] +#[allow(non_camel_case_types)] +pub enum Timeframe { + #[default] + hour, + day, + month, + year, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MonitorParams { + #[serde(default)] + pub timeframe: Timeframe, + pub date_s: Option<u64>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum MonitorResponse { + #[serde(rename = "no-conversions", rename_all = "camelCase")] + Simple { + taler_in_count: u64, + taler_in_volume: Amount, + taler_out_count: u64, + taler_out_volume: Amount, + }, + #[serde(rename = "with-conversions", rename_all = "camelCase")] + Conversion { + cashin_count: u64, + cashin_regional_volume: Amount, + cashin_fiat_volume: Amount, + cashout_count: u64, + cashout_regional_volume: Amount, + cashout_fiat_volume: Amount, + taler_in_count: u64, + taler_in_volume: Amount, + taler_out_count: u64, + taler_out_volume: Amount, + }, +} + async fn config(State(state): State<Arc<BankState>>) -> Response { Json(Config { name: (), @@ -160,6 +206,24 @@ pub fn bank_api(state: Arc<BankState>) -> Router { } else { router } + .route( + "/monitor", + get( + async |Query(params): Query<MonitorParams>, + _: AdminRAuth, + State(state): State<Arc<BankState>>| { + ApiResult::Ok(Json( + monitor( + &state.db, + &state.cfg.regional_currency, + state.cfg.fiat_currency(), + &params, + ) + .await?, + )) + }, + ), + ) .merge(token_api()) .merge(account_api()) .merge(tx_api()) @@ -207,7 +271,7 @@ pub mod test { use crate::{ CONFIG_SOURCE, api::{ - BankState, + BankState, MonitorResponse, account::{ AccountData, Balance, CreditDebitInfo, create_admin_account, rand_iban_payto, }, @@ -798,4 +862,26 @@ pub mod test { } } } + + #[tokio::test] + async fn corebank() { + let ctx = bank_setup().await; + + ctx.auth_routine(Method::GET, "/monitor", Auth::Admin).await; + + ctx.get("/config").await.assert_ok(); + + ctx.get_admin("/monitor?timeframe=day&which=25") + .await + .assert_ok_json::<MonitorResponse>(); + ctx.get_admin("/monitor?timeframe=day=which=25") + .await + .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); + + ctx.fill_cashout_info("customer").await; + ctx.cashout("KUDOS:1").await; + ctx.get_admin("/monitor") + .await + .assert_ok_json::<MonitorResponse>(); + } } diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs @@ -19,12 +19,17 @@ use std::time::Duration; -use sqlx::PgPool; -use taler_api::notification::NotificationChannel; +use jiff::Timestamp; +use sqlx::{PgPool, postgres::PgRow}; +use taler_api::{db::TypeHelper, notification::NotificationChannel, serialized}; +use taler_common::types::amount::{Amount, Currency}; use tokio::join; use uuid::Uuid; -use crate::{api::withdrawal::WithdrawalStatus, config::BankCfg}; +use crate::{ + api::{MonitorParams, MonitorResponse, withdrawal::WithdrawalStatus}, + config::BankCfg, +}; pub mod account; pub mod cashout; @@ -96,3 +101,85 @@ pub async fn notification_listener( ); Ok(()) } + +pub async fn monitor( + db: &PgPool, + regional: &Currency, + fiat: Option<&Currency>, + params: &MonitorParams, +) -> sqlx::Result<MonitorResponse> { + let timestamp = if let Some(s) = params.date_s { + Timestamp::from_second(s as i64).unwrap() + } else { + Timestamp::now() + } + .to_string(); + Ok(serialized!( + sqlx::query( + " + SELECT + cashin_count + ,cashin_regional_volume + ,cashin_fiat_volume + ,cashout_count + ,cashout_regional_volume + ,cashout_fiat_volume + ,taler_in_count + ,taler_in_volume + ,taler_out_count + ,taler_out_volume + FROM stats_get_frame($1::timestamp,$2::stat_timeframe_enum) + ", + ) + .bind(&timestamp) + .bind(params.timeframe) + .try_map(|r: PgRow| { + Ok(if let Some(fiat) = fiat { + MonitorResponse::Conversion { + cashin_count: r.try_get_u64("cashin_count")?, + cashin_regional_volume: r.try_get_amount("cashin_regional_volume", regional)?, + cashin_fiat_volume: r.try_get_amount("cashin_fiat_volume", fiat)?, + cashout_count: r.try_get_u64("cashout_count")?, + cashout_regional_volume: r + .try_get_amount("cashout_regional_volume", regional)?, + cashout_fiat_volume: r.try_get_amount("cashout_fiat_volume", fiat)?, + taler_in_count: r.try_get_u64("taler_in_count")?, + taler_in_volume: r.try_get_amount("taler_in_volume", regional)?, + taler_out_count: r.try_get_u64("taler_out_count")?, + taler_out_volume: r.try_get_amount("taler_out_volume", regional)?, + } + } else { + MonitorResponse::Simple { + taler_in_count: r.try_get_u64("taler_in_count")?, + taler_in_volume: r.try_get_amount("taler_in_volume", regional)?, + taler_out_count: r.try_get_u64("taler_out_count")?, + taler_out_volume: r.try_get_amount("taler_out_volume", regional)?, + } + }) + }) + .fetch_optional(db) + )? + .unwrap_or_else(|| { + if let Some(fiat) = fiat { + MonitorResponse::Conversion { + cashin_count: 0, + cashin_regional_volume: Amount::zero(regional), + cashin_fiat_volume: Amount::zero(fiat), + cashout_count: 0, + cashout_regional_volume: Amount::zero(regional), + cashout_fiat_volume: Amount::zero(fiat), + taler_in_count: 0, + taler_in_volume: Amount::zero(regional), + taler_out_count: 0, + taler_out_volume: Amount::zero(regional), + } + } else { + MonitorResponse::Simple { + taler_in_count: 0, + taler_in_volume: Amount::zero(regional), + taler_out_count: 0, + taler_out_volume: Amount::zero(regional), + } + } + })) +} diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -1834,59 +1834,36 @@ COMMENT ON FUNCTION tan_challenge_try IS 'Try to confirm a challenge, return tru CREATE FUNCTION stats_get_frame( IN date TIMESTAMP, - IN in_timeframe stat_timeframe_enum, - OUT cashin_count INT8, - OUT cashin_regional_volume taler_amount, - OUT cashin_fiat_volume taler_amount, - OUT cashout_count INT8, - OUT cashout_regional_volume taler_amount, - OUT cashout_fiat_volume taler_amount, - OUT taler_in_count INT8, - OUT taler_in_volume taler_amount, - OUT taler_out_count INT8, - OUT taler_out_volume taler_amount + IN in_timeframe stat_timeframe_enum ) -LANGUAGE plpgsql AS $$ -BEGIN - date = date_trunc(in_timeframe::text, date); +RETURNS TABLE ( + cashin_count INT8, + cashin_regional_volume taler_amount, + cashin_fiat_volume taler_amount, + cashout_count INT8, + cashout_regional_volume taler_amount, + cashout_fiat_volume taler_amount, + taler_in_count INT8, + taler_in_volume taler_amount, + taler_out_count INT8, + taler_out_volume taler_amount +) +LANGUAGE sql AS $$ SELECT - s.cashin_count - ,(s.cashin_regional_volume).val - ,(s.cashin_regional_volume).frac - ,(s.cashin_fiat_volume).val - ,(s.cashin_fiat_volume).frac - ,s.cashout_count - ,(s.cashout_regional_volume).val - ,(s.cashout_regional_volume).frac - ,(s.cashout_fiat_volume).val - ,(s.cashout_fiat_volume).frac - ,s.taler_in_count - ,(s.taler_in_volume).val - ,(s.taler_in_volume).frac - ,s.taler_out_count - ,(s.taler_out_volume).val - ,(s.taler_out_volume).frac - INTO cashin_count - ,cashin_regional_volume.val - ,cashin_regional_volume.frac - ,cashin_fiat_volume.val - ,cashin_fiat_volume.frac + ,cashin_regional_volume + ,cashin_fiat_volume ,cashout_count - ,cashout_regional_volume.val - ,cashout_regional_volume.frac - ,cashout_fiat_volume.val - ,cashout_fiat_volume.frac + ,cashout_regional_volume + ,cashout_fiat_volume ,taler_in_count - ,taler_in_volume.val - ,taler_in_volume.frac + ,taler_in_volume ,taler_out_count - ,taler_out_volume.val - ,taler_out_volume.frac - FROM bank_stats AS s - WHERE s.timeframe = in_timeframe - AND s.start_time = date; -END $$; + ,taler_out_volume + FROM bank_stats + WHERE timeframe = in_timeframe + AND start_time = date_trunc(in_timeframe::text, date) +$$; CREATE PROCEDURE stats_register_payment( IN name TEXT,