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:
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,
+ ¶ms,
+ 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,
+ ¶ms.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,
+ ¶ms,
+ 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,
+ ¶ms,
+ 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, ¶ms.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;