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

@@ -28,7 +28,7 @@ use rust_decimal::{Decimal, RoundingStrategy};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::Sha256;
use sqlx::PgPool;
use sqlx::{PgPool, PgTransaction};
use std::collections::HashMap;
use tokio_stream::StreamExt;
use tracing::error;
@@ -623,29 +623,22 @@ pub async fn create_payout(
total_fee: fees.total_fee(),
sent_to_method,
payouts_queue: &payouts_queue,
db: PgPool::clone(&pool),
transaction,
};
let payout_item = match &body.method {
match &body.method {
PayoutMethodRequest::PayPal | PayoutMethodRequest::Venmo => {
paypal_payout(payout_cx).await?
paypal_payout(payout_cx).await?;
}
PayoutMethodRequest::Tremendous { method_details } => {
tremendous_payout(payout_cx, method_details).await?
tremendous_payout(payout_cx, method_details).await?;
}
PayoutMethodRequest::MuralPay { method_details } => {
mural_pay_payout(payout_cx, method_details, &gotenberg).await?
mural_pay_payout(payout_cx, method_details, &gotenberg).await?;
}
};
}
payout_item
.insert(&mut transaction)
.await
.wrap_internal_err("failed to insert payout")?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis)
.await
.wrap_internal_err("failed to clear user caches")?;
@@ -653,7 +646,6 @@ pub async fn create_payout(
Ok(())
}
#[derive(Clone, Copy)]
struct PayoutContext<'a> {
body: &'a Withdrawal,
user: &'a DBUser,
@@ -666,6 +658,8 @@ struct PayoutContext<'a> {
total_fee: Decimal2dp,
sent_to_method: Decimal2dp,
payouts_queue: &'a PayoutsQueue,
db: PgPool,
transaction: PgTransaction<'a>,
}
fn get_verified_email(user: &DBUser) -> Result<&str, ApiError> {
@@ -692,12 +686,14 @@ async fn tremendous_payout(
total_fee,
sent_to_method,
payouts_queue,
db: _,
mut transaction,
}: PayoutContext<'_>,
TremendousDetails {
delivery_email,
currency,
}: &TremendousDetails,
) -> Result<DBPayout, ApiError> {
) -> Result<(), ApiError> {
let user_email = get_verified_email(user)?;
#[derive(Deserialize)]
@@ -773,7 +769,7 @@ async fn tremendous_payout(
let platform_id = res.order.rewards.first().map(|reward| reward.id.clone());
Ok(DBPayout {
DBPayout {
id: payout_id,
user_id: user.id,
created: Utc::now(),
@@ -784,7 +780,17 @@ async fn tremendous_payout(
method_id: Some(body.method_id.clone()),
method_address: Some(user_email.to_string()),
platform_id,
})
}
.insert(&mut transaction)
.await
.wrap_internal_err("failed to insert payout")?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
Ok(())
}
async fn mural_pay_payout(
@@ -798,12 +804,35 @@ async fn mural_pay_payout(
total_fee,
sent_to_method: _,
payouts_queue,
db,
mut transaction,
}: PayoutContext<'_>,
details: &MuralPayDetails,
gotenberg: &GotenbergClient,
) -> Result<DBPayout, ApiError> {
) -> Result<(), ApiError> {
let user_email = get_verified_email(user)?;
let method_id = match &details.payout_details {
MuralPayoutRequest::Blockchain { .. } => {
"blockchain-usdc-polygon".to_string()
}
MuralPayoutRequest::Fiat {
fiat_and_rail_details,
..
} => fiat_and_rail_details.code().to_string(),
};
// Once the Mural payout request has been created successfully,
// then we *must* commit the payout into the DB,
// to link the Mural payout request to the `payout` row.
// Even if we can't execute the payout.
// For this, we immediately insert and commit the txn.
// Otherwise if we don't put it into the DB, we've got a ghost Mural
// payout with no related database entry.
//
// However, this doesn't mean that the payout will definitely go through.
// For this, we need to execute it, and handle errors.
let payout_request = payouts_queue
.create_muralpay_payout_request(
payout_id,
@@ -816,22 +845,13 @@ async fn mural_pay_payout(
)
.await?;
let method_id = match &details.payout_details {
MuralPayoutRequest::Blockchain { .. } => {
"blockchain-usdc-polygon".to_string()
}
MuralPayoutRequest::Fiat {
fiat_and_rail_details,
..
} => fiat_and_rail_details.code().to_string(),
};
Ok(DBPayout {
let payout = DBPayout {
id: payout_id,
user_id: user.id,
created: Utc::now(),
// after the payout has been successfully executed,
// we wait for Mural's confirmation that the funds have been delivered
// done in `SyncPayoutStatuses` background task
status: PayoutStatus::InTransit,
amount: amount_minus_fee.get(),
fee: Some(total_fee.get()),
@@ -839,7 +859,61 @@ async fn mural_pay_payout(
method_id: Some(method_id),
method_address: Some(user_email.to_string()),
platform_id: Some(payout_request.id.to_string()),
})
};
payout
.insert(&mut transaction)
.await
.wrap_internal_err("failed to insert payout")?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit payout insert transaction")?;
// try to immediately execute the payout request...
// use a poor man's try/catch block using this `async move {}`
// to catch any errors within this block
let result = async move {
payouts_queue
.execute_mural_payout_request(payout_request.id)
.await
.wrap_internal_err("failed to execute payout request")?;
eyre::Ok(())
}
.await;
// and if it fails, make sure to immediately cancel it -
// we don't want floating payout requests
if let Err(err) = result {
if let Err(err) = sqlx::query!(
"
UPDATE payouts
SET status = $1
WHERE id = $2
",
PayoutStatus::Failed.as_str(),
payout.id as _,
)
.execute(&db)
.await
{
error!(
"Created a Mural payout request, but failed to execute it, \
and failed to mark the payout as failed: {err:#?}"
);
}
payouts_queue
.cancel_mural_payout_request(payout_request.id)
.await
.wrap_internal_err_with(|| {
eyre!("failed to cancel unexecuted payout request\noriginal error: {err:#?}")
})?;
return Err(ApiError::Internal(err));
}
Ok(())
}
async fn paypal_payout(
@@ -853,8 +927,10 @@ async fn paypal_payout(
total_fee,
sent_to_method,
payouts_queue,
db: _,
mut transaction,
}: PayoutContext<'_>,
) -> Result<DBPayout, ApiError> {
) -> Result<(), ApiError> {
let (wallet, wallet_type, address, display_address) =
if matches!(body.method, PayoutMethodRequest::Venmo) {
if let Some(venmo) = &user.venmo_handle {
@@ -965,7 +1041,7 @@ async fn paypal_payout(
let platform_id = Some(data.payout_item_id.clone());
Ok(DBPayout {
DBPayout {
id: payout_id,
user_id: user.id,
created: Utc::now(),
@@ -976,7 +1052,17 @@ async fn paypal_payout(
method_id: Some(body.method_id.clone()),
method_address: Some(display_address.clone()),
platform_id,
})
}
.insert(&mut transaction)
.await
.wrap_internal_err("failed to insert payout")?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
Ok(())
}
/// User performing a payout-related action.
@@ -1201,7 +1287,7 @@ pub async fn cancel_payout(
.parse::<muralpay::PayoutRequestId>()
.wrap_request_err("invalid payout request ID")?;
payouts
.cancel_muralpay_payout_request(payout_request_id)
.cancel_mural_payout_request(payout_request_id)
.await
.wrap_internal_err(
"failed to cancel payout request",