diff --git a/Cargo.lock b/Cargo.lock index 3cea3b20f..de0e6baa9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5147,11 +5147,7 @@ dependencies = [ "arc-swap", "bytes", "chrono", - "clap", - "color-eyre", "derive_more 2.0.1", - "dotenvy", - "eyre", "reqwest", "rust_decimal", "rust_iso3166", @@ -5160,8 +5156,6 @@ dependencies = [ "serde_json", "serde_with", "strum", - "tokio", - "tracing-subscriber", "utoipa", "uuid 1.18.1", ] diff --git a/apps/labrinth/.sqlx/query-76cf88116014e3151db2f85b0bd30dba70ae62f9f922ed711dbb85fcf4ec5f7c.json b/apps/labrinth/.sqlx/query-76cf88116014e3151db2f85b0bd30dba70ae62f9f922ed711dbb85fcf4ec5f7c.json new file mode 100644 index 000000000..e6a6c41d1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-76cf88116014e3151db2f85b0bd30dba70ae62f9f922ed711dbb85fcf4ec5f7c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE payouts\n SET status = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "76cf88116014e3151db2f85b0bd30dba70ae62f9f922ed711dbb85fcf4ec5f7c" +} diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 6920d146d..381e4f7b3 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -72,7 +72,7 @@ lettre = { workspace = true } meilisearch-sdk = { workspace = true, features = ["reqwest"] } modrinth-maxmind = { workspace = true } modrinth-util = { workspace = true, features = ["decimal", "utoipa"] } -muralpay = { workspace = true, features = ["mock", "utoipa"] } +muralpay = { workspace = true, features = ["client", "mock", "utoipa"] } murmur2 = { workspace = true } paste = { workspace = true } path-util = { workspace = true } diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs index 2b6398d51..945414db6 100644 --- a/apps/labrinth/src/background_task.rs +++ b/apps/labrinth/src/background_task.rs @@ -10,7 +10,6 @@ use crate::search::indexing::index_projects; use crate::util::anrok; use crate::{database, search}; use clap::ValueEnum; -use muralpay::MuralPay; use sqlx::Postgres; use tracing::{error, info, warn}; @@ -39,7 +38,7 @@ impl BackgroundTask { stripe_client: stripe::Client, anrok_client: anrok::Client, email_queue: EmailQueue, - mural_client: MuralPay, + mural_client: muralpay::Client, ) { use BackgroundTask::*; match self { @@ -207,7 +206,10 @@ pub async fn payouts( info!("Done running payouts"); } -pub async fn sync_payout_statuses(pool: sqlx::Pool, mural: MuralPay) { +pub async fn sync_payout_statuses( + pool: sqlx::Pool, + mural: muralpay::Client, +) { // Mural sets a max limit of 100 for search payouts endpoint const LIMIT: u32 = 100; diff --git a/apps/labrinth/src/models/v3/payouts.rs b/apps/labrinth/src/models/v3/payouts.rs index 7e2c2428e..7abbe36b9 100644 --- a/apps/labrinth/src/models/v3/payouts.rs +++ b/apps/labrinth/src/models/v3/payouts.rs @@ -150,7 +150,7 @@ pub struct TremendousForexResponse { #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct MuralPayDetails { pub payout_details: MuralPayoutRequest, - pub recipient_info: muralpay::PayoutRecipientInfo, + pub recipient_info: muralpay::CreatePayoutRecipientInfo, } impl PayoutMethodType { diff --git a/apps/labrinth/src/queue/payouts/mod.rs b/apps/labrinth/src/queue/payouts/mod.rs index 5fca223ed..46f10865a 100644 --- a/apps/labrinth/src/queue/payouts/mod.rs +++ b/apps/labrinth/src/queue/payouts/mod.rs @@ -21,7 +21,6 @@ use dashmap::DashMap; use eyre::{Result, eyre}; use futures::TryStreamExt; use modrinth_util::decimal::Decimal2dp; -use muralpay::MuralPay; use reqwest::Method; use rust_decimal::prelude::ToPrimitive; use rust_decimal::{Decimal, RoundingStrategy, dec}; @@ -48,7 +47,7 @@ pub struct PayoutsQueue { } pub struct MuralPayConfig { - pub client: MuralPay, + pub client: muralpay::Client, pub source_account_id: muralpay::AccountId, } @@ -77,11 +76,11 @@ impl Default for PayoutsQueue { } } -pub fn create_muralpay_client() -> Result { +pub fn create_muralpay_client() -> Result { let api_url = env_var("MURALPAY_API_URL")?; let api_key = env_var("MURALPAY_API_KEY")?; let transfer_api_key = env_var("MURALPAY_TRANSFER_API_KEY")?; - Ok(MuralPay::new(api_url, api_key, Some(transfer_api_key))) + Ok(muralpay::Client::new(api_url, api_key, transfer_api_key)) } pub fn create_muralpay() -> Result { diff --git a/apps/labrinth/src/queue/payouts/mural.rs b/apps/labrinth/src/queue/payouts/mural.rs index 01fe6ca48..d825bff3e 100644 --- a/apps/labrinth/src/queue/payouts/mural.rs +++ b/apps/labrinth/src/queue/payouts/mural.rs @@ -3,7 +3,7 @@ use chrono::Utc; use eyre::{Result, eyre}; use futures::{StreamExt, TryFutureExt, stream::FuturesUnordered}; use modrinth_util::decimal::Decimal2dp; -use muralpay::{MuralError, MuralPay, TokenFeeRequest}; +use muralpay::{MuralError, TokenFeeRequest}; use rust_decimal::{Decimal, prelude::ToPrimitive}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -69,7 +69,7 @@ impl PayoutsQueue { gross_amount: Decimal2dp, fees: PayoutFees, payout_details: MuralPayoutRequest, - recipient_info: muralpay::PayoutRecipientInfo, + recipient_info: muralpay::CreatePayoutRecipientInfo, gotenberg: &GotenbergClient, ) -> Result { let muralpay = self.muralpay.load(); @@ -183,36 +183,27 @@ impl PayoutsQueue { ), })?; - // try to immediately execute the payout request... - // use a poor man's try/catch block using this `async move {}` - // to catch any errors within this block - let result = async move { - muralpay - .client - .execute_payout_request(payout_request.id) - .await - .wrap_internal_err("failed to execute payout request")?; - eyre::Ok(()) - } - .await; - - // and if it fails, make sure to immediately cancel it - - // we don't want floating payout requests - if let Err(err) = result { - muralpay - .client - .cancel_payout_request(payout_request.id) - .await - .wrap_internal_err_with(|| { - eyre!("failed to cancel unexecuted payout request\noriginal error: {err:#?}") - })?; - return Err(ApiError::Internal(err)); - } - Ok(payout_request) } - pub async fn cancel_muralpay_payout_request( + pub async fn execute_mural_payout_request( + &self, + id: muralpay::PayoutRequestId, + ) -> Result<(), ApiError> { + let muralpay = self.muralpay.load(); + let muralpay = muralpay + .as_ref() + .wrap_internal_err("Mural Pay client not available")?; + + muralpay + .client + .execute_payout_request(id) + .await + .wrap_internal_err("failed to execute payout request")?; + Ok(()) + } + + pub async fn cancel_mural_payout_request( &self, id: muralpay::PayoutRequestId, ) -> Result<()> { @@ -263,7 +254,7 @@ impl PayoutsQueue { /// Mural state, and updates the payout status. pub async fn sync_pending_payouts_from_mural( db: &PgPool, - mural: &MuralPay, + mural: &muralpay::Client, limit: u32, ) -> eyre::Result<()> { #[derive(Debug)] @@ -369,9 +360,13 @@ pub async fn sync_pending_payouts_from_mural( /// Queries Mural for canceled or failed payouts, and updates the corresponding /// Labrinth payouts' statuses. +/// +/// This will update: +/// - Mural payout requests which are failed or canceled +/// - Mural payout requests where all of the payouts are failed or canceled pub async fn sync_failed_mural_payouts_to_labrinth( db: &PgPool, - mural: &MuralPay, + mural: &muralpay::Client, limit: u32, ) -> eyre::Result<()> { info!("Syncing failed Mural payouts to Labrinth"); @@ -380,12 +375,7 @@ pub async fn sync_failed_mural_payouts_to_labrinth( loop { let search_resp = mural .search_payout_requests( - Some(muralpay::PayoutStatusFilter::PayoutStatus { - statuses: vec![ - muralpay::PayoutStatus::Canceled, - muralpay::PayoutStatus::Failed, - ], - }), + None, Some(muralpay::SearchParams { limit: Some(u64::from(limit)), next_id, @@ -395,48 +385,51 @@ pub async fn sync_failed_mural_payouts_to_labrinth( .wrap_internal_err( "failed to fetch failed payout requests from Mural", )?; - next_id = search_resp.next_id; if search_resp.results.is_empty() { break; } - - let num_canceled = search_resp - .results - .iter() - .filter(|p| p.status == muralpay::PayoutStatus::Canceled) - .count(); - let num_failed = search_resp - .results - .iter() - .filter(|p| p.status == muralpay::PayoutStatus::Failed) - .count(); - info!( - "Found {num_canceled} canceled and {num_failed} failed Mural payouts" - ); + next_id = search_resp.next_id; let mut payout_platform_ids = Vec::::new(); let mut payout_new_statuses = Vec::::new(); - for payout_req in search_resp.results { - let new_payout_status = match payout_req.status { - muralpay::PayoutStatus::Canceled => PayoutStatus::Cancelled, - muralpay::PayoutStatus::Failed => PayoutStatus::Failed, - _ => { - warn!( - "Found payout {} with status {:?}, which should have been filtered out by our Mural request - Mural bug", - payout_req.id, payout_req.status + for payout_request in search_resp.results { + let payout_platform_id = payout_request.id; + + let new_payout_status = match payout_request.status { + muralpay::PayoutStatus::Canceled => { + trace!( + "- Payout request {payout_platform_id} set to {} because it is cancelled in Mural", + PayoutStatus::Cancelled ); - continue; + Some(PayoutStatus::Cancelled) } + muralpay::PayoutStatus::Failed => { + trace!( + "- Payout request {payout_platform_id} set to {} because it is failed in Mural", + PayoutStatus::Failed + ); + Some(PayoutStatus::Failed) + } + // this will also fail any payout request which has no payouts + _ if payout_request + .payouts + .iter() + .all(payout_should_be_failed) => + { + trace!( + "- Payout request {payout_platform_id} set to {} because all of its payouts are failed", + PayoutStatus::Failed + ); + Some(PayoutStatus::Failed) + } + _ => None, }; - let payout_platform_id = payout_req.id; - trace!( - "- Payout {payout_platform_id} set to {new_payout_status:?}", - ); - - payout_platform_ids.push(payout_platform_id.to_string()); - payout_new_statuses.push(new_payout_status.to_string()); + if let Some(new_payout_status) = new_payout_status { + payout_platform_ids.push(payout_platform_id.to_string()); + payout_new_statuses.push(new_payout_status.to_string()); + } } let result = sqlx::query!( @@ -470,6 +463,17 @@ pub async fn sync_failed_mural_payouts_to_labrinth( Ok(()) } +fn payout_should_be_failed(payout: &muralpay::Payout) -> bool { + matches!( + payout.details, + muralpay::PayoutDetails::Fiat(muralpay::FiatPayoutDetails { + fiat_payout_status: muralpay::FiatPayoutStatus::Failed { .. } + | muralpay::FiatPayoutStatus::Refunded { .. }, + .. + }) + ) +} + #[cfg(test)] mod tests { use super::*; @@ -477,8 +481,8 @@ mod tests { api_v3::ApiV3, environment::{TestEnvironment, with_test_environment}, }; - use muralpay::MuralPay; - use muralpay::mock::MuralPayMock; + use muralpay::MuralPayMock; + use rust_decimal::dec; fn create_mock_payout_request( id: &str, @@ -494,12 +498,51 @@ mod tests { transaction_hash: None, memo: None, status, - payouts: vec![], + payouts: vec![Payout { + id: PayoutId(uuid::Uuid::new_v4()), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + amount: TokenAmount { + token_amount: dec!(10.00), + token_symbol: "USDC".into(), + }, + details: PayoutDetails::Fiat(FiatPayoutDetails { + fiat_and_rail_code: FiatAndRailCode::Usd, + fiat_payout_status: FiatPayoutStatus::Pending { + initiated_at: chrono::Utc::now(), + }, + fiat_amount: FiatAmount { + fiat_amount: dec!(10.00), + fiat_currency_code: CurrencyCode::Usd, + }, + transaction_fee: TokenAmount { + token_amount: dec!(1.00), + token_symbol: "USDC".into(), + }, + exchange_fee_percentage: dec!(0.0), + exchange_rate: dec!(1.0), + fee_total: TokenAmount { + token_amount: dec!(1.00), + token_symbol: "USDC".into(), + }, + developer_fee: None, + }), + recipient_info: PayoutRecipientInfo::Inline { + name: "John Smith".into(), + details: InlineRecipientDetails::Fiat { + details: InlineFiatRecipientDetails { + fiat_currency_code: CurrencyCode::Usd, + bank_name: "Foo Bank".into(), + truncated_bank_account_number: "1234".into(), + }, + }, + }, + }], } } - fn create_mock_muralpay() -> MuralPay { - MuralPay::from_mock(MuralPayMock { + fn create_mock_muralpay() -> muralpay::Client { + muralpay::Client::from_mock(MuralPayMock { get_payout_request: Box::new(|_id| { Err(muralpay::MuralError::Api(muralpay::ApiError { error_instance_id: uuid::Uuid::new_v4(), @@ -643,7 +686,7 @@ mod tests { }) }); - let mock_client = MuralPay::from_mock(mock); + let mock_client = muralpay::Client::from_mock(mock); // Run the function let result = @@ -756,7 +799,7 @@ mod tests { }) }); - let mock_client = MuralPay::from_mock(mock); + let mock_client = muralpay::Client::from_mock(mock); // Run the function let result = @@ -818,7 +861,7 @@ mod tests { }) }); - let mock_client = MuralPay::from_mock(mock); + let mock_client = muralpay::Client::from_mock(mock); // Run the function - should handle this gracefully sync_failed_mural_payouts_to_labrinth( diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs index cec3f1299..3c83d87bf 100644 --- a/apps/labrinth/src/routes/v3/payouts.rs +++ b/apps/labrinth/src/routes/v3/payouts.rs @@ -28,7 +28,7 @@ use rust_decimal::{Decimal, RoundingStrategy}; use serde::{Deserialize, Serialize}; use serde_json::json; use sha2::Sha256; -use sqlx::PgPool; +use sqlx::{PgPool, PgTransaction}; use std::collections::HashMap; use tokio_stream::StreamExt; use tracing::error; @@ -623,29 +623,22 @@ pub async fn create_payout( total_fee: fees.total_fee(), sent_to_method, payouts_queue: &payouts_queue, + db: PgPool::clone(&pool), + transaction, }; - let payout_item = match &body.method { + match &body.method { PayoutMethodRequest::PayPal | PayoutMethodRequest::Venmo => { - paypal_payout(payout_cx).await? + paypal_payout(payout_cx).await?; } PayoutMethodRequest::Tremendous { method_details } => { - tremendous_payout(payout_cx, method_details).await? + tremendous_payout(payout_cx, method_details).await?; } PayoutMethodRequest::MuralPay { method_details } => { - mural_pay_payout(payout_cx, method_details, &gotenberg).await? + mural_pay_payout(payout_cx, method_details, &gotenberg).await?; } - }; + } - payout_item - .insert(&mut transaction) - .await - .wrap_internal_err("failed to insert payout")?; - - transaction - .commit() - .await - .wrap_internal_err("failed to commit transaction")?; crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis) .await .wrap_internal_err("failed to clear user caches")?; @@ -653,7 +646,6 @@ pub async fn create_payout( Ok(()) } -#[derive(Clone, Copy)] struct PayoutContext<'a> { body: &'a Withdrawal, user: &'a DBUser, @@ -666,6 +658,8 @@ struct PayoutContext<'a> { total_fee: Decimal2dp, sent_to_method: Decimal2dp, payouts_queue: &'a PayoutsQueue, + db: PgPool, + transaction: PgTransaction<'a>, } fn get_verified_email(user: &DBUser) -> Result<&str, ApiError> { @@ -692,12 +686,14 @@ async fn tremendous_payout( total_fee, sent_to_method, payouts_queue, + db: _, + mut transaction, }: PayoutContext<'_>, TremendousDetails { delivery_email, currency, }: &TremendousDetails, -) -> Result { +) -> Result<(), ApiError> { let user_email = get_verified_email(user)?; #[derive(Deserialize)] @@ -773,7 +769,7 @@ async fn tremendous_payout( let platform_id = res.order.rewards.first().map(|reward| reward.id.clone()); - Ok(DBPayout { + DBPayout { id: payout_id, user_id: user.id, created: Utc::now(), @@ -784,7 +780,17 @@ async fn tremendous_payout( method_id: Some(body.method_id.clone()), method_address: Some(user_email.to_string()), platform_id, - }) + } + .insert(&mut transaction) + .await + .wrap_internal_err("failed to insert payout")?; + + transaction + .commit() + .await + .wrap_internal_err("failed to commit transaction")?; + + Ok(()) } async fn mural_pay_payout( @@ -798,12 +804,35 @@ async fn mural_pay_payout( total_fee, sent_to_method: _, payouts_queue, + db, + mut transaction, }: PayoutContext<'_>, details: &MuralPayDetails, gotenberg: &GotenbergClient, -) -> Result { +) -> Result<(), ApiError> { let user_email = get_verified_email(user)?; + let method_id = match &details.payout_details { + MuralPayoutRequest::Blockchain { .. } => { + "blockchain-usdc-polygon".to_string() + } + MuralPayoutRequest::Fiat { + fiat_and_rail_details, + .. + } => fiat_and_rail_details.code().to_string(), + }; + + // Once the Mural payout request has been created successfully, + // then we *must* commit the payout into the DB, + // to link the Mural payout request to the `payout` row. + // Even if we can't execute the payout. + // For this, we immediately insert and commit the txn. + // Otherwise if we don't put it into the DB, we've got a ghost Mural + // payout with no related database entry. + // + // However, this doesn't mean that the payout will definitely go through. + // For this, we need to execute it, and handle errors. + let payout_request = payouts_queue .create_muralpay_payout_request( payout_id, @@ -816,22 +845,13 @@ async fn mural_pay_payout( ) .await?; - let method_id = match &details.payout_details { - MuralPayoutRequest::Blockchain { .. } => { - "blockchain-usdc-polygon".to_string() - } - MuralPayoutRequest::Fiat { - fiat_and_rail_details, - .. - } => fiat_and_rail_details.code().to_string(), - }; - - Ok(DBPayout { + let payout = DBPayout { id: payout_id, user_id: user.id, created: Utc::now(), // after the payout has been successfully executed, // we wait for Mural's confirmation that the funds have been delivered + // done in `SyncPayoutStatuses` background task status: PayoutStatus::InTransit, amount: amount_minus_fee.get(), fee: Some(total_fee.get()), @@ -839,7 +859,61 @@ async fn mural_pay_payout( method_id: Some(method_id), method_address: Some(user_email.to_string()), platform_id: Some(payout_request.id.to_string()), - }) + }; + payout + .insert(&mut transaction) + .await + .wrap_internal_err("failed to insert payout")?; + + transaction + .commit() + .await + .wrap_internal_err("failed to commit payout insert transaction")?; + + // try to immediately execute the payout request... + // use a poor man's try/catch block using this `async move {}` + // to catch any errors within this block + let result = async move { + payouts_queue + .execute_mural_payout_request(payout_request.id) + .await + .wrap_internal_err("failed to execute payout request")?; + eyre::Ok(()) + } + .await; + + // and if it fails, make sure to immediately cancel it - + // we don't want floating payout requests + if let Err(err) = result { + if let Err(err) = sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE id = $2 + ", + PayoutStatus::Failed.as_str(), + payout.id as _, + ) + .execute(&db) + .await + { + error!( + "Created a Mural payout request, but failed to execute it, \ + and failed to mark the payout as failed: {err:#?}" + ); + } + + payouts_queue + .cancel_mural_payout_request(payout_request.id) + .await + .wrap_internal_err_with(|| { + eyre!("failed to cancel unexecuted payout request\noriginal error: {err:#?}") + })?; + + return Err(ApiError::Internal(err)); + } + + Ok(()) } async fn paypal_payout( @@ -853,8 +927,10 @@ async fn paypal_payout( total_fee, sent_to_method, payouts_queue, + db: _, + mut transaction, }: PayoutContext<'_>, -) -> Result { +) -> Result<(), ApiError> { let (wallet, wallet_type, address, display_address) = if matches!(body.method, PayoutMethodRequest::Venmo) { if let Some(venmo) = &user.venmo_handle { @@ -965,7 +1041,7 @@ async fn paypal_payout( let platform_id = Some(data.payout_item_id.clone()); - Ok(DBPayout { + DBPayout { id: payout_id, user_id: user.id, created: Utc::now(), @@ -976,7 +1052,17 @@ async fn paypal_payout( method_id: Some(body.method_id.clone()), method_address: Some(display_address.clone()), platform_id, - }) + } + .insert(&mut transaction) + .await + .wrap_internal_err("failed to insert payout")?; + + transaction + .commit() + .await + .wrap_internal_err("failed to commit transaction")?; + + Ok(()) } /// User performing a payout-related action. @@ -1201,7 +1287,7 @@ pub async fn cancel_payout( .parse::() .wrap_request_err("invalid payout request ID")?; payouts - .cancel_muralpay_payout_request(payout_request_id) + .cancel_mural_payout_request(payout_request_id) .await .wrap_internal_err( "failed to cancel payout request", diff --git a/packages/muralpay/Cargo.toml b/packages/muralpay/Cargo.toml index 35466bd09..fa6cfa26f 100644 --- a/packages/muralpay/Cargo.toml +++ b/packages/muralpay/Cargo.toml @@ -3,7 +3,6 @@ name = "muralpay" version = "0.1.0" edition.workspace = true description = "Mural Pay API" -repository = "https://github.com/modrinth/code/" license = "MIT" keywords = [] categories = ["api-bindings"] @@ -18,26 +17,19 @@ derive_more = { workspace = true, features = [ "error", "from", ] } -reqwest = { workspace = true, features = ["default-tls", "http2", "json"] } -rust_decimal = { workspace = true, features = ["macros"] } +reqwest = { workspace = true, features = ["default-tls", "http2", "json"], optional = true } +rust_decimal = { workspace = true, features = ["macros", "serde-with-float"] } rust_iso3166 = { workspace = true } -secrecy = { workspace = true } +secrecy = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_with = { workspace = true } strum = { workspace = true, features = ["derive"] } -utoipa = { workspace = true, features = ["uuid"], optional = true } +utoipa = { workspace = true, features = ["chrono", "decimal", "uuid"], optional = true } uuid = { workspace = true, features = ["serde"] } -[dev-dependencies] -clap = { workspace = true, features = ["derive"] } -color-eyre = { workspace = true } -dotenvy = { workspace = true } -eyre = { workspace = true } -tokio = { workspace = true, features = ["full"] } -tracing-subscriber = { workspace = true } - [features] +client = ["dep:reqwest", "dep:secrecy"] mock = ["dep:arc-swap"] utoipa = ["dep:utoipa"] diff --git a/packages/muralpay/examples/muralpay.rs b/packages/muralpay/examples/muralpay.rs deleted file mode 100644 index 1af7f7bb6..000000000 --- a/packages/muralpay/examples/muralpay.rs +++ /dev/null @@ -1,330 +0,0 @@ -use std::{env, fmt::Debug, io}; - -use eyre::{Result, WrapErr, eyre}; -use muralpay::{ - AccountId, CounterpartyId, CreatePayout, CreatePayoutDetails, Dob, - FiatAccountType, FiatAndRailCode, FiatAndRailDetails, FiatFeeRequest, - FiatPayoutFee, MuralPay, PayoutMethodId, PayoutRecipientInfo, - PayoutRequestId, PhysicalAddress, TokenAmount, TokenFeeRequest, - TokenPayoutFee, UsdSymbol, -}; -use rust_decimal::{Decimal, dec}; -use serde::Serialize; - -#[derive(Debug, clap::Parser)] -struct Args { - #[arg(short, long)] - output: Option, - #[clap(subcommand)] - command: Command, -} - -#[derive(Debug, clap::Subcommand)] -enum Command { - /// Account listing and management - Account { - #[command(subcommand)] - command: AccountCommand, - }, - /// Payouts and payout requests - Payout { - #[command(subcommand)] - command: PayoutCommand, - }, - /// Counterparty management - Counterparty { - #[command(subcommand)] - command: CounterpartyCommand, - }, - /// Payout method management - PayoutMethod { - #[command(subcommand)] - command: PayoutMethodCommand, - }, -} - -#[derive(Debug, clap::Subcommand)] -enum AccountCommand { - /// List all accounts - #[clap(alias = "ls")] - List, -} - -#[derive(Debug, clap::Subcommand)] -enum PayoutCommand { - /// List all payout requests - #[clap(alias = "ls")] - List, - /// Get details for a single payout request - Get { - /// ID of the payout request - payout_request_id: PayoutRequestId, - }, - /// Create a payout request - Create { - /// ID of the Mural account to send from - source_account_id: AccountId, - /// Description for this payout request - memo: Option, - }, - /// Get fees for a transaction - Fees { - #[command(subcommand)] - command: PayoutFeesCommand, - }, - /// Get bank details for a fiat and rail code - BankDetails { - /// Fiat and rail code to fetch bank details for - fiat_and_rail_code: FiatAndRailCode, - }, -} - -#[derive(Debug, clap::Subcommand)] -enum PayoutFeesCommand { - /// Get fees for a token-to-fiat transaction - Token { - amount: Decimal, - fiat_and_rail_code: FiatAndRailCode, - }, - /// Get fees for a fiat-to-token transaction - Fiat { - amount: Decimal, - fiat_and_rail_code: FiatAndRailCode, - }, -} - -#[derive(Debug, clap::Subcommand)] -enum CounterpartyCommand { - /// List all counterparties - #[clap(alias = "ls")] - List, -} - -#[derive(Debug, clap::Subcommand)] -enum PayoutMethodCommand { - /// List payout methods for a counterparty - #[clap(alias = "ls")] - List { - /// ID of the counterparty - counterparty_id: CounterpartyId, - }, - /// Delete a payout method - Delete { - /// ID of the counterparty - counterparty_id: CounterpartyId, - /// ID of the payout method to delete - payout_method_id: PayoutMethodId, - }, -} - -#[derive(Debug, Clone, Copy, clap::ValueEnum)] -enum OutputFormat { - Json, - JsonMin, -} - -#[tokio::main] -async fn main() -> Result<()> { - _ = dotenvy::dotenv(); - color_eyre::install().expect("failed to install `color-eyre`"); - tracing_subscriber::fmt().init(); - - let args = ::parse(); - let of = args.output; - - let api_url = env::var("MURALPAY_API_URL") - .unwrap_or_else(|_| muralpay::SANDBOX_API_URL.to_string()); - let api_key = env::var("MURALPAY_API_KEY").wrap_err("no API key")?; - let transfer_api_key = env::var("MURALPAY_TRANSFER_API_KEY").ok(); - - let muralpay = MuralPay::new(api_url, api_key, transfer_api_key); - - match args.command { - Command::Account { - command: AccountCommand::List, - } => run(of, muralpay.get_all_accounts().await?), - Command::Payout { - command: PayoutCommand::List, - } => run(of, muralpay.search_payout_requests(None, None).await?), - Command::Payout { - command: PayoutCommand::Get { payout_request_id }, - } => run(of, muralpay.get_payout_request(payout_request_id).await?), - Command::Payout { - command: - PayoutCommand::Create { - source_account_id, - memo, - }, - } => run( - of, - create_payout_request( - &muralpay, - source_account_id, - memo.as_deref(), - ) - .await?, - ), - Command::Payout { - command: - PayoutCommand::Fees { - command: - PayoutFeesCommand::Token { - amount, - fiat_and_rail_code, - }, - }, - } => run( - of, - get_fees_for_token_amount(&muralpay, amount, fiat_and_rail_code) - .await?, - ), - Command::Payout { - command: - PayoutCommand::Fees { - command: - PayoutFeesCommand::Fiat { - amount, - fiat_and_rail_code, - }, - }, - } => run( - of, - get_fees_for_fiat_amount(&muralpay, amount, fiat_and_rail_code) - .await?, - ), - Command::Payout { - command: PayoutCommand::BankDetails { fiat_and_rail_code }, - } => run(of, muralpay.get_bank_details(&[fiat_and_rail_code]).await?), - Command::Counterparty { - command: CounterpartyCommand::List, - } => run(of, list_counterparties(&muralpay).await?), - Command::PayoutMethod { - command: PayoutMethodCommand::List { counterparty_id }, - } => run( - of, - muralpay - .search_payout_methods(counterparty_id, None) - .await?, - ), - Command::PayoutMethod { - command: - PayoutMethodCommand::Delete { - counterparty_id, - payout_method_id, - }, - } => run( - of, - muralpay - .delete_payout_method(counterparty_id, payout_method_id) - .await?, - ), - } - - Ok(()) -} - -async fn create_payout_request( - muralpay: &MuralPay, - source_account_id: AccountId, - memo: Option<&str>, -) -> Result<()> { - muralpay - .create_payout_request( - source_account_id, - memo, - &[CreatePayout { - amount: TokenAmount { - token_amount: dec!(2.00), - token_symbol: muralpay::USDC.into(), - }, - payout_details: CreatePayoutDetails::Fiat { - bank_name: "Foo Bank".into(), - bank_account_owner: "John Smith".into(), - developer_fee: None, - fiat_and_rail_details: FiatAndRailDetails::Usd { - symbol: UsdSymbol::Usd, - account_type: FiatAccountType::Checking, - bank_account_number: "123456789".into(), - // idk what the format is, https://wise.com/us/routing-number/bank/us-bank - bank_routing_number: "071004200".into(), - }, - }, - recipient_info: PayoutRecipientInfo::Individual { - first_name: "John".into(), - last_name: "Smith".into(), - email: "john.smith@example.com".into(), - date_of_birth: Dob::new(1970, 1, 1).unwrap(), - physical_address: PhysicalAddress { - address1: "1234 Elm Street".into(), - address2: Some("Apt 56B".into()), - country: rust_iso3166::US, - state: "CA".into(), - city: "Springfield".into(), - zip: "90001".into(), - }, - }, - supporting_details: None, - }], - ) - .await?; - Ok(()) -} - -async fn get_fees_for_token_amount( - muralpay: &MuralPay, - amount: Decimal, - fiat_and_rail_code: FiatAndRailCode, -) -> Result { - let fees = muralpay - .get_fees_for_token_amount(&[TokenFeeRequest { - amount: TokenAmount { - token_amount: amount, - token_symbol: muralpay::USDC.into(), - }, - fiat_and_rail_code, - }]) - .await?; - let fee = fees - .into_iter() - .next() - .ok_or_else(|| eyre!("no fee results returned"))?; - Ok(fee) -} - -async fn get_fees_for_fiat_amount( - muralpay: &MuralPay, - amount: Decimal, - fiat_and_rail_code: FiatAndRailCode, -) -> Result { - let fees = muralpay - .get_fees_for_fiat_amount(&[FiatFeeRequest { - fiat_amount: amount, - token_symbol: muralpay::USDC.into(), - fiat_and_rail_code, - }]) - .await?; - let fee = fees - .into_iter() - .next() - .ok_or_else(|| eyre!("no fee results returned"))?; - Ok(fee) -} - -async fn list_counterparties(muralpay: &MuralPay) -> Result<()> { - let _counterparties = muralpay.search_counterparties(None).await?; - Ok(()) -} - -fn run(output_format: Option, value: T) { - match output_format { - None => { - println!("{value:#?}"); - } - Some(OutputFormat::Json) => { - _ = serde_json::to_writer_pretty(io::stdout(), &value) - } - Some(OutputFormat::JsonMin) => { - _ = serde_json::to_writer(io::stdout(), &value); - } - } -} diff --git a/packages/muralpay/src/account.rs b/packages/muralpay/src/account.rs index f0a911533..7a124b904 100644 --- a/packages/muralpay/src/account.rs +++ b/packages/muralpay/src/account.rs @@ -1,83 +1,65 @@ -use std::str::FromStr; - -use chrono::{DateTime, Utc}; -use derive_more::{Deref, Display}; -use rust_decimal::Decimal; -use secrecy::ExposeSecret; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{ - Blockchain, FiatAmount, MuralError, MuralPay, TokenAmount, WalletDetails, - util::RequestExt, +use { + crate::{Blockchain, FiatAmount, TokenAmount, WalletDetails}, + chrono::{DateTime, Utc}, + derive_more::{Deref, Display}, + rust_decimal::Decimal, + serde::{Deserialize, Serialize}, + std::str::FromStr, + uuid::Uuid, }; -impl MuralPay { - pub async fn get_all_accounts(&self) -> Result, MuralError> { - mock!(self, get_all_accounts()); +#[cfg(feature = "client")] +const _: () = { + use crate::{MuralError, RequestExt}; - self.http_get(|base| format!("{base}/api/accounts")) - .send_mural() - .await - } + impl crate::Client { + pub async fn get_all_accounts(&self) -> Result, MuralError> { + maybe_mock!(self, get_all_accounts()); - pub async fn get_account( - &self, - id: AccountId, - ) -> Result { - mock!(self, get_account(id)); - - self.http_get(|base| format!("{base}/api/accounts/{id}")) - .send_mural() - .await - } - - pub async fn create_account( - &self, - name: impl AsRef, - description: Option>, - ) -> Result { - mock!( - self, - create_account( - name.as_ref(), - description.as_ref().map(|x| x.as_ref()), - ) - ); - - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - struct Body<'a> { - name: &'a str, - description: Option<&'a str>, + self.http_get(|base| format!("{base}/api/accounts")) + .send_mural() + .await } - let body = Body { - name: name.as_ref(), - description: description.as_ref().map(|x| x.as_ref()), - }; + pub async fn get_account(&self, id: AccountId) -> Result { + maybe_mock!(self, get_account(id)); - self.http - .post(format!("{}/api/accounts", self.api_url)) - .bearer_auth(self.api_key.expose_secret()) - .json(&body) - .send_mural() - .await + self.http_get(|base| format!("{base}/api/accounts/{id}")) + .send_mural() + .await + } + + pub async fn create_account( + &self, + name: impl AsRef, + description: Option>, + ) -> Result { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + name: &'a str, + description: Option<&'a str>, + } + + maybe_mock!( + self, + create_account(name.as_ref(), description.as_ref().map(AsRef::as_ref)) + ); + + let body = Body { + name: name.as_ref(), + description: description.as_ref().map(AsRef::as_ref), + }; + + self.http_post(|base| format!("{base}/api/accounts")) + .json(&body) + .send_mural() + .await + } } -} +}; -#[derive( - Debug, - Display, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Deref, - Serialize, - Deserialize, -)] +#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[display("{}", _0.hyphenated())] pub struct AccountId(pub Uuid); @@ -90,6 +72,12 @@ impl FromStr for AccountId { } } +impl From for Uuid { + fn from(value: AccountId) -> Self { + value.0 + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] diff --git a/packages/muralpay/src/error.rs b/packages/muralpay/src/client/error.rs similarity index 58% rename from packages/muralpay/src/error.rs rename to packages/muralpay/src/client/error.rs index e7d9790a5..507f73689 100644 --- a/packages/muralpay/src/error.rs +++ b/packages/muralpay/src/client/error.rs @@ -1,9 +1,10 @@ -use std::{collections::HashMap, fmt}; - -use bytes::Bytes; -use derive_more::{Display, Error, From}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; +use { + bytes::Bytes, + derive_more::{Display, Error, From}, + serde::{Deserialize, Serialize}, + std::{collections::HashMap, fmt}, + uuid::Uuid, +}; #[derive(Debug, Display, Error, From)] pub enum MuralError { @@ -27,43 +28,6 @@ pub enum MuralError { pub type Result = std::result::Result; -#[derive(Debug, Display, Error, From)] -pub enum TransferError { - #[display("no transfer API key")] - NoTransferKey, - #[display("API error")] - Api(Box), - #[display("request error")] - Request(reqwest::Error), - #[display("failed to decode response\n{json:?}")] - #[from(skip)] - Decode { - source: serde_json::Error, - json: Bytes, - }, - #[display("failed to decode error response\n{json:?}")] - #[from(skip)] - DecodeError { - source: serde_json::Error, - json: Bytes, - }, -} - -impl From for TransferError { - fn from(value: MuralError) -> Self { - match value { - MuralError::Api(x) => Self::Api(Box::new(x)), - MuralError::Request(x) => Self::Request(x), - MuralError::Decode { source, json } => { - Self::Decode { source, json } - } - MuralError::DecodeError { source, json } => { - Self::DecodeError { source, json } - } - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize, Error)] #[serde(rename_all = "camelCase")] pub struct ApiError { @@ -96,7 +60,7 @@ where impl fmt::Display for ApiError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut lines = vec![self.message.to_string()]; + let mut lines = vec![self.message.clone()]; if !self.details.is_empty() { lines.push("details:".into()); @@ -105,8 +69,7 @@ impl fmt::Display for ApiError { if !self.params.is_empty() { lines.push("params:".into()); - lines - .extend(self.params.iter().map(|(k, v)| format!("- {k}: {v}"))); + lines.extend(self.params.iter().map(|(k, v)| format!("- {k}: {v}"))); } lines.push(format!("error name: {}", self.name)); diff --git a/packages/muralpay/src/mock.rs b/packages/muralpay/src/client/mock.rs similarity index 78% rename from packages/muralpay/src/mock.rs rename to packages/muralpay/src/client/mock.rs index e3761cfd8..c06e4231a 100644 --- a/packages/muralpay/src/mock.rs +++ b/packages/muralpay/src/client/mock.rs @@ -1,14 +1,15 @@ //! See [`MuralPayMock`]. -use std::fmt::{self, Debug}; - -use crate::{ - Account, AccountId, BankDetailsResponse, Counterparty, CounterpartyId, - CreateCounterparty, CreatePayout, FiatAndRailCode, FiatFeeRequest, - FiatPayoutFee, MuralError, Organization, OrganizationId, PayoutMethod, - PayoutMethodDetails, PayoutMethodId, PayoutRequest, PayoutRequestId, - PayoutStatusFilter, SearchParams, SearchRequest, SearchResponse, - TokenFeeRequest, TokenPayoutFee, TransferError, UpdateCounterparty, +use { + crate::{ + Account, AccountId, BankDetailsResponse, Counterparty, CounterpartyId, CreateCounterparty, + CreatePayout, FiatAndRailCode, FiatFeeRequest, FiatPayoutFee, MuralError, Organization, + OrganizationId, PayoutMethod, PayoutMethodDetails, PayoutMethodId, PayoutRequest, + PayoutRequestId, PayoutStatusFilter, SearchParams, SearchRequest, SearchResponse, + TokenFeeRequest, TokenPayoutFee, UpdateCounterparty, + transaction::{Transaction, TransactionId}, + }, + std::fmt::{self, Debug}, }; macro_rules! impl_mock { @@ -43,8 +44,8 @@ impl_mock! { fn get_fees_for_token_amount(&[TokenFeeRequest]) -> Result, MuralError>; fn get_fees_for_fiat_amount(&[FiatFeeRequest]) -> Result, MuralError>; fn create_payout_request(AccountId, Option<&str>, &[CreatePayout]) -> Result; - fn execute_payout_request(PayoutRequestId) -> Result; - fn cancel_payout_request(PayoutRequestId) -> Result; + fn execute_payout_request(PayoutRequestId) -> Result; + fn cancel_payout_request(PayoutRequestId) -> Result; fn get_bank_details(&[FiatAndRailCode]) -> Result; fn search_payout_methods(CounterpartyId, Option>) -> Result, MuralError>; fn get_payout_method(CounterpartyId, PayoutMethodId) -> Result; @@ -56,6 +57,8 @@ impl_mock! { fn get_counterparty(CounterpartyId) -> Result; fn create_counterparty(&CreateCounterparty) -> Result; fn update_counterparty(CounterpartyId, &UpdateCounterparty) -> Result; + fn get_transaction(TransactionId) -> Result; + fn search_transactions(AccountId, Option>) -> Result, MuralError>; } impl Debug for MuralPayMock { diff --git a/packages/muralpay/src/client/mod.rs b/packages/muralpay/src/client/mod.rs new file mode 100644 index 000000000..2af188553 --- /dev/null +++ b/packages/muralpay/src/client/mod.rs @@ -0,0 +1,122 @@ +mod error; +pub use error::*; +use { + reqwest::{IntoUrl, RequestBuilder}, + secrecy::{ExposeSecret, SecretString}, +}; + +#[cfg(feature = "mock")] +mod mock; +#[cfg(feature = "mock")] +pub use mock::MuralPayMock; +use serde::de::DeserializeOwned; + +#[derive(Debug, Clone)] +pub struct Client { + pub http: reqwest::Client, + pub api_url: String, + pub api_key: SecretString, + pub transfer_api_key: SecretString, + #[cfg(feature = "mock")] + pub mock: std::sync::Arc>, +} + +impl Client { + pub fn new( + api_url: impl Into, + api_key: impl Into, + transfer_api_key: impl Into, + ) -> Self { + Self { + http: reqwest::Client::new(), + api_url: api_url.into(), + api_key: api_key.into(), + transfer_api_key: transfer_api_key.into(), + #[cfg(feature = "mock")] + mock: std::sync::Arc::new(arc_swap::ArcSwapOption::empty()), + } + } + + /// Creates a client which mocks responses. + #[cfg(feature = "mock")] + #[must_use] + pub fn from_mock(mock: mock::MuralPayMock) -> Self { + Self { + http: reqwest::Client::new(), + api_url: String::new(), + api_key: SecretString::from(String::new()), + transfer_api_key: SecretString::from(String::new()), + mock: std::sync::Arc::new(arc_swap::ArcSwapOption::from_pointee(mock)), + } + } + + fn http_req(&self, make_req: impl FnOnce() -> RequestBuilder) -> RequestBuilder { + make_req() + .bearer_auth(self.api_key.expose_secret()) + .header("accept", "application/json") + .header("content-type", "application/json") + } + + pub(crate) fn http_get(&self, make_url: impl FnOnce(&str) -> U) -> RequestBuilder { + self.http_req(|| self.http.get(make_url(&self.api_url))) + } + + pub(crate) fn http_post(&self, make_url: impl FnOnce(&str) -> U) -> RequestBuilder { + self.http_req(|| self.http.post(make_url(&self.api_url))) + } + + pub(crate) fn http_put(&self, make_url: impl FnOnce(&str) -> U) -> RequestBuilder { + self.http_req(|| self.http.put(make_url(&self.api_url))) + } + + pub(crate) fn http_delete( + &self, + make_url: impl FnOnce(&str) -> U, + ) -> RequestBuilder { + self.http_req(|| self.http.delete(make_url(&self.api_url))) + } + + pub async fn health(&self) -> reqwest::Result<()> { + self.http_get(|base| format!("{base}/api/health")) + .send() + .await? + .error_for_status()?; + Ok(()) + } +} + +pub trait RequestExt: Sized { + #[must_use] + fn transfer_auth(self, client: &Client) -> Self; + + fn send_mural( + self, + ) -> impl Future> + Send + Sync; +} + +const HEADER_TRANSFER_API_KEY: &str = "transfer-api-key"; + +impl RequestExt for reqwest::RequestBuilder { + fn transfer_auth(self, client: &Client) -> Self { + self.header( + HEADER_TRANSFER_API_KEY, + client.transfer_api_key.expose_secret(), + ) + } + + async fn send_mural(self) -> crate::Result { + let resp = self.send().await?; + let status = resp.status(); + if status.is_client_error() || status.is_server_error() { + let json = resp.bytes().await?; + let err = serde_json::from_slice::(&json) + .map_err(|source| MuralError::DecodeError { source, json })?; + Err(MuralError::Api(err)) + } else { + let json = resp.bytes().await?; + let t = serde_json::from_slice::(&json) + .map_err(|source| MuralError::Decode { source, json })?; + Ok(t) + } + } +} diff --git a/packages/muralpay/src/counterparty.rs b/packages/muralpay/src/counterparty.rs index a53bd72be..a7fae8ac5 100644 --- a/packages/muralpay/src/counterparty.rs +++ b/packages/muralpay/src/counterparty.rs @@ -1,96 +1,84 @@ -use chrono::{DateTime, Utc}; -use derive_more::{Deref, Display}; -use serde::{Deserialize, Serialize}; -use std::str::FromStr; -use uuid::Uuid; - -use crate::{ - MuralError, MuralPay, PhysicalAddress, SearchParams, SearchResponse, - util::RequestExt, +use { + crate::PhysicalAddress, + chrono::{DateTime, Utc}, + derive_more::{Deref, Display}, + serde::{Deserialize, Serialize}, + std::str::FromStr, + uuid::Uuid, }; -impl MuralPay { - pub async fn search_counterparties( - &self, - params: Option>, - ) -> Result, MuralError> { - mock!(self, search_counterparties(params)); +#[cfg(feature = "client")] +const _: () = { + use crate::{MuralError, RequestExt, SearchParams, SearchResponse}; - self.http_post(|base| format!("{base}/api/counterparties/search")) - .query(¶ms.map(|p| p.to_query()).unwrap_or_default()) - .send_mural() - .await - } + impl crate::Client { + pub async fn search_counterparties( + &self, + params: Option>, + ) -> Result, MuralError> { + maybe_mock!(self, search_counterparties(params)); - pub async fn get_counterparty( - &self, - id: CounterpartyId, - ) -> Result { - mock!(self, get_counterparty(id)); - - self.http_get(|base| { - format!("{base}/api/counterparties/counterparty/{id}") - }) - .send_mural() - .await - } - - pub async fn create_counterparty( - &self, - counterparty: &CreateCounterparty, - ) -> Result { - mock!(self, create_counterparty(counterparty)); - - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - struct Body<'a> { - counterparty: &'a CreateCounterparty, + self.http_post(|base| format!("{base}/api/counterparties/search")) + .query(¶ms.map(|p| p.to_query()).unwrap_or_default()) + .send_mural() + .await } - let body = Body { counterparty }; + pub async fn get_counterparty( + &self, + id: CounterpartyId, + ) -> Result { + maybe_mock!(self, get_counterparty(id)); - self.http_post(|base| format!("{base}/api/counterparties")) - .json(&body) - .send_mural() - .await - } - - pub async fn update_counterparty( - &self, - id: CounterpartyId, - counterparty: &UpdateCounterparty, - ) -> Result { - mock!(self, update_counterparty(id, counterparty)); - - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - struct Body<'a> { - counterparty: &'a UpdateCounterparty, + self.http_get(|base| format!("{base}/api/counterparties/counterparty/{id}")) + .send_mural() + .await } - let body = Body { counterparty }; + pub async fn create_counterparty( + &self, + counterparty: &CreateCounterparty, + ) -> Result { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + counterparty: &'a CreateCounterparty, + } - self.http_put(|base| { - format!("{base}/api/counterparties/counterparty/{id}") - }) - .json(&body) - .send_mural() - .await + maybe_mock!(self, create_counterparty(counterparty)); + + let body = Body { counterparty }; + + self.http_post(|base| format!("{base}/api/counterparties")) + .json(&body) + .send_mural() + .await + } + + pub async fn update_counterparty( + &self, + id: CounterpartyId, + counterparty: &UpdateCounterparty, + ) -> Result { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + counterparty: &'a UpdateCounterparty, + } + + maybe_mock!(self, update_counterparty(id, counterparty)); + + let body = Body { counterparty }; + + self.http_put(|base| format!("{base}/api/counterparties/counterparty/{id}")) + .json(&body) + .send_mural() + .await + } } -} +}; -#[derive( - Debug, - Display, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Deref, - Serialize, - Deserialize, -)] +#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[display("{}", _0.hyphenated())] pub struct CounterpartyId(pub Uuid); @@ -103,6 +91,12 @@ impl FromStr for CounterpartyId { } } +impl From for Uuid { + fn from(value: CounterpartyId) -> Self { + value.0 + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] diff --git a/packages/muralpay/src/lib.rs b/packages/muralpay/src/lib.rs index 4d38f9247..1dcdfcca4 100644 --- a/packages/muralpay/src/lib.rs +++ b/packages/muralpay/src/lib.rs @@ -1,6 +1,7 @@ #![doc = include_str!("../README.md")] -macro_rules! mock { +#[cfg(feature = "client")] +macro_rules! maybe_mock { ($self:expr, $fn:ident ( $($args:expr),* $(,)? )) => { #[cfg(feature = "mock")] if let Some(mock) = &*($self).mock.load() { @@ -11,26 +12,28 @@ macro_rules! mock { mod account; mod counterparty; -mod error; mod organization; mod payout; mod payout_method; mod serde_iso3166; +mod transaction; mod util; -#[cfg(feature = "mock")] -pub mod mock; - pub use { - account::*, counterparty::*, error::*, organization::*, payout::*, - payout_method::*, + account::*, counterparty::*, organization::*, payout::*, payout_method::*, + transaction::*, +}; +use { + rust_decimal::Decimal, + serde::{Deserialize, Serialize}, + std::{ops::Deref, str::FromStr}, + uuid::Uuid, }; -use rust_decimal::Decimal; -use secrecy::SecretString; -use serde::{Deserialize, Serialize}; -use std::{ops::Deref, str::FromStr}; -use uuid::Uuid; +#[cfg(feature = "client")] +mod client; +#[cfg(feature = "client")] +pub use client::*; pub const API_URL: &str = "https://api.muralpay.com"; pub const SANDBOX_API_URL: &str = "https://api-staging.muralpay.com"; @@ -38,46 +41,6 @@ pub const SANDBOX_API_URL: &str = "https://api-staging.muralpay.com"; /// Default token symbol for [`TokenAmount::token_symbol`] values. pub const USDC: &str = "USDC"; -#[derive(Debug)] -pub struct MuralPay { - pub http: reqwest::Client, - pub api_url: String, - pub api_key: SecretString, - pub transfer_api_key: Option, - #[cfg(feature = "mock")] - mock: arc_swap::ArcSwapOption, -} - -impl MuralPay { - pub fn new( - api_url: impl Into, - api_key: impl Into, - transfer_api_key: Option>, - ) -> Self { - Self { - http: reqwest::Client::new(), - api_url: api_url.into(), - api_key: api_key.into(), - transfer_api_key: transfer_api_key.map(Into::into), - #[cfg(feature = "mock")] - mock: arc_swap::ArcSwapOption::empty(), - } - } - - /// Creates a client which mocks responses. - #[cfg(feature = "mock")] - #[must_use] - pub fn from_mock(mock: mock::MuralPayMock) -> Self { - Self { - http: reqwest::Client::new(), - api_url: "".into(), - api_key: SecretString::from(String::new()), - transfer_api_key: None, - mock: arc_swap::ArcSwapOption::from_pointee(mock), - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] @@ -119,7 +82,9 @@ pub enum FiatAccountType { crate::util::display_as_serialize!(FiatAccountType); -#[derive(Debug, Clone, Copy, Serialize, Deserialize, strum::EnumIter)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::EnumIter, +)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum FiatAndRailCode { @@ -149,7 +114,7 @@ impl FromStr for FiatAndRailCode { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct WalletDetails { @@ -157,7 +122,7 @@ pub struct WalletDetails { pub wallet_address: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct TokenAmount { @@ -166,7 +131,7 @@ pub struct TokenAmount { pub token_symbol: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct FiatAmount { @@ -195,7 +160,7 @@ impl + Clone> SearchParams { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct SearchResponse { diff --git a/packages/muralpay/src/organization.rs b/packages/muralpay/src/organization.rs index b8e1c0099..71053e8fb 100644 --- a/packages/muralpay/src/organization.rs +++ b/packages/muralpay/src/organization.rs @@ -1,91 +1,81 @@ -use std::str::FromStr; - -use chrono::{DateTime, Utc}; -use derive_more::{Deref, Display}; -use secrecy::ExposeSecret; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{ - CurrencyCode, MuralError, MuralPay, SearchResponse, util::RequestExt, +use { + crate::CurrencyCode, + chrono::{DateTime, Utc}, + derive_more::{Deref, Display}, + serde::{Deserialize, Serialize}, + std::str::FromStr, + uuid::Uuid, }; -impl MuralPay { - pub async fn search_organizations( - &self, - req: SearchRequest, - ) -> Result, MuralError> { - mock!(self, search_organizations(req.clone())); +#[cfg(feature = "client")] +const _: () = { + use crate::{MuralError, RequestExt, SearchResponse}; - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - struct Body { - #[serde(skip_serializing_if = "Option::is_none")] - filter: Option, + impl crate::Client { + pub async fn search_organizations( + &self, + req: SearchRequest, + ) -> Result, MuralError> { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body { + #[serde(skip_serializing_if = "Option::is_none")] + filter: Option, + } + + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Filter { + #[serde(rename = "type")] + ty: FilterType, + name: String, + } + + #[derive(Debug, Clone, Copy, Serialize)] + #[serde(rename_all = "snake_case")] + pub enum FilterType { + Name, + } + + maybe_mock!(self, search_organizations(req.clone())); + + let query = [ + req.limit.map(|limit| ("limit", limit.to_string())), + req.next_id + .map(|next_id| ("nextId", next_id.hyphenated().to_string())), + ] + .into_iter() + .flatten() + .collect::>(); + + let body = Body { + filter: req.name.map(|name| Filter { + ty: FilterType::Name, + name, + }), + }; + + self.http_post(|base| format!("{base}/api/organizations/search")) + .query(&query) + .json(&body) + .send_mural() + .await } - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - struct Filter { - #[serde(rename = "type")] - ty: FilterType, - name: String, + pub async fn get_organization( + &self, + id: OrganizationId, + ) -> Result { + maybe_mock!(self, get_organization(id)); + + self.http_post(|base| format!("{base}/api/organizations/{id}")) + .send_mural() + .await } - - #[derive(Debug, Clone, Copy, Serialize)] - #[serde(rename_all = "snake_case")] - pub enum FilterType { - Name, - } - - let query = [ - req.limit.map(|limit| ("limit", limit.to_string())), - req.next_id - .map(|next_id| ("nextId", next_id.hyphenated().to_string())), - ] - .into_iter() - .flatten() - .collect::>(); - - let body = Body { - filter: req.name.map(|name| Filter { - ty: FilterType::Name, - name, - }), - }; - - self.http_post(|base| format!("{base}/api/organizations/search")) - .bearer_auth(self.api_key.expose_secret()) - .query(&query) - .json(&body) - .send_mural() - .await } +}; - pub async fn get_organization( - &self, - id: OrganizationId, - ) -> Result { - mock!(self, get_organization(id)); - - self.http_post(|base| format!("{base}/api/organizations/{id}")) - .send_mural() - .await - } -} - -#[derive( - Debug, - Display, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Deref, - Serialize, - Deserialize, -)] +#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[display("{}", _0.hyphenated())] pub struct OrganizationId(pub Uuid); @@ -98,6 +88,12 @@ impl FromStr for OrganizationId { } } +impl From for Uuid { + fn from(value: OrganizationId) -> Self { + value.0 + } +} + #[derive(Debug, Clone, Default)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] pub struct SearchRequest { diff --git a/packages/muralpay/src/payout.rs b/packages/muralpay/src/payout.rs index f6423f2e4..8ae2c1a04 100644 --- a/packages/muralpay/src/payout.rs +++ b/packages/muralpay/src/payout.rs @@ -6,170 +6,188 @@ ) )] -use std::str::FromStr; - -use chrono::{DateTime, Utc}; -use derive_more::{Deref, Display, Error, From}; -use rust_decimal::Decimal; -use rust_iso3166::CountryCode; -use serde::{Deserialize, Serialize}; -use serde_with::{DeserializeFromStr, SerializeDisplay}; -use uuid::Uuid; - -use crate::{ - AccountId, Blockchain, FiatAccountType, FiatAmount, FiatAndRailCode, - MuralError, MuralPay, SearchParams, SearchResponse, TokenAmount, - TransferError, WalletDetails, util::RequestExt, +use { + crate::{ + AccountId, Blockchain, CounterpartyId, CurrencyCode, FiatAccountType, + FiatAmount, FiatAndRailCode, PayoutMethodId, TokenAmount, + TransactionId, WalletDetails, + }, + chrono::{DateTime, Utc}, + derive_more::{Deref, Display, Error, From}, + rust_decimal::Decimal, + rust_iso3166::CountryCode, + serde::{Deserialize, Serialize}, + serde_with::{DeserializeFromStr, SerializeDisplay}, + std::str::FromStr, + uuid::Uuid, }; -impl MuralPay { - pub async fn search_payout_requests( - &self, - filter: Option, - params: Option>, - ) -> Result, MuralError> - { - mock!(self, search_payout_requests(filter, params)); +#[cfg(feature = "client")] +const _: () = { + use crate::{MuralError, RequestExt, SearchParams, SearchResponse}; - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - struct Body { - // if we submit `null`, Mural errors; we have to explicitly exclude this field - #[serde(skip_serializing_if = "Option::is_none")] + impl crate::Client { + pub async fn search_payout_requests( + &self, filter: Option, + params: Option>, + ) -> Result, MuralError> + { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body { + // if we submit `null`, Mural errors; we have to explicitly exclude this field + #[serde(skip_serializing_if = "Option::is_none")] + filter: Option, + } + + maybe_mock!(self, search_payout_requests(filter, params)); + + let body = Body { filter }; + + self.http_post(|base| format!("{base}/api/payouts/search")) + .query(¶ms.map(|p| p.to_query()).unwrap_or_default()) + .json(&body) + .send_mural() + .await } - let body = Body { filter }; + pub async fn get_payout_request( + &self, + id: PayoutRequestId, + ) -> Result { + maybe_mock!(self, get_payout_request(id)); - self.http_post(|base| format!("{base}/api/payouts/search")) - .query(¶ms.map(|p| p.to_query()).unwrap_or_default()) + self.http_get(|base| format!("{base}/api/payouts/payout/{id}")) + .send_mural() + .await + } + + pub async fn get_fees_for_token_amount( + &self, + token_fee_requests: &[TokenFeeRequest], + ) -> Result, MuralError> { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + token_fee_requests: &'a [TokenFeeRequest], + } + + maybe_mock!(self, get_fees_for_token_amount(token_fee_requests)); + + let body = Body { token_fee_requests }; + + self.http_post(|base| { + format!("{base}/api/payouts/fees/token-to-fiat") + }) .json(&body) .send_mural() .await - } - - pub async fn get_payout_request( - &self, - id: PayoutRequestId, - ) -> Result { - mock!(self, get_payout_request(id)); - - self.http_get(|base| format!("{base}/api/payouts/payout/{id}")) - .send_mural() - .await - } - - pub async fn get_fees_for_token_amount( - &self, - token_fee_requests: &[TokenFeeRequest], - ) -> Result, MuralError> { - mock!(self, get_fees_for_token_amount(token_fee_requests)); - - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - struct Body<'a> { - token_fee_requests: &'a [TokenFeeRequest], } - let body = Body { token_fee_requests }; + pub async fn get_fees_for_fiat_amount( + &self, + fiat_fee_requests: &[FiatFeeRequest], + ) -> Result, MuralError> { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + fiat_fee_requests: &'a [FiatFeeRequest], + } - self.http_post(|base| format!("{base}/api/payouts/fees/token-to-fiat")) + maybe_mock!(self, get_fees_for_fiat_amount(fiat_fee_requests)); + + let body = Body { fiat_fee_requests }; + + self.http_post(|base| { + format!("{base}/api/payouts/fees/fiat-to-token") + }) .json(&body) .send_mural() .await - } - - pub async fn get_fees_for_fiat_amount( - &self, - fiat_fee_requests: &[FiatFeeRequest], - ) -> Result, MuralError> { - mock!(self, get_fees_for_fiat_amount(fiat_fee_requests)); - - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - struct Body<'a> { - fiat_fee_requests: &'a [FiatFeeRequest], } - let body = Body { fiat_fee_requests }; - - self.http_post(|base| format!("{base}/api/payouts/fees/fiat-to-token")) - .json(&body) - .send_mural() - .await - } - - pub async fn create_payout_request( - &self, - source_account_id: AccountId, - memo: Option>, - payouts: &[CreatePayout], - ) -> Result { - mock!(self, create_payout_request(source_account_id, memo.as_ref().map(|x| x.as_ref()), payouts)); - - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - struct Body<'a> { + pub async fn create_payout_request( + &self, source_account_id: AccountId, - memo: Option<&'a str>, - payouts: &'a [CreatePayout], + memo: Option>, + payouts: &[CreatePayout], + ) -> Result { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + source_account_id: AccountId, + memo: Option<&'a str>, + payouts: &'a [CreatePayout], + } + + maybe_mock!( + self, + create_payout_request( + source_account_id, + memo.as_ref().map(AsRef::as_ref), + payouts + ) + ); + + let body = Body { + source_account_id, + memo: memo.as_ref().map(AsRef::as_ref), + payouts, + }; + + self.http_post(|base| format!("{base}/api/payouts/payout")) + .json(&body) + .send_mural() + .await } - let body = Body { - source_account_id, - memo: memo.as_ref().map(|x| x.as_ref()), - payouts, - }; + pub async fn execute_payout_request( + &self, + id: PayoutRequestId, + ) -> Result { + maybe_mock!(self, execute_payout_request(id)); - self.http_post(|base| format!("{base}/api/payouts/payout")) - .json(&body) + self.http_post(|base| { + format!("{base}/api/payouts/payout/{id}/execute") + }) + .transfer_auth(self) .send_mural() .await - } + } - pub async fn execute_payout_request( - &self, - id: PayoutRequestId, - ) -> Result { - mock!(self, execute_payout_request(id)); + pub async fn cancel_payout_request( + &self, + id: PayoutRequestId, + ) -> Result { + maybe_mock!(self, cancel_payout_request(id)); - self.http_post(|base| format!("{base}/api/payouts/payout/{id}/execute")) - .transfer_auth(self)? + self.http_post(|base| { + format!("{base}/api/payouts/payout/{id}/cancel") + }) + .transfer_auth(self) .send_mural() .await - .map_err(From::from) + } + + pub async fn get_bank_details( + &self, + fiat_currency_and_rail: &[FiatAndRailCode], + ) -> Result { + maybe_mock!(self, get_bank_details(fiat_currency_and_rail)); + + let query = fiat_currency_and_rail + .iter() + .map(|code| ("fiatCurrencyAndRail", code.to_string())) + .collect::>(); + + self.http_get(|base| format!("{base}/api/payouts/bank-details")) + .query(&query) + .send_mural() + .await + } } - - pub async fn cancel_payout_request( - &self, - id: PayoutRequestId, - ) -> Result { - mock!(self, cancel_payout_request(id)); - - self.http_post(|base| format!("{base}/api/payouts/payout/{id}/cancel")) - .transfer_auth(self)? - .send_mural() - .await - .map_err(From::from) - } - - pub async fn get_bank_details( - &self, - fiat_currency_and_rail: &[FiatAndRailCode], - ) -> Result { - mock!(self, get_bank_details(fiat_currency_and_rail)); - - let query = fiat_currency_and_rail - .iter() - .map(|code| ("fiatCurrencyAndRail", code.to_string())) - .collect::>(); - - self.http_get(|base| format!("{base}/api/payouts/bank-details")) - .query(&query) - .send_mural() - .await - } -} +}; #[derive( Debug, @@ -195,6 +213,12 @@ impl FromStr for PayoutRequestId { } } +impl From for Uuid { + fn from(value: PayoutRequestId) -> Self { + value.0 + } +} + #[derive( Debug, Display, @@ -219,6 +243,12 @@ impl FromStr for PayoutId { } } +impl From for Uuid { + fn from(value: PayoutId) -> Self { + value.0 + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(tag = "type", rename_all = "camelCase")] @@ -226,7 +256,7 @@ pub enum PayoutStatusFilter { PayoutStatus { statuses: Vec }, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct PayoutRequest { @@ -251,7 +281,7 @@ pub enum PayoutStatus { Failed, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct Payout { @@ -260,9 +290,10 @@ pub struct Payout { pub updated_at: DateTime, pub amount: TokenAmount, pub details: PayoutDetails, + pub recipient_info: PayoutRecipientInfo, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(tag = "type", rename_all = "camelCase")] pub enum PayoutDetails { @@ -270,7 +301,7 @@ pub enum PayoutDetails { Blockchain(BlockchainPayoutDetails), } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct FiatPayoutDetails { @@ -286,7 +317,7 @@ pub struct FiatPayoutDetails { pub developer_fee: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(tag = "type", rename_all = "kebab-case")] pub enum FiatPayoutStatus { @@ -310,7 +341,69 @@ pub enum FiatPayoutStatus { reason: String, error_code: FiatPayoutErrorCode, }, + #[serde(rename_all = "camelCase")] Canceled, + // since 1.31 + #[serde(rename_all = "camelCase")] + RefundInProgress { + error_code: RefundErrorCode, + failure_reason: String, + refund_initiated_at: DateTime, + }, + // since 1.31 + #[serde(rename_all = "camelCase")] + Refunded { + error_code: RefundErrorCode, + failure_reason: String, + refund_completed_at: DateTime, + refund_initiated_at: DateTime, + refund_transaction_id: TransactionId, + }, +} + +// since 1.31 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RefundErrorCode { + Unknown, + AccountNumberIncorrect, + RejectedByBank, + AccountTypeIncorrect, + AccountClosed, + BeneficiaryDocumentationIncorrect, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum FiatPayoutStatusKind { + Created, + Pending, + OnHold, + Completed, + Failed, + Canceled, + RefundInProgress, + Refunded, +} + +impl FiatPayoutStatus { + #[must_use] + pub const fn kind(&self) -> FiatPayoutStatusKind { + match self { + Self::Created { .. } => FiatPayoutStatusKind::Created, + Self::Pending { .. } => FiatPayoutStatusKind::Pending, + Self::OnHold { .. } => FiatPayoutStatusKind::OnHold, + Self::Completed { .. } => FiatPayoutStatusKind::Completed, + Self::Failed { .. } => FiatPayoutStatusKind::Failed, + Self::Canceled { .. } => FiatPayoutStatusKind::Canceled, + Self::RefundInProgress { .. } => { + FiatPayoutStatusKind::RefundInProgress + } + Self::Refunded { .. } => FiatPayoutStatusKind::Refunded, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -325,7 +418,7 @@ pub enum FiatPayoutErrorCode { BeneficiaryDocumentationIncorrect, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct DeveloperFee { @@ -333,7 +426,7 @@ pub struct DeveloperFee { pub developer_fee_percentage: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct BlockchainPayoutDetails { @@ -353,13 +446,51 @@ pub enum BlockchainPayoutStatus { Canceled, } +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PayoutRecipientInfo { + #[serde(rename_all = "camelCase")] + Counterparty { + counterparty_id: CounterpartyId, + payout_method_id: PayoutMethodId, + }, + #[serde(rename_all = "camelCase")] + Inline { + name: String, + details: InlineRecipientDetails, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum InlineRecipientDetails { + #[serde(rename_all = "camelCase")] + Fiat { details: InlineFiatRecipientDetails }, + #[serde(rename_all = "camelCase")] + Blockchain { + wallet_address: String, + blockchain: Blockchain, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct InlineFiatRecipientDetails { + pub fiat_currency_code: CurrencyCode, + pub bank_name: String, + pub truncated_bank_account_number: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct CreatePayout { pub amount: TokenAmount, pub payout_details: CreatePayoutDetails, - pub recipient_info: PayoutRecipientInfo, + pub recipient_info: CreatePayoutRecipientInfo, pub supporting_details: Option, } @@ -487,7 +618,8 @@ pub enum FiatAndRailDetails { } impl FiatAndRailDetails { - pub fn code(&self) -> FiatAndRailCode { + #[must_use] + pub const fn code(&self) -> FiatAndRailCode { match self { Self::Usd { .. } => FiatAndRailCode::Usd, Self::Cop { .. } => FiatAndRailCode::Cop, @@ -607,7 +739,7 @@ pub enum PixAccountType { #[derive(Debug, Clone, Serialize, Deserialize, From)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(tag = "type", rename_all = "camelCase")] -pub enum PayoutRecipientInfo { +pub enum CreatePayoutRecipientInfo { #[serde(rename_all = "camelCase")] Individual { first_name: String, @@ -624,20 +756,23 @@ pub enum PayoutRecipientInfo { }, } -impl PayoutRecipientInfo { +impl CreatePayoutRecipientInfo { + #[must_use] pub fn email(&self) -> &str { match self { - PayoutRecipientInfo::Individual { email, .. } => email, - PayoutRecipientInfo::Business { email, .. } => email, + Self::Individual { email, .. } | Self::Business { email, .. } => { + email + } } } - pub fn physical_address(&self) -> &PhysicalAddress { + #[must_use] + pub const fn physical_address(&self) -> &PhysicalAddress { match self { - PayoutRecipientInfo::Individual { + Self::Individual { physical_address, .. - } => physical_address, - PayoutRecipientInfo::Business { + } + | Self::Business { physical_address, .. } => physical_address, } diff --git a/packages/muralpay/src/payout_method.rs b/packages/muralpay/src/payout_method.rs index cf556274a..cde190713 100644 --- a/packages/muralpay/src/payout_method.rs +++ b/packages/muralpay/src/payout_method.rs @@ -1,90 +1,104 @@ -use std::str::FromStr; - -use chrono::{DateTime, Utc}; -use derive_more::{Deref, Display, Error}; -use serde::{Deserialize, Serialize}; -use serde_with::DeserializeFromStr; -use uuid::Uuid; - -use crate::{ - ArsSymbol, BobSymbol, BrlSymbol, ClpSymbol, CopSymbol, CounterpartyId, - CrcSymbol, DocumentType, EurSymbol, FiatAccountType, MuralError, MuralPay, - MxnSymbol, PenSymbol, SearchParams, SearchResponse, UsdSymbol, - WalletDetails, ZarSymbol, util::RequestExt, +use { + crate::{ + ArsSymbol, BobSymbol, BrlSymbol, ClpSymbol, CopSymbol, CounterpartyId, CrcSymbol, + DocumentType, EurSymbol, FiatAccountType, MxnSymbol, PenSymbol, UsdSymbol, WalletDetails, + ZarSymbol, + }, + chrono::{DateTime, Utc}, + derive_more::{Deref, Display, Error}, + serde::{Deserialize, Serialize}, + serde_with::DeserializeFromStr, + std::str::FromStr, + uuid::Uuid, }; -impl MuralPay { - pub async fn search_payout_methods( - &self, - counterparty_id: CounterpartyId, - params: Option>, - ) -> Result, MuralError> { - mock!(self, search_payout_methods(counterparty_id, params)); +#[cfg(feature = "client")] +const _: () = { + use crate::{MuralError, RequestExt, SearchParams, SearchResponse}; - self.http_post(|base| { - format!( - "{base}/api/counterparties/{counterparty_id}/payout-methods/search" - ) - }) - .query(¶ms.map(|p| p.to_query()).unwrap_or_default()) - .send_mural() - .await - } + impl crate::Client { + pub async fn search_payout_methods( + &self, + counterparty_id: CounterpartyId, + params: Option>, + ) -> Result, MuralError> { + maybe_mock!(self, search_payout_methods(counterparty_id, params)); - pub async fn get_payout_method( - &self, - counterparty_id: CounterpartyId, - payout_method_id: PayoutMethodId, - ) -> Result { - mock!(self, get_payout_method(counterparty_id, payout_method_id)); - - self.http_get(|base| format!("{base}/api/counterparties/{counterparty_id}/payout-methods/{payout_method_id}")) + self.http_post(|base| { + format!("{base}/api/counterparties/{counterparty_id}/payout-methods/search") + }) + .query(¶ms.map(|p| p.to_query()).unwrap_or_default()) .send_mural() .await - } - - pub async fn create_payout_method( - &self, - counterparty_id: CounterpartyId, - alias: impl AsRef, - payout_method: &PayoutMethodDetails, - ) -> Result { - mock!(self, create_payout_method(counterparty_id, alias.as_ref(), payout_method)); - - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - struct Body<'a> { - alias: &'a str, - payout_method: &'a PayoutMethodDetails, } - let body = Body { - alias: alias.as_ref(), - payout_method, - }; + pub async fn get_payout_method( + &self, + counterparty_id: CounterpartyId, + payout_method_id: PayoutMethodId, + ) -> Result { + maybe_mock!(self, get_payout_method(counterparty_id, payout_method_id)); - self.http_post(|base| { - format!( - "{base}/api/counterparties/{counterparty_id}/payout-methods" - ) - }) - .json(&body) - .send_mural() - .await - } - - pub async fn delete_payout_method( - &self, - counterparty_id: CounterpartyId, - payout_method_id: PayoutMethodId, - ) -> Result<(), MuralError> { - mock!(self, delete_payout_method(counterparty_id, payout_method_id)); - - self.http_delete(|base| format!("{base}/api/counterparties/{counterparty_id}/payout-methods/{payout_method_id}")) + self.http_get(|base| { + format!( + "{base}/api/counterparties/{counterparty_id}/payout-methods/{payout_method_id}" + ) + }) .send_mural() .await + } + + pub async fn create_payout_method( + &self, + counterparty_id: CounterpartyId, + alias: impl AsRef, + payout_method: &PayoutMethodDetails, + ) -> Result { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + alias: &'a str, + payout_method: &'a PayoutMethodDetails, + } + + maybe_mock!( + self, + create_payout_method(counterparty_id, alias.as_ref(), payout_method) + ); + + let body = Body { + alias: alias.as_ref(), + payout_method, + }; + + self.http_post(|base| { + format!("{base}/api/counterparties/{counterparty_id}/payout-methods") + }) + .json(&body) + .send_mural() + .await + } + + pub async fn delete_payout_method( + &self, + counterparty_id: CounterpartyId, + payout_method_id: PayoutMethodId, + ) -> Result<(), MuralError> { + maybe_mock!( + self, + delete_payout_method(counterparty_id, payout_method_id) + ); + + self.http_delete(|base| { + format!( + "{base}/api/counterparties/{counterparty_id}/payout-methods/{payout_method_id}" + ) + }) + .send_mural() + .await + } } -} +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] @@ -107,18 +121,7 @@ pub enum PayoutMethodPixAccountType { BankAccount, } -#[derive( - Debug, - Display, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Deref, - Serialize, - Deserialize, -)] +#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[display("{}", _0.hyphenated())] pub struct PayoutMethodId(pub Uuid); diff --git a/packages/muralpay/src/serde_iso3166.rs b/packages/muralpay/src/serde_iso3166.rs index 28c9e1987..8769107cb 100644 --- a/packages/muralpay/src/serde_iso3166.rs +++ b/packages/muralpay/src/serde_iso3166.rs @@ -1,12 +1,10 @@ -use serde::{Deserialize, de::Error}; -use std::borrow::Cow; +use { + rust_iso3166::CountryCode, + serde::{Deserialize, de::Error}, + std::borrow::Cow, +}; -use rust_iso3166::CountryCode; - -pub fn serialize( - v: &CountryCode, - serializer: S, -) -> Result { +pub fn serialize(v: &CountryCode, serializer: S) -> Result { serializer.serialize_str(v.alpha2) } @@ -17,8 +15,6 @@ pub fn deserialize<'de, D: serde::Deserializer<'de>>( rust_iso3166::ALPHA2_MAP .get(&country_code) .copied() - .ok_or_else(|| { - D::Error::custom("invalid ISO 3166 alpha-2 country code") - }) + .ok_or_else(|| D::Error::custom("invalid ISO 3166 alpha-2 country code")) }) } diff --git a/packages/muralpay/src/transaction.rs b/packages/muralpay/src/transaction.rs new file mode 100644 index 000000000..088f8dd21 --- /dev/null +++ b/packages/muralpay/src/transaction.rs @@ -0,0 +1,138 @@ +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use derive_more::{Deref, Display}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{AccountId, Blockchain, FiatAmount, PayoutId, PayoutRequestId, TokenAmount}; + +#[cfg(feature = "client")] +const _: () = { + use crate::{Account, MuralError, RequestExt, SearchParams, SearchResponse}; + + impl crate::Client { + pub async fn get_transaction(&self, id: TransactionId) -> Result { + maybe_mock!(self, get_transaction(id)); + + self.http_get(|base| format!("{base}/api/transactions/{id}")) + .send_mural() + .await + } + + pub async fn search_transactions( + &self, + account_id: AccountId, + params: Option>, + ) -> Result, MuralError> { + maybe_mock!(self, search_transactions(account_id, params)); + + self.http_post(|base| format!("{base}/api/transactions/search/account/{account_id}")) + .query(¶ms.map(|p| p.to_query()).unwrap_or_default()) + .send_mural() + .await + } + } +}; + +#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[display("{}", _0.hyphenated())] +pub struct TransactionId(pub Uuid); + +impl FromStr for TransactionId { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + s.parse::().map(Self) + } +} + +impl From for Uuid { + fn from(value: TransactionId) -> Self { + value.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Transaction { + pub id: TransactionId, + pub hash: String, + pub transaction_execution_date: DateTime, + pub memo: Option, + pub blockchain: Blockchain, + pub amount: TokenAmount, + pub account_id: AccountId, + // pub counterparty_info, + pub transaction_details: TransactionDetails, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum TransactionDetails { + #[serde(rename_all = "camelCase")] + Payout { + payout_request_id: PayoutRequestId, + payout_id: PayoutId, + }, + #[serde(rename_all = "camelCase")] + Deposit { details: DepositDetails }, + #[serde(rename_all = "camelCase")] + ExternalPayout { recipient_wallet_address: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum DepositDetails { + #[serde(rename_all = "camelCase")] + Fiat { + deposit_id: Uuid, + created_at: DateTime, + sent_fiat_amount: FiatAmount, + sender_metadata: Option, + deposit_status_info: DepositStatus, + }, + #[serde(rename_all = "camelCase")] + Blockchain { + sender_address: String, + blockchain: Blockchain, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum SenderMetadata { + #[serde(rename_all = "camelCase")] + Ach { + ach_routing_number: String, + sender_name: String, + description: Option, + trace_number: String, + }, + #[serde(rename_all = "camelCase")] + Wire { + wire_routing_number: String, + sender_name: Option, + bank_name: String, + bank_beneficiary_name: String, + imad: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum DepositStatus { + #[serde(rename_all = "camelCase")] + AwaitingFunds, + #[serde(rename_all = "camelCase")] + Completed { + initiated_at: DateTime, + completed_at: DateTime, + }, +} diff --git a/packages/muralpay/src/util.rs b/packages/muralpay/src/util.rs index 709c3df40..70aae6c6f 100644 --- a/packages/muralpay/src/util.rs +++ b/packages/muralpay/src/util.rs @@ -1,85 +1,3 @@ -use reqwest::{IntoUrl, RequestBuilder}; -use secrecy::ExposeSecret; -use serde::de::DeserializeOwned; - -use crate::{ApiError, MuralError, MuralPay, TransferError}; - -impl MuralPay { - fn http_req( - &self, - make_req: impl FnOnce() -> RequestBuilder, - ) -> RequestBuilder { - make_req() - .bearer_auth(self.api_key.expose_secret()) - .header("accept", "application/json") - .header("content-type", "application/json") - } - - pub(crate) fn http_get( - &self, - make_url: impl FnOnce(&str) -> U, - ) -> RequestBuilder { - self.http_req(|| self.http.get(make_url(&self.api_url))) - } - - pub(crate) fn http_post( - &self, - make_url: impl FnOnce(&str) -> U, - ) -> RequestBuilder { - self.http_req(|| self.http.post(make_url(&self.api_url))) - } - - pub(crate) fn http_put( - &self, - make_url: impl FnOnce(&str) -> U, - ) -> RequestBuilder { - self.http_req(|| self.http.put(make_url(&self.api_url))) - } - - pub(crate) fn http_delete( - &self, - make_url: impl FnOnce(&str) -> U, - ) -> RequestBuilder { - self.http_req(|| self.http.delete(make_url(&self.api_url))) - } -} - -pub trait RequestExt: Sized { - fn transfer_auth(self, client: &MuralPay) -> Result; - - async fn send_mural(self) -> crate::Result; -} - -const HEADER_TRANSFER_API_KEY: &str = "transfer-api-key"; - -impl RequestExt for reqwest::RequestBuilder { - fn transfer_auth(self, client: &MuralPay) -> Result { - let transfer_api_key = client - .transfer_api_key - .as_ref() - .ok_or(TransferError::NoTransferKey)?; - - Ok(self - .header(HEADER_TRANSFER_API_KEY, transfer_api_key.expose_secret())) - } - - async fn send_mural(self) -> crate::Result { - let resp = self.send().await?; - let status = resp.status(); - if status.is_client_error() || status.is_server_error() { - let json = resp.bytes().await?; - let err = serde_json::from_slice::(&json) - .map_err(|source| MuralError::DecodeError { source, json })?; - Err(MuralError::Api(err)) - } else { - let json = resp.bytes().await?; - let t = serde_json::from_slice::(&json) - .map_err(|source| MuralError::Decode { source, json })?; - Ok(t) - } - } -} - macro_rules! display_as_serialize { ($T:ty) => { const _: () = { @@ -87,8 +5,7 @@ macro_rules! display_as_serialize { impl fmt::Display for $T { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let value = - serde_json::to_value(self).map_err(|_| fmt::Error)?; + let value = serde_json::to_value(self).map_err(|_| fmt::Error)?; let value = value.as_str().ok_or(fmt::Error)?; write!(f, "{value}") } @@ -96,5 +13,4 @@ macro_rules! display_as_serialize { }; }; } - pub(crate) use display_as_serialize;