mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 01:17:46 +08:00
fix(admin): make bulk wallet batches idempotent
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS public.admin_user_wallet_balance_batches (
|
||||
admin_user_id character varying(64) NOT NULL,
|
||||
idempotency_key character varying(128) NOT NULL,
|
||||
request_fingerprint character varying(64) NOT NULL,
|
||||
target_user_ids jsonb NOT NULL,
|
||||
missing_user_ids jsonb NOT NULL,
|
||||
warnings jsonb NOT NULL,
|
||||
user_outcomes jsonb NOT NULL,
|
||||
created_at_unix_secs bigint NOT NULL,
|
||||
updated_at_unix_secs bigint NOT NULL,
|
||||
CONSTRAINT admin_user_wallet_balance_batches_pkey PRIMARY KEY (admin_user_id, idempotency_key)
|
||||
);
|
||||
@@ -24,8 +24,9 @@ use aether_data_contracts::repository::wallet::{
|
||||
wallet_recharge_checkout_failed_response, wallet_recharge_checkout_uncertain_response,
|
||||
wallet_recharge_order_is_reclaimable_placeholder, wallet_recharge_replay_matches,
|
||||
wallet_recharge_response_is_checkout_placeholder, wallet_refund_proof_is_success,
|
||||
AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery,
|
||||
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
|
||||
AdjustWalletBalanceInBatchInput, AdjustWalletBalanceInput, AdminPaymentOrderListQuery,
|
||||
AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery, AdminUserWalletBalanceBatchContext,
|
||||
AdminUserWalletBalanceBatchUserOutcome, AdminWalletLedgerQuery, AdminWalletListQuery,
|
||||
AdminWalletRefundRequestListQuery, CompareAndSwapPaymentOrderStripeClientSecretInput,
|
||||
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
|
||||
CreateAdminRedeemCodeBatchResult, CreateManualWalletRechargeInput,
|
||||
@@ -34,21 +35,23 @@ use aether_data_contracts::repository::wallet::{
|
||||
CreateWalletRefundRequestOutcome, CreatedAdminRedeemCodePlaintext,
|
||||
CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
|
||||
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
|
||||
FailWalletRechargeCheckoutInput, InitializeAuthWalletOutcome, ProcessAdminWalletRefundInput,
|
||||
ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome, ReclaimWalletRechargeCheckoutInput,
|
||||
RedeemWalletCodeInput, RedeemWalletCodeOutcome, StoredAdminPaymentCallback,
|
||||
StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder, StoredAdminPaymentOrderPage,
|
||||
StoredAdminRedeemCode, StoredAdminRedeemCodeBatch, StoredAdminRedeemCodeBatchPage,
|
||||
StoredAdminRedeemCodePage, StoredAdminWalletLedgerItem, StoredAdminWalletLedgerPage,
|
||||
StoredAdminWalletListItem, StoredAdminWalletListPage, StoredAdminWalletRefund,
|
||||
StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestItem,
|
||||
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
||||
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
||||
FailWalletRechargeCheckoutInput, InitializeAuthWalletOutcome,
|
||||
PrepareAdminUserWalletBalanceBatchInput, PrepareAdminUserWalletBalanceBatchOutcome,
|
||||
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
|
||||
ReclaimWalletRechargeCheckoutInput, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
|
||||
StoredAdminPaymentCallback, StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder,
|
||||
StoredAdminPaymentOrderPage, StoredAdminRedeemCode, StoredAdminRedeemCodeBatch,
|
||||
StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminUserWalletBalanceBatch,
|
||||
StoredAdminWalletLedgerItem, StoredAdminWalletLedgerPage, StoredAdminWalletListItem,
|
||||
StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage,
|
||||
StoredAdminWalletRefundRequestItem, StoredAdminWalletRefundRequestPage,
|
||||
StoredAdminWalletTransaction, StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
||||
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, UpdateAdminWalletRefundGatewayInput,
|
||||
UpdateWalletRechargeCheckoutInput, WalletLookupKey, WalletMutationOutcome,
|
||||
WalletReadRepository, WalletWriteRepository,
|
||||
};
|
||||
use aether_data_contracts::DataLayerError;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{
|
||||
error::{postgres_error, SqlxResultExt},
|
||||
@@ -815,6 +818,31 @@ fn effective_wallet_adjustment_amount(input: &AdjustWalletBalanceInput, before_t
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_admin_wallet_batch_outcomes(
|
||||
connection: &mut sqlx::PgConnection,
|
||||
context: &AdminUserWalletBalanceBatchContext,
|
||||
outcomes: &BTreeMap<String, AdminUserWalletBalanceBatchUserOutcome>,
|
||||
) -> Result<(), DataLayerError> {
|
||||
let outcomes = serde_json::to_value(outcomes).map_err(|error| {
|
||||
DataLayerError::UnexpectedValue(format!("admin wallet batch outcomes are invalid: {error}"))
|
||||
})?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE admin_user_wallet_balance_batches
|
||||
SET user_outcomes = $3, updated_at_unix_secs = $4
|
||||
WHERE admin_user_id = $1 AND idempotency_key = $2
|
||||
"#,
|
||||
)
|
||||
.bind(&context.admin_user_id)
|
||||
.bind(&context.idempotency_key)
|
||||
.bind(outcomes)
|
||||
.bind(Utc::now().timestamp().max(0))
|
||||
.execute(connection)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WalletReadRepository for SqlxWalletRepository {
|
||||
async fn find(
|
||||
@@ -4280,6 +4308,58 @@ RETURNING
|
||||
self.tx_runner
|
||||
.run_read_write(|tx| {
|
||||
Box::pin(async move {
|
||||
let batch_context = input.batch_context.clone();
|
||||
let mut batch_user_outcomes = if let Some(context) = &batch_context {
|
||||
let batch_row = sqlx::query(
|
||||
r#"
|
||||
SELECT target_user_ids, user_outcomes
|
||||
FROM admin_user_wallet_balance_batches
|
||||
WHERE admin_user_id = $1 AND idempotency_key = $2
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(&context.admin_user_id)
|
||||
.bind(&context.idempotency_key)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
.ok_or_else(|| {
|
||||
DataLayerError::InvalidInput(
|
||||
"admin wallet batch was not prepared".to_string(),
|
||||
)
|
||||
})?;
|
||||
let target_user_ids: Vec<String> =
|
||||
serde_json::from_value(row_get(&batch_row, "target_user_ids")?)
|
||||
.map_err(|error| {
|
||||
DataLayerError::UnexpectedValue(format!(
|
||||
"admin wallet batch target list is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
if !target_user_ids.iter().any(|id| id == &context.user_id) {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"user is outside the prepared admin wallet batch".to_string(),
|
||||
));
|
||||
}
|
||||
let outcomes: BTreeMap<String, AdminUserWalletBalanceBatchUserOutcome> =
|
||||
serde_json::from_value(row_get(&batch_row, "user_outcomes")?).map_err(
|
||||
|error| {
|
||||
DataLayerError::UnexpectedValue(format!(
|
||||
"admin wallet batch outcomes are invalid: {error}"
|
||||
))
|
||||
},
|
||||
)?;
|
||||
if let Some(outcome) = outcomes.get(&context.user_id) {
|
||||
match outcome {
|
||||
AdminUserWalletBalanceBatchUserOutcome::Succeeded => {}
|
||||
AdminUserWalletBalanceBatchUserOutcome::Failed(_) => {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(outcomes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(row) = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -4298,10 +4378,16 @@ SELECT
|
||||
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
|
||||
FROM wallets
|
||||
WHERE id = $1
|
||||
AND ($2::character varying IS NULL OR user_id = $2::character varying)
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(&input.wallet_id)
|
||||
.bind(
|
||||
batch_context
|
||||
.as_ref()
|
||||
.map(|context| context.user_id.as_str()),
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
@@ -4322,9 +4408,28 @@ FOR UPDATE
|
||||
"wallet balance is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
let wallet = map_wallet_row(&row)?;
|
||||
let already_applied = batch_context.as_ref().is_some_and(|context| {
|
||||
batch_user_outcomes.as_ref().is_some_and(|outcomes| {
|
||||
outcomes.get(&context.user_id)
|
||||
== Some(&AdminUserWalletBalanceBatchUserOutcome::Succeeded)
|
||||
})
|
||||
});
|
||||
if already_applied {
|
||||
return Ok(Some((wallet, None)));
|
||||
}
|
||||
let amount_usd = effective_wallet_adjustment_amount(&input, before_total);
|
||||
if amount_usd == 0.0 {
|
||||
return Ok(Some((map_wallet_row(&row)?, None)));
|
||||
if let (Some(context), Some(outcomes)) =
|
||||
(batch_context.as_ref(), batch_user_outcomes.as_mut())
|
||||
{
|
||||
outcomes.insert(
|
||||
context.user_id.clone(),
|
||||
AdminUserWalletBalanceBatchUserOutcome::Succeeded,
|
||||
);
|
||||
persist_admin_wallet_batch_outcomes(tx, context, outcomes).await?;
|
||||
}
|
||||
return Ok(Some((wallet, None)));
|
||||
}
|
||||
let mut after_recharge = before_recharge;
|
||||
let mut after_gift = before_gift;
|
||||
@@ -4467,6 +4572,16 @@ VALUES (
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
|
||||
if let (Some(context), Some(outcomes)) =
|
||||
(batch_context.as_ref(), batch_user_outcomes.as_mut())
|
||||
{
|
||||
outcomes.insert(
|
||||
context.user_id.clone(),
|
||||
AdminUserWalletBalanceBatchUserOutcome::Succeeded,
|
||||
);
|
||||
persist_admin_wallet_batch_outcomes(tx, context, outcomes).await?;
|
||||
}
|
||||
|
||||
Ok(Some((
|
||||
wallet,
|
||||
Some(StoredAdminWalletTransaction {
|
||||
@@ -4495,6 +4610,237 @@ VALUES (
|
||||
.await
|
||||
}
|
||||
|
||||
async fn prepare_admin_user_wallet_balance_batch(
|
||||
&self,
|
||||
input: PrepareAdminUserWalletBalanceBatchInput,
|
||||
) -> Result<PrepareAdminUserWalletBalanceBatchOutcome, DataLayerError> {
|
||||
let target_user_ids = serde_json::to_value(&input.target_user_ids).map_err(|error| {
|
||||
DataLayerError::InvalidInput(format!("invalid batch target users: {error}"))
|
||||
})?;
|
||||
let missing_user_ids = serde_json::to_value(&input.missing_user_ids).map_err(|error| {
|
||||
DataLayerError::InvalidInput(format!("invalid missing batch users: {error}"))
|
||||
})?;
|
||||
let warnings = serde_json::to_value(&input.warnings).map_err(|error| {
|
||||
DataLayerError::InvalidInput(format!("invalid batch warnings: {error}"))
|
||||
})?;
|
||||
let now = Utc::now().timestamp().max(0);
|
||||
self.tx_runner
|
||||
.run_read_write(|tx| {
|
||||
Box::pin(async move {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO admin_user_wallet_balance_batches (
|
||||
admin_user_id, idempotency_key, request_fingerprint, target_user_ids,
|
||||
missing_user_ids, warnings, user_outcomes, created_at_unix_secs, updated_at_unix_secs
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, '{}'::jsonb, $7, $7)
|
||||
ON CONFLICT (admin_user_id, idempotency_key) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(&input.admin_user_id)
|
||||
.bind(&input.idempotency_key)
|
||||
.bind(&input.request_fingerprint)
|
||||
.bind(target_user_ids)
|
||||
.bind(missing_user_ids)
|
||||
.bind(warnings)
|
||||
.bind(now)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT admin_user_id, idempotency_key, request_fingerprint, target_user_ids,
|
||||
missing_user_ids, warnings, user_outcomes
|
||||
FROM admin_user_wallet_balance_batches
|
||||
WHERE admin_user_id = $1 AND idempotency_key = $2
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(&input.admin_user_id)
|
||||
.bind(&input.idempotency_key)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let request_fingerprint: String = row_get(&row, "request_fingerprint")?;
|
||||
if request_fingerprint != input.request_fingerprint {
|
||||
return Ok(PrepareAdminUserWalletBalanceBatchOutcome::Conflict);
|
||||
}
|
||||
|
||||
let read_json = |column: &str| -> Result<serde_json::Value, DataLayerError> {
|
||||
row_get(&row, column)
|
||||
};
|
||||
let batch = StoredAdminUserWalletBalanceBatch {
|
||||
admin_user_id: row_get(&row, "admin_user_id")?,
|
||||
idempotency_key: row_get(&row, "idempotency_key")?,
|
||||
request_fingerprint,
|
||||
target_user_ids: serde_json::from_value(read_json("target_user_ids")?)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||
missing_user_ids: serde_json::from_value(read_json("missing_user_ids")?)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||
warnings: serde_json::from_value(read_json("warnings")?)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||
user_outcomes: serde_json::from_value(read_json("user_outcomes")?)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||
};
|
||||
Ok(PrepareAdminUserWalletBalanceBatchOutcome::Ready(batch))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_admin_user_wallet_balance_batch(
|
||||
&self,
|
||||
admin_user_id: &str,
|
||||
idempotency_key: &str,
|
||||
expected_fingerprint: &str,
|
||||
) -> Result<Option<PrepareAdminUserWalletBalanceBatchOutcome>, DataLayerError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT admin_user_id, idempotency_key, request_fingerprint, target_user_ids,
|
||||
missing_user_ids, warnings, user_outcomes
|
||||
FROM admin_user_wallet_balance_batches
|
||||
WHERE admin_user_id = $1 AND idempotency_key = $2
|
||||
"#,
|
||||
)
|
||||
.bind(admin_user_id)
|
||||
.bind(idempotency_key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let request_fingerprint: String = row_get(&row, "request_fingerprint")?;
|
||||
if request_fingerprint != expected_fingerprint {
|
||||
return Ok(Some(PrepareAdminUserWalletBalanceBatchOutcome::Conflict));
|
||||
}
|
||||
let read_json =
|
||||
|column: &str| -> Result<serde_json::Value, DataLayerError> { row_get(&row, column) };
|
||||
let batch = StoredAdminUserWalletBalanceBatch {
|
||||
admin_user_id: row_get(&row, "admin_user_id")?,
|
||||
idempotency_key: row_get(&row, "idempotency_key")?,
|
||||
request_fingerprint,
|
||||
target_user_ids: serde_json::from_value(read_json("target_user_ids")?)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||
missing_user_ids: serde_json::from_value(read_json("missing_user_ids")?)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||
warnings: serde_json::from_value(read_json("warnings")?)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||
user_outcomes: serde_json::from_value(read_json("user_outcomes")?)
|
||||
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||
};
|
||||
Ok(Some(PrepareAdminUserWalletBalanceBatchOutcome::Ready(
|
||||
batch,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn adjust_admin_user_wallet_balance_batch_user(
|
||||
&self,
|
||||
input: AdjustWalletBalanceInBatchInput,
|
||||
) -> Result<AdminUserWalletBalanceBatchUserOutcome, DataLayerError> {
|
||||
let context = AdminUserWalletBalanceBatchContext {
|
||||
admin_user_id: input.admin_user_id.clone(),
|
||||
idempotency_key: input.idempotency_key.clone(),
|
||||
user_id: input.user_id.clone(),
|
||||
};
|
||||
let mut adjustment = input.adjustment;
|
||||
adjustment.batch_context = Some(context);
|
||||
if self.adjust_wallet_balance(adjustment).await?.is_some() {
|
||||
return Ok(AdminUserWalletBalanceBatchUserOutcome::Succeeded);
|
||||
}
|
||||
self.record_admin_user_wallet_balance_batch_failure(
|
||||
&input.admin_user_id,
|
||||
&input.idempotency_key,
|
||||
&input.user_id,
|
||||
"用户钱包不可用",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn record_admin_user_wallet_balance_batch_failure(
|
||||
&self,
|
||||
admin_user_id: &str,
|
||||
idempotency_key: &str,
|
||||
user_id: &str,
|
||||
reason: &str,
|
||||
) -> Result<AdminUserWalletBalanceBatchUserOutcome, DataLayerError> {
|
||||
let admin_user_id = admin_user_id.to_string();
|
||||
let idempotency_key = idempotency_key.to_string();
|
||||
let user_id = user_id.to_string();
|
||||
let reason = reason.to_string();
|
||||
self.tx_runner
|
||||
.run_read_write(|tx| {
|
||||
Box::pin(async move {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT target_user_ids, user_outcomes
|
||||
FROM admin_user_wallet_balance_batches
|
||||
WHERE admin_user_id = $1 AND idempotency_key = $2
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(&admin_user_id)
|
||||
.bind(&idempotency_key)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
.ok_or_else(|| {
|
||||
DataLayerError::InvalidInput(
|
||||
"admin wallet batch was not prepared".to_string(),
|
||||
)
|
||||
})?;
|
||||
let target_user_ids: Vec<String> =
|
||||
serde_json::from_value(row_get(&row, "target_user_ids")?).map_err(
|
||||
|error| {
|
||||
DataLayerError::UnexpectedValue(format!(
|
||||
"admin wallet batch target list is invalid: {error}"
|
||||
))
|
||||
},
|
||||
)?;
|
||||
if !target_user_ids.iter().any(|id| id == &user_id) {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"user is outside the prepared admin wallet batch".to_string(),
|
||||
));
|
||||
}
|
||||
let mut outcomes: BTreeMap<String, AdminUserWalletBalanceBatchUserOutcome> =
|
||||
serde_json::from_value(row_get(&row, "user_outcomes")?).map_err(
|
||||
|error| {
|
||||
DataLayerError::UnexpectedValue(format!(
|
||||
"admin wallet batch outcomes are invalid: {error}"
|
||||
))
|
||||
},
|
||||
)?;
|
||||
if let Some(outcome) = outcomes.get(&user_id) {
|
||||
return Ok(outcome.clone());
|
||||
}
|
||||
let outcome = AdminUserWalletBalanceBatchUserOutcome::Failed(reason);
|
||||
outcomes.insert(user_id, outcome.clone());
|
||||
let value = serde_json::to_value(&outcomes).map_err(|error| {
|
||||
DataLayerError::UnexpectedValue(format!(
|
||||
"admin wallet batch outcomes are invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE admin_user_wallet_balance_batches
|
||||
SET user_outcomes = $3, updated_at_unix_secs = $4
|
||||
WHERE admin_user_id = $1 AND idempotency_key = $2
|
||||
"#,
|
||||
)
|
||||
.bind(&admin_user_id)
|
||||
.bind(&idempotency_key)
|
||||
.bind(value)
|
||||
.bind(Utc::now().timestamp().max(0))
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
Ok(outcome)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_manual_wallet_recharge(
|
||||
&self,
|
||||
mut input: CreateManualWalletRechargeInput,
|
||||
@@ -8503,10 +8849,12 @@ VALUES ($1, $2, 'gift', 'gift_initial', $3, 0, $3, 0, 0, 0, $3, 'system_task', $
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use aether_data_contracts::repository::wallet::{
|
||||
AdjustWalletBalanceInput, CreateManualWalletRechargeInput, CreditAdminPaymentOrderInput,
|
||||
ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome, RedeemWalletCodeInput,
|
||||
RedeemWalletCodeOutcome, WalletLookupKey, WalletMutationOutcome, WalletReadRepository,
|
||||
WalletWriteRepository,
|
||||
AdjustWalletBalanceInBatchInput, AdjustWalletBalanceInput,
|
||||
AdminUserWalletBalanceBatchUserOutcome, CreateManualWalletRechargeInput,
|
||||
CreditAdminPaymentOrderInput, PrepareAdminUserWalletBalanceBatchInput,
|
||||
PrepareAdminUserWalletBalanceBatchOutcome, ProcessPaymentCallbackInput,
|
||||
ProcessPaymentCallbackOutcome, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
|
||||
WalletLookupKey, WalletMutationOutcome, WalletReadRepository, WalletWriteRepository,
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
@@ -8590,6 +8938,7 @@ mod tests {
|
||||
"payment_orders",
|
||||
"payment_callbacks",
|
||||
"wallet_transactions",
|
||||
"admin_user_wallet_balance_batches",
|
||||
"user_plan_entitlements",
|
||||
"redeem_code_batches",
|
||||
"redeem_codes",
|
||||
@@ -9245,6 +9594,7 @@ mod tests {
|
||||
operator_id: None,
|
||||
description: None,
|
||||
clamp_deduction_to_available_balance: true,
|
||||
batch_context: None,
|
||||
};
|
||||
assert_eq!(effective_wallet_adjustment_amount(&input, 13.0), -13.0);
|
||||
assert_eq!(effective_wallet_adjustment_amount(&input, 0.0), -0.0);
|
||||
@@ -9275,6 +9625,7 @@ mod tests {
|
||||
operator_id: Some("admin-user".to_string()),
|
||||
description: Some("bulk deduction".to_string()),
|
||||
clamp_deduction_to_available_balance: true,
|
||||
batch_context: None,
|
||||
})
|
||||
.await
|
||||
.expect("bulk adjustment should succeed")
|
||||
@@ -9301,6 +9652,7 @@ mod tests {
|
||||
operator_id: Some("admin-user".to_string()),
|
||||
description: Some("bulk deduction at zero".to_string()),
|
||||
clamp_deduction_to_available_balance: true,
|
||||
batch_context: None,
|
||||
})
|
||||
.await
|
||||
.expect("zero-balance adjustment should succeed")
|
||||
@@ -9331,6 +9683,7 @@ mod tests {
|
||||
operator_id: Some("admin-user".to_string()),
|
||||
description: Some("bulk deduction from negative balance".to_string()),
|
||||
clamp_deduction_to_available_balance: true,
|
||||
batch_context: None,
|
||||
})
|
||||
.await
|
||||
.expect("legacy negative wallet should remain usable")
|
||||
@@ -9347,6 +9700,137 @@ mod tests {
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL bootstrap schema"]
|
||||
async fn live_admin_wallet_balance_batch_replays_committed_and_zero_delta_results_once() {
|
||||
let pool = isolated_wallet_test_pool().await;
|
||||
let (wallet_id, user_id) = seed_wallet(&pool).await;
|
||||
let repository = SqlxWalletRepository::new(pool.clone());
|
||||
let admin_user_id = "admin-user".to_string();
|
||||
let make_adjustment =
|
||||
|idempotency_key: &str, amount_usd: f64| AdjustWalletBalanceInBatchInput {
|
||||
admin_user_id: admin_user_id.clone(),
|
||||
idempotency_key: idempotency_key.to_string(),
|
||||
user_id: user_id.clone(),
|
||||
adjustment: AdjustWalletBalanceInput {
|
||||
wallet_id: wallet_id.clone(),
|
||||
amount_usd,
|
||||
balance_type: "recharge".to_string(),
|
||||
operator_id: Some(admin_user_id.clone()),
|
||||
description: Some("idempotency integration test".to_string()),
|
||||
clamp_deduction_to_available_balance: true,
|
||||
batch_context: None,
|
||||
},
|
||||
};
|
||||
let prepare_batch = |idempotency_key: &str, request_fingerprint: &str| {
|
||||
PrepareAdminUserWalletBalanceBatchInput {
|
||||
admin_user_id: admin_user_id.clone(),
|
||||
idempotency_key: idempotency_key.to_string(),
|
||||
request_fingerprint: request_fingerprint.to_string(),
|
||||
target_user_ids: vec![user_id.clone()],
|
||||
missing_user_ids: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
repository
|
||||
.prepare_admin_user_wallet_balance_batch(prepare_batch(
|
||||
"deduct-key",
|
||||
"deduct-fingerprint"
|
||||
))
|
||||
.await
|
||||
.unwrap(),
|
||||
PrepareAdminUserWalletBalanceBatchOutcome::Ready(_)
|
||||
));
|
||||
let deduct = make_adjustment("deduct-key", -50.0);
|
||||
assert_eq!(
|
||||
repository
|
||||
.adjust_admin_user_wallet_balance_batch_user(deduct.clone())
|
||||
.await
|
||||
.unwrap(),
|
||||
AdminUserWalletBalanceBatchUserOutcome::Succeeded
|
||||
);
|
||||
assert_eq!(
|
||||
repository
|
||||
.adjust_admin_user_wallet_balance_batch_user(deduct.clone())
|
||||
.await
|
||||
.unwrap(),
|
||||
AdminUserWalletBalanceBatchUserOutcome::Succeeded
|
||||
);
|
||||
let wallet = repository
|
||||
.find(WalletLookupKey::WalletId(&wallet_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(wallet.balance + wallet.gift_balance, 0.0);
|
||||
|
||||
assert!(matches!(
|
||||
repository
|
||||
.prepare_admin_user_wallet_balance_batch(prepare_batch(
|
||||
"zero-key",
|
||||
"zero-fingerprint"
|
||||
))
|
||||
.await
|
||||
.unwrap(),
|
||||
PrepareAdminUserWalletBalanceBatchOutcome::Ready(_)
|
||||
));
|
||||
let zero_delta = make_adjustment("zero-key", -5.0);
|
||||
assert_eq!(
|
||||
repository
|
||||
.adjust_admin_user_wallet_balance_batch_user(zero_delta.clone())
|
||||
.await
|
||||
.unwrap(),
|
||||
AdminUserWalletBalanceBatchUserOutcome::Succeeded
|
||||
);
|
||||
repository
|
||||
.adjust_wallet_balance(AdjustWalletBalanceInput {
|
||||
wallet_id: wallet_id.clone(),
|
||||
amount_usd: 8.0,
|
||||
balance_type: "recharge".to_string(),
|
||||
operator_id: Some(admin_user_id.clone()),
|
||||
description: Some("recharge after zero-delta batch".to_string()),
|
||||
clamp_deduction_to_available_balance: true,
|
||||
batch_context: None,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
for replay in [zero_delta, deduct] {
|
||||
assert_eq!(
|
||||
repository
|
||||
.adjust_admin_user_wallet_balance_batch_user(replay)
|
||||
.await
|
||||
.unwrap(),
|
||||
AdminUserWalletBalanceBatchUserOutcome::Succeeded
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
repository
|
||||
.prepare_admin_user_wallet_balance_batch(prepare_batch(
|
||||
"zero-key",
|
||||
"different-fingerprint"
|
||||
))
|
||||
.await
|
||||
.unwrap(),
|
||||
PrepareAdminUserWalletBalanceBatchOutcome::Conflict
|
||||
);
|
||||
let wallet = repository
|
||||
.find(WalletLookupKey::WalletId(&wallet_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(wallet.balance + wallet.gift_balance, 8.0);
|
||||
let transaction_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM wallet_transactions WHERE wallet_id = $1")
|
||||
.bind(&wallet_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(transaction_count, 2);
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_constructs_from_lazy_pool() {
|
||||
let factory = PostgresPoolFactory::new(PostgresPoolConfig {
|
||||
|
||||
@@ -2464,6 +2464,57 @@ pub struct AdjustWalletBalanceInput {
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub clamp_deduction_to_available_balance: bool,
|
||||
#[serde(default)]
|
||||
pub batch_context: Option<AdminUserWalletBalanceBatchContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdminUserWalletBalanceBatchContext {
|
||||
pub admin_user_id: String,
|
||||
pub idempotency_key: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PrepareAdminUserWalletBalanceBatchInput {
|
||||
pub admin_user_id: String,
|
||||
pub idempotency_key: String,
|
||||
pub request_fingerprint: String,
|
||||
pub target_user_ids: Vec<String>,
|
||||
pub missing_user_ids: Vec<String>,
|
||||
pub warnings: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredAdminUserWalletBalanceBatch {
|
||||
pub admin_user_id: String,
|
||||
pub idempotency_key: String,
|
||||
pub request_fingerprint: String,
|
||||
pub target_user_ids: Vec<String>,
|
||||
pub missing_user_ids: Vec<String>,
|
||||
pub warnings: Vec<serde_json::Value>,
|
||||
pub user_outcomes: std::collections::BTreeMap<String, AdminUserWalletBalanceBatchUserOutcome>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum PrepareAdminUserWalletBalanceBatchOutcome {
|
||||
Ready(StoredAdminUserWalletBalanceBatch),
|
||||
Conflict,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "status", content = "reason", rename_all = "snake_case")]
|
||||
pub enum AdminUserWalletBalanceBatchUserOutcome {
|
||||
Succeeded,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AdjustWalletBalanceInBatchInput {
|
||||
pub admin_user_id: String,
|
||||
pub idempotency_key: String,
|
||||
pub user_id: String,
|
||||
pub adjustment: AdjustWalletBalanceInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
@@ -2900,6 +2951,47 @@ pub trait WalletWriteRepository: Send + Sync {
|
||||
crate::DataLayerError,
|
||||
>;
|
||||
|
||||
async fn prepare_admin_user_wallet_balance_batch(
|
||||
&self,
|
||||
_input: PrepareAdminUserWalletBalanceBatchInput,
|
||||
) -> Result<PrepareAdminUserWalletBalanceBatchOutcome, crate::DataLayerError> {
|
||||
Err(crate::DataLayerError::InvalidInput(
|
||||
"idempotent admin wallet batches are not available".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_admin_user_wallet_balance_batch(
|
||||
&self,
|
||||
_admin_user_id: &str,
|
||||
_idempotency_key: &str,
|
||||
_request_fingerprint: &str,
|
||||
) -> Result<Option<PrepareAdminUserWalletBalanceBatchOutcome>, crate::DataLayerError> {
|
||||
Err(crate::DataLayerError::InvalidInput(
|
||||
"idempotent admin wallet batches are not available".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn adjust_admin_user_wallet_balance_batch_user(
|
||||
&self,
|
||||
_input: AdjustWalletBalanceInBatchInput,
|
||||
) -> Result<AdminUserWalletBalanceBatchUserOutcome, crate::DataLayerError> {
|
||||
Err(crate::DataLayerError::InvalidInput(
|
||||
"idempotent admin wallet batches are not available".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn record_admin_user_wallet_balance_batch_failure(
|
||||
&self,
|
||||
_admin_user_id: &str,
|
||||
_idempotency_key: &str,
|
||||
_user_id: &str,
|
||||
_reason: &str,
|
||||
) -> Result<AdminUserWalletBalanceBatchUserOutcome, crate::DataLayerError> {
|
||||
Err(crate::DataLayerError::InvalidInput(
|
||||
"idempotent admin wallet batches are not available".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn create_manual_wallet_recharge(
|
||||
&self,
|
||||
input: CreateManualWalletRechargeInput,
|
||||
|
||||
@@ -343,3 +343,17 @@ CREATE INDEX IF NOT EXISTS idx_redeem_codes_status ON public.redeem_codes USING
|
||||
CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_user ON public.redeem_codes USING btree (redeemed_by_user_id, redeemed_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_order ON public.redeem_codes USING btree (redeemed_payment_order_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.admin_user_wallet_balance_batches (
|
||||
admin_user_id character varying(64) NOT NULL,
|
||||
idempotency_key character varying(128) NOT NULL,
|
||||
request_fingerprint character varying(64) NOT NULL,
|
||||
target_user_ids jsonb NOT NULL,
|
||||
missing_user_ids jsonb NOT NULL,
|
||||
warnings jsonb NOT NULL,
|
||||
user_outcomes jsonb NOT NULL,
|
||||
created_at_unix_secs bigint NOT NULL,
|
||||
updated_at_unix_secs bigint NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY public.admin_user_wallet_balance_batches ADD CONSTRAINT admin_user_wallet_balance_batches_pkey PRIMARY KEY (admin_user_id, idempotency_key);
|
||||
|
||||
|
||||
@@ -1366,3 +1366,47 @@ columns = ["redeemed_by_user_id", "redeemed_at"]
|
||||
[[table.redeem_codes.indexes]]
|
||||
name = "idx_redeem_codes_redeemed_order"
|
||||
columns = ["redeemed_payment_order_id"]
|
||||
|
||||
[table.admin_user_wallet_balance_batches]
|
||||
domain = "wallet_billing"
|
||||
order = 110
|
||||
primary_key = ["admin_user_id", "idempotency_key"]
|
||||
|
||||
[[table.admin_user_wallet_balance_batches.columns]]
|
||||
name = "admin_user_id"
|
||||
type = "text_id"
|
||||
length = 64
|
||||
|
||||
[[table.admin_user_wallet_balance_batches.columns]]
|
||||
name = "idempotency_key"
|
||||
type = "text"
|
||||
length = 128
|
||||
|
||||
[[table.admin_user_wallet_balance_batches.columns]]
|
||||
name = "request_fingerprint"
|
||||
type = "text"
|
||||
length = 64
|
||||
|
||||
[[table.admin_user_wallet_balance_batches.columns]]
|
||||
name = "target_user_ids"
|
||||
type = "json"
|
||||
|
||||
[[table.admin_user_wallet_balance_batches.columns]]
|
||||
name = "missing_user_ids"
|
||||
type = "json"
|
||||
|
||||
[[table.admin_user_wallet_balance_batches.columns]]
|
||||
name = "warnings"
|
||||
type = "json"
|
||||
|
||||
[[table.admin_user_wallet_balance_batches.columns]]
|
||||
name = "user_outcomes"
|
||||
type = "json"
|
||||
|
||||
[[table.admin_user_wallet_balance_batches.columns]]
|
||||
name = "created_at_unix_secs"
|
||||
type = "unix_seconds"
|
||||
|
||||
[[table.admin_user_wallet_balance_batches.columns]]
|
||||
name = "updated_at_unix_secs"
|
||||
type = "unix_seconds"
|
||||
|
||||
@@ -16,27 +16,30 @@ pub use aether_data_contracts::repository::wallet::{
|
||||
wallet_recharge_order_is_checkout_placeholder,
|
||||
wallet_recharge_order_is_reclaimable_placeholder, wallet_recharge_replay_matches,
|
||||
wallet_recharge_response_is_checkout_placeholder, wallet_refund_proof_is_success,
|
||||
AdjustWalletBalanceInput, AdminPaymentCallbackRecord, AdminPaymentOrderListQuery,
|
||||
AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery, AdminWalletLedgerQuery,
|
||||
AdminWalletListQuery, AdminWalletPaymentOrderRecord, AdminWalletRefundRecord,
|
||||
AdminWalletRefundRequestListQuery, AdminWalletTransactionRecord, CanonicalWalletRefundFields,
|
||||
CompareAndSwapPaymentOrderStripeClientSecretInput, CompleteAdminWalletRefundInput,
|
||||
CreateAdminRedeemCodeBatchInput, CreateAdminRedeemCodeBatchResult,
|
||||
CreateManualWalletRechargeInput, CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome,
|
||||
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
|
||||
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome,
|
||||
CreatedAdminRedeemCodePlaintext, CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
|
||||
AdjustWalletBalanceInBatchInput, AdjustWalletBalanceInput, AdminPaymentCallbackRecord,
|
||||
AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery,
|
||||
AdminUserWalletBalanceBatchContext, AdminUserWalletBalanceBatchUserOutcome,
|
||||
AdminWalletLedgerQuery, AdminWalletListQuery, AdminWalletPaymentOrderRecord,
|
||||
AdminWalletRefundRecord, AdminWalletRefundRequestListQuery, AdminWalletTransactionRecord,
|
||||
CanonicalWalletRefundFields, CompareAndSwapPaymentOrderStripeClientSecretInput,
|
||||
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
|
||||
CreateAdminRedeemCodeBatchResult, CreateManualWalletRechargeInput,
|
||||
CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome, CreateWalletRechargeOrderInput,
|
||||
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
|
||||
CreateWalletRefundRequestOutcome, CreatedAdminRedeemCodePlaintext,
|
||||
CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
|
||||
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
|
||||
FailWalletRechargeCheckoutInput, InitializeAuthWalletOutcome, ProcessAdminWalletRefundInput,
|
||||
ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome, ReclaimWalletRechargeCheckoutInput,
|
||||
RedeemWalletCodeInput, RedeemWalletCodeOutcome, StoredAdminPaymentCallback,
|
||||
StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder, StoredAdminPaymentOrderPage,
|
||||
StoredAdminRedeemCode, StoredAdminRedeemCodeBatch, StoredAdminRedeemCodeBatchPage,
|
||||
StoredAdminRedeemCodePage, StoredAdminWalletLedgerItem, StoredAdminWalletLedgerPage,
|
||||
StoredAdminWalletListItem, StoredAdminWalletListPage, StoredAdminWalletRefund,
|
||||
StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestItem,
|
||||
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
||||
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
||||
FailWalletRechargeCheckoutInput, InitializeAuthWalletOutcome,
|
||||
PrepareAdminUserWalletBalanceBatchInput, PrepareAdminUserWalletBalanceBatchOutcome,
|
||||
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
|
||||
ReclaimWalletRechargeCheckoutInput, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
|
||||
StoredAdminPaymentCallback, StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder,
|
||||
StoredAdminPaymentOrderPage, StoredAdminRedeemCode, StoredAdminRedeemCodeBatch,
|
||||
StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminUserWalletBalanceBatch,
|
||||
StoredAdminWalletLedgerItem, StoredAdminWalletLedgerPage, StoredAdminWalletListItem,
|
||||
StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage,
|
||||
StoredAdminWalletRefundRequestItem, StoredAdminWalletRefundRequestPage,
|
||||
StoredAdminWalletTransaction, StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
||||
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, UpdateAdminWalletRefundGatewayInput,
|
||||
UpdateWalletRechargeCheckoutInput, WalletLookupKey, WalletMutationOutcome,
|
||||
WalletReadRepository, WalletReadSeed, WalletReadSnapshot, WalletRepository,
|
||||
|
||||
Reference in New Issue
Block a user