use std::collections::BTreeMap; use std::sync::{Mutex, RwLock}; use async_trait::async_trait; #[cfg(test)] use super::WalletReadSeed; use super::{ canonicalize_payment_method, canonicalize_wallet_refund_fields, payment_order_refund_amounts_are_consistent, payment_order_stripe_client_secret_cas_replacement, project_wallet_gateway_response, project_wallet_recharge_gateway_response, redeem_code_payment_method, redeem_code_refundable_amount, validate_admin_redeem_code_batch_input, validate_plan_purchase_order_input, validate_plan_wallet_credit_entitlements, validate_redeem_wallet_credit, validate_wallet_recharge_order_input, wallet_recharge_checkout_claim_response, wallet_recharge_checkout_claim_token, wallet_recharge_checkout_failed_response, wallet_recharge_checkout_uncertain_response, 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, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery, AdminWalletRefundRequestListQuery, 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, StoredAdminWalletLedgerPage, StoredAdminWalletListItem, StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction, StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger, StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, UpdateAdminWalletRefundGatewayInput, UpdateWalletRechargeCheckoutInput, WalletLookupKey, WalletMutationOutcome, WalletReadRepository, WalletWriteRepository, }; use crate::DataLayerError; #[derive(Debug, Default)] pub struct InMemoryWalletRepository { wallets_by_id: RwLock>, payment_orders_by_id: RwLock>, payment_callbacks_by_id: RwLock>, wallet_transactions_by_id: RwLock>, refunds_by_id: RwLock>, redeem_batches_by_id: RwLock>, redeem_codes_by_id: RwLock>, redeem_code_hash_to_id: RwLock>, refund_idempotency_to_id: RwLock>, refund_creation_lock: Mutex<()>, // Wallet creation, order attachment, and compensation cleanup must be // serialized together. Separate map locks cannot make the // "check references, then delete" sequence atomic. wallet_lifecycle_lock: Mutex<()>, } fn wallet_recharge_metadata_value<'a>( record: &'a StoredAdminPaymentOrder, key: &str, ) -> Option<&'a str> { record .gateway_response .as_ref() .and_then(serde_json::Value::as_object) .and_then(|object| object.get(key)) .and_then(serde_json::Value::as_str) } /// A compensation path may remove only a freshly initialized wallet that has /// never carried funds or a financial reference. Keep this predicate in one /// place so the initial check and the final compare use exactly the same /// definition even when another in-memory writer updates the row between /// those checks. fn wallet_is_untouched_for_compensation(wallet: &StoredWalletSnapshot) -> bool { wallet.balance == 0.0 && wallet.gift_balance == 0.0 && wallet.total_recharged == 0.0 && wallet.total_consumed == 0.0 && wallet.total_refunded == 0.0 && wallet.total_adjusted == 0.0 && matches!(wallet.limit_mode.as_str(), "finite" | "unlimited") && wallet.currency == "USD" && wallet.status == "active" } fn provisional_auth_wallet_transactions_match( transactions: &BTreeMap, wallet: &StoredWalletSnapshot, user_id: &str, ) -> bool { let wallet_transactions = transactions .values() .filter(|transaction| transaction.wallet_id == wallet.id) .collect::>(); if wallet.gift_balance == 0.0 { return wallet_transactions.is_empty(); } if wallet_transactions.len() != 1 { return false; } let transaction = wallet_transactions[0]; transaction.category == "gift" && transaction.reason_code == "gift_initial" && transaction.amount == wallet.gift_balance && transaction.balance_before == 0.0 && transaction.balance_after == wallet.gift_balance && transaction.recharge_balance_before == 0.0 && transaction.recharge_balance_after == 0.0 && transaction.gift_balance_before == 0.0 && transaction.gift_balance_after == wallet.gift_balance && transaction.link_type.as_deref() == Some("system_task") && transaction.link_id.as_deref() == Some(user_id) && transaction.operator_id.is_none() } impl InMemoryWalletRepository { fn insert_payment_order_unique( &self, order: StoredAdminPaymentOrder, ) -> Result<(), DataLayerError> { let mut orders = self.payment_orders_by_id.write().expect("wallet repo lock"); if orders.contains_key(&order.id) || orders .values() .any(|existing| existing.order_no == order.order_no) { return Err(DataLayerError::InvalidInput( "payment order number already belongs to another order".to_string(), )); } if let Some(gateway_order_id) = order.gateway_order_id.as_deref() { if orders.values().any(|existing| { existing.payment_method == order.payment_method && existing.gateway_order_id.as_deref() == Some(gateway_order_id) }) { return Err(DataLayerError::InvalidInput( "payment gateway order already belongs to another order".to_string(), )); } } orders.insert(order.id.clone(), order); Ok(()) } fn insert_wallet_recharge_order_unique( &self, order: StoredAdminPaymentOrder, now_unix_secs: u64, input: &CreateWalletRechargeOrderInput, ) -> Result<(Option, bool), DataLayerError> { let mut orders = self.payment_orders_by_id.write().expect("wallet repo lock"); if let Some(existing) = orders .values() .find(|existing| existing.order_no == order.order_no) { let existing_kind = existing .gateway_response .as_ref() .and_then(serde_json::Value::as_object) .and_then(|object| object.get("order_kind")) .and_then(serde_json::Value::as_str); if existing.user_id == order.user_id && existing_kind == Some("wallet_recharge") { let existing_provider = wallet_recharge_metadata_value(existing, "payment_provider") .or_else(|| wallet_recharge_metadata_value(existing, "gateway")); // A seeded legacy order can outlive a deleted/provisioning // wallet. In that read-model-only case the incoming wallet // id is provisional and cannot be treated as an immutable // field; a real wallet remains strictly bound below. let replay_wallet_id = if self .wallets_by_id .read() .expect("wallet repo lock") .contains_key(&existing.wallet_id) { order.wallet_id.as_str() } else { existing.wallet_id.as_str() }; if !wallet_recharge_replay_matches( &existing.wallet_id, existing.amount_usd, existing.pay_amount, existing.pay_currency.as_deref(), existing.exchange_rate, &existing.payment_method, existing_provider, wallet_recharge_metadata_value(existing, "payment_channel"), replay_wallet_id, input, ) { return Err(DataLayerError::InvalidInput( "wallet recharge replay changes immutable order fields".to_string(), )); } if wallet_recharge_order_is_reclaimable_placeholder(existing, now_unix_secs) { let Some(candidate_token) = order .gateway_response .as_ref() .and_then(wallet_recharge_checkout_claim_token) else { return Ok((Some(existing.clone()), false)); }; let claimed = wallet_recharge_checkout_claim_response( order.gateway_response.as_ref().expect("gateway response"), candidate_token, now_unix_secs, ) .map_err(DataLayerError::InvalidInput)?; let existing_id = existing.id.clone(); let existing = orders .get_mut(&existing_id) .expect("recharge order should remain present"); existing.gateway_response = Some(claimed); existing.gateway_order_id = Some(existing.order_no.clone()); existing.status = "pending".to_string(); existing.expires_at_unix_secs = order.expires_at_unix_secs; return Ok((Some(existing.clone()), true)); } return Ok((Some(existing.clone()), false)); } return Err(DataLayerError::InvalidInput( "payment order number already belongs to another user".to_string(), )); } if let Some(gateway_order_id) = order.gateway_order_id.as_deref() { if orders.values().any(|existing| { existing.payment_method == order.payment_method && existing.gateway_order_id.as_deref() == Some(gateway_order_id) }) { return Err(DataLayerError::InvalidInput( "payment gateway order already belongs to another order".to_string(), )); } } orders.insert(order.id.clone(), order); Ok((None, false)) } fn remove_created_wallet_if_unreferenced(&self, wallet_id: &str) { // The caller holds wallet_lifecycle_lock, as do all in-memory paths // that attach a payment order. The reference check and compensation // delete are therefore atomic with respect to a new order attachment. let wallet_is_referenced = self .payment_orders_by_id .read() .expect("wallet repo lock") .values() .any(|existing| existing.wallet_id == wallet_id); if !wallet_is_referenced { let mut wallets = self.wallets_by_id.write().expect("wallet repo lock"); // A regular wallet snapshot update does not need the lifecycle // lock. Re-check the complete pristine shape while holding the // write lock so a concurrent credit cannot be deleted as part of // compensation. if wallets .get(wallet_id) .is_some_and(wallet_is_untouched_for_compensation) { wallets.remove(wallet_id); } } } pub fn seed(items: I) -> Self where I: IntoIterator, { let mut wallets_by_id = BTreeMap::new(); for item in items { wallets_by_id.insert(item.id.clone(), item); } Self { wallets_by_id: RwLock::new(wallets_by_id), payment_orders_by_id: RwLock::new(BTreeMap::new()), payment_callbacks_by_id: RwLock::new(BTreeMap::new()), wallet_transactions_by_id: RwLock::new(BTreeMap::new()), refunds_by_id: RwLock::new(BTreeMap::new()), redeem_batches_by_id: RwLock::new(BTreeMap::new()), redeem_codes_by_id: RwLock::new(BTreeMap::new()), redeem_code_hash_to_id: RwLock::new(BTreeMap::new()), refund_idempotency_to_id: RwLock::new(BTreeMap::new()), refund_creation_lock: Mutex::new(()), wallet_lifecycle_lock: Mutex::new(()), } } #[cfg(test)] pub(crate) fn seed_read_model(seed: WalletReadSeed) -> Self { let mut wallets_by_id = BTreeMap::new(); for item in seed.wallets { wallets_by_id.insert(item.id.clone(), item); } let mut payment_orders_by_id = BTreeMap::new(); for item in seed.payment_orders { payment_orders_by_id.insert(item.id.clone(), item); } let mut payment_callbacks_by_id = BTreeMap::new(); for item in seed.payment_callbacks { payment_callbacks_by_id.insert(item.id.clone(), item); } let mut wallet_transactions_by_id = BTreeMap::new(); for item in seed.wallet_transactions { wallet_transactions_by_id.insert(item.id.clone(), item); } let mut refunds_by_id = BTreeMap::new(); for item in seed.refunds { refunds_by_id.insert(item.id.clone(), item); } let refund_idempotency_to_id = seed .refund_idempotency .into_iter() .map(|(user_id, idempotency_key, refund_id)| ((user_id, idempotency_key), refund_id)) .collect(); let mut redeem_batches_by_id = BTreeMap::new(); for item in seed.redeem_batches { redeem_batches_by_id.insert(item.id.clone(), item); } let mut redeem_codes_by_id = BTreeMap::new(); for item in seed.redeem_codes { redeem_codes_by_id.insert(item.id.clone(), item); } Self { wallets_by_id: RwLock::new(wallets_by_id), payment_orders_by_id: RwLock::new(payment_orders_by_id), payment_callbacks_by_id: RwLock::new(payment_callbacks_by_id), wallet_transactions_by_id: RwLock::new(wallet_transactions_by_id), refunds_by_id: RwLock::new(refunds_by_id), redeem_batches_by_id: RwLock::new(redeem_batches_by_id), redeem_codes_by_id: RwLock::new(redeem_codes_by_id), redeem_code_hash_to_id: RwLock::new(BTreeMap::new()), refund_idempotency_to_id: RwLock::new(refund_idempotency_to_id), refund_creation_lock: Mutex::new(()), wallet_lifecycle_lock: Mutex::new(()), } } pub(crate) fn with_wallets_mut( &self, f: impl FnOnce(&mut BTreeMap) -> R, ) -> R { let mut wallets = self.wallets_by_id.write().expect("wallet repo lock"); f(&mut wallets) } } fn current_unix_secs() -> u64 { chrono::Utc::now().timestamp().max(0) as u64 } fn current_unix_ms() -> u64 { chrono::Utc::now().timestamp_millis().max(0) as u64 } struct WalletSnapshotUpdate<'a> { balance: f64, gift_balance: f64, limit_mode: &'a str, currency: &'a str, status: &'a str, total_recharged: f64, total_consumed: f64, total_refunded: f64, total_adjusted: f64, updated_at_unix_secs: Option, } fn update_wallet_by_owner( wallets_by_id: &RwLock>, matches_owner: impl Fn(&StoredWalletSnapshot) -> bool, update: impl FnOnce(&mut StoredWalletSnapshot), ) -> Result, DataLayerError> { let mut wallets = wallets_by_id.write().expect("wallet repo lock"); let Some(wallet) = wallets.values_mut().find(|wallet| matches_owner(wallet)) else { return Ok(None); }; update(wallet); Ok(Some(wallet.clone())) } fn update_wallet_snapshot_by_owner( wallets_by_id: &RwLock>, matches_owner: impl Fn(&StoredWalletSnapshot) -> bool, update: WalletSnapshotUpdate<'_>, ) -> Result, DataLayerError> { update_wallet_by_owner(wallets_by_id, matches_owner, |wallet| { wallet.balance = update.balance; wallet.gift_balance = update.gift_balance; wallet.limit_mode = update.limit_mode.to_string(); wallet.currency = update.currency.to_string(); wallet.status = update.status.to_string(); wallet.total_recharged = update.total_recharged; wallet.total_consumed = update.total_consumed; wallet.total_refunded = update.total_refunded; wallet.total_adjusted = update.total_adjusted; wallet.updated_at_unix_secs = update .updated_at_unix_secs .unwrap_or_else(current_unix_secs); }) } fn initialize_auth_wallet_in_memory( wallets_by_id: &RwLock>, wallet_transactions_by_id: &RwLock>, user_id: Option<&str>, api_key_id: Option<&str>, initial_gift_usd: f64, unlimited: bool, ) -> Result, DataLayerError> { let owner_id = user_id .or(api_key_id) .filter(|value| !value.trim().is_empty()); if owner_id.is_none() || (user_id.is_some() && api_key_id.is_some()) { return Err(DataLayerError::InvalidInput( "wallet owner must be exactly one non-empty user or API-key id".to_string(), )); } if !initial_gift_usd.is_finite() { return Err(DataLayerError::InvalidInput( "initial gift amount must be finite".to_string(), )); } // Initialization is intentionally idempotent. Database backends enforce this // with the owner unique indexes; perform the same lookup before creating the // in-memory row so retries cannot mint another wallet or gift transaction. { let wallets = wallets_by_id.read().expect("wallet repo lock"); let existing = wallets.values().find(|wallet| { if let Some(user_id) = user_id { wallet.user_id.as_deref() == Some(user_id) && wallet.api_key_id.is_none() } else if let Some(api_key_id) = api_key_id { wallet.api_key_id.as_deref() == Some(api_key_id) && wallet.user_id.is_none() } else { false } }); if let Some(wallet) = existing { return Ok(Some((wallet.clone(), false))); } } let gift_amount = if unlimited { 0.0 } else { initial_gift_usd.max(0.0) }; let wallet = StoredWalletSnapshot::new( uuid::Uuid::new_v4().to_string(), user_id.map(str::to_string), api_key_id.map(str::to_string), 0.0, gift_amount, if unlimited { "unlimited" } else { "finite" }.to_string(), "USD".to_string(), "active".to_string(), 0.0, 0.0, 0.0, gift_amount, current_unix_secs() as i64, )?; wallets_by_id .write() .expect("wallet repo lock") .insert(wallet.id.clone(), wallet.clone()); if gift_amount > 0.0 { let link_id = user_id.or(api_key_id).unwrap_or_default().to_string(); let description = if api_key_id.is_some() { "独立余额 Key 初始赠款" } else { "用户初始赠款" }; let transaction = StoredAdminWalletTransaction { id: uuid::Uuid::new_v4().to_string(), wallet_id: wallet.id.clone(), category: "gift".to_string(), reason_code: "gift_initial".to_string(), amount: gift_amount, balance_before: 0.0, balance_after: gift_amount, recharge_balance_before: 0.0, recharge_balance_after: 0.0, gift_balance_before: 0.0, gift_balance_after: gift_amount, link_type: Some("system_task".to_string()), link_id: Some(link_id), operator_id: None, operator_name: None, operator_email: None, description: Some(description.to_string()), created_at_unix_ms: Some(current_unix_ms()), }; wallet_transactions_by_id .write() .expect("wallet repo lock") .insert(transaction.id.clone(), transaction); } Ok(Some((wallet, true))) } fn normalize_redeem_code(value: &str) -> Option { let normalized = value .chars() .filter(|ch| ch.is_ascii_alphanumeric()) .map(|ch| ch.to_ascii_uppercase()) .collect::(); if normalized.len() < 16 { None } else { Some(normalized) } } fn format_redeem_code(normalized: &str) -> String { normalized .as_bytes() .chunks(8) .map(|chunk| std::str::from_utf8(chunk).unwrap_or_default()) .collect::>() .join("-") } fn hash_redeem_code(normalized: &str) -> String { use sha2::Digest; format!("{:x}", sha2::Sha256::digest(normalized.as_bytes())) } fn mask_redeem_code(prefix: &str, suffix: &str) -> String { format!("{prefix}****{suffix}") } fn generate_redeem_code() -> String { format_redeem_code( &uuid::Uuid::new_v4() .simple() .to_string() .to_ascii_uppercase(), ) } #[async_trait] impl WalletReadRepository for InMemoryWalletRepository { async fn find( &self, key: WalletLookupKey<'_>, ) -> Result, DataLayerError> { let wallets = self.wallets_by_id.read().expect("wallet repo lock"); Ok(match key { WalletLookupKey::WalletId(wallet_id) => wallets.get(wallet_id).cloned(), WalletLookupKey::UserId(user_id) => wallets .values() .find(|wallet| wallet.user_id.as_deref() == Some(user_id)) .cloned(), WalletLookupKey::ApiKeyId(api_key_id) => wallets .values() .find(|wallet| wallet.api_key_id.as_deref() == Some(api_key_id)) .cloned(), }) } async fn update_auth_user_wallet_limit_mode( &self, user_id: &str, limit_mode: &str, ) -> Result, DataLayerError> { update_wallet_by_owner( &self.wallets_by_id, |wallet| wallet.user_id.as_deref() == Some(user_id), |wallet| { wallet.limit_mode = limit_mode.to_string(); wallet.updated_at_unix_secs = current_unix_secs(); }, ) } async fn update_auth_api_key_wallet_limit_mode( &self, api_key_id: &str, limit_mode: &str, ) -> Result, DataLayerError> { update_wallet_by_owner( &self.wallets_by_id, |wallet| wallet.api_key_id.as_deref() == Some(api_key_id), |wallet| { wallet.limit_mode = limit_mode.to_string(); wallet.updated_at_unix_secs = current_unix_secs(); }, ) } async fn initialize_auth_user_wallet( &self, user_id: &str, initial_gift_usd: f64, unlimited: bool, ) -> Result, DataLayerError> { if user_id.trim().is_empty() { return Err(DataLayerError::InvalidInput( "user id is required to initialize a wallet".to_string(), )); } let _lifecycle_guard = self .wallet_lifecycle_lock .lock() .expect("wallet lifecycle lock"); initialize_auth_wallet_in_memory( &self.wallets_by_id, &self.wallet_transactions_by_id, Some(user_id), None, initial_gift_usd, unlimited, ) .map(|result| result.map(|(wallet, _created)| wallet)) } async fn initialize_auth_user_wallet_with_outcome( &self, user_id: &str, initial_gift_usd: f64, unlimited: bool, ) -> Result, DataLayerError> { if user_id.trim().is_empty() { return Err(DataLayerError::InvalidInput( "user id is required to initialize a wallet".to_string(), )); } let _lifecycle_guard = self .wallet_lifecycle_lock .lock() .expect("wallet lifecycle lock"); initialize_auth_wallet_in_memory( &self.wallets_by_id, &self.wallet_transactions_by_id, Some(user_id), None, initial_gift_usd, unlimited, ) .map(|result| { result.map(|(wallet, created)| InitializeAuthWalletOutcome { wallet, created }) }) } async fn initialize_auth_api_key_wallet( &self, api_key_id: &str, initial_gift_usd: f64, unlimited: bool, ) -> Result, DataLayerError> { if api_key_id.trim().is_empty() { return Err(DataLayerError::InvalidInput( "api key id is required to initialize a wallet".to_string(), )); } let _lifecycle_guard = self .wallet_lifecycle_lock .lock() .expect("wallet lifecycle lock"); initialize_auth_wallet_in_memory( &self.wallets_by_id, &self.wallet_transactions_by_id, None, Some(api_key_id), initial_gift_usd, unlimited, ) .map(|result| result.map(|(wallet, _created)| wallet)) } async fn initialize_auth_api_key_wallet_with_outcome( &self, api_key_id: &str, initial_gift_usd: f64, unlimited: bool, ) -> Result, DataLayerError> { if api_key_id.trim().is_empty() { return Err(DataLayerError::InvalidInput( "api key id is required to initialize a wallet".to_string(), )); } let _lifecycle_guard = self .wallet_lifecycle_lock .lock() .expect("wallet lifecycle lock"); initialize_auth_wallet_in_memory( &self.wallets_by_id, &self.wallet_transactions_by_id, None, Some(api_key_id), initial_gift_usd, unlimited, ) .map(|result| { result.map(|(wallet, created)| InitializeAuthWalletOutcome { wallet, created }) }) } async fn update_auth_user_wallet_snapshot( &self, user_id: &str, balance: f64, gift_balance: f64, limit_mode: &str, currency: &str, status: &str, total_recharged: f64, total_consumed: f64, total_refunded: f64, total_adjusted: f64, updated_at_unix_secs: Option, ) -> Result, DataLayerError> { update_wallet_snapshot_by_owner( &self.wallets_by_id, |wallet| wallet.user_id.as_deref() == Some(user_id), WalletSnapshotUpdate { balance, gift_balance, limit_mode, currency, status, total_recharged, total_consumed, total_refunded, total_adjusted, updated_at_unix_secs, }, ) } async fn update_auth_api_key_wallet_snapshot( &self, api_key_id: &str, balance: f64, gift_balance: f64, limit_mode: &str, currency: &str, status: &str, total_recharged: f64, total_consumed: f64, total_refunded: f64, total_adjusted: f64, updated_at_unix_secs: Option, ) -> Result, DataLayerError> { update_wallet_snapshot_by_owner( &self.wallets_by_id, |wallet| wallet.api_key_id.as_deref() == Some(api_key_id), WalletSnapshotUpdate { balance, gift_balance, limit_mode, currency, status, total_recharged, total_consumed, total_refunded, total_adjusted, updated_at_unix_secs, }, ) } async fn list_wallets_by_user_ids( &self, user_ids: &[String], ) -> Result, DataLayerError> { if user_ids.is_empty() { return Ok(Vec::new()); } let user_set: std::collections::BTreeSet<&str> = user_ids.iter().map(String::as_str).collect(); let wallets = self.wallets_by_id.read().expect("wallet repo lock"); Ok(wallets .values() .filter(|wallet| { wallet .user_id .as_deref() .map(|user_id| user_set.contains(user_id)) .unwrap_or(false) }) .cloned() .collect()) } async fn list_wallets_by_api_key_ids( &self, api_key_ids: &[String], ) -> Result, DataLayerError> { if api_key_ids.is_empty() { return Ok(Vec::new()); } let key_set: std::collections::BTreeSet<&str> = api_key_ids.iter().map(String::as_str).collect(); let wallets = self.wallets_by_id.read().expect("wallet repo lock"); Ok(wallets .values() .filter(|wallet| { wallet .api_key_id .as_deref() .map(|api_key_id| key_set.contains(api_key_id)) .unwrap_or(false) }) .cloned() .collect()) } async fn list_admin_wallets( &self, query: &AdminWalletListQuery, ) -> Result { let wallets = self.wallets_by_id.read().expect("wallet repo lock"); let mut items = wallets .values() .filter(|wallet| { query .status .as_deref() .is_none_or(|expected| wallet.status == expected) }) .filter(|wallet| match query.owner_type.as_deref() { Some("user") => wallet.user_id.is_some(), Some("api_key") => wallet.api_key_id.is_some(), _ => true, }) .map(|wallet| StoredAdminWalletListItem { id: wallet.id.clone(), user_id: wallet.user_id.clone(), api_key_id: wallet.api_key_id.clone(), balance: wallet.balance, gift_balance: wallet.gift_balance, limit_mode: wallet.limit_mode.clone(), currency: wallet.currency.clone(), status: wallet.status.clone(), total_recharged: wallet.total_recharged, total_consumed: wallet.total_consumed, total_refunded: wallet.total_refunded, total_adjusted: wallet.total_adjusted, user_name: None, api_key_name: None, created_at_unix_ms: None, updated_at_unix_secs: Some(wallet.updated_at_unix_secs), }) .collect::>(); items.sort_by(|left, right| { right .updated_at_unix_secs .cmp(&left.updated_at_unix_secs) .then_with(|| right.id.cmp(&left.id)) }); let total = items.len() as u64; let items = items .into_iter() .skip(query.offset) .take(query.limit) .collect::>(); Ok(StoredAdminWalletListPage { items, total }) } async fn list_admin_wallet_ledger( &self, _query: &AdminWalletLedgerQuery, ) -> Result { Ok(StoredAdminWalletLedgerPage::default()) } async fn list_admin_wallet_refund_requests( &self, query: &AdminWalletRefundRequestListQuery, ) -> Result { let wallets = self.wallets_by_id.read().expect("wallet repo lock").clone(); let mut items = self .refunds_by_id .read() .expect("wallet repo lock") .values() .filter(|refund| { query .status .as_deref() .is_none_or(|expected| refund.status == expected) }) .filter_map(|refund| { let wallet = wallets.get(&refund.wallet_id)?; Some(super::StoredAdminWalletRefundRequestItem { id: refund.id.clone(), refund_no: refund.refund_no.clone(), wallet_id: refund.wallet_id.clone(), user_id: refund.user_id.clone(), payment_order_id: refund.payment_order_id.clone(), source_type: refund.source_type.clone(), source_id: refund.source_id.clone(), refund_mode: refund.refund_mode.clone(), amount_usd: refund.amount_usd, status: refund.status.clone(), reason: refund.reason.clone(), failure_reason: refund.failure_reason.clone(), gateway_refund_id: refund.gateway_refund_id.clone(), payout_method: refund.payout_method.clone(), payout_reference: refund.payout_reference.clone(), payout_proof: refund.payout_proof.clone(), requested_by: refund.requested_by.clone(), approved_by: refund.approved_by.clone(), processed_by: refund.processed_by.clone(), wallet_user_id: wallet.user_id.clone(), wallet_user_name: None, wallet_api_key_id: wallet.api_key_id.clone(), api_key_name: None, wallet_status: wallet.status.clone(), created_at_unix_ms: Some(refund.created_at_unix_ms), updated_at_unix_secs: Some(refund.updated_at_unix_secs), processed_at_unix_secs: refund.processed_at_unix_secs, completed_at_unix_secs: refund.completed_at_unix_secs, }) }) .collect::>(); items.sort_by_key(|item| std::cmp::Reverse(item.created_at_unix_ms)); let total = items.len() as u64; let items = items .into_iter() .skip(query.offset) .take(query.limit) .collect(); Ok(StoredAdminWalletRefundRequestPage { items, total }) } async fn list_admin_wallet_transactions( &self, wallet_id: &str, limit: usize, offset: usize, ) -> Result { let mut items = self .wallet_transactions_by_id .read() .expect("wallet repo lock") .values() .filter(|tx| tx.wallet_id == wallet_id) .cloned() .collect::>(); items.sort_by_key(|item| std::cmp::Reverse(item.created_at_unix_ms)); let total = items.len() as u64; let items = items.into_iter().skip(offset).take(limit).collect(); Ok(StoredAdminWalletTransactionPage { items, total }) } async fn find_wallet_today_usage( &self, _wallet_id: &str, _billing_timezone: &str, ) -> Result, DataLayerError> { Ok(None) } async fn list_wallet_daily_usage_history( &self, _wallet_id: &str, _billing_timezone: &str, _limit: usize, ) -> Result { Ok(StoredWalletDailyUsageLedgerPage::default()) } async fn list_admin_wallet_refunds( &self, wallet_id: &str, limit: usize, offset: usize, ) -> Result { let mut items = self .refunds_by_id .read() .expect("wallet repo lock") .values() .filter(|refund| refund.wallet_id == wallet_id) .cloned() .collect::>(); items.sort_by_key(|item| std::cmp::Reverse(item.created_at_unix_ms)); let total = items.len() as u64; let items = items.into_iter().skip(offset).take(limit).collect(); Ok(StoredAdminWalletRefundPage { items, total }) } async fn list_admin_payment_orders( &self, query: &AdminPaymentOrderListQuery, ) -> Result { let now = current_unix_secs(); let mut items = self .payment_orders_by_id .read() .expect("wallet repo lock") .values() .filter(|order| { query.status.as_deref().is_none_or(|expected| { let effective = if order.status == "pending" && order.expires_at_unix_secs.is_some_and(|value| value <= now) { "expired" } else { order.status.as_str() }; effective == expected }) && query .payment_method .as_deref() .is_none_or(|expected| order.payment_method == expected) }) .cloned() .collect::>(); items.sort_by_key(|item| std::cmp::Reverse(item.created_at_unix_ms)); let total = items.len() as u64; let items = items .into_iter() .skip(query.offset) .take(query.limit) .collect(); Ok(StoredAdminPaymentOrderPage { items, total }) } async fn find_admin_payment_order( &self, order_id: &str, ) -> Result, DataLayerError> { Ok(self .payment_orders_by_id .read() .expect("wallet repo lock") .get(order_id) .cloned()) } async fn list_wallet_payment_orders_by_user_id( &self, user_id: &str, limit: usize, offset: usize, ) -> Result { let mut items = self .payment_orders_by_id .read() .expect("wallet repo lock") .values() .filter(|order| { order.user_id.as_deref() == Some(user_id) && order .gateway_response .as_ref() .and_then(|value| value.get("order_kind")) .and_then(serde_json::Value::as_str) != Some("plan_purchase") }) .cloned() .collect::>(); items.sort_by_key(|item| std::cmp::Reverse(item.created_at_unix_ms)); let total = items.len() as u64; let items = items.into_iter().skip(offset).take(limit).collect(); Ok(StoredAdminPaymentOrderPage { items, total }) } async fn count_pending_refunds_by_user_id(&self, user_id: &str) -> Result { const PENDING_REFUND_STATUSES: &[&str] = &["pending_approval", "approved", "processing"]; Ok(self .refunds_by_id .read() .expect("wallet repo lock") .values() .filter(|refund| { refund.user_id.as_deref() == Some(user_id) && PENDING_REFUND_STATUSES.contains(&refund.status.as_str()) }) .count() as u64) } async fn count_pending_payment_orders_by_user_id( &self, user_id: &str, ) -> Result { const PENDING_PAYMENT_ORDER_STATUSES: &[&str] = &["pending", "paid"]; Ok(self .payment_orders_by_id .read() .expect("wallet repo lock") .values() .filter(|order| { order.user_id.as_deref() == Some(user_id) && PENDING_PAYMENT_ORDER_STATUSES.contains(&order.status.as_str()) }) .count() as u64) } async fn find_wallet_payment_order_by_user_id( &self, user_id: &str, order_id: &str, ) -> Result, DataLayerError> { Ok(self .payment_orders_by_id .read() .expect("wallet repo lock") .get(order_id) .filter(|order| { order.user_id.as_deref() == Some(user_id) && order .gateway_response .as_ref() .and_then(|value| value.get("order_kind")) .and_then(serde_json::Value::as_str) != Some("plan_purchase") }) .cloned()) } async fn find_wallet_recharge_order_by_order_no( &self, user_id: &str, order_no: &str, ) -> Result, DataLayerError> { Ok(self .payment_orders_by_id .read() .expect("wallet repo lock") .values() .find(|order| { order.user_id.as_deref() == Some(user_id) && order.order_no == order_no && order .gateway_response .as_ref() .and_then(serde_json::Value::as_object) .and_then(|object| object.get("order_kind")) .and_then(serde_json::Value::as_str) == Some("wallet_recharge") }) .cloned()) } async fn find_pending_plan_purchase_order_by_user_id( &self, user_id: &str, product_id: &str, ) -> Result, DataLayerError> { let now = current_unix_secs(); Ok(self .payment_orders_by_id .read() .expect("wallet repo lock") .values() .filter(|order| { order.user_id.as_deref() == Some(user_id) && order.status == "pending" && order .expires_at_unix_secs .is_some_and(|expires_at| expires_at > now) && order .gateway_response .as_ref() .is_some_and(|gateway_response| { gateway_response .get("order_kind") .and_then(serde_json::Value::as_str) == Some("plan_purchase") && gateway_response .get("product_id") .and_then(serde_json::Value::as_str) == Some(product_id) }) }) .max_by_key(|order| order.created_at_unix_ms) .cloned()) } async fn find_payment_order_by_order_no( &self, order_no: &str, ) -> Result, DataLayerError> { Ok(self .payment_orders_by_id .read() .expect("wallet repo lock") .values() .find(|order| order.order_no == order_no) .cloned()) } async fn find_wallet_refund( &self, wallet_id: &str, refund_id: &str, ) -> Result, DataLayerError> { Ok(self .refunds_by_id .read() .expect("wallet repo lock") .get(refund_id) .filter(|refund| refund.wallet_id == wallet_id) .cloned()) } async fn list_admin_payment_callbacks( &self, payment_method: Option<&str>, limit: usize, offset: usize, ) -> Result { let mut items = self .payment_callbacks_by_id .read() .expect("wallet repo lock") .values() .filter(|callback| { payment_method.is_none_or(|expected| callback.payment_method == expected) }) .cloned() .collect::>(); items.sort_by_key(|item| std::cmp::Reverse(item.created_at_unix_ms)); let total = items.len() as u64; let items = items.into_iter().skip(offset).take(limit).collect(); Ok(StoredAdminPaymentCallbackPage { items, total }) } async fn list_admin_redeem_code_batches( &self, query: &AdminRedeemCodeBatchListQuery, ) -> Result { let mut items = self .redeem_batches_by_id .read() .expect("wallet repo lock") .values() .filter(|batch| { query .status .as_deref() .is_none_or(|expected| batch.status == expected) }) .cloned() .collect::>(); items.sort_by_key(|item| std::cmp::Reverse(item.created_at_unix_ms)); let total = items.len() as u64; let items = items .into_iter() .skip(query.offset) .take(query.limit) .collect(); Ok(StoredAdminRedeemCodeBatchPage { items, total }) } async fn find_admin_redeem_code_batch( &self, batch_id: &str, ) -> Result, DataLayerError> { Ok(self .redeem_batches_by_id .read() .expect("wallet repo lock") .get(batch_id) .cloned()) } async fn list_admin_redeem_codes( &self, query: &AdminRedeemCodeListQuery, ) -> Result { let mut items = self .redeem_codes_by_id .read() .expect("wallet repo lock") .values() .filter(|code| code.batch_id == query.batch_id) .filter(|code| { query .status .as_deref() .is_none_or(|expected| code.status == expected) }) .cloned() .collect::>(); items.sort_by_key(|item| std::cmp::Reverse(item.created_at_unix_ms)); let total = items.len() as u64; let items = items .into_iter() .skip(query.offset) .take(query.limit) .collect(); Ok(StoredAdminRedeemCodePage { items, total }) } } #[async_trait] impl WalletWriteRepository for InMemoryWalletRepository { async fn delete_wallet_if_unreferenced( &self, wallet_id: &str, owner: WalletLookupKey<'_>, ) -> Result { if wallet_id.trim().is_empty() { return Ok(false); } let owner_matches = |wallet: &StoredWalletSnapshot| match owner { WalletLookupKey::UserId(user_id) => { !user_id.trim().is_empty() && wallet.id == wallet_id && wallet.user_id.as_deref() == Some(user_id) && wallet.api_key_id.is_none() } WalletLookupKey::ApiKeyId(api_key_id) => { !api_key_id.trim().is_empty() && wallet.id == wallet_id && wallet.api_key_id.as_deref() == Some(api_key_id) && wallet.user_id.is_none() } WalletLookupKey::WalletId(_) => false, }; if matches!(owner, WalletLookupKey::WalletId(_)) { return Err(DataLayerError::InvalidInput( "wallet compensation requires an explicit user or API-key owner".to_string(), )); } let _lifecycle_guard = self .wallet_lifecycle_lock .lock() .expect("wallet lifecycle lock"); let wallet = { let wallets = self.wallets_by_id.read().expect("wallet repo lock"); wallets .values() .find(|wallet| owner_matches(wallet)) .cloned() }; let Some(wallet) = wallet else { return Ok(false); }; // Compensation is only allowed for an untouched, freshly-created wallet. A journal // entry can race with an existing wallet lookup, and deleting a zero-reference wallet // with persisted funds would otherwise destroy those funds. if !wallet_is_untouched_for_compensation(&wallet) { return Ok(false); } let referenced = self .payment_orders_by_id .read() .expect("wallet repo lock") .values() .any(|order| order.wallet_id == wallet.id) || self .refunds_by_id .read() .expect("wallet repo lock") .values() .any(|refund| refund.wallet_id == wallet.id) || self .wallet_transactions_by_id .read() .expect("wallet repo lock") .values() .any(|transaction| transaction.wallet_id == wallet.id) || self .redeem_codes_by_id .read() .expect("wallet repo lock") .values() .any(|code| code.redeemed_wallet_id.as_deref() == Some(wallet.id.as_str())); if referenced { return Ok(false); } let mut wallets = self.wallets_by_id.write().expect("wallet repo lock"); let removable = wallets.get(&wallet.id).is_some_and(|current| { owner_matches(current) && current == &wallet && wallet_is_untouched_for_compensation(current) }); if !removable { return Ok(false); } Ok(wallets.remove(&wallet.id).is_some()) } async fn delete_wallet_if_snapshot_matches_and_unreferenced( &self, expected: &StoredWalletSnapshot, owner: WalletLookupKey<'_>, ) -> Result { if expected.id.trim().is_empty() { return Ok(false); } let owner_matches = |wallet: &StoredWalletSnapshot| match owner { WalletLookupKey::UserId(user_id) => { !user_id.trim().is_empty() && wallet.id == expected.id && wallet.user_id.as_deref() == Some(user_id) && wallet.api_key_id.is_none() } WalletLookupKey::ApiKeyId(api_key_id) => { !api_key_id.trim().is_empty() && wallet.id == expected.id && wallet.api_key_id.as_deref() == Some(api_key_id) && wallet.user_id.is_none() } WalletLookupKey::WalletId(_) => false, }; if matches!(owner, WalletLookupKey::WalletId(_)) { return Err(DataLayerError::InvalidInput( "wallet compensation requires an explicit user or API-key owner".to_string(), )); } let _lifecycle_guard = self .wallet_lifecycle_lock .lock() .expect("wallet lifecycle lock"); let current = { let wallets = self.wallets_by_id.read().expect("wallet repo lock"); wallets .get(&expected.id) .filter(|wallet| owner_matches(wallet)) .cloned() }; // Compare every field, including the owner and update timestamp. A // mismatch means another operation touched the wallet, so compensation // must fail closed and preserve its funds. if current.as_ref() != Some(expected) { return Ok(false); } let referenced = self .payment_orders_by_id .read() .expect("wallet repo lock") .values() .any(|order| order.wallet_id == expected.id) || self .refunds_by_id .read() .expect("wallet repo lock") .values() .any(|refund| refund.wallet_id == expected.id) || self .wallet_transactions_by_id .read() .expect("wallet repo lock") .values() .any(|transaction| transaction.wallet_id == expected.id) || self .redeem_codes_by_id .read() .expect("wallet repo lock") .values() .any(|code| code.redeemed_wallet_id.as_deref() == Some(expected.id.as_str())); if referenced { return Ok(false); } let mut wallets = self.wallets_by_id.write().expect("wallet repo lock"); if wallets .get(&expected.id) .is_some_and(|wallet| owner_matches(wallet) && wallet == expected) { return Ok(wallets.remove(&expected.id).is_some()); } Ok(false) } async fn restore_wallet_if_snapshot_matches( &self, before: &StoredWalletSnapshot, after: &StoredWalletSnapshot, owner: WalletLookupKey<'_>, ) -> Result { if before.id.trim().is_empty() || after.id.trim().is_empty() { return Ok(false); } if before.id != after.id { return Err(DataLayerError::InvalidInput( "wallet restore snapshots must reference the same wallet".to_string(), )); } let owner_matches = |wallet: &StoredWalletSnapshot| match owner { WalletLookupKey::UserId(user_id) => { !user_id.trim().is_empty() && wallet.user_id.as_deref() == Some(user_id) && wallet.api_key_id.is_none() } WalletLookupKey::ApiKeyId(api_key_id) => { !api_key_id.trim().is_empty() && wallet.api_key_id.as_deref() == Some(api_key_id) && wallet.user_id.is_none() } WalletLookupKey::WalletId(_) => false, }; if matches!(owner, WalletLookupKey::WalletId(_)) { return Err(DataLayerError::InvalidInput( "wallet restore requires an explicit user or API-key owner".to_string(), )); } // Keep the compare and replacement atomic with the lifecycle operations. The map write // lock also prevents an ordinary wallet mutation from interleaving between the compare // and restore; a changed snapshot therefore fails closed instead of being overwritten. let _lifecycle_guard = self .wallet_lifecycle_lock .lock() .expect("wallet lifecycle lock"); let mut wallets = self.wallets_by_id.write().expect("wallet repo lock"); let Some(current) = wallets.get(&after.id) else { return Ok(false); }; if current != after || !owner_matches(current) || !owner_matches(before) { return Ok(false); } wallets.insert(before.id.clone(), before.clone()); Ok(true) } async fn delete_provisional_auth_user_wallet( &self, wallet_id: &str, user_id: &str, ) -> Result { if wallet_id.trim().is_empty() || user_id.trim().is_empty() { return Ok(false); } let _lifecycle_guard = self .wallet_lifecycle_lock .lock() .expect("wallet lifecycle lock"); // Provisioning rollback is deliberately fail-closed. The only // transaction that may exist is the deterministic initial gift entry; // any other financial artifact makes the wallet ineligible for purge. let wallet = { let wallets = self.wallets_by_id.read().expect("wallet repo lock"); wallets .values() .find(|wallet| { wallet.id == wallet_id && wallet.user_id.as_deref() == Some(user_id) && wallet.api_key_id.is_none() && wallet.balance == 0.0 && wallet.total_recharged == 0.0 && wallet.total_consumed == 0.0 && wallet.total_refunded == 0.0 && wallet.total_adjusted == wallet.gift_balance && wallet.gift_balance >= 0.0 && wallet.status == "active" && matches!(wallet.limit_mode.as_str(), "finite" | "unlimited") && wallet.currency == "USD" }) .cloned() }; let Some(wallet) = wallet else { return Ok(false); }; let transactions = self .wallet_transactions_by_id .read() .expect("wallet repo lock"); let transaction_matches = provisional_auth_wallet_transactions_match(&transactions, &wallet, user_id); drop(transactions); if !transaction_matches { return Ok(false); } if self .payment_orders_by_id .read() .expect("wallet repo lock") .values() .any(|order| order.wallet_id == wallet.id) || self .refunds_by_id .read() .expect("wallet repo lock") .values() .any(|refund| refund.wallet_id == wallet.id) || self .redeem_codes_by_id .read() .expect("wallet repo lock") .values() .any(|code| code.redeemed_wallet_id.as_deref() == Some(wallet.id.as_str())) { return Ok(false); } // Snapshot updates do not take `wallet_lifecycle_lock`; hold the // wallet write lock through the final compare and removal so a credit // cannot land after the check but before deletion. Re-check the // transaction set under its write lock as well, since a concurrent // financial entry must make this compensation fail closed. let mut wallets = self.wallets_by_id.write().expect("wallet repo lock"); if wallets.get(&wallet.id) != Some(&wallet) { return Ok(false); } let mut transactions = self .wallet_transactions_by_id .write() .expect("wallet repo lock"); if !provisional_auth_wallet_transactions_match(&transactions, &wallet, user_id) { return Ok(false); } transactions.retain(|_, transaction| transaction.wallet_id != wallet.id); Ok(wallets.remove(&wallet.id).is_some()) } async fn create_wallet_recharge_order( &self, mut input: CreateWalletRechargeOrderInput, ) -> Result { input.payment_method = canonicalize_payment_method(&input.payment_method) .map_err(DataLayerError::InvalidInput)?; validate_wallet_recharge_order_input(&input).map_err(DataLayerError::InvalidInput)?; if !input.amount_usd.is_finite() || input.amount_usd <= 0.0 || input .pay_amount .is_some_and(|value| !value.is_finite() || value <= 0.0) || input .exchange_rate .is_some_and(|value| !value.is_finite() || value <= 0.0) || input.expires_at_unix_secs > i64::MAX as u64 { return Err(DataLayerError::InvalidInput( "invalid wallet recharge numeric fields".to_string(), )); } let mut gateway_response = project_wallet_recharge_gateway_response(&input.gateway_response) .map_err(DataLayerError::InvalidInput)?; if !gateway_response.is_object() { return Err(DataLayerError::InvalidInput( "wallet recharge gateway response must be an object".to_string(), )); } let gateway_object = gateway_response .as_object_mut() .expect("validated wallet recharge gateway response"); if let Some(provider) = input.payment_provider.as_deref() { gateway_object.insert( "payment_provider".to_string(), serde_json::Value::String(provider.trim().to_ascii_lowercase()), ); } if let Some(channel) = input.payment_channel.as_deref() { gateway_object.insert( "payment_channel".to_string(), serde_json::Value::String(channel.trim().to_ascii_lowercase()), ); } let _lifecycle_guard = self .wallet_lifecycle_lock .lock() .expect("wallet lifecycle lock"); let now_secs = current_unix_secs(); // Keep the wallet and payment-order locks in separate scopes. The // repository stores them in independent maps, and holding one while // acquiring the other can deadlock with refund/order readers. let (wallet_id, created_wallet) = { let mut wallets = self.wallets_by_id.write().expect("wallet repo lock"); let existing_wallet = wallets .values() .find(|wallet| wallet.user_id.as_deref() == Some(input.user_id.as_str())) .map(|wallet| (wallet.id.clone(), wallet.status.clone())); if existing_wallet .as_ref() .is_some_and(|(_, status)| status != "active") { return Ok(CreateWalletRechargeOrderOutcome::WalletInactive); } match existing_wallet { Some((wallet_id, _)) => (wallet_id, false), None => { let wallet_id = input .preferred_wallet_id .clone() .unwrap_or_else(|| format!("wallet-{}", uuid::Uuid::new_v4())); if wallets.contains_key(&wallet_id) { return Err(DataLayerError::InvalidInput( "wallet identifier already belongs to another owner".to_string(), )); } let wallet = StoredWalletSnapshot::new( wallet_id.clone(), Some(input.user_id.clone()), None, 0.0, 0.0, "finite".to_string(), "USD".to_string(), "active".to_string(), 0.0, 0.0, 0.0, 0.0, now_secs as i64, )?; wallets.insert(wallet_id.clone(), wallet); (wallet_id, true) } } }; let replay_input = input.clone(); let order = StoredAdminPaymentOrder { id: format!("payment-order-{}", uuid::Uuid::new_v4()), order_no: input.order_no, wallet_id: wallet_id.clone(), user_id: Some(input.user_id), amount_usd: input.amount_usd, pay_amount: input.pay_amount, pay_currency: input.pay_currency, exchange_rate: input.exchange_rate, refunded_amount_usd: 0.0, refundable_amount_usd: 0.0, payment_method: input.payment_method, payment_provider: input.payment_provider, order_kind: "wallet_recharge".to_string(), gateway_order_id: Some(input.gateway_order_id), gateway_response: Some(gateway_response), status: "pending".to_string(), created_at_unix_ms: current_unix_ms(), paid_at_unix_secs: None, credited_at_unix_secs: None, expires_at_unix_secs: Some(input.expires_at_unix_secs), }; match self.insert_wallet_recharge_order_unique(order.clone(), now_secs, &replay_input) { Ok((Some(existing), true)) => { if created_wallet { self.remove_created_wallet_if_unreferenced(&wallet_id); } return Ok(CreateWalletRechargeOrderOutcome::Created(existing)); } Ok((Some(existing), false)) => { if created_wallet { self.remove_created_wallet_if_unreferenced(&wallet_id); } return Ok(CreateWalletRechargeOrderOutcome::Existing(existing)); } Ok((None, false)) => {} Ok((None, true)) => { if created_wallet { self.remove_created_wallet_if_unreferenced(&wallet_id); } return Err(DataLayerError::InvalidInput( "reclaimed recharge order disappeared".to_string(), )); } Err(error) => { if created_wallet { self.remove_created_wallet_if_unreferenced(&wallet_id); } return Err(error); } } Ok(CreateWalletRechargeOrderOutcome::Created(order)) } async fn update_wallet_recharge_checkout( &self, input: UpdateWalletRechargeCheckoutInput, ) -> Result, DataLayerError> { if input.order_id.trim().is_empty() || input.gateway_order_id.trim().is_empty() { return Ok(WalletMutationOutcome::Invalid( "wallet recharge checkout identifiers are required".to_string(), )); } let gateway_response = match project_wallet_recharge_gateway_response(&input.gateway_response) { Ok(value) => value, Err(error) => return Ok(WalletMutationOutcome::Invalid(error)), }; let mut orders = self.payment_orders_by_id.write().expect("wallet repo lock"); let Some(current_order) = orders.get(&input.order_id) else { return Ok(WalletMutationOutcome::NotFound); }; let is_wallet_recharge = current_order .gateway_response .as_ref() .and_then(serde_json::Value::as_object) .and_then(|object| object.get("order_kind")) .and_then(serde_json::Value::as_str) == Some("wallet_recharge"); if !is_wallet_recharge { return Ok(WalletMutationOutcome::Invalid( "payment order is not a wallet recharge".to_string(), )); } let current_is_checkout_placeholder = wallet_recharge_order_is_checkout_placeholder(current_order); let current_token = current_order .gateway_response .as_ref() .and_then(wallet_recharge_checkout_claim_token); let requested_token = wallet_recharge_checkout_claim_token(&gateway_response); if current_token.is_some() && current_token != requested_token { return Ok(WalletMutationOutcome::Invalid( "wallet recharge checkout claim is no longer current".to_string(), )); } if current_order.status != "pending" { if current_order.gateway_order_id.as_deref() == Some(input.gateway_order_id.as_str()) { return Ok(WalletMutationOutcome::Applied(current_order.clone())); } return Ok(WalletMutationOutcome::Invalid( "wallet recharge order is no longer pending".to_string(), )); } let now_secs = current_unix_secs(); if !current_order .expires_at_unix_secs .is_some_and(|expires_at| expires_at > now_secs) { return Ok(WalletMutationOutcome::Invalid( "wallet recharge order is expired".to_string(), )); } // The initial row stores the order number as a temporary gateway id. // Once a provider checkout is persisted, a concurrent creator must // not overwrite that evidence with a second provider checkout. if current_order .gateway_order_id .as_deref() .is_some_and(|existing| { existing != input.gateway_order_id.as_str() && existing != current_order.order_no.as_str() && !current_is_checkout_placeholder }) { return Ok(WalletMutationOutcome::Invalid( "wallet recharge checkout is already bound".to_string(), )); } let payment_method = current_order.payment_method.clone(); if orders.values().any(|existing| { existing.id != input.order_id && existing.payment_method == payment_method && existing.gateway_order_id.as_deref() == Some(input.gateway_order_id.as_str()) }) { return Ok(WalletMutationOutcome::Invalid( "payment gateway order already belongs to another order".to_string(), )); } let order = orders .get_mut(&input.order_id) .expect("wallet recharge order disappeared while write lock held"); order.gateway_order_id = Some(input.gateway_order_id); order.gateway_response = Some(gateway_response); Ok(WalletMutationOutcome::Applied(order.clone())) } async fn compare_and_swap_payment_order_stripe_client_secret( &self, input: CompareAndSwapPaymentOrderStripeClientSecretInput, ) -> Result { let mut orders = self.payment_orders_by_id.write().expect("wallet repo lock"); let Some(current) = orders.get(&input.order_id) else { return Ok(false); }; let Some(replacement) = payment_order_stripe_client_secret_cas_replacement(current, &input) .map_err(DataLayerError::InvalidInput)? else { return Ok(false); }; let current = orders .get_mut(&input.order_id) .expect("payment order disappeared while write lock held"); current.gateway_response = Some(replacement); Ok(true) } async fn fail_wallet_recharge_checkout( &self, input: FailWalletRechargeCheckoutInput, ) -> Result, DataLayerError> { if input.order_id.trim().is_empty() || input.claim_token.trim().is_empty() || input.claim_token.len() > 128 { return Ok(WalletMutationOutcome::Invalid( "wallet recharge checkout failure identifiers are required".to_string(), )); } let mut orders = self.payment_orders_by_id.write().expect("wallet repo lock"); let Some(order) = orders.get_mut(&input.order_id) else { return Ok(WalletMutationOutcome::NotFound); }; if !wallet_recharge_order_is_checkout_placeholder(order) { return Ok(WalletMutationOutcome::Invalid( "payment order is not a checkout placeholder".to_string(), )); } let current_token = order .gateway_response .as_ref() .and_then(wallet_recharge_checkout_claim_token); if current_token != Some(input.claim_token.trim()) { return Ok(WalletMutationOutcome::Invalid( "wallet recharge checkout claim is no longer current".to_string(), )); } if order.status != "pending" { return Ok(WalletMutationOutcome::Applied(order.clone())); } let failed = if input.provider_request_may_have_succeeded { wallet_recharge_checkout_uncertain_response( order.gateway_response.as_ref(), &input.reason, current_unix_secs(), ) } else { wallet_recharge_checkout_failed_response( order.gateway_response.as_ref(), &input.reason, current_unix_secs(), ) }; order.gateway_response = Some(failed); order.status = "failed".to_string(); Ok(WalletMutationOutcome::Applied(order.clone())) } async fn reclaim_wallet_recharge_checkout( &self, input: ReclaimWalletRechargeCheckoutInput, ) -> Result, DataLayerError> { if input.order_id.trim().is_empty() || input.claim_token.trim().is_empty() || input.claim_token.len() > 128 || input.expires_at_unix_secs <= current_unix_secs() { return Ok(WalletMutationOutcome::Invalid( "wallet recharge checkout reclaim identifiers are invalid".to_string(), )); } // Keep the in-memory backend aligned with SQL backends: a reclaim may // only install a server-created placeholder, never provider checkout // evidence supplied by an internal caller. if !wallet_recharge_response_is_checkout_placeholder(&input.gateway_response) { return Ok(WalletMutationOutcome::Invalid( "wallet recharge reclaim response must be a placeholder".to_string(), )); } let now = current_unix_secs(); let response = wallet_recharge_checkout_claim_response( &input.gateway_response, &input.claim_token, now, ) .map_err(DataLayerError::InvalidInput)?; let mut orders = self.payment_orders_by_id.write().expect("wallet repo lock"); let Some(order) = orders.get_mut(&input.order_id) else { return Ok(WalletMutationOutcome::NotFound); }; if !wallet_recharge_order_is_reclaimable_placeholder(order, now) { return Ok(WalletMutationOutcome::Invalid( "wallet recharge checkout is still in progress or already completed".to_string(), )); } order.gateway_response = Some(response); order.gateway_order_id = Some(order.order_no.clone()); order.status = "pending".to_string(); order.expires_at_unix_secs = Some(input.expires_at_unix_secs); Ok(WalletMutationOutcome::Applied(order.clone())) } async fn create_plan_purchase_order( &self, mut input: CreatePlanPurchaseOrderInput, ) -> Result { input.payment_method = canonicalize_payment_method(&input.payment_method) .map_err(DataLayerError::InvalidInput)?; validate_plan_purchase_order_input(&input).map_err(DataLayerError::InvalidInput)?; let projected_gateway_response = project_wallet_gateway_response(&input.gateway_response) .map_err(DataLayerError::InvalidInput)?; let entitlements = input .product_snapshot .get("entitlements") .or_else(|| input.product_snapshot.get("entitlements_json")) .cloned() .unwrap_or_else(|| serde_json::json!([])); validate_plan_wallet_credit_entitlements(&entitlements) .map_err(DataLayerError::InvalidInput)?; let _lifecycle_guard = self .wallet_lifecycle_lock .lock() .expect("wallet lifecycle lock"); let wallet_id = { let wallets = self.wallets_by_id.read().expect("wallet repo lock"); let Some(wallet) = wallets .values() .find(|wallet| wallet.user_id.as_deref() == Some(input.user_id.as_str())) else { return Ok(CreatePlanPurchaseOrderOutcome::WalletInactive); }; if wallet.status != "active" { return Ok(CreatePlanPurchaseOrderOutcome::WalletInactive); } wallet.id.clone() }; let max_active_per_user = input .product_snapshot .get("max_active_per_user") .and_then(serde_json::Value::as_i64) .unwrap_or(1) .max(1); let purchase_limit_scope = input .product_snapshot .get("purchase_limit_scope") .and_then(serde_json::Value::as_str) .unwrap_or("active_period"); if purchase_limit_scope != "unlimited" { let now_secs = current_unix_secs(); let existing_count = self .payment_orders_by_id .read() .expect("wallet repo lock") .values() .filter(|order| order.user_id.as_deref() == Some(input.user_id.as_str())) .filter(|order| { let Some(gateway_response) = order.gateway_response.as_ref() else { return false; }; gateway_response .get("order_kind") .and_then(serde_json::Value::as_str) == Some("plan_purchase") && gateway_response .get("product_id") .and_then(serde_json::Value::as_str) == Some(input.product_id.as_str()) }) .filter(|order| { if order.status == "pending" { return order .expires_at_unix_secs .is_some_and(|expires_at| expires_at > now_secs); } if purchase_limit_scope == "lifetime" { return order.status == "credited"; } order.status == "credited" && order .expires_at_unix_secs .is_some_and(|expires_at| expires_at > now_secs) }) .count() as i64; if existing_count >= max_active_per_user { return Ok(CreatePlanPurchaseOrderOutcome::ActivePlanLimitReached); } } let mut gateway_response = match projected_gateway_response { serde_json::Value::Object(map) => map, value => { let mut map = serde_json::Map::new(); map.insert("raw".to_string(), value); map } }; gateway_response.insert( "order_kind".to_string(), serde_json::Value::String("plan_purchase".to_string()), ); gateway_response.insert( "product_id".to_string(), serde_json::Value::String(input.product_id), ); gateway_response.insert("product_snapshot".to_string(), input.product_snapshot); let order = StoredAdminPaymentOrder { id: format!("payment-order-{}", uuid::Uuid::new_v4()), order_no: input.order_no, wallet_id, user_id: Some(input.user_id), amount_usd: input.amount_usd, pay_amount: Some(input.pay_amount), pay_currency: Some(input.pay_currency), exchange_rate: Some(input.exchange_rate), refunded_amount_usd: 0.0, refundable_amount_usd: 0.0, payment_method: input.payment_method, payment_provider: input.payment_provider, order_kind: "plan_purchase".to_string(), gateway_order_id: Some(input.gateway_order_id), gateway_response: Some(serde_json::Value::Object(gateway_response)), status: "pending".to_string(), created_at_unix_ms: current_unix_ms(), paid_at_unix_secs: None, credited_at_unix_secs: None, expires_at_unix_secs: Some(input.expires_at_unix_secs), }; self.insert_payment_order_unique(order.clone())?; Ok(CreatePlanPurchaseOrderOutcome::Created(order)) } async fn create_wallet_refund_request( &self, input: CreateWalletRefundRequestInput, ) -> Result { if !input.amount_usd.is_finite() || input.amount_usd <= 0.0 { return Ok(CreateWalletRefundRequestOutcome::InvalidInput( "refund amount must be finite and greater than zero".to_string(), )); } if input .idempotency_key .as_deref() .is_some_and(|key| key.trim().is_empty() || key.chars().count() > 128) { return Ok(CreateWalletRefundRequestOutcome::InvalidInput( "refund idempotency key is invalid".to_string(), )); } // SQL backends lock the wallet row while reserving a refund. Serialize // the in-memory equivalent so concurrent requests cannot both spend // the same available balance or race compensation cleanup. Keep both // locks in this order everywhere: lifecycle first, reservation second. let _lifecycle_guard = self .wallet_lifecycle_lock .lock() .expect("wallet lifecycle lock"); let _reservation_guard = self .refund_creation_lock .lock() .expect("wallet refund creation lock"); let wallet = { let wallets = self.wallets_by_id.read().expect("wallet repo lock"); let Some(wallet) = wallets.get(&input.wallet_id) else { return Ok(CreateWalletRefundRequestOutcome::WalletMissing); }; if wallet.user_id.as_deref() != Some(input.user_id.as_str()) { return Ok(CreateWalletRefundRequestOutcome::WalletMissing); } wallet.clone() }; if !wallet.balance.is_finite() { return Ok(CreateWalletRefundRequestOutcome::InvalidInput( "wallet recharge balance is invalid".to_string(), )); } if let Some(idempotency_key) = input.idempotency_key.as_deref() { let key = (input.user_id.clone(), idempotency_key.to_string()); if let Some(refund_id) = self .refund_idempotency_to_id .read() .expect("wallet repo lock") .get(&key) .cloned() { if let Some(refund) = self .refunds_by_id .read() .expect("wallet repo lock") .get(&refund_id) .cloned() { return Ok(CreateWalletRefundRequestOutcome::Duplicate(refund)); } return Ok(CreateWalletRefundRequestOutcome::DuplicateRejected); } } let reserved_amount = self .refunds_by_id .read() .expect("wallet repo lock") .values() .filter(|refund| { refund.wallet_id == input.wallet_id && matches!(refund.status.as_str(), "pending_approval" | "approved") }) .try_fold(0.0_f64, |total, refund| { let amount = refund.amount_usd; if !amount.is_finite() || amount <= 0.0 { return None; } let next = total + amount; next.is_finite().then_some(next) }); let Some(reserved_amount) = reserved_amount else { return Ok(CreateWalletRefundRequestOutcome::InvalidInput( "wallet refund reservation is invalid".to_string(), )); }; let available_balance = wallet.balance - reserved_amount; if !available_balance.is_finite() || input.amount_usd > available_balance { return Ok(CreateWalletRefundRequestOutcome::RefundAmountExceedsAvailableBalance); } let mut resolved_payment_method: Option = None; if let Some(order_id) = input.payment_order_id.as_deref() { let order = { let orders = self.payment_orders_by_id.read().expect("wallet repo lock"); let Some(order) = orders.get(order_id) else { return Ok(CreateWalletRefundRequestOutcome::PaymentOrderNotFound); }; if order.wallet_id != input.wallet_id || order.status != "credited" { return Ok(CreateWalletRefundRequestOutcome::PaymentOrderNotRefundable); } order.clone() }; let reserved_for_order = self .refunds_by_id .read() .expect("wallet repo lock") .values() .filter(|refund| { refund.payment_order_id.as_deref() == Some(order_id) && matches!(refund.status.as_str(), "pending_approval" | "approved") }) .try_fold(0.0_f64, |total, refund| { let amount = refund.amount_usd; if !amount.is_finite() || amount <= 0.0 { return None; } let next = total + amount; next.is_finite().then_some(next) }); if !payment_order_refund_amounts_are_consistent( order.amount_usd, order.refunded_amount_usd, order.refundable_amount_usd, ) { return Ok(CreateWalletRefundRequestOutcome::InvalidInput( "payment order refund amounts are invalid".to_string(), )); } let Some(reserved_for_order) = reserved_for_order else { return Ok(CreateWalletRefundRequestOutcome::InvalidInput( "payment order refund reservation is invalid".to_string(), )); }; let available_order_amount = order.refundable_amount_usd - reserved_for_order; if !available_order_amount.is_finite() || input.amount_usd > available_order_amount { return Ok( CreateWalletRefundRequestOutcome::RefundAmountExceedsAvailableOrderAmount, ); } resolved_payment_method = Some(order.payment_method.clone()); } let canonical = canonicalize_wallet_refund_fields( input.payment_order_id.as_deref(), input.source_type.as_deref(), input.source_id.as_deref(), input.refund_mode.as_deref(), resolved_payment_method.as_deref(), ) .map_err(DataLayerError::InvalidInput)?; let idempotency_key = input.idempotency_key.clone(); let user_id = input.user_id.clone(); let refund = StoredAdminWalletRefund { id: format!("refund-{}", uuid::Uuid::new_v4()), refund_no: input.refund_no, wallet_id: input.wallet_id, user_id: Some(input.user_id), payment_order_id: input.payment_order_id.clone(), source_type: canonical.source_type, source_id: canonical.source_id, refund_mode: canonical.refund_mode, amount_usd: input.amount_usd, status: "pending_approval".to_string(), reason: input.reason, failure_reason: None, gateway_refund_id: None, payout_method: None, payout_reference: None, payout_proof: None, requested_by: None, approved_by: None, processed_by: None, created_at_unix_ms: current_unix_ms(), updated_at_unix_secs: current_unix_secs(), processed_at_unix_secs: None, completed_at_unix_secs: None, }; self.refunds_by_id .write() .expect("wallet repo lock") .insert(refund.id.clone(), refund.clone()); if let Some(idempotency_key) = idempotency_key { self.refund_idempotency_to_id .write() .expect("wallet repo lock") .insert((user_id, idempotency_key), refund.id.clone()); } Ok(CreateWalletRefundRequestOutcome::Created(refund)) } async fn process_payment_callback( &self, mut input: ProcessPaymentCallbackInput, ) -> Result { input .canonicalize_and_validate() .map_err(DataLayerError::InvalidInput)?; Ok(ProcessPaymentCallbackOutcome::Failed { duplicate: false, error: "payment callback is not supported in memory wallet repository".to_string(), }) } async fn adjust_wallet_balance( &self, _input: AdjustWalletBalanceInput, ) -> Result, DataLayerError> { Ok(None) } async fn create_manual_wallet_recharge( &self, _input: CreateManualWalletRechargeInput, ) -> Result, DataLayerError> { Ok(None) } async fn process_admin_wallet_refund( &self, _input: ProcessAdminWalletRefundInput, ) -> Result< WalletMutationOutcome<( StoredWalletSnapshot, super::StoredAdminWalletRefund, super::StoredAdminWalletTransaction, )>, DataLayerError, > { Ok(WalletMutationOutcome::NotFound) } async fn update_admin_wallet_refund_gateway( &self, input: UpdateAdminWalletRefundGatewayInput, ) -> Result, DataLayerError> { if input.gateway_refund_id.trim().is_empty() || input.gateway_refund_id.len() > 128 { return Ok(WalletMutationOutcome::Invalid( "gateway refund identifier is invalid".to_string(), )); } if input .payout_proof .as_ref() .is_some_and(|proof| !proof.is_object()) { return Ok(WalletMutationOutcome::Invalid( "gateway refund proof must be an object".to_string(), )); } let mut refunds = self.refunds_by_id.write().expect("wallet repo lock"); let Some(refund) = refunds.get_mut(&input.refund_id) else { return Ok(WalletMutationOutcome::NotFound); }; if refund.wallet_id != input.wallet_id { return Ok(WalletMutationOutcome::NotFound); } if !refund.amount_usd.is_finite() || refund.amount_usd <= 0.0 { return Ok(WalletMutationOutcome::Invalid( "refund amount must be finite and greater than zero".to_string(), )); } if let Some(existing_id) = refund.gateway_refund_id.as_deref() { if existing_id != input.gateway_refund_id { return Ok(WalletMutationOutcome::Invalid( "gateway refund identifier conflicts with existing evidence".to_string(), )); } } if refund.status == "succeeded" { return Ok(WalletMutationOutcome::Applied(refund.clone())); } if refund.status != "processing" { return Ok(WalletMutationOutcome::Invalid( "refund status must be processing before gateway update".to_string(), )); } if refund.gateway_refund_id.is_none() { refund.gateway_refund_id = Some(input.gateway_refund_id); } // Preserve a processing proof for ordinary replays, but allow an // explicit successful gateway proof to upgrade it. if refund.payout_proof.is_none() || input .payout_proof .as_ref() .is_some_and(wallet_refund_proof_is_success) { refund.payout_proof = input.payout_proof; } refund.updated_at_unix_secs = current_unix_secs(); Ok(WalletMutationOutcome::Applied(refund.clone())) } async fn complete_admin_wallet_refund( &self, _input: CompleteAdminWalletRefundInput, ) -> Result, DataLayerError> { Ok(WalletMutationOutcome::NotFound) } async fn fail_admin_wallet_refund( &self, _input: FailAdminWalletRefundInput, ) -> Result< WalletMutationOutcome<( StoredWalletSnapshot, super::StoredAdminWalletRefund, Option, )>, DataLayerError, > { Ok(WalletMutationOutcome::NotFound) } async fn expire_admin_payment_order( &self, _order_id: &str, ) -> Result, DataLayerError> { Ok(WalletMutationOutcome::NotFound) } async fn fail_admin_payment_order( &self, _order_id: &str, ) -> Result, DataLayerError> { Ok(WalletMutationOutcome::NotFound) } async fn credit_admin_payment_order( &self, _input: CreditAdminPaymentOrderInput, ) -> Result, DataLayerError> { Ok(WalletMutationOutcome::NotFound) } async fn create_admin_redeem_code_batch( &self, input: CreateAdminRedeemCodeBatchInput, ) -> Result { validate_admin_redeem_code_batch_input(&input).map_err(DataLayerError::InvalidInput)?; let now_ms = current_unix_ms(); let now_secs = current_unix_secs(); let batch_id = format!("redeem-batch-{}", uuid::Uuid::new_v4()); let mut plaintext_codes = Vec::with_capacity(input.total_count); let mut codes_by_id = self.redeem_codes_by_id.write().expect("wallet repo lock"); let mut code_hash_to_id = self .redeem_code_hash_to_id .write() .expect("wallet repo lock"); for _ in 0..input.total_count { loop { let code = generate_redeem_code(); let normalized = normalize_redeem_code(&code).expect("generated code should normalize"); let code_hash = hash_redeem_code(&normalized); if code_hash_to_id.contains_key(&code_hash) { continue; } let code_id = format!("redeem-code-{}", uuid::Uuid::new_v4()); let prefix = normalized.chars().take(4).collect::(); let suffix = normalized .chars() .rev() .take(4) .collect::>() .into_iter() .rev() .collect::(); let masked_code = mask_redeem_code(&prefix, &suffix); codes_by_id.insert( code_id.clone(), StoredAdminRedeemCode { id: code_id.clone(), batch_id: batch_id.clone(), batch_name: Some(input.name.clone()), code_prefix: prefix.clone(), code_suffix: suffix.clone(), masked_code: masked_code.clone(), status: "active".to_string(), redeemed_by_user_id: None, redeemed_by_user_name: None, redeemed_wallet_id: None, redeemed_payment_order_id: None, redeemed_order_no: None, redeemed_at_unix_secs: None, disabled_by: None, expires_at_unix_secs: input.expires_at_unix_secs, created_at_unix_ms: now_ms, updated_at_unix_secs: now_secs, }, ); code_hash_to_id.insert(code_hash, code_id.clone()); plaintext_codes.push(CreatedAdminRedeemCodePlaintext { code_id, code, masked_code, }); break; } } let batch = StoredAdminRedeemCodeBatch { id: batch_id.clone(), name: input.name, amount_usd: input.amount_usd, currency: input.currency, balance_bucket: input.balance_bucket, total_count: input.total_count as u64, redeemed_count: 0, active_count: input.total_count as u64, status: "active".to_string(), description: input.description, created_by: input.created_by, expires_at_unix_secs: input.expires_at_unix_secs, created_at_unix_ms: now_ms, updated_at_unix_secs: now_secs, }; self.redeem_batches_by_id .write() .expect("wallet repo lock") .insert(batch_id, batch.clone()); Ok(CreateAdminRedeemCodeBatchResult { batch, codes: plaintext_codes, }) } async fn disable_admin_redeem_code_batch( &self, input: DisableAdminRedeemCodeBatchInput, ) -> Result, DataLayerError> { let now_secs = current_unix_secs(); let updated = { let mut batches = self.redeem_batches_by_id.write().expect("wallet repo lock"); let Some(batch) = batches.get_mut(&input.batch_id) else { return Ok(WalletMutationOutcome::NotFound); }; batch.status = "disabled".to_string(); batch.updated_at_unix_secs = now_secs; batch.clone() }; let mut codes = self.redeem_codes_by_id.write().expect("wallet repo lock"); for code in codes .values_mut() .filter(|code| code.batch_id == input.batch_id) { if code.status == "active" { code.status = "disabled".to_string(); code.disabled_by = input.operator_id.clone(); code.updated_at_unix_secs = now_secs; } } if let Some(batch) = self .redeem_batches_by_id .write() .expect("wallet repo lock") .get_mut(&input.batch_id) { batch.active_count = 0; } Ok(WalletMutationOutcome::Applied(updated)) } async fn delete_admin_redeem_code_batch( &self, input: DeleteAdminRedeemCodeBatchInput, ) -> Result, DataLayerError> { let batch = { let batches = self.redeem_batches_by_id.read().expect("wallet repo lock"); let Some(batch) = batches.get(&input.batch_id) else { return Ok(WalletMutationOutcome::NotFound); }; batch.clone() }; let _ = input.operator_id; if batch.status != "disabled" { return Ok(WalletMutationOutcome::Invalid( "only disabled redeem code batch can be deleted".to_string(), )); } let codes = self.redeem_codes_by_id.read().expect("wallet repo lock"); if codes .values() .any(|code| code.batch_id == input.batch_id && code.status == "redeemed") { return Ok(WalletMutationOutcome::Invalid( "redeemed batch cannot be deleted".to_string(), )); } let code_ids = codes .values() .filter(|code| code.batch_id == input.batch_id) .map(|code| code.id.clone()) .collect::>(); drop(codes); self.redeem_batches_by_id .write() .expect("wallet repo lock") .remove(&input.batch_id); self.redeem_codes_by_id .write() .expect("wallet repo lock") .retain(|code_id, _| !code_ids.contains(code_id)); self.redeem_code_hash_to_id .write() .expect("wallet repo lock") .retain(|_, code_id| !code_ids.contains(code_id)); Ok(WalletMutationOutcome::Applied(batch)) } async fn disable_admin_redeem_code( &self, input: DisableAdminRedeemCodeInput, ) -> Result, DataLayerError> { let now_secs = current_unix_secs(); let updated = { let mut codes = self.redeem_codes_by_id.write().expect("wallet repo lock"); let Some(code) = codes.get_mut(&input.code_id) else { return Ok(WalletMutationOutcome::NotFound); }; if code.status == "redeemed" { return Ok(WalletMutationOutcome::Invalid( "redeemed code cannot be disabled".to_string(), )); } code.status = "disabled".to_string(); code.disabled_by = input.operator_id; code.updated_at_unix_secs = now_secs; code.clone() }; if let Some(batch) = self .redeem_batches_by_id .write() .expect("wallet repo lock") .get_mut(&updated.batch_id) { batch.active_count = self .redeem_codes_by_id .read() .expect("wallet repo lock") .values() .filter(|code| code.batch_id == updated.batch_id && code.status == "active") .count() as u64; batch.updated_at_unix_secs = now_secs; } Ok(WalletMutationOutcome::Applied(updated)) } async fn redeem_wallet_code( &self, input: RedeemWalletCodeInput, ) -> Result { let _lifecycle_guard = self .wallet_lifecycle_lock .lock() .expect("wallet lifecycle lock"); let Some(normalized) = normalize_redeem_code(&input.code) else { return Ok(RedeemWalletCodeOutcome::InvalidCode); }; let code_hash = hash_redeem_code(&normalized); let Some(code_id) = self .redeem_code_hash_to_id .read() .expect("wallet repo lock") .get(&code_hash) .cloned() else { return Ok(RedeemWalletCodeOutcome::CodeNotFound); }; let now_secs = current_unix_secs(); let now_ms = current_unix_ms(); let (batch_id, batch_name, balance_bucket, amount_usd) = { let batches = self.redeem_batches_by_id.read().expect("wallet repo lock"); let codes = self.redeem_codes_by_id.read().expect("wallet repo lock"); let Some(code) = codes.get(&code_id) else { return Ok(RedeemWalletCodeOutcome::CodeNotFound); }; match code.status.as_str() { "disabled" => return Ok(RedeemWalletCodeOutcome::CodeDisabled), "redeemed" => return Ok(RedeemWalletCodeOutcome::CodeRedeemed), _ => {} } if code .expires_at_unix_secs .is_some_and(|value| value <= now_secs) { return Ok(RedeemWalletCodeOutcome::CodeExpired); } let Some(batch) = batches.get(&code.batch_id) else { return Ok(RedeemWalletCodeOutcome::CodeNotFound); }; if batch.status != "active" { return Ok(RedeemWalletCodeOutcome::BatchDisabled); } if batch .expires_at_unix_secs .is_some_and(|value| value <= now_secs) { return Ok(RedeemWalletCodeOutcome::CodeExpired); } ( code.batch_id.clone(), batch.name.clone(), batch.balance_bucket.clone(), batch.amount_usd, ) }; let (wallet, balance_before, gift_before) = { let wallets = self.wallets_by_id.read().expect("wallet repo lock"); if let Some(wallet) = wallets .values() .find(|wallet| wallet.user_id.as_deref() == Some(input.user_id.as_str())) { if wallet.status != "active" { return Ok(RedeemWalletCodeOutcome::WalletInactive); } let balance_before = wallet.balance; let gift_before = wallet.gift_balance; let (after_recharge, after_gift, after_total_recharged) = validate_redeem_wallet_credit( &balance_bucket, amount_usd, balance_before, gift_before, wallet.total_recharged, ) .map_err(DataLayerError::UnexpectedValue)?; let mut wallet = wallet.clone(); wallet.balance = after_recharge; wallet.gift_balance = after_gift; wallet.total_recharged = after_total_recharged; wallet.updated_at_unix_secs = now_secs; (wallet, balance_before, gift_before) } else { let (after_recharge, after_gift, after_total_recharged) = validate_redeem_wallet_credit(&balance_bucket, amount_usd, 0.0, 0.0, 0.0) .map_err(DataLayerError::UnexpectedValue)?; let wallet = StoredWalletSnapshot::new( format!("wallet-{}", uuid::Uuid::new_v4()), Some(input.user_id.clone()), None, after_recharge, after_gift, "finite".to_string(), "USD".to_string(), "active".to_string(), after_total_recharged, 0.0, 0.0, 0.0, now_secs as i64, )?; (wallet, 0.0, 0.0) } }; let order = StoredAdminPaymentOrder { id: format!("payment-order-{}", uuid::Uuid::new_v4()), order_no: input.order_no, wallet_id: wallet.id.clone(), user_id: Some(input.user_id.clone()), amount_usd, pay_amount: None, pay_currency: None, exchange_rate: None, refunded_amount_usd: 0.0, refundable_amount_usd: redeem_code_refundable_amount(&balance_bucket, amount_usd), payment_method: redeem_code_payment_method(&balance_bucket).to_string(), payment_provider: Some("redeem_code".to_string()), order_kind: "wallet_recharge".to_string(), gateway_order_id: Some(format!("card_{}", uuid::Uuid::new_v4().simple())), gateway_response: Some(serde_json::json!({ "source": "redeem_code", "batch_id": batch_id, "batch_name": batch_name, "balance_bucket": balance_bucket, })), status: "credited".to_string(), created_at_unix_ms: now_ms, paid_at_unix_secs: Some(now_secs), credited_at_unix_secs: Some(now_secs), expires_at_unix_secs: None, }; // Reserve the globally unique payment identity before publishing any // wallet mutation. Every operation after this point is infallible map // replacement, so a duplicate order cannot leave credited funds. self.insert_payment_order_unique(order.clone())?; self.wallets_by_id .write() .expect("wallet repo lock") .insert(wallet.id.clone(), wallet.clone()); let tx = StoredAdminWalletTransaction { id: format!("wallet-tx-{}", uuid::Uuid::new_v4()), wallet_id: wallet.id.clone(), category: "recharge".to_string(), reason_code: "topup_card_code".to_string(), amount: amount_usd, balance_before: balance_before + gift_before, balance_after: wallet.balance + wallet.gift_balance, recharge_balance_before: balance_before, recharge_balance_after: wallet.balance, gift_balance_before: gift_before, gift_balance_after: wallet.gift_balance, link_type: Some("payment_order".to_string()), link_id: Some(order.id.clone()), operator_id: None, operator_name: None, operator_email: None, description: Some("兑换码充值".to_string()), created_at_unix_ms: Some(now_ms), }; self.wallet_transactions_by_id .write() .expect("wallet repo lock") .insert(tx.id.clone(), tx); if let Some(code) = self .redeem_codes_by_id .write() .expect("wallet repo lock") .get_mut(&code_id) { code.status = "redeemed".to_string(); code.redeemed_by_user_id = Some(input.user_id); code.redeemed_wallet_id = Some(wallet.id.clone()); code.redeemed_payment_order_id = Some(order.id.clone()); code.redeemed_order_no = Some(order.order_no.clone()); code.redeemed_at_unix_secs = Some(now_secs); code.updated_at_unix_secs = now_secs; } if let Some(batch) = self .redeem_batches_by_id .write() .expect("wallet repo lock") .get_mut(&batch_id) { batch.redeemed_count = batch.redeemed_count.saturating_add(1); batch.active_count = batch.active_count.saturating_sub(1); batch.updated_at_unix_secs = now_secs; } Ok(RedeemWalletCodeOutcome::Redeemed { wallet, order, amount_usd, batch_name, }) } } #[cfg(test)] mod tests { use super::{InMemoryWalletRepository, WalletReadSeed}; use crate::repository::wallet::{ AdminWalletListQuery, CompareAndSwapPaymentOrderStripeClientSecretInput, CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome, CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome, FailWalletRechargeCheckoutInput, StoredAdminPaymentOrder, StoredAdminWalletRefund, StoredWalletSnapshot, UpdateWalletRechargeCheckoutInput, WalletLookupKey, WalletMutationOutcome, WalletReadRepository, WalletWriteRepository, }; use crate::DataLayerError; use serde_json::json; use std::sync::Arc; fn sample_wallet() -> StoredWalletSnapshot { StoredWalletSnapshot::new( "wallet-1".to_string(), Some("user-1".to_string()), Some("key-1".to_string()), 10.0, 2.0, "finite".to_string(), "USD".to_string(), "active".to_string(), 0.0, 0.0, 0.0, 0.0, 100, ) .expect("wallet should build") } #[tokio::test] async fn compensation_delete_preserves_wallet_with_persisted_balance_in_memory() { let wallet = StoredWalletSnapshot::new( "funded-wallet".to_string(), Some("funded-user".to_string()), None, 1.0, 0.0, "finite".to_string(), "USD".to_string(), "active".to_string(), 1.0, 0.0, 0.0, 1.0, 100, ) .expect("wallet should build"); let repository = InMemoryWalletRepository::seed(vec![wallet]); assert!(!repository .delete_wallet_if_unreferenced("funded-wallet", WalletLookupKey::UserId("funded-user")) .await .expect("funded wallet must not be deleted")); assert!(repository .find(WalletLookupKey::UserId("funded-user")) .await .expect("wallet lookup should succeed") .is_some()); } #[tokio::test] async fn provisional_recharge_cleanup_preserves_wallet_changed_after_initial_check_in_memory() { let wallet = StoredWalletSnapshot::new( "provisional-recharge-wallet".to_string(), Some("provisional-recharge-user".to_string()), None, 0.0, 0.0, "finite".to_string(), "USD".to_string(), "active".to_string(), 0.0, 0.0, 0.0, 0.0, 100, ) .expect("wallet should build"); let repository = InMemoryWalletRepository::seed(vec![wallet]); repository.with_wallets_mut(|wallets| { let wallet = wallets .get_mut("provisional-recharge-wallet") .expect("wallet should be seeded"); wallet.balance = 2.0; wallet.total_recharged = 2.0; wallet.updated_at_unix_secs = 200; }); // This is the same cleanup helper used when a recharge order loses a // uniqueness race after creating a provisional wallet. A wallet that // acquired funds must survive the compensation attempt. repository.remove_created_wallet_if_unreferenced("provisional-recharge-wallet"); let retained = repository .find(WalletLookupKey::UserId("provisional-recharge-user")) .await .expect("wallet lookup should succeed") .expect("changed wallet should remain"); assert_eq!(retained.balance, 2.0); assert_eq!(retained.total_recharged, 2.0); } #[tokio::test] async fn snapshot_compensation_deletes_matching_funded_wallet_and_preserves_changed_one() { let wallet = StoredWalletSnapshot::new( "import-funded-wallet".to_string(), Some("import-funded-user".to_string()), None, 12.5, 3.25, "finite".to_string(), "USD".to_string(), "active".to_string(), 12.5, 0.0, 0.0, 0.0, 4242, ) .expect("wallet should build"); let repository = InMemoryWalletRepository::seed(vec![wallet.clone()]); assert!(repository .delete_wallet_if_snapshot_matches_and_unreferenced( &wallet, WalletLookupKey::UserId("import-funded-user"), ) .await .expect("matching snapshot should delete")); assert!(repository .find(WalletLookupKey::WalletId("import-funded-wallet")) .await .expect("wallet lookup should succeed") .is_none()); let repository = InMemoryWalletRepository::seed(vec![wallet.clone()]); repository.with_wallets_mut(|wallets| { wallets .get_mut("import-funded-wallet") .expect("wallet should be seeded") .balance = 99.0; }); assert!(!repository .delete_wallet_if_snapshot_matches_and_unreferenced( &wallet, WalletLookupKey::UserId("import-funded-user"), ) .await .expect("changed snapshot should be retained")); assert!(repository .find(WalletLookupKey::WalletId("import-funded-wallet")) .await .expect("wallet lookup should succeed") .is_some()); } #[tokio::test] async fn snapshot_restore_is_compare_and_swap_in_memory() { let before = StoredWalletSnapshot::new( "existing-wallet".to_string(), Some("existing-user".to_string()), None, 4.0, 2.0, "finite".to_string(), "USD".to_string(), "active".to_string(), 7.0, 3.0, 0.0, 0.0, 100, ) .expect("wallet should build"); let mut after = before.clone(); after.balance = 25.0; after.gift_balance = 5.0; after.total_recharged = 28.0; after.updated_at_unix_secs = 200; let repository = InMemoryWalletRepository::seed(vec![after.clone()]); assert!(repository .restore_wallet_if_snapshot_matches( &before, &after, WalletLookupKey::UserId("existing-user"), ) .await .expect("matching post-state should restore")); assert_eq!( repository .find(WalletLookupKey::UserId("existing-user")) .await .expect("wallet lookup should succeed"), Some(before.clone()) ); let repository = InMemoryWalletRepository::seed(vec![after.clone()]); repository.with_wallets_mut(|wallets| { let wallet = wallets .get_mut("existing-wallet") .expect("wallet should be seeded"); wallet.balance = 99.0; wallet.updated_at_unix_secs = 300; }); assert!(!repository .restore_wallet_if_snapshot_matches( &before, &after, WalletLookupKey::UserId("existing-user"), ) .await .expect("changed post-state should fail closed")); let retained = repository .find(WalletLookupKey::UserId("existing-user")) .await .expect("wallet lookup should succeed") .expect("changed wallet should remain"); assert_eq!(retained.balance, 99.0); assert_eq!(retained.updated_at_unix_secs, 300); } #[tokio::test] async fn updates_auth_wallet_limit_mode_and_snapshot_in_memory() { let repository = InMemoryWalletRepository::seed(vec![sample_wallet()]); let limit_updated = repository .update_auth_user_wallet_limit_mode("user-1", "unlimited") .await .expect("limit mode update should succeed") .expect("wallet should update"); assert_eq!(limit_updated.limit_mode, "unlimited"); let snapshot_updated = repository .update_auth_api_key_wallet_snapshot( "key-1", 20.0, 4.0, "finite", "USD", "active", 30.0, 5.0, 1.0, 2.0, Some(777), ) .await .expect("snapshot update should succeed") .expect("wallet should update"); assert_eq!(snapshot_updated.balance, 20.0); assert_eq!(snapshot_updated.gift_balance, 4.0); assert_eq!(snapshot_updated.total_recharged, 30.0); assert_eq!(snapshot_updated.total_consumed, 5.0); assert_eq!(snapshot_updated.total_refunded, 1.0); assert_eq!(snapshot_updated.total_adjusted, 2.0); assert_eq!(snapshot_updated.updated_at_unix_secs, 777); assert!(repository .update_auth_user_wallet_limit_mode("missing-user", "finite") .await .expect("missing limit mode update should succeed") .is_none()); let user_wallet = repository .initialize_auth_user_wallet("user-2", 7.0, false) .await .expect("user wallet init should succeed") .expect("user wallet should initialize"); assert_eq!(user_wallet.user_id.as_deref(), Some("user-2")); assert_eq!(user_wallet.gift_balance, 7.0); assert_eq!(user_wallet.total_adjusted, 7.0); let api_key_wallet = repository .initialize_auth_api_key_wallet("key-2", 7.0, true) .await .expect("api key wallet init should succeed") .expect("api key wallet should initialize"); assert_eq!(api_key_wallet.api_key_id.as_deref(), Some("key-2")); assert_eq!(api_key_wallet.limit_mode, "unlimited"); assert_eq!(api_key_wallet.gift_balance, 0.0); } fn sample_payment_order( id: &str, user_id: Option<&str>, status: &str, ) -> StoredAdminPaymentOrder { StoredAdminPaymentOrder { id: id.to_string(), order_no: format!("order-no-{id}"), wallet_id: "wallet-1".to_string(), user_id: user_id.map(str::to_string), amount_usd: 10.0, pay_amount: None, pay_currency: None, exchange_rate: None, refunded_amount_usd: 0.0, refundable_amount_usd: 10.0, payment_method: "stripe".to_string(), payment_provider: Some("stripe".to_string()), order_kind: "wallet_recharge".to_string(), gateway_order_id: None, gateway_response: None, status: status.to_string(), created_at_unix_ms: 100, paid_at_unix_secs: None, credited_at_unix_secs: None, expires_at_unix_secs: None, } } fn stripe_secret_cas_input( order: &StoredAdminPaymentOrder, expected_gateway_response: serde_json::Value, expected_ciphertext: &str, replacement_ciphertext: &str, ) -> CompareAndSwapPaymentOrderStripeClientSecretInput { CompareAndSwapPaymentOrderStripeClientSecretInput { order_id: order.id.clone(), order_no: order.order_no.clone(), wallet_id: order.wallet_id.clone(), user_id: order.user_id.clone(), payment_method: order.payment_method.clone(), payment_provider: order.payment_provider.clone(), order_kind: order.order_kind.clone(), gateway_order_id: order.gateway_order_id.clone(), expected_status: order.status.clone(), expected_expires_at_unix_secs: order.expires_at_unix_secs, expected_gateway_response, expected_client_secret_encrypted: expected_ciphertext.to_string(), replacement_client_secret_encrypted: replacement_ciphertext.to_string(), } } #[tokio::test] async fn stripe_secret_cas_is_exact_and_never_overwrites_a_newer_value_in_memory() { let legacy = "gAAAAABlegacy"; let replacement = concat!( "aether-payment-order-stripe-client-secret-v2:", "aether-runtime-secret-v1:gAAAAABreplacement" ); let mut order = sample_payment_order("stripe-cas-order", Some("user-1"), "pending"); order.gateway_order_id = Some("pi-cas".to_string()); order.expires_at_unix_secs = Some(4_102_444_800); order.gateway_response = Some(json!({ "gateway": "stripe", "publishable_key": "pk_test_public", "_stripe_client_secret_encrypted": legacy, })); let observed = order .gateway_response .clone() .expect("fixture response should exist"); let repository = InMemoryWalletRepository::seed_read_model(WalletReadSeed { payment_orders: vec![order.clone()], ..WalletReadSeed::default() }); let input = stripe_secret_cas_input(&order, observed.clone(), legacy, replacement); let mut stale_json = input.clone(); stale_json.expected_gateway_response["publishable_key"] = json!("pk_test_changed"); assert!(!repository .compare_and_swap_payment_order_stripe_client_secret(stale_json) .await .expect("stale JSON should be a normal CAS miss")); let mut stale_ciphertext = input.clone(); stale_ciphertext.expected_client_secret_encrypted = "gAAAAABother".to_string(); assert!(!repository .compare_and_swap_payment_order_stripe_client_secret(stale_ciphertext) .await .expect("stale ciphertext should be a normal CAS miss")); let mut foreign_identity = input.clone(); foreign_identity.order_no = "order-no-foreign".to_string(); assert!(!repository .compare_and_swap_payment_order_stripe_client_secret(foreign_identity) .await .expect("foreign identity should be a normal CAS miss")); assert!(repository .compare_and_swap_payment_order_stripe_client_secret(input.clone()) .await .expect("exact CAS should succeed")); assert!(!repository .compare_and_swap_payment_order_stripe_client_secret(input) .await .expect("an old migration must not overwrite the new value")); let stored = repository .find_admin_payment_order(&order.id) .await .expect("stored order should be readable") .expect("stored order should remain"); let response = stored .gateway_response .expect("stored response should remain"); assert_eq!( response["_stripe_client_secret_encrypted"].as_str(), Some(replacement) ); assert_eq!(response["publishable_key"], "pk_test_public"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrent_stripe_secret_migrations_have_exactly_one_winner_in_memory() { let legacy = "gAAAAABlegacy-race"; let mut order = sample_payment_order("stripe-cas-race", Some("user-1"), "pending"); order.expires_at_unix_secs = Some(4_102_444_800); order.gateway_response = Some(json!({ "gateway": "stripe", "_stripe_client_secret_encrypted": legacy, })); let observed = order .gateway_response .clone() .expect("fixture response should exist"); let repository = Arc::new(InMemoryWalletRepository::seed_read_model(WalletReadSeed { payment_orders: vec![order.clone()], ..WalletReadSeed::default() })); let barrier = Arc::new(tokio::sync::Barrier::new(3)); let mut tasks = Vec::new(); for suffix in ["winner-a", "winner-b"] { let repository = Arc::clone(&repository); let barrier = Arc::clone(&barrier); let input = stripe_secret_cas_input( &order, observed.clone(), legacy, &format!( "aether-payment-order-stripe-client-secret-v2:aether-runtime-secret-v1:gAAAAAB{suffix}" ), ); tasks.push(tokio::spawn(async move { barrier.wait().await; repository .compare_and_swap_payment_order_stripe_client_secret(input) .await })); } barrier.wait().await; let mut winners = 0; for task in tasks { if task .await .expect("migration task should join") .expect("migration should not error") { winners += 1; } } assert_eq!(winners, 1); } fn sample_refund(id: &str, user_id: Option<&str>, status: &str) -> StoredAdminWalletRefund { StoredAdminWalletRefund { id: id.to_string(), refund_no: format!("refund-no-{id}"), wallet_id: "wallet-1".to_string(), user_id: user_id.map(str::to_string), payment_order_id: None, source_type: "wallet_balance".to_string(), source_id: None, refund_mode: "offline_payout".to_string(), amount_usd: 3.0, status: status.to_string(), reason: None, failure_reason: None, gateway_refund_id: None, payout_method: None, payout_reference: None, payout_proof: None, requested_by: None, approved_by: None, processed_by: None, created_at_unix_ms: 100, updated_at_unix_secs: 100, processed_at_unix_secs: None, completed_at_unix_secs: None, } } #[tokio::test] async fn finds_wallet_by_owner() { let repository = InMemoryWalletRepository::seed(vec![sample_wallet()]); let wallet = repository .find(WalletLookupKey::UserId("user-1")) .await .expect("lookup should succeed") .expect("wallet should exist"); assert_eq!(wallet.id, "wallet-1"); } #[tokio::test] async fn lists_admin_wallets_with_filters_and_pagination() { let repository = InMemoryWalletRepository::seed(vec![ sample_wallet(), StoredWalletSnapshot::new( "wallet-2".to_string(), Some("user-2".to_string()), None, 3.0, 1.0, "finite".to_string(), "USD".to_string(), "inactive".to_string(), 0.0, 0.0, 0.0, 0.0, 90, ) .expect("wallet should build"), StoredWalletSnapshot::new( "wallet-3".to_string(), None, Some("key-3".to_string()), 5.0, 0.0, "unlimited".to_string(), "USD".to_string(), "active".to_string(), 0.0, 0.0, 0.0, 0.0, 110, ) .expect("wallet should build"), ]); let page = repository .list_admin_wallets(&AdminWalletListQuery { status: Some("active".to_string()), owner_type: Some("api_key".to_string()), limit: 1, offset: 0, }) .await .expect("list should succeed"); assert_eq!(page.total, 2); assert_eq!(page.items.len(), 1); assert_eq!(page.items[0].id, "wallet-3"); assert_eq!(page.items[0].updated_at_unix_secs, Some(110)); } #[tokio::test] async fn daily_usage_queries_default_to_empty_in_memory() { let repository = InMemoryWalletRepository::seed(vec![sample_wallet()]); let today = repository .find_wallet_today_usage("wallet-1", "Asia/Shanghai") .await .expect("lookup should succeed"); let history = repository .list_wallet_daily_usage_history("wallet-1", "Asia/Shanghai", 20) .await .expect("history should succeed"); assert!(today.is_none()); assert_eq!(history.total, 0); assert!(history.items.is_empty()); } #[tokio::test] async fn deletes_only_untouched_provisional_auth_wallets_in_memory() { let repository = InMemoryWalletRepository::seed(Vec::new()); let provisional_wallet = repository .initialize_auth_user_wallet("provisional-user", 10.0, false) .await .expect("wallet initialization should succeed") .expect("provisional wallet should exist"); assert!(repository .delete_provisional_auth_user_wallet(&provisional_wallet.id, "provisional-user") .await .expect("provisional cleanup should succeed")); assert!(repository .find(WalletLookupKey::UserId("provisional-user")) .await .expect("wallet lookup should succeed") .is_none()); } #[tokio::test] async fn provisional_cleanup_keeps_wallet_with_financial_activity_in_memory() { let wallet = StoredWalletSnapshot::new( "active-wallet".to_string(), Some("active-user".to_string()), None, 0.0, 10.0, "finite".to_string(), "USD".to_string(), "active".to_string(), 0.0, 1.0, 0.0, 10.0, 1, ) .expect("wallet should build"); let repository = InMemoryWalletRepository::seed([wallet]); assert!(!repository .delete_provisional_auth_user_wallet("active-wallet", "active-user") .await .expect("provisional cleanup should succeed")); assert!(repository .find(WalletLookupKey::UserId("active-user")) .await .expect("wallet lookup should succeed") .is_some()); } #[tokio::test] async fn lifetime_plan_purchase_blocks_duplicate_pending_order_in_memory() { let repository = InMemoryWalletRepository::seed(vec![sample_wallet()]); let snapshot = json!({ "id": "first-plan", "duration_unit": "month", "duration_value": 1, "max_active_per_user": 1, "purchase_limit_scope": "lifetime", "entitlements": [ { "type": "wallet_credit", "amount_usd": 1.0, "balance_bucket": "gift" } ] }); let first = repository .create_plan_purchase_order(CreatePlanPurchaseOrderInput { preferred_wallet_id: None, user_id: "user-1".to_string(), amount_usd: 1.0, pay_amount: 7.2, pay_currency: "CNY".to_string(), exchange_rate: 7.2, payment_method: "alipay".to_string(), payment_provider: Some("epay".to_string()), payment_channel: Some("alipay".to_string()), gateway_order_id: "gateway-first-plan-1".to_string(), gateway_response: json!({ "checkout": true }), order_no: "order-first-plan-1".to_string(), product_id: "first-plan".to_string(), product_snapshot: snapshot.clone(), expires_at_unix_secs: 4_102_444_800, }) .await .expect("first plan purchase should resolve"); assert!(matches!(first, CreatePlanPurchaseOrderOutcome::Created(_))); let duplicate = repository .create_plan_purchase_order(CreatePlanPurchaseOrderInput { preferred_wallet_id: None, user_id: "user-1".to_string(), amount_usd: 1.0, pay_amount: 7.2, pay_currency: "CNY".to_string(), exchange_rate: 7.2, payment_method: "alipay".to_string(), payment_provider: Some("epay".to_string()), payment_channel: Some("alipay".to_string()), gateway_order_id: "gateway-first-plan-2".to_string(), gateway_response: json!({ "checkout": true }), order_no: "order-first-plan-2".to_string(), product_id: "first-plan".to_string(), product_snapshot: snapshot, expires_at_unix_secs: 4_102_444_800, }) .await .expect("duplicate plan purchase should resolve"); assert!(matches!( duplicate, CreatePlanPurchaseOrderOutcome::ActivePlanLimitReached )); let unlimited_snapshot = json!({ "id": "unlimited-plan", "duration_unit": "month", "duration_value": 1, "max_active_per_user": 1, "purchase_limit_scope": "unlimited", "entitlements": [ { "type": "wallet_credit", "amount_usd": 1.0, "balance_bucket": "gift" } ] }); for index in 1..=2 { let order = repository .create_plan_purchase_order(CreatePlanPurchaseOrderInput { preferred_wallet_id: None, user_id: "user-1".to_string(), amount_usd: 1.0, pay_amount: 7.2, pay_currency: "CNY".to_string(), exchange_rate: 7.2, payment_method: "alipay".to_string(), payment_provider: Some("epay".to_string()), payment_channel: Some("alipay".to_string()), gateway_order_id: format!("gateway-unlimited-plan-{index}"), gateway_response: json!({ "checkout": true }), order_no: format!("order-unlimited-plan-{index}"), product_id: "unlimited-plan".to_string(), product_snapshot: unlimited_snapshot.clone(), expires_at_unix_secs: 4_102_444_800, }) .await .expect("unlimited plan purchase should resolve"); assert!(matches!(order, CreatePlanPurchaseOrderOutcome::Created(_))); } } #[tokio::test] async fn plan_purchase_rejects_malformed_wallet_credit_in_memory() { let repository = InMemoryWalletRepository::seed(vec![sample_wallet()]); let result = repository .create_plan_purchase_order(CreatePlanPurchaseOrderInput { preferred_wallet_id: None, user_id: "user-1".to_string(), amount_usd: 1.0, pay_amount: 1.0, pay_currency: "USD".to_string(), exchange_rate: 1.0, payment_method: "stripe".to_string(), payment_provider: Some("stripe".to_string()), payment_channel: Some("card".to_string()), gateway_order_id: "gateway-invalid-wallet-credit".to_string(), gateway_response: json!({ "checkout": true }), order_no: "order-invalid-wallet-credit".to_string(), product_id: "invalid-wallet-credit-plan".to_string(), product_snapshot: json!({ "id": "invalid-wallet-credit-plan", "purchase_limit_scope": "unlimited", "entitlements": [{ "type": "wallet_credit", "amount_usd": 1.0, "balance_bucket": "unknown" }] }), expires_at_unix_secs: 4_102_444_800, }) .await; assert!(matches!(result, Err(DataLayerError::InvalidInput(_)))); assert!(repository .payment_orders_by_id .read() .expect("wallet repo lock") .is_empty()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn gateway_order_uniqueness_is_atomic_in_memory() { let repository = Arc::new(InMemoryWalletRepository::seed(Vec::new())); let barrier = Arc::new(tokio::sync::Barrier::new(3)); let mut tasks = Vec::new(); for index in 0..2 { let repository = Arc::clone(&repository); let barrier = Arc::clone(&barrier); tasks.push(tokio::spawn(async move { barrier.wait().await; repository .create_wallet_recharge_order(CreateWalletRechargeOrderInput { preferred_wallet_id: Some(format!("wallet-concurrent-{index}")), user_id: format!("user-concurrent-{index}"), amount_usd: 1.0, pay_amount: Some(1.0), pay_currency: Some("USD".to_string()), exchange_rate: Some(1.0), payment_method: " EPAY ".to_string(), payment_provider: None, payment_channel: None, gateway_order_id: "shared-memory-gateway-id".to_string(), gateway_response: json!({ "checkout": true }), order_no: format!("order-concurrent-{index}"), expires_at_unix_secs: 4_102_444_800, }) .await })); } barrier.wait().await; let mut created = 0; let mut rejected = 0; for task in tasks { match task.await.expect("order task should join") { Ok(CreateWalletRechargeOrderOutcome::Created(order)) => { assert_eq!(order.payment_method, "epay"); created += 1; } Err(DataLayerError::InvalidInput(_)) => rejected += 1, other => panic!("unexpected concurrent order result: {other:?}"), } } assert_eq!((created, rejected), (1, 1)); assert_eq!( repository .payment_orders_by_id .read() .expect("wallet repo lock") .len(), 1 ); assert_eq!( repository .wallets_by_id .read() .expect("wallet repo lock") .len(), 1, "the rejected order must not leave a provisional wallet behind" ); let orders = repository .payment_orders_by_id .read() .expect("wallet repo lock"); let wallets = repository.wallets_by_id.read().expect("wallet repo lock"); let stored_order = orders.values().next().expect("winning order should remain"); let stored_wallet = wallets .get(&stored_order.wallet_id) .expect("winning order must not reference a removed wallet"); assert_eq!(stored_wallet.user_id, stored_order.user_id); } #[tokio::test] async fn recharge_order_conflict_removes_unreferenced_provisional_wallet_in_memory() { let mut existing = sample_payment_order( "existing-recharge-order", Some("recharge-conflict-user"), "pending", ); existing.order_no = "recharge-conflict-order-no".to_string(); existing.wallet_id = "wallet-from-old-read-model".to_string(); existing.payment_method = "epay".to_string(); existing.gateway_order_id = Some("gateway-from-old-read-model".to_string()); existing.gateway_response = Some(json!({ "order_kind": "wallet_recharge", "integration_status": "checkout_pending" })); let repository = InMemoryWalletRepository::seed_read_model(WalletReadSeed { payment_orders: vec![existing.clone()], ..WalletReadSeed::default() }); let outcome = repository .create_wallet_recharge_order(CreateWalletRechargeOrderInput { preferred_wallet_id: Some("wallet-provisional-conflict".to_string()), user_id: "recharge-conflict-user".to_string(), amount_usd: 10.0, pay_amount: None, pay_currency: None, exchange_rate: None, payment_method: "epay".to_string(), payment_provider: Some("epay".to_string()), payment_channel: None, gateway_order_id: "gateway-retry".to_string(), gateway_response: json!({ "integration_status": "checkout_pending" }), order_no: "recharge-conflict-order-no".to_string(), expires_at_unix_secs: 4_102_444_800, }) .await .expect("existing recharge order should be returned"); assert!(matches!( outcome, CreateWalletRechargeOrderOutcome::Existing(order) if order.id == "existing-recharge-order" )); assert!(repository .find(WalletLookupKey::UserId("recharge-conflict-user")) .await .expect("wallet lookup should succeed") .is_none()); assert_eq!( repository .payment_orders_by_id .read() .expect("wallet repo lock") .len(), 1 ); } #[tokio::test] async fn recharge_rejects_preferred_wallet_id_owned_by_another_user_in_memory() { let repository = InMemoryWalletRepository::seed(vec![sample_wallet()]); let result = repository .create_wallet_recharge_order(CreateWalletRechargeOrderInput { preferred_wallet_id: Some("wallet-1".to_string()), user_id: "different-owner".to_string(), amount_usd: 5.0, pay_amount: Some(5.0), pay_currency: Some("USD".to_string()), exchange_rate: Some(1.0), payment_method: "stripe".to_string(), payment_provider: Some("stripe".to_string()), payment_channel: Some("card".to_string()), gateway_order_id: "gateway-wallet-id-collision".to_string(), gateway_response: json!({ "checkout": true }), order_no: "order-wallet-id-collision".to_string(), expires_at_unix_secs: 4_102_444_800, }) .await; assert!(matches!( result, Err(DataLayerError::InvalidInput(message)) if message.contains("wallet identifier already belongs") )); let original = repository .find(WalletLookupKey::UserId("user-1")) .await .expect("original wallet lookup should succeed") .expect("original wallet should remain present"); assert_eq!(original.id, "wallet-1"); assert!(repository .find(WalletLookupKey::UserId("different-owner")) .await .expect("new owner lookup should succeed") .is_none()); assert!(repository .payment_orders_by_id .read() .expect("wallet repo lock") .is_empty()); } #[tokio::test] async fn recharge_checkout_update_preserves_order_kind_in_memory() { let repository = InMemoryWalletRepository::seed(Vec::new()); let created = repository .create_wallet_recharge_order(CreateWalletRechargeOrderInput { preferred_wallet_id: None, user_id: "user-recharge-kind".to_string(), amount_usd: 3.0, pay_amount: Some(3.0), pay_currency: Some("USD".to_string()), exchange_rate: Some(1.0), payment_method: "epay".to_string(), payment_provider: Some("epay".to_string()), payment_channel: Some("alipay".to_string()), gateway_order_id: "placeholder-order-kind".to_string(), gateway_response: json!({ "gateway": "epay", "integration_status": "checkout_pending" }), order_no: "order-recharge-kind".to_string(), expires_at_unix_secs: 4_102_444_800, }) .await .expect("recharge order should be created"); let CreateWalletRechargeOrderOutcome::Created(order) = created else { panic!("expected a newly created recharge order"); }; let updated = repository .update_wallet_recharge_checkout(UpdateWalletRechargeCheckoutInput { order_id: order.id.clone(), gateway_order_id: "provider-order-kind".to_string(), gateway_response: json!({ "gateway": "epay", "payment_url": "https://pay.example.test/order" }), }) .await .expect("checkout update should succeed"); assert!(matches!(updated, WalletMutationOutcome::Applied(_))); let replay = repository .find_wallet_recharge_order_by_order_no("user-recharge-kind", "order-recharge-kind") .await .expect("recharge lookup should succeed") .expect("updated order should remain discoverable"); assert_eq!( replay.gateway_order_id.as_deref(), Some("provider-order-kind") ); assert_eq!( replay .gateway_response .as_ref() .and_then(|value| value.get("order_kind")) .and_then(serde_json::Value::as_str), Some("wallet_recharge") ); } #[tokio::test] async fn recharge_checkout_update_rejects_expired_order_in_memory() { let repository = InMemoryWalletRepository::seed(Vec::new()); let created = repository .create_wallet_recharge_order(CreateWalletRechargeOrderInput { preferred_wallet_id: None, user_id: "user-expired-checkout".to_string(), amount_usd: 3.0, pay_amount: Some(3.0), pay_currency: Some("USD".to_string()), exchange_rate: Some(1.0), payment_method: "epay".to_string(), payment_provider: Some("epay".to_string()), payment_channel: Some("alipay".to_string()), gateway_order_id: "order-expired-checkout".to_string(), gateway_response: serde_json::json!({ "order_kind": "wallet_recharge", "integration_status": "checkout_pending" }), order_no: "order-expired-checkout".to_string(), expires_at_unix_secs: 1, }) .await .expect("expired recharge order should be creatable for regression setup"); let CreateWalletRechargeOrderOutcome::Created(order) = created else { panic!("expected a newly created recharge order"); }; let result = repository .update_wallet_recharge_checkout(UpdateWalletRechargeCheckoutInput { order_id: order.id.clone(), gateway_order_id: "provider-expired-checkout".to_string(), gateway_response: serde_json::json!({ "order_kind": "wallet_recharge", "payment_url": "https://pay.example.test/expired" }), }) .await .expect("expired checkout update should resolve"); assert!(matches!(result, WalletMutationOutcome::Invalid(_))); let replay = repository .find_wallet_recharge_order_by_order_no( "user-expired-checkout", "order-expired-checkout", ) .await .expect("expired recharge lookup should succeed") .expect("expired recharge order should remain stored"); assert_eq!( replay.gateway_order_id.as_deref(), Some("order-expired-checkout") ); } #[tokio::test] async fn failed_recharge_without_channel_can_be_reclaimed_in_memory() { let repository = InMemoryWalletRepository::seed(Vec::new()); let first = repository .create_wallet_recharge_order(CreateWalletRechargeOrderInput { preferred_wallet_id: None, user_id: "user-reclaim-no-channel".to_string(), amount_usd: 3.0, pay_amount: None, pay_currency: None, exchange_rate: None, payment_method: "stripe".to_string(), payment_provider: Some("stripe".to_string()), payment_channel: None, gateway_order_id: "order-reclaim-no-channel".to_string(), gateway_response: json!({ "gateway": "stripe", "order_kind": "wallet_recharge", "integration_status": "checkout_pending", "checkout_claim_token": "first-claim" }), order_no: "order-reclaim-no-channel".to_string(), expires_at_unix_secs: 4_102_444_800, }) .await .expect("initial recharge should be created"); let CreateWalletRechargeOrderOutcome::Created(first) = first else { panic!("expected initial recharge order"); }; let failed = repository .fail_wallet_recharge_checkout(FailWalletRechargeCheckoutInput { order_id: first.id.clone(), claim_token: "first-claim".to_string(), reason: "provider unavailable".to_string(), provider_request_may_have_succeeded: false, }) .await .expect("checkout failure should resolve"); assert!(matches!(failed, WalletMutationOutcome::Applied(_))); let retry = repository .create_wallet_recharge_order(CreateWalletRechargeOrderInput { preferred_wallet_id: None, user_id: "user-reclaim-no-channel".to_string(), amount_usd: 3.0, pay_amount: None, pay_currency: None, exchange_rate: None, payment_method: "stripe".to_string(), payment_provider: Some("stripe".to_string()), payment_channel: None, gateway_order_id: "order-reclaim-no-channel".to_string(), gateway_response: json!({ "gateway": "stripe", "order_kind": "wallet_recharge", "integration_status": "checkout_pending", "checkout_claim_token": "retry-claim" }), order_no: "order-reclaim-no-channel".to_string(), expires_at_unix_secs: 4_102_444_800, }) .await .expect("failed placeholder should be reclaimable"); let CreateWalletRechargeOrderOutcome::Created(reclaimed) = retry else { panic!("expected failed placeholder to be reclaimed"); }; assert_eq!(reclaimed.id, first.id); assert_eq!( reclaimed .gateway_response .as_ref() .and_then(|value| value.get("checkout_claim_token")) .and_then(serde_json::Value::as_str), Some("retry-claim") ); assert_eq!(reclaimed.status, "pending"); } #[tokio::test] async fn recharge_order_rejects_non_finite_numeric_fields_in_memory() { let invalid_inputs = [ (f64::NAN, Some(1.0), Some(1.0), 4_102_444_800), (1.0, Some(f64::INFINITY), Some(1.0), 4_102_444_800), (1.0, Some(1.0), Some(0.0), 4_102_444_800), (1.0, Some(1.0), Some(1.0), i64::MAX as u64 + 1), ]; for (index, (amount_usd, pay_amount, exchange_rate, expires_at)) in invalid_inputs.into_iter().enumerate() { let repository = InMemoryWalletRepository::seed(Vec::new()); let result = repository .create_wallet_recharge_order(CreateWalletRechargeOrderInput { preferred_wallet_id: None, user_id: format!("invalid-recharge-user-{index}"), amount_usd, pay_amount, pay_currency: Some("USD".to_string()), exchange_rate, payment_method: "stripe".to_string(), payment_provider: Some("stripe".to_string()), payment_channel: Some("card".to_string()), gateway_order_id: format!("invalid-recharge-gateway-{index}"), gateway_response: json!({"payment_url": "https://pay.example.test"}), order_no: format!("invalid-recharge-order-{index}"), expires_at_unix_secs: expires_at, }) .await; assert!(matches!(result, Err(DataLayerError::InvalidInput(_)))); assert!(repository .find(WalletLookupKey::UserId(&format!( "invalid-recharge-user-{index}" ))) .await .expect("wallet lookup should succeed") .is_none()); } } #[tokio::test] async fn refund_creation_is_idempotent_and_rejects_corrupt_wallet_balance_in_memory() { let repository = InMemoryWalletRepository::seed(vec![sample_wallet()]); let first = repository .create_wallet_refund_request(CreateWalletRefundRequestInput { wallet_id: "wallet-1".to_string(), user_id: "user-1".to_string(), amount_usd: 4.0, payment_order_id: None, source_type: None, source_id: None, refund_mode: None, reason: Some("first request".to_string()), idempotency_key: Some("memory-refund-idempotency".to_string()), refund_no: "memory-refund-1".to_string(), }) .await .expect("first refund should resolve"); let CreateWalletRefundRequestOutcome::Created(first) = first else { panic!("expected first refund to be created"); }; let replay = repository .create_wallet_refund_request(CreateWalletRefundRequestInput { wallet_id: "wallet-1".to_string(), user_id: "user-1".to_string(), amount_usd: 9.0, payment_order_id: None, source_type: None, source_id: None, refund_mode: None, reason: Some("replayed request".to_string()), idempotency_key: Some("memory-refund-idempotency".to_string()), refund_no: "memory-refund-2".to_string(), }) .await .expect("refund replay should resolve"); assert!(matches!( replay, CreateWalletRefundRequestOutcome::Duplicate(refund) if refund.id == first.id )); assert_eq!( repository .refunds_by_id .read() .expect("wallet repo lock") .len(), 1 ); repository.with_wallets_mut(|wallets| { wallets .get_mut("wallet-1") .expect("sample wallet should exist") .balance = f64::NAN; }); let corrupt = repository .create_wallet_refund_request(CreateWalletRefundRequestInput { wallet_id: "wallet-1".to_string(), user_id: "user-1".to_string(), amount_usd: 1.0, payment_order_id: None, source_type: None, source_id: None, refund_mode: None, reason: None, idempotency_key: Some("memory-refund-corrupt".to_string()), refund_no: "memory-refund-corrupt".to_string(), }) .await .expect("corrupt wallet refund should resolve"); assert!(matches!( corrupt, CreateWalletRefundRequestOutcome::InvalidInput(_) )); } #[tokio::test] async fn seeded_refund_idempotency_replays_only_explicit_mappings_in_memory() { let seeded = sample_refund("refund-seeded-idempotency", Some("user-1"), "approved"); let mapped_repository = InMemoryWalletRepository::seed_read_model(WalletReadSeed { wallets: vec![sample_wallet()], refunds: vec![seeded.clone()], refund_idempotency: vec![( "user-1".to_string(), "seeded-refund-key".to_string(), seeded.id.clone(), )], ..WalletReadSeed::default() }); let replay = mapped_repository .create_wallet_refund_request(CreateWalletRefundRequestInput { wallet_id: "wallet-1".to_string(), user_id: "user-1".to_string(), amount_usd: 1.0, payment_order_id: None, source_type: None, source_id: None, refund_mode: None, reason: Some("seeded replay".to_string()), idempotency_key: Some("seeded-refund-key".to_string()), refund_no: "seeded-refund-replay".to_string(), }) .await .expect("seeded refund replay should resolve"); assert!(matches!( replay, CreateWalletRefundRequestOutcome::Duplicate(refund) if refund.id == seeded.id )); assert_eq!( mapped_repository .refunds_by_id .read() .expect("wallet repo lock") .len(), 1 ); let unmapped_repository = InMemoryWalletRepository::seed_read_model(WalletReadSeed { wallets: vec![sample_wallet()], refunds: vec![seeded], ..WalletReadSeed::default() }); let created = unmapped_repository .create_wallet_refund_request(CreateWalletRefundRequestInput { wallet_id: "wallet-1".to_string(), user_id: "user-1".to_string(), amount_usd: 1.0, payment_order_id: None, source_type: None, source_id: None, refund_mode: None, reason: Some("unmapped seed".to_string()), idempotency_key: Some("seeded-refund-key".to_string()), refund_no: "seeded-refund-unmapped".to_string(), }) .await .expect("unmapped seeded refund request should resolve"); assert!(matches!( created, CreateWalletRefundRequestOutcome::Created(_) )); assert_eq!( unmapped_repository .refunds_by_id .read() .expect("wallet repo lock") .len(), 2 ); } #[tokio::test] async fn counts_pending_user_refunds_and_payment_orders() { let repository = InMemoryWalletRepository::seed_read_model(WalletReadSeed { wallets: vec![sample_wallet()], payment_orders: vec![ sample_payment_order("order-1", Some("user-1"), "pending"), sample_payment_order("order-2", Some("user-1"), "paid"), sample_payment_order("order-3", Some("user-1"), "credited"), sample_payment_order("order-4", Some("user-2"), "pending"), ], payment_callbacks: Vec::new(), wallet_transactions: Vec::new(), refunds: vec![ sample_refund("refund-1", Some("user-1"), "pending_approval"), sample_refund("refund-2", Some("user-1"), "processing"), sample_refund("refund-3", Some("user-1"), "completed"), sample_refund("refund-4", Some("user-2"), "approved"), ], refund_idempotency: Vec::new(), redeem_batches: Vec::new(), redeem_codes: Vec::new(), }); assert_eq!( repository .count_pending_payment_orders_by_user_id("user-1") .await .expect("payment order count should succeed"), 2 ); assert_eq!( repository .count_pending_refunds_by_user_id("user-1") .await .expect("refund count should succeed"), 2 ); } #[tokio::test] async fn refund_reservation_rejects_invalid_active_amounts_in_memory() { for (label, amount) in [ ("negative", -100.0), ("zero", 0.0), ("infinite", f64::INFINITY), ("nan", f64::NAN), ] { let mut invalid = sample_refund( &format!("refund-invalid-{label}"), Some("user-1"), "pending_approval", ); invalid.amount_usd = amount; let repository = InMemoryWalletRepository::seed_read_model(WalletReadSeed { wallets: vec![sample_wallet()], refunds: vec![invalid], ..WalletReadSeed::default() }); let outcome = repository .create_wallet_refund_request(CreateWalletRefundRequestInput { wallet_id: "wallet-1".to_string(), user_id: "user-1".to_string(), amount_usd: 1.0, payment_order_id: None, source_type: None, source_id: None, refund_mode: None, reason: Some(format!("invalid reservation: {label}")), idempotency_key: Some(format!("reservation-invalid-{label}")), refund_no: format!("reservation-invalid-{label}"), }) .await .expect("reservation request should resolve"); assert!( matches!(outcome, CreateWalletRefundRequestOutcome::InvalidInput(_)), "active {label} reservation must fail closed: {outcome:?}" ); } // Completed refunds do not reserve balance and remain ignored. let mut completed = sample_refund("refund-completed", Some("user-1"), "completed"); completed.amount_usd = 100.0; let repository = InMemoryWalletRepository::seed_read_model(WalletReadSeed { wallets: vec![sample_wallet()], refunds: vec![completed], ..WalletReadSeed::default() }); let outcome = repository .create_wallet_refund_request(CreateWalletRefundRequestInput { wallet_id: "wallet-1".to_string(), user_id: "user-1".to_string(), amount_usd: 1.0, payment_order_id: None, source_type: None, source_id: None, refund_mode: None, reason: Some("completed reservation is ignored".to_string()), idempotency_key: Some("reservation-completed-ignored".to_string()), refund_no: "reservation-completed-ignored".to_string(), }) .await .expect("completed reservation should not block request"); assert!(matches!( outcome, CreateWalletRefundRequestOutcome::Created(_) )); } }