Fix Mural payout status syncing (#4853)

* Fix Mural payout status syncing

* Make Mural payout code more resilient

* prepare sqlx

* fix test
This commit is contained in:
aecsocket
2025-12-08 20:34:41 +00:00
committed by GitHub
parent cfd2977c21
commit 9aa06fbc26
22 changed files with 1171 additions and 1151 deletions

6
Cargo.lock generated
View File

@@ -5147,11 +5147,7 @@ dependencies = [
"arc-swap", "arc-swap",
"bytes", "bytes",
"chrono", "chrono",
"clap",
"color-eyre",
"derive_more 2.0.1", "derive_more 2.0.1",
"dotenvy",
"eyre",
"reqwest", "reqwest",
"rust_decimal", "rust_decimal",
"rust_iso3166", "rust_iso3166",
@@ -5160,8 +5156,6 @@ dependencies = [
"serde_json", "serde_json",
"serde_with", "serde_with",
"strum", "strum",
"tokio",
"tracing-subscriber",
"utoipa", "utoipa",
"uuid 1.18.1", "uuid 1.18.1",
] ]

View File

@@ -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"
}

View File

@@ -72,7 +72,7 @@ lettre = { workspace = true }
meilisearch-sdk = { workspace = true, features = ["reqwest"] } meilisearch-sdk = { workspace = true, features = ["reqwest"] }
modrinth-maxmind = { workspace = true } modrinth-maxmind = { workspace = true }
modrinth-util = { workspace = true, features = ["decimal", "utoipa"] } modrinth-util = { workspace = true, features = ["decimal", "utoipa"] }
muralpay = { workspace = true, features = ["mock", "utoipa"] } muralpay = { workspace = true, features = ["client", "mock", "utoipa"] }
murmur2 = { workspace = true } murmur2 = { workspace = true }
paste = { workspace = true } paste = { workspace = true }
path-util = { workspace = true } path-util = { workspace = true }

View File

@@ -10,7 +10,6 @@ use crate::search::indexing::index_projects;
use crate::util::anrok; use crate::util::anrok;
use crate::{database, search}; use crate::{database, search};
use clap::ValueEnum; use clap::ValueEnum;
use muralpay::MuralPay;
use sqlx::Postgres; use sqlx::Postgres;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@@ -39,7 +38,7 @@ impl BackgroundTask {
stripe_client: stripe::Client, stripe_client: stripe::Client,
anrok_client: anrok::Client, anrok_client: anrok::Client,
email_queue: EmailQueue, email_queue: EmailQueue,
mural_client: MuralPay, mural_client: muralpay::Client,
) { ) {
use BackgroundTask::*; use BackgroundTask::*;
match self { match self {
@@ -207,7 +206,10 @@ pub async fn payouts(
info!("Done running payouts"); info!("Done running payouts");
} }
pub async fn sync_payout_statuses(pool: sqlx::Pool<Postgres>, mural: MuralPay) { pub async fn sync_payout_statuses(
pool: sqlx::Pool<Postgres>,
mural: muralpay::Client,
) {
// Mural sets a max limit of 100 for search payouts endpoint // Mural sets a max limit of 100 for search payouts endpoint
const LIMIT: u32 = 100; const LIMIT: u32 = 100;

View File

@@ -150,7 +150,7 @@ pub struct TremendousForexResponse {
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MuralPayDetails { pub struct MuralPayDetails {
pub payout_details: MuralPayoutRequest, pub payout_details: MuralPayoutRequest,
pub recipient_info: muralpay::PayoutRecipientInfo, pub recipient_info: muralpay::CreatePayoutRecipientInfo,
} }
impl PayoutMethodType { impl PayoutMethodType {

View File

@@ -21,7 +21,6 @@ use dashmap::DashMap;
use eyre::{Result, eyre}; use eyre::{Result, eyre};
use futures::TryStreamExt; use futures::TryStreamExt;
use modrinth_util::decimal::Decimal2dp; use modrinth_util::decimal::Decimal2dp;
use muralpay::MuralPay;
use reqwest::Method; use reqwest::Method;
use rust_decimal::prelude::ToPrimitive; use rust_decimal::prelude::ToPrimitive;
use rust_decimal::{Decimal, RoundingStrategy, dec}; use rust_decimal::{Decimal, RoundingStrategy, dec};
@@ -48,7 +47,7 @@ pub struct PayoutsQueue {
} }
pub struct MuralPayConfig { pub struct MuralPayConfig {
pub client: MuralPay, pub client: muralpay::Client,
pub source_account_id: muralpay::AccountId, pub source_account_id: muralpay::AccountId,
} }
@@ -77,11 +76,11 @@ impl Default for PayoutsQueue {
} }
} }
pub fn create_muralpay_client() -> Result<MuralPay> { pub fn create_muralpay_client() -> Result<muralpay::Client> {
let api_url = env_var("MURALPAY_API_URL")?; let api_url = env_var("MURALPAY_API_URL")?;
let api_key = env_var("MURALPAY_API_KEY")?; let api_key = env_var("MURALPAY_API_KEY")?;
let transfer_api_key = env_var("MURALPAY_TRANSFER_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<MuralPayConfig> { pub fn create_muralpay() -> Result<MuralPayConfig> {

View File

@@ -3,7 +3,7 @@ use chrono::Utc;
use eyre::{Result, eyre}; use eyre::{Result, eyre};
use futures::{StreamExt, TryFutureExt, stream::FuturesUnordered}; use futures::{StreamExt, TryFutureExt, stream::FuturesUnordered};
use modrinth_util::decimal::Decimal2dp; use modrinth_util::decimal::Decimal2dp;
use muralpay::{MuralError, MuralPay, TokenFeeRequest}; use muralpay::{MuralError, TokenFeeRequest};
use rust_decimal::{Decimal, prelude::ToPrimitive}; use rust_decimal::{Decimal, prelude::ToPrimitive};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
@@ -69,7 +69,7 @@ impl PayoutsQueue {
gross_amount: Decimal2dp, gross_amount: Decimal2dp,
fees: PayoutFees, fees: PayoutFees,
payout_details: MuralPayoutRequest, payout_details: MuralPayoutRequest,
recipient_info: muralpay::PayoutRecipientInfo, recipient_info: muralpay::CreatePayoutRecipientInfo,
gotenberg: &GotenbergClient, gotenberg: &GotenbergClient,
) -> Result<muralpay::PayoutRequest, ApiError> { ) -> Result<muralpay::PayoutRequest, ApiError> {
let muralpay = self.muralpay.load(); 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) 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, &self,
id: muralpay::PayoutRequestId, id: muralpay::PayoutRequestId,
) -> Result<()> { ) -> Result<()> {
@@ -263,7 +254,7 @@ impl PayoutsQueue {
/// Mural state, and updates the payout status. /// Mural state, and updates the payout status.
pub async fn sync_pending_payouts_from_mural( pub async fn sync_pending_payouts_from_mural(
db: &PgPool, db: &PgPool,
mural: &MuralPay, mural: &muralpay::Client,
limit: u32, limit: u32,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
#[derive(Debug)] #[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 /// Queries Mural for canceled or failed payouts, and updates the corresponding
/// Labrinth payouts' statuses. /// 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( pub async fn sync_failed_mural_payouts_to_labrinth(
db: &PgPool, db: &PgPool,
mural: &MuralPay, mural: &muralpay::Client,
limit: u32, limit: u32,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
info!("Syncing failed Mural payouts to Labrinth"); info!("Syncing failed Mural payouts to Labrinth");
@@ -380,12 +375,7 @@ pub async fn sync_failed_mural_payouts_to_labrinth(
loop { loop {
let search_resp = mural let search_resp = mural
.search_payout_requests( .search_payout_requests(
Some(muralpay::PayoutStatusFilter::PayoutStatus { None,
statuses: vec![
muralpay::PayoutStatus::Canceled,
muralpay::PayoutStatus::Failed,
],
}),
Some(muralpay::SearchParams { Some(muralpay::SearchParams {
limit: Some(u64::from(limit)), limit: Some(u64::from(limit)),
next_id, next_id,
@@ -395,48 +385,51 @@ pub async fn sync_failed_mural_payouts_to_labrinth(
.wrap_internal_err( .wrap_internal_err(
"failed to fetch failed payout requests from Mural", "failed to fetch failed payout requests from Mural",
)?; )?;
next_id = search_resp.next_id;
if search_resp.results.is_empty() { if search_resp.results.is_empty() {
break; break;
} }
next_id = search_resp.next_id;
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"
);
let mut payout_platform_ids = Vec::<String>::new(); let mut payout_platform_ids = Vec::<String>::new();
let mut payout_new_statuses = Vec::<String>::new(); let mut payout_new_statuses = Vec::<String>::new();
for payout_req in search_resp.results { for payout_request in search_resp.results {
let new_payout_status = match payout_req.status { let payout_platform_id = payout_request.id;
muralpay::PayoutStatus::Canceled => PayoutStatus::Cancelled,
muralpay::PayoutStatus::Failed => PayoutStatus::Failed, let new_payout_status = match payout_request.status {
_ => { muralpay::PayoutStatus::Canceled => {
warn!( trace!(
"Found payout {} with status {:?}, which should have been filtered out by our Mural request - Mural bug", "- Payout request {payout_platform_id} set to {} because it is cancelled in Mural",
payout_req.id, payout_req.status 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!( if let Some(new_payout_status) = new_payout_status {
"- 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());
}
payout_platform_ids.push(payout_platform_id.to_string());
payout_new_statuses.push(new_payout_status.to_string());
} }
let result = sqlx::query!( let result = sqlx::query!(
@@ -470,6 +463,17 @@ pub async fn sync_failed_mural_payouts_to_labrinth(
Ok(()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -477,8 +481,8 @@ mod tests {
api_v3::ApiV3, api_v3::ApiV3,
environment::{TestEnvironment, with_test_environment}, environment::{TestEnvironment, with_test_environment},
}; };
use muralpay::MuralPay; use muralpay::MuralPayMock;
use muralpay::mock::MuralPayMock; use rust_decimal::dec;
fn create_mock_payout_request( fn create_mock_payout_request(
id: &str, id: &str,
@@ -494,12 +498,51 @@ mod tests {
transaction_hash: None, transaction_hash: None,
memo: None, memo: None,
status, 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 { fn create_mock_muralpay() -> muralpay::Client {
MuralPay::from_mock(MuralPayMock { muralpay::Client::from_mock(MuralPayMock {
get_payout_request: Box::new(|_id| { get_payout_request: Box::new(|_id| {
Err(muralpay::MuralError::Api(muralpay::ApiError { Err(muralpay::MuralError::Api(muralpay::ApiError {
error_instance_id: uuid::Uuid::new_v4(), 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 // Run the function
let result = 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 // Run the function
let result = 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 // Run the function - should handle this gracefully
sync_failed_mural_payouts_to_labrinth( sync_failed_mural_payouts_to_labrinth(

View File

@@ -28,7 +28,7 @@ use rust_decimal::{Decimal, RoundingStrategy};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use sha2::Sha256; use sha2::Sha256;
use sqlx::PgPool; use sqlx::{PgPool, PgTransaction};
use std::collections::HashMap; use std::collections::HashMap;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tracing::error; use tracing::error;
@@ -623,29 +623,22 @@ pub async fn create_payout(
total_fee: fees.total_fee(), total_fee: fees.total_fee(),
sent_to_method, sent_to_method,
payouts_queue: &payouts_queue, payouts_queue: &payouts_queue,
db: PgPool::clone(&pool),
transaction,
}; };
let payout_item = match &body.method { match &body.method {
PayoutMethodRequest::PayPal | PayoutMethodRequest::Venmo => { PayoutMethodRequest::PayPal | PayoutMethodRequest::Venmo => {
paypal_payout(payout_cx).await? paypal_payout(payout_cx).await?;
} }
PayoutMethodRequest::Tremendous { method_details } => { PayoutMethodRequest::Tremendous { method_details } => {
tremendous_payout(payout_cx, method_details).await? tremendous_payout(payout_cx, method_details).await?;
} }
PayoutMethodRequest::MuralPay { method_details } => { 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) crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis)
.await .await
.wrap_internal_err("failed to clear user caches")?; .wrap_internal_err("failed to clear user caches")?;
@@ -653,7 +646,6 @@ pub async fn create_payout(
Ok(()) Ok(())
} }
#[derive(Clone, Copy)]
struct PayoutContext<'a> { struct PayoutContext<'a> {
body: &'a Withdrawal, body: &'a Withdrawal,
user: &'a DBUser, user: &'a DBUser,
@@ -666,6 +658,8 @@ struct PayoutContext<'a> {
total_fee: Decimal2dp, total_fee: Decimal2dp,
sent_to_method: Decimal2dp, sent_to_method: Decimal2dp,
payouts_queue: &'a PayoutsQueue, payouts_queue: &'a PayoutsQueue,
db: PgPool,
transaction: PgTransaction<'a>,
} }
fn get_verified_email(user: &DBUser) -> Result<&str, ApiError> { fn get_verified_email(user: &DBUser) -> Result<&str, ApiError> {
@@ -692,12 +686,14 @@ async fn tremendous_payout(
total_fee, total_fee,
sent_to_method, sent_to_method,
payouts_queue, payouts_queue,
db: _,
mut transaction,
}: PayoutContext<'_>, }: PayoutContext<'_>,
TremendousDetails { TremendousDetails {
delivery_email, delivery_email,
currency, currency,
}: &TremendousDetails, }: &TremendousDetails,
) -> Result<DBPayout, ApiError> { ) -> Result<(), ApiError> {
let user_email = get_verified_email(user)?; let user_email = get_verified_email(user)?;
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -773,7 +769,7 @@ async fn tremendous_payout(
let platform_id = res.order.rewards.first().map(|reward| reward.id.clone()); let platform_id = res.order.rewards.first().map(|reward| reward.id.clone());
Ok(DBPayout { DBPayout {
id: payout_id, id: payout_id,
user_id: user.id, user_id: user.id,
created: Utc::now(), created: Utc::now(),
@@ -784,7 +780,17 @@ async fn tremendous_payout(
method_id: Some(body.method_id.clone()), method_id: Some(body.method_id.clone()),
method_address: Some(user_email.to_string()), method_address: Some(user_email.to_string()),
platform_id, 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( async fn mural_pay_payout(
@@ -798,12 +804,35 @@ async fn mural_pay_payout(
total_fee, total_fee,
sent_to_method: _, sent_to_method: _,
payouts_queue, payouts_queue,
db,
mut transaction,
}: PayoutContext<'_>, }: PayoutContext<'_>,
details: &MuralPayDetails, details: &MuralPayDetails,
gotenberg: &GotenbergClient, gotenberg: &GotenbergClient,
) -> Result<DBPayout, ApiError> { ) -> Result<(), ApiError> {
let user_email = get_verified_email(user)?; 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 let payout_request = payouts_queue
.create_muralpay_payout_request( .create_muralpay_payout_request(
payout_id, payout_id,
@@ -816,22 +845,13 @@ async fn mural_pay_payout(
) )
.await?; .await?;
let method_id = match &details.payout_details { let payout = DBPayout {
MuralPayoutRequest::Blockchain { .. } => {
"blockchain-usdc-polygon".to_string()
}
MuralPayoutRequest::Fiat {
fiat_and_rail_details,
..
} => fiat_and_rail_details.code().to_string(),
};
Ok(DBPayout {
id: payout_id, id: payout_id,
user_id: user.id, user_id: user.id,
created: Utc::now(), created: Utc::now(),
// after the payout has been successfully executed, // after the payout has been successfully executed,
// we wait for Mural's confirmation that the funds have been delivered // we wait for Mural's confirmation that the funds have been delivered
// done in `SyncPayoutStatuses` background task
status: PayoutStatus::InTransit, status: PayoutStatus::InTransit,
amount: amount_minus_fee.get(), amount: amount_minus_fee.get(),
fee: Some(total_fee.get()), fee: Some(total_fee.get()),
@@ -839,7 +859,61 @@ async fn mural_pay_payout(
method_id: Some(method_id), method_id: Some(method_id),
method_address: Some(user_email.to_string()), method_address: Some(user_email.to_string()),
platform_id: Some(payout_request.id.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( async fn paypal_payout(
@@ -853,8 +927,10 @@ async fn paypal_payout(
total_fee, total_fee,
sent_to_method, sent_to_method,
payouts_queue, payouts_queue,
db: _,
mut transaction,
}: PayoutContext<'_>, }: PayoutContext<'_>,
) -> Result<DBPayout, ApiError> { ) -> Result<(), ApiError> {
let (wallet, wallet_type, address, display_address) = let (wallet, wallet_type, address, display_address) =
if matches!(body.method, PayoutMethodRequest::Venmo) { if matches!(body.method, PayoutMethodRequest::Venmo) {
if let Some(venmo) = &user.venmo_handle { if let Some(venmo) = &user.venmo_handle {
@@ -965,7 +1041,7 @@ async fn paypal_payout(
let platform_id = Some(data.payout_item_id.clone()); let platform_id = Some(data.payout_item_id.clone());
Ok(DBPayout { DBPayout {
id: payout_id, id: payout_id,
user_id: user.id, user_id: user.id,
created: Utc::now(), created: Utc::now(),
@@ -976,7 +1052,17 @@ async fn paypal_payout(
method_id: Some(body.method_id.clone()), method_id: Some(body.method_id.clone()),
method_address: Some(display_address.clone()), method_address: Some(display_address.clone()),
platform_id, 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. /// User performing a payout-related action.
@@ -1201,7 +1287,7 @@ pub async fn cancel_payout(
.parse::<muralpay::PayoutRequestId>() .parse::<muralpay::PayoutRequestId>()
.wrap_request_err("invalid payout request ID")?; .wrap_request_err("invalid payout request ID")?;
payouts payouts
.cancel_muralpay_payout_request(payout_request_id) .cancel_mural_payout_request(payout_request_id)
.await .await
.wrap_internal_err( .wrap_internal_err(
"failed to cancel payout request", "failed to cancel payout request",

View File

@@ -3,7 +3,6 @@ name = "muralpay"
version = "0.1.0" version = "0.1.0"
edition.workspace = true edition.workspace = true
description = "Mural Pay API" description = "Mural Pay API"
repository = "https://github.com/modrinth/code/"
license = "MIT" license = "MIT"
keywords = [] keywords = []
categories = ["api-bindings"] categories = ["api-bindings"]
@@ -18,26 +17,19 @@ derive_more = { workspace = true, features = [
"error", "error",
"from", "from",
] } ] }
reqwest = { workspace = true, features = ["default-tls", "http2", "json"] } reqwest = { workspace = true, features = ["default-tls", "http2", "json"], optional = true }
rust_decimal = { workspace = true, features = ["macros"] } rust_decimal = { workspace = true, features = ["macros", "serde-with-float"] }
rust_iso3166 = { workspace = true } rust_iso3166 = { workspace = true }
secrecy = { workspace = true } secrecy = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_with = { workspace = true } serde_with = { workspace = true }
strum = { workspace = true, features = ["derive"] } 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"] } 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] [features]
client = ["dep:reqwest", "dep:secrecy"]
mock = ["dep:arc-swap"] mock = ["dep:arc-swap"]
utoipa = ["dep:utoipa"] utoipa = ["dep:utoipa"]

View File

@@ -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<OutputFormat>,
#[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<String>,
},
/// 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 = <Args as clap::Parser>::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<TokenPayoutFee> {
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<FiatPayoutFee> {
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<T: Debug + Serialize>(output_format: Option<OutputFormat>, 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);
}
}
}

View File

@@ -1,83 +1,65 @@
use std::str::FromStr; use {
crate::{Blockchain, FiatAmount, TokenAmount, WalletDetails},
use chrono::{DateTime, Utc}; chrono::{DateTime, Utc},
use derive_more::{Deref, Display}; derive_more::{Deref, Display},
use rust_decimal::Decimal; rust_decimal::Decimal,
use secrecy::ExposeSecret; serde::{Deserialize, Serialize},
use serde::{Deserialize, Serialize}; std::str::FromStr,
use uuid::Uuid; uuid::Uuid,
use crate::{
Blockchain, FiatAmount, MuralError, MuralPay, TokenAmount, WalletDetails,
util::RequestExt,
}; };
impl MuralPay { #[cfg(feature = "client")]
pub async fn get_all_accounts(&self) -> Result<Vec<Account>, MuralError> { const _: () = {
mock!(self, get_all_accounts()); use crate::{MuralError, RequestExt};
self.http_get(|base| format!("{base}/api/accounts")) impl crate::Client {
.send_mural() pub async fn get_all_accounts(&self) -> Result<Vec<Account>, MuralError> {
.await maybe_mock!(self, get_all_accounts());
}
pub async fn get_account( self.http_get(|base| format!("{base}/api/accounts"))
&self, .send_mural()
id: AccountId, .await
) -> Result<Account, MuralError> {
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<str>,
description: Option<impl AsRef<str>>,
) -> Result<Account, MuralError> {
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>,
} }
let body = Body { pub async fn get_account(&self, id: AccountId) -> Result<Account, MuralError> {
name: name.as_ref(), maybe_mock!(self, get_account(id));
description: description.as_ref().map(|x| x.as_ref()),
};
self.http self.http_get(|base| format!("{base}/api/accounts/{id}"))
.post(format!("{}/api/accounts", self.api_url)) .send_mural()
.bearer_auth(self.api_key.expose_secret()) .await
.json(&body) }
.send_mural()
.await pub async fn create_account(
&self,
name: impl AsRef<str>,
description: Option<impl AsRef<str>>,
) -> Result<Account, MuralError> {
#[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( #[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)]
Debug,
Display,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Deref,
Serialize,
Deserialize,
)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[display("{}", _0.hyphenated())] #[display("{}", _0.hyphenated())]
pub struct AccountId(pub Uuid); pub struct AccountId(pub Uuid);
@@ -90,6 +72,12 @@ impl FromStr for AccountId {
} }
} }
impl From<AccountId> for Uuid {
fn from(value: AccountId) -> Self {
value.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View File

@@ -1,9 +1,10 @@
use std::{collections::HashMap, fmt}; use {
bytes::Bytes,
use bytes::Bytes; derive_more::{Display, Error, From},
use derive_more::{Display, Error, From}; serde::{Deserialize, Serialize},
use serde::{Deserialize, Serialize}; std::{collections::HashMap, fmt},
use uuid::Uuid; uuid::Uuid,
};
#[derive(Debug, Display, Error, From)] #[derive(Debug, Display, Error, From)]
pub enum MuralError { pub enum MuralError {
@@ -27,43 +28,6 @@ pub enum MuralError {
pub type Result<T, E = MuralError> = std::result::Result<T, E>; pub type Result<T, E = MuralError> = std::result::Result<T, E>;
#[derive(Debug, Display, Error, From)]
pub enum TransferError {
#[display("no transfer API key")]
NoTransferKey,
#[display("API error")]
Api(Box<ApiError>),
#[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<MuralError> 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)] #[derive(Debug, Clone, Serialize, Deserialize, Error)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ApiError { pub struct ApiError {
@@ -96,7 +60,7 @@ where
impl fmt::Display for ApiError { impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 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() { if !self.details.is_empty() {
lines.push("details:".into()); lines.push("details:".into());
@@ -105,8 +69,7 @@ impl fmt::Display for ApiError {
if !self.params.is_empty() { if !self.params.is_empty() {
lines.push("params:".into()); lines.push("params:".into());
lines lines.extend(self.params.iter().map(|(k, v)| format!("- {k}: {v}")));
.extend(self.params.iter().map(|(k, v)| format!("- {k}: {v}")));
} }
lines.push(format!("error name: {}", self.name)); lines.push(format!("error name: {}", self.name));

View File

@@ -1,14 +1,15 @@
//! See [`MuralPayMock`]. //! See [`MuralPayMock`].
use std::fmt::{self, Debug}; use {
crate::{
use crate::{ Account, AccountId, BankDetailsResponse, Counterparty, CounterpartyId, CreateCounterparty,
Account, AccountId, BankDetailsResponse, Counterparty, CounterpartyId, CreatePayout, FiatAndRailCode, FiatFeeRequest, FiatPayoutFee, MuralError, Organization,
CreateCounterparty, CreatePayout, FiatAndRailCode, FiatFeeRequest, OrganizationId, PayoutMethod, PayoutMethodDetails, PayoutMethodId, PayoutRequest,
FiatPayoutFee, MuralError, Organization, OrganizationId, PayoutMethod, PayoutRequestId, PayoutStatusFilter, SearchParams, SearchRequest, SearchResponse,
PayoutMethodDetails, PayoutMethodId, PayoutRequest, PayoutRequestId, TokenFeeRequest, TokenPayoutFee, UpdateCounterparty,
PayoutStatusFilter, SearchParams, SearchRequest, SearchResponse, transaction::{Transaction, TransactionId},
TokenFeeRequest, TokenPayoutFee, TransferError, UpdateCounterparty, },
std::fmt::{self, Debug},
}; };
macro_rules! impl_mock { macro_rules! impl_mock {
@@ -43,8 +44,8 @@ impl_mock! {
fn get_fees_for_token_amount(&[TokenFeeRequest]) -> Result<Vec<TokenPayoutFee>, MuralError>; fn get_fees_for_token_amount(&[TokenFeeRequest]) -> Result<Vec<TokenPayoutFee>, MuralError>;
fn get_fees_for_fiat_amount(&[FiatFeeRequest]) -> Result<Vec<FiatPayoutFee>, MuralError>; fn get_fees_for_fiat_amount(&[FiatFeeRequest]) -> Result<Vec<FiatPayoutFee>, MuralError>;
fn create_payout_request(AccountId, Option<&str>, &[CreatePayout]) -> Result<PayoutRequest, MuralError>; fn create_payout_request(AccountId, Option<&str>, &[CreatePayout]) -> Result<PayoutRequest, MuralError>;
fn execute_payout_request(PayoutRequestId) -> Result<PayoutRequest, TransferError>; fn execute_payout_request(PayoutRequestId) -> Result<PayoutRequest, MuralError>;
fn cancel_payout_request(PayoutRequestId) -> Result<PayoutRequest, TransferError>; fn cancel_payout_request(PayoutRequestId) -> Result<PayoutRequest, MuralError>;
fn get_bank_details(&[FiatAndRailCode]) -> Result<BankDetailsResponse, MuralError>; fn get_bank_details(&[FiatAndRailCode]) -> Result<BankDetailsResponse, MuralError>;
fn search_payout_methods(CounterpartyId, Option<SearchParams<PayoutMethodId>>) -> Result<SearchResponse<PayoutMethodId, PayoutMethod>, MuralError>; fn search_payout_methods(CounterpartyId, Option<SearchParams<PayoutMethodId>>) -> Result<SearchResponse<PayoutMethodId, PayoutMethod>, MuralError>;
fn get_payout_method(CounterpartyId, PayoutMethodId) -> Result<PayoutMethod, MuralError>; fn get_payout_method(CounterpartyId, PayoutMethodId) -> Result<PayoutMethod, MuralError>;
@@ -56,6 +57,8 @@ impl_mock! {
fn get_counterparty(CounterpartyId) -> Result<Counterparty, MuralError>; fn get_counterparty(CounterpartyId) -> Result<Counterparty, MuralError>;
fn create_counterparty(&CreateCounterparty) -> Result<Counterparty, MuralError>; fn create_counterparty(&CreateCounterparty) -> Result<Counterparty, MuralError>;
fn update_counterparty(CounterpartyId, &UpdateCounterparty) -> Result<Counterparty, MuralError>; fn update_counterparty(CounterpartyId, &UpdateCounterparty) -> Result<Counterparty, MuralError>;
fn get_transaction(TransactionId) -> Result<Transaction, MuralError>;
fn search_transactions(AccountId, Option<SearchParams<AccountId>>) -> Result<SearchResponse<AccountId, Account>, MuralError>;
} }
impl Debug for MuralPayMock { impl Debug for MuralPayMock {

View File

@@ -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<arc_swap::ArcSwapOption<mock::MuralPayMock>>,
}
impl Client {
pub fn new(
api_url: impl Into<String>,
api_key: impl Into<SecretString>,
transfer_api_key: impl Into<SecretString>,
) -> 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<U: IntoUrl>(&self, make_url: impl FnOnce(&str) -> U) -> RequestBuilder {
self.http_req(|| self.http.get(make_url(&self.api_url)))
}
pub(crate) fn http_post<U: IntoUrl>(&self, make_url: impl FnOnce(&str) -> U) -> RequestBuilder {
self.http_req(|| self.http.post(make_url(&self.api_url)))
}
pub(crate) fn http_put<U: IntoUrl>(&self, make_url: impl FnOnce(&str) -> U) -> RequestBuilder {
self.http_req(|| self.http.put(make_url(&self.api_url)))
}
pub(crate) fn http_delete<U: IntoUrl>(
&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<T: DeserializeOwned>(
self,
) -> impl Future<Output = crate::Result<T>> + 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<T: DeserializeOwned>(self) -> crate::Result<T> {
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::<ApiError>(&json)
.map_err(|source| MuralError::DecodeError { source, json })?;
Err(MuralError::Api(err))
} else {
let json = resp.bytes().await?;
let t = serde_json::from_slice::<T>(&json)
.map_err(|source| MuralError::Decode { source, json })?;
Ok(t)
}
}
}

View File

@@ -1,96 +1,84 @@
use chrono::{DateTime, Utc}; use {
use derive_more::{Deref, Display}; crate::PhysicalAddress,
use serde::{Deserialize, Serialize}; chrono::{DateTime, Utc},
use std::str::FromStr; derive_more::{Deref, Display},
use uuid::Uuid; serde::{Deserialize, Serialize},
std::str::FromStr,
use crate::{ uuid::Uuid,
MuralError, MuralPay, PhysicalAddress, SearchParams, SearchResponse,
util::RequestExt,
}; };
impl MuralPay { #[cfg(feature = "client")]
pub async fn search_counterparties( const _: () = {
&self, use crate::{MuralError, RequestExt, SearchParams, SearchResponse};
params: Option<SearchParams<CounterpartyId>>,
) -> Result<SearchResponse<CounterpartyId, Counterparty>, MuralError> {
mock!(self, search_counterparties(params));
self.http_post(|base| format!("{base}/api/counterparties/search")) impl crate::Client {
.query(&params.map(|p| p.to_query()).unwrap_or_default()) pub async fn search_counterparties(
.send_mural() &self,
.await params: Option<SearchParams<CounterpartyId>>,
} ) -> Result<SearchResponse<CounterpartyId, Counterparty>, MuralError> {
maybe_mock!(self, search_counterparties(params));
pub async fn get_counterparty( self.http_post(|base| format!("{base}/api/counterparties/search"))
&self, .query(&params.map(|p| p.to_query()).unwrap_or_default())
id: CounterpartyId, .send_mural()
) -> Result<Counterparty, MuralError> { .await
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<Counterparty, MuralError> {
mock!(self, create_counterparty(counterparty));
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
counterparty: &'a CreateCounterparty,
} }
let body = Body { counterparty }; pub async fn get_counterparty(
&self,
id: CounterpartyId,
) -> Result<Counterparty, MuralError> {
maybe_mock!(self, get_counterparty(id));
self.http_post(|base| format!("{base}/api/counterparties")) self.http_get(|base| format!("{base}/api/counterparties/counterparty/{id}"))
.json(&body) .send_mural()
.send_mural() .await
.await
}
pub async fn update_counterparty(
&self,
id: CounterpartyId,
counterparty: &UpdateCounterparty,
) -> Result<Counterparty, MuralError> {
mock!(self, update_counterparty(id, counterparty));
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
counterparty: &'a UpdateCounterparty,
} }
let body = Body { counterparty }; pub async fn create_counterparty(
&self,
counterparty: &CreateCounterparty,
) -> Result<Counterparty, MuralError> {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
counterparty: &'a CreateCounterparty,
}
self.http_put(|base| { maybe_mock!(self, create_counterparty(counterparty));
format!("{base}/api/counterparties/counterparty/{id}")
}) let body = Body { counterparty };
.json(&body)
.send_mural() self.http_post(|base| format!("{base}/api/counterparties"))
.await .json(&body)
.send_mural()
.await
}
pub async fn update_counterparty(
&self,
id: CounterpartyId,
counterparty: &UpdateCounterparty,
) -> Result<Counterparty, MuralError> {
#[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( #[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)]
Debug,
Display,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Deref,
Serialize,
Deserialize,
)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[display("{}", _0.hyphenated())] #[display("{}", _0.hyphenated())]
pub struct CounterpartyId(pub Uuid); pub struct CounterpartyId(pub Uuid);
@@ -103,6 +91,12 @@ impl FromStr for CounterpartyId {
} }
} }
impl From<CounterpartyId> for Uuid {
fn from(value: CounterpartyId) -> Self {
value.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View File

@@ -1,6 +1,7 @@
#![doc = include_str!("../README.md")] #![doc = include_str!("../README.md")]
macro_rules! mock { #[cfg(feature = "client")]
macro_rules! maybe_mock {
($self:expr, $fn:ident ( $($args:expr),* $(,)? )) => { ($self:expr, $fn:ident ( $($args:expr),* $(,)? )) => {
#[cfg(feature = "mock")] #[cfg(feature = "mock")]
if let Some(mock) = &*($self).mock.load() { if let Some(mock) = &*($self).mock.load() {
@@ -11,26 +12,28 @@ macro_rules! mock {
mod account; mod account;
mod counterparty; mod counterparty;
mod error;
mod organization; mod organization;
mod payout; mod payout;
mod payout_method; mod payout_method;
mod serde_iso3166; mod serde_iso3166;
mod transaction;
mod util; mod util;
#[cfg(feature = "mock")]
pub mod mock;
pub use { pub use {
account::*, counterparty::*, error::*, organization::*, payout::*, account::*, counterparty::*, organization::*, payout::*, payout_method::*,
payout_method::*, transaction::*,
};
use {
rust_decimal::Decimal,
serde::{Deserialize, Serialize},
std::{ops::Deref, str::FromStr},
uuid::Uuid,
}; };
use rust_decimal::Decimal; #[cfg(feature = "client")]
use secrecy::SecretString; mod client;
use serde::{Deserialize, Serialize}; #[cfg(feature = "client")]
use std::{ops::Deref, str::FromStr}; pub use client::*;
use uuid::Uuid;
pub const API_URL: &str = "https://api.muralpay.com"; pub const API_URL: &str = "https://api.muralpay.com";
pub const SANDBOX_API_URL: &str = "https://api-staging.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. /// Default token symbol for [`TokenAmount::token_symbol`] values.
pub const USDC: &str = "USDC"; 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<SecretString>,
#[cfg(feature = "mock")]
mock: arc_swap::ArcSwapOption<mock::MuralPayMock>,
}
impl MuralPay {
pub fn new(
api_url: impl Into<String>,
api_key: impl Into<SecretString>,
transfer_api_key: Option<impl Into<SecretString>>,
) -> 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
@@ -119,7 +82,9 @@ pub enum FiatAccountType {
crate::util::display_as_serialize!(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))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum FiatAndRailCode { 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))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct WalletDetails { pub struct WalletDetails {
@@ -157,7 +122,7 @@ pub struct WalletDetails {
pub wallet_address: String, pub wallet_address: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TokenAmount { pub struct TokenAmount {
@@ -166,7 +131,7 @@ pub struct TokenAmount {
pub token_symbol: String, pub token_symbol: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct FiatAmount { pub struct FiatAmount {
@@ -195,7 +160,7 @@ impl<Id: Deref<Target = Uuid> + Clone> SearchParams<Id> {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SearchResponse<Id, T> { pub struct SearchResponse<Id, T> {

View File

@@ -1,91 +1,81 @@
use std::str::FromStr; use {
crate::CurrencyCode,
use chrono::{DateTime, Utc}; chrono::{DateTime, Utc},
use derive_more::{Deref, Display}; derive_more::{Deref, Display},
use secrecy::ExposeSecret; serde::{Deserialize, Serialize},
use serde::{Deserialize, Serialize}; std::str::FromStr,
use uuid::Uuid; uuid::Uuid,
use crate::{
CurrencyCode, MuralError, MuralPay, SearchResponse, util::RequestExt,
}; };
impl MuralPay { #[cfg(feature = "client")]
pub async fn search_organizations( const _: () = {
&self, use crate::{MuralError, RequestExt, SearchResponse};
req: SearchRequest,
) -> Result<SearchResponse<OrganizationId, Organization>, MuralError> {
mock!(self, search_organizations(req.clone()));
#[derive(Debug, Serialize)] impl crate::Client {
#[serde(rename_all = "camelCase")] pub async fn search_organizations(
struct Body { &self,
#[serde(skip_serializing_if = "Option::is_none")] req: SearchRequest,
filter: Option<Filter>, ) -> Result<SearchResponse<OrganizationId, Organization>, MuralError> {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body {
#[serde(skip_serializing_if = "Option::is_none")]
filter: Option<Filter>,
}
#[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::<Vec<_>>();
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)] pub async fn get_organization(
#[serde(rename_all = "camelCase")] &self,
struct Filter { id: OrganizationId,
#[serde(rename = "type")] ) -> Result<Organization, MuralError> {
ty: FilterType, maybe_mock!(self, get_organization(id));
name: String,
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::<Vec<_>>();
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( #[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)]
&self,
id: OrganizationId,
) -> Result<Organization, MuralError> {
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,
)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[display("{}", _0.hyphenated())] #[display("{}", _0.hyphenated())]
pub struct OrganizationId(pub Uuid); pub struct OrganizationId(pub Uuid);
@@ -98,6 +88,12 @@ impl FromStr for OrganizationId {
} }
} }
impl From<OrganizationId> for Uuid {
fn from(value: OrganizationId) -> Self {
value.0
}
}
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct SearchRequest { pub struct SearchRequest {

View File

@@ -6,170 +6,188 @@
) )
)] )]
use std::str::FromStr; use {
crate::{
use chrono::{DateTime, Utc}; AccountId, Blockchain, CounterpartyId, CurrencyCode, FiatAccountType,
use derive_more::{Deref, Display, Error, From}; FiatAmount, FiatAndRailCode, PayoutMethodId, TokenAmount,
use rust_decimal::Decimal; TransactionId, WalletDetails,
use rust_iso3166::CountryCode; },
use serde::{Deserialize, Serialize}; chrono::{DateTime, Utc},
use serde_with::{DeserializeFromStr, SerializeDisplay}; derive_more::{Deref, Display, Error, From},
use uuid::Uuid; rust_decimal::Decimal,
rust_iso3166::CountryCode,
use crate::{ serde::{Deserialize, Serialize},
AccountId, Blockchain, FiatAccountType, FiatAmount, FiatAndRailCode, serde_with::{DeserializeFromStr, SerializeDisplay},
MuralError, MuralPay, SearchParams, SearchResponse, TokenAmount, std::str::FromStr,
TransferError, WalletDetails, util::RequestExt, uuid::Uuid,
}; };
impl MuralPay { #[cfg(feature = "client")]
pub async fn search_payout_requests( const _: () = {
&self, use crate::{MuralError, RequestExt, SearchParams, SearchResponse};
filter: Option<PayoutStatusFilter>,
params: Option<SearchParams<PayoutRequestId>>,
) -> Result<SearchResponse<PayoutRequestId, PayoutRequest>, MuralError>
{
mock!(self, search_payout_requests(filter, params));
#[derive(Debug, Serialize)] impl crate::Client {
#[serde(rename_all = "camelCase")] pub async fn search_payout_requests(
struct Body { &self,
// if we submit `null`, Mural errors; we have to explicitly exclude this field
#[serde(skip_serializing_if = "Option::is_none")]
filter: Option<PayoutStatusFilter>, filter: Option<PayoutStatusFilter>,
params: Option<SearchParams<PayoutRequestId>>,
) -> Result<SearchResponse<PayoutRequestId, PayoutRequest>, 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<PayoutStatusFilter>,
}
maybe_mock!(self, search_payout_requests(filter, params));
let body = Body { filter };
self.http_post(|base| format!("{base}/api/payouts/search"))
.query(&params.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<PayoutRequest, MuralError> {
maybe_mock!(self, get_payout_request(id));
self.http_post(|base| format!("{base}/api/payouts/search")) self.http_get(|base| format!("{base}/api/payouts/payout/{id}"))
.query(&params.map(|p| p.to_query()).unwrap_or_default()) .send_mural()
.await
}
pub async fn get_fees_for_token_amount(
&self,
token_fee_requests: &[TokenFeeRequest],
) -> Result<Vec<TokenPayoutFee>, 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) .json(&body)
.send_mural() .send_mural()
.await .await
}
pub async fn get_payout_request(
&self,
id: PayoutRequestId,
) -> Result<PayoutRequest, MuralError> {
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<Vec<TokenPayoutFee>, 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<Vec<FiatPayoutFee>, 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) .json(&body)
.send_mural() .send_mural()
.await .await
}
pub async fn get_fees_for_fiat_amount(
&self,
fiat_fee_requests: &[FiatFeeRequest],
) -> Result<Vec<FiatPayoutFee>, 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 }; pub async fn create_payout_request(
&self,
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<impl AsRef<str>>,
payouts: &[CreatePayout],
) -> Result<PayoutRequest, MuralError> {
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> {
source_account_id: AccountId, source_account_id: AccountId,
memo: Option<&'a str>, memo: Option<impl AsRef<str>>,
payouts: &'a [CreatePayout], payouts: &[CreatePayout],
) -> Result<PayoutRequest, MuralError> {
#[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 { pub async fn execute_payout_request(
source_account_id, &self,
memo: memo.as_ref().map(|x| x.as_ref()), id: PayoutRequestId,
payouts, ) -> Result<PayoutRequest, MuralError> {
}; maybe_mock!(self, execute_payout_request(id));
self.http_post(|base| format!("{base}/api/payouts/payout")) self.http_post(|base| {
.json(&body) format!("{base}/api/payouts/payout/{id}/execute")
})
.transfer_auth(self)
.send_mural() .send_mural()
.await .await
} }
pub async fn execute_payout_request( pub async fn cancel_payout_request(
&self, &self,
id: PayoutRequestId, id: PayoutRequestId,
) -> Result<PayoutRequest, TransferError> { ) -> Result<PayoutRequest, MuralError> {
mock!(self, execute_payout_request(id)); maybe_mock!(self, cancel_payout_request(id));
self.http_post(|base| format!("{base}/api/payouts/payout/{id}/execute")) self.http_post(|base| {
.transfer_auth(self)? format!("{base}/api/payouts/payout/{id}/cancel")
})
.transfer_auth(self)
.send_mural() .send_mural()
.await .await
.map_err(From::from) }
pub async fn get_bank_details(
&self,
fiat_currency_and_rail: &[FiatAndRailCode],
) -> Result<BankDetailsResponse, MuralError> {
maybe_mock!(self, get_bank_details(fiat_currency_and_rail));
let query = fiat_currency_and_rail
.iter()
.map(|code| ("fiatCurrencyAndRail", code.to_string()))
.collect::<Vec<_>>();
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<PayoutRequest, TransferError> {
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<BankDetailsResponse, MuralError> {
mock!(self, get_bank_details(fiat_currency_and_rail));
let query = fiat_currency_and_rail
.iter()
.map(|code| ("fiatCurrencyAndRail", code.to_string()))
.collect::<Vec<_>>();
self.http_get(|base| format!("{base}/api/payouts/bank-details"))
.query(&query)
.send_mural()
.await
}
}
#[derive( #[derive(
Debug, Debug,
@@ -195,6 +213,12 @@ impl FromStr for PayoutRequestId {
} }
} }
impl From<PayoutRequestId> for Uuid {
fn from(value: PayoutRequestId) -> Self {
value.0
}
}
#[derive( #[derive(
Debug, Debug,
Display, Display,
@@ -219,6 +243,12 @@ impl FromStr for PayoutId {
} }
} }
impl From<PayoutId> for Uuid {
fn from(value: PayoutId) -> Self {
value.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")] #[serde(tag = "type", rename_all = "camelCase")]
@@ -226,7 +256,7 @@ pub enum PayoutStatusFilter {
PayoutStatus { statuses: Vec<PayoutStatus> }, PayoutStatus { statuses: Vec<PayoutStatus> },
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PayoutRequest { pub struct PayoutRequest {
@@ -251,7 +281,7 @@ pub enum PayoutStatus {
Failed, Failed,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Payout { pub struct Payout {
@@ -260,9 +290,10 @@ pub struct Payout {
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
pub amount: TokenAmount, pub amount: TokenAmount,
pub details: PayoutDetails, 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))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")] #[serde(tag = "type", rename_all = "camelCase")]
pub enum PayoutDetails { pub enum PayoutDetails {
@@ -270,7 +301,7 @@ pub enum PayoutDetails {
Blockchain(BlockchainPayoutDetails), Blockchain(BlockchainPayoutDetails),
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct FiatPayoutDetails { pub struct FiatPayoutDetails {
@@ -286,7 +317,7 @@ pub struct FiatPayoutDetails {
pub developer_fee: Option<DeveloperFee>, pub developer_fee: Option<DeveloperFee>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "kebab-case")] #[serde(tag = "type", rename_all = "kebab-case")]
pub enum FiatPayoutStatus { pub enum FiatPayoutStatus {
@@ -310,7 +341,69 @@ pub enum FiatPayoutStatus {
reason: String, reason: String,
error_code: FiatPayoutErrorCode, error_code: FiatPayoutErrorCode,
}, },
#[serde(rename_all = "camelCase")]
Canceled, Canceled,
// since 1.31
#[serde(rename_all = "camelCase")]
RefundInProgress {
error_code: RefundErrorCode,
failure_reason: String,
refund_initiated_at: DateTime<Utc>,
},
// since 1.31
#[serde(rename_all = "camelCase")]
Refunded {
error_code: RefundErrorCode,
failure_reason: String,
refund_completed_at: DateTime<Utc>,
refund_initiated_at: DateTime<Utc>,
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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -325,7 +418,7 @@ pub enum FiatPayoutErrorCode {
BeneficiaryDocumentationIncorrect, BeneficiaryDocumentationIncorrect,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DeveloperFee { pub struct DeveloperFee {
@@ -333,7 +426,7 @@ pub struct DeveloperFee {
pub developer_fee_percentage: Option<Decimal>, pub developer_fee_percentage: Option<Decimal>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BlockchainPayoutDetails { pub struct BlockchainPayoutDetails {
@@ -353,13 +446,51 @@ pub enum BlockchainPayoutStatus {
Canceled, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CreatePayout { pub struct CreatePayout {
pub amount: TokenAmount, pub amount: TokenAmount,
pub payout_details: CreatePayoutDetails, pub payout_details: CreatePayoutDetails,
pub recipient_info: PayoutRecipientInfo, pub recipient_info: CreatePayoutRecipientInfo,
pub supporting_details: Option<SupportingDetails>, pub supporting_details: Option<SupportingDetails>,
} }
@@ -487,7 +618,8 @@ pub enum FiatAndRailDetails {
} }
impl FiatAndRailDetails { impl FiatAndRailDetails {
pub fn code(&self) -> FiatAndRailCode { #[must_use]
pub const fn code(&self) -> FiatAndRailCode {
match self { match self {
Self::Usd { .. } => FiatAndRailCode::Usd, Self::Usd { .. } => FiatAndRailCode::Usd,
Self::Cop { .. } => FiatAndRailCode::Cop, Self::Cop { .. } => FiatAndRailCode::Cop,
@@ -607,7 +739,7 @@ pub enum PixAccountType {
#[derive(Debug, Clone, Serialize, Deserialize, From)] #[derive(Debug, Clone, Serialize, Deserialize, From)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")] #[serde(tag = "type", rename_all = "camelCase")]
pub enum PayoutRecipientInfo { pub enum CreatePayoutRecipientInfo {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
Individual { Individual {
first_name: String, first_name: String,
@@ -624,20 +756,23 @@ pub enum PayoutRecipientInfo {
}, },
} }
impl PayoutRecipientInfo { impl CreatePayoutRecipientInfo {
#[must_use]
pub fn email(&self) -> &str { pub fn email(&self) -> &str {
match self { match self {
PayoutRecipientInfo::Individual { email, .. } => email, Self::Individual { email, .. } | Self::Business { email, .. } => {
PayoutRecipientInfo::Business { email, .. } => email, email
}
} }
} }
pub fn physical_address(&self) -> &PhysicalAddress { #[must_use]
pub const fn physical_address(&self) -> &PhysicalAddress {
match self { match self {
PayoutRecipientInfo::Individual { Self::Individual {
physical_address, .. physical_address, ..
} => physical_address, }
PayoutRecipientInfo::Business { | Self::Business {
physical_address, .. physical_address, ..
} => physical_address, } => physical_address,
} }

View File

@@ -1,90 +1,104 @@
use std::str::FromStr; use {
crate::{
use chrono::{DateTime, Utc}; ArsSymbol, BobSymbol, BrlSymbol, ClpSymbol, CopSymbol, CounterpartyId, CrcSymbol,
use derive_more::{Deref, Display, Error}; DocumentType, EurSymbol, FiatAccountType, MxnSymbol, PenSymbol, UsdSymbol, WalletDetails,
use serde::{Deserialize, Serialize}; ZarSymbol,
use serde_with::DeserializeFromStr; },
use uuid::Uuid; chrono::{DateTime, Utc},
derive_more::{Deref, Display, Error},
use crate::{ serde::{Deserialize, Serialize},
ArsSymbol, BobSymbol, BrlSymbol, ClpSymbol, CopSymbol, CounterpartyId, serde_with::DeserializeFromStr,
CrcSymbol, DocumentType, EurSymbol, FiatAccountType, MuralError, MuralPay, std::str::FromStr,
MxnSymbol, PenSymbol, SearchParams, SearchResponse, UsdSymbol, uuid::Uuid,
WalletDetails, ZarSymbol, util::RequestExt,
}; };
impl MuralPay { #[cfg(feature = "client")]
pub async fn search_payout_methods( const _: () = {
&self, use crate::{MuralError, RequestExt, SearchParams, SearchResponse};
counterparty_id: CounterpartyId,
params: Option<SearchParams<PayoutMethodId>>,
) -> Result<SearchResponse<PayoutMethodId, PayoutMethod>, MuralError> {
mock!(self, search_payout_methods(counterparty_id, params));
self.http_post(|base| { impl crate::Client {
format!( pub async fn search_payout_methods(
"{base}/api/counterparties/{counterparty_id}/payout-methods/search" &self,
) counterparty_id: CounterpartyId,
}) params: Option<SearchParams<PayoutMethodId>>,
.query(&params.map(|p| p.to_query()).unwrap_or_default()) ) -> Result<SearchResponse<PayoutMethodId, PayoutMethod>, MuralError> {
.send_mural() maybe_mock!(self, search_payout_methods(counterparty_id, params));
.await
}
pub async fn get_payout_method( self.http_post(|base| {
&self, format!("{base}/api/counterparties/{counterparty_id}/payout-methods/search")
counterparty_id: CounterpartyId, })
payout_method_id: PayoutMethodId, .query(&params.map(|p| p.to_query()).unwrap_or_default())
) -> Result<PayoutMethod, MuralError> {
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}"))
.send_mural() .send_mural()
.await .await
}
pub async fn create_payout_method(
&self,
counterparty_id: CounterpartyId,
alias: impl AsRef<str>,
payout_method: &PayoutMethodDetails,
) -> Result<PayoutMethod, MuralError> {
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 { pub async fn get_payout_method(
alias: alias.as_ref(), &self,
payout_method, counterparty_id: CounterpartyId,
}; payout_method_id: PayoutMethodId,
) -> Result<PayoutMethod, MuralError> {
maybe_mock!(self, get_payout_method(counterparty_id, payout_method_id));
self.http_post(|base| { self.http_get(|base| {
format!( format!(
"{base}/api/counterparties/{counterparty_id}/payout-methods" "{base}/api/counterparties/{counterparty_id}/payout-methods/{payout_method_id}"
) )
}) })
.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}"))
.send_mural() .send_mural()
.await .await
}
pub async fn create_payout_method(
&self,
counterparty_id: CounterpartyId,
alias: impl AsRef<str>,
payout_method: &PayoutMethodDetails,
) -> Result<PayoutMethod, MuralError> {
#[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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
@@ -107,18 +121,7 @@ pub enum PayoutMethodPixAccountType {
BankAccount, BankAccount,
} }
#[derive( #[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deref, Serialize, Deserialize)]
Debug,
Display,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Deref,
Serialize,
Deserialize,
)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[display("{}", _0.hyphenated())] #[display("{}", _0.hyphenated())]
pub struct PayoutMethodId(pub Uuid); pub struct PayoutMethodId(pub Uuid);

View File

@@ -1,12 +1,10 @@
use serde::{Deserialize, de::Error}; use {
use std::borrow::Cow; rust_iso3166::CountryCode,
serde::{Deserialize, de::Error},
std::borrow::Cow,
};
use rust_iso3166::CountryCode; pub fn serialize<S: serde::Serializer>(v: &CountryCode, serializer: S) -> Result<S::Ok, S::Error> {
pub fn serialize<S: serde::Serializer>(
v: &CountryCode,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(v.alpha2) serializer.serialize_str(v.alpha2)
} }
@@ -17,8 +15,6 @@ pub fn deserialize<'de, D: serde::Deserializer<'de>>(
rust_iso3166::ALPHA2_MAP rust_iso3166::ALPHA2_MAP
.get(&country_code) .get(&country_code)
.copied() .copied()
.ok_or_else(|| { .ok_or_else(|| D::Error::custom("invalid ISO 3166 alpha-2 country code"))
D::Error::custom("invalid ISO 3166 alpha-2 country code")
})
}) })
} }

View File

@@ -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<Transaction, MuralError> {
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<SearchParams<AccountId>>,
) -> Result<SearchResponse<AccountId, Account>, MuralError> {
maybe_mock!(self, search_transactions(account_id, params));
self.http_post(|base| format!("{base}/api/transactions/search/account/{account_id}"))
.query(&params.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 = <Uuid as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<Uuid>().map(Self)
}
}
impl From<TransactionId> 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<Utc>,
pub memo: Option<String>,
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<Utc>,
sent_fiat_amount: FiatAmount,
sender_metadata: Option<SenderMetadata>,
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<String>,
trace_number: String,
},
#[serde(rename_all = "camelCase")]
Wire {
wire_routing_number: String,
sender_name: Option<String>,
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<Utc>,
completed_at: DateTime<Utc>,
},
}

View File

@@ -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<U: IntoUrl>(
&self,
make_url: impl FnOnce(&str) -> U,
) -> RequestBuilder {
self.http_req(|| self.http.get(make_url(&self.api_url)))
}
pub(crate) fn http_post<U: IntoUrl>(
&self,
make_url: impl FnOnce(&str) -> U,
) -> RequestBuilder {
self.http_req(|| self.http.post(make_url(&self.api_url)))
}
pub(crate) fn http_put<U: IntoUrl>(
&self,
make_url: impl FnOnce(&str) -> U,
) -> RequestBuilder {
self.http_req(|| self.http.put(make_url(&self.api_url)))
}
pub(crate) fn http_delete<U: IntoUrl>(
&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<Self, TransferError>;
async fn send_mural<T: DeserializeOwned>(self) -> crate::Result<T>;
}
const HEADER_TRANSFER_API_KEY: &str = "transfer-api-key";
impl RequestExt for reqwest::RequestBuilder {
fn transfer_auth(self, client: &MuralPay) -> Result<Self, TransferError> {
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<T: DeserializeOwned>(self) -> crate::Result<T> {
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::<ApiError>(&json)
.map_err(|source| MuralError::DecodeError { source, json })?;
Err(MuralError::Api(err))
} else {
let json = resp.bytes().await?;
let t = serde_json::from_slice::<T>(&json)
.map_err(|source| MuralError::Decode { source, json })?;
Ok(t)
}
}
}
macro_rules! display_as_serialize { macro_rules! display_as_serialize {
($T:ty) => { ($T:ty) => {
const _: () = { const _: () = {
@@ -87,8 +5,7 @@ macro_rules! display_as_serialize {
impl fmt::Display for $T { impl fmt::Display for $T {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = let value = serde_json::to_value(self).map_err(|_| fmt::Error)?;
serde_json::to_value(self).map_err(|_| fmt::Error)?;
let value = value.as_str().ok_or(fmt::Error)?; let value = value.as_str().ok_or(fmt::Error)?;
write!(f, "{value}") write!(f, "{value}")
} }
@@ -96,5 +13,4 @@ macro_rules! display_as_serialize {
}; };
}; };
} }
pub(crate) use display_as_serialize; pub(crate) use display_as_serialize;