Files
Aether/crates/aether-data/runtime/src/backend/referrals.rs
T

2732 lines
91 KiB
Rust

use crate::DataLayerError;
use serde::{Deserialize, Serialize};
use sqlx::Row;
use std::collections::HashSet;
use super::DataBackends;
#[derive(Debug, Clone, Copy)]
pub struct ReferralDataState<'a> {
backends: Option<&'a DataBackends>,
}
impl<'a> ReferralDataState<'a> {
pub fn new(backends: Option<&'a DataBackends>) -> Self {
Self { backends }
}
}
const REFERRAL_FETCH_LIMIT: usize = 5_000;
#[derive(Debug, Clone, Serialize)]
pub struct ReferralUserDashboard {
pub invite_code: String,
pub total_invites: u64,
pub effective_invites: u64,
pub paid_reward_usd: f64,
pub pending_reward_usd: f64,
pub reversed_reward_usd: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReferralRelationshipRecord {
pub id: String,
pub inviter_user_id: String,
pub inviter_username: Option<String>,
pub invitee_user_id: String,
pub invitee_username: Option<String>,
pub invite_code_snapshot: String,
pub first_paid_order_id: Option<String>,
pub first_paid_at_unix_secs: Option<u64>,
pub source: Option<serde_json::Value>,
pub created_at_unix_secs: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReferralRewardRecord {
pub id: String,
pub referral_id: String,
pub inviter_user_id: String,
pub invitee_user_id: String,
pub reward_type: String,
pub source_order_id: Option<String>,
pub trigger_point: String,
pub amount_usd: f64,
pub status: String,
pub wallet_transaction_id: Option<String>,
pub idempotency_key: String,
pub reversed_amount_usd: f64,
pub pending_reversal_amount_usd: f64,
pub admin_operator_id: Option<String>,
pub admin_note: Option<String>,
pub created_at_unix_secs: u64,
pub updated_at_unix_secs: u64,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ReferralAdminStats {
pub total_invites: u64,
pub effective_invites: u64,
pub paid_reward_usd: f64,
pub pending_reward_usd: f64,
pub reversed_reward_usd: f64,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ReferralRelationshipListQuery {
pub inviter: Option<String>,
pub invitee: Option<String>,
pub invite_code: Option<String>,
pub first_paid: Option<bool>,
pub limit: usize,
pub offset: usize,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ReferralRewardListQuery {
pub order_id: Option<String>,
pub reward_type: Option<String>,
pub status: Option<String>,
pub limit: usize,
pub offset: usize,
}
#[derive(Debug, Clone)]
pub struct ReferralRewardConfig {
pub percent_enabled: bool,
pub percent_rate: f64,
pub headcount_enabled: bool,
pub headcount_amount_usd: f64,
pub headcount_trigger: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReferralMutationStatus {
Applied,
NotFound,
Invalid,
Unavailable,
}
#[derive(Debug, Clone)]
struct ReferralPaymentOrderContext {
id: String,
user_id: String,
amount_usd: f64,
payment_method: String,
status: String,
order_kind: String,
}
#[derive(Debug, Clone)]
struct ReferralPaymentOrderRefundContext {
amount_usd: f64,
refunded_amount_usd: f64,
}
#[derive(Debug, Clone)]
struct ReferralCreditTarget {
id: String,
wallet_id: String,
amount_usd: f64,
reward_type: String,
}
macro_rules! row_string {
($row:expr, $col:expr) => {
$row.try_get::<String, _>($col)
.map_err(DataLayerError::sql)?
};
}
macro_rules! row_optional_string {
($row:expr, $col:expr) => {
$row.try_get::<Option<String>, _>($col)
.map_err(DataLayerError::sql)?
};
}
macro_rules! row_f64 {
($row:expr, $col:expr) => {
$row.try_get::<f64, _>($col).map_err(DataLayerError::sql)?
};
}
macro_rules! relationship_from_row {
($row:expr) => {{
let source_text = row_optional_string!($row, "source_json");
Ok(ReferralRelationshipRecord {
id: row_string!($row, "id"),
inviter_user_id: row_string!($row, "inviter_user_id"),
inviter_username: row_optional_string!($row, "inviter_username"),
invitee_user_id: row_string!($row, "invitee_user_id"),
invitee_username: row_optional_string!($row, "invitee_username"),
invite_code_snapshot: row_string!($row, "invite_code_snapshot"),
first_paid_order_id: row_optional_string!($row, "first_paid_order_id"),
first_paid_at_unix_secs: row_optional_unix_secs($row, "first_paid_at_unix_secs")?,
source: parse_optional_json(source_text)?,
created_at_unix_secs: row_unix_secs($row, "created_at_unix_secs")?,
})
}};
}
macro_rules! reward_from_row {
($row:expr) => {{
Ok(ReferralRewardRecord {
id: row_string!($row, "id"),
referral_id: row_string!($row, "referral_id"),
inviter_user_id: row_string!($row, "inviter_user_id"),
invitee_user_id: row_string!($row, "invitee_user_id"),
reward_type: row_string!($row, "reward_type"),
source_order_id: row_optional_string!($row, "source_order_id"),
trigger_point: row_string!($row, "trigger_point"),
amount_usd: row_f64!($row, "amount_usd"),
status: row_string!($row, "status"),
wallet_transaction_id: row_optional_string!($row, "wallet_transaction_id"),
idempotency_key: row_string!($row, "idempotency_key"),
reversed_amount_usd: row_f64!($row, "reversed_amount_usd"),
pending_reversal_amount_usd: row_f64!($row, "pending_reversal_amount_usd"),
admin_operator_id: row_optional_string!($row, "admin_operator_id"),
admin_note: row_optional_string!($row, "admin_note"),
created_at_unix_secs: row_unix_secs($row, "created_at_unix_secs")?,
updated_at_unix_secs: row_unix_secs($row, "updated_at_unix_secs")?,
})
}};
}
#[cfg(any(feature = "mysql", feature = "sqlite"))]
fn now_unix_secs() -> u64 {
chrono::Utc::now().timestamp().max(0) as u64
}
#[cfg(any(feature = "mysql", feature = "sqlite"))]
fn now_unix_ms() -> u64 {
chrono::Utc::now().timestamp_millis().max(0) as u64
}
fn row_unix_secs<R>(row: &R, column: &str) -> Result<u64, DataLayerError>
where
R: Row,
for<'c> &'c str: sqlx::ColumnIndex<R>,
for<'r> i64: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
{
let value = row.try_get::<i64, _>(column).map_err(DataLayerError::sql)?;
Ok(value.max(0) as u64)
}
fn row_optional_unix_secs<R>(row: &R, column: &str) -> Result<Option<u64>, DataLayerError>
where
R: Row,
for<'c> &'c str: sqlx::ColumnIndex<R>,
for<'r> Option<i64>: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
{
let value = row
.try_get::<Option<i64>, _>(column)
.map_err(DataLayerError::sql)?;
Ok(value.map(|value| value.max(0) as u64))
}
fn parse_optional_json(value: Option<String>) -> Result<Option<serde_json::Value>, DataLayerError> {
value
.filter(|value| !value.trim().is_empty())
.map(|value| serde_json::from_str(&value).map_err(DataLayerError::sql))
.transpose()
}
fn generate_invite_code() -> String {
format!(
"AE{}",
&uuid::Uuid::new_v4().simple().to_string()[..10].to_ascii_uppercase()
)
}
fn referral_text_matches(value: Option<&str>, needle: Option<&str>) -> bool {
let Some(needle) = needle.map(str::trim).filter(|value| !value.is_empty()) else {
return true;
};
let Some(value) = value else {
return false;
};
value
.to_ascii_lowercase()
.contains(&needle.to_ascii_lowercase())
}
fn referral_list_window<T: Clone>(items: &[T], limit: usize, offset: usize) -> Vec<T> {
let limit = limit.clamp(1, 200);
items.iter().skip(offset).take(limit).cloned().collect()
}
fn referral_admin_stats(
relationships: &[ReferralRelationshipRecord],
rewards: &[ReferralRewardRecord],
) -> ReferralAdminStats {
ReferralAdminStats {
total_invites: relationships.len() as u64,
effective_invites: relationships
.iter()
.filter(|item| item.first_paid_order_id.is_some())
.count() as u64,
paid_reward_usd: rewards
.iter()
.filter(|item| item.status == "applied")
.map(|item| item.amount_usd)
.sum(),
pending_reward_usd: rewards
.iter()
.filter(|item| matches!(item.status.as_str(), "pending" | "failed"))
.map(|item| item.amount_usd)
.sum(),
reversed_reward_usd: rewards.iter().map(|item| item.reversed_amount_usd).sum(),
}
}
fn normalize_referral_code(value: &str) -> Option<String> {
let value = value.trim().to_ascii_uppercase();
(!value.is_empty() && value.len() <= 64).then_some(value)
}
fn reward_description(target: &ReferralCreditTarget) -> String {
match target.reward_type.as_str() {
"percent" => "邀请充值比例返利".to_string(),
"headcount" => "邀请人头返利".to_string(),
_ => "邀请返利".to_string(),
}
}
fn referral_retry_allowed(status: &str) -> bool {
status == "failed"
}
fn referral_void_allowed(status: &str) -> bool {
matches!(status, "pending" | "failed")
}
fn referral_reversal_delta(
reward_amount_usd: f64,
order_amount_usd: f64,
refunded_amount_usd: f64,
reversed_amount_usd: f64,
pending_reversal_amount_usd: f64,
) -> f64 {
let target_reversal =
referral_reversal_target(reward_amount_usd, order_amount_usd, refunded_amount_usd);
(target_reversal - reversed_amount_usd - pending_reversal_amount_usd).max(0.0)
}
fn referral_reversal_target(
reward_amount_usd: f64,
order_amount_usd: f64,
refunded_amount_usd: f64,
) -> f64 {
if reward_amount_usd <= 0.0 || order_amount_usd <= 0.0 || refunded_amount_usd <= 0.0 {
return 0.0;
}
reward_amount_usd * (refunded_amount_usd / order_amount_usd).clamp(0.0, 1.0)
}
impl ReferralDataState<'_> {
pub fn has_referral_data_backend(&self) -> bool {
self.backends.is_some()
}
pub async fn record_user_privacy_policy_acceptance(
&self,
user_id: &str,
version: &str,
) -> Result<bool, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(false);
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let affected = sqlx::query(
r#"
UPDATE users
SET privacy_policy_accepted_version = $2,
privacy_policy_accepted_at = NOW()
WHERE id = $1
"#,
)
.bind(user_id)
.bind(version)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?
.rows_affected();
return Ok(affected > 0);
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let affected = sqlx::query(
r#"
UPDATE users
SET privacy_policy_accepted_version = ?,
privacy_policy_accepted_at = ?
WHERE id = ?
"#,
)
.bind(version)
.bind(now_unix_secs() as i64)
.bind(user_id)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?
.rows_affected();
return Ok(affected > 0);
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let affected = sqlx::query(
r#"
UPDATE users
SET privacy_policy_accepted_version = ?,
privacy_policy_accepted_at = ?
WHERE id = ?
"#,
)
.bind(version)
.bind(now_unix_secs() as i64)
.bind(user_id)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?
.rows_affected();
return Ok(affected > 0);
}
Ok(false)
}
pub async fn referral_dashboard(
&self,
user_id: &str,
) -> Result<Option<ReferralUserDashboard>, DataLayerError> {
let Some(invite_code) = self.ensure_referral_invite_code(user_id).await? else {
return Ok(None);
};
let relationships = self
.list_referral_relationships_raw(Some(user_id), None)
.await?;
let rewards = self.list_referral_rewards_raw(Some(user_id)).await?;
let stats = referral_admin_stats(&relationships, &rewards);
Ok(Some(ReferralUserDashboard {
invite_code,
total_invites: stats.total_invites,
effective_invites: stats.effective_invites,
paid_reward_usd: stats.paid_reward_usd,
pending_reward_usd: stats.pending_reward_usd,
reversed_reward_usd: stats.reversed_reward_usd,
}))
}
pub async fn list_admin_referral_relationships(
&self,
query: ReferralRelationshipListQuery,
) -> Result<Option<(Vec<ReferralRelationshipRecord>, u64, ReferralAdminStats)>, DataLayerError>
{
if self.backends.is_none() {
return Ok(None);
}
let all_relationships = self.list_referral_relationships_raw(None, None).await?;
let all_rewards = self.list_referral_rewards_raw(None).await?;
let filtered = all_relationships
.into_iter()
.filter(|item| {
referral_text_matches(item.inviter_username.as_deref(), query.inviter.as_deref())
|| referral_text_matches(
Some(item.inviter_user_id.as_str()),
query.inviter.as_deref(),
)
})
.filter(|item| {
referral_text_matches(item.invitee_username.as_deref(), query.invitee.as_deref())
|| referral_text_matches(
Some(item.invitee_user_id.as_str()),
query.invitee.as_deref(),
)
})
.filter(|item| {
referral_text_matches(
Some(item.invite_code_snapshot.as_str()),
query.invite_code.as_deref(),
)
})
.filter(|item| {
query
.first_paid
.map(|expected| item.first_paid_order_id.is_some() == expected)
.unwrap_or(true)
})
.collect::<Vec<_>>();
let total = filtered.len() as u64;
let filtered_referral_ids = filtered
.iter()
.map(|item| item.id.as_str())
.collect::<HashSet<_>>();
let filtered_rewards = all_rewards
.into_iter()
.filter(|item| filtered_referral_ids.contains(item.referral_id.as_str()))
.collect::<Vec<_>>();
let stats = referral_admin_stats(&filtered, &filtered_rewards);
Ok(Some((
referral_list_window(&filtered, query.limit, query.offset),
total,
stats,
)))
}
pub async fn list_admin_referral_rewards(
&self,
query: ReferralRewardListQuery,
) -> Result<Option<(Vec<ReferralRewardRecord>, u64, ReferralAdminStats)>, DataLayerError> {
if self.backends.is_none() {
return Ok(None);
}
let relationships = self.list_referral_relationships_raw(None, None).await?;
let rewards = self.list_referral_rewards_raw(None).await?;
let filtered = rewards
.iter()
.filter(|item| {
referral_text_matches(item.source_order_id.as_deref(), query.order_id.as_deref())
})
.filter(|item| {
referral_text_matches(
Some(item.reward_type.as_str()),
query.reward_type.as_deref(),
)
})
.filter(|item| {
referral_text_matches(Some(item.status.as_str()), query.status.as_deref())
})
.cloned()
.collect::<Vec<_>>();
let total = filtered.len() as u64;
let filtered_referral_ids = filtered
.iter()
.map(|item| item.referral_id.as_str())
.collect::<HashSet<_>>();
let filtered_relationships = relationships
.into_iter()
.filter(|item| filtered_referral_ids.contains(item.id.as_str()))
.collect::<Vec<_>>();
let stats = referral_admin_stats(&filtered_relationships, &filtered);
Ok(Some((
referral_list_window(&filtered, query.limit, query.offset),
total,
stats,
)))
}
pub async fn bind_referral_invite_code(
&self,
invitee_user_id: &str,
invite_code: Option<&str>,
source: Option<serde_json::Value>,
) -> Result<Option<ReferralRelationshipRecord>, DataLayerError> {
let Some(code) = invite_code.and_then(normalize_referral_code) else {
return Ok(None);
};
let Some(inviter_user_id) = self.find_referral_inviter_by_code(&code).await? else {
return Err(DataLayerError::InvalidInput("邀请码无效".to_string()));
};
if inviter_user_id == invitee_user_id {
return Err(DataLayerError::InvalidInput(
"不能使用自己的邀请码注册".to_string(),
));
}
let referral_id = uuid::Uuid::new_v4().to_string();
let source_json = source.map(|value| value.to_string());
let inserted = self
.insert_referral_relationship(
&referral_id,
&inviter_user_id,
invitee_user_id,
&code,
source_json.as_deref(),
)
.await?;
if !inserted {
return Ok(None);
}
self.find_referral_relationship(&referral_id).await
}
pub async fn apply_registration_referral_reward(
&self,
invitee_user_id: &str,
amount_usd: f64,
trigger_point: &str,
) -> Result<Vec<ReferralRewardRecord>, DataLayerError> {
if amount_usd <= 0.0 {
return Ok(Vec::new());
}
let Some(relationship) = self
.find_referral_relationship_by_invitee(invitee_user_id)
.await?
else {
return Ok(Vec::new());
};
let idempotency_key = format!("referral:{}:headcount:{trigger_point}", relationship.id);
self.insert_referral_reward(
&relationship,
"headcount",
None,
trigger_point,
amount_usd,
&idempotency_key,
)
.await?;
self.credit_pending_referral_rewards(&[idempotency_key], None, None)
.await
}
pub async fn apply_paid_order_referral_rewards(
&self,
order_id: &str,
config: ReferralRewardConfig,
) -> Result<Vec<ReferralRewardRecord>, DataLayerError> {
if self.backends.is_none() {
return Ok(Vec::new());
}
if !config.percent_enabled && !config.headcount_enabled {
return Ok(Vec::new());
}
let Some(context) = self.find_referral_payment_order_context(order_id).await? else {
return Ok(Vec::new());
};
if context.status != "credited" {
return Ok(Vec::new());
}
if !matches!(
context.order_kind.as_str(),
"wallet_recharge" | "plan_purchase"
) {
return Ok(Vec::new());
}
if matches!(
context.payment_method.as_str(),
"manual" | "admin_manual" | "redeem_code" | "gift"
) {
return Ok(Vec::new());
}
let Some(relationship) = self
.find_referral_relationship_by_invitee(&context.user_id)
.await?
else {
return Ok(Vec::new());
};
let marked_first_paid = self
.mark_referral_first_paid_order(&relationship.id, &context.id)
.await?;
let mut idempotency_keys = Vec::new();
if config.percent_enabled && config.percent_rate > 0.0 {
let amount_usd = (context.amount_usd * config.percent_rate / 100.0).max(0.0);
if amount_usd > 0.0 {
let idempotency_key =
format!("referral:{}:percent:{}", relationship.id, context.id);
self.insert_referral_reward(
&relationship,
"percent",
Some(&context.id),
"paid_order",
amount_usd,
&idempotency_key,
)
.await?;
idempotency_keys.push(idempotency_key);
}
}
if config.headcount_enabled
&& config.headcount_amount_usd > 0.0
&& config.headcount_trigger == "first_paid_order"
&& marked_first_paid
{
let idempotency_key =
format!("referral:{}:headcount:first_paid_order", relationship.id);
self.insert_referral_reward(
&relationship,
"headcount",
Some(&context.id),
"first_paid_order",
config.headcount_amount_usd,
&idempotency_key,
)
.await?;
idempotency_keys.push(idempotency_key);
}
if idempotency_keys.is_empty() {
return Ok(Vec::new());
}
self.credit_pending_referral_rewards(&idempotency_keys, None, None)
.await
}
pub async fn retry_referral_reward(
&self,
reward_id: &str,
operator_id: Option<&str>,
note: Option<&str>,
) -> Result<Option<ReferralRewardRecord>, DataLayerError> {
let Some(reward) = self.find_referral_reward(reward_id).await? else {
return Ok(None);
};
if !referral_retry_allowed(&reward.status) {
return Err(DataLayerError::InvalidInput(
"仅失败返利可以补发".to_string(),
));
}
let rewards = self
.credit_pending_referral_rewards(&[reward.idempotency_key], operator_id, note)
.await?;
Ok(rewards.into_iter().next())
}
pub async fn void_referral_reward(
&self,
reward_id: &str,
operator_id: Option<&str>,
note: Option<&str>,
) -> Result<Option<ReferralRewardRecord>, DataLayerError> {
let Some(reward) = self.find_referral_reward(reward_id).await? else {
return Ok(None);
};
if !referral_void_allowed(&reward.status) {
return Err(DataLayerError::InvalidInput(
"仅待发或失败返利可以作废".to_string(),
));
}
self.update_referral_reward_status(reward_id, "voided", operator_id, note)
.await?;
self.find_referral_reward(reward_id).await
}
pub async fn reverse_referral_rewards_for_order(
&self,
order_id: &str,
amount_usd: f64,
) -> Result<Vec<ReferralRewardRecord>, DataLayerError> {
if amount_usd <= 0.0 {
return Ok(Vec::new());
}
let Some(refund_context) = self
.find_referral_payment_order_refund_context(order_id)
.await?
else {
return Ok(Vec::new());
};
let rewards = self
.find_applied_referral_rewards_by_order(order_id)
.await?;
let mut reversed = Vec::new();
for reward in rewards {
let reversal_amount = referral_reversal_delta(
reward.amount_usd,
refund_context.amount_usd,
refund_context.refunded_amount_usd,
reward.reversed_amount_usd,
reward.pending_reversal_amount_usd,
);
if reversal_amount <= 0.0 {
continue;
}
let target_reversal = referral_reversal_target(
reward.amount_usd,
refund_context.amount_usd,
refund_context.refunded_amount_usd,
);
self.apply_referral_reward_reversal(&reward, target_reversal)
.await?;
if let Some(updated) = self.find_referral_reward(&reward.id).await? {
reversed.push(updated);
}
}
Ok(reversed)
}
}
impl ReferralDataState<'_> {
async fn ensure_referral_invite_code(
&self,
user_id: &str,
) -> Result<Option<String>, DataLayerError> {
if let Some(existing) = self.find_referral_invite_code(user_id).await? {
return Ok(Some(existing));
}
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
for _ in 0..5 {
let code = generate_invite_code();
let mut inserted = 0;
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
inserted = sqlx::query(
r#"
INSERT INTO user_invite_codes (user_id, invite_code, active, created_at, updated_at)
VALUES ($1, $2, TRUE, NOW(), NOW())
ON CONFLICT DO NOTHING
"#,
)
.bind(user_id)
.bind(&code)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?
.rows_affected();
}
#[cfg(feature = "mysql")]
if inserted == 0 {
if let Some(backend) = backends.mysql() {
inserted = sqlx::query(
r#"
INSERT IGNORE INTO user_invite_codes (user_id, invite_code, active, created_at, updated_at)
VALUES (?, ?, TRUE, ?, ?)
"#,
)
.bind(user_id)
.bind(&code)
.bind(now_unix_secs() as i64)
.bind(now_unix_secs() as i64)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?
.rows_affected();
}
}
#[cfg(feature = "sqlite")]
if inserted == 0 {
if let Some(backend) = backends.sqlite() {
inserted = sqlx::query(
r#"
INSERT OR IGNORE INTO user_invite_codes (user_id, invite_code, active, created_at, updated_at)
VALUES (?, ?, 1, ?, ?)
"#,
)
.bind(user_id)
.bind(&code)
.bind(now_unix_secs() as i64)
.bind(now_unix_secs() as i64)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?
.rows_affected();
}
}
if inserted > 0 {
return Ok(Some(code));
}
if let Some(existing) = self.find_referral_invite_code(user_id).await? {
return Ok(Some(existing));
}
}
self.find_referral_invite_code(user_id).await
}
async fn find_referral_invite_code(
&self,
user_id: &str,
) -> Result<Option<String>, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let row = sqlx::query(
"SELECT invite_code FROM user_invite_codes WHERE user_id = $1 AND active = TRUE",
)
.bind(user_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?;
return row
.map(|row| {
row.try_get::<String, _>("invite_code")
.map_err(DataLayerError::sql)
})
.transpose();
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let row = sqlx::query(
"SELECT invite_code FROM user_invite_codes WHERE user_id = ? AND active = TRUE",
)
.bind(user_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row
.map(|row| {
row.try_get::<String, _>("invite_code")
.map_err(DataLayerError::sql)
})
.transpose();
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let row = sqlx::query(
"SELECT invite_code FROM user_invite_codes WHERE user_id = ? AND active = 1",
)
.bind(user_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row
.map(|row| {
row.try_get::<String, _>("invite_code")
.map_err(DataLayerError::sql)
})
.transpose();
}
Ok(None)
}
async fn find_referral_inviter_by_code(
&self,
invite_code: &str,
) -> Result<Option<String>, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let row = sqlx::query(
"SELECT user_id FROM user_invite_codes WHERE invite_code = $1 AND active = TRUE",
)
.bind(invite_code)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?;
return row
.map(|row| {
row.try_get::<String, _>("user_id")
.map_err(DataLayerError::sql)
})
.transpose();
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let row = sqlx::query(
"SELECT user_id FROM user_invite_codes WHERE invite_code = ? AND active = TRUE",
)
.bind(invite_code)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row
.map(|row| {
row.try_get::<String, _>("user_id")
.map_err(DataLayerError::sql)
})
.transpose();
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let row = sqlx::query(
"SELECT user_id FROM user_invite_codes WHERE invite_code = ? AND active = 1",
)
.bind(invite_code)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row
.map(|row| {
row.try_get::<String, _>("user_id")
.map_err(DataLayerError::sql)
})
.transpose();
}
Ok(None)
}
async fn insert_referral_relationship(
&self,
referral_id: &str,
inviter_user_id: &str,
invitee_user_id: &str,
invite_code: &str,
source_json: Option<&str>,
) -> Result<bool, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(false);
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let affected = sqlx::query(
r#"
INSERT INTO user_referrals (
id, inviter_user_id, invitee_user_id, invite_code_snapshot, source_json, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5::jsonb, NOW(), NOW())
ON CONFLICT (invitee_user_id) DO NOTHING
"#,
)
.bind(referral_id)
.bind(inviter_user_id)
.bind(invitee_user_id)
.bind(invite_code)
.bind(source_json)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?
.rows_affected();
return Ok(affected > 0);
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let affected = sqlx::query(
r#"
INSERT IGNORE INTO user_referrals (
id, inviter_user_id, invitee_user_id, invite_code_snapshot, source_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(referral_id)
.bind(inviter_user_id)
.bind(invitee_user_id)
.bind(invite_code)
.bind(source_json)
.bind(now_unix_secs() as i64)
.bind(now_unix_secs() as i64)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?
.rows_affected();
return Ok(affected > 0);
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let affected = sqlx::query(
r#"
INSERT OR IGNORE INTO user_referrals (
id, inviter_user_id, invitee_user_id, invite_code_snapshot, source_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(referral_id)
.bind(inviter_user_id)
.bind(invitee_user_id)
.bind(invite_code)
.bind(source_json)
.bind(now_unix_secs() as i64)
.bind(now_unix_secs() as i64)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?
.rows_affected();
return Ok(affected > 0);
}
Ok(false)
}
async fn list_referral_relationships_raw(
&self,
inviter_user_id: Option<&str>,
invitee_user_id: Option<&str>,
) -> Result<Vec<ReferralRelationshipRecord>, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(Vec::new());
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let rows = sqlx::query(
r#"
SELECT
r.id, r.inviter_user_id, inviter.username AS inviter_username,
r.invitee_user_id, invitee.username AS invitee_username,
r.invite_code_snapshot, r.first_paid_order_id,
EXTRACT(EPOCH FROM r.first_paid_at)::BIGINT AS first_paid_at_unix_secs,
r.source_json::TEXT AS source_json,
EXTRACT(EPOCH FROM r.created_at)::BIGINT AS created_at_unix_secs
FROM user_referrals r
LEFT JOIN users inviter ON inviter.id = r.inviter_user_id
LEFT JOIN users invitee ON invitee.id = r.invitee_user_id
WHERE ($1::TEXT IS NULL OR r.inviter_user_id = $1)
AND ($2::TEXT IS NULL OR r.invitee_user_id = $2)
ORDER BY r.created_at DESC
LIMIT $3
"#,
)
.bind(inviter_user_id)
.bind(invitee_user_id)
.bind(REFERRAL_FETCH_LIMIT as i64)
.fetch_all(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?;
return rows.iter().map(|row| relationship_from_row!(row)).collect();
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let rows = sqlx::query(
r#"
SELECT
r.id, r.inviter_user_id, inviter.username AS inviter_username,
r.invitee_user_id, invitee.username AS invitee_username,
r.invite_code_snapshot, r.first_paid_order_id,
r.first_paid_at AS first_paid_at_unix_secs,
r.source_json AS source_json,
r.created_at AS created_at_unix_secs
FROM user_referrals r
LEFT JOIN users inviter ON inviter.id = r.inviter_user_id
LEFT JOIN users invitee ON invitee.id = r.invitee_user_id
WHERE (? IS NULL OR r.inviter_user_id = ?)
AND (? IS NULL OR r.invitee_user_id = ?)
ORDER BY r.created_at DESC
LIMIT ?
"#,
)
.bind(inviter_user_id)
.bind(inviter_user_id)
.bind(invitee_user_id)
.bind(invitee_user_id)
.bind(REFERRAL_FETCH_LIMIT as i64)
.fetch_all(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return rows.iter().map(|row| relationship_from_row!(row)).collect();
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let rows = sqlx::query(
r#"
SELECT
r.id, r.inviter_user_id, inviter.username AS inviter_username,
r.invitee_user_id, invitee.username AS invitee_username,
r.invite_code_snapshot, r.first_paid_order_id,
r.first_paid_at AS first_paid_at_unix_secs,
r.source_json AS source_json,
r.created_at AS created_at_unix_secs
FROM user_referrals r
LEFT JOIN users inviter ON inviter.id = r.inviter_user_id
LEFT JOIN users invitee ON invitee.id = r.invitee_user_id
WHERE (? IS NULL OR r.inviter_user_id = ?)
AND (? IS NULL OR r.invitee_user_id = ?)
ORDER BY r.created_at DESC
LIMIT ?
"#,
)
.bind(inviter_user_id)
.bind(inviter_user_id)
.bind(invitee_user_id)
.bind(invitee_user_id)
.bind(REFERRAL_FETCH_LIMIT as i64)
.fetch_all(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return rows.iter().map(|row| relationship_from_row!(row)).collect();
}
Ok(Vec::new())
}
async fn find_referral_relationship(
&self,
referral_id: &str,
) -> Result<Option<ReferralRelationshipRecord>, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let row = sqlx::query(
r#"
SELECT
r.id, r.inviter_user_id, inviter.username AS inviter_username,
r.invitee_user_id, invitee.username AS invitee_username,
r.invite_code_snapshot, r.first_paid_order_id,
EXTRACT(EPOCH FROM r.first_paid_at)::BIGINT AS first_paid_at_unix_secs,
r.source_json::TEXT AS source_json,
EXTRACT(EPOCH FROM r.created_at)::BIGINT AS created_at_unix_secs
FROM user_referrals r
LEFT JOIN users inviter ON inviter.id = r.inviter_user_id
LEFT JOIN users invitee ON invitee.id = r.invitee_user_id
WHERE r.id = $1
LIMIT 1
"#,
)
.bind(referral_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?;
return row.map(|row| relationship_from_row!(&row)).transpose();
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let row = sqlx::query(
r#"
SELECT
r.id, r.inviter_user_id, inviter.username AS inviter_username,
r.invitee_user_id, invitee.username AS invitee_username,
r.invite_code_snapshot, r.first_paid_order_id,
r.first_paid_at AS first_paid_at_unix_secs,
r.source_json AS source_json,
r.created_at AS created_at_unix_secs
FROM user_referrals r
LEFT JOIN users inviter ON inviter.id = r.inviter_user_id
LEFT JOIN users invitee ON invitee.id = r.invitee_user_id
WHERE r.id = ?
LIMIT 1
"#,
)
.bind(referral_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(|row| relationship_from_row!(&row)).transpose();
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let row = sqlx::query(
r#"
SELECT
r.id, r.inviter_user_id, inviter.username AS inviter_username,
r.invitee_user_id, invitee.username AS invitee_username,
r.invite_code_snapshot, r.first_paid_order_id,
r.first_paid_at AS first_paid_at_unix_secs,
r.source_json AS source_json,
r.created_at AS created_at_unix_secs
FROM user_referrals r
LEFT JOIN users inviter ON inviter.id = r.inviter_user_id
LEFT JOIN users invitee ON invitee.id = r.invitee_user_id
WHERE r.id = ?
LIMIT 1
"#,
)
.bind(referral_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(|row| relationship_from_row!(&row)).transpose();
}
Ok(None)
}
async fn find_referral_relationship_by_invitee(
&self,
invitee_user_id: &str,
) -> Result<Option<ReferralRelationshipRecord>, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let row = sqlx::query(
r#"
SELECT
r.id, r.inviter_user_id, inviter.username AS inviter_username,
r.invitee_user_id, invitee.username AS invitee_username,
r.invite_code_snapshot, r.first_paid_order_id,
EXTRACT(EPOCH FROM r.first_paid_at)::BIGINT AS first_paid_at_unix_secs,
r.source_json::TEXT AS source_json,
EXTRACT(EPOCH FROM r.created_at)::BIGINT AS created_at_unix_secs
FROM user_referrals r
LEFT JOIN users inviter ON inviter.id = r.inviter_user_id
LEFT JOIN users invitee ON invitee.id = r.invitee_user_id
WHERE r.invitee_user_id = $1
LIMIT 1
"#,
)
.bind(invitee_user_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?;
return row.map(|row| relationship_from_row!(&row)).transpose();
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let row = sqlx::query(
r#"
SELECT
r.id, r.inviter_user_id, inviter.username AS inviter_username,
r.invitee_user_id, invitee.username AS invitee_username,
r.invite_code_snapshot, r.first_paid_order_id,
r.first_paid_at AS first_paid_at_unix_secs,
r.source_json AS source_json,
r.created_at AS created_at_unix_secs
FROM user_referrals r
LEFT JOIN users inviter ON inviter.id = r.inviter_user_id
LEFT JOIN users invitee ON invitee.id = r.invitee_user_id
WHERE r.invitee_user_id = ?
LIMIT 1
"#,
)
.bind(invitee_user_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(|row| relationship_from_row!(&row)).transpose();
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let row = sqlx::query(
r#"
SELECT
r.id, r.inviter_user_id, inviter.username AS inviter_username,
r.invitee_user_id, invitee.username AS invitee_username,
r.invite_code_snapshot, r.first_paid_order_id,
r.first_paid_at AS first_paid_at_unix_secs,
r.source_json AS source_json,
r.created_at AS created_at_unix_secs
FROM user_referrals r
LEFT JOIN users inviter ON inviter.id = r.inviter_user_id
LEFT JOIN users invitee ON invitee.id = r.invitee_user_id
WHERE r.invitee_user_id = ?
LIMIT 1
"#,
)
.bind(invitee_user_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(|row| relationship_from_row!(&row)).transpose();
}
Ok(None)
}
async fn list_referral_rewards_raw(
&self,
inviter_user_id: Option<&str>,
) -> Result<Vec<ReferralRewardRecord>, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(Vec::new());
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let rows = sqlx::query(
r#"
SELECT
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key,
reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note,
EXTRACT(EPOCH FROM created_at)::BIGINT AS created_at_unix_secs,
EXTRACT(EPOCH FROM updated_at)::BIGINT AS updated_at_unix_secs
FROM referral_rewards
WHERE ($1::TEXT IS NULL OR inviter_user_id = $1)
ORDER BY created_at DESC
LIMIT $2
"#,
)
.bind(inviter_user_id)
.bind(REFERRAL_FETCH_LIMIT as i64)
.fetch_all(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?;
return rows.iter().map(|row| reward_from_row!(row)).collect();
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let rows = sqlx::query(
r#"
SELECT
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key,
reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM referral_rewards
WHERE (? IS NULL OR inviter_user_id = ?)
ORDER BY created_at DESC
LIMIT ?
"#,
)
.bind(inviter_user_id)
.bind(inviter_user_id)
.bind(REFERRAL_FETCH_LIMIT as i64)
.fetch_all(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return rows.iter().map(|row| reward_from_row!(row)).collect();
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let rows = sqlx::query(
r#"
SELECT
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key,
reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM referral_rewards
WHERE (? IS NULL OR inviter_user_id = ?)
ORDER BY created_at DESC
LIMIT ?
"#,
)
.bind(inviter_user_id)
.bind(inviter_user_id)
.bind(REFERRAL_FETCH_LIMIT as i64)
.fetch_all(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return rows.iter().map(|row| reward_from_row!(row)).collect();
}
Ok(Vec::new())
}
async fn find_referral_reward(
&self,
reward_id: &str,
) -> Result<Option<ReferralRewardRecord>, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let row = sqlx::query(
r#"
SELECT
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key,
reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note,
EXTRACT(EPOCH FROM created_at)::BIGINT AS created_at_unix_secs,
EXTRACT(EPOCH FROM updated_at)::BIGINT AS updated_at_unix_secs
FROM referral_rewards
WHERE id = $1
LIMIT 1
"#,
)
.bind(reward_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?;
return row.map(|row| reward_from_row!(&row)).transpose();
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let row = sqlx::query(
r#"
SELECT
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key,
reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM referral_rewards
WHERE id = ?
LIMIT 1
"#,
)
.bind(reward_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(|row| reward_from_row!(&row)).transpose();
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let row = sqlx::query(
r#"
SELECT
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key,
reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM referral_rewards
WHERE id = ?
LIMIT 1
"#,
)
.bind(reward_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(|row| reward_from_row!(&row)).transpose();
}
Ok(None)
}
async fn find_referral_reward_by_idempotency_key(
&self,
idempotency_key: &str,
) -> Result<Option<ReferralRewardRecord>, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let row = sqlx::query(
r#"
SELECT
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key,
reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note,
EXTRACT(EPOCH FROM created_at)::BIGINT AS created_at_unix_secs,
EXTRACT(EPOCH FROM updated_at)::BIGINT AS updated_at_unix_secs
FROM referral_rewards
WHERE idempotency_key = $1
LIMIT 1
"#,
)
.bind(idempotency_key)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?;
return row.map(|row| reward_from_row!(&row)).transpose();
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let row = sqlx::query(
r#"
SELECT
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key,
reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM referral_rewards
WHERE idempotency_key = ?
LIMIT 1
"#,
)
.bind(idempotency_key)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(|row| reward_from_row!(&row)).transpose();
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let row = sqlx::query(
r#"
SELECT
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key,
reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM referral_rewards
WHERE idempotency_key = ?
LIMIT 1
"#,
)
.bind(idempotency_key)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(|row| reward_from_row!(&row)).transpose();
}
Ok(None)
}
async fn find_applied_referral_rewards_by_order(
&self,
order_id: &str,
) -> Result<Vec<ReferralRewardRecord>, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(Vec::new());
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let rows = sqlx::query(
r#"
SELECT
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key,
reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note,
EXTRACT(EPOCH FROM created_at)::BIGINT AS created_at_unix_secs,
EXTRACT(EPOCH FROM updated_at)::BIGINT AS updated_at_unix_secs
FROM referral_rewards
WHERE source_order_id = $1 AND status = 'applied'
ORDER BY created_at ASC
"#,
)
.bind(order_id)
.fetch_all(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?;
return rows.iter().map(|row| reward_from_row!(row)).collect();
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let rows = sqlx::query(
r#"
SELECT
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key,
reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM referral_rewards
WHERE source_order_id = ? AND status = 'applied'
ORDER BY created_at ASC
"#,
)
.bind(order_id)
.fetch_all(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return rows.iter().map(|row| reward_from_row!(row)).collect();
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let rows = sqlx::query(
r#"
SELECT
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, wallet_transaction_id, idempotency_key,
reversed_amount_usd, pending_reversal_amount_usd, admin_operator_id, admin_note,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM referral_rewards
WHERE source_order_id = ? AND status = 'applied'
ORDER BY created_at ASC
"#,
)
.bind(order_id)
.fetch_all(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return rows.iter().map(|row| reward_from_row!(row)).collect();
}
Ok(Vec::new())
}
async fn insert_referral_reward(
&self,
relationship: &ReferralRelationshipRecord,
reward_type: &str,
source_order_id: Option<&str>,
trigger_point: &str,
amount_usd: f64,
idempotency_key: &str,
) -> Result<bool, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(false);
};
let reward_id = uuid::Uuid::new_v4().to_string();
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let affected = sqlx::query(
r#"
INSERT INTO referral_rewards (
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, idempotency_key, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9, NOW(), NOW())
ON CONFLICT (idempotency_key) DO NOTHING
"#,
)
.bind(&reward_id)
.bind(&relationship.id)
.bind(&relationship.inviter_user_id)
.bind(&relationship.invitee_user_id)
.bind(reward_type)
.bind(source_order_id)
.bind(trigger_point)
.bind(amount_usd)
.bind(idempotency_key)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?
.rows_affected();
return Ok(affected > 0);
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let affected = sqlx::query(
r#"
INSERT IGNORE INTO referral_rewards (
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, idempotency_key, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
"#,
)
.bind(&reward_id)
.bind(&relationship.id)
.bind(&relationship.inviter_user_id)
.bind(&relationship.invitee_user_id)
.bind(reward_type)
.bind(source_order_id)
.bind(trigger_point)
.bind(amount_usd)
.bind(idempotency_key)
.bind(now_unix_secs() as i64)
.bind(now_unix_secs() as i64)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?
.rows_affected();
return Ok(affected > 0);
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let affected = sqlx::query(
r#"
INSERT OR IGNORE INTO referral_rewards (
id, referral_id, inviter_user_id, invitee_user_id, reward_type, source_order_id,
trigger_point, amount_usd, status, idempotency_key, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
"#,
)
.bind(&reward_id)
.bind(&relationship.id)
.bind(&relationship.inviter_user_id)
.bind(&relationship.invitee_user_id)
.bind(reward_type)
.bind(source_order_id)
.bind(trigger_point)
.bind(amount_usd)
.bind(idempotency_key)
.bind(now_unix_secs() as i64)
.bind(now_unix_secs() as i64)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?
.rows_affected();
return Ok(affected > 0);
}
Ok(false)
}
async fn find_referral_payment_order_context(
&self,
order_id: &str,
) -> Result<Option<ReferralPaymentOrderContext>, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let row = sqlx::query(
r#"
SELECT id, user_id, amount_usd, payment_method, status, order_kind
FROM payment_orders
WHERE id = $1
"#,
)
.bind(order_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?;
return row.map(payment_order_context_from_row).transpose();
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let row = sqlx::query(
r#"
SELECT id, user_id, amount_usd, payment_method, status, order_kind
FROM payment_orders
WHERE id = ?
"#,
)
.bind(order_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(payment_order_context_from_row).transpose();
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let row = sqlx::query(
r#"
SELECT id, user_id, amount_usd, payment_method, status, order_kind
FROM payment_orders
WHERE id = ?
"#,
)
.bind(order_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(payment_order_context_from_row).transpose();
}
Ok(None)
}
async fn find_referral_payment_order_refund_context(
&self,
order_id: &str,
) -> Result<Option<ReferralPaymentOrderRefundContext>, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let row = sqlx::query(
r#"
SELECT amount_usd, refunded_amount_usd
FROM payment_orders
WHERE id = $1
"#,
)
.bind(order_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?;
return row.map(payment_order_refund_context_from_row).transpose();
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let row = sqlx::query(
r#"
SELECT amount_usd, refunded_amount_usd
FROM payment_orders
WHERE id = ?
"#,
)
.bind(order_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(payment_order_refund_context_from_row).transpose();
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let row = sqlx::query(
r#"
SELECT amount_usd, refunded_amount_usd
FROM payment_orders
WHERE id = ?
"#,
)
.bind(order_id)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(payment_order_refund_context_from_row).transpose();
}
Ok(None)
}
async fn mark_referral_first_paid_order(
&self,
referral_id: &str,
order_id: &str,
) -> Result<bool, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(false);
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let affected = sqlx::query(
r#"
UPDATE user_referrals
SET first_paid_order_id = $2,
first_paid_at = NOW(),
updated_at = NOW()
WHERE id = $1 AND first_paid_order_id IS NULL
"#,
)
.bind(referral_id)
.bind(order_id)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?
.rows_affected();
return Ok(affected > 0);
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let affected = sqlx::query(
r#"
UPDATE user_referrals
SET first_paid_order_id = ?,
first_paid_at = ?,
updated_at = ?
WHERE id = ? AND first_paid_order_id IS NULL
"#,
)
.bind(order_id)
.bind(now_unix_secs() as i64)
.bind(now_unix_secs() as i64)
.bind(referral_id)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?
.rows_affected();
return Ok(affected > 0);
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let affected = sqlx::query(
r#"
UPDATE user_referrals
SET first_paid_order_id = ?,
first_paid_at = ?,
updated_at = ?
WHERE id = ? AND first_paid_order_id IS NULL
"#,
)
.bind(order_id)
.bind(now_unix_secs() as i64)
.bind(now_unix_secs() as i64)
.bind(referral_id)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?
.rows_affected();
return Ok(affected > 0);
}
Ok(false)
}
async fn update_referral_reward_status(
&self,
reward_id: &str,
status: &str,
operator_id: Option<&str>,
note: Option<&str>,
) -> Result<bool, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(false);
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let affected = sqlx::query(
r#"
UPDATE referral_rewards
SET status = $2,
admin_operator_id = COALESCE($3, admin_operator_id),
admin_note = COALESCE($4, admin_note),
updated_at = NOW()
WHERE id = $1 AND status IN ('pending', 'failed')
"#,
)
.bind(reward_id)
.bind(status)
.bind(operator_id)
.bind(note)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?
.rows_affected();
return Ok(affected > 0);
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let affected = sqlx::query(
r#"
UPDATE referral_rewards
SET status = ?,
admin_operator_id = COALESCE(?, admin_operator_id),
admin_note = COALESCE(?, admin_note),
updated_at = ?
WHERE id = ? AND status IN ('pending', 'failed')
"#,
)
.bind(status)
.bind(operator_id)
.bind(note)
.bind(now_unix_secs() as i64)
.bind(reward_id)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?
.rows_affected();
return Ok(affected > 0);
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let affected = sqlx::query(
r#"
UPDATE referral_rewards
SET status = ?,
admin_operator_id = COALESCE(?, admin_operator_id),
admin_note = COALESCE(?, admin_note),
updated_at = ?
WHERE id = ? AND status IN ('pending', 'failed')
"#,
)
.bind(status)
.bind(operator_id)
.bind(note)
.bind(now_unix_secs() as i64)
.bind(reward_id)
.execute(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?
.rows_affected();
return Ok(affected > 0);
}
Ok(false)
}
async fn credit_pending_referral_rewards(
&self,
idempotency_keys: &[String],
operator_id: Option<&str>,
note: Option<&str>,
) -> Result<Vec<ReferralRewardRecord>, DataLayerError> {
let mut credited = Vec::new();
for key in idempotency_keys {
if let Some(target) = self.referral_credit_target_by_key(key).await? {
self.credit_referral_reward(target, operator_id, note)
.await?;
if let Some(updated) = self.find_referral_reward_by_idempotency_key(key).await? {
credited.push(updated);
}
}
}
Ok(credited)
}
async fn referral_credit_target_by_key(
&self,
idempotency_key: &str,
) -> Result<Option<ReferralCreditTarget>, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
#[cfg(feature = "postgres")]
if let Some(backend) = backends.postgres() {
let row = sqlx::query(
r#"
SELECT
rw.id, rw.inviter_user_id, rw.invitee_user_id, rw.amount_usd, rw.reward_type,
rw.trigger_point, wallets.id AS wallet_id
FROM referral_rewards rw
JOIN wallets ON wallets.user_id = rw.inviter_user_id
WHERE rw.idempotency_key = $1
AND rw.status IN ('pending', 'failed')
"#,
)
.bind(idempotency_key)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::postgres)?;
return row.map(credit_target_from_row).transpose();
}
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
let row = sqlx::query(
r#"
SELECT
rw.id, rw.inviter_user_id, rw.invitee_user_id, rw.amount_usd, rw.reward_type,
rw.trigger_point, wallets.id AS wallet_id
FROM referral_rewards rw
JOIN wallets ON wallets.user_id = rw.inviter_user_id
WHERE rw.idempotency_key = ?
AND rw.status IN ('pending', 'failed')
"#,
)
.bind(idempotency_key)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(credit_target_from_row).transpose();
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
let row = sqlx::query(
r#"
SELECT
rw.id, rw.inviter_user_id, rw.invitee_user_id, rw.amount_usd, rw.reward_type,
rw.trigger_point, wallets.id AS wallet_id
FROM referral_rewards rw
JOIN wallets ON wallets.user_id = rw.inviter_user_id
WHERE rw.idempotency_key = ?
AND rw.status IN ('pending', 'failed')
"#,
)
.bind(idempotency_key)
.fetch_optional(&backend.pool_clone())
.await
.map_err(DataLayerError::sql)?;
return row.map(credit_target_from_row).transpose();
}
Ok(None)
}
async fn credit_referral_reward(
&self,
target: ReferralCreditTarget,
operator_id: Option<&str>,
note: Option<&str>,
) -> Result<(), DataLayerError> {
#[cfg(feature = "postgres")]
if let Some(backend) = self.backends.and_then(DataBackends::postgres) {
let mut tx = backend
.pool_clone()
.begin()
.await
.map_err(DataLayerError::postgres)?;
let claimed = sqlx::query(
r#"
UPDATE referral_rewards
SET status = 'applying',
admin_operator_id = COALESCE($2, admin_operator_id),
admin_note = COALESCE($3, admin_note),
updated_at = NOW()
WHERE id = $1 AND status IN ('pending', 'failed')
"#,
)
.bind(&target.id)
.bind(operator_id)
.bind(note)
.execute(&mut *tx)
.await
.map_err(DataLayerError::postgres)?
.rows_affected();
if claimed == 0 {
tx.commit().await.map_err(DataLayerError::postgres)?;
return Ok(());
}
let wallet = sqlx::query(
r#"
SELECT balance, gift_balance
FROM wallets
WHERE id = $1
FOR UPDATE
"#,
)
.bind(&target.wallet_id)
.fetch_optional(&mut *tx)
.await
.map_err(DataLayerError::postgres)?;
let Some(wallet) = wallet else {
sqlx::query(
r#"
UPDATE referral_rewards
SET status = 'failed',
admin_operator_id = COALESCE($2, admin_operator_id),
admin_note = COALESCE($3, admin_note),
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(&target.id)
.bind(operator_id)
.bind(note.or(Some("邀请人钱包不存在")))
.execute(&mut *tx)
.await
.map_err(DataLayerError::postgres)?;
tx.commit().await.map_err(DataLayerError::postgres)?;
return Ok(());
};
let balance = row_f64!(wallet, "balance");
let gift_before = row_f64!(wallet, "gift_balance");
let gift_after = gift_before + target.amount_usd;
let tx_id = uuid::Uuid::new_v4().to_string();
let description = note
.map(ToOwned::to_owned)
.unwrap_or_else(|| reward_description(&target));
sqlx::query(
r#"
UPDATE wallets
SET gift_balance = $2,
total_adjusted = total_adjusted + $3,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(&target.wallet_id)
.bind(gift_after)
.bind(target.amount_usd)
.execute(&mut *tx)
.await
.map_err(DataLayerError::postgres)?;
sqlx::query(
r#"
INSERT INTO wallet_transactions (
id, wallet_id, category, reason_code, amount,
balance_before, balance_after,
recharge_balance_before, recharge_balance_after,
gift_balance_before, gift_balance_after,
link_type, link_id, operator_id, description, created_at
)
VALUES ($1, $2, 'adjust', 'referral_reward', $3, $4, $5, $6, $6, $7, $8,
'referral_reward', $9, $10, $11, NOW())
"#,
)
.bind(&tx_id)
.bind(&target.wallet_id)
.bind(target.amount_usd)
.bind(balance + gift_before)
.bind(balance + gift_after)
.bind(balance)
.bind(gift_before)
.bind(gift_after)
.bind(&target.id)
.bind(operator_id)
.bind(&description)
.execute(&mut *tx)
.await
.map_err(DataLayerError::postgres)?;
sqlx::query(
r#"
UPDATE referral_rewards
SET status = 'applied',
wallet_transaction_id = $2,
admin_operator_id = COALESCE($3, admin_operator_id),
admin_note = COALESCE($4, admin_note),
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(&target.id)
.bind(&tx_id)
.bind(operator_id)
.bind(note)
.execute(&mut *tx)
.await
.map_err(DataLayerError::postgres)?;
tx.commit().await.map_err(DataLayerError::postgres)?;
return Ok(());
}
#[cfg(feature = "mysql")]
if let Some(backend) = self.backends.and_then(DataBackends::mysql) {
self.credit_referral_reward_mysql_numeric_time(
&backend.pool_clone(),
target,
operator_id,
note,
)
.await?;
return Ok(());
}
#[cfg(feature = "sqlite")]
if let Some(backend) = self.backends.and_then(DataBackends::sqlite) {
self.credit_referral_reward_sqlite_numeric_time(
&backend.pool_clone(),
target,
operator_id,
note,
)
.await?;
return Ok(());
}
Ok(())
}
async fn apply_referral_reward_reversal(
&self,
reward: &ReferralRewardRecord,
target_reversal_amount_usd: f64,
) -> Result<(), DataLayerError> {
#[cfg(feature = "postgres")]
if let Some(backend) = self.backends.and_then(DataBackends::postgres) {
let mut tx = backend
.pool_clone()
.begin()
.await
.map_err(DataLayerError::postgres)?;
let reward_row = sqlx::query(
r#"
SELECT reversed_amount_usd, pending_reversal_amount_usd
FROM referral_rewards
WHERE id = $1
FOR UPDATE
"#,
)
.bind(&reward.id)
.fetch_optional(&mut *tx)
.await
.map_err(DataLayerError::postgres)?;
let Some(reward_row) = reward_row else {
tx.commit().await.map_err(DataLayerError::postgres)?;
return Ok(());
};
let current_reversed = row_f64!(reward_row, "reversed_amount_usd");
let current_pending = row_f64!(reward_row, "pending_reversal_amount_usd");
let amount_usd =
(target_reversal_amount_usd - current_reversed - current_pending).max(0.0);
if amount_usd <= 0.0 {
tx.commit().await.map_err(DataLayerError::postgres)?;
return Ok(());
}
let wallet = sqlx::query(
r#"
SELECT id, balance, gift_balance
FROM wallets
WHERE user_id = $1
FOR UPDATE
"#,
)
.bind(&reward.inviter_user_id)
.fetch_optional(&mut *tx)
.await
.map_err(DataLayerError::postgres)?;
let Some(wallet) = wallet else {
tx.commit().await.map_err(DataLayerError::postgres)?;
return Ok(());
};
let wallet_id = row_string!(wallet, "id");
let balance = row_f64!(wallet, "balance");
let gift_before = row_f64!(wallet, "gift_balance");
let actual_reverse = gift_before.min(amount_usd);
let pending_reverse = (amount_usd - actual_reverse).max(0.0);
let gift_after = gift_before - actual_reverse;
let tx_id = uuid::Uuid::new_v4().to_string();
if actual_reverse > 0.0 {
sqlx::query(
r#"
UPDATE wallets
SET gift_balance = $2,
total_adjusted = total_adjusted - $3,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(&wallet_id)
.bind(gift_after)
.bind(actual_reverse)
.execute(&mut *tx)
.await
.map_err(DataLayerError::postgres)?;
sqlx::query(
r#"
INSERT INTO wallet_transactions (
id, wallet_id, category, reason_code, amount,
balance_before, balance_after,
recharge_balance_before, recharge_balance_after,
gift_balance_before, gift_balance_after,
link_type, link_id, description, created_at
)
VALUES ($1, $2, 'adjust', 'referral_reward_reversal', $3, $4, $5, $6, $6, $7, $8,
'referral_reward', $9, '邀请返利退款冲回', NOW())
"#,
)
.bind(&tx_id)
.bind(&wallet_id)
.bind(-actual_reverse)
.bind(balance + gift_before)
.bind(balance + gift_after)
.bind(balance)
.bind(gift_before)
.bind(gift_after)
.bind(&reward.id)
.execute(&mut *tx)
.await
.map_err(DataLayerError::postgres)?;
}
sqlx::query(
r#"
UPDATE referral_rewards
SET reversed_amount_usd = reversed_amount_usd + $2,
pending_reversal_amount_usd = pending_reversal_amount_usd + $3,
status = CASE
WHEN reversed_amount_usd + $2 >= amount_usd THEN 'reversed'
ELSE status
END,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(&reward.id)
.bind(actual_reverse)
.bind(pending_reverse)
.execute(&mut *tx)
.await
.map_err(DataLayerError::postgres)?;
tx.commit().await.map_err(DataLayerError::postgres)?;
return Ok(());
}
#[cfg(any(feature = "mysql", feature = "sqlite"))]
{
// MySQL/SQLite refunds use integer timestamps in the wallet tables.
return self
.apply_referral_reward_reversal_numeric_time(reward, target_reversal_amount_usd)
.await;
}
#[cfg(not(any(feature = "mysql", feature = "sqlite")))]
Ok(())
}
}
#[cfg(any(feature = "mysql", feature = "sqlite"))]
macro_rules! referral_credit_numeric_method {
($name:ident, $pool_ty:ty, $wallet_sql:expr) => {
async fn $name(
&self,
pool: &$pool_ty,
target: ReferralCreditTarget,
operator_id: Option<&str>,
note: Option<&str>,
) -> Result<(), DataLayerError> {
let mut tx = pool.begin().await.map_err(DataLayerError::sql)?;
let claimed = sqlx::query(
r#"
UPDATE referral_rewards
SET status = 'applying',
admin_operator_id = COALESCE(?, admin_operator_id),
admin_note = COALESCE(?, admin_note),
updated_at = ?
WHERE id = ? AND status IN ('pending', 'failed')
"#,
)
.bind(operator_id)
.bind(note)
.bind(now_unix_secs() as i64)
.bind(&target.id)
.execute(&mut *tx)
.await
.map_err(DataLayerError::sql)?
.rows_affected();
if claimed == 0 {
tx.commit().await.map_err(DataLayerError::sql)?;
return Ok(());
}
let wallet = sqlx::query($wallet_sql)
.bind(&target.wallet_id)
.fetch_optional(&mut *tx)
.await
.map_err(DataLayerError::sql)?;
let Some(wallet) = wallet else {
sqlx::query(
r#"
UPDATE referral_rewards
SET status = 'failed',
admin_operator_id = COALESCE(?, admin_operator_id),
admin_note = COALESCE(?, admin_note),
updated_at = ?
WHERE id = ?
"#,
)
.bind(operator_id)
.bind(note.or(Some("邀请人钱包不存在")))
.bind(now_unix_secs() as i64)
.bind(&target.id)
.execute(&mut *tx)
.await
.map_err(DataLayerError::sql)?;
tx.commit().await.map_err(DataLayerError::sql)?;
return Ok(());
};
let balance = row_f64!(wallet, "balance");
let gift_before = row_f64!(wallet, "gift_balance");
let gift_after = gift_before + target.amount_usd;
let tx_id = uuid::Uuid::new_v4().to_string();
let description = note
.map(ToOwned::to_owned)
.unwrap_or_else(|| reward_description(&target));
sqlx::query(
r#"
UPDATE wallets
SET gift_balance = ?,
total_adjusted = total_adjusted + ?,
updated_at = ?
WHERE id = ?
"#,
)
.bind(gift_after)
.bind(target.amount_usd)
.bind(now_unix_secs() as i64)
.bind(&target.wallet_id)
.execute(&mut *tx)
.await
.map_err(DataLayerError::sql)?;
sqlx::query(
r#"
INSERT INTO wallet_transactions (
id, wallet_id, category, reason_code, amount,
balance_before, balance_after,
recharge_balance_before, recharge_balance_after,
gift_balance_before, gift_balance_after,
link_type, link_id, operator_id, description, created_at
)
VALUES (?, ?, 'adjust', 'referral_reward', ?, ?, ?, ?, ?, ?, ?,
'referral_reward', ?, ?, ?, ?)
"#,
)
.bind(&tx_id)
.bind(&target.wallet_id)
.bind(target.amount_usd)
.bind(balance + gift_before)
.bind(balance + gift_after)
.bind(balance)
.bind(balance)
.bind(gift_before)
.bind(gift_after)
.bind(&target.id)
.bind(operator_id)
.bind(&description)
.bind(now_unix_ms() as i64)
.execute(&mut *tx)
.await
.map_err(DataLayerError::sql)?;
sqlx::query(
r#"
UPDATE referral_rewards
SET status = 'applied',
wallet_transaction_id = ?,
admin_operator_id = COALESCE(?, admin_operator_id),
admin_note = COALESCE(?, admin_note),
updated_at = ?
WHERE id = ?
"#,
)
.bind(&tx_id)
.bind(operator_id)
.bind(note)
.bind(now_unix_secs() as i64)
.bind(&target.id)
.execute(&mut *tx)
.await
.map_err(DataLayerError::sql)?;
tx.commit().await.map_err(DataLayerError::sql)?;
Ok(())
}
};
}
impl ReferralDataState<'_> {
#[cfg(feature = "mysql")]
referral_credit_numeric_method!(
credit_referral_reward_mysql_numeric_time,
sqlx::MySqlPool,
"SELECT balance, gift_balance FROM wallets WHERE id = ? FOR UPDATE"
);
#[cfg(feature = "sqlite")]
referral_credit_numeric_method!(
credit_referral_reward_sqlite_numeric_time,
sqlx::SqlitePool,
"SELECT balance, gift_balance FROM wallets WHERE id = ?"
);
#[cfg(any(feature = "mysql", feature = "sqlite"))]
async fn apply_referral_reward_reversal_numeric_time(
&self,
reward: &ReferralRewardRecord,
target_reversal_amount_usd: f64,
) -> Result<(), DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(());
};
#[cfg(feature = "mysql")]
if let Some(backend) = backends.mysql() {
return apply_referral_reward_reversal_for_mysql_pool(
&backend.pool_clone(),
reward,
target_reversal_amount_usd,
)
.await;
}
#[cfg(feature = "sqlite")]
if let Some(backend) = backends.sqlite() {
return apply_referral_reward_reversal_for_sqlite_pool(
&backend.pool_clone(),
reward,
target_reversal_amount_usd,
)
.await;
}
Ok(())
}
}
fn payment_order_context_from_row<R>(row: R) -> Result<ReferralPaymentOrderContext, DataLayerError>
where
R: Row,
for<'c> &'c str: sqlx::ColumnIndex<R>,
for<'r> String: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
for<'r> Option<String>: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
for<'r> f64: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
{
let Some(user_id) = row_optional_string!(row, "user_id") else {
return Err(DataLayerError::InvalidInput(
"payment order has no user_id".to_string(),
));
};
Ok(ReferralPaymentOrderContext {
id: row_string!(row, "id"),
user_id,
amount_usd: row_f64!(row, "amount_usd"),
payment_method: row_string!(row, "payment_method"),
status: row_string!(row, "status"),
order_kind: row_string!(row, "order_kind"),
})
}
fn payment_order_refund_context_from_row<R>(
row: R,
) -> Result<ReferralPaymentOrderRefundContext, DataLayerError>
where
R: Row,
for<'c> &'c str: sqlx::ColumnIndex<R>,
for<'r> f64: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
{
Ok(ReferralPaymentOrderRefundContext {
amount_usd: row_f64!(row, "amount_usd"),
refunded_amount_usd: row_f64!(row, "refunded_amount_usd"),
})
}
fn credit_target_from_row<R>(row: R) -> Result<ReferralCreditTarget, DataLayerError>
where
R: Row,
for<'c> &'c str: sqlx::ColumnIndex<R>,
for<'r> String: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
for<'r> f64: sqlx::Decode<'r, R::Database> + sqlx::Type<R::Database>,
{
Ok(ReferralCreditTarget {
id: row_string!(row, "id"),
wallet_id: row_string!(row, "wallet_id"),
amount_usd: row_f64!(row, "amount_usd"),
reward_type: row_string!(row, "reward_type"),
})
}
#[cfg(any(feature = "mysql", feature = "sqlite"))]
macro_rules! referral_reversal_numeric_fn {
($name:ident, $pool_ty:ty, $wallet_sql:expr) => {
async fn $name(
pool: &$pool_ty,
reward: &ReferralRewardRecord,
target_reversal_amount_usd: f64,
) -> Result<(), DataLayerError> {
let mut tx = pool.begin().await.map_err(DataLayerError::sql)?;
sqlx::query("UPDATE referral_rewards SET updated_at = updated_at WHERE id = ?")
.bind(&reward.id)
.execute(&mut *tx)
.await
.map_err(DataLayerError::sql)?;
let reward_row = sqlx::query(
r#"
SELECT reversed_amount_usd, pending_reversal_amount_usd
FROM referral_rewards
WHERE id = ?
"#,
)
.bind(&reward.id)
.fetch_optional(&mut *tx)
.await
.map_err(DataLayerError::sql)?;
let Some(reward_row) = reward_row else {
tx.commit().await.map_err(DataLayerError::sql)?;
return Ok(());
};
let current_reversed = row_f64!(reward_row, "reversed_amount_usd");
let current_pending = row_f64!(reward_row, "pending_reversal_amount_usd");
let amount_usd =
(target_reversal_amount_usd - current_reversed - current_pending).max(0.0);
if amount_usd <= 0.0 {
tx.commit().await.map_err(DataLayerError::sql)?;
return Ok(());
}
let wallet = sqlx::query($wallet_sql)
.bind(&reward.inviter_user_id)
.fetch_optional(&mut *tx)
.await
.map_err(DataLayerError::sql)?;
let Some(wallet) = wallet else {
tx.commit().await.map_err(DataLayerError::sql)?;
return Ok(());
};
let wallet_id = row_string!(wallet, "id");
let balance = row_f64!(wallet, "balance");
let gift_before = row_f64!(wallet, "gift_balance");
let actual_reverse = gift_before.min(amount_usd);
let pending_reverse = (amount_usd - actual_reverse).max(0.0);
let gift_after = gift_before - actual_reverse;
if actual_reverse > 0.0 {
sqlx::query(
r#"
UPDATE wallets
SET gift_balance = ?,
total_adjusted = total_adjusted - ?,
updated_at = ?
WHERE id = ?
"#,
)
.bind(gift_after)
.bind(actual_reverse)
.bind(now_unix_secs() as i64)
.bind(&wallet_id)
.execute(&mut *tx)
.await
.map_err(DataLayerError::sql)?;
sqlx::query(
r#"
INSERT INTO wallet_transactions (
id, wallet_id, category, reason_code, amount,
balance_before, balance_after,
recharge_balance_before, recharge_balance_after,
gift_balance_before, gift_balance_after,
link_type, link_id, description, created_at
)
VALUES (?, ?, 'adjust', 'referral_reward_reversal', ?, ?, ?, ?, ?, ?, ?,
'referral_reward', ?, '邀请返利退款冲回', ?)
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&wallet_id)
.bind(-actual_reverse)
.bind(balance + gift_before)
.bind(balance + gift_after)
.bind(balance)
.bind(balance)
.bind(gift_before)
.bind(gift_after)
.bind(&reward.id)
.bind(now_unix_ms() as i64)
.execute(&mut *tx)
.await
.map_err(DataLayerError::sql)?;
}
sqlx::query(
r#"
UPDATE referral_rewards
SET reversed_amount_usd = reversed_amount_usd + ?,
pending_reversal_amount_usd = pending_reversal_amount_usd + ?,
status = CASE
WHEN reversed_amount_usd + ? >= amount_usd THEN 'reversed'
ELSE status
END,
updated_at = ?
WHERE id = ?
"#,
)
.bind(actual_reverse)
.bind(pending_reverse)
.bind(actual_reverse)
.bind(now_unix_secs() as i64)
.bind(&reward.id)
.execute(&mut *tx)
.await
.map_err(DataLayerError::sql)?;
tx.commit().await.map_err(DataLayerError::sql)?;
Ok(())
}
};
}
#[cfg(feature = "mysql")]
referral_reversal_numeric_fn!(
apply_referral_reward_reversal_for_mysql_pool,
sqlx::MySqlPool,
"SELECT id, balance, gift_balance FROM wallets WHERE user_id = ? FOR UPDATE"
);
#[cfg(feature = "sqlite")]
referral_reversal_numeric_fn!(
apply_referral_reward_reversal_for_sqlite_pool,
sqlx::SqlitePool,
"SELECT id, balance, gift_balance FROM wallets WHERE user_id = ?"
);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn referral_retry_only_allows_failed_rewards() {
assert!(referral_retry_allowed("failed"));
for status in ["pending", "applied", "reversed", "voided"] {
assert!(!referral_retry_allowed(status), "{status} must not retry");
}
}
#[test]
fn referral_void_only_allows_pending_and_failed_rewards() {
assert!(referral_void_allowed("pending"));
assert!(referral_void_allowed("failed"));
for status in ["applied", "reversed", "voided"] {
assert!(!referral_void_allowed(status), "{status} must not void");
}
}
#[test]
fn referral_reversal_delta_uses_cumulative_refund_ratio() {
let first = referral_reversal_delta(10.0, 100.0, 20.0, 0.0, 0.0);
assert!((first - 2.0).abs() < f64::EPSILON);
let second = referral_reversal_delta(10.0, 100.0, 50.0, 2.0, 0.0);
assert!((second - 3.0).abs() < f64::EPSILON);
let repeated = referral_reversal_delta(10.0, 100.0, 50.0, 2.0, 3.0);
assert_eq!(repeated, 0.0);
let full = referral_reversal_delta(10.0, 100.0, 125.0, 5.0, 0.0);
assert!((full - 5.0).abs() < f64::EPSILON);
}
}