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

View File

@@ -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<MuralPay> {
pub fn create_muralpay_client() -> Result<muralpay::Client> {
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<MuralPayConfig> {

View File

@@ -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<muralpay::PayoutRequest, ApiError> {
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::<String>::new();
let mut payout_new_statuses = Vec::<String>::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(