commit 2ea416cb182f1b0990a6c6a203c3d909f1457d7f
parent 519d6229087f969ae987f9db60ee84236c571abf
Author: Antoine A <>
Date: Sat, 16 May 2026 14:16:13 +0200
bank: transaction API
Diffstat:
10 files changed, 757 insertions(+), 130 deletions(-)
diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs
@@ -18,8 +18,9 @@
*/
use sqlx::PgPool;
+use taler_api::notification::NotificationChannel;
-use crate::config::BankCfg;
+use crate::{config::BankCfg, db::notification_listener};
pub mod account;
pub mod conversion;
@@ -30,6 +31,34 @@ pub mod tx;
pub struct BankState {
pub db: PgPool,
pub cfg: BankCfg,
+ pub tx_channel: NotificationChannel<u64, i64>,
+ pub taler_out_channel: NotificationChannel<u64, i64>,
+ pub taler_in_channel: NotificationChannel<u64, i64>,
+ pub revenue_channel: NotificationChannel<u64, i64>,
+}
+
+impl BankState {
+ pub async fn start(pool: PgPool, cfg: BankCfg) -> Self {
+ let tx_channel = NotificationChannel::new();
+ let taler_in_channel = NotificationChannel::new();
+ let taler_out_channel = NotificationChannel::new();
+ let revenue_channel = NotificationChannel::new();
+ tokio::spawn(notification_listener(
+ pool.clone(),
+ tx_channel.clone(),
+ taler_out_channel.clone(),
+ taler_in_channel.clone(),
+ revenue_channel.clone(),
+ ));
+ Self {
+ cfg,
+ db: pool,
+ tx_channel,
+ taler_in_channel,
+ taler_out_channel,
+ revenue_channel,
+ }
+ }
}
#[cfg(test)]
@@ -38,7 +67,9 @@ pub mod test {
use axum::{
Router,
- http::{Method, StatusCode, header::AUTHORIZATION},
+ extract::{Request, State},
+ http::{HeaderValue, Method, StatusCode, header::AUTHORIZATION},
+ middleware::{self, Next},
};
use compact_str::CompactString;
use jiff::Timestamp;
@@ -46,11 +77,12 @@ pub mod test {
use sqlx::{PgPool, Pool, Postgres, pool::PoolConnection};
use taler_api::api::TalerRouter;
use taler_common::{
+ api_common::{HashCode, ShortHashCode},
config::Config,
error_code::ErrorCode,
types::{
amount::{Amount, Decimal},
- payto::PaytoURI,
+ payto::{IbanPayto, PaytoURI},
},
};
use taler_test_utils::{
@@ -81,7 +113,7 @@ pub mod test {
pub enum Auth {
Admin,
Optional,
- User,
+ UserOrAdmin,
UserOnly,
Token,
}
@@ -93,8 +125,8 @@ pub mod test {
pub unknown_payto: BankPayto,
pub tmp_payto: BankPayto,
pub admin_payto: BankPayto,
- pub db: PgPool,
pub server: Router,
+ pub state: Arc<BankState>,
tokens: BTreeMap<CompactString, String>,
}
@@ -106,10 +138,7 @@ pub mod test {
)
.unwrap();
- let state = Arc::new(BankState {
- db: db.clone(),
- cfg: BankCfg::parse(cfg).unwrap(),
- });
+ let state = Arc::new(BankState::start(db.clone(), BankCfg::parse(cfg).unwrap()).await);
let server = token_api()
.merge(account_api())
.merge(tx_api())
@@ -202,30 +231,29 @@ pub mod test {
tmp_payto: rand_iban_payto().convert(),
admin_payto: admin_payto.into(),
server,
- db,
+ state,
tokens: BTreeMap::new(),
};
ctx.cache_tokens(&["admin", "merchant", "exchange", "customer"])
.await;
- if state.cfg.fiat.is_some() {
+ if ctx.state.cfg.fiat.is_some() {
ctx.create_conversion_rate_class().await;
}
ctx
}
- pub fn swap_cfg(mut self, conf: &str) -> Self {
+ pub async fn swap_cfg(mut self, conf: &str) -> Self {
let cfg = Config::from_file(
CONFIG_SOURCE,
Some(format!("../../libeufin-bank/conf/{conf}")),
)
.unwrap();
- let state = Arc::new(BankState {
- db: self.db.clone(),
- cfg: BankCfg::parse(cfg).unwrap(),
- });
+ 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())
@@ -233,6 +261,7 @@ pub mod test {
.merge(conversion_api(state.clone()))
.with_state(state.clone())
.finalize();
+ self.state = state;
self
}
@@ -279,6 +308,18 @@ 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());
+ next.run(req).await
+ },
+ ))
+ }
+
fn requesta(
&self,
method: Method,
@@ -360,7 +401,7 @@ pub mod test {
.await
.assert_error(ErrorCode::GENERIC_FORBIDDEN);
}
- Auth::User | Auth::Token | Auth::Optional => {}
+ Auth::UserOrAdmin | Auth::Token | Auth::Optional => {}
Auth::UserOnly => {
self.requesta(method.clone(), path, Some("admin"))
.await
@@ -386,18 +427,18 @@ pub mod test {
self.tmp_payto.as_uri()
}
- /** Perform a bank transaction of [amount] [from] account [to] account with [subject} */
- pub async fn tx(&self, from: &str, amount: &str, to: &str) {
+ pub async fn tx_s(&self, from: &str, amount: &str, to: &str, subject: &str) {
let payto = match to {
"admin" => &self.admin_payto,
"merchant" => &self.merchant_payto,
"customer" => &self.customer_payto,
+ "exchange" => &self.exchange_payto,
_ => &self.tmp_payto,
};
self.posta(format!("/accounts/{from}/transactions"))
.json(json!({
- "payto_uri": format!("{payto}?message=payout"),
- "amount": amount,
+ "payto_uri": format!("{payto}?message={subject}"),
+ "amount": format!("{}:{amount}", self.state.cfg.regional_currency),
}))
.await
.maybe_challenge(&self)
@@ -405,6 +446,31 @@ pub mod test {
.assert_ok();
}
+ pub async fn tx(&self, from: &str, amount: &str, to: &str) {
+ self.tx_s(from, amount, to, "payout").await
+ }
+
+ pub async fn transfer(
+ &self,
+ amount: &str,
+ payto: &IbanPayto,
+ metadata: Option<CompactString>,
+ ) {
+ self.posta("/accounts/exchange/taler-wire-gateway/transfer")
+ .json({
+ json!({
+ "request_uid": HashCode::rand(),
+ "amount": amount,
+ "exchange_base_url": "http://exchange.example.com/",
+ "wtid": ShortHashCode::rand(),
+ "credit_account": payto,
+ "metadata": metadata,
+ })
+ })
+ .await
+ .assert_ok()
+ }
+
pub async fn create_conversion_rate_class(&self) -> u64 {
self.post_admin("/conversion-rate-classes")
.json(json!({
@@ -436,10 +502,10 @@ pub mod test {
credit_debit_indicator,
} = res.balance;
let prefix = match credit_debit_indicator {
- CreditDebitInfo::credit => '+',
- CreditDebitInfo::debit => '-',
+ CreditDebitInfo::credit => "",
+ CreditDebitInfo::debit => "-",
};
- pretty_assertions::assert_eq!(format!("{prefix}{amount}"), expected);
+ pretty_assertions::assert_eq!(format!("{prefix}{}", amount.decimal()), expected);
}
}
diff --git a/crates/libeufin-bank/src/api/account.rs b/crates/libeufin-bank/src/api/account.rs
@@ -917,7 +917,7 @@ pub mod test {
ctx.post("/accounts").json(&body).await.assert_error(error);
ctx.post_admin("/accounts").json(&body).await.assert_ok();
};
- ctx.auth_routine(Method::GET, "/accounts/merchant", Auth::User)
+ ctx.auth_routine(Method::GET, "/accounts/merchant", Auth::UserOrAdmin)
.await;
// Check generated payto
@@ -1296,7 +1296,7 @@ pub mod test {
acc
);
- let ctx = ctx.swap_cfg("test_bonus.conf");
+ let ctx = ctx.swap_cfg("test_bonus.conf").await;
// Create bonus
{
let req = json!({
@@ -1313,9 +1313,9 @@ pub mod test {
.json(json!(req + { "username": username }))
.await
.assert_ok();
- ctx.assert_balance(&username, "+KUDOS:100").await;
+ ctx.assert_balance(&username, "100").await;
}
- ctx.assert_balance("admin", "-KUDOS:1000").await;
+ ctx.assert_balance("admin", "-1000").await;
// Check insufficient fund
ctx.post_admin("/accounts")
@@ -1328,8 +1328,8 @@ pub mod test {
}
// Restricted account creation
- let ctx = ctx.swap_cfg("test_restrict.conf");
- ctx.auth_routine(Method::POST, "/accounts", Auth::User)
+ let ctx = ctx.swap_cfg("test_restrict.conf").await;
+ ctx.auth_routine(Method::POST, "/accounts", Auth::UserOrAdmin)
.await;
ctx.post_admin("/accounts")
.json(json!({
@@ -1341,7 +1341,7 @@ pub mod test {
.assert_ok();
// Unsupported tan
- let ctx = ctx.swap_cfg("test_tan_err.conf");
+ let ctx = ctx.swap_cfg("test_tan_err.conf").await;
ctx.post_admin("/accounts")
.json(json!({
"username": "foo",
@@ -1353,7 +1353,7 @@ pub mod test {
.assert_error(ErrorCode::BANK_TAN_CHANNEL_NOT_SUPPORTED);
// No password check
- let ctx = ctx.swap_cfg("test_no_password_check.conf");
+ let ctx = ctx.swap_cfg("test_no_password_check.conf").await;
// Short password
ctx.post("/accounts")
.json(json!({
@@ -1403,12 +1403,12 @@ pub mod test {
ctx.cache_tokens(&["john"]).await;
ctx.fill_tan_info("john").await;
// Fail to delete, due to a non-zero balance.
- ctx.tx("customer", "KUDOS:1", "john").await;
+ ctx.tx("customer", "1", "john").await;
ctx.deletea("/accounts/john")
.await
.assert_error(ErrorCode::BANK_ACCOUNT_BALANCE_NOT_ZERO);
// Successful deletion
- ctx.tx("john", "KUDOS:1", "customer").await;
+ ctx.tx("john", "1", "customer").await;
ctx.deletea("/accounts/john")
.await
.assert_challenge(&ctx)
@@ -1425,14 +1425,14 @@ pub mod test {
// GC
{
let zero = Duration::default();
- collect(&ctx.db, &Timestamp::now(), &zero, &zero, &zero)
+ collect(&ctx.state.db, &Timestamp::now(), &zero, &zero, &zero)
.await
.unwrap();
// TODO need more operations
}
// Test admin-only account deletion
- let ctx = ctx.swap_cfg("test_restrict.conf");
+ let ctx = ctx.swap_cfg("test_restrict.conf").await;
ctx.auth_routine(Method::DELETE, "/accounts/merchant", Auth::Admin)
.await;
// Exchange is still restricted
@@ -1441,7 +1441,7 @@ pub mod test {
.assert_error(ErrorCode::BANK_RESERVED_USERNAME_CONFLICT);
// Test delete exchange account
- let ctx = ctx.swap_cfg("test_no_conversion.conf");
+ let ctx = ctx.swap_cfg("test_no_conversion.conf").await;
// Exchange is no longer restricted
ctx.deletea("/accounts/exchange").await.assert_no_content();
}
@@ -1468,7 +1468,7 @@ pub mod test {
async fn reconfig() {
let mut ctx = bank_setup().await;
- ctx.auth_routine(Method::PATCH, "/accounts/merchant", Auth::User)
+ ctx.auth_routine(Method::PATCH, "/accounts/merchant", Auth::UserOrAdmin)
.await;
for channel in TanChannel::entries {
@@ -1651,7 +1651,7 @@ pub mod test {
assert_eq!(acc.is_public, true);
// Restriction
- let ctx = ctx.swap_cfg("test_restrict.conf");
+ let ctx = ctx.swap_cfg("test_restrict.conf").await;
{
check_admin_only(
&ctx,
@@ -1679,7 +1679,7 @@ pub mod test {
}
// Unsupported tan
- let ctx = ctx.swap_cfg("test_tan_err.conf");
+ let ctx = ctx.swap_cfg("test_tan_err.conf").await;
ctx.patcha("/accounts/customer")
.json(json!({
"tan_channel": "email"
@@ -1691,7 +1691,7 @@ pub mod test {
#[tokio::test]
async fn password() {
let ctx = bank_setup().await;
- ctx.auth_routine(Method::PATCH, "/accounts/merchant/auth", Auth::User)
+ ctx.auth_routine(Method::PATCH, "/accounts/merchant/auth", Auth::UserOrAdmin)
.await;
// Changing the password
@@ -1789,7 +1789,7 @@ pub mod test {
.assert_error(ErrorCode::BANK_PATCH_BAD_OLD_PASSWORD);
// No password check
- let ctx = ctx.swap_cfg("test_no_password_check.conf");
+ let ctx = ctx.swap_cfg("test_no_password_check.conf").await;
ctx.patcha("/accounts/merchant/auth")
.json(json!({
"old_password": "merchant-password",
@@ -1832,13 +1832,13 @@ pub mod test {
// Hard delete accounts
let zero = Duration::default();
- collect(&ctx.db, &Timestamp::now(), &zero, &zero, &zero)
+ collect(&ctx.state.db, &Timestamp::now(), &zero, &zero, &zero)
.await
.unwrap();
ctx.get("/public-accounts").await.assert_no_content();
ctx.get_admin("/accounts").await.assert_ok();
- let ctx = ctx.swap_cfg("test.conf");
+ let ctx = ctx.swap_cfg("test.conf").await;
for _ in 0..3 {
ctx.create_conversion_rate_class().await;
diff --git a/crates/libeufin-bank/src/api/conversion.rs b/crates/libeufin-bank/src/api/conversion.rs
@@ -640,7 +640,7 @@ pub mod test {
ctx.auth_routine(
Method::GET,
"/accounts/merchant/conversion-info/cashout-rate?amount_debit=KUDOS:1",
- Auth::User,
+ Auth::UserOrAdmin,
)
.await;
@@ -838,7 +838,7 @@ pub mod test {
// No rate
sqlx::query("DELETE FROM config WHERE key='conversion_rate'")
- .execute(&ctx.db)
+ .execute(&ctx.state.db)
.await
.unwrap();
for prefix in prefixes {
@@ -856,7 +856,7 @@ pub mod test {
}
// Not implemented
- let ctx = ctx.swap_cfg("test_no_conversion.conf");
+ let ctx = ctx.swap_cfg("test_no_conversion.conf").await;
ctx.get_admin("/conversion-rate-classes")
.await
.assert_not_implemented();
diff --git a/crates/libeufin-bank/src/api/token.rs b/crates/libeufin-bank/src/api/token.rs
@@ -222,8 +222,12 @@ pub mod test {
ctx.auth_routine(Method::POST, "/accounts/merchant/token", Auth::Token)
.await;
- ctx.auth_routine(Method::DELETE, "/accounts/merchant/tokens/1", Auth::User)
- .await;
+ ctx.auth_routine(
+ Method::DELETE,
+ "/accounts/merchant/tokens/1",
+ Auth::UserOrAdmin,
+ )
+ .await;
// Unknown account
ctx.post("/accounts/merchant/token")
@@ -251,7 +255,7 @@ pub mod test {
.assert_ok_json();
// Checking that the token lifetime defaulted to 24 hours
let token = access(
- &ctx.db,
+ &ctx.state.db,
Base32::<32>::from_str(res.access_token.strip_prefix(TOKEN_PREFIX).unwrap())
.unwrap()
.as_ref(),
@@ -504,7 +508,7 @@ pub mod test {
#[tokio::test]
async fn get() {
let ctx = bank_setup().await;
- ctx.auth_routine(Method::GET, "/accounts/merchant/tokens", Auth::User)
+ ctx.auth_routine(Method::GET, "/accounts/merchant/tokens", Auth::UserOrAdmin)
.await;
// Check OK
diff --git a/crates/libeufin-bank/src/api/tx.rs b/crates/libeufin-bank/src/api/tx.rs
@@ -21,21 +21,32 @@
use std::sync::Arc;
-use axum::{Json, Router, extract::State, response::IntoResponse, routing::post};
+use axum::{
+ Json, Router,
+ extract::State,
+ response::{IntoResponse, NoContent},
+ routing::{get, post},
+};
+use compact_str::CompactString;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
-use taler_api::error::{bad_request, failure, failure_code};
+use taler_api::{
+ error::{ApiResult, bad_request, failure, failure_code},
+ extract::{Path, Query},
+};
use taler_common::{
api_common::ShortHashCode,
+ api_params::HistoryParams,
error_code::ErrorCode,
- types::{amount::Amount, payto::ParsedPayto},
+ types::{amount::Amount, payto::ParsedPayto, timestamp::TalerTimestamp},
};
use crate::{
api::BankState,
- db::tx::{TxResult, create},
+ auth::UserRAuth,
+ db::tx::{TxResult, create, get_by_id, pool_history},
mfa::{BankTxOp, MfaReq},
- payto::{BankPayto, LibeufinId},
+ payto::{BankPayto, FullBankPayto, LibeufinId},
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -50,96 +61,490 @@ pub struct TransactionCreateResponse {
pub row_id: u64,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
+#[sqlx(type_name = "direction_enum")]
+#[allow(non_camel_case_types)]
+pub enum TransactionDirection {
+ credit,
+ debit,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BankAccountTransactionInfo {
+ pub creditor_payto_uri: FullBankPayto,
+ pub debtor_payto_uri: FullBankPayto,
+ pub amount: Amount,
+ pub direction: TransactionDirection,
+ pub subject: String,
+ pub row_id: u64,
+ pub date: TalerTimestamp,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BankAccountTransactionsResponse {
+ pub transactions: Vec<BankAccountTransactionInfo>,
+}
+
pub fn tx_api() -> Router<Arc<BankState>> {
- Router::new().route(
- "/accounts/{username}/transactions",
- post(
- async |State(state): State<Arc<BankState>>,
- MfaReq { mut auth, req, mfa }: MfaReq<BankTxOp>| {
- let subject = req
- .payto_uri
- .subject
- .clone()
- .ok_or_else(|| bad_request("Wire transfer lacks subject"))?;
- let amount = req
- .payto_uri
- .amount
- .or(req.amount)
- .ok_or_else(|| bad_request("Wire transfer lacks amount"))?;
-
- // TODO check regional currency
- match create(
- &state.db,
- BankPayto::new(req.payto_uri.into_inner()),
- &auth.username,
- &subject,
- amount,
- &Timestamp::now(),
- mfa.is_2fa(),
- req.request_uid,
- state.cfg.wire_transfer_fees,
- state.cfg.min_amount,
- state.cfg.max_amount,
- )
- .await?
- {
- TxResult::Success(row_id) => {
- Ok(Json(TransactionCreateResponse { row_id }).into_response())
- }
- TxResult::UnknownCreditor => {
- Err(failure_code(ErrorCode::BANK_UNKNOWN_CREDITOR))
+ Router::new()
+ .route(
+ "/accounts/{username}/transactions",
+ post(
+ async |State(state): State<Arc<BankState>>,
+ MfaReq { mut auth, req, mfa }: MfaReq<BankTxOp>| {
+ let subject = req
+ .payto_uri
+ .subject
+ .clone()
+ .ok_or_else(|| bad_request("Wire transfer lacks subject"))?;
+ let amount = req
+ .payto_uri
+ .amount
+ .or(req.amount)
+ .ok_or_else(|| bad_request("Wire transfer lacks amount"))?;
+
+ state.cfg.check_regio(&amount)?;
+
+ match create(
+ &state.db,
+ BankPayto::new(req.payto_uri.into_inner()),
+ &auth.username,
+ &subject,
+ amount,
+ &Timestamp::now(),
+ mfa.is_2fa(),
+ req.request_uid,
+ state.cfg.wire_transfer_fees,
+ state.cfg.min_amount,
+ state.cfg.max_amount,
+ )
+ .await?
+ {
+ TxResult::Success(row_id) => {
+ Ok(Json(TransactionCreateResponse { row_id }).into_response())
+ }
+ TxResult::UnknownCreditor => {
+ Err(failure_code(ErrorCode::BANK_UNKNOWN_CREDITOR))
+ }
+ TxResult::AdminCreditor => {
+ Err(failure_code(ErrorCode::BANK_ADMIN_CREDITOR))
+ }
+ TxResult::UnknownDebtor => {
+ Err(failure_code(ErrorCode::BANK_UNKNOWN_DEBTOR))
+ }
+ TxResult::BothPartySame => Err(failure_code(ErrorCode::BANK_SAME_ACCOUNT)),
+ TxResult::BalanceInsufficient => Err(failure(
+ ErrorCode::BANK_UNALLOWED_DEBIT,
+ "Insufficient funds",
+ )),
+ TxResult::BadAmount => Err(failure(
+ ErrorCode::BANK_UNALLOWED_DEBIT,
+ "Amount either to high or too low",
+ )),
+ TxResult::TanRequired => {
+ mfa.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await
+ }
+ TxResult::RequestUidReuse => {
+ Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED))
+ }
}
- TxResult::AdminCreditor => Err(failure_code(ErrorCode::BANK_ADMIN_CREDITOR)),
- TxResult::UnknownDebtor => Err(failure_code(ErrorCode::BANK_UNKNOWN_DEBTOR)),
- TxResult::BothPartySame => Err(failure_code(ErrorCode::BANK_SAME_ACCOUNT)),
- TxResult::BalanceInsufficient => Err(failure(
- ErrorCode::BANK_UNALLOWED_DEBIT,
- "Insufficient funds",
- )),
- TxResult::BadAmount => Err(failure(
- ErrorCode::BANK_UNALLOWED_DEBIT,
- "Amount either to high or too low",
- )),
- TxResult::TanRequired => {
- mfa.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await
+ },
+ )
+ .get(
+ async |mut auth: UserRAuth,
+ Query(params): Query<HistoryParams>,
+ State(state): State<Arc<BankState>>| {
+ let params = params.check()?;
+ let transactions = pool_history(
+ &state.db,
+ &state.cfg.ctx,
+ &state.cfg.regional_currency,
+ &state.tx_channel,
+ ¶ms,
+ auth.bank_info(&state.db, &state.cfg.ctx)
+ .await?
+ .bank_account_id,
+ )
+ .await?;
+ if transactions.is_empty() {
+ ApiResult::Ok(NoContent.into_response())
+ } else {
+ Ok(Json(BankAccountTransactionsResponse { transactions }).into_response())
}
- TxResult::RequestUidReuse => {
- Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED))
+ },
+ ),
+ )
+ .route(
+ "/accounts/{username}/transactions/{id}",
+ get(
+ async |auth: UserRAuth,
+ Path((_, id)): Path<(CompactString, u64)>,
+ State(state): State<Arc<BankState>>| {
+ if let Some(tx) = get_by_id(
+ &state.db,
+ &state.cfg.regional_currency,
+ &state.cfg.ctx,
+ id,
+ &auth.username,
+ )
+ .await?
+ {
+ Ok(Json(tx))
+ } else {
+ Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND))
}
- }
- },
- ),
- )
+ },
+ ),
+ )
}
#[cfg(test)]
pub mod test {
use axum::http::Method;
- use taler_test_utils::json;
+ use taler_api::subject::{fmt_in_subject, fmt_out_subject};
+ use taler_common::{
+ api_common::{EddsaPublicKey, ShortHashCode},
+ db::IncomingType,
+ error_code::ErrorCode,
+ types::amount::amount,
+ };
+ use taler_test_utils::{
+ json,
+ routine::{Page, routine_history},
+ tasks,
+ };
+ use url::Url;
use crate::api::{
- test::{Auth, bank_setup},
- tx::TransactionCreateResponse,
+ test::{Auth, MfaRequest, bank_setup, bank_setup_conf},
+ tx::{
+ BankAccountTransactionInfo, BankAccountTransactionsResponse, TransactionCreateResponse,
+ },
};
#[tokio::test]
- async fn create() {
+ async fn tx() {
let ctx = bank_setup().await;
- ctx.auth_routine(Method::POST, "/accounts/merchant/transactions", Auth::User)
- .await;
+ ctx.auth_routine(
+ Method::POST,
+ "/accounts/merchant/transactions",
+ Auth::UserOnly,
+ )
+ .await;
+ ctx.auth_routine(
+ Method::GET,
+ "/accounts/merchant/transactions/42",
+ Auth::UserOrAdmin,
+ )
+ .await;
let valid_req = json!({
"payto_uri": format!("{}?message=payout", ctx.exchange_payto),
"amount": "KUDOS:0.3"
});
- // Check OK
- let res: TransactionCreateResponse = ctx
+ // OK
+ let id = ctx
.posta("/accounts/merchant/transactions")
.json(&valid_req)
.await
+ .assert_ok_json::<TransactionCreateResponse>()
+ .row_id;
+ let tx: BankAccountTransactionInfo = ctx
+ .geta(format!("/accounts/merchant/transactions/{id}"))
+ .await
.assert_ok_json();
+ assert_eq!(tx.subject, "payout");
+ assert_eq!(tx.amount, amount("KUDOS:0.3"));
+
+ // Idempotency
+ let uid = ShortHashCode::rand();
+ let id = ctx
+ .posta("/accounts/merchant/transactions")
+ .json(json!(valid_req + { "request_uid": uid }))
+ .await
+ .assert_ok_json::<TransactionCreateResponse>()
+ .row_id;
+ assert_eq!(
+ id,
+ ctx.posta("/accounts/merchant/transactions")
+ .json(json!(valid_req + { "request_uid": uid }))
+ .await
+ .assert_ok_json::<TransactionCreateResponse>()
+ .row_id
+ );
+ ctx.posta("/accounts/merchant/transactions")
+ .json(json!(valid_req + {
+ "request_uid": uid,
+ "amount": "KUDOS:42"
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED);
+
+ // Amount in payto_uri
+ let id = ctx
+ .posta("/accounts/merchant/transactions")
+ .json(json!({
+ "payto_uri": format!("{}?message=payout3&amount=KUDOS:1.05", ctx.exchange_payto),
+ "amount": "KUDOS:10.003"
+ }))
+ .await
+ .assert_ok_json::<TransactionCreateResponse>()
+ .row_id;
+ let tx: BankAccountTransactionInfo = ctx
+ .geta(format!("/accounts/merchant/transactions/{id}"))
+ .await
+ .assert_ok_json();
+ assert_eq!(tx.subject, "payout3");
+ assert_eq!(tx.amount, amount("KUDOS:1.05"));
+
+ // Unknown tx
+ ctx.geta("/accounts/merchant/transactions/3")
+ .await
+ .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
+ // Other user tx
+ ctx.geta(format!("/accounts/customer/transactions/{id}"))
+ .await
+ .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
+
+ // Wrong currency
+ ctx.posta("/accounts/merchant/transactions")
+ .json(json!(valid_req + { "amount": "EUR:3.3" }))
+ .await
+ .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
+ // Surpassing the debt limit
+ ctx.posta("/accounts/merchant/transactions")
+ .json(json!(valid_req + { "amount": "KUDOS:555" }))
+ .await
+ .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT);
+ // Missing message
+ ctx.posta("/accounts/merchant/transactions")
+ .json(json!(valid_req + { "payto_uri": ctx.exchange_payto }))
+ .await
+ .assert_bad_request();
+ // Unknown creditor
+ ctx.posta("/accounts/merchant/transactions")
+ .json(json!(valid_req + {
+ "payto_uri": format!("{}?message=payout", ctx.unknown_payto)
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_UNKNOWN_CREDITOR);
+ // Transaction to self
+ ctx.posta("/accounts/merchant/transactions")
+ .json(json!(valid_req + {
+ "payto_uri": format!("{}?message=payout", ctx.merchant_payto)
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_SAME_ACCOUNT);
+ // Transaction to admin
+ ctx.posta("/accounts/merchant/transactions")
+ .json(json!(valid_req + {
+ "payto_uri": format!("{}?message=payout", ctx.admin_payto)
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_ADMIN_CREDITOR);
+
+ // Init state
+ ctx.assert_balance("merchant", "0").await;
+ ctx.assert_balance("customer", "0").await;
+ // Send 2 times 3
+ for _ in 0..2 {
+ ctx.tx("merchant", "3", "customer").await;
+ }
+ ctx.posta("/accounts/merchant/transactions")
+ .json(json!(valid_req + {
+ "payto_uri": format!("{}?message=payout2&amount=KUDOS:5", ctx.customer_payto)
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT);
+ ctx.assert_balance("merchant", "-6").await;
+ ctx.assert_balance("customer", "6").await;
+ // Send through debt
+ ctx.tx("customer", "10", "merchant").await;
+ ctx.assert_balance("merchant", "4").await;
+ ctx.assert_balance("customer", "-4").await;
+ ctx.tx("merchant", "4", "customer").await;
+
+ // Check bounce
+ ctx.assert_balance("merchant", "0").await;
+ ctx.assert_balance("exchange", "0").await;
+ ctx.tx_s("merchant", "1", "exchange", "").await; // Bounce common to transaction
+ ctx.tx_s("merchant", "1", "exchange", "Malformed").await; // Bounce malformed transaction
+ ctx.tx_s("merchant", "1", "exchange", "ADMIN BALANCE ADJUST")
+ .await; // Bounce admin balance adjust
+ let key = EddsaPublicKey::rand();
+ ctx.tx_s(
+ "merchant",
+ "1",
+ "exchange",
+ &fmt_in_subject(IncomingType::reserve, &key),
+ )
+ .await; // Accept incoming
+ ctx.tx_s(
+ "merchant",
+ "1",
+ "exchange",
+ &fmt_in_subject(IncomingType::reserve, &key),
+ )
+ .await; // Bounce reserve_pub reuse
+ ctx.assert_balance("merchant", "-1").await;
+ ctx.assert_balance("exchange", "1").await;
+
+ // Check warn
+ 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();
+ ctx.tx_s(
+ "exchange",
+ "1",
+ "merchant",
+ &fmt_out_subject(&wtid, &url, None),
+ )
+ .await; // Accept outgoing
+ ctx.tx_s(
+ "exchange",
+ "1",
+ "merchant",
+ &fmt_out_subject(&wtid, &url, None),
+ )
+ .await; // Warn wtid reuse
+ ctx.assert_balance("merchant", "3").await;
+ ctx.assert_balance("exchange", "-3").await;
+
+ // Check 2fa
+ ctx.fill_tan_info("merchant").await;
+ ctx.assert_balance("merchant", "3").await;
+ ctx.assert_balance("customer", "0").await;
+ ctx.posta("/accounts/merchant/transactions")
+ .json(json!(valid_req + {
+ "payto_uri": format!("{}?message=tan+check&amount=KUDOS:1", ctx.customer_payto)
+ }))
+ .await
+ .assert_challenge_check(&ctx, async |_| {
+ ctx.assert_balance("merchant", "3").await;
+ ctx.assert_balance("customer", "0").await;
+ })
+ .await
+ .assert_ok();
+ ctx.assert_balance("merchant", "2").await;
+ ctx.assert_balance("customer", "1").await;
+
+ // Check 2fa idempotency
+ let req = json!({
+ "payto_uri": format!("{}?message=tan+check&amount=KUDOS:1", ctx.customer_payto),
+ "request_uid": ShortHashCode::rand(),
+ });
+ let res = ctx
+ .posta("/accounts/merchant/transactions")
+ .json(&req)
+ .await
+ .assert_challenge_check(&ctx, async |_| {
+ ctx.assert_balance("merchant", "2").await;
+ ctx.assert_balance("customer", "1").await;
+ })
+ .await
+ .assert_ok_json::<TransactionCreateResponse>();
+ ctx.assert_balance("merchant", "1").await;
+ ctx.assert_balance("customer", "2").await;
+ assert_eq!(
+ res,
+ ctx.posta("/accounts/merchant/transactions")
+ .json(&req)
+ .await
+ .assert_ok_json::<TransactionCreateResponse>()
+ );
+ ctx.posta("/accounts/merchant/transactions")
+ .json(json!(req + {
+ "payto_uri": format!("{}?message=tan+chec2k&amount=KUDOS:1", ctx.customer_payto)
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED);
+ }
+
+ #[tokio::test]
+ async fn tx_with_fee() {
+ let ctx = bank_setup_conf("test_with_fees.conf").await;
+
+ // Init state
+ ctx.assert_balance("merchant", "0").await;
+ ctx.assert_balance("customer", "0").await;
+ ctx.assert_balance("admin", "0").await;
+
+ // Check fee are sent to admin
+ ctx.tx("merchant", "3", "customer").await;
+ ctx.assert_balance("merchant", "-3.1").await;
+ ctx.assert_balance("customer", "3").await;
+ ctx.assert_balance("admin", "0.1").await;
+
+ // Check amount with fee and min & max are checked
+ for amount in ["KUDOS:7", "KUDOS:6.9", "KUDOS:0", "KUDOS:150"] {
+ ctx.posta("/accounts/merchant/transactions")
+ .json(json!({
+ "payto_uri": format!("{}?message=payout2&amount={amount}", ctx.customer_payto)
+ }))
+ .await
+ .assert_error(ErrorCode::BANK_UNALLOWED_DEBIT);
+ }
+
+ // Check empty account
+ ctx.tx("merchant", "6.8", "customer").await;
+ ctx.assert_balance("merchant", "-10").await;
+ ctx.assert_balance("customer", "9.8").await;
+ ctx.assert_balance("admin", "0.2").await;
+
+ // Admin check no fee
+ ctx.tx("admin", "0.35", "merchant").await;
+ ctx.assert_balance("merchant", "-9.65").await;
+ ctx.assert_balance("admin", "-0.15").await;
+
+ // Admin recover from debt
+ ctx.tx("customer", "1", "merchant").await;
+ ctx.assert_balance("admin", "-0.05").await;
+ ctx.tx("customer", "1", "merchant").await;
+ ctx.assert_balance("merchant", "-7.65").await;
+ ctx.assert_balance("customer", "7.6").await;
+ ctx.assert_balance("admin", "0.05").await;
+ }
+
+ impl Page for BankAccountTransactionsResponse {
+ fn ids(&self) -> Vec<i64> {
+ self.transactions
+ .iter()
+ .map(|it| it.row_id as i64)
+ .collect()
+ }
+ }
+
+ #[tokio::test]
+ async fn history() {
+ let ctx = &bank_setup().await;
+
+ ctx.auth_routine(
+ Method::POST,
+ "/accounts/merchant/transactions",
+ Auth::UserOrAdmin,
+ )
+ .await;
+
+ routine_history::<BankAccountTransactionsResponse>(
+ &ctx.admin_router().await,
+ "/accounts/customer/transactions",
+ tasks!(
+ // Incoming
+ { ctx.tx("merchant", "0.1", "customer").await },
+ // Outgoing
+ { ctx.tx("customer", "0.1", "merchant").await },
+ // TODO cashout
+ ),
+ tasks!(
+ // Other account
+ { ctx.tx("merchant", "0.1", "exchange").await },
+ { ctx.tx("exchange", "0.1", "merchant").await },
+ ),
+ )
+ .await;
}
}
diff --git a/crates/libeufin-bank/src/auth.rs b/crates/libeufin-bank/src/auth.rs
@@ -301,6 +301,13 @@ impl UserAuthScope for UserRWScope {
const KIND: AuthKind = AuthKind::UserOrAdmin;
}
+pub struct UserORWScope;
+
+impl UserAuthScope for UserORWScope {
+ const SCOPE: TokenLogicalScope = TokenLogicalScope::readwrite;
+ const KIND: AuthKind = AuthKind::UserOnly;
+}
+
pub struct UserRScope;
impl UserAuthScope for UserRScope {
diff --git a/crates/libeufin-bank/src/db.rs b/crates/libeufin-bank/src/db.rs
@@ -17,7 +17,13 @@
* <http://www.gnu.org/licenses/>
*/
-const SCHEMA: &str = "libeufin_bank";
+use std::time::Duration;
+
+use compact_str::CompactString;
+use sqlx::PgPool;
+use taler_api::notification::NotificationChannel;
+use tokio::join;
+use uuid::Uuid;
pub mod account;
pub mod conversion;
@@ -25,3 +31,48 @@ pub mod gc;
pub mod tan;
pub mod token;
pub mod tx;
+
+const SCHEMA: &str = "libeufin_bank";
+
+pub async fn notification_listener(
+ pool: PgPool,
+ tx_channel: NotificationChannel<u64, i64>,
+ taler_out_channel: NotificationChannel<u64, i64>,
+ taler_in_channel: NotificationChannel<u64, i64>,
+ revenue_channel: NotificationChannel<u64, i64>,
+) -> sqlx::Result<()> {
+ // GC notifications channels very hours
+ let gc = async {
+ let mut interval = tokio::time::interval(Duration::from_hours(1));
+ loop {
+ tx_channel.prune();
+ taler_out_channel.prune();
+ taler_in_channel.prune();
+ revenue_channel.prune();
+
+ interval.tick().await;
+ }
+ };
+
+ let listener = async {
+ taler_api::notification::notification_listener!(&pool,
+ "bank_tx" => (debtor: u64, creditor: u64, debit: i64, credit: i64) {
+ tx_channel.dispatch(&debtor, debit);
+ tx_channel.dispatch(&creditor, credit);
+ revenue_channel.dispatch(&creditor, credit);
+ },
+ "bank_outgoing_tx" => (account: u64, _a: u64, debit: i64, _d: i64) {
+ taler_out_channel.dispatch(&account, debit);
+ },
+ "bank_incoming_tx" => (account: u64, row: i64) {
+ taler_in_channel.dispatch(&account, row);
+ },
+ "bank_withdrawal_status" => (uuid: Uuid, status: CompactString) {
+ // TODO
+ }
+ );
+ sqlx::Result::<_, sqlx::error::Error>::Ok(())
+ };
+ join!(gc, listener);
+ Ok(())
+}
diff --git a/crates/libeufin-bank/src/db/tx.rs b/crates/libeufin-bank/src/db/tx.rs
@@ -20,14 +20,23 @@
//! Data access logic for transactions
use jiff::Timestamp;
-use sqlx::{PgPool, postgres::PgRow};
+use sqlx::{PgPool, QueryBuilder, Row, postgres::PgRow};
use taler_api::{
- db::{BindHelper, TypeHelper},
+ db::{BindHelper, TypeHelper, history},
+ notification::NotificationChannel,
+ serialized,
subject::{IncomingSubject, parse_incoming_unstructured},
};
-use taler_common::{api_common::ShortHashCode, types::amount::Amount};
+use taler_common::{
+ api_common::ShortHashCode,
+ api_params::History,
+ types::amount::{Amount, Currency},
+};
-use crate::payto::BankPayto;
+use crate::{
+ api::tx::BankAccountTransactionInfo,
+ payto::{BankPayto, PaytoCtx, sql_bank_payto},
+};
/** Result status of bank transaction creation */
pub enum TxResult {
@@ -132,4 +141,88 @@ pub async fn create(
}
// /** Get transaction [rowId] owned by [username] */
-//pub async fn get(db: &PgPool, id: u64, username: &str) -> sqlx::Result<Option<Ban>>
+pub async fn get_by_id(
+ db: &PgPool,
+ currency: &Currency,
+ ctx: &PaytoCtx,
+ id: u64,
+ username: &str,
+) -> sqlx::Result<Option<BankAccountTransactionInfo>> {
+ serialized!(
+ sqlx::query("
+ SELECT
+ creditor_payto
+ ,creditor_name
+ ,debtor_payto
+ ,debtor_name
+ ,subject
+ ,amount
+ ,transaction_date
+ ,direction
+ ,bank_transaction_id
+ 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
+ WHERE bank_transaction_id=$1 AND username=$2
+ ")
+ .bind(id as i64)
+ .bind(username)
+ .try_map(|r: PgRow| Ok(BankAccountTransactionInfo {
+ creditor_payto_uri: sql_bank_payto(&r, ctx, "creditor_payto", "creditor_name")?,
+ debtor_payto_uri: sql_bank_payto(&r, ctx, "debtor_payto", "debtor_name")?,
+ direction: r.try_get("direction")?,
+ 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")?
+ }))
+ .fetch_optional(db)
+ )
+}
+
+/** Pool [accountId] transactions history */
+pub async fn pool_history(
+ db: &PgPool,
+ ctx: &PaytoCtx,
+ currency: &Currency,
+ channel: &NotificationChannel<u64, i64>,
+ params: &History,
+ account_id: u64,
+) -> sqlx::Result<Vec<BankAccountTransactionInfo>> {
+ 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
+ ,creditor_payto
+ ,creditor_name
+ ,subject
+ ,direction
+ FROM bank_account_transactions
+ WHERE bank_account_id=",
+ );
+ query.push_bind(account_id as i64).push(" AND ");
+ query
+ },
+ |r| {
+ Ok(BankAccountTransactionInfo {
+ creditor_payto_uri: sql_bank_payto(&r, ctx, "creditor_payto", "creditor_name")?,
+ debtor_payto_uri: sql_bank_payto(&r, ctx, "debtor_payto", "debtor_name")?,
+ direction: r.try_get("direction")?,
+ 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")?,
+ })
+ },
+ )
+ .await
+}
diff --git a/crates/libeufin-bank/src/mfa.rs b/crates/libeufin-bank/src/mfa.rs
@@ -49,7 +49,7 @@ use crate::{
token::TokenRequest,
tx::TransactionCreateRequest,
},
- auth::{UserAuth, UserAuthScope, UserRWScope, UserTokenScope},
+ auth::{UserAuth, UserAuthScope, UserORWScope, UserRWScope, UserTokenScope},
db::account::BankInfo,
payto::PaytoCtx,
};
@@ -98,7 +98,7 @@ pub struct BankTxOp;
impl MfaOp for BankTxOp {
const OP: Operation = Operation::bank_transaction;
- type Scope = UserRWScope;
+ type Scope = UserORWScope;
type Body = TransactionCreateRequest;
}
diff --git a/crates/libeufin-nexus/src/api.rs b/crates/libeufin-nexus/src/api.rs
@@ -324,6 +324,7 @@ pub mod test {
transfer_routine,
},
server::TestServer as _,
+ tasks,
};
use crate::{
@@ -384,7 +385,7 @@ pub mod test {
routine_pagination::<OutgoingHistory>(
&server,
"/taler-wire-gateway/history/outgoing",
- async |_| {
+ tasks!({
register_out_tx(
&pool,
&gen_out_pay("subject"),
@@ -392,7 +393,7 @@ pub mod test {
)
.await
.unwrap();
- },
+ }),
)
.await;
}