libeufin

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

commit efe0ff0ffba560e68bb4ef9e87119076e79d9f5b
parent 8e4cb6c709c79554eb5de5ad980aea669cc639b4
Author: Antoine A <>
Date:   Thu, 21 May 2026 17:20:23 +0200

bank: add Wire Gateway, Revenue and Prepared Transfer APIs

Diffstat:
MCargo.lock | 31+++++++++++++++++++++++++++++--
Mcrates/libeufin-bank/Cargo.toml | 4++--
Mcrates/libeufin-bank/src/api.rs | 220++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/libeufin-bank/src/api/account.rs | 4++--
Mcrates/libeufin-bank/src/api/cashout.rs | 7+++----
Mcrates/libeufin-bank/src/api/conversion.rs | 16++++++++--------
Acrates/libeufin-bank/src/api/prepared.rs | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-bank/src/api/revenue.rs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/api/tan.rs | 78++----------------------------------------------------------------------------
Mcrates/libeufin-bank/src/api/token.rs | 4++--
Mcrates/libeufin-bank/src/api/tx.rs | 12+++++-------
Acrates/libeufin-bank/src/api/wire.rs | 719+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/api/withdrawal.rs | 38++++++++++++++++----------------------
Mcrates/libeufin-bank/src/auth.rs | 32++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/config.rs | 9+++++----
Mcrates/libeufin-bank/src/db.rs | 2++
Mcrates/libeufin-bank/src/db/account.rs | 24+++++++++++++++++++-----
Mcrates/libeufin-bank/src/db/cashout.rs | 3+--
Acrates/libeufin-bank/src/db/exchange.rs | 401+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-bank/src/db/prepared.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/libeufin-bank/src/db/token.rs | 8++++----
Mcrates/libeufin-bank/src/db/tx.rs | 46++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/libeufin-bank/src/db/withdrawal.rs | 2+-
Mcrates/libeufin-bank/src/payto.rs | 19+++++++++++++++----
Mcrates/libeufin-nexus/src/api.rs | 70+++++++++++++++++++++++++++++++---------------------------------------
Mcrates/libeufin-nexus/src/bench.rs | 2+-
Mcrates/libeufin-nexus/src/db/exchange.rs | 22++++++++++++----------
Mcrates/libeufin-nexus/src/db/initiated.rs | 2+-
Mcrates/libeufin-nexus/src/db/list.rs | 2+-
Mcrates/libeufin-nexus/src/db/payment.rs | 40++++++++++++++++++++--------------------
Mcrates/libeufin-nexus/src/db/transfer.rs | 62+++++++++++++++++++++++++++++++++-----------------------------
Mcrates/libeufin-nexus/src/model.rs | 2+-
Mcrates/libeufin-nexus/src/test.rs | 8++++----
Mdatabase-versioning/libeufin-bank-procedures.sql | 155++++++++++++++++++++++++++++++++++++++++---------------------------------------
34 files changed, 2105 insertions(+), 383 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -859,9 +859,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -1270,6 +1270,12 @@ dependencies = [ ] [[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1695,6 +1701,7 @@ dependencies = [ "taler-macros", "taler-test-utils", "tokio", + "tower-http", "tracing", "url", "uuid", @@ -1873,6 +1880,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3305,6 +3322,7 @@ dependencies = [ name = "taler-macros" version = "1.5.0" dependencies = [ + "proc-macro2", "quote", "syn", ] @@ -3527,10 +3545,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", diff --git a/crates/libeufin-bank/Cargo.toml b/crates/libeufin-bank/Cargo.toml @@ -26,10 +26,11 @@ compact_str.workspace = true uuid.workspace = true axum.workspace = true rand.workspace = true +tower-http = { version = "0.6", features = ["fs"]} futures = "0.3" url = "2.5" regex = "1.12" bcrypt = "0.19.0" [dev-dependencies] -pretty_assertions.workspace = true -\ No newline at end of file +pretty_assertions.workspace = true diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs @@ -17,20 +17,59 @@ * <http://www.gnu.org/licenses/> */ +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::State, + response::{IntoResponse, Redirect, Response}, + routing::get, +}; +use serde::Serialize; use sqlx::PgPool; -use taler_api::notification::NotificationChannel; +use taler_api::{api::TalerRouter as _, notification::NotificationChannel}; +use taler_common::{ + api::LibtoolVersion, + types::amount::{Amount, Currency}, +}; +use taler_macros::api_config; +use tower_http::services::ServeDir; use uuid::Uuid; -use crate::{api::withdrawal::WithdrawalStatus, config::BankCfg, db::notification_listener}; +use crate::{ + TanChannel, + api::{ + account::account_api, + cashout::cashout_api, + conversion::conversion_api, + prepared::prepared_api, + revenue::revenue_api, + tan::tan_api, + token::token_api, + tx::tx_api, + wire::wire_api, + withdrawal::{WithdrawalStatus, withdrawal_api}, + }, + config::{BankCfg, CurrencySpecification}, + db::notification_listener, +}; pub mod account; pub mod cashout; pub mod conversion; +pub mod prepared; +pub mod revenue; pub mod tan; pub mod token; pub mod tx; +pub mod wire; pub mod withdrawal; +const IMPLEMENTATION: &str = "urn:net:taler:specs:libeufin-bank:taler-rust"; +const COREBANK_API_VERSION: LibtoolVersion = LibtoolVersion::new(12, 1, 0); +const CONVERSION_API_VERSION: LibtoolVersion = LibtoolVersion::new(2, 1, 1); +const INTEGRATION_API_VERSION: LibtoolVersion = LibtoolVersion::new(5, 1, 5); + pub struct BankState { pub db: PgPool, pub cfg: BankCfg, @@ -68,9 +107,76 @@ impl BankState { } } +#[api_config("taler-coreban")] +#[derive(Debug, Clone, Serialize)] +pub struct Config<'a> { + pub currency: Currency, + pub currency_specification: &'a CurrencySpecification, + pub base_url: &'a str, + pub bank_name: &'a str, + pub allow_conversion: bool, + pub allow_registrations: bool, + pub allow_deletions: bool, + pub allow_edit_name: bool, + pub allow_edit_cashout_payto_uri: bool, + pub default_debit_threshold: Amount, + pub supported_tan_channels: Vec<TanChannel>, + pub wire_type: &'a str, + pub wire_transfer_fees: Amount, + pub min_wire_transfer_amount: Amount, + pub max_wire_transfer_amount: Amount, +} + +async fn config(State(state): State<Arc<BankState>>) -> Response { + Json(Config { + name: (), + version: COREBANK_API_VERSION, + implementation: Some(IMPLEMENTATION), + currency: state.cfg.regional_currency, + currency_specification: &state.cfg.regional_currency_spec, + base_url: state.cfg.base_url.as_str(), + bank_name: &state.cfg.name, + allow_conversion: state.cfg.fiat.is_some(), + allow_registrations: state.cfg.allow_registration, + allow_deletions: state.cfg.allow_account_deletion, + allow_edit_name: state.cfg.allow_edit_name, + allow_edit_cashout_payto_uri: state.cfg.allow_edit_cashout, + default_debit_threshold: state.cfg.default_debt_limit, + supported_tan_channels: state.cfg.tan_channels.keys().copied().collect::<Vec<_>>(), + wire_type: state.cfg.wire_method.as_ref(), + wire_transfer_fees: state.cfg.wire_transfer_fees, + min_wire_transfer_amount: state.cfg.min_amount, + max_wire_transfer_amount: state.cfg.max_amount, + }) + .into_response() +} + +pub fn bank_api(state: Arc<BankState>) -> Router { + let router = Router::new().route("/config", get(config)); // TODO monitor + if let Some(path) = &state.cfg.spa_path { + router + .nest_service("/webui/", ServeDir::new(path)) + .route("/", get(async || Redirect::permanent("/webui/"))) + } else { + router + } + .merge(token_api()) + .merge(account_api()) + .merge(tx_api()) + .merge(tan_api()) + .merge(conversion_api(state.clone())) + .merge(cashout_api(state.clone())) + .merge(withdrawal_api()) + .merge(revenue_api()) + .merge(wire_api()) + .merge(prepared_api()) + .with_state(state) + .finalize() +} + #[cfg(test)] pub mod test { - use std::{collections::BTreeMap, ops::Deref, sync::Arc}; + use std::{collections::BTreeMap, fmt::Display, ops::Deref, sync::Arc}; use axum::{ Router, @@ -82,14 +188,13 @@ pub mod test { use jiff::Timestamp; use rand::{random_range, seq::IndexedRandom}; use sqlx::{PgPool, Pool, Postgres, pool::PoolConnection}; - use taler_api::api::TalerRouter; use taler_common::{ - api_common::{EddsaPublicKey, HashCode, ShortHashCode}, + api::{EddsaPublicKey, HashCode, ShortHashCode}, config::Config, error_code::ErrorCode, types::{ amount::{Amount, Decimal}, - payto::{IbanPayto, PaytoURI}, + payto::PaytoURI, }, }; use taler_test_utils::{ @@ -104,15 +209,12 @@ pub mod test { api::{ BankState, account::{ - AccountData, Balance, CreditDebitInfo, account_api, create_admin_account, - rand_iban_payto, + AccountData, Balance, CreditDebitInfo, create_admin_account, rand_iban_payto, }, - cashout::cashout_api, - conversion::{ConversionRateClassResponse, ConversionResponse, conversion_api}, - tan::{ChallengeResponse, tan_api}, - token::token_api, - tx::tx_api, - withdrawal::withdrawal_api, + bank_api, + conversion::{ConversionRateClassResponse, ConversionResponse}, + tan::ChallengeResponse, + withdrawal::BankAccountCreateWithdrawalResponse, }, config::BankCfg, db::{self, account::CreationResult}, @@ -149,15 +251,7 @@ pub mod test { .unwrap(); let state = Arc::new(BankState::start(db.clone(), BankCfg::parse(cfg).unwrap()).await); - let server = token_api() - .merge(account_api()) - .merge(tx_api()) - .merge(tan_api()) - .merge(conversion_api(state.clone())) - .merge(cashout_api(state.clone())) - .merge(withdrawal_api()) - .with_state(state.clone()) - .finalize(); + let server = bank_api(state.clone()); let merchant_payto = db::account::create( &db, @@ -282,15 +376,7 @@ pub mod test { let state = Arc::new( BankState::start(self.state.db.clone(), BankCfg::parse(cfg).unwrap()).await, ); - self.server = token_api() - .merge(account_api()) - .merge(tx_api()) - .merge(tan_api()) - .merge(conversion_api(state.clone())) - .merge(cashout_api(state.clone())) - .merge(withdrawal_api()) - .with_state(state.clone()) - .finalize(); + self.server = bank_api(state.clone()); self.state = state; self } @@ -339,12 +425,22 @@ pub mod test { } pub async fn admin_router(&self) -> Router { - let token = self.tokens["admin"].clone(); self.server.clone().layer(middleware::from_fn_with_state( - token, - async |State(token): State<String>, mut req: Request, next: Next| { - req.headers_mut() - .insert(AUTHORIZATION, HeaderValue::from_str(&token).unwrap()); + Arc::new(self.tokens.clone()), + async |State(tokens): State<Arc<BTreeMap<CompactString, String>>>, + mut req: Request, + next: Next| { + let path = req.uri().path(); + if path.starts_with("/accounts") && !path.contains("admin") { + let username = Self::extract_username(req.uri().path()); + let header = HeaderValue::from_str(&tokens[username]).unwrap(); + req.headers_mut().insert(AUTHORIZATION, header); + } else { + req.headers_mut().insert( + AUTHORIZATION, + HeaderValue::from_str(&tokens["admin"]).unwrap(), + ); + } next.run(req).await }, )) @@ -466,7 +562,7 @@ pub mod test { self.tmp_payto.as_uri() } - pub async fn tx_s(&self, from: &str, amount: &str, to: &str, subject: &str) { + pub async fn tx_s(&self, from: &str, amount: &str, to: &str, subject: impl Display) { let payto = match to { "admin" => &self.admin_payto, "merchant" => &self.merchant_payto, @@ -505,14 +601,14 @@ pub mod test { pub async fn transfer( &self, amount: &str, - payto: &IbanPayto, + payto: &BankPayto, metadata: Option<CompactString>, ) { self.posta("/accounts/exchange/taler-wire-gateway/transfer") .json({ json!({ "request_uid": HashCode::rand(), - "amount": amount, + "amount": format!("{}:{amount}", self.state.cfg.regional_currency), "exchange_base_url": "http://exchange.example.com/", "wtid": ShortHashCode::rand(), "credit_account": payto, @@ -523,6 +619,36 @@ pub mod test { .assert_ok() } + pub async fn withdrawal(&self, amount: &str) { + let uuid = self + .posta("/accounts/merchant/withdrawals") + .json({ + json!({ + "amount": format!("{}:{amount}", self.state.cfg.regional_currency) + }) + }) + .await + .assert_ok_json::<BankAccountCreateWithdrawalResponse>() + .withdrawal_id; + self.withdraw_select(uuid).await; + self.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) + .json(json!({})) + .await + .assert_no_content(); + } + + pub async fn withdraw_select(&self, uuid: Uuid) -> EddsaPublicKey { + let key = EddsaPublicKey::rand(); + self.post(format!("/taler-integration/withdrawal-operation/{uuid}")) + .json(json!({ + "reserve_pub": key, + "selected_exchange": self.exchange_payto + })) + .await + .assert_ok(); + key + } + pub async fn create_conversion_rate_class(&self) -> u64 { self.post_admin("/conversion-rate-classes") .json(json!({ @@ -542,23 +668,11 @@ pub mod test { .amount_credit } - pub async fn withdraw_select(&self, uuid: Uuid) -> EddsaPublicKey { - let key = EddsaPublicKey::rand(); - self.post(format!("/taler-integration/withdrawal-operation/{uuid}")) - .json(json!({ - "reserve_pub": key, - "selected_exchange": self.exchange_payto - })) - .await - .assert_ok(); - key - } - /** Set [account] debit threshold to [maxDebt] amount */ pub async fn set_max_debt(&self, username: &str, amount: &str) { self.patch_admin(&format!("/accounts/{username}")) .json(json!({ - "debit_threshold": amount + "debit_threshold": format!("{}:{amount}", self.state.cfg.regional_currency) })) .await .assert_no_content(); diff --git a/crates/libeufin-bank/src/api/account.rs b/crates/libeufin-bank/src/api/account.rs @@ -38,7 +38,7 @@ use taler_api::{ extract::{Query, Req}, }; use taler_common::{ - api_params::{Page, PageParams, ParamsErr}, + api::params::{Page, PageParams, ParamsErr}, error_code::ErrorCode::{self}, types::{ amount::Amount, @@ -1301,7 +1301,7 @@ pub mod test { "name": "Mallory" }); - ctx.set_max_debt("admin", "KUDOS:1000").await; + ctx.set_max_debt("admin", "1000").await; // Check OK for i in 0..10 { diff --git a/crates/libeufin-bank/src/api/cashout.rs b/crates/libeufin-bank/src/api/cashout.rs @@ -34,8 +34,7 @@ use taler_api::{ extract::{Path, Query}, }; use taler_common::{ - api_common::ShortHashCode, - api_params::PageParams, + api::{ShortHashCode, params::PageParams}, error_code::ErrorCode, types::{amount::Amount, timestamp::TalerTimestamp}, }; @@ -205,7 +204,7 @@ pub fn cashout_api(state: Arc<BankState>) -> Router<Arc<BankState>> { }, ), ) - .layer(middleware::from_fn_with_state( + .route_layer(middleware::from_fn_with_state( state, async |State(state): State<Arc<BankState>>, req, next: Next| { if state.cfg.fiat.is_none() { @@ -220,7 +219,7 @@ pub fn cashout_api(state: Arc<BankState>) -> Router<Arc<BankState>> { #[cfg(test)] pub mod test { use axum::http::Method; - use taler_common::{api_common::ShortHashCode, error_code::ErrorCode, types::amount::amount}; + use taler_common::{api::ShortHashCode, error_code::ErrorCode, types::amount::amount}; use taler_test_utils::{ json, routine::{Page, routine_pagination}, diff --git a/crates/libeufin-bank/src/api/conversion.rs b/crates/libeufin-bank/src/api/conversion.rs @@ -34,14 +34,14 @@ use taler_api::{ extract::{Path, Query, Req}, }; use taler_common::{ - api_params::{Page, PageParams, ParamsErr}, + api::params::{Page, PageParams, ParamsErr}, error_code::ErrorCode, types::amount::{Amount, Currency, Decimal}, }; -use taler_macros::EnumMeta; +use taler_macros::{EnumMeta, api_config}; use crate::{ - api::BankState, + api::{BankState, CONVERSION_API_VERSION, IMPLEMENTATION}, auth::{AdminRAuth, AdminRWAuth, UserOptRAuth, UserRAuth}, config::{BankCfg, CurrencySpecification}, db::conversion::{ @@ -174,10 +174,9 @@ pub struct ConversionRateClasses { pub classes: Vec<ConversionRateClass>, } +#[api_config("taler-conversion-info")] #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ConversionConfig<'a> { - pub name: &'a str, - pub version: &'a str, pub regional_currency: &'a Currency, pub regional_currency_specification: &'a CurrencySpecification, pub fiat_currency: &'a Currency, @@ -252,8 +251,9 @@ async fn config(State(state): State<Arc<BankState>>) -> ApiResult<Response> { let rate = get_default_rate(&state.db, &state.cfg.regional_currency, fiat).await?; ApiResult::Ok( Json(ConversionConfig { - name: "taler-conversion-info", - version: "2:0:1", + name: (), + version: CONVERSION_API_VERSION, + implementation: Some(IMPLEMENTATION), conversion_rate: &rate, fiat_currency: fiat, fiat_currency_specification: spec, @@ -578,7 +578,7 @@ pub fn conversion_api(state: Arc<BankState>) -> Router<Arc<BankState>> { }, ), ) - .layer(middleware::from_fn_with_state( + .route_layer(middleware::from_fn_with_state( state, async |State(state): State<Arc<BankState>>, req, next: Next| { if state.cfg.fiat.is_none() { diff --git a/crates/libeufin-bank/src/api/prepared.rs b/crates/libeufin-bank/src/api/prepared.rs @@ -0,0 +1,241 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* 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. + +* 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/> +*/ + +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::State, + response::NoContent, + routing::{get, post}, +}; +use compact_str::CompactString; +use jiff::Timestamp; +use taler_api::{ + api::{Validation, prepared::simple_subject}, + constants::PREPARED_TRANSFER_API_VERSION, + error::failure_code, + extract::{Path, Req}, +}; +use taler_common::{ + api::prepared::{ + PreparedTransferConfig, RegistrationRequest, RegistrationResponse, SubjectFormat, + TransferSubject, Unregistration, + }, + error_code::ErrorCode, + types::timestamp::TalerTimestamp, +}; + +use crate::{ + api::{BankState, IMPLEMENTATION}, + db::prepared::{RegistrationResult, register, unregister}, +}; + +pub fn prepared_api() -> Router<Arc<BankState>> { + Router::new() + .route( + "/accounts/{username}/taler-prepared-transfer/config", + get(async |State(state): State<Arc<BankState>>| { + Json(PreparedTransferConfig { + name: (), + version: PREPARED_TRANSFER_API_VERSION, + implementation: Some(IMPLEMENTATION), + currency: state.cfg.regional_currency, + supported_formats: vec![SubjectFormat::URI, SubjectFormat::SIMPLE], + }) + }), + ) + .route( + "/accounts/{username}/taler-prepared-transfer/registration", + post( + async |Path(username): Path<CompactString>, + State(state): State<Arc<BankState>>, + Req(req): Req<RegistrationRequest>| { + req.check(&state.cfg.regional_currency)?; + match register( + &state.db, + &username, + req.r#type, + &req.account_pub, + &req.authorization_pub, + &req.authorization_sig, + req.recurrent, + &req.credit_amount, + &Timestamp::now(), + ) + .await? + { + RegistrationResult::Success(uuid) => { + let mut subjects = Vec::new(); + if let Some(uuid) = uuid { + subjects.push(TransferSubject::Uri { + credit_amount: req.credit_amount, + uri: state.cfg.taler_withdraw_uri(uuid), + }); + } + subjects.push(simple_subject(req)); + Ok(Json(RegistrationResponse { + subjects, + expiration: TalerTimestamp::Never, + })) + } + RegistrationResult::UnknownAccount => { + Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)) + } + RegistrationResult::NotExchange => { + Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)) + } + RegistrationResult::ReservePubReuse => { + Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) + } + } + }, + ), + ) + .route( + "/accounts/{username}/taler-prepared-transfer/unregistration", + post( + async |State(state): State<Arc<BankState>>, Req(req): Req<Unregistration>| { + req.check(&state.cfg.regional_currency)?; + if unregister(&state.db, &req.authorization_pub, &Timestamp::now()).await? { + Ok(NoContent) + } else { + Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)) + } + }, + ), + ) +} + +#[cfg(test)] +mod test { + use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair as _}; + use sqlx::{Row as _, postgres::PgRow}; + use taler_api::{crypto::eddsa_sign, db::TypeHelper as _}; + use taler_common::{api::EddsaPublicKey, db::IncomingType, error_code::ErrorCode}; + use taler_test_utils::{ + json, + routine::{Status, registration_routine}, + server::TestServer as _, + }; + + use crate::api::test::bank_setup; + + #[tokio::test] + async fn registration() { + let ctx = bank_setup().await; + + let key_pair1 = Ed25519KeyPair::generate().unwrap(); + let auth_pub1 = EddsaPublicKey::try_from(key_pair1.public_key().as_ref()).unwrap(); + let req = json!({ + "credit_amount": "KUDOS:120", + "type": "reserve", + "alg": "EdDSA", + "account_pub": auth_pub1, + "authorization_pub": auth_pub1, + "authorization_sig": eddsa_sign(&key_pair1, auth_pub1.as_ref()), + "recurrent": false + }); + ctx.post("/accounts/merchant/taler-prepared-transfer/registration") + .json(&req) + .await + .assert_error(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE); + ctx.post("/accounts/unknown/taler-prepared-transfer/registration") + .json(&req) + .await + .assert_error(ErrorCode::BANK_UNKNOWN_ACCOUNT); + + ctx.set_max_debt("customer", "1000").await; + registration_routine( + &ctx.admin_router().await.prefix("/accounts/exchange"), + &ctx.customer_payto.as_uri(), + async || { + sqlx::query( + " + SELECT + pending_recurrent_incoming_transactions.authorization_pub IS NOT NULL, + exchange_incoming_id IS NULL, + type::text, + metadata + FROM bank_account_transactions + JOIN bank_accounts ON bank_account_transactions.bank_account_id=bank_accounts.bank_account_id + JOIN customers ON customer_id=owning_customer_id + LEFT JOIN taler_exchange_incoming ON (bank_transaction=bank_transaction_id) + LEFT JOIN pending_recurrent_incoming_transactions USING (bank_transaction_id) + WHERE username='exchange' AND direction='credit' + ORDER BY bank_transaction_id + ", + ) + .try_map(|r: PgRow| { + Ok( + if r.try_get_flag(0)? { + Status::Pending + } else if r.try_get_flag(1)? { + Status::Bounced + } else { + match r.try_get(2)? { + None => Status::Simple, + Some(IncomingType::reserve) => Status::Reserve(r.try_get(3)?), + Some(IncomingType::kyc) => Status::Kyc(r.try_get(3)?), + Some(e) => unreachable!("{e:?}") + } + } + ) + }) + .fetch_all(&ctx.state.db) + .await + .unwrap() + } + ) + .await; + + /* + * + * // Non recurrent no URI + client.post("/accounts/exchange/taler-prepared-transfer/registration") { + json(valid_req) { + "type" to "reserve" + } + }.assertOkJson<SubjectResult> { + val uuid = (it.subjects[0] as? TransferSubject.Uri)!!.uri.substringAfterLast('/') + client.postA("/accounts/customer/withdrawals/$uuid/confirm").assertNoContent() // reserve + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce + assertBalance("customer", "-KUDOS:1") + assertBalance("exchange", "+KUDOS:1") + } + * // Withdrawal is aborted on completion + client.post("/accounts/exchange/taler-prepared-transfer/registration") { + json(valid_req) { + "type" to "kyc" + } + }.assertOkJson<SubjectResult> { + val uuid = (it.subjects[0] as? TransferSubject.Uri)!!.uri.substringAfterLast('/') + println("UUID $uuid") + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // kyc + tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce + client.postA("/accounts/customer/withdrawals/$uuid/confirm") + .assertConflict(TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT) // aborted + assertBalance("customer", "-KUDOS:2") + assertBalance("exchange", "+KUDOS:2") + } + + */ + } +} diff --git a/crates/libeufin-bank/src/api/revenue.rs b/crates/libeufin-bank/src/api/revenue.rs @@ -0,0 +1,104 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* 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. + +* 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/> +*/ + +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::State, + response::{IntoResponse, NoContent}, + routing::get, +}; +use taler_api::{constants::REVENUE_API_VERSION, error::ApiResult, extract::Query}; +use taler_common::api::{ + params::HistoryParams, + revenue::{RevenueConfig, RevenueIncomingHistory}, +}; + +use crate::{ + api::{BankState, IMPLEMENTATION}, + auth::RevenueAuth, + db::tx::revenue_history, +}; + +pub fn revenue_api() -> Router<Arc<BankState>> { + Router::new() + .route( + "/accounts/{username}/taler-revenue/config", + get(async |State(state): State<Arc<BankState>>| { + Json(RevenueConfig { + name: (), + version: REVENUE_API_VERSION, + implementation: Some(IMPLEMENTATION), + currency: state.cfg.regional_currency, + }) + }), + ) + .route( + "/accounts/{USERNAME}/taler-revenue/history", + get( + async |mut auth: RevenueAuth, + Query(params): Query<HistoryParams>, + State(state): State<Arc<BankState>>| { + let params = params.check()?; + let info = auth.bank_info(&state.db, &state.cfg.ctx).await?; + let incoming_transactions = revenue_history( + &state.db, + &state.cfg.ctx, + &state.cfg.regional_currency, + &state.revenue_channel, + &params, + info.bank_account_id, + ) + .await?; + if incoming_transactions.is_empty() { + ApiResult::Ok(NoContent.into_response()) + } else { + Ok(Json(RevenueIncomingHistory { + incoming_transactions, + credit_account: info.payto.as_uri(), + }) + .into_response()) + } + }, + ), + ) +} + +#[cfg(test)] +mod test { + use taler_common::types::payto::PaytoImpl as _; + use taler_test_utils::{routine::revenue_routine, server::TestServer, tasks}; + + use crate::api::test::bank_setup; + + #[tokio::test] + async fn revenue() { + let ctx = bank_setup().await; + ctx.set_max_debt("customer", "1000").await; + revenue_routine( + &ctx.admin_router().await.prefix("/accounts/exchange"), + &ctx.customer_payto.as_full_uri("Customer"), + true, + tasks!(), + tasks!(), + ) + .await; + } +} diff --git a/crates/libeufin-bank/src/api/tan.rs b/crates/libeufin-bank/src/api/tan.rs @@ -23,14 +23,14 @@ use axum::{ Json, Router, extract::State, http::StatusCode, - response::{IntoResponse, NoContent, Response}, + response::{IntoResponse, NoContent}, routing::post, }; use compact_str::CompactString; use jiff::Timestamp; use serde::{Deserialize, Serialize}; use taler_api::{ - error::{ApiResult, failure, failure_code}, + error::{failure, failure_code}, extract::{Path, Req}, }; use taler_common::{error_code::ErrorCode, types::timestamp::TalerTimestamp}; @@ -69,80 +69,6 @@ pub struct ChallengeSolve { pub tan: CompactString, } -#[axum::debug_handler] -pub async fn tmp( - State(state): State<Arc<BankState>>, - Path((_, id)): Path<((), Uuid)>, -) -> ApiResult<Response> { - match send(&state.db, &id, &Timestamp::now(), MAX_ACTIVE_CHALLENGES).await? { - SendResult::NotFound => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), - SendResult::Expired => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), - SendResult::TooMany => Err(failure_code(ErrorCode::BANK_TAN_RATE_LIMITED)), - SendResult::Solved => Ok(StatusCode::GONE.into_response()), - SendResult::Send { - info, - channel, - code, - expiration, - } => { - let (script, env) = &state.cfg.tan_channels[&channel]; - let msg = format!("T-{code} is your {} verification code", state.cfg.name); - let res = async { - let mut child = Command::new(script) - .arg(&info) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .envs(env.iter()) - .spawn()?; - - if let Some(mut stdin) = child.stdin.take() { - let _ = stdin.write_all(msg.as_bytes()).await; - } - - child.wait_with_output().await - } - .await; - let output = match res { - Err(e) => { - tracing::error!(target: "tan", "{channel} {script} failed: {e}"); - return Err(failure( - ErrorCode::BANK_TAN_CHANNEL_SCRIPT_FAILED, - format_args!("TAN channel {channel} IO failure"), - )); - } - Ok(output) => output, - }; - - let code = output.status.code().unwrap_or(-1); - if code != 0 { - let out = String::from_utf8_lossy(&output.stdout); - tracing::error!(target: "tan", "{channel} {script}: {code} {out}"); - return Err(failure( - ErrorCode::BANK_TAN_CHANNEL_SCRIPT_FAILED, - format_args!("TAN channel {channel} failure with exit code"), - )); - } - - let retransmission = Timestamp::now() + Duration::from_mins(3); - mark_sent(&state.db, &id, &retransmission).await?; - Ok(Json(ChallengeRequestResponse { - solve_expiration: expiration.into(), - earliest_retransmission: retransmission.into(), - }) - .into_response()) - } - SendResult::Success { - expiration, - retransmission, - } => Ok(Json(ChallengeRequestResponse { - solve_expiration: expiration.into(), - earliest_retransmission: retransmission.into(), - }) - .into_response()), - } -} - pub fn tan_api() -> Router<Arc<BankState>> { Router::new().route( "/accounts/{username}/challenge/{id}", diff --git a/crates/libeufin-bank/src/api/token.rs b/crates/libeufin-bank/src/api/token.rs @@ -32,7 +32,7 @@ use taler_api::{ extract::{Path, Query}, }; use taler_common::{ - api_params::PageParams, + api::params::PageParams, error_code::ErrorCode::{self}, types::{ base32::Base32, @@ -198,7 +198,7 @@ pub mod test { use axum::http::{Method, header::AUTHORIZATION}; use jiff::{SignedDuration, Timestamp}; use taler_common::{ - api_common::ErrorDetail, + api::ErrorDetail, error_code::ErrorCode, types::{base32::Base32, timestamp::TalerTimestamp}, }; diff --git a/crates/libeufin-bank/src/api/tx.rs b/crates/libeufin-bank/src/api/tx.rs @@ -34,8 +34,7 @@ use taler_api::{ extract::{Path, Query}, }; use taler_common::{ - api_common::ShortHashCode, - api_params::HistoryParams, + api::{ShortHashCode, params::HistoryParams}, error_code::ErrorCode, types::{amount::Amount, payto::ParsedPayto, timestamp::TalerTimestamp}, }; @@ -203,7 +202,7 @@ pub mod test { use axum::http::Method; use taler_api::subject::{fmt_in_subject, fmt_out_subject}; use taler_common::{ - api_common::{EddsaPublicKey, ShortHashCode}, + api::{EddsaPublicKey, ShortHashCode}, db::IncomingType, error_code::ErrorCode, types::amount::amount, @@ -213,7 +212,6 @@ pub mod test { routine::{Page, routine_history}, tasks, }; - use url::Url; use crate::api::{ test::{Auth, MfaRequest, bank_setup, bank_setup_conf}, @@ -395,19 +393,19 @@ pub mod test { ctx.tx_s("exchange", "1", "merchant", "").await; // Warn common to transaction ctx.tx_s("exchange", "1", "merchant", "Malformed").await; // Warn malformed transaction let wtid = ShortHashCode::rand(); - let url = Url::parse("https://exchange.example.com").unwrap(); + let url = "https://exchange.example.com"; ctx.tx_s( "exchange", "1", "merchant", - &fmt_out_subject(&wtid, &url, None), + &fmt_out_subject(&wtid, url, None), ) .await; // Accept outgoing ctx.tx_s( "exchange", "1", "merchant", - &fmt_out_subject(&wtid, &url, None), + &fmt_out_subject(&wtid, url, None), ) .await; // Warn wtid reuse ctx.assert_balance("merchant", "3").await; diff --git a/crates/libeufin-bank/src/api/wire.rs b/crates/libeufin-bank/src/api/wire.rs @@ -0,0 +1,719 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* 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. + +* 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/> +*/ + +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::State, + response::{IntoResponse, NoContent, Response}, + routing::{get, post}, +}; +use jiff::Timestamp; +use serde::Deserialize; +use sqlx::PgPool; +use taler_api::{ + api::Validation, + constants::WIRE_GATEWAY_API_VERSION, + error::{ApiResult, failure_code}, + extract::{Path, Query, Req}, + subject::{IncomingSubject, fmt_in_subject}, +}; +use taler_common::{ + api::{ + params::{HistoryParams, Page, PageParams}, + wire::{ + AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, + IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, + TransferState, WireConfig, + }, + }, + error_code::ErrorCode, + types::{amount::Amount, payto::PaytoURI}, +}; + +use crate::{ + api::{BankState, IMPLEMENTATION}, + auth::{UserAdminAuth, WireRAuth, WireRWAuth}, + db::{ + account::check_info, + exchange::{ + AddIncomingResult, TransferResult, add_incoming, incoming_history, outgoing_history, + page_transfer, transfer, transfer_by_id, + }, + }, + payto::BankPayto, +}; + +#[derive(Debug, Clone, Deserialize)] +pub struct TransferParams { + #[serde(flatten)] + pub page: PageParams, + pub status: Option<TransferState>, +} + +impl TransferParams { + pub fn check(self) -> ApiResult<Transfer> { + Ok(Transfer { + page: self.page.check()?, + status: self.status, + }) + } +} + +pub struct Transfer { + pub page: Page, + pub status: Option<TransferState>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AccountCheckParams { + pub account: BankPayto, +} + +async fn admin_add_incoming( + db: &PgPool, + username: &str, + amount: &Amount, + debtor: &PaytoURI, + metadata: &IncomingSubject, +) -> ApiResult<Response> { + let payto = BankPayto::try_from(debtor)?; + match add_incoming( + db, + amount, + &payto, + &format!( + "Admin incoming {}", + fmt_in_subject(metadata.ty(), metadata.key()) + ), + username, + &Timestamp::now(), + metadata, + ) + .await? + { + AddIncomingResult::Success { id, timestamp, .. } => Ok(Json(AddIncomingResponse { + row_id: id, + timestamp: timestamp.into(), + }) + .into_response()), + AddIncomingResult::NotAnExchange => { + Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)) + } + AddIncomingResult::UnknownExchange => Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)), + AddIncomingResult::UnknownDebtor => Err(failure_code(ErrorCode::BANK_UNKNOWN_DEBTOR)), + AddIncomingResult::BothPartyAreExchange => { + Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE)) + } + AddIncomingResult::ReservePubReuse => { + Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) + } + AddIncomingResult::UnknownMapping => { + Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_UNKNOWN)) + } + AddIncomingResult::MappingReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_REUSED)) + } + AddIncomingResult::BalanceInsufficient => { + Err(failure_code(ErrorCode::BANK_UNALLOWED_DEBIT)) + } + } +} + +pub fn wire_api() -> Router<Arc<BankState>> { + Router::new() + .route( + "/accounts/{username}/taler-wire-gateway/config", + get(async |State(state): State<Arc<BankState>>| { + Json(WireConfig { + name: (), + version: WIRE_GATEWAY_API_VERSION, + implementation: Some(IMPLEMENTATION), + currency: state.cfg.regional_currency, + support_account_check: true, + }) + }), + ) + .route( + "/accounts/{username}/taler-wire-gateway/transfer", + post( + async |auth: WireRWAuth, + State(state): State<Arc<BankState>>, + Req(req): Req<TransferRequest>| { + req.check(&state.cfg.regional_currency)?; + let payto = BankPayto::try_from(&req.credit_account)?; + match transfer( + &state.db, + &auth.username, + &req, + &payto, + &Timestamp::now(), + state.cfg.fiat.is_some(), + ) + .await? + { + TransferResult::Success { id, timestamp } => Ok(Json(TransferResponse { + timestamp: timestamp.into(), + row_id: id, + })), + TransferResult::NotAnExchange => { + Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)) + } + TransferResult::UnknownExchange => { + Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)) + } + TransferResult::BothPartyAreExchange => { + Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE)) + } + TransferResult::BalanceInsufficient => { + Err(failure_code(ErrorCode::BANK_UNALLOWED_DEBIT)) + } + TransferResult::ReserveUidReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) + } + TransferResult::WtidReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_WTID_REUSED)) + } + TransferResult::AdminCreditor => { + Err(failure_code(ErrorCode::BANK_ADMIN_CREDITOR)) + } + } + }, + ), + ) + .route( + "/accounts/{username}/taler-wire-gateway/transfers", + get( + async |Query(params): Query<TransferParams>, + mut auth: WireRAuth, + State(state): State<Arc<BankState>>| { + let params = params.check()?; + let info = auth.bank_info(&state.db, &state.cfg.ctx).await?; + if !info.is_exchange { + return Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)); + } + let transfers = page_transfer( + &state.db, + &state.cfg.ctx, + &state.cfg.regional_currency, + &params.page, + info.bank_account_id, + params.status, + ) + .await?; + if transfers.is_empty() { + ApiResult::Ok(NoContent.into_response()) + } else { + Ok(Json(TransferList { + transfers, + debit_account: info.payto.as_uri(), + }) + .into_response()) + } + }, + ), + ) + .route( + "/accounts/{username}/taler-wire-gateway/transfers/{id}", + get( + async |Path((_, id)): Path<((), u64)>, + mut auth: WireRAuth, + State(state): State<Arc<BankState>>| { + let info = auth.bank_info(&state.db, &state.cfg.ctx).await?; + if !info.is_exchange { + return Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)); + } + match transfer_by_id( + &state.db, + &state.cfg.ctx, + &state.cfg.regional_currency, + info.bank_account_id, + id, + ) + .await? + { + Some(t) => Ok(Json(t)), + None => Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)), + } + }, + ), + ) + .route( + "/accounts/{username}/taler-wire-gateway/history/incoming", + get( + async |Query(params): Query<HistoryParams>, + mut auth: WireRAuth, + State(state): State<Arc<BankState>>| { + let params = params.check()?; + let info = auth.bank_info(&state.db, &state.cfg.ctx).await?; + if !info.is_exchange { + return Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)); + } + let incoming_transactions = incoming_history( + &state.db, + &state.cfg.ctx, + &state.taler_in_channel, + &state.cfg.regional_currency, + &params, + info.bank_account_id, + ) + .await?; + if incoming_transactions.is_empty() { + ApiResult::Ok(NoContent.into_response()) + } else { + Ok(Json(IncomingHistory { + credit_account: info.payto.as_uri(), + incoming_transactions, + }) + .into_response()) + } + }, + ), + ) + .route( + "/accounts/{username}/taler-wire-gateway/history/outgoing", + get( + async |Query(params): Query<HistoryParams>, + mut auth: WireRAuth, + State(state): State<Arc<BankState>>| { + let params = params.check()?; + let info = auth.bank_info(&state.db, &state.cfg.ctx).await?; + if !info.is_exchange { + return Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)); + } + let outgoing_transactions = outgoing_history( + &state.db, + &state.cfg.ctx, + &state.taler_out_channel, + &state.cfg.regional_currency, + &params, + info.bank_account_id, + ) + .await?; + if outgoing_transactions.is_empty() { + ApiResult::Ok(NoContent.into_response()) + } else { + Ok(Json(OutgoingHistory { + debit_account: info.payto.as_uri(), + outgoing_transactions, + }) + .into_response()) + } + }, + ), + ) + .route( + "/accounts/{username}/taler-wire-gateway/admin/add-incoming", + post( + async |auth: UserAdminAuth, + State(state): State<Arc<BankState>>, + Req(req): Req<AddIncomingRequest>| { + req.check(&state.cfg.regional_currency)?; + admin_add_incoming( + &state.db, + &auth.username, + &req.amount, + &req.debit_account, + &IncomingSubject::Reserve(req.reserve_pub), + ) + .await + }, + ), + ) + .route( + "/accounts/{username}/taler-wire-gateway/admin/add-kycauth", + post( + async |auth: UserAdminAuth, + State(state): State<Arc<BankState>>, + Req(req): Req<AddKycauthRequest>| { + req.check(&state.cfg.regional_currency)?; + admin_add_incoming( + &state.db, + &auth.username, + &req.amount, + &req.debit_account, + &IncomingSubject::Kyc(req.account_pub), + ) + .await + }, + ), + ) + .route( + "/accounts/{username}/taler-wire-gateway/admin/add-mapped", + post( + async |auth: UserAdminAuth, + State(state): State<Arc<BankState>>, + Req(req): Req<AddMappedRequest>| { + req.check(&state.cfg.regional_currency)?; + admin_add_incoming( + &state.db, + &auth.username, + &req.amount, + &req.debit_account, + &IncomingSubject::Map(req.authorization_pub), + ) + .await + }, + ), + ) + .route( + "/accounts/{username}/taler-wire-gateway/account/check", + get( + async |Query(params): Query<AccountCheckParams>, + mut auth: WireRAuth, + State(state): State<Arc<BankState>>| { + let info = auth.bank_info(&state.db, &state.cfg.ctx).await?; + if !info.is_exchange { + return Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)); + } + match check_info(&state.db, &params.account).await? { + Some(t) => Ok(Json(t)), + None => Err(failure_code(ErrorCode::BANK_UNKNOWN_ACCOUNT)), + } + }, + ), + ) +} + +#[cfg(test)] +pub mod test { + use axum::http::Method; + use taler_api::subject::fmt_out_subject; + use taler_common::{ + api::{ + EddsaPublicKey, HashCode, ShortHashCode, + wire::{AccountInfo, TransferResponse, TransferState, TransferStatus}, + }, + db::IncomingType, + error_code::ErrorCode, + types::{amount::amount, payto::PaytoImpl}, + }; + use taler_test_utils::{ + json, + routine::{ + admin_add_incoming_routine, in_history_routine, out_history_routine, transfer_routine, + }, + server::TestServer as _, + tasks, + }; + + use crate::api::test::{Auth, bank_setup}; + + #[tokio::test] + async fn transfer() { + let ctx = bank_setup().await; + + ctx.auth_routine( + Method::POST, + "/accounts/merchant/taler-wire-gateway/transfer", + Auth::UserOnly, + ) + .await; + ctx.auth_routine( + Method::GET, + "/accounts/merchant/taler-wire-gateway/transfers/32", + Auth::UserOnly, + ) + .await; + ctx.auth_routine( + Method::GET, + "/accounts/merchant/taler-wire-gateway/transfers", + Auth::UserOnly, + ) + .await; + + let req = json!({ + "request_uid": HashCode::rand(), + "amount": "KUDOS:55", + "exchange_base_url": "http://exchange.example.com/", + "wtid": ShortHashCode::rand(), + "credit_account": ctx.merchant_payto + }); + + ctx.posta("/accounts/exchange/taler-wire-gateway/transfer") + .json(&req) + .await + .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); + + ctx.set_max_debt("exchange", "1000").await; + transfer_routine( + &ctx.admin_router().await.prefix("/accounts/exchange"), + TransferState::success, + &ctx.customer_payto.as_uri(), + ) + .await; + + let admin_req = json!(req + { + "credit_account" : ctx.admin_payto + }); + + // Check conversion bounce + for _ in 0..2 { + ctx.posta("/accounts/exchange/taler-wire-gateway/transfer") + .json(&admin_req) + .await + .assert_ok(); + } + + let ctx = ctx.swap_cfg("test_no_conversion.conf").await; + // Transfer works for common accounts + + let req = json!(req + { + "request_uid": HashCode::rand(), + "wtid": ShortHashCode::rand(), + }); + for _ in 0..2 { + ctx.posta("/accounts/exchange/taler-wire-gateway/transfer") + .json(&req) + .await + .assert_ok(); + } + // But fails to admin accounts + ctx.posta("/accounts/exchange/taler-wire-gateway/transfer") + .json(json!(admin_req + { + "request_uid": HashCode::rand(), + "wtid": ShortHashCode::rand(), + })) + .await + .assert_error(ErrorCode::BANK_ADMIN_CREDITOR); + + // Dot now fail for unknown account + let res = ctx + .posta("/accounts/exchange/taler-wire-gateway/transfer") + .json(json!(req + { + "request_uid": HashCode::rand(), + "wtid": ShortHashCode::rand(), + "credit_account": ctx.unknown_payto + })) + .await + .assert_ok_json::<TransferResponse>(); + let s: TransferStatus = ctx + .geta(format!( + "/accounts/exchange/taler-wire-gateway/transfers/{}", + res.row_id + )) + .await + .assert_ok_json(); + assert_eq!( + TransferStatus { + status: TransferState::permanent_failure, + status_msg: Some("Unknown account".into()), + amount: amount("KUDOS:55"), + origin_exchange_url: s.origin_exchange_url.clone(), + metadata: None, + wtid: s.wtid.clone(), + credit_account: s.credit_account.clone(), + timestamp: s.timestamp, + }, + s + ); + + // Not an exchange + ctx.geta(format!( + "/accounts/merchant/taler-wire-gateway/transfers/{}", + res.row_id + )) + .await + .assert_error(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE); + // Another account + ctx.patcha("/accounts/merchant") + .json(json!({ + "is_taler_exchange": true + })) + .await + .assert_no_content(); + ctx.geta(format!( + "/accounts/merchant/taler-wire-gateway/transfers/{}", + res.row_id + )) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + } + + #[tokio::test] + async fn outgoing_history() { + let ctx = &bank_setup().await; + ctx.auth_routine( + Method::GET, + "/accounts/merchant/taler-wire-gateway/history/outgoing", + Auth::UserOnly, + ) + .await; + ctx.set_max_debt("exchange", "1000").await; + out_history_routine( + &ctx.admin_router().await.prefix("/accounts/exchange"), + tasks!( + // Transactions using clean add incoming logic + { ctx.transfer("10", &ctx.customer_payto, None).await }, + // And with metadata + { + ctx.transfer("12", &ctx.customer_payto, Some("CON:ID".into())) + .await + } + ), + tasks!( + // // Failed transfer + { ctx.transfer("10", &ctx.unknown_payto, None).await }, + // Ignore manual outgoing transaction + { + ctx.tx_s( + "exchange", + "10", + "merchant", + fmt_out_subject( + &ShortHashCode::rand(), + "https://exchange.exmaple.com/", + None, + ), + ) + .await + }, + // Ignore malformed incoming transaction + { ctx.tx("merchant", "10", "exchange",).await }, + // Ignore malformed outgoing transaction + { ctx.tx("exchange", "10", "merchant",).await }, + ), + ) + .await; + } + + #[tokio::test] + async fn incoming_history() { + let ctx = &bank_setup().await; + ctx.auth_routine( + Method::GET, + "/accounts/merchant/taler-wire-gateway/history/incoming", + Auth::UserOnly, + ) + .await; + ctx.set_max_debt("customer", "1000").await; + ctx.set_max_debt("merchant", "1000").await; + in_history_routine( + &ctx.admin_router().await.prefix("/accounts/exchange"), + &ctx.customer_payto.as_uri(), + false, + tasks!( + // Reserve transactions using raw bank transaction logic + { + ctx.tx_s( + "merchant", + "10", + "exchange", + format!("history test with {} reserve pub", EddsaPublicKey::rand()), + ) + .await + }, + // Reserve transactions using withdraw logic + { ctx.withdrawal("9").await }, + // KYC transactions using raw bank transaction logic + { + ctx.tx_s( + "merchant", + "10", + "exchange", + format!( + "history test with KYC:{} account pub", + EddsaPublicKey::rand() + ), + ) + .await + } + ), + tasks!( + // Ignore malformed incoming transaction + { ctx.tx("merchant", "10", "exchange",).await }, + // Ignore malformed outgoing transaction + { ctx.tx("exchange", "10", "merchant",).await }, + ), + ) + .await; + } + + #[tokio::test] + async fn admin_add_incoming() { + let ctx = bank_setup().await; + ctx.auth_routine( + Method::GET, + "/accounts/merchant/taler-wire-gateway/history/incoming", + Auth::UserOnly, + ) + .await; + for ty in IncomingType::entries { + let (path, key) = match ty { + IncomingType::reserve => ("incoming", "reserve_pub"), + IncomingType::kyc => ("kycauth", "account_pub"), + IncomingType::map => ("mapped", "authorization_pub"), + }; + let path = format!("/accounts/exchange/taler-wire-gateway/admin/add-{path}"); + ctx.auth_routine(Method::POST, &path, Auth::Admin).await; + let req = json!({ + "amount": format!("KUDOS:44"), + "debit_account": ctx.customer_payto, + key: EddsaPublicKey::rand(), + }); + + ctx.post_admin(path.replace("exchange", "merchant")) + .json(&req) + .await + .assert_error(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE); + ctx.post_admin(&path) + .json(json!(req + { + "debit_account": ctx.exchange_payto + })) + .await + .assert_error(ErrorCode::BANK_ACCOUNT_IS_EXCHANGE); + if *ty != IncomingType::map { + ctx.post_admin(&path) + .json(json!(req + { + "amount": format!("KUDOS:44"), + })) + .await + .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT); + } + } + ctx.set_max_debt("customer", "1000").await; + admin_add_incoming_routine( + &ctx.admin_router().await.prefix("/accounts/exchange"), + &ctx.customer_payto.as_full_uri("Customer"), + true, + ) + .await; + } + + #[tokio::test] + async fn account_check() { + let ctx = bank_setup().await; + ctx.geta("/accounts/exchange/taler-wire-gateway/account/check") + .await + .assert_error(ErrorCode::GENERIC_PARAMETER_MISSING); + ctx.geta(format!( + "/accounts/exchange/taler-wire-gateway/account/check?account={}", + ctx.unknown_payto + )) + .await + .assert_error(ErrorCode::BANK_UNKNOWN_ACCOUNT); + ctx.geta(format!( + "/accounts/exchange/taler-wire-gateway/account/check?account={}", + ctx.merchant_payto + )) + .await + .assert_ok_json::<AccountInfo>(); + } +} diff --git a/crates/libeufin-bank/src/api/withdrawal.rs b/crates/libeufin-bank/src/api/withdrawal.rs @@ -33,15 +33,19 @@ use taler_api::{ extract::{Path, Query, Req}, }; use taler_common::{ - api_common::EddsaPublicKey, - api_params::{Pooling, PoolingParams}, + api::{ + EddsaPublicKey, + params::{Pooling, PoolingParams}, + }, error_code::ErrorCode, types::amount::{Amount, Currency}, }; +use taler_macros::api_config; +use url::Url; use uuid::Uuid; use crate::{ - api::BankState, + api::{BankState, IMPLEMENTATION, INTEGRATION_API_VERSION}, auth::UserORWAuth, config::CurrencySpecification, db::withdrawal::{ @@ -76,7 +80,7 @@ pub struct BankAccountCreateWithdrawalRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BankAccountCreateWithdrawalResponse { pub withdrawal_id: Uuid, - pub taler_withdraw_uri: String, + pub taler_withdraw_uri: Url, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -143,10 +147,9 @@ pub struct BankAccountConfirmWithdrawalRequest { pub amount: Option<Amount>, } +#[api_config("taler-bank-integration")] #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct TalerIntegrationConfigResponse<'a> { - pub name: &'a str, - pub version: &'a str, pub currency: &'a Currency, pub currency_specification: &'a CurrencySpecification, } @@ -325,8 +328,9 @@ pub fn withdrawal_api() -> Router<Arc<BankState>> { "/taler-integration/config", get(async |State(state): State<Arc<BankState>>| { Json(TalerIntegrationConfigResponse { - name: "taler-bank-integration", - version: "5:0:5", + name: (), + version: INTEGRATION_API_VERSION, + implementation: Some(IMPLEMENTATION), currency: &state.cfg.regional_currency, currency_specification: &state.cfg.regional_currency_spec, }) @@ -461,7 +465,7 @@ mod test { use axum::http::Method; use serde::de::DeserializeOwned; use taler_common::{ - api_common::EddsaPublicKey, + api::EddsaPublicKey, error_code::ErrorCode, types::amount::{Currency, amount}, }; @@ -493,12 +497,7 @@ mod test { })) .await .assert_ok_json::<BankAccountCreateWithdrawalResponse>() - .taler_withdraw_uri - .rsplit('/') - .next() - .unwrap() - .parse() - .unwrap(); + .withdrawal_id; let confirmed = ctx .posta("/accounts/merchant/withdrawals") .json(json!({ @@ -506,12 +505,7 @@ mod test { })) .await .assert_ok_json::<BankAccountCreateWithdrawalResponse>() - .taler_withdraw_uri - .rsplit('/') - .next() - .unwrap() - .parse() - .unwrap(); + .withdrawal_id; // Check no useless polling assert_time(0..5000, async { @@ -650,7 +644,7 @@ mod test { })) .await .assert_ok_json(); - let uuid = res.taler_withdraw_uri.rsplit('/').next().unwrap(); + let uuid = res.taler_withdraw_uri.path().rsplit('/').next().unwrap(); let w: BankWithdrawalOperationStatus = ctx .get(format!("/taler-integration/withdrawal-operation/{uuid}")) .await diff --git a/crates/libeufin-bank/src/auth.rs b/crates/libeufin-bank/src/auth.rs @@ -323,11 +323,43 @@ impl UserAuthScope for UserTokenScope { const ALLOW_BASIC_AUTH: bool = true; } +pub struct RevenueScope; + +impl UserAuthScope for RevenueScope { + const SCOPE: TokenLogicalScope = TokenLogicalScope::revenue; + const KIND: AuthKind = AuthKind::UserOrAdmin; +} + +pub struct WireRScope; + +impl UserAuthScope for WireRScope { + const SCOPE: TokenLogicalScope = TokenLogicalScope::readonly_wiregateway; + const KIND: AuthKind = AuthKind::UserOnly; +} + +pub struct WireRWScope; + +impl UserAuthScope for WireRWScope { + const SCOPE: TokenLogicalScope = TokenLogicalScope::readwrite_wiregateway; + const KIND: AuthKind = AuthKind::UserOnly; +} + +pub struct UserAdminScope; + +impl UserAuthScope for UserAdminScope { + const SCOPE: TokenLogicalScope = TokenLogicalScope::readwrite; + const KIND: AuthKind = AuthKind::AdminOnly; +} + pub type UserRWAuth = UserAuth<UserRWScope>; pub type UserRAuth = UserAuth<UserRScope>; pub type UserORWAuth = UserAuth<UserORWScope>; pub type UserTokenAuth = UserAuth<UserTokenScope>; pub type UserOptRAuth = UserOptAuth<UserRScope>; +pub type RevenueAuth = UserAuth<RevenueScope>; +pub type WireRAuth = UserAuth<WireRScope>; +pub type WireRWAuth = UserAuth<WireRWScope>; +pub type UserAdminAuth = UserAuth<UserAdminScope>; pub trait RootAuthScope: Send { const SCOPE: TokenLogicalScope; diff --git a/crates/libeufin-bank/src/config.rs b/crates/libeufin-bank/src/config.rs @@ -232,7 +232,7 @@ impl BankCfg { /// Builds the taler://withdraw-URI. Such URI will serve the requests /// from wallets, when they need to manage the operation. - pub fn taler_withdraw_uri(&self, id: Uuid) -> String { + pub fn taler_withdraw_uri(&self, uuid: Uuid) -> Url { let base = &self.base_url; // Determine the correct schema/protocol @@ -249,10 +249,11 @@ impl BankCfg { // Get the path and ensure it handles trailing slashes properly let path = base.path(); - format!( + Url::parse(&format!( "{}://withdraw/{}{}{}taler-integration/{}", - protocol, host, port_suffix, path, id - ) + protocol, host, port_suffix, path, uuid + )) + .unwrap() } /// Builds the confirmation web UI URL. diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs @@ -29,7 +29,9 @@ use crate::api::withdrawal::WithdrawalStatus; pub mod account; pub mod cashout; pub mod conversion; +pub mod exchange; pub mod gc; +pub mod prepared; pub mod tan; pub mod token; pub mod tx; diff --git a/crates/libeufin-bank/src/db/account.rs b/crates/libeufin-bank/src/db/account.rs @@ -23,10 +23,14 @@ use sqlx::{ use taler_api::{ db::{BindHelper as _, PgError, TypeHelper as _, page}, error::ApiResult, + serialized, }; -use taler_common::types::{ - amount::{Amount, Currency}, - payto::{BankID, IbanPayto}, +use taler_common::{ + api::wire::AccountInfo, + types::{ + amount::{Amount, Currency}, + payto::{BankID, IbanPayto, PaytoImpl}, + }, }; use crate::{ @@ -37,7 +41,7 @@ use crate::{ }, db::conversion::user_rate, mfa::Tans, - payto::{FullBankPayto, LibeufinId, sql_bank_payto, sql_opt_iban_payto}, + payto::{BankPayto, FullBankPayto, LibeufinId, sql_bank_payto, sql_opt_iban_payto}, pw::PwCrypto, }; @@ -218,7 +222,7 @@ pub async fn create( } } - CreationResult::Success(internal.bank(name, ctx)) + CreationResult::Success(internal.bank(ctx).into_inner().full(name)) }; tx.commit().await?; sqlx::Result::Ok(res) @@ -689,6 +693,16 @@ pub async fn bank_info( .await } +/** Check bank info of account [payto] */ +pub async fn check_info(db: &PgPool, payto: &BankPayto) -> sqlx::Result<Option<AccountInfo>> { + serialized!( + sqlx::query("SELECT FROM bank_accounts WHERE internal_payto=$1") + .bind(payto.canonical()) + .try_map(|_: PgRow| Ok(AccountInfo {})) + .fetch_optional(db) + ) +} + /** Get data of account [username] */ pub async fn by_username( db: &PgPool, diff --git a/crates/libeufin-bank/src/db/cashout.rs b/crates/libeufin-bank/src/db/cashout.rs @@ -27,8 +27,7 @@ use taler_api::{ serialized, }; use taler_common::{ - api_common::ShortHashCode, - api_params::Page, + api::{ShortHashCode, params::Page}, types::amount::{Amount, Currency}, }; diff --git a/crates/libeufin-bank/src/db/exchange.rs b/crates/libeufin-bank/src/db/exchange.rs @@ -0,0 +1,401 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* 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. + +* 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/> +*/ + +//! Data access logic for exchange specific logic + +use jiff::Timestamp; +use sqlx::{PgPool, QueryBuilder, Row, postgres::PgRow}; +use taler_api::{ + db::{BindHelper, TypeHelper, history, page}, + notification::NotificationChannel, + serialized, + subject::{IncomingSubject, fmt_out_subject}, +}; +use taler_common::{ + api::{ + params::{History, Page}, + wire::{ + IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferRequest, + TransferState, TransferStatus, + }, + }, + db::IncomingType, + types::amount::{Amount, Currency}, +}; + +use crate::payto::{BankPayto, PaytoCtx, sql_bank_payto, sql_bank_simple_payto}; + +/** Result of taler transfer transaction creation */ +pub enum TransferResult { + /** Transaction [id] and wire transfer [timestamp] */ + Success { + id: u64, + timestamp: Timestamp, + }, + NotAnExchange, + UnknownExchange, + BothPartyAreExchange, + BalanceInsufficient, + ReserveUidReuse, + WtidReuse, + AdminCreditor, +} + +/** Perform a Taler transfer */ +pub async fn transfer( + db: &PgPool, + username: &str, + req: &TransferRequest, + payto: &BankPayto, + timestamp: &Timestamp, + conversion: bool, +) -> sqlx::Result<TransferResult> { + let subject = fmt_out_subject(&req.wtid, &req.exchange_base_url, req.metadata.as_deref()); + serialized!( + sqlx::query( + " + SELECT + out_debtor_not_found, + out_debtor_not_exchange, + out_both_exchanges, + out_request_uid_reuse, + out_wtid_reuse, + out_exchange_balance_insufficient, + out_creditor_admin, + out_tx_row_id, + out_timestamp + FROM taler_transfer($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + ", + ) + .bind(&req.request_uid) + .bind(&req.wtid) + .bind(&subject) + .bind(req.amount) + .bind(req.exchange_base_url.as_str()) + .bind(&req.metadata) + .bind(payto.canonical()) + .bind(username) + .bind_timestamp(timestamp) + .bind(conversion) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_debtor_not_found")? { + TransferResult::UnknownExchange + } else if r.try_get_flag("out_debtor_not_exchange")? { + TransferResult::NotAnExchange + } else if r.try_get_flag("out_both_exchanges")? { + TransferResult::BothPartyAreExchange + } else if r.try_get_flag("out_exchange_balance_insufficient")? { + TransferResult::BalanceInsufficient + } else if r.try_get_flag("out_request_uid_reuse")? { + TransferResult::ReserveUidReuse + } else if r.try_get_flag("out_wtid_reuse")? { + TransferResult::WtidReuse + } else if r.try_get_flag("out_creditor_admin")? { + TransferResult::AdminCreditor + } else { + TransferResult::Success { + id: r.try_get_u64("out_tx_row_id")?, + timestamp: r.try_get_timestamp("out_timestamp")?, + } + }) + }) + .fetch_one(db) + ) +} + +/** Get status of transfer [txId] of account [exchangeId] */ +pub async fn transfer_by_id( + db: &PgPool, + ctx: &PaytoCtx, + currency: &Currency, + exchange_id: u64, + tx_id: u64, +) -> sqlx::Result<Option<TransferStatus>> { + serialized!( + sqlx::query( + " + SELECT + wtid, + exchange_base_url, + metadata, + transfer_date, + amount, + creditor_payto, + status, + status_msg + FROM transfer_operations + WHERE transfer_operation_id=$1 AND exchange_id=$2 + ", + ) + .bind(tx_id as i64) + .bind(exchange_id as i64) + .try_map(|r: PgRow| { + Ok(TransferStatus { + status: r.try_get("status")?, + status_msg: r.try_get("status_msg")?, + amount: r.try_get_amount("amount", currency)?, + origin_exchange_url: r.try_get("exchange_base_url")?, + metadata: r.try_get("metadata")?, + wtid: r.try_get("wtid")?, + credit_account: sql_bank_simple_payto(&r, ctx, "creditor_payto")?.as_uri(), + timestamp: r.try_get_timestamp("transfer_date")?.into(), + }) + }) + .fetch_optional(db) + ) +} + +/** Get a page of transfers status of account [exchangeId] */ +pub async fn page_transfer( + db: &PgPool, + ctx: &PaytoCtx, + currency: &Currency, + params: &Page, + exchange_id: u64, + status: Option<TransferState>, +) -> sqlx::Result<Vec<TransferListStatus>> { + page( + db, + params, + "transfer_operation_id", + || { + let mut query = QueryBuilder::new( + " + SELECT + transfer_operation_id + ,transfer_date + ,amount + ,creditor_payto + ,status + FROM transfer_operations + WHERE exchange_id=", + ); + query.push_bind(exchange_id as i64).push(" AND "); + if let Some(status) = status { + query.push("status=").push_bind(status).push(" AND "); + } + query + }, + |r| { + Ok(TransferListStatus { + row_id: r.try_get_u64("transfer_operation_id")?, + status: r.try_get("status")?, + amount: r.try_get_amount("amount", currency)?, + credit_account: sql_bank_simple_payto(&r, ctx, "creditor_payto")?.as_uri(), + timestamp: r.try_get_timestamp("transfer_date")?.into(), + }) + }, + ) + .await +} + +/** Result of taler add incoming transaction creation */ +pub enum AddIncomingResult { + /** Transaction [id] and wire transfer [timestamp] */ + Success { + id: u64, + timestamp: Timestamp, + pending: bool, + }, + NotAnExchange, + UnknownExchange, + UnknownDebtor, + BothPartyAreExchange, + ReservePubReuse, + UnknownMapping, + MappingReuse, + BalanceInsufficient, +} + +/** Add a new taler incoming transaction */ +pub async fn add_incoming( + db: &PgPool, + amount: &Amount, + debtor: &BankPayto, + subject: &str, + username: &str, + timestamp: &Timestamp, + metadata: &IncomingSubject, +) -> sqlx::Result<AddIncomingResult> { + serialized!( + sqlx::query( + " + SELECT + out_creditor_not_found + ,out_creditor_not_exchange + ,out_debtor_not_found + ,out_both_exchanges + ,out_reserve_pub_reuse + ,out_mapping_reuse + ,out_unknown_mapping + ,out_debitor_balance_insufficient + ,out_tx_row_id + ,out_pending + FROM taler_add_incoming($1,$2,$3,$4,$5,$6,$7::taler_incoming_type) + ", + ) + .bind(metadata.key()) + .bind(subject) + .bind(amount) + .bind(debtor.canonical()) + .bind(username) + .bind_timestamp(timestamp) + .bind(metadata.ty()) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_creditor_not_found")? { + AddIncomingResult::UnknownExchange + } else if r.try_get_flag("out_creditor_not_exchange")? { + AddIncomingResult::NotAnExchange + } else if r.try_get_flag("out_debtor_not_found")? { + AddIncomingResult::UnknownDebtor + } else if r.try_get_flag("out_both_exchanges")? { + AddIncomingResult::BothPartyAreExchange + } else if r.try_get_flag("out_debitor_balance_insufficient")? { + AddIncomingResult::BalanceInsufficient + } else if r.try_get_flag("out_reserve_pub_reuse")? { + AddIncomingResult::ReservePubReuse + } else if r.try_get_flag("out_mapping_reuse")? { + AddIncomingResult::MappingReuse + } else if r.try_get_flag("out_unknown_mapping")? { + AddIncomingResult::UnknownMapping + } else { + AddIncomingResult::Success { + id: r.try_get_u64("out_tx_row_id")?, + timestamp: *timestamp, + pending: r.try_get_flag("out_pending")?, + } + }) + }) + .fetch_one(db) + ) +} + +/** Query [exchangeId] history of taler incoming transactions */ +pub async fn incoming_history( + db: &PgPool, + ctx: &PaytoCtx, + channel: &NotificationChannel<u64, i64>, + currency: &Currency, + params: &History, + exchange_id: u64, +) -> sqlx::Result<Vec<IncomingBankTransaction>> { + history( + db, + "bank_transaction_id", + params, + || channel.subscribe(exchange_id), + || { + let mut query = QueryBuilder::new( + "SELECT + bank_transaction_id + ,transaction_date + ,amount + ,debtor_payto + ,debtor_name + ,type::text + ,metadata + ,authorization_pub + ,authorization_sig + FROM taler_exchange_incoming AS tfr + JOIN bank_account_transactions AS txs + ON bank_transaction=txs.bank_transaction_id + WHERE bank_account_id=", + ); + query.push_bind(exchange_id as i64).push(" AND "); + query + }, + |r| { + Ok(match r.try_get("type")? { + IncomingType::reserve => IncomingBankTransaction::Reserve { + row_id: r.try_get_u64("bank_transaction_id")?, + date: r.try_get_timestamp("transaction_date")?.into(), + amount: r.try_get_amount("amount", currency)?, + credit_fee: None, + debit_account: sql_bank_payto(&r, ctx, "debtor_payto", "debtor_name")?.as_uri(), + reserve_pub: r.try_get("metadata")?, + authorization_pub: r.try_get("authorization_pub")?, + authorization_sig: r.try_get("authorization_sig")?, + }, + IncomingType::kyc => IncomingBankTransaction::Kyc { + row_id: r.try_get_u64("bank_transaction_id")?, + date: r.try_get_timestamp("transaction_date")?.into(), + amount: r.try_get_amount("amount", currency)?, + credit_fee: None, + debit_account: sql_bank_payto(&r, ctx, "debtor_payto", "debtor_name")?.as_uri(), + account_pub: r.try_get("metadata")?, + authorization_pub: r.try_get("authorization_pub")?, + authorization_sig: r.try_get("authorization_sig")?, + }, + IncomingType::map => unreachable!(), + }) + }, + ) + .await +} + +/** Query [exchangeId] history of taler incoming transactions */ +pub async fn outgoing_history( + db: &PgPool, + ctx: &PaytoCtx, + channel: &NotificationChannel<u64, i64>, + currency: &Currency, + params: &History, + exchange_id: u64, +) -> sqlx::Result<Vec<OutgoingBankTransaction>> { + history( + db, + "bank_transaction_id", + params, + || channel.subscribe(exchange_id), + || { + let mut query = QueryBuilder::new( + "SELECT + bank_transaction_id + ,transaction_date + ,txs.amount + ,txs.creditor_payto + ,txs.creditor_name + ,wtid + ,exchange_base_url + ,transfer_operations.metadata + FROM taler_exchange_outgoing AS tfr + JOIN transfer_operations USING (exchange_outgoing_id) + JOIN bank_account_transactions AS txs + ON bank_transaction=txs.bank_transaction_id + WHERE bank_account_id=", + ); + query.push_bind(exchange_id as i64).push(" AND "); + query + }, + |r| { + Ok(OutgoingBankTransaction { + row_id: r.try_get_u64("bank_transaction_id")?, + date: r.try_get_timestamp("transaction_date")?.into(), + amount: r.try_get_amount("amount", currency)?, + debit_fee: None, + credit_account: sql_bank_payto(&r, ctx, "creditor_payto", "creditor_name")? + .as_uri(), + wtid: r.try_get("wtid")?, + metadata: r.try_get("metadata")?, + exchange_base_url: r.try_get_parse("exchange_base_url")?, + }) + }, + ) + .await +} diff --git a/crates/libeufin-bank/src/db/prepared.rs b/crates/libeufin-bank/src/db/prepared.rs @@ -0,0 +1,99 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* 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. + +* 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/> +*/ + +//! Data access logic for prepared transfer specific logic + +use jiff::Timestamp; +use sqlx::{PgPool, Row, postgres::PgRow}; +use taler_api::{ + db::{BindHelper, TypeHelper}, + serialized, +}; +use taler_common::{ + api::{EddsaPublicKey, EddsaSignature, prepared::TransferType}, + db::IncomingType, + types::amount::Amount, +}; +use uuid::Uuid; + +/** Result of prepared transfer registration */ +pub enum RegistrationResult { + Success(Option<Uuid>), + UnknownAccount, + NotExchange, + ReservePubReuse, +} + +/** Register a prepared transfer */ +pub async fn register( + db: &PgPool, + username: &str, + ty: TransferType, + account_pub: &EddsaPublicKey, + auth_pub: &EddsaPublicKey, + auth_sig: &EddsaSignature, + recurrent: bool, + amount: &Amount, + timestamp: &Timestamp, +) -> sqlx::Result<RegistrationResult> { + let ty: IncomingType = ty.into(); + serialized!( + sqlx::query( + " + SELECT out_unknown_account, out_not_exchange, out_reserve_pub_reuse, out_withdrawal_uuid + FROM register_prepared_transfers($1,$2::taler_incoming_type,$3,$4,$5,$6,$7,$8,$9) + " + ) + .bind(username) + .bind(ty) + .bind(account_pub) + .bind(auth_pub) + .bind(auth_sig) + .bind(recurrent) + .bind(amount) + .bind_timestamp(timestamp) + .bind(format!("Taler prepared MAP:{auth_pub}")) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_unknown_account")? { + RegistrationResult::UnknownAccount + } else if r.try_get_flag("out_not_exchange")? { + RegistrationResult::NotExchange + } else if r.try_get_flag("out_reserve_pub_reuse")? { + RegistrationResult::ReservePubReuse + } else { + RegistrationResult::Success(r.try_get("out_withdrawal_uuid")?) + }) + }) + .fetch_one(db) + ) +} + +/** Register a prepared transfer */ +pub async fn unregister( + db: &PgPool, + auth_pub: &EddsaPublicKey, + timestamp: &Timestamp, +) -> sqlx::Result<bool> { + serialized!( + sqlx::query_scalar("SELECT out_found FROM delete_prepared_transfers($1,$2)") + .bind(auth_pub) + .bind_timestamp(timestamp) + .fetch_one(db) + ) +} diff --git a/crates/libeufin-bank/src/db/token.rs b/crates/libeufin-bank/src/db/token.rs @@ -26,7 +26,7 @@ use taler_api::{ db::{BindHelper, TypeHelper}, serialized, }; -use taler_common::{api_params::Page, types::timestamp::TalerTimestamp}; +use taler_common::{api::params::Page, types::timestamp::TalerTimestamp}; use crate::{ api::token::TokenInfo, @@ -224,10 +224,10 @@ pub async fn page( description, last_access, bearer_token_id - FROM bearer_tokens - WHERE + FROM bearer_tokens + WHERE expiration_time > $1 AND - bank_customer=(SELECT customer_id FROM customers WHERE deleted_at IS NULL AND username = $2) + bank_customer=(SELECT customer_id FROM customers WHERE deleted_at IS NULL AND username = $2) AND ", args diff --git a/crates/libeufin-bank/src/db/tx.rs b/crates/libeufin-bank/src/db/tx.rs @@ -28,8 +28,7 @@ use taler_api::{ subject::{IncomingSubject, parse_incoming_unstructured}, }; use taler_common::{ - api_common::ShortHashCode, - api_params::History, + api::{ShortHashCode, params::History, revenue::RevenueIncomingBankTransaction}, types::amount::{Amount, Currency}, }; @@ -226,3 +225,46 @@ pub async fn pool_history( ) .await } + +/** Query [accountId] history of incoming transactions to its account */ +pub async fn revenue_history( + db: &PgPool, + ctx: &PaytoCtx, + currency: &Currency, + channel: &NotificationChannel<u64, i64>, + params: &History, + account_id: u64, +) -> sqlx::Result<Vec<RevenueIncomingBankTransaction>> { + history( + db, + "bank_transaction_id", + params, + || channel.subscribe(account_id), + || { + let mut query = QueryBuilder::new( + "SELECT + bank_transaction_id + ,transaction_date + ,amount + ,debtor_payto + ,debtor_name + ,subject + FROM bank_account_transactions + WHERE direction='credit' AND bank_account_id=", + ); + query.push_bind(account_id as i64).push(" AND "); + query + }, + |r| { + Ok(RevenueIncomingBankTransaction { + debit_account: sql_bank_payto(&r, ctx, "debtor_payto", "debtor_name")?.as_uri(), + subject: r.try_get("subject")?, + amount: r.try_get_amount("amount", currency)?, + date: r.try_get_timestamp("transaction_date")?.into(), + row_id: r.try_get_u64("bank_transaction_id")?, + credit_fee: None, + }) + }, + ) + .await +} diff --git a/crates/libeufin-bank/src/db/withdrawal.rs b/crates/libeufin-bank/src/db/withdrawal.rs @@ -26,7 +26,7 @@ use taler_api::{ serialized, }; use taler_common::{ - api_common::EddsaPublicKey, + api::EddsaPublicKey, types::amount::{Amount, Currency}, }; use uuid::Uuid; diff --git a/crates/libeufin-bank/src/payto.rs b/crates/libeufin-bank/src/payto.rs @@ -92,12 +92,12 @@ impl LibeufinId { } } - pub fn bank(mut self, name: &str, ctx: &PaytoCtx) -> FullBankPayto { + pub fn bank(mut self, ctx: &PaytoCtx) -> BankPayto { match &mut self { LibeufinId::IBAN(bank_id) => bank_id.bic = ctx.bic, LibeufinId::XTalerBank(xtaler_bank) => xtaler_bank.hostname = ctx.hostname.clone(), }; - FullPayto::new(self, name) + Payto::new(self) } pub fn expect_iban(&self) -> ApiResult<&BankID> { @@ -164,6 +164,15 @@ pub struct PaytoCtx { pub hostname: CompactString, } +pub fn sql_bank_simple_payto( + r: &PgRow, + ctx: &PaytoCtx, + payto_idx: &str, +) -> sqlx::Result<BankPayto> { + let payto: BankPayto = r.try_get_parse(payto_idx)?; + Ok(payto.into_inner().bank(ctx)) +} + pub fn sql_bank_payto( r: &PgRow, ctx: &PaytoCtx, @@ -172,7 +181,8 @@ pub fn sql_bank_payto( ) -> sqlx::Result<FullBankPayto> { let payto: BankPayto = r.try_get_parse(payto_idx)?; let name = r.try_get(name_idx)?; - Ok(payto.into_inner().bank(name, ctx)) + let payto = payto.into_inner().bank(ctx); + Ok(payto.into_inner().full(name)) } pub fn sql_opt_bank_payto( @@ -184,7 +194,8 @@ pub fn sql_opt_bank_payto( let payto: Option<BankPayto> = r.try_get_opt_parse(payto_idx)?; if let Some(payto) = payto { let name = r.try_get(name_idx)?; - Ok(Some(payto.into_inner().bank(name, ctx))) + let payto = payto.into_inner().bank(ctx); + Ok(Some(payto.into_inner().full(name))) } else { Ok(None) } diff --git a/crates/libeufin-nexus/src/api.rs b/crates/libeufin-nexus/src/api.rs @@ -24,23 +24,29 @@ use libeufin_ebics::{ }; use sqlx::PgPool; use taler_api::{ - api::{TalerApi, revenue::Revenue, transfer::PreparedTransfer, wire::WireGateway}, - error::{ApiResult, failure, failure_code}, + api::{ + TalerApi, + prepared::{PreparedTransfer, simple_subject}, + revenue::Revenue, + wire::WireGateway, + }, + error::{ApiResult, failure_code}, subject::{IncomingSubject, fmt_in_subject, subject_fmt_qr_bill}, }; use taler_common::{ - api_common::{SafeU64, safe_u64}, - api_params::{History, Page}, - api_revenue::RevenueIncomingHistory, - api_transfer::{ - RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, Unregistration, - }, - api_wire::{ - AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, - IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, - TransferState, TransferStatus, + api::{ + params::{History, Page}, + prepared::{ + RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, + Unregistration, + }, + revenue::RevenueIncomingHistory, + wire::{ + AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, + IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, + TransferState, TransferStatus, + }, }, - db::IncomingType, error_code::ErrorCode, types::{ amount::{Amount, Currency}, @@ -93,8 +99,8 @@ impl NexusApi { } impl TalerApi for NexusApi { - fn currency(&self) -> &str { - self.currency.as_ref() + fn currency(&self) -> Currency { + self.currency } fn implementation(&self) -> &'static str { @@ -132,7 +138,7 @@ async fn add_incoming( .await? { IncomingRegistrationResult::Success(in_result) => Ok(AddIncomingResponse { - row_id: safe_u64(in_result.id), + row_id: in_result.id, timestamp: now.into(), }), IncomingRegistrationResult::ReservePubReuse => { @@ -154,7 +160,7 @@ impl WireGateway for NexusApi { match result { TransferResult::Success { id, timestamp } => Ok(TransferResponse { timestamp: timestamp.into(), - row_id: SafeU64::try_from(id).unwrap(), + row_id: id, }), TransferResult::RequestUidReuse => { Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) @@ -273,14 +279,7 @@ impl PreparedTransfer for NexusApi { credit_amount: req.credit_amount, qr_reference_number: reference_number, }, - TransferSubject::Simple { - credit_amount: req.credit_amount, - subject: if req.authorization_pub == req.account_pub && !req.recurrent { - fmt_in_subject(req.r#type.into(), &req.account_pub) - } else { - fmt_in_subject(IncomingType::map, &req.authorization_pub) - }, - }, + simple_subject(req), ], expiration: TalerTimestamp::Never, }), @@ -293,15 +292,8 @@ impl PreparedTransfer for NexusApi { } } - async fn unregistration(&self, req: Unregistration) -> ApiResult<()> { - if !transfer_unregister(&self.pool, &req.authorization_pub, &Timestamp::now()).await? { - Err(failure( - ErrorCode::BANK_TRANSACTION_NOT_FOUND, - format!("Prepared transfer '{}' not found", req.authorization_pub), - )) - } else { - Ok(()) - } + async fn unregistration(&self, req: Unregistration) -> ApiResult<bool> { + Ok(transfer_unregister(&self.pool, &req.authorization_pub, &Timestamp::now()).await?) } } @@ -311,10 +303,10 @@ pub mod test { use sqlx::PgPool; use taler_api::{api::TalerRouter as _, auth::AuthMethod, subject::OutgoingSubject}; - use taler_common::{ - api_revenue::RevenueConfig, - api_transfer::PreparedTransferConfig, - api_wire::{OutgoingHistory, TransferState, WireConfig}, + use taler_common::api::{ + prepared::PreparedTransferConfig, + revenue::RevenueConfig, + wire::{OutgoingHistory, TransferState, WireConfig}, }; use taler_test_utils::{ Router, @@ -407,7 +399,7 @@ pub mod test { #[tokio::test] async fn revenue() { let (server, _) = api_setup().await; - revenue_routine(&server, &ACCOUNT, true).await; + revenue_routine(&server, &ACCOUNT, true, tasks!(), tasks!()).await; } #[tokio::test] diff --git a/crates/libeufin-nexus/src/bench.rs b/crates/libeufin-nexus/src/bench.rs @@ -27,7 +27,7 @@ mod test { use serde_json::json; use taler_api::{crypto::eddsa_sign, subject::subject_fmt_qr_bill}; use taler_common::{ - api_common::{EddsaPublicKey, HashCode, ShortHashCode}, + api::{EddsaPublicKey, HashCode, ShortHashCode}, bench::{Bench, h32, h64}, encoding::hex, error_code::ErrorCode, diff --git a/crates/libeufin-nexus/src/db/exchange.rs b/crates/libeufin-nexus/src/db/exchange.rs @@ -22,11 +22,13 @@ use taler_api::{ subject::fmt_out_subject, }; use taler_common::{ - api_params::{History, Page}, - api_revenue::RevenueIncomingBankTransaction, - api_wire::{ - IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferRequest, - TransferState, TransferStatus, + api::{ + params::{History, Page}, + revenue::RevenueIncomingBankTransaction, + wire::{ + IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferRequest, + TransferState, TransferStatus, + }, }, db::IncomingType, types::amount::Currency, @@ -66,7 +68,7 @@ pub async fn outgoing_history( }, |r: PgRow| { Ok(OutgoingBankTransaction { - row_id: r.try_get_safeu64("outgoing_transaction_id")?, + row_id: r.try_get_u64("outgoing_transaction_id")?, amount: r.try_get_amount("amount", currency)?, debit_fee: r .try_get_opt_amount("debit_fee", currency)? @@ -118,7 +120,7 @@ pub async fn incoming_history( .filter(|it| it.is_zero()); Ok(match r.try_get("type")? { IncomingType::reserve => IncomingBankTransaction::Reserve { - row_id: r.try_get_safeu64("incoming_transaction_id")?, + row_id: r.try_get_u64("incoming_transaction_id")?, amount: r.try_get_amount("amount", currency)?, credit_fee, debit_account: r.try_get_payto("debit_payto")?, @@ -128,7 +130,7 @@ pub async fn incoming_history( authorization_sig: r.try_get("authorization_sig")?, }, IncomingType::kyc => IncomingBankTransaction::Kyc { - row_id: r.try_get_safeu64("incoming_transaction_id")?, + row_id: r.try_get_u64("incoming_transaction_id")?, amount: r.try_get_amount("amount", currency)?, credit_fee, debit_account: r.try_get_payto("debit_payto")?, @@ -172,7 +174,7 @@ pub async fn revenue_history( }, |r: PgRow| { Ok(RevenueIncomingBankTransaction { - row_id: r.try_get_safeu64("incoming_transaction_id")?, + row_id: r.try_get_u64("incoming_transaction_id")?, amount: r.try_get_amount("amount", currency)?, credit_fee: r .try_get_opt_amount("credit_fee", currency)? @@ -313,7 +315,7 @@ pub async fn transfer_page( }, |r: PgRow| { Ok(TransferListStatus { - row_id: r.try_get_safeu64("initiated_outgoing_transaction_id")?, + row_id: r.try_get_u64("initiated_outgoing_transaction_id")?, status: r.try_get::<SubmissionState, _>("status")?.to_transfer_status(), amount: r.try_get_amount("amount", currency)?, credit_account: r.try_get_payto("credit_payto")?, diff --git a/crates/libeufin-nexus/src/db/initiated.rs b/crates/libeufin-nexus/src/db/initiated.rs @@ -480,7 +480,7 @@ mod test { async fn ingest(db: &PgPool, cfg: &NexusIngestCfg, execution_time: Timestamp) { for tx in [ Tx::In( - gen_in_pay(format!("test at {execution_time}")) + gen_in_pay(format_args!("test at {execution_time}")) .with_execution_time(execution_time), ), Tx::Out( diff --git a/crates/libeufin-nexus/src/db/list.rs b/crates/libeufin-nexus/src/db/list.rs @@ -20,7 +20,7 @@ use libeufin_ebics::iso20022::model::{InId, OutId}; use sqlx::{PgPool, Row as _, postgres::PgRow}; use taler_api::db::TypeHelper as _; use taler_common::{ - api_common::{EddsaPublicKey, ShortHashCode}, + api::{EddsaPublicKey, ShortHashCode}, types::amount::{Amount, Currency, Decimal}, }; diff --git a/crates/libeufin-nexus/src/db/payment.rs b/crates/libeufin-nexus/src/db/payment.rs @@ -309,7 +309,7 @@ mod test { use sqlx::{PgPool, postgres::PgRow}; use taler_api::{db::TypeHelper as _, subject::subject_fmt_qr_bill}; use taler_common::{ - api_common::{EddsaPublicKey, EddsaSignature, ShortHashCode}, + api::{EddsaPublicKey, EddsaSignature, ShortHashCode}, db::IncomingType, types::amount::amount, }; @@ -608,7 +608,7 @@ mod test { let cfg = NexusIngestCfg::simple(AccountType::exchange, &CURR); // Register - let incoming = gen_in_pay("test".to_owned()); + let incoming = gen_in_pay("test"); register_incoming(&db, &cfg, &incoming).await.unwrap(); check_in_state(&db, &[Bounced]).await; @@ -617,19 +617,19 @@ mod test { check_in_state(&db, &[Bounced]).await; // Many - register_incoming(&db, &cfg, &gen_in_pay("another subject".to_owned())) + register_incoming(&db, &cfg, &gen_in_pay("another subject")) .await .unwrap(); check_in_state(&db, &[Bounced, Bounced]).await; // Admin balance adjust is ignored - register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST".to_owned())) + register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST")) .await .unwrap(); check_in_state(&db, &[Bounced, Bounced, Simple]).await; - let original = gen_in_pay("test 2".to_owned()); + let original = gen_in_pay("test 2"); let incomplete = InTx { subject: None, debtor: None, @@ -656,7 +656,7 @@ mod test { let subject = format!("test with {key} reserve pub"); // Register - let incoming = gen_in_pay(subject.clone()); + let incoming = gen_in_pay(&subject); register_incoming(&db, &cfg, &incoming).await.unwrap(); check_in_state(&db, &[Reserve(key.clone())]).await; @@ -665,22 +665,22 @@ mod test { check_in_state(&db, &[Reserve(key.clone())]).await; // Key reuse is bounced - register_incoming(&db, &cfg, &gen_in_pay(subject.clone())) + register_incoming(&db, &cfg, &gen_in_pay(&subject)) .await .unwrap(); - register_incoming(&db, &cfg, &gen_in_pay(format!("another {subject}"))) + register_incoming(&db, &cfg, &gen_in_pay(format_args!("another {subject}"))) .await .unwrap(); check_in_state(&db, &[Reserve(key.clone()), Bounced, Bounced]).await; // Admin balance adjust is ignored - register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST".to_owned())) + register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST")) .await .unwrap(); check_in_state(&db, &[Reserve(key.clone()), Bounced, Bounced, Simple]).await; let new = EddsaPublicKey::rand(); - let original = gen_in_pay(format!("test 2 with {new} reserve pub")); + let original = gen_in_pay(format_args!("test 2 with {new} reserve pub")); let incomplete = InTx { subject: None, debtor: None, @@ -737,7 +737,7 @@ mod test { ); // Register - let incoming = gen_in_pay(subject.clone()); + let incoming = gen_in_pay(&subject); register_incoming(&db, &cfg, &incoming).await.unwrap(); check_in_state(&db, &[Reserve(first.clone())]).await; @@ -746,12 +746,12 @@ mod test { check_in_state(&db, &[Reserve(first.clone())]).await; // Admin balance adjust is ignored - register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST".to_owned())) + register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST")) .await .unwrap(); check_in_state(&db, &[Reserve(first.clone()), Simple]).await; - let original = gen_in_pay(format!("test 2 for {subject}")); + let original = gen_in_pay(format_args!("test 2 for {subject}")); let incomplete = InTx { subject: None, debtor: None, @@ -787,7 +787,7 @@ mod test { // Key reuse is pending for _ in 0..3 { - register_incoming(&db, &cfg, &gen_in_pay(subject.clone())) + register_incoming(&db, &cfg, &gen_in_pay(&subject)) .await .unwrap(); } @@ -870,7 +870,7 @@ mod test { check_in_state(&db, &[Reserve(first.clone())]).await; // Admin balance adjust is ignored - register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST".to_owned())) + register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST")) .await .unwrap(); check_in_state(&db, &[Reserve(first.clone()), Simple]).await; @@ -911,7 +911,7 @@ mod test { // Key reuse is pending for _ in 0..3 { - register_incoming(&db, &cfg, &gen_in_pay(reference_number.clone())) + register_incoming(&db, &cfg, &gen_in_pay(&reference_number)) .await .unwrap(); } @@ -999,7 +999,7 @@ mod test { .iter() .enumerate() { - let payment = gen_in_pay("subject".to_owned()); + let payment = gen_in_pay("subject"); // Register minimal let partial = InTx { @@ -1046,7 +1046,7 @@ mod test { .enumerate() { let key = EddsaPublicKey::rand(); - let payment = gen_in_pay(format!("test with {key} reserve pub")); + let payment = gen_in_pay(format_args!("test with {key} reserve pub")); // Register minimal let partial = InTx { @@ -1091,7 +1091,7 @@ mod test { // Check we do not bounce already registered talerable transaction let key = EddsaPublicKey::rand(); - let payment = gen_in_pay(format!("test with {key} reserve pub")); + let payment = gen_in_pay(format_args!("test with {key} reserve pub")); register_incoming(&db, &cfg, &payment).await.unwrap(); assert_eq!( register_in_malformed( @@ -1117,7 +1117,7 @@ mod test { // Check we do not register as talerable bounced transaction let new_key = EddsaPublicKey::rand(); - let payment = gen_in_pay(format!("bounced {new_key}")); + let payment = gen_in_pay(format_args!("bounced {new_key}")); let incomplete = InTx { subject: None, ..payment.clone() diff --git a/crates/libeufin-nexus/src/db/transfer.rs b/crates/libeufin-nexus/src/db/transfer.rs @@ -15,10 +15,13 @@ */ use jiff::Timestamp; -use sqlx::{PgPool, Row as _, postgres::PgRow}; -use taler_api::db::{BindHelper as _, TypeHelper as _}; +use sqlx::{PgPool, postgres::PgRow}; +use taler_api::{ + db::{BindHelper as _, TypeHelper as _}, + serialized, +}; use taler_common::{ - api_common::{EddsaPublicKey, EddsaSignature}, + api::{EddsaPublicKey, EddsaSignature}, db::IncomingType, }; @@ -39,8 +42,9 @@ pub async fn transfer_register( reference_number: &str, timestamp: &Timestamp, ) -> sqlx::Result<RegistrationResult> { - sqlx::query( - " + serialized!( + sqlx::query( + " SELECT out_subject_reuse, out_reserve_pub_reuse @@ -48,25 +52,25 @@ pub async fn transfer_register( $1::taler_incoming_type,$2,$3,$4,$5,$6,$7 ) ", - ) - .bind(ty) - .bind(account_pub) - .bind(auth_pub) - .bind(auth_sig) - .bind(recurrent) - .bind(reference_number) - .bind_timestamp(timestamp) - .try_map(|r: PgRow| { - Ok(if r.try_get_flag("out_subject_reuse")? { - RegistrationResult::SubjectReuse - } else if r.try_get_flag("out_reserve_pub_reuse")? { - RegistrationResult::ReservePubReuse - } else { - RegistrationResult::Success + ) + .bind(ty) + .bind(account_pub) + .bind(auth_pub) + .bind(auth_sig) + .bind(recurrent) + .bind(reference_number) + .bind_timestamp(timestamp) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_subject_reuse")? { + RegistrationResult::SubjectReuse + } else if r.try_get_flag("out_reserve_pub_reuse")? { + RegistrationResult::ReservePubReuse + } else { + RegistrationResult::Success + }) }) - }) - .fetch_one(db) - .await + .fetch_one(db) + ) } pub async fn transfer_unregister( @@ -74,10 +78,10 @@ pub async fn transfer_unregister( auth_pub: &EddsaPublicKey, timestamp: &Timestamp, ) -> sqlx::Result<bool> { - sqlx::query("SELECT out_found FROM delete_prepared_transfers($1,$2)") - .bind(auth_pub) - .bind_timestamp(timestamp) - .try_map(|r: PgRow| r.try_get(0)) - .fetch_one(db) - .await + serialized!( + sqlx::query_scalar("SELECT out_found FROM delete_prepared_transfers($1,$2)") + .bind(auth_pub) + .bind_timestamp(timestamp) + .fetch_one(db) + ) } diff --git a/crates/libeufin-nexus/src/model.rs b/crates/libeufin-nexus/src/model.rs @@ -20,7 +20,7 @@ use compact_str::CompactString; use jiff::Timestamp; use taler_common::{ - api_wire::TransferState, + api::wire::TransferState, types::{amount::Amount, payto::PaytoURI}, }; diff --git a/crates/libeufin-nexus/src/test.rs b/crates/libeufin-nexus/src/test.rs @@ -17,7 +17,7 @@ * <http://www.gnu.org/licenses/> */ -use std::{str::FromStr as _, sync::LazyLock}; +use std::{fmt::Display, str::FromStr as _, sync::LazyLock}; use compact_str::CompactString; use jiff::Timestamp; @@ -25,7 +25,7 @@ use libeufin_ebics::iso20022::model::{InId, InTx, OutId, OutTx}; use sqlx::PgPool; use taler_api::subject::{fmt_in_subject, fmt_out_subject, subject_fmt_qr_bill}; use taler_common::{ - api_common::{EddsaPublicKey, EddsaSignature}, + api::{EddsaPublicKey, EddsaSignature}, db::IncomingType, types::{ amount::{Amount, Currency}, @@ -88,7 +88,7 @@ pub fn gen_init_pay( } /** Generates an incoming payment, given its subject */ -pub fn gen_in_pay(subject: impl Into<String>) -> InTx { +pub fn gen_in_pay(subject: impl Display) -> InTx { InTx { id: InId::new(None, Some(rand_ebics_id()), None), amount: Amount::new(&CURR, 44, 0), @@ -98,7 +98,7 @@ pub fn gen_in_pay(subject: impl Into<String>) -> InTx { .unwrap() .as_uri(), ), - subject: Some(subject.into()), + subject: Some(subject.to_string()), execution_time: Timestamp::now(), } } diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -133,7 +133,7 @@ CREATE FUNCTION account_balance_is_sufficient( OUT out_balance_insufficient BOOLEAN, OUT out_bad_amount BOOLEAN ) -LANGUAGE plpgsql STABLE AS $$ +LANGUAGE plpgsql STABLE AS $$ DECLARE account_has_debt BOOLEAN; account_balance taler_amount; @@ -151,8 +151,8 @@ END IF; -- Add fees to the amount IF in_wire_transfer_fees IS NOT NULL AND in_wire_transfer_fees != (0, 0)::taler_amount THEN - SELECT sum.val, sum.frac - INTO amount_with_fee.val, amount_with_fee.frac + SELECT sum.val, sum.frac + INTO amount_with_fee.val, amount_with_fee.frac FROM amount_add(in_amount, in_wire_transfer_fees) as sum; ELSE amount_with_fee = in_amount; @@ -170,10 +170,10 @@ SELECT FROM bank_accounts WHERE bank_account_id=in_account_id; -- Check enough funds -IF account_has_debt THEN +IF account_has_debt THEN -- debt case: simply checking against the max debt allowed. - SELECT sum.val, sum.frac - INTO account_balance.val, account_balance.frac + SELECT sum.val, sum.frac + INTO account_balance.val, account_balance.frac FROM amount_add(account_balance, amount_with_fee) as sum; SELECT NOT ok INTO out_balance_insufficient @@ -215,13 +215,13 @@ WITH computed AS ( SELECT CASE has_debt WHEN false THEN amount_add(balance, max_debt) ELSE (SELECT diff FROM amount_left_minus_right(max_debt, balance)) - END AS amount + END AS amount FROM bank_accounts WHERE bank_account_id=in_account_id ) SELECT (amount).val, (amount).frac INTO out_max_amount.val, out_max_amount.frac FROM computed; -IF in_max_amount.val < out_max_amount.val +IF in_max_amount.val < out_max_amount.val OR (in_max_amount.val = out_max_amount.val AND in_max_amount.frac < out_max_amount.frac) THEN out_max_amount = in_max_amount; END IF; @@ -323,7 +323,7 @@ IF has_fee THEN bank_account_id, has_debt, (balance).val, (balance).frac, internal_payto, customers.name - INTO + INTO admin_account_id, admin_has_debt, admin_balance.val, admin_balance.frac, admin_payto, admin_name @@ -370,8 +370,8 @@ END IF; -- Add fees to the amount IF has_fee AND admin_account_id != in_debtor_account_id THEN - SELECT sum.val, sum.frac - INTO amount_with_fee.val, amount_with_fee.frac + SELECT sum.val, sum.frac + INTO amount_with_fee.val, amount_with_fee.frac FROM amount_add(in_amount, in_wire_transfer_fees) as sum; ELSE has_fee=false; @@ -380,10 +380,10 @@ END IF; -- DEBTOR SIDE -- check debtor has enough funds. -IF debtor_has_debt THEN +IF debtor_has_debt THEN -- debt case: simply checking against the max debt allowed. - SELECT sum.val, sum.frac - INTO debtor_balance.val, debtor_balance.frac + SELECT sum.val, sum.frac + INTO debtor_balance.val, debtor_balance.frac FROM amount_add(debtor_balance, amount_with_fee) as sum; SELECT NOT ok INTO out_balance_insufficient @@ -427,8 +427,8 @@ END IF; -- from debit to a credit situation, and adjust the balance -- accordingly. IF NOT creditor_has_debt THEN -- easy case. - SELECT sum.val, sum.frac - INTO creditor_balance.val, creditor_balance.frac + SELECT sum.val, sum.frac + INTO creditor_balance.val, creditor_balance.frac FROM amount_add(creditor_balance, in_amount) as sum; ELSE -- creditor had debit but MIGHT switch to credit. SELECT @@ -458,8 +458,8 @@ END IF; -- accordingly. IF has_fee THEN IF NOT admin_has_debt THEN -- easy case. - SELECT sum.val, sum.frac - INTO admin_balance.val, admin_balance.frac + SELECT sum.val, sum.frac + INTO admin_balance.val, admin_balance.frac FROM amount_add(admin_balance, in_wire_transfer_fees) as sum; ELSE -- creditor had debit but MIGHT switch to credit. SELECT (diff).val, (diff).frac, NOT ok @@ -606,15 +606,15 @@ DECLARE my_customer_id INT8; BEGIN -- check if account exists, has zero balance and if 2FA is required -SELECT +SELECT customer_id ,NOT in_is_tan AND cardinality(tan_channels) > 0 ,(balance).val != 0 OR (balance).frac != 0 - INTO + INTO my_customer_id ,out_tan_required ,out_balance_not_zero - FROM customers + FROM customers JOIN bank_accounts ON owning_customer_id = customer_id WHERE username = in_username AND deleted_at IS NULL; IF NOT FOUND OR out_balance_not_zero OR out_tan_required THEN @@ -753,12 +753,12 @@ IF out_reserve_pub_reuse THEN END IF; -- Perform bank wire transfer -SELECT +SELECT transfer.out_balance_insufficient, transfer.out_bad_amount, transfer.out_credit_row_id, transfer.out_debit_row_id - INTO + INTO out_balance_insufficient, out_bad_amount, out_credit_row_id, @@ -832,7 +832,7 @@ bounce_tx INT8; bounce_amount taler_amount; BEGIN -- Check for idempotence and conflict -SELECT (amount != in_amount +SELECT (amount != in_amount OR creditor_payto != in_credit_account_payto OR exchange_base_url != in_exchange_base_url OR metadata != in_metadata @@ -853,8 +853,8 @@ out_timestamp=in_timestamp; SELECT bank_account_id, NOT is_taler_exchange, conversion_rate_class_id INTO exchange_account_id, out_debtor_not_exchange, account_conversion_rate_class_id - FROM bank_accounts - JOIN customers + FROM bank_accounts + JOIN customers ON customer_id=owning_customer_id WHERE username = in_username AND deleted_at IS NULL; out_debtor_not_found=NOT FOUND; @@ -906,7 +906,7 @@ IF creditor_admin THEN out_creditor_admin=TRUE; RETURN; END IF; - + -- Find the bounced transaction SELECT (amount).val, (amount).frac, incoming_transaction_id INTO bounce_amount.val, bounce_amount.frac, bounce_tx @@ -1055,7 +1055,7 @@ CREATE FUNCTION taler_add_incoming( OUT out_debitor_balance_insufficient BOOLEAN, -- Success return OUT out_tx_row_id INT8, - OUT out_pending INT8 + OUT out_pending BOOLEAN ) LANGUAGE plpgsql AS $$ DECLARE @@ -1066,8 +1066,8 @@ BEGIN SELECT bank_account_id, NOT is_taler_exchange INTO exchange_bank_account_id, out_creditor_not_exchange - FROM bank_accounts - JOIN customers + FROM bank_accounts + JOIN customers ON customer_id=owning_customer_id WHERE username = in_username AND deleted_at IS NULL; IF NOT FOUND OR out_creditor_not_exchange THEN @@ -1163,7 +1163,7 @@ END IF; -- Find debit bank account ID and check it's a different account and if 2FA is required SELECT bank_account_id, is_taler_exchange, out_credit_bank_account_id=bank_account_id, NOT in_is_tan AND cardinality(tan_channels) > 0 INTO out_debit_bank_account_id, out_debtor_is_exchange, out_same_account, out_tan_required - FROM bank_accounts + FROM bank_accounts JOIN customers ON customer_id=owning_customer_id WHERE username = in_debit_account_username AND deleted_at IS NULL; IF NOT FOUND OR out_same_account THEN @@ -1173,7 +1173,7 @@ END IF; -- Check for idempotence and conflict IF in_request_uid IS NOT NULL THEN SELECT (amount != in_amount - OR subject != in_subject + OR subject != in_subject OR bank_account_id != out_debit_bank_account_id), bank_transaction INTO out_request_uid_reuse, out_debit_row_id FROM bank_transaction_operations @@ -1220,7 +1220,7 @@ IF out_creditor_is_exchange AND NOT out_debtor_is_exchange AND in_bounce_cause I ) as transfer; IF out_balance_insufficient OR out_bad_amount THEN RETURN; - END IF; + END IF; IF local_reserve_pub_reuse THEN in_bounce_cause = 'reserve public key reuse'; ELSIF local_mapping_reuse THEN @@ -1254,7 +1254,7 @@ IF out_credit_row_id IS NULL THEN ) as transfer; IF out_balance_insufficient OR out_bad_amount THEN RETURN; - END IF; + END IF; END IF; -- Bounce if necessary @@ -1264,7 +1264,7 @@ END IF; -- Store operation IF in_request_uid IS NOT NULL THEN - INSERT INTO bank_transaction_operations (request_uid, bank_transaction) + INSERT INTO bank_transaction_operations (request_uid, bank_transaction) VALUES (in_request_uid, out_debit_row_id); END IF; END $$; @@ -1286,7 +1286,7 @@ CREATE FUNCTION create_taler_withdrawal( OUT out_balance_insufficient BOOLEAN, OUT out_bad_amount BOOLEAN ) -LANGUAGE plpgsql AS $$ +LANGUAGE plpgsql AS $$ DECLARE account_id INT8; amount_with_fee taler_amount; @@ -1306,8 +1306,8 @@ IF in_account_username IS NOT NULL THEN -- Check enough funds IF in_amount IS NOT NULL OR in_suggested_amount IS NOT NULL THEN SELECT test.out_balance_insufficient, test.out_bad_amount FROM account_balance_is_sufficient( - account_id, - COALESCE(in_amount, in_suggested_amount), + account_id, + COALESCE(in_amount, in_suggested_amount), in_wire_transfer_fees, in_min_amount, in_max_amount @@ -1361,7 +1361,7 @@ CREATE FUNCTION select_taler_withdrawal( -- Success return OUT out_status TEXT ) -LANGUAGE plpgsql AS $$ +LANGUAGE plpgsql AS $$ DECLARE selected BOOLEAN; account_id INT8; @@ -1380,13 +1380,13 @@ END IF; -- Check for conflict and idempotence SELECT - selection_done, + selection_done, aborted, - CASE + CASE WHEN confirmation_done THEN 'confirmed' ELSE 'selected' END, - selection_done + selection_done AND (exchange_bank_account != exchange_account_id OR reserve_pub != in_reserve_pub OR amount != in_amount), amount != in_amount, wallet_bank_account @@ -1487,7 +1487,7 @@ BEGIN -- Load account info SELECT bank_account_id, NOT in_is_tan AND cardinality(tan_channels) > 0 INTO wallet_bank_account_local, out_tan_required -FROM bank_accounts +FROM bank_accounts JOIN customers ON owning_customer_id=customer_id WHERE username=in_username AND deleted_at IS NULL; @@ -1565,7 +1565,7 @@ CREATE FUNCTION cashin( OUT out_too_small BOOLEAN, OUT out_balance_insufficient BOOLEAN ) -LANGUAGE plpgsql AS $$ +LANGUAGE plpgsql AS $$ DECLARE converted_amount taler_amount; admin_account_id INT8; @@ -1579,7 +1579,7 @@ BEGIN SELECT bank_account_id, conversion_rate_class_id INTO exchange_account_id, exchange_conversion_rate_class_id FROM bank_accounts - JOIN customers + JOIN customers ON customer_id=owning_customer_id WHERE username = 'exchange'; IF NOT FOUND THEN @@ -1591,7 +1591,7 @@ END IF; SELECT bank_account_id INTO admin_account_id FROM bank_accounts - JOIN customers + JOIN customers ON customer_id=owning_customer_id WHERE username = 'admin'; @@ -1604,10 +1604,10 @@ IF out_too_small THEN END IF; -- Perform incoming transaction -SELECT +SELECT transfer.out_balance_insufficient, transfer.out_credit_row_id - INTO + INTO out_balance_insufficient, tx_row_id FROM make_incoming( @@ -1653,7 +1653,7 @@ CREATE FUNCTION cashout_create( -- Success return OUT out_cashout_id INT8 ) -LANGUAGE plpgsql AS $$ +LANGUAGE plpgsql AS $$ DECLARE account_id INT8; account_conversion_rate_class_id INT8; @@ -1663,12 +1663,12 @@ tx_id INT8; BEGIN -- Check account exists, has all info and if 2FA is required -SELECT +SELECT bank_account_id, is_taler_exchange, conversion_rate_class_id, -- Remove potential residual query string an add the receiver_name split_part(cashout_payto, '?', 1) || '?receiver-name=' || url_encode(name), NOT in_is_tan AND cardinality(tan_channels) > 0 - INTO + INTO account_id, out_account_is_exchange, account_conversion_rate_class_id, account_cashout_payto, out_tan_required FROM bank_accounts @@ -1686,7 +1686,7 @@ END IF; -- check conversion SELECT under_min, too_small OR in_amount_credit!=converted - INTO out_under_min, out_bad_conversion + INTO out_under_min, out_bad_conversion FROM conversion_to(in_amount_debit, 'cashout'::text, account_conversion_rate_class_id); IF out_bad_conversion THEN RETURN; @@ -1696,7 +1696,7 @@ END IF; SELECT bank_account_id INTO admin_account_id FROM bank_accounts - JOIN customers + JOIN customers ON customer_id=owning_customer_id WHERE username = 'admin'; @@ -1774,7 +1774,7 @@ CREATE FUNCTION tan_challenge_mark_sent ( IN in_retransmission_period INT8 ) RETURNS void LANGUAGE sql AS $$ - UPDATE tan_challenges SET + UPDATE tan_challenges SET retransmission_date = in_timestamp + in_retransmission_period WHERE uuid = in_uuid; $$; @@ -1782,9 +1782,9 @@ COMMENT ON FUNCTION tan_challenge_mark_sent IS 'Register a challenge as successf CREATE FUNCTION tan_challenge_try ( IN in_uuid UUID, - IN in_code TEXT, + IN in_code TEXT, IN in_timestamp INT8, - -- Error status + -- Error status OUT out_ok BOOLEAN, OUT out_no_op BOOLEAN, OUT out_no_retry BOOLEAN, @@ -1801,15 +1801,15 @@ token_creation BOOLEAN; BEGIN -- Try to solve challenge -UPDATE tan_challenges SET - confirmation_date = CASE +UPDATE tan_challenges SET + confirmation_date = CASE WHEN (retry_counter > 0 AND in_timestamp < expiration_date AND code = in_code) THEN in_timestamp ELSE confirmation_date END, retry_counter = retry_counter - 1 WHERE uuid = in_uuid -RETURNING - confirmation_date IS NOT NULL, +RETURNING + confirmation_date IS NOT NULL, retry_counter <= 0 AND confirmation_date IS NULL, in_timestamp >= expiration_date AND confirmation_date IS NULL, op = 'create_token', @@ -1849,7 +1849,7 @@ CREATE FUNCTION stats_get_frame( LANGUAGE plpgsql AS $$ BEGIN date = date_trunc(in_timeframe::text, date); - SELECT + SELECT s.cashin_count ,(s.cashin_regional_volume).val ,(s.cashin_regional_volume).frac @@ -1884,7 +1884,7 @@ BEGIN ,taler_out_volume.val ,taler_out_volume.frac FROM bank_stats AS s - WHERE s.timeframe = in_timeframe + WHERE s.timeframe = in_timeframe AND s.start_time = date; END $$; @@ -1911,7 +1911,7 @@ BEGIN 1, regional_amount FROM unnest(enum_range(null::stat_timeframe_enum)) AS frame - ON CONFLICT (timeframe, start_time) DO UPDATE + ON CONFLICT (timeframe, start_time) DO UPDATE SET taler_in_count=s.taler_in_count+1, taler_in_volume=(SELECT amount_add(s.taler_in_volume, regional_amount)); ELSIF name = 'taler_out' THEN @@ -1926,7 +1926,7 @@ BEGIN 1, regional_amount FROM unnest(enum_range(null::stat_timeframe_enum)) AS frame - ON CONFLICT (timeframe, start_time) DO UPDATE + ON CONFLICT (timeframe, start_time) DO UPDATE SET taler_out_count=s.taler_out_count+1, taler_out_volume=(SELECT amount_add(s.taler_out_volume, regional_amount)); ELSIF name = 'cashin' THEN @@ -1942,8 +1942,8 @@ BEGIN 1, regional_amount, fiat_amount - FROM unnest(enum_range(null::stat_timeframe_enum)) AS frame - ON CONFLICT (timeframe, start_time) DO UPDATE + FROM unnest(enum_range(null::stat_timeframe_enum)) AS frame + ON CONFLICT (timeframe, start_time) DO UPDATE SET cashin_count=s.cashin_count+1, cashin_regional_volume=(SELECT amount_add(s.cashin_regional_volume, regional_amount)), cashin_fiat_volume=(SELECT amount_add(s.cashin_fiat_volume, fiat_amount)); @@ -1960,8 +1960,8 @@ BEGIN 1, regional_amount, fiat_amount - FROM unnest(enum_range(null::stat_timeframe_enum)) AS frame - ON CONFLICT (timeframe, start_time) DO UPDATE + FROM unnest(enum_range(null::stat_timeframe_enum)) AS frame + ON CONFLICT (timeframe, start_time) DO UPDATE SET cashout_count=s.cashout_count+1, cashout_regional_volume=(SELECT amount_add(s.cashout_regional_volume, regional_amount)), cashout_fiat_volume=(SELECT amount_add(s.cashout_fiat_volume, fiat_amount)); @@ -2096,7 +2096,7 @@ BEGIN (cashin_tiny_amount).val, (cashin_tiny_amount).frac, (cashin_min_amount).val, (cashin_min_amount).frac, cashin_rounding_mode - INTO + INTO at_ratio.val, at_ratio.frac, out_fee.val, out_fee.frac, tiny_amount.val, tiny_amount.frac, @@ -2110,7 +2110,7 @@ BEGIN (cashout_tiny_amount).val, (cashout_tiny_amount).frac, (cashout_min_amount).val, (cashout_min_amount).frac, cashout_rounding_mode - INTO + INTO at_ratio.val, at_ratio.frac, out_fee.val, out_fee.frac, tiny_amount.val, tiny_amount.frac, @@ -2128,7 +2128,7 @@ BEGIN END IF; -- Perform conversion - SELECT (result).val, (result).frac, out_too_small INTO converted.val, converted.frac, too_small + SELECT (result).val, (result).frac, out_too_small INTO converted.val, converted.frac, too_small FROM conversion_apply_ratio(amount, at_ratio, out_fee, tiny_amount, mode); END $$; @@ -2158,7 +2158,7 @@ BEGIN (cashout_tiny_amount).val, (cashout_tiny_amount).frac, (cashin_min_amount).val, (cashin_min_amount).frac, cashin_rounding_mode - INTO + INTO ratio.val, ratio.frac, out_fee.val, out_fee.frac, tiny_amount.val, tiny_amount.frac, @@ -2174,7 +2174,7 @@ BEGIN (cashin_tiny_amount).val, (cashin_tiny_amount).frac, (cashout_min_amount).val, (cashout_min_amount).frac, cashout_rounding_mode - INTO + INTO ratio.val, ratio.frac, out_fee.val, out_fee.frac, tiny_amount.val, tiny_amount.frac, @@ -2213,7 +2213,7 @@ RETURNS TABLE ( cashout_rounding_mode rounding_mode ) LANGUAGE sql STABLE AS $$ - SELECT + SELECT (value->'cashin'->'ratio'->'val', value->'cashin'->'ratio'->'frac')::taler_amount, (value->'cashin'->'fee'->'val', value->'cashin'->'fee'->'frac')::taler_amount, (value->'cashin'->'tiny_amount'->'val', value->'cashin'->'tiny_amount'->'frac')::taler_amount, @@ -2327,8 +2327,8 @@ SELECT bank_account_id, NOT is_taler_exchange out_unknown_creditor=NOT FOUND; if out_unknown_creditor OR out_not_exchange THEN RETURN; END IF; --- Check idempotency -SELECT withdrawal_uuid, prepared_transfers.type = in_type +-- Check idempotency +SELECT withdrawal_uuid, prepared_transfers.type = in_type AND account_pub = in_account_pub AND recurrent = in_recurrent AND amount = in_amount @@ -2347,7 +2347,8 @@ END IF; -- Check reserve pub reuse out_reserve_pub_reuse=in_type = 'reserve' AND ( - EXISTS(SELECT FROM taler_exchange_incoming WHERE metadata = in_account_pub AND type = 'reserve') + EXISTS(SELECT FROM taler_exchange_incoming WHERE metadata = in_account_pub AND type = 'reserve') OR + EXISTS(SELECT FROM prepared_transfers WHERE account_pub = in_account_pub AND type = 'reserve' AND authorization_pub != in_authorization_pub) ); IF out_reserve_pub_reuse THEN RETURN;