Files
AstralRinth/apps/labrinth/src/queue/payouts/mural.rs
aecsocket 9aa06fbc26 Fix Mural payout status syncing (#4853)
* Fix Mural payout status syncing

* Make Mural payout code more resilient

* prepare sqlx

* fix test
2025-12-08 20:34:41 +00:00

887 lines
30 KiB
Rust

use ariadne::ids::UserId;
use chrono::Utc;
use eyre::{Result, eyre};
use futures::{StreamExt, TryFutureExt, stream::FuturesUnordered};
use modrinth_util::decimal::Decimal2dp;
use muralpay::{MuralError, TokenFeeRequest};
use rust_decimal::{Decimal, prelude::ToPrimitive};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use tracing::{info, trace, warn};
use crate::{
database::models::DBPayoutId,
models::payouts::{PayoutMethodType, PayoutStatus},
queue::payouts::{AccountBalance, PayoutFees, PayoutsQueue},
routes::ApiError,
util::{
error::Context,
gotenberg::{GotenbergClient, PaymentStatement},
},
};
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MuralPayoutRequest {
Fiat {
bank_name: String,
bank_account_owner: String,
fiat_and_rail_details: muralpay::FiatAndRailDetails,
},
Blockchain {
wallet_address: String,
},
}
impl PayoutsQueue {
pub async fn compute_muralpay_fees(
&self,
amount: Decimal2dp,
fiat_and_rail_code: muralpay::FiatAndRailCode,
) -> Result<muralpay::TokenPayoutFee, ApiError> {
let muralpay = self.muralpay.load();
let muralpay = muralpay
.as_ref()
.wrap_internal_err("Mural Pay client not available")?;
let fees = muralpay
.client
.get_fees_for_token_amount(&[TokenFeeRequest {
amount: muralpay::TokenAmount {
token_symbol: muralpay::USDC.into(),
token_amount: amount.get(),
},
fiat_and_rail_code,
}])
.await
.wrap_internal_err("failed to request fees")?;
let fee = fees
.into_iter()
.next()
.wrap_internal_err("no fees returned")?;
Ok(fee)
}
pub async fn create_muralpay_payout_request(
&self,
payout_id: DBPayoutId,
user_id: UserId,
gross_amount: Decimal2dp,
fees: PayoutFees,
payout_details: MuralPayoutRequest,
recipient_info: muralpay::CreatePayoutRecipientInfo,
gotenberg: &GotenbergClient,
) -> Result<muralpay::PayoutRequest, ApiError> {
let muralpay = self.muralpay.load();
let muralpay = muralpay
.as_ref()
.wrap_internal_err("Mural Pay client not available")?;
let payout_details = match payout_details {
crate::queue::payouts::mural::MuralPayoutRequest::Fiat {
bank_name,
bank_account_owner,
fiat_and_rail_details,
} => muralpay::CreatePayoutDetails::Fiat {
bank_name,
bank_account_owner,
developer_fee: None,
fiat_and_rail_details,
},
crate::queue::payouts::mural::MuralPayoutRequest::Blockchain {
wallet_address,
} => {
muralpay::CreatePayoutDetails::Blockchain {
wallet_details: muralpay::WalletDetails {
// only Polygon chain is currently supported
blockchain: muralpay::Blockchain::Polygon,
wallet_address,
},
}
}
};
// Mural takes `fees.method_fee` off the top of the amount we tell them to send
let sent_to_method = gross_amount - fees.platform_fee;
// ..so the net is `gross - platform_fee - method_fee`
let net_amount = gross_amount - fees.total_fee();
let recipient_address = recipient_info.physical_address();
let recipient_email = recipient_info.email().to_string();
let gross_amount_cents = gross_amount.get() * Decimal::from(100);
let net_amount_cents = net_amount.get() * Decimal::from(100);
let fees_cents = fees.total_fee().get() * Decimal::from(100);
let address_line_3 = format!(
"{}, {}, {}",
recipient_address.city,
recipient_address.state,
recipient_address.zip
);
let payment_statement = PaymentStatement {
payment_id: payout_id.into(),
recipient_address_line_1: Some(recipient_address.address1.clone()),
recipient_address_line_2: recipient_address.address2.clone(),
recipient_address_line_3: Some(address_line_3),
recipient_email,
payment_date: Utc::now(),
gross_amount_cents: gross_amount_cents
.to_i64()
.wrap_internal_err_with(|| eyre!("gross amount of cents `{gross_amount_cents}` cannot be expressed as an `i64`"))?,
net_amount_cents: net_amount_cents
.to_i64()
.wrap_internal_err_with(|| eyre!("net amount of cents `{net_amount_cents}` cannot be expressed as an `i64`"))?,
fees_cents: fees_cents
.to_i64()
.wrap_internal_err_with(|| eyre!("fees amount of cents `{fees_cents}` cannot be expressed as an `i64`"))?,
currency_code: "USD".into(),
};
let payment_statement_doc = gotenberg
.wait_for_payment_statement(&payment_statement)
.await
.wrap_internal_err("failed to generate payment statement")?;
// TODO
// std::fs::write(
// "/tmp/modrinth-payout-statement.pdf",
// base64::Engine::decode(
// &base64::engine::general_purpose::STANDARD,
// &payment_statement_doc.body,
// )
// .unwrap(),
// )
// .unwrap();
let payout = muralpay::CreatePayout {
amount: muralpay::TokenAmount {
token_amount: sent_to_method.get(),
token_symbol: muralpay::USDC.into(),
},
payout_details,
recipient_info,
supporting_details: Some(muralpay::SupportingDetails {
supporting_document: Some(format!(
"data:application/pdf;base64,{}",
payment_statement_doc.body
)),
payout_purpose: Some(muralpay::PayoutPurpose::VendorPayment),
}),
};
let payout_request = muralpay
.client
.create_payout_request(
muralpay.source_account_id,
Some(format!("User {user_id}")),
&[payout],
)
.await
.map_err(|err| match err {
MuralError::Api(err) => ApiError::Request(err.into()),
err => ApiError::Internal(
eyre!(err).wrap_err("failed to create payout request"),
),
})?;
Ok(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<()> {
let muralpay = self.muralpay.load();
let muralpay = muralpay
.as_ref()
.wrap_err("Mural Pay client not available")?;
muralpay.client.cancel_payout_request(id).await?;
Ok(())
}
pub async fn get_mural_balance(
&self,
) -> Result<Option<AccountBalance>, ApiError> {
let muralpay = self.muralpay.load();
let muralpay = muralpay
.as_ref()
.wrap_internal_err("Mural Pay client not available")?;
let account = muralpay
.client
.get_account(muralpay.source_account_id)
.await
.wrap_internal_err("failed to get source account")?;
let details = account
.account_details
.wrap_internal_err("source account does not have details")?;
let available = details
.balances
.iter()
.map(|balance| {
if balance.token_symbol == muralpay::USDC {
balance.token_amount
} else {
Decimal::ZERO
}
})
.sum::<Decimal>();
Ok(Some(AccountBalance {
available,
pending: Decimal::ZERO,
}))
}
}
/// Finds Labrinth payouts which are not complete, fetches their corresponding
/// Mural state, and updates the payout status.
pub async fn sync_pending_payouts_from_mural(
db: &PgPool,
mural: &muralpay::Client,
limit: u32,
) -> eyre::Result<()> {
#[derive(Debug)]
struct UpdatePayoutOp {
payout_id: i64,
status: PayoutStatus,
}
info!("Syncing pending payouts from Mural");
let mut txn = db
.begin()
.await
.wrap_internal_err("failed to begin transaction")?;
let rows = sqlx::query!(
"
SELECT id, platform_id FROM payouts
WHERE
method = $1
AND status = ANY($2::text[])
LIMIT $3
",
&PayoutMethodType::MuralPay.to_string(),
&[
PayoutStatus::InTransit,
PayoutStatus::Unknown,
PayoutStatus::Cancelling
]
.iter()
.map(|s| s.to_string())
.collect::<Vec<String>>(),
i64::from(limit),
)
.fetch_all(&mut *txn)
.await
.wrap_internal_err("failed to fetch incomplete Mural payouts")?;
info!("Found {} incomplete Mural payouts", rows.len());
let futs = rows.into_iter().map(|row| async move {
let platform_id = row.platform_id.wrap_err("no platform ID")?;
let payout_request_id = platform_id.parse::<muralpay::PayoutRequestId>()
.wrap_err_with(|| eyre!("platform ID '{platform_id:?}' is not a valid payout request ID"))?;
let payout_request = mural.get_payout_request(payout_request_id).await
.wrap_err_with(|| eyre!("failed to fetch payout request {payout_request_id}"))?;
let new_payout_status = match payout_request.status {
muralpay::PayoutStatus::Canceled => Some(PayoutStatus::Cancelled),
muralpay::PayoutStatus::Executed => Some(PayoutStatus::Success),
muralpay::PayoutStatus::Failed => Some(PayoutStatus::Failed),
_ => None,
};
if let Some(status) = new_payout_status {
eyre::Ok(Some(UpdatePayoutOp {
payout_id: row.id,
status
}))
} else {
eyre::Ok(None)
}
}.map_err(move |err| eyre!(err).wrap_err(eyre!("failed to update payout with ID '{}'", row.id))));
let mut futs = futs.collect::<FuturesUnordered<_>>();
let mut payout_ids = Vec::<i64>::new();
let mut payout_statuses = Vec::<String>::new();
while let Some(result) = futs.next().await {
let op = match result {
Ok(Some(op)) => op,
Ok(None) => continue,
Err(err) => {
warn!("Failed to update payout: {err:#?}");
continue;
}
};
payout_ids.push(op.payout_id);
payout_statuses.push(op.status.to_string());
}
sqlx::query!(
"
UPDATE payouts
SET status = u.status
FROM UNNEST($1::bigint[], $2::varchar[]) AS u(id, status)
WHERE payouts.id = u.id
",
&payout_ids,
&payout_statuses,
)
.execute(&mut *txn)
.await
.wrap_internal_err("failed to update payout statuses")?;
txn.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
Ok(())
}
/// 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::Client,
limit: u32,
) -> eyre::Result<()> {
info!("Syncing failed Mural payouts to Labrinth");
let mut next_id = None;
loop {
let search_resp = mural
.search_payout_requests(
None,
Some(muralpay::SearchParams {
limit: Some(u64::from(limit)),
next_id,
}),
)
.await
.wrap_internal_err(
"failed to fetch failed payout requests from Mural",
)?;
if search_resp.results.is_empty() {
break;
}
next_id = search_resp.next_id;
let mut payout_platform_ids = Vec::<String>::new();
let mut payout_new_statuses = Vec::<String>::new();
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
);
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,
};
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!(
"
UPDATE payouts
SET status = u.status
FROM UNNEST($1::text[], $2::text[]) AS u(platform_id, status)
WHERE
payouts.method = $3
AND payouts.platform_id = u.platform_id
",
&payout_platform_ids,
&payout_new_statuses,
PayoutMethodType::MuralPay.as_str(),
)
.execute(db)
.await
.wrap_internal_err("failed to update payout statuses")?;
info!(
"Attempted to update {} payouts in database from Mural info, {} rows affected",
payout_platform_ids.len(),
result.rows_affected()
);
if next_id.is_none() {
break;
}
}
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::*;
use crate::test::{
api_v3::ApiV3,
environment::{TestEnvironment, with_test_environment},
};
use muralpay::MuralPayMock;
use rust_decimal::dec;
fn create_mock_payout_request(
id: &str,
status: muralpay::PayoutStatus,
) -> muralpay::PayoutRequest {
use muralpay::*;
PayoutRequest {
id: PayoutRequestId(id.parse().unwrap()),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
source_account_id: AccountId(uuid::Uuid::new_v4()),
transaction_hash: None,
memo: None,
status,
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::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(),
name: "Not found".to_string(),
message: "Payout request not found".to_string(),
details: vec![],
params: std::collections::HashMap::new(),
}))
}),
search_payout_requests: Box::new(|_filter, _params| {
Ok(muralpay::SearchResponse {
total: 0,
next_id: None,
results: vec![],
})
}),
..Default::default()
})
}
async fn setup_test_db_with_payouts(
db: &sqlx::PgPool,
payouts: Vec<(i64, String, PayoutStatus)>,
) -> Result<(), eyre::Error> {
for (id, platform_id, status) in payouts {
sqlx::query!(
"
INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)
VALUES ($1, $2, $3, $4, $5, 10.0, NOW())
",
id,
PayoutMethodType::MuralPay.as_str(),
platform_id,
status.as_str(),
1i64, // user_id
)
.execute(db)
.await?;
}
Ok(())
}
#[actix_rt::test]
async fn test_sync_pending_payouts_from_mural_success() {
with_test_environment(None, |env: TestEnvironment<ApiV3>| async move {
let db = &env.db.pool;
// Setup test data
let uuid1 = uuid::Uuid::new_v4().to_string();
let uuid2 = uuid::Uuid::new_v4().to_string();
let uuid3 = uuid::Uuid::new_v4().to_string();
let uuid4 = uuid::Uuid::new_v4().to_string();
setup_test_db_with_payouts(
db,
vec![
(1, uuid1.clone(), PayoutStatus::InTransit),
(2, uuid2.clone(), PayoutStatus::Unknown),
(3, uuid3.clone(), PayoutStatus::Cancelling),
(4, uuid4.clone(), PayoutStatus::InTransit), // This one won't change
],
)
.await
.unwrap();
// Verify setup
let updated_payouts = sqlx::query!(
r#"
SELECT
id,
status AS "status: PayoutStatus"
FROM payouts
ORDER BY id
"#
)
.fetch_all(db)
.await
.unwrap();
assert_eq!(updated_payouts.len(), 4);
assert_eq!(updated_payouts[0].status, PayoutStatus::InTransit);
assert_eq!(updated_payouts[1].status, PayoutStatus::Unknown);
assert_eq!(updated_payouts[2].status, PayoutStatus::Cancelling);
assert_eq!(updated_payouts[3].status, PayoutStatus::InTransit);
// Setup mock client with proper responses
let mut mock = MuralPayMock::default();
// Create mock payout requests
let payout1 = create_mock_payout_request(
&uuid1,
muralpay::PayoutStatus::Executed,
);
let payout2 = create_mock_payout_request(
&uuid2,
muralpay::PayoutStatus::Canceled,
);
let payout3 = create_mock_payout_request(
&uuid3,
muralpay::PayoutStatus::Failed,
);
let payout4 = create_mock_payout_request(
&uuid4,
muralpay::PayoutStatus::Pending,
);
// Mock get_payout_request
let payout_requests = std::collections::HashMap::from([
(uuid1.clone(), payout1.clone()),
(uuid2.clone(), payout2.clone()),
(uuid3.clone(), payout3.clone()),
(uuid4.clone(), payout4.clone()),
]);
mock.get_payout_request = Box::new(move |id| {
let id_str = id.to_string();
match payout_requests.get(&id_str) {
Some(request) => Ok(request.clone()),
None => {
Err(muralpay::MuralError::Api(muralpay::ApiError {
error_instance_id: uuid::Uuid::new_v4(),
name: "Not found".to_string(),
message: "Payout request not found".to_string(),
details: vec![],
params: std::collections::HashMap::new(),
}))
}
}
});
// Mock search_payout_requests
mock.search_payout_requests = Box::new(move |_filter, _params| {
Ok(muralpay::SearchResponse {
total: 4,
results: vec![
payout1.clone(),
payout2.clone(),
payout3.clone(),
payout4.clone(),
],
next_id: None,
})
});
let mock_client = muralpay::Client::from_mock(mock);
// Run the function
let result =
sync_pending_payouts_from_mural(db, &mock_client, 10).await;
assert!(result.is_ok());
// Verify results
let updated_payouts = sqlx::query!(
r#"
SELECT
id,
status AS "status: PayoutStatus"
FROM payouts
ORDER BY id
"#
)
.fetch_all(db)
.await
.unwrap();
assert_eq!(updated_payouts.len(), 4);
assert_eq!(updated_payouts[0].status, PayoutStatus::Success);
assert_eq!(updated_payouts[1].status, PayoutStatus::Cancelled);
assert_eq!(updated_payouts[2].status, PayoutStatus::Failed);
assert_eq!(updated_payouts[3].status, PayoutStatus::InTransit);
})
.await;
}
#[actix_rt::test]
async fn test_sync_pending_payouts_from_mural_handles_missing_platform_id()
{
with_test_environment(None, |env: TestEnvironment<ApiV3>| async move {
let db = &env.db.pool;
// Setup test data with null platform_id
sqlx::query!(
"
INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)
VALUES ($1, $2, NULL, $3, $4, 10.00, NOW())
",
1,
PayoutMethodType::MuralPay.as_str(),
PayoutStatus::InTransit.as_str(),
1i64, // user_id
)
.execute(db)
.await
.unwrap();
let mock_client = create_mock_muralpay();
// Run the function - should not fail even with null platform_id
sync_pending_payouts_from_mural(
db,
&mock_client,
10,
)
.await.unwrap();
}).await;
}
#[actix_rt::test]
async fn test_sync_failed_mural_payouts_to_labrinth_success() {
with_test_environment(None, |env: TestEnvironment<ApiV3>| async move {
let db = &env.db.pool;
// Setup test data
let uuid1 = uuid::Uuid::new_v4().to_string();
let uuid2 = uuid::Uuid::new_v4().to_string();
let uuid3 = uuid::Uuid::new_v4().to_string();
setup_test_db_with_payouts(
db,
vec![
(1, uuid1.clone(), PayoutStatus::InTransit), // Will be updated to cancelled
(2, uuid2.clone(), PayoutStatus::Success), // Will be updated to failed
(3, uuid3.clone(), PayoutStatus::Success), // Will remain unchanged
],
)
.await
.unwrap();
// Setup mock client
let mut mock = MuralPayMock::default();
// Create mock payout requests
let payout1 = create_mock_payout_request(
&uuid1,
muralpay::PayoutStatus::Canceled,
);
let payout2 = create_mock_payout_request(
&uuid2,
muralpay::PayoutStatus::Failed,
);
let payout3 = create_mock_payout_request(
&uuid::Uuid::new_v4().to_string(),
muralpay::PayoutStatus::Failed,
); // No matching DB record
// Mock search_payout_requests
mock.search_payout_requests = Box::new(move |_filter, _params| {
Ok(muralpay::SearchResponse {
total: 3,
results: vec![
payout1.clone(),
payout2.clone(),
payout3.clone(),
],
next_id: None,
})
});
let mock_client = muralpay::Client::from_mock(mock);
// Run the function
let result =
sync_failed_mural_payouts_to_labrinth(db, &mock_client, 10)
.await;
assert!(result.is_ok());
// Verify results
let updated_payouts = sqlx::query!(
r#"
SELECT
id,
status AS "status: PayoutStatus"
FROM payouts
ORDER BY id
"#
)
.fetch_all(db)
.await
.unwrap();
assert_eq!(updated_payouts.len(), 3);
assert_eq!(updated_payouts[0].status, PayoutStatus::Cancelled); // search_req_1 -> canceled
assert_eq!(updated_payouts[1].status, PayoutStatus::Failed); // search_req_2 -> failed
assert_eq!(updated_payouts[2].status, PayoutStatus::Success); // search_req_3 unchanged
})
.await;
}
#[actix_rt::test]
async fn test_sync_failed_mural_payouts_to_labrinth_handles_wrong_status() {
with_test_environment(None, |env: TestEnvironment<ApiV3>| async move {
let db = &env.db.pool;
// Setup test data
let uuid1 = uuid::Uuid::new_v4().to_string();
setup_test_db_with_payouts(
db,
vec![(1, uuid1.clone(), PayoutStatus::InTransit)],
)
.await
.unwrap();
// Setup mock client with a payout that has unexpected status
let mut mock = MuralPayMock::default();
let payout1 = create_mock_payout_request(
&uuid1,
muralpay::PayoutStatus::Pending,
); // Should be filtered out
// Mock search_payout_requests
mock.search_payout_requests = Box::new(move |_filter, _params| {
Ok(muralpay::SearchResponse {
total: 1,
results: vec![payout1.clone()],
next_id: None,
})
});
let mock_client = muralpay::Client::from_mock(mock);
// Run the function - should handle this gracefully
sync_failed_mural_payouts_to_labrinth(
db,
&mock_client,
10,
)
.await
.unwrap();
// Verify status remains unchanged
let payout =
sqlx::query!(r#"SELECT status AS "status: PayoutStatus" FROM payouts WHERE id = 1"#)
.fetch_one(db)
.await
.unwrap();
assert_eq!(payout.status, PayoutStatus::InTransit); // Unchanged
})
.await;
}
}