prepared.rs (11490B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 2026 Taler Systems S.A. 4 5 * LibEuFin is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU Affero General Public License as 7 * published by the Free Software Foundation; either version 3, or 8 * (at your option) any later version. 9 10 * LibEuFin is distributed in the hope that it will be useful, but 11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 12 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General 13 * Public License for more details. 14 15 * You should have received a copy of the GNU Affero General Public 16 * License along with LibEuFin; see the file COPYING. If not, see 17 * <http://www.gnu.org/licenses/> 18 */ 19 20 use std::sync::Arc; 21 22 use axum::{ 23 Json, Router, 24 extract::State, 25 response::NoContent, 26 routing::{get, post}, 27 }; 28 use jiff::Timestamp; 29 use taler_api::{ 30 api::{Validation, prepared::simple_subject}, 31 constants::PREPARED_TRANSFER_API_VERSION, 32 error::failure_code, 33 extract::Req, 34 }; 35 use taler_common::{ 36 api::prepared::{ 37 PreparedTransferConfig, RegistrationRequest, RegistrationResponse, SubjectFormat, 38 TransferSubject, Unregistration, 39 }, 40 error_code::ErrorCode, 41 types::time::TalerTimestamp, 42 }; 43 44 use crate::{ 45 api::{BankState, IMPLEMENTATION}, 46 db::prepared::{RegistrationResult, register, unregister}, 47 payto::BankPayto, 48 }; 49 50 pub fn prepared_api() -> Router<Arc<BankState>> { 51 Router::new() 52 .route( 53 "/taler-prepared-transfer/config", 54 get(async |State(state): State<Arc<BankState>>| { 55 Json(PreparedTransferConfig { 56 name: (), 57 version: PREPARED_TRANSFER_API_VERSION, 58 implementation: Some(IMPLEMENTATION), 59 currency: state.cfg.regional_currency, 60 supported_formats: vec![SubjectFormat::URI, SubjectFormat::SIMPLE], 61 }) 62 }), 63 ) 64 .route( 65 "/taler-prepared-transfer/registration", 66 post( 67 async |State(state): State<Arc<BankState>>, Req(req): Req<RegistrationRequest>| { 68 req.check(&state.cfg.regional_currency)?; 69 let creditor = BankPayto::try_from(&req.credit_account)?; 70 match register( 71 &state.db, 72 &creditor, 73 req.r#type, 74 &req.account_pub, 75 &req.authorization_pub, 76 &req.authorization_sig, 77 req.recurrent, 78 &req.credit_amount, 79 &Timestamp::now(), 80 ) 81 .await? 82 { 83 RegistrationResult::Success(uuid) => { 84 let mut subjects = Vec::new(); 85 if let Some(uuid) = uuid { 86 subjects.push(TransferSubject::Uri { 87 credit_amount: req.credit_amount, 88 uri: state.cfg.taler_withdraw_uri(uuid), 89 }); 90 } 91 subjects.push(simple_subject(req)); 92 Ok(Json(RegistrationResponse { 93 subjects, 94 expiration: TalerTimestamp::Never, 95 })) 96 } 97 RegistrationResult::UnknownCreditor => { 98 Err(failure_code(ErrorCode::BANK_UNKNOWN_CREDITOR)) 99 } 100 RegistrationResult::NotExchange => { 101 Err(failure_code(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE)) 102 } 103 RegistrationResult::ReservePubReuse => { 104 Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) 105 } 106 } 107 }, 108 ), 109 ) 110 .route( 111 "/taler-prepared-transfer/unregistration", 112 post( 113 async |State(state): State<Arc<BankState>>, Req(req): Req<Unregistration>| { 114 req.check(&state.cfg.regional_currency)?; 115 if unregister(&state.db, &req.authorization_pub, &Timestamp::now()).await? { 116 Ok(NoContent) 117 } else { 118 Err(failure_code(ErrorCode::BANK_TRANSACTION_NOT_FOUND)) 119 } 120 }, 121 ), 122 ) 123 } 124 125 #[cfg(test)] 126 mod test { 127 use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair as _}; 128 use sqlx::{ 129 Row as _, 130 postgres::{PgConnectOptions, PgRow}, 131 }; 132 use taler_api::db::TypeHelper as _; 133 use taler_common::{ 134 api::{ 135 EddsaPublicKey, EddsaSignature, 136 prepared::{ 137 PublicKeyAlg, RegistrationRequest, RegistrationResponse, TransferSubject, 138 TransferType, 139 }, 140 }, 141 db::IncomingType, 142 error_code::ErrorCode, 143 types::amount::{Amount, Currency}, 144 }; 145 use taler_macros::db_test; 146 use taler_test_utils::{ 147 json, 148 routine::{Status, registration_routine}, 149 server::TestServer as _, 150 }; 151 152 use crate::api::test::bank_setup; 153 154 #[db_test(raw)] 155 async fn registration(db: PgConnectOptions) { 156 let ctx = bank_setup(db).await; 157 158 let key_pair = Ed25519KeyPair::generate().unwrap(); 159 let auth_pub = EddsaPublicKey::try_from(key_pair.public_key().as_ref()).unwrap(); 160 let req = RegistrationRequest { 161 credit_account: ctx.exchange_payto.as_uri(), 162 r#type: TransferType::reserve, 163 recurrent: false, 164 credit_amount: Amount::new(&Currency::KUDOS, 1, 0), 165 alg: PublicKeyAlg::EdDSA, 166 account_pub: auth_pub, 167 authorization_pub: auth_pub, 168 authorization_sig: EddsaSignature::ZEROED, 169 } 170 .signed(&key_pair); 171 ctx.post("/taler-prepared-transfer/registration") 172 .json( 173 RegistrationRequest { 174 credit_account: ctx.customer_payto.as_uri(), 175 ..req.clone() 176 } 177 .signed(&key_pair), 178 ) 179 .await 180 .assert_error(ErrorCode::BANK_ACCOUNT_IS_NOT_EXCHANGE); 181 ctx.post("/taler-prepared-transfer/registration") 182 .json( 183 RegistrationRequest { 184 credit_account: ctx.unknown_payto.as_uri(), 185 ..req.clone() 186 } 187 .signed(&key_pair), 188 ) 189 .await 190 .assert_error(ErrorCode::BANK_UNKNOWN_CREDITOR); 191 192 ctx.set_max_debt("customer", "1000").await; 193 let admin = ctx.admin_router().await; 194 registration_routine( 195 &admin.prefix("/accounts/exchange/taler-wire-gateway"), 196 &admin.prefix("/taler-prepared-transfer"), 197 &ctx.customer_payto.as_uri(), 198 &ctx.exchange_payto.as_uri(), 199 &ctx.unknown_payto.as_uri(), 200 async || { 201 sqlx::query( 202 " 203 SELECT 204 pending_recurrent_incoming_transactions.authorization_pub IS NOT NULL, 205 exchange_incoming_id IS NULL, 206 type::text, 207 metadata 208 FROM bank_account_transactions 209 JOIN bank_accounts ON bank_account_transactions.bank_account_id=bank_accounts.bank_account_id 210 JOIN customers ON customer_id=owning_customer_id 211 LEFT JOIN taler_exchange_incoming ON (bank_transaction=bank_transaction_id) 212 LEFT JOIN pending_recurrent_incoming_transactions USING (bank_transaction_id) 213 WHERE username='exchange' AND direction='credit' 214 ORDER BY bank_transaction_id 215 ", 216 ) 217 .try_map(|r: PgRow| { 218 Ok( 219 if r.try_get_flag(0)? { 220 Status::Pending 221 } else if r.try_get_flag(1)? { 222 Status::Bounced 223 } else { 224 match r.try_get_opt_parse(2)? { 225 None => Status::Simple, 226 Some(IncomingType::reserve) => Status::Reserve(r.try_get(3)?), 227 Some(IncomingType::kyc) => Status::Kyc(r.try_get(3)?), 228 Some(e) => unreachable!("{e:?}") 229 } 230 } 231 ) 232 }) 233 .fetch_all(&ctx.state.db) 234 .await 235 .unwrap() 236 } 237 ) 238 .await; 239 240 ctx.assert_balance("customer", "-630").await; 241 ctx.assert_balance("exchange", "630").await; 242 let subject = &format!("Taler MAP:{auth_pub}"); 243 244 // Non recurrent accept on then bounce 245 let res = ctx 246 .post("/taler-prepared-transfer/registration") 247 .json(&req) 248 .await 249 .assert_ok_json::<RegistrationResponse>(); 250 let uuid = match &res.subjects[0] { 251 TransferSubject::Uri { uri, .. } => uri.as_str().rsplit('/').next().unwrap(), 252 _ => unreachable!(), 253 }; 254 255 ctx.get(format!("/withdrawals/{uuid}")).await.assert_ok(); 256 ctx.get(format!("/taler-integration/withdrawal-operation/{uuid}")) 257 .await 258 .assert_ok(); 259 ctx.posta(format!("/accounts/customer/withdrawals/{uuid}/confirm")) 260 .json(json!({})) 261 .await 262 .assert_no_content(); // reserve 263 ctx.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) 264 .json(json!({})) 265 .await 266 .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); 267 ctx.tx_s("customer", "1", "exchange", subject).await; // bounce 268 ctx.tx_s("customer", "1", "exchange", subject).await; // bounce 269 ctx.assert_balance("customer", "-631").await; 270 ctx.assert_balance("exchange", "631").await; 271 272 // Withdrawal is aborted on completion 273 let res = ctx 274 .post("/taler-prepared-transfer/registration") 275 .json( 276 RegistrationRequest { 277 r#type: TransferType::kyc, 278 ..req.clone() 279 } 280 .signed(&key_pair), 281 ) 282 .await 283 .assert_ok_json::<RegistrationResponse>(); 284 let uuid = match &res.subjects[0] { 285 TransferSubject::Uri { uri, .. } => uri.as_str().rsplit('/').next().unwrap(), 286 _ => unreachable!(), 287 }; 288 ctx.tx_s("customer", "1", "exchange", subject).await; // kyc 289 ctx.tx_s("customer", "1", "exchange", subject).await; // bounce 290 ctx.posta(format!("/accounts/customer/withdrawals/{uuid}/confirm")) 291 .json(json!({})) 292 .await 293 .assert_error(ErrorCode::BANK_CONFIRM_ABORT_CONFLICT); // Aborted 294 ctx.assert_balance("customer", "-632").await; 295 ctx.assert_balance("exchange", "632").await; 296 } 297 }