feat: add payment gateway and billing plans

This commit is contained in:
Entropy.Xu
2026-05-13 01:18:38 +08:00
parent 0fa97595bf
commit 10285c5eb9
97 changed files with 14797 additions and 404 deletions

View File

@@ -3,7 +3,11 @@ use std::sync::RwLock;
use async_trait::async_trait;
use super::{BillingReadRepository, StoredBillingModelContext};
use super::{
AdminBillingMutationOutcome, BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository,
PaymentGatewayConfigRecord, PaymentGatewayConfigWriteInput, StoredBillingModelContext,
UserDailyQuotaAvailabilityRecord, UserPlanEntitlementRecord,
};
use crate::DataLayerError;
type BillingContextKey = (String, String, Option<String>);
@@ -12,6 +16,9 @@ type BillingContextMap = BTreeMap<BillingContextKey, StoredBillingModelContext>;
#[derive(Debug, Default)]
pub struct InMemoryBillingReadRepository {
by_key: RwLock<BillingContextMap>,
gateway_configs_by_provider: RwLock<BTreeMap<String, PaymentGatewayConfigRecord>>,
billing_plans_by_id: RwLock<BTreeMap<String, BillingPlanRecord>>,
entitlements_by_id: RwLock<BTreeMap<String, UserPlanEntitlementRecord>>,
}
impl InMemoryBillingReadRepository {
@@ -32,10 +39,88 @@ impl InMemoryBillingReadRepository {
}
Self {
by_key: RwLock::new(by_key),
gateway_configs_by_provider: RwLock::new(BTreeMap::new()),
billing_plans_by_id: RwLock::new(BTreeMap::new()),
entitlements_by_id: RwLock::new(BTreeMap::new()),
}
}
}
fn current_unix_secs() -> u64 {
chrono::Utc::now().timestamp().max(0) as u64
}
fn billing_plan_from_input(
id: String,
input: &BillingPlanWriteInput,
created_at: u64,
) -> BillingPlanRecord {
BillingPlanRecord {
id,
title: input.title.clone(),
description: input.description.clone(),
price_amount: input.price_amount,
price_currency: input.price_currency.clone(),
duration_unit: input.duration_unit.clone(),
duration_value: input.duration_value,
enabled: input.enabled,
sort_order: input.sort_order,
max_active_per_user: input.max_active_per_user,
purchase_limit_scope: input.purchase_limit_scope.clone(),
entitlements_json: input.entitlements_json.clone(),
created_at_unix_secs: created_at,
updated_at_unix_secs: current_unix_secs(),
}
}
fn daily_quota_availability_from_entitlements(
entitlements: impl IntoIterator<Item = UserPlanEntitlementRecord>,
now: u64,
) -> UserDailyQuotaAvailabilityRecord {
let mut has_active_daily_quota = false;
let mut total_quota_usd = 0.0;
let used_usd = 0.0;
let mut remaining_usd = 0.0;
let mut allow_wallet_overage = true;
for entitlement in entitlements {
if entitlement.status != "active"
|| entitlement.starts_at_unix_secs > now
|| entitlement.expires_at_unix_secs <= now
{
continue;
}
let Some(items) = entitlement.entitlements_snapshot.as_array() else {
continue;
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
has_active_daily_quota = true;
total_quota_usd += daily_quota_usd;
remaining_usd += daily_quota_usd;
allow_wallet_overage &= item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
}
}
UserDailyQuotaAvailabilityRecord {
has_active_daily_quota,
total_quota_usd,
used_usd,
remaining_usd,
allow_wallet_overage,
}
}
#[async_trait]
impl BillingReadRepository for InMemoryBillingReadRepository {
async fn find_model_context(
@@ -101,6 +186,204 @@ impl BillingReadRepository for InMemoryBillingReadRepository {
})
.map(|(_, value)| value.clone()))
}
async fn find_payment_gateway_config(
&self,
provider: &str,
) -> Result<Option<PaymentGatewayConfigRecord>, DataLayerError> {
Ok(self
.gateway_configs_by_provider
.read()
.expect("billing repository lock")
.get(&provider.trim().to_ascii_lowercase())
.cloned())
}
async fn upsert_payment_gateway_config(
&self,
input: &PaymentGatewayConfigWriteInput,
) -> Result<AdminBillingMutationOutcome<PaymentGatewayConfigRecord>, DataLayerError> {
let provider = input.provider.trim().to_ascii_lowercase();
let now = current_unix_secs();
let mut configs = self
.gateway_configs_by_provider
.write()
.expect("billing repository lock");
let created_at = configs
.get(&provider)
.map(|value| value.created_at_unix_secs)
.unwrap_or(now);
let merchant_key_encrypted = if input.preserve_existing_secret {
configs
.get(&provider)
.and_then(|value| value.merchant_key_encrypted.clone())
} else {
input.merchant_key_encrypted.clone()
};
let record = PaymentGatewayConfigRecord {
provider: provider.clone(),
enabled: input.enabled,
endpoint_url: input.endpoint_url.clone(),
callback_base_url: input.callback_base_url.clone(),
merchant_id: input.merchant_id.clone(),
merchant_key_encrypted,
pay_currency: input.pay_currency.clone(),
usd_exchange_rate: input.usd_exchange_rate,
min_recharge_usd: input.min_recharge_usd,
channels_json: input.channels_json.clone(),
created_at_unix_secs: created_at,
updated_at_unix_secs: now,
};
configs.insert(provider, record.clone());
Ok(AdminBillingMutationOutcome::Applied(record))
}
async fn list_billing_plans(
&self,
include_disabled: bool,
) -> Result<Option<Vec<BillingPlanRecord>>, DataLayerError> {
let mut items = self
.billing_plans_by_id
.read()
.expect("billing repository lock")
.values()
.filter(|item| include_disabled || item.enabled)
.cloned()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
left.sort_order
.cmp(&right.sort_order)
.then_with(|| left.price_amount.total_cmp(&right.price_amount))
.then_with(|| left.id.cmp(&right.id))
});
Ok(Some(items))
}
async fn find_billing_plan(
&self,
plan_id: &str,
) -> Result<Option<BillingPlanRecord>, DataLayerError> {
Ok(self
.billing_plans_by_id
.read()
.expect("billing repository lock")
.get(plan_id)
.cloned())
}
async fn create_billing_plan(
&self,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let id = uuid::Uuid::new_v4().to_string();
let record = billing_plan_from_input(id.clone(), input, current_unix_secs());
self.billing_plans_by_id
.write()
.expect("billing repository lock")
.insert(id, record.clone());
Ok(AdminBillingMutationOutcome::Applied(record))
}
async fn update_billing_plan(
&self,
plan_id: &str,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let mut plans = self
.billing_plans_by_id
.write()
.expect("billing repository lock");
let Some(existing) = plans.get(plan_id).cloned() else {
return Ok(AdminBillingMutationOutcome::NotFound);
};
let record =
billing_plan_from_input(plan_id.to_string(), input, existing.created_at_unix_secs);
plans.insert(plan_id.to_string(), record.clone());
Ok(AdminBillingMutationOutcome::Applied(record))
}
async fn set_billing_plan_enabled(
&self,
plan_id: &str,
enabled: bool,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let mut plans = self
.billing_plans_by_id
.write()
.expect("billing repository lock");
let Some(record) = plans.get_mut(plan_id) else {
return Ok(AdminBillingMutationOutcome::NotFound);
};
record.enabled = enabled;
record.updated_at_unix_secs = current_unix_secs();
Ok(AdminBillingMutationOutcome::Applied(record.clone()))
}
async fn delete_billing_plan(
&self,
plan_id: &str,
) -> Result<AdminBillingMutationOutcome<()>, DataLayerError> {
let mut plans = self
.billing_plans_by_id
.write()
.expect("billing repository lock");
if !plans.contains_key(plan_id) {
return Ok(AdminBillingMutationOutcome::NotFound);
}
let has_entitlements = self
.entitlements_by_id
.read()
.expect("billing repository lock")
.values()
.any(|item| item.plan_id == plan_id);
if has_entitlements {
return Ok(AdminBillingMutationOutcome::Invalid(
"套餐已有订单或权益,不能删除,请停用该套餐".to_string(),
));
}
plans.remove(plan_id);
Ok(AdminBillingMutationOutcome::Applied(()))
}
async fn list_user_plan_entitlements(
&self,
user_id: &str,
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
let now = current_unix_secs();
let mut items = self
.entitlements_by_id
.read()
.expect("billing repository lock")
.values()
.filter(|item| {
item.user_id == user_id
&& item.status == "active"
&& item.expires_at_unix_secs > now
})
.cloned()
.collect::<Vec<_>>();
items.sort_by_key(|item| item.expires_at_unix_secs);
Ok(Some(items))
}
async fn find_user_daily_quota_availability(
&self,
user_id: &str,
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, DataLayerError> {
let now = current_unix_secs();
let entitlements = self
.entitlements_by_id
.read()
.expect("billing repository lock")
.values()
.filter(|item| item.user_id == user_id)
.cloned()
.collect::<Vec<_>>();
Ok(Some(daily_quota_availability_from_entitlements(
entitlements,
now,
)))
}
}
fn find_context_by_provider_model_name(

View File

@@ -7,7 +7,9 @@ mod sqlite;
pub(crate) use aether_data_contracts::repository::billing::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput,
BillingReadRepository, StoredBillingModelContext,
BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository, PaymentGatewayConfigRecord,
PaymentGatewayConfigWriteInput, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
UserPlanEntitlementRecord,
};
pub use memory::InMemoryBillingReadRepository;
pub use mysql::MysqlBillingReadRepository;

View File

@@ -4,7 +4,9 @@ use sqlx::{mysql::MySqlRow, Row};
use super::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput,
BillingReadRepository, StoredBillingModelContext,
BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository, PaymentGatewayConfigRecord,
PaymentGatewayConfigWriteInput, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
UserPlanEntitlementRecord,
};
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
@@ -609,8 +611,409 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
},
))
}
async fn find_payment_gateway_config(
&self,
provider: &str,
) -> Result<Option<PaymentGatewayConfigRecord>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency, usd_exchange_rate, min_recharge_usd,
channels_json, created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM payment_gateway_configs
WHERE provider = ?
LIMIT 1
"#,
)
.bind(provider.trim().to_ascii_lowercase())
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref()
.map(map_payment_gateway_config_mysql)
.transpose()
}
async fn upsert_payment_gateway_config(
&self,
input: &PaymentGatewayConfigWriteInput,
) -> Result<AdminBillingMutationOutcome<PaymentGatewayConfigRecord>, DataLayerError> {
let provider = input.provider.trim().to_ascii_lowercase();
let existing_secret = if input.preserve_existing_secret {
sqlx::query_scalar::<_, String>(
"SELECT merchant_key_encrypted FROM payment_gateway_configs WHERE provider = ?",
)
.bind(&provider)
.fetch_optional(&self.pool)
.await
.map_sql_err()?
} else {
None
};
let secret = if input.preserve_existing_secret {
existing_secret
} else {
input.merchant_key_encrypted.clone()
};
let now = current_unix_secs_i64();
sqlx::query(
r#"
INSERT INTO payment_gateway_configs (
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency, usd_exchange_rate, min_recharge_usd,
channels_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
endpoint_url = VALUES(endpoint_url),
callback_base_url = VALUES(callback_base_url),
merchant_id = VALUES(merchant_id),
merchant_key_encrypted = VALUES(merchant_key_encrypted),
pay_currency = VALUES(pay_currency),
usd_exchange_rate = VALUES(usd_exchange_rate),
min_recharge_usd = VALUES(min_recharge_usd),
channels_json = VALUES(channels_json),
updated_at = VALUES(updated_at)
"#,
)
.bind(&provider)
.bind(input.enabled)
.bind(&input.endpoint_url)
.bind(input.callback_base_url.as_deref())
.bind(&input.merchant_id)
.bind(secret.as_deref())
.bind(&input.pay_currency)
.bind(input.usd_exchange_rate)
.bind(input.min_recharge_usd)
.bind(json_to_string(&input.channels_json)?)
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_sql_err()?;
match self.find_payment_gateway_config(&provider).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Err(DataLayerError::UnexpectedValue(
"upserted payment gateway config missing".to_string(),
)),
}
}
async fn list_billing_plans(
&self,
include_disabled: bool,
) -> Result<Option<Vec<BillingPlanRecord>>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM billing_plans
WHERE (? = TRUE OR enabled = TRUE)
ORDER BY sort_order ASC, price_amount ASC, id ASC
"#,
)
.bind(include_disabled)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
Ok(Some(
rows.iter()
.map(map_billing_plan_mysql)
.collect::<Result<Vec<_>, _>>()?,
))
}
async fn find_billing_plan(
&self,
plan_id: &str,
) -> Result<Option<BillingPlanRecord>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM billing_plans
WHERE id = ?
LIMIT 1
"#,
)
.bind(plan_id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref().map(map_billing_plan_mysql).transpose()
}
async fn create_billing_plan(
&self,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let id = uuid::Uuid::new_v4().to_string();
let now = current_unix_secs_i64();
sqlx::query(BILLING_PLAN_INSERT_MYSQL)
.bind(&id)
.bind(&input.title)
.bind(input.description.as_deref())
.bind(input.price_amount)
.bind(&input.price_currency)
.bind(&input.duration_unit)
.bind(input.duration_value)
.bind(input.enabled)
.bind(input.sort_order)
.bind(input.max_active_per_user)
.bind(&input.purchase_limit_scope)
.bind(json_to_string(&input.entitlements_json)?)
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_sql_err()?;
match self.find_billing_plan(&id).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Err(DataLayerError::UnexpectedValue(
"created billing plan missing".to_string(),
)),
}
}
async fn update_billing_plan(
&self,
plan_id: &str,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let result = sqlx::query(BILLING_PLAN_UPDATE_MYSQL)
.bind(&input.title)
.bind(input.description.as_deref())
.bind(input.price_amount)
.bind(&input.price_currency)
.bind(&input.duration_unit)
.bind(input.duration_value)
.bind(input.enabled)
.bind(input.sort_order)
.bind(input.max_active_per_user)
.bind(&input.purchase_limit_scope)
.bind(json_to_string(&input.entitlements_json)?)
.bind(current_unix_secs_i64())
.bind(plan_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
match self.find_billing_plan(plan_id).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Ok(AdminBillingMutationOutcome::NotFound),
}
}
async fn set_billing_plan_enabled(
&self,
plan_id: &str,
enabled: bool,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let result =
sqlx::query("UPDATE billing_plans SET enabled = ?, updated_at = ? WHERE id = ?")
.bind(enabled)
.bind(current_unix_secs_i64())
.bind(plan_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
match self.find_billing_plan(plan_id).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Ok(AdminBillingMutationOutcome::NotFound),
}
}
async fn delete_billing_plan(
&self,
plan_id: &str,
) -> Result<AdminBillingMutationOutcome<()>, DataLayerError> {
let exists =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM billing_plans WHERE id = ?")
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
if exists == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
let order_count = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM payment_orders
WHERE product_id = ?
AND order_kind = 'plan_purchase'
"#,
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
let entitlement_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM user_plan_entitlements WHERE plan_id = ?",
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
if order_count > 0 || entitlement_count > 0 {
return Ok(AdminBillingMutationOutcome::Invalid(
"套餐已有订单或权益,不能删除,请停用该套餐".to_string(),
));
}
let result = sqlx::query("DELETE FROM billing_plans WHERE id = ?")
.bind(plan_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
Ok(AdminBillingMutationOutcome::NotFound)
} else {
Ok(AdminBillingMutationOutcome::Applied(()))
}
}
async fn list_user_plan_entitlements(
&self,
user_id: &str,
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT
id, user_id, plan_id, payment_order_id, status,
starts_at AS starts_at_unix_secs, expires_at AS expires_at_unix_secs,
entitlements_snapshot, created_at AS created_at_unix_secs,
updated_at AS updated_at_unix_secs
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND expires_at > ?
ORDER BY expires_at ASC, created_at ASC
"#,
)
.bind(user_id)
.bind(current_unix_secs_i64())
.fetch_all(&self.pool)
.await
.map_sql_err()?;
Ok(Some(
rows.iter()
.map(map_user_plan_entitlement_mysql)
.collect::<Result<Vec<_>, _>>()?,
))
}
async fn find_user_daily_quota_availability(
&self,
user_id: &str,
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, DataLayerError> {
let now_unix_secs = current_unix_secs_i64();
let rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND starts_at <= ?
AND expires_at > ?
ORDER BY expires_at ASC, created_at ASC, id ASC
"#,
)
.bind(user_id)
.bind(now_unix_secs)
.bind(now_unix_secs)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
let now = chrono::Utc::now();
let mut grants = Vec::new();
for row in rows {
let entitlement_id: String = row.try_get("id").map_sql_err()?;
let entitlements = parse_json(row.try_get("entitlements_snapshot").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([]));
grants.extend(daily_quota_grants_from_entitlement(
&entitlement_id,
&entitlements,
now,
)?);
}
let mut total_quota_usd = 0.0;
let mut used_usd = 0.0;
let mut remaining_usd = 0.0;
let mut allow_wallet_overage = true;
for grant in &grants {
allow_wallet_overage &= grant.allow_wallet_overage;
let used = sqlx::query_scalar::<_, f64>(
r#"
SELECT COALESCE(SUM(amount_usd), 0)
FROM entitlement_usage_ledgers
WHERE user_entitlement_id = ?
AND usage_date = ?
"#,
)
.bind(&grant.entitlement_id)
.bind(&grant.usage_date)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
total_quota_usd += grant.daily_quota_usd;
used_usd += used.min(grant.daily_quota_usd).max(0.0);
remaining_usd += (grant.daily_quota_usd - used).max(0.0);
}
let has_active_daily_quota = !grants.is_empty();
Ok(Some(UserDailyQuotaAvailabilityRecord {
has_active_daily_quota,
total_quota_usd,
used_usd,
remaining_usd,
allow_wallet_overage: has_active_daily_quota && allow_wallet_overage,
}))
}
}
const BILLING_PLAN_INSERT_MYSQL: &str = r#"
INSERT INTO billing_plans (
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json,
created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#;
const BILLING_PLAN_UPDATE_MYSQL: &str = r#"
UPDATE billing_plans
SET title = ?,
description = ?,
price_amount = ?,
price_currency = ?,
duration_unit = ?,
duration_value = ?,
enabled = ?,
sort_order = ?,
max_active_per_user = ?,
purchase_limit_scope = ?,
entitlements_json = ?,
updated_at = ?
WHERE id = ?
"#;
struct RankedContext {
rank: u8,
is_available: bool,
@@ -751,10 +1154,153 @@ fn json_to_string(value: &serde_json::Value) -> Result<String, DataLayerError> {
})
}
#[derive(Debug)]
struct DailyQuotaGrant {
entitlement_id: String,
daily_quota_usd: f64,
usage_date: String,
allow_wallet_overage: bool,
}
fn daily_quota_usage_date(
reset_timezone: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<String, DataLayerError> {
let timezone = reset_timezone
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Asia/Shanghai")
.parse::<chrono_tz::Tz>()
.map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?;
Ok(now.with_timezone(&timezone).date_naive().to_string())
}
fn daily_quota_grants_from_entitlement(
entitlement_id: &str,
entitlements: &serde_json::Value,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<DailyQuotaGrant>, DataLayerError> {
let mut grants = Vec::new();
let Some(items) = entitlements.as_array() else {
return Ok(grants);
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
grants.push(DailyQuotaGrant {
entitlement_id: entitlement_id.to_string(),
daily_quota_usd,
usage_date: daily_quota_usage_date(
item.get("reset_timezone")
.and_then(serde_json::Value::as_str),
now,
)?,
allow_wallet_overage: item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
});
}
Ok(grants)
}
fn read_count_mysql(row: &MySqlRow) -> Result<u64, DataLayerError> {
Ok(row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64)
}
fn map_payment_gateway_config_mysql(
row: &MySqlRow,
) -> Result<PaymentGatewayConfigRecord, DataLayerError> {
Ok(PaymentGatewayConfigRecord {
provider: row.try_get("provider").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?,
endpoint_url: row.try_get("endpoint_url").map_sql_err()?,
callback_base_url: row.try_get("callback_base_url").map_sql_err()?,
merchant_id: row.try_get("merchant_id").map_sql_err()?,
merchant_key_encrypted: row.try_get("merchant_key_encrypted").map_sql_err()?,
pay_currency: row.try_get("pay_currency").map_sql_err()?,
usd_exchange_rate: row.try_get("usd_exchange_rate").map_sql_err()?,
min_recharge_usd: row.try_get("min_recharge_usd").map_sql_err()?,
channels_json: parse_json(row.try_get("channels_json").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
})
}
fn map_billing_plan_mysql(row: &MySqlRow) -> Result<BillingPlanRecord, DataLayerError> {
Ok(BillingPlanRecord {
id: row.try_get("id").map_sql_err()?,
title: row.try_get("title").map_sql_err()?,
description: row.try_get("description").map_sql_err()?,
price_amount: row.try_get("price_amount").map_sql_err()?,
price_currency: row.try_get("price_currency").map_sql_err()?,
duration_unit: row.try_get("duration_unit").map_sql_err()?,
duration_value: row.try_get("duration_value").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?,
sort_order: row.try_get("sort_order").map_sql_err()?,
max_active_per_user: row.try_get("max_active_per_user").map_sql_err()?,
purchase_limit_scope: row
.try_get::<Option<String>, _>("purchase_limit_scope")
.map_sql_err()?
.unwrap_or_else(|| "active_period".to_string()),
entitlements_json: parse_json(row.try_get("entitlements_json").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
})
}
fn map_user_plan_entitlement_mysql(
row: &MySqlRow,
) -> Result<UserPlanEntitlementRecord, DataLayerError> {
Ok(UserPlanEntitlementRecord {
id: row.try_get("id").map_sql_err()?,
user_id: row.try_get("user_id").map_sql_err()?,
plan_id: row.try_get("plan_id").map_sql_err()?,
payment_order_id: row.try_get("payment_order_id").map_sql_err()?,
status: row.try_get("status").map_sql_err()?,
starts_at_unix_secs: row
.try_get::<i64, _>("starts_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
expires_at_unix_secs: row
.try_get::<i64, _>("expires_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
entitlements_snapshot: parse_json(row.try_get("entitlements_snapshot").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
})
}
async fn find_admin_billing_rule_mysql(
pool: &MysqlPool,
rule_id: &str,

View File

@@ -4,7 +4,9 @@ use sqlx::{PgPool, Row};
use super::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput,
BillingReadRepository, StoredBillingModelContext,
BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository, PaymentGatewayConfigRecord,
PaymentGatewayConfigWriteInput, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
UserPlanEntitlementRecord,
};
use crate::{error::SqlxResultExt, DataLayerError};
@@ -687,8 +689,422 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW())
},
))
}
async fn find_payment_gateway_config(
&self,
provider: &str,
) -> Result<Option<PaymentGatewayConfigRecord>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency,
CAST(usd_exchange_rate AS DOUBLE PRECISION) AS usd_exchange_rate,
CAST(min_recharge_usd AS DOUBLE PRECISION) AS min_recharge_usd,
channels_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
FROM payment_gateway_configs
WHERE provider = $1
LIMIT 1
"#,
)
.bind(provider.trim().to_ascii_lowercase())
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
row.as_ref().map(map_payment_gateway_config_row).transpose()
}
async fn upsert_payment_gateway_config(
&self,
input: &PaymentGatewayConfigWriteInput,
) -> Result<AdminBillingMutationOutcome<PaymentGatewayConfigRecord>, DataLayerError> {
let provider = input.provider.trim().to_ascii_lowercase();
let row = sqlx::query(
r#"
INSERT INTO payment_gateway_configs (
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency, usd_exchange_rate, min_recharge_usd,
channels_json, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
ON CONFLICT (provider)
DO UPDATE SET
enabled = EXCLUDED.enabled,
endpoint_url = EXCLUDED.endpoint_url,
callback_base_url = EXCLUDED.callback_base_url,
merchant_id = EXCLUDED.merchant_id,
merchant_key_encrypted = CASE
WHEN $11::BOOL THEN payment_gateway_configs.merchant_key_encrypted
ELSE EXCLUDED.merchant_key_encrypted
END,
pay_currency = EXCLUDED.pay_currency,
usd_exchange_rate = EXCLUDED.usd_exchange_rate,
min_recharge_usd = EXCLUDED.min_recharge_usd,
channels_json = EXCLUDED.channels_json,
updated_at = NOW()
RETURNING
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency,
CAST(usd_exchange_rate AS DOUBLE PRECISION) AS usd_exchange_rate,
CAST(min_recharge_usd AS DOUBLE PRECISION) AS min_recharge_usd,
channels_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
"#,
)
.bind(&provider)
.bind(input.enabled)
.bind(&input.endpoint_url)
.bind(input.callback_base_url.as_deref())
.bind(&input.merchant_id)
.bind(input.merchant_key_encrypted.as_deref())
.bind(&input.pay_currency)
.bind(input.usd_exchange_rate)
.bind(input.min_recharge_usd)
.bind(&input.channels_json)
.bind(input.preserve_existing_secret)
.fetch_one(&self.pool)
.await
.map_postgres_err()?;
Ok(AdminBillingMutationOutcome::Applied(
map_payment_gateway_config_row(&row)?,
))
}
async fn list_billing_plans(
&self,
include_disabled: bool,
) -> Result<Option<Vec<BillingPlanRecord>>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT
id, title, description,
CAST(price_amount AS DOUBLE PRECISION) AS price_amount,
price_currency, duration_unit, duration_value, enabled, sort_order,
max_active_per_user, purchase_limit_scope, entitlements_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
FROM billing_plans
WHERE ($1::BOOL = TRUE OR enabled = TRUE)
ORDER BY sort_order ASC, price_amount ASC, id ASC
"#,
)
.bind(include_disabled)
.fetch_all(&self.pool)
.await
.map_postgres_err()?;
Ok(Some(
rows.iter()
.map(map_billing_plan_row)
.collect::<Result<Vec<_>, _>>()?,
))
}
async fn find_billing_plan(
&self,
plan_id: &str,
) -> Result<Option<BillingPlanRecord>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT
id, title, description,
CAST(price_amount AS DOUBLE PRECISION) AS price_amount,
price_currency, duration_unit, duration_value, enabled, sort_order,
max_active_per_user, purchase_limit_scope, entitlements_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
FROM billing_plans
WHERE id = $1
LIMIT 1
"#,
)
.bind(plan_id)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
row.as_ref().map(map_billing_plan_row).transpose()
}
async fn create_billing_plan(
&self,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let id = uuid::Uuid::new_v4().to_string();
let row = sqlx::query(BILLING_PLAN_INSERT_RETURNING_SQL)
.bind(&id)
.bind(&input.title)
.bind(input.description.as_deref())
.bind(input.price_amount)
.bind(&input.price_currency)
.bind(&input.duration_unit)
.bind(input.duration_value)
.bind(input.enabled)
.bind(input.sort_order)
.bind(input.max_active_per_user)
.bind(&input.purchase_limit_scope)
.bind(&input.entitlements_json)
.fetch_one(&self.pool)
.await
.map_postgres_err()?;
Ok(AdminBillingMutationOutcome::Applied(map_billing_plan_row(
&row,
)?))
}
async fn update_billing_plan(
&self,
plan_id: &str,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let row = sqlx::query(BILLING_PLAN_UPDATE_RETURNING_SQL)
.bind(plan_id)
.bind(&input.title)
.bind(input.description.as_deref())
.bind(input.price_amount)
.bind(&input.price_currency)
.bind(&input.duration_unit)
.bind(input.duration_value)
.bind(input.enabled)
.bind(input.sort_order)
.bind(input.max_active_per_user)
.bind(&input.purchase_limit_scope)
.bind(&input.entitlements_json)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
match row {
Some(row) => Ok(AdminBillingMutationOutcome::Applied(map_billing_plan_row(
&row,
)?)),
None => Ok(AdminBillingMutationOutcome::NotFound),
}
}
async fn set_billing_plan_enabled(
&self,
plan_id: &str,
enabled: bool,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let row = sqlx::query(
r#"
UPDATE billing_plans
SET enabled = $2, updated_at = NOW()
WHERE id = $1
RETURNING
id, title, description,
CAST(price_amount AS DOUBLE PRECISION) AS price_amount,
price_currency, duration_unit, duration_value, enabled, sort_order,
max_active_per_user, purchase_limit_scope, entitlements_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
"#,
)
.bind(plan_id)
.bind(enabled)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
match row {
Some(row) => Ok(AdminBillingMutationOutcome::Applied(map_billing_plan_row(
&row,
)?)),
None => Ok(AdminBillingMutationOutcome::NotFound),
}
}
async fn delete_billing_plan(
&self,
plan_id: &str,
) -> Result<AdminBillingMutationOutcome<()>, DataLayerError> {
let exists = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*)::bigint FROM billing_plans WHERE id = $1",
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_postgres_err()?;
if exists == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
let order_count = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)::bigint
FROM payment_orders
WHERE product_id = $1
AND order_kind = 'plan_purchase'
"#,
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_postgres_err()?;
let entitlement_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*)::bigint FROM user_plan_entitlements WHERE plan_id = $1",
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_postgres_err()?;
if order_count > 0 || entitlement_count > 0 {
return Ok(AdminBillingMutationOutcome::Invalid(
"套餐已有订单或权益,不能删除,请停用该套餐".to_string(),
));
}
let result = sqlx::query("DELETE FROM billing_plans WHERE id = $1")
.bind(plan_id)
.execute(&self.pool)
.await
.map_postgres_err()?;
if result.rows_affected() == 0 {
Ok(AdminBillingMutationOutcome::NotFound)
} else {
Ok(AdminBillingMutationOutcome::Applied(()))
}
}
async fn list_user_plan_entitlements(
&self,
user_id: &str,
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT
id, user_id, plan_id, payment_order_id, status,
CAST(EXTRACT(EPOCH FROM starts_at) AS BIGINT) AS starts_at_unix_secs,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs,
entitlements_snapshot,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
FROM user_plan_entitlements
WHERE user_id = $1
AND status = 'active'
AND expires_at > NOW()
ORDER BY expires_at ASC, created_at ASC
"#,
)
.bind(user_id)
.fetch_all(&self.pool)
.await
.map_postgres_err()?;
Ok(Some(
rows.iter()
.map(map_user_plan_entitlement_row)
.collect::<Result<Vec<_>, _>>()?,
))
}
async fn find_user_daily_quota_availability(
&self,
user_id: &str,
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = $1
AND status = 'active'
AND starts_at <= NOW()
AND expires_at > NOW()
ORDER BY expires_at ASC, created_at ASC, id ASC
"#,
)
.bind(user_id)
.fetch_all(&self.pool)
.await
.map_postgres_err()?;
let now = chrono::Utc::now();
let mut grants = Vec::new();
for row in rows {
let entitlement_id: String = row.try_get("id").map_postgres_err()?;
let entitlements: serde_json::Value =
row.try_get("entitlements_snapshot").map_postgres_err()?;
grants.extend(daily_quota_grants_from_entitlement(
&entitlement_id,
&entitlements,
now,
)?);
}
let mut total_quota_usd = 0.0;
let mut used_usd = 0.0;
let mut remaining_usd = 0.0;
let mut allow_wallet_overage = true;
for grant in &grants {
allow_wallet_overage &= grant.allow_wallet_overage;
let used = sqlx::query_scalar::<_, Option<f64>>(
r#"
SELECT CAST(COALESCE(SUM(amount_usd), 0) AS DOUBLE PRECISION)
FROM entitlement_usage_ledgers
WHERE user_entitlement_id = $1
AND usage_date = $2
"#,
)
.bind(&grant.entitlement_id)
.bind(&grant.usage_date)
.fetch_one(&self.pool)
.await
.map_postgres_err()?
.unwrap_or(0.0);
total_quota_usd += grant.daily_quota_usd;
used_usd += used.min(grant.daily_quota_usd).max(0.0);
remaining_usd += (grant.daily_quota_usd - used).max(0.0);
}
let has_active_daily_quota = !grants.is_empty();
Ok(Some(UserDailyQuotaAvailabilityRecord {
has_active_daily_quota,
total_quota_usd,
used_usd,
remaining_usd,
allow_wallet_overage: has_active_daily_quota && allow_wallet_overage,
}))
}
}
const BILLING_PLAN_INSERT_RETURNING_SQL: &str = r#"
INSERT INTO billing_plans (
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
RETURNING
id, title, description,
CAST(price_amount AS DOUBLE PRECISION) AS price_amount,
price_currency, duration_unit, duration_value, enabled, sort_order,
max_active_per_user, purchase_limit_scope, entitlements_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
"#;
const BILLING_PLAN_UPDATE_RETURNING_SQL: &str = r#"
UPDATE billing_plans
SET
title = $2,
description = $3,
price_amount = $4,
price_currency = $5,
duration_unit = $6,
duration_value = $7,
enabled = $8,
sort_order = $9,
max_active_per_user = $10,
purchase_limit_scope = $11,
entitlements_json = $12,
updated_at = NOW()
WHERE id = $1
RETURNING
id, title, description,
CAST(price_amount AS DOUBLE PRECISION) AS price_amount,
price_currency, duration_unit, duration_value, enabled, sort_order,
max_active_per_user, purchase_limit_scope, entitlements_json,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
"#;
fn map_row(row: &sqlx::postgres::PgRow) -> Result<StoredBillingModelContext, DataLayerError> {
StoredBillingModelContext::new(
row.try_get("provider_id").map_postgres_err()?,
@@ -718,6 +1134,146 @@ fn read_count(row: sqlx::postgres::PgRow) -> Result<u64, DataLayerError> {
Ok(row.try_get::<i64, _>("total").map_postgres_err()?.max(0) as u64)
}
#[derive(Debug)]
struct DailyQuotaGrant {
entitlement_id: String,
daily_quota_usd: f64,
usage_date: String,
allow_wallet_overage: bool,
}
fn daily_quota_usage_date(
reset_timezone: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<String, DataLayerError> {
let timezone = reset_timezone
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Asia/Shanghai")
.parse::<chrono_tz::Tz>()
.map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?;
Ok(now.with_timezone(&timezone).date_naive().to_string())
}
fn daily_quota_grants_from_entitlement(
entitlement_id: &str,
entitlements: &serde_json::Value,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<DailyQuotaGrant>, DataLayerError> {
let mut grants = Vec::new();
let Some(items) = entitlements.as_array() else {
return Ok(grants);
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
grants.push(DailyQuotaGrant {
entitlement_id: entitlement_id.to_string(),
daily_quota_usd,
usage_date: daily_quota_usage_date(
item.get("reset_timezone")
.and_then(serde_json::Value::as_str),
now,
)?,
allow_wallet_overage: item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
});
}
Ok(grants)
}
fn map_payment_gateway_config_row(
row: &sqlx::postgres::PgRow,
) -> Result<PaymentGatewayConfigRecord, DataLayerError> {
Ok(PaymentGatewayConfigRecord {
provider: row.try_get("provider").map_postgres_err()?,
enabled: row.try_get("enabled").map_postgres_err()?,
endpoint_url: row.try_get("endpoint_url").map_postgres_err()?,
callback_base_url: row.try_get("callback_base_url").map_postgres_err()?,
merchant_id: row.try_get("merchant_id").map_postgres_err()?,
merchant_key_encrypted: row.try_get("merchant_key_encrypted").map_postgres_err()?,
pay_currency: row.try_get("pay_currency").map_postgres_err()?,
usd_exchange_rate: row.try_get("usd_exchange_rate").map_postgres_err()?,
min_recharge_usd: row.try_get("min_recharge_usd").map_postgres_err()?,
channels_json: row
.try_get::<Option<serde_json::Value>, _>("channels_json")
.map_postgres_err()?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
})
}
fn map_billing_plan_row(row: &sqlx::postgres::PgRow) -> Result<BillingPlanRecord, DataLayerError> {
Ok(BillingPlanRecord {
id: row.try_get("id").map_postgres_err()?,
title: row.try_get("title").map_postgres_err()?,
description: row.try_get("description").map_postgres_err()?,
price_amount: row.try_get("price_amount").map_postgres_err()?,
price_currency: row.try_get("price_currency").map_postgres_err()?,
duration_unit: row.try_get("duration_unit").map_postgres_err()?,
duration_value: row.try_get("duration_value").map_postgres_err()?,
enabled: row.try_get("enabled").map_postgres_err()?,
sort_order: row.try_get("sort_order").map_postgres_err()?,
max_active_per_user: row.try_get("max_active_per_user").map_postgres_err()?,
purchase_limit_scope: row.try_get("purchase_limit_scope").map_postgres_err()?,
entitlements_json: row.try_get("entitlements_json").map_postgres_err()?,
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
})
}
fn map_user_plan_entitlement_row(
row: &sqlx::postgres::PgRow,
) -> Result<UserPlanEntitlementRecord, DataLayerError> {
Ok(UserPlanEntitlementRecord {
id: row.try_get("id").map_postgres_err()?,
user_id: row.try_get("user_id").map_postgres_err()?,
plan_id: row.try_get("plan_id").map_postgres_err()?,
payment_order_id: row.try_get("payment_order_id").map_postgres_err()?,
status: row.try_get("status").map_postgres_err()?,
starts_at_unix_secs: row
.try_get::<i64, _>("starts_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
expires_at_unix_secs: row
.try_get::<i64, _>("expires_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
entitlements_snapshot: row.try_get("entitlements_snapshot").map_postgres_err()?,
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_postgres_err()?
.max(0) as u64,
})
}
fn map_admin_billing_rule_row(
row: &sqlx::postgres::PgRow,
) -> Result<AdminBillingRuleRecord, DataLayerError> {

View File

@@ -4,7 +4,9 @@ use sqlx::{sqlite::SqliteRow, Row};
use super::{
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput,
BillingReadRepository, StoredBillingModelContext,
BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository, PaymentGatewayConfigRecord,
PaymentGatewayConfigWriteInput, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
UserPlanEntitlementRecord,
};
use crate::driver::sqlite::{sqlite_optional_real, SqlitePool};
use crate::error::SqlResultExt;
@@ -609,8 +611,409 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
},
))
}
async fn find_payment_gateway_config(
&self,
provider: &str,
) -> Result<Option<PaymentGatewayConfigRecord>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency, usd_exchange_rate, min_recharge_usd,
channels_json, created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM payment_gateway_configs
WHERE provider = ?
LIMIT 1
"#,
)
.bind(provider.trim().to_ascii_lowercase())
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref()
.map(map_payment_gateway_config_sqlite)
.transpose()
}
async fn upsert_payment_gateway_config(
&self,
input: &PaymentGatewayConfigWriteInput,
) -> Result<AdminBillingMutationOutcome<PaymentGatewayConfigRecord>, DataLayerError> {
let provider = input.provider.trim().to_ascii_lowercase();
let existing_secret = if input.preserve_existing_secret {
sqlx::query_scalar::<_, String>(
"SELECT merchant_key_encrypted FROM payment_gateway_configs WHERE provider = ?",
)
.bind(&provider)
.fetch_optional(&self.pool)
.await
.map_sql_err()?
} else {
None
};
let secret = if input.preserve_existing_secret {
existing_secret
} else {
input.merchant_key_encrypted.clone()
};
let now = current_unix_secs_i64();
sqlx::query(
r#"
INSERT INTO payment_gateway_configs (
provider, enabled, endpoint_url, callback_base_url, merchant_id,
merchant_key_encrypted, pay_currency, usd_exchange_rate, min_recharge_usd,
channels_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider) DO UPDATE SET
enabled = excluded.enabled,
endpoint_url = excluded.endpoint_url,
callback_base_url = excluded.callback_base_url,
merchant_id = excluded.merchant_id,
merchant_key_encrypted = excluded.merchant_key_encrypted,
pay_currency = excluded.pay_currency,
usd_exchange_rate = excluded.usd_exchange_rate,
min_recharge_usd = excluded.min_recharge_usd,
channels_json = excluded.channels_json,
updated_at = excluded.updated_at
"#,
)
.bind(&provider)
.bind(input.enabled)
.bind(&input.endpoint_url)
.bind(input.callback_base_url.as_deref())
.bind(&input.merchant_id)
.bind(secret.as_deref())
.bind(&input.pay_currency)
.bind(input.usd_exchange_rate)
.bind(input.min_recharge_usd)
.bind(json_to_string(&input.channels_json)?)
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_sql_err()?;
match self.find_payment_gateway_config(&provider).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Err(DataLayerError::UnexpectedValue(
"upserted payment gateway config missing".to_string(),
)),
}
}
async fn list_billing_plans(
&self,
include_disabled: bool,
) -> Result<Option<Vec<BillingPlanRecord>>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM billing_plans
WHERE (? = 1 OR enabled = 1)
ORDER BY sort_order ASC, price_amount ASC, id ASC
"#,
)
.bind(include_disabled)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
Ok(Some(
rows.iter()
.map(map_billing_plan_sqlite)
.collect::<Result<Vec<_>, _>>()?,
))
}
async fn find_billing_plan(
&self,
plan_id: &str,
) -> Result<Option<BillingPlanRecord>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json,
created_at AS created_at_unix_secs, updated_at AS updated_at_unix_secs
FROM billing_plans
WHERE id = ?
LIMIT 1
"#,
)
.bind(plan_id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref().map(map_billing_plan_sqlite).transpose()
}
async fn create_billing_plan(
&self,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let id = uuid::Uuid::new_v4().to_string();
let now = current_unix_secs_i64();
sqlx::query(BILLING_PLAN_INSERT_SQLITE)
.bind(&id)
.bind(&input.title)
.bind(input.description.as_deref())
.bind(input.price_amount)
.bind(&input.price_currency)
.bind(&input.duration_unit)
.bind(input.duration_value)
.bind(input.enabled)
.bind(input.sort_order)
.bind(input.max_active_per_user)
.bind(&input.purchase_limit_scope)
.bind(json_to_string(&input.entitlements_json)?)
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_sql_err()?;
match self.find_billing_plan(&id).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Err(DataLayerError::UnexpectedValue(
"created billing plan missing".to_string(),
)),
}
}
async fn update_billing_plan(
&self,
plan_id: &str,
input: &BillingPlanWriteInput,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let result = sqlx::query(BILLING_PLAN_UPDATE_SQLITE)
.bind(&input.title)
.bind(input.description.as_deref())
.bind(input.price_amount)
.bind(&input.price_currency)
.bind(&input.duration_unit)
.bind(input.duration_value)
.bind(input.enabled)
.bind(input.sort_order)
.bind(input.max_active_per_user)
.bind(&input.purchase_limit_scope)
.bind(json_to_string(&input.entitlements_json)?)
.bind(current_unix_secs_i64())
.bind(plan_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
match self.find_billing_plan(plan_id).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Ok(AdminBillingMutationOutcome::NotFound),
}
}
async fn set_billing_plan_enabled(
&self,
plan_id: &str,
enabled: bool,
) -> Result<AdminBillingMutationOutcome<BillingPlanRecord>, DataLayerError> {
let result =
sqlx::query("UPDATE billing_plans SET enabled = ?, updated_at = ? WHERE id = ?")
.bind(enabled)
.bind(current_unix_secs_i64())
.bind(plan_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
match self.find_billing_plan(plan_id).await? {
Some(record) => Ok(AdminBillingMutationOutcome::Applied(record)),
None => Ok(AdminBillingMutationOutcome::NotFound),
}
}
async fn delete_billing_plan(
&self,
plan_id: &str,
) -> Result<AdminBillingMutationOutcome<()>, DataLayerError> {
let exists =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM billing_plans WHERE id = ?")
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
if exists == 0 {
return Ok(AdminBillingMutationOutcome::NotFound);
}
let order_count = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM payment_orders
WHERE product_id = ?
AND order_kind = 'plan_purchase'
"#,
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
let entitlement_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM user_plan_entitlements WHERE plan_id = ?",
)
.bind(plan_id)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
if order_count > 0 || entitlement_count > 0 {
return Ok(AdminBillingMutationOutcome::Invalid(
"套餐已有订单或权益,不能删除,请停用该套餐".to_string(),
));
}
let result = sqlx::query("DELETE FROM billing_plans WHERE id = ?")
.bind(plan_id)
.execute(&self.pool)
.await
.map_sql_err()?;
if result.rows_affected() == 0 {
Ok(AdminBillingMutationOutcome::NotFound)
} else {
Ok(AdminBillingMutationOutcome::Applied(()))
}
}
async fn list_user_plan_entitlements(
&self,
user_id: &str,
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT
id, user_id, plan_id, payment_order_id, status,
starts_at AS starts_at_unix_secs, expires_at AS expires_at_unix_secs,
entitlements_snapshot, created_at AS created_at_unix_secs,
updated_at AS updated_at_unix_secs
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND expires_at > ?
ORDER BY expires_at ASC, created_at ASC
"#,
)
.bind(user_id)
.bind(current_unix_secs_i64())
.fetch_all(&self.pool)
.await
.map_sql_err()?;
Ok(Some(
rows.iter()
.map(map_user_plan_entitlement_sqlite)
.collect::<Result<Vec<_>, _>>()?,
))
}
async fn find_user_daily_quota_availability(
&self,
user_id: &str,
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, DataLayerError> {
let now_unix_secs = current_unix_secs_i64();
let rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND starts_at <= ?
AND expires_at > ?
ORDER BY expires_at ASC, created_at ASC, id ASC
"#,
)
.bind(user_id)
.bind(now_unix_secs)
.bind(now_unix_secs)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
let now = chrono::Utc::now();
let mut grants = Vec::new();
for row in rows {
let entitlement_id: String = row.try_get("id").map_sql_err()?;
let entitlements = parse_json(row.try_get("entitlements_snapshot").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([]));
grants.extend(daily_quota_grants_from_entitlement(
&entitlement_id,
&entitlements,
now,
)?);
}
let mut total_quota_usd = 0.0;
let mut used_usd = 0.0;
let mut remaining_usd = 0.0;
let mut allow_wallet_overage = true;
for grant in &grants {
allow_wallet_overage &= grant.allow_wallet_overage;
let used = sqlx::query_scalar::<_, f64>(
r#"
SELECT COALESCE(SUM(amount_usd), 0)
FROM entitlement_usage_ledgers
WHERE user_entitlement_id = ?
AND usage_date = ?
"#,
)
.bind(&grant.entitlement_id)
.bind(&grant.usage_date)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
total_quota_usd += grant.daily_quota_usd;
used_usd += used.min(grant.daily_quota_usd).max(0.0);
remaining_usd += (grant.daily_quota_usd - used).max(0.0);
}
let has_active_daily_quota = !grants.is_empty();
Ok(Some(UserDailyQuotaAvailabilityRecord {
has_active_daily_quota,
total_quota_usd,
used_usd,
remaining_usd,
allow_wallet_overage: has_active_daily_quota && allow_wallet_overage,
}))
}
}
const BILLING_PLAN_INSERT_SQLITE: &str = r#"
INSERT INTO billing_plans (
id, title, description, price_amount, price_currency, duration_unit,
duration_value, enabled, sort_order, max_active_per_user, purchase_limit_scope,
entitlements_json,
created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#;
const BILLING_PLAN_UPDATE_SQLITE: &str = r#"
UPDATE billing_plans
SET title = ?,
description = ?,
price_amount = ?,
price_currency = ?,
duration_unit = ?,
duration_value = ?,
enabled = ?,
sort_order = ?,
max_active_per_user = ?,
purchase_limit_scope = ?,
entitlements_json = ?,
updated_at = ?
WHERE id = ?
"#;
struct RankedContext {
rank: u8,
is_available: bool,
@@ -745,10 +1148,153 @@ fn json_to_string(value: &serde_json::Value) -> Result<String, DataLayerError> {
})
}
#[derive(Debug)]
struct DailyQuotaGrant {
entitlement_id: String,
daily_quota_usd: f64,
usage_date: String,
allow_wallet_overage: bool,
}
fn daily_quota_usage_date(
reset_timezone: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<String, DataLayerError> {
let timezone = reset_timezone
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Asia/Shanghai")
.parse::<chrono_tz::Tz>()
.map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?;
Ok(now.with_timezone(&timezone).date_naive().to_string())
}
fn daily_quota_grants_from_entitlement(
entitlement_id: &str,
entitlements: &serde_json::Value,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<DailyQuotaGrant>, DataLayerError> {
let mut grants = Vec::new();
let Some(items) = entitlements.as_array() else {
return Ok(grants);
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
grants.push(DailyQuotaGrant {
entitlement_id: entitlement_id.to_string(),
daily_quota_usd,
usage_date: daily_quota_usage_date(
item.get("reset_timezone")
.and_then(serde_json::Value::as_str),
now,
)?,
allow_wallet_overage: item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
});
}
Ok(grants)
}
fn read_count_sqlite(row: &SqliteRow) -> Result<u64, DataLayerError> {
Ok(row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64)
}
fn map_payment_gateway_config_sqlite(
row: &SqliteRow,
) -> Result<PaymentGatewayConfigRecord, DataLayerError> {
Ok(PaymentGatewayConfigRecord {
provider: row.try_get("provider").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?,
endpoint_url: row.try_get("endpoint_url").map_sql_err()?,
callback_base_url: row.try_get("callback_base_url").map_sql_err()?,
merchant_id: row.try_get("merchant_id").map_sql_err()?,
merchant_key_encrypted: row.try_get("merchant_key_encrypted").map_sql_err()?,
pay_currency: row.try_get("pay_currency").map_sql_err()?,
usd_exchange_rate: sqlite_optional_real(row, "usd_exchange_rate")?.unwrap_or(0.0),
min_recharge_usd: sqlite_optional_real(row, "min_recharge_usd")?.unwrap_or(0.0),
channels_json: parse_json(row.try_get("channels_json").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
})
}
fn map_billing_plan_sqlite(row: &SqliteRow) -> Result<BillingPlanRecord, DataLayerError> {
Ok(BillingPlanRecord {
id: row.try_get("id").map_sql_err()?,
title: row.try_get("title").map_sql_err()?,
description: row.try_get("description").map_sql_err()?,
price_amount: sqlite_optional_real(row, "price_amount")?.unwrap_or(0.0),
price_currency: row.try_get("price_currency").map_sql_err()?,
duration_unit: row.try_get("duration_unit").map_sql_err()?,
duration_value: row.try_get("duration_value").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?,
sort_order: row.try_get("sort_order").map_sql_err()?,
max_active_per_user: row.try_get("max_active_per_user").map_sql_err()?,
purchase_limit_scope: row
.try_get::<Option<String>, _>("purchase_limit_scope")
.map_sql_err()?
.unwrap_or_else(|| "active_period".to_string()),
entitlements_json: parse_json(row.try_get("entitlements_json").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
})
}
fn map_user_plan_entitlement_sqlite(
row: &SqliteRow,
) -> Result<UserPlanEntitlementRecord, DataLayerError> {
Ok(UserPlanEntitlementRecord {
id: row.try_get("id").map_sql_err()?,
user_id: row.try_get("user_id").map_sql_err()?,
plan_id: row.try_get("plan_id").map_sql_err()?,
payment_order_id: row.try_get("payment_order_id").map_sql_err()?,
status: row.try_get("status").map_sql_err()?,
starts_at_unix_secs: row
.try_get::<i64, _>("starts_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
expires_at_unix_secs: row
.try_get::<i64, _>("expires_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
entitlements_snapshot: parse_json(row.try_get("entitlements_snapshot").ok().flatten())?
.unwrap_or_else(|| serde_json::json!([])),
created_at_unix_secs: row
.try_get::<i64, _>("created_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
updated_at_unix_secs: row
.try_get::<i64, _>("updated_at_unix_secs")
.map_sql_err()?
.max(0) as u64,
})
}
async fn find_admin_billing_rule_sqlite(
pool: &SqlitePool,
rule_id: &str,
@@ -857,7 +1403,7 @@ mod tests {
use crate::lifecycle::migrate::run_sqlite_migrations;
use crate::repository::billing::{
AdminBillingCollectorWriteInput, AdminBillingMutationOutcome, AdminBillingRuleWriteInput,
BillingReadRepository,
BillingPlanWriteInput, BillingReadRepository,
};
#[tokio::test]
@@ -1010,6 +1556,96 @@ mod tests {
assert_eq!(preset.errors, Vec::<String>::new());
}
#[tokio::test]
async fn sqlite_repository_deletes_unused_billing_plans_only() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_sqlite_migrations(&pool)
.await
.expect("sqlite migrations should run");
let repository = SqliteBillingReadRepository::new(pool.clone());
let input = BillingPlanWriteInput {
title: "Daily Plan".to_string(),
description: None,
price_amount: 100.0,
price_currency: "CNY".to_string(),
duration_unit: "month".to_string(),
duration_value: 1,
enabled: true,
sort_order: 10,
max_active_per_user: 1,
purchase_limit_scope: "active_period".to_string(),
entitlements_json: json!([{
"type": "daily_quota",
"daily_quota_usd": 50.0,
"reset_timezone": "Asia/Shanghai",
"allow_wallet_overage": false
}]),
};
let removable = match repository
.create_billing_plan(&input)
.await
.expect("plan create should run")
{
AdminBillingMutationOutcome::Applied(plan) => plan,
other => panic!("unexpected plan create outcome: {other:?}"),
};
assert_eq!(
repository
.delete_billing_plan(&removable.id)
.await
.expect("plan delete should run"),
AdminBillingMutationOutcome::Applied(())
);
assert!(repository
.find_billing_plan(&removable.id)
.await
.expect("plan lookup should run")
.is_none());
let referenced = match repository
.create_billing_plan(&input)
.await
.expect("plan create should run")
{
AdminBillingMutationOutcome::Applied(plan) => plan,
other => panic!("unexpected plan create outcome: {other:?}"),
};
sqlx::query(
r#"
INSERT INTO payment_orders (
id, order_no, wallet_id, amount_usd, payment_method, order_kind,
product_id, fulfillment_status, status, created_at
)
VALUES ('order-1', 'order-no-1', 'wallet-1', 0, 'epay', 'plan_purchase',
?, 'pending', 'pending', 1)
"#,
)
.bind(&referenced.id)
.execute(&pool)
.await
.expect("payment order should seed");
match repository
.delete_billing_plan(&referenced.id)
.await
.expect("plan delete should run")
{
AdminBillingMutationOutcome::Invalid(detail) => {
assert!(detail.contains("不能删除"));
}
other => panic!("unexpected referenced plan delete outcome: {other:?}"),
}
assert!(repository
.find_billing_plan(&referenced.id)
.await
.expect("plan lookup should run")
.is_some());
}
async fn seed_billing_context(pool: &sqlx::SqlitePool) {
sqlx::query(
r#"

View File

@@ -3,7 +3,10 @@ use std::sync::{Arc, RwLock};
use async_trait::async_trait;
use super::{SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput};
use super::{
plan_finite_wallet_debit, SettlementWriteRepository, StoredUsageSettlement,
UsageSettlementInput, SETTLEMENT_EPSILON_USD,
};
use crate::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot};
use crate::DataLayerError;
@@ -99,10 +102,10 @@ impl SettlementWriteRepository for InMemorySettlementRepository {
})));
}
let final_billing_status = if input.status == "completed" {
"settled"
let mut final_billing_status = if input.status == "completed" {
"settled".to_string()
} else {
"void"
"void".to_string()
};
let mut settlement = self.wallets.with_mut(|wallets| {
let wallet_id = input
@@ -156,17 +159,31 @@ impl SettlementWriteRepository for InMemorySettlementRepository {
if wallet.limit_mode.eq_ignore_ascii_case("unlimited") {
wallet.total_consumed += input.total_cost_usd;
} else {
let gift_deduction = before_gift.max(0.0).min(input.total_cost_usd);
let recharge_deduction = input.total_cost_usd - gift_deduction;
wallet.gift_balance = before_gift - gift_deduction;
wallet.balance = before_recharge - recharge_deduction;
wallet.total_consumed += input.total_cost_usd;
let debit_plan = plan_finite_wallet_debit(
before_recharge,
before_gift,
input.total_cost_usd,
);
if debit_plan.covered_usd() + SETTLEMENT_EPSILON_USD < input.total_cost_usd
{
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
} else {
wallet.balance = before_recharge - debit_plan.recharge_deduction;
wallet.gift_balance = before_gift - debit_plan.gift_deduction;
wallet.total_consumed += input.total_cost_usd;
}
}
}
settlement.wallet_recharge_balance_after = Some(wallet.balance);
settlement.wallet_gift_balance_after = Some(wallet.gift_balance);
settlement.wallet_balance_after = Some(wallet.balance + wallet.gift_balance);
} else if final_billing_status == "settled"
&& input.total_cost_usd > SETTLEMENT_EPSILON_USD
{
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
}
settlement
@@ -312,11 +329,40 @@ mod tests {
.expect("settlement should succeed")
.expect("settlement should exist");
assert_eq!(settlement.billing_status, "insufficient_quota");
assert_eq!(settlement.wallet_id, None);
assert_eq!(settlement.wallet_balance_before, None);
assert_eq!(settlement.wallet_balance_after, None);
}
#[tokio::test]
async fn finite_wallet_insufficient_balance_does_not_overdraw() {
let repository = InMemorySettlementRepository::seed(vec![sample_wallet()]);
let settlement = repository
.settle_usage(UsageSettlementInput {
request_id: "req-insufficient-wallet".to_string(),
user_id: Some("user-1".to_string()),
api_key_id: Some("key-1".to_string()),
api_key_is_standalone: false,
provider_id: Some("provider-1".to_string()),
status: "completed".to_string(),
billing_status: "pending".to_string(),
total_cost_usd: 15.0,
actual_total_cost_usd: 7.5,
finalized_at_unix_secs: Some(200),
})
.await
.expect("settlement should succeed")
.expect("settlement should exist");
assert_eq!(settlement.billing_status, "insufficient_quota");
assert_eq!(settlement.wallet_balance_before, Some(12.0));
assert_eq!(settlement.wallet_balance_after, Some(12.0));
assert_eq!(settlement.wallet_recharge_balance_after, Some(10.0));
assert_eq!(settlement.wallet_gift_balance_after, Some(2.0));
assert_eq!(settlement.provider_monthly_used_usd, None);
}
#[tokio::test]
async fn returns_stored_snapshot_when_usage_is_already_finalized() {
let repository = InMemorySettlementRepository::seed(vec![sample_wallet()]);

View File

@@ -3,6 +3,39 @@ mod mysql;
mod postgres;
mod sqlite;
const SETTLEMENT_EPSILON_USD: f64 = 0.000_000_01;
#[derive(Debug, Clone, Copy)]
struct WalletDebitPlan {
recharge_deduction: f64,
gift_deduction: f64,
}
impl WalletDebitPlan {
fn covered_usd(self) -> f64 {
self.recharge_deduction + self.gift_deduction
}
}
fn finite_wallet_available_usd(recharge_balance: f64, gift_balance: f64) -> f64 {
recharge_balance.max(0.0) + gift_balance.max(0.0)
}
fn plan_finite_wallet_debit(
recharge_balance: f64,
gift_balance: f64,
requested_usd: f64,
) -> WalletDebitPlan {
let recharge_deduction = recharge_balance.max(0.0).min(requested_usd.max(0.0));
let gift_deduction = gift_balance
.max(0.0)
.min((requested_usd - recharge_deduction).max(0.0));
WalletDebitPlan {
recharge_deduction,
gift_deduction,
}
}
#[allow(unused_imports)]
pub(crate) use aether_data_contracts::repository::settlement::{
SettlementRepository, SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput,

View File

@@ -1,7 +1,10 @@
use async_trait::async_trait;
use sqlx::{mysql::MySqlRow, Row};
use super::{SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput};
use super::{
finite_wallet_available_usd, plan_finite_wallet_debit, SettlementWriteRepository,
StoredUsageSettlement, UsageSettlementInput, SETTLEMENT_EPSILON_USD,
};
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
@@ -133,6 +136,197 @@ fn now_unix_secs() -> Result<i64, DataLayerError> {
.map_err(|_| DataLayerError::InvalidInput("timestamp overflow".to_string()))
}
#[derive(Debug, Default)]
struct DailyQuotaDebitResult {
debited_usd: f64,
insufficient: bool,
}
#[derive(Debug)]
struct DailyQuotaGrant {
entitlement_id: String,
daily_quota_usd: f64,
usage_date: String,
allow_wallet_overage: bool,
}
fn daily_quota_usage_date(
reset_timezone: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<String, DataLayerError> {
let timezone = reset_timezone
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Asia/Shanghai")
.parse::<chrono_tz::Tz>()
.map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?;
Ok(now.with_timezone(&timezone).date_naive().to_string())
}
fn daily_quota_grants_from_entitlement(
entitlement_id: &str,
entitlements: &serde_json::Value,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<DailyQuotaGrant>, DataLayerError> {
let mut grants = Vec::new();
let Some(items) = entitlements.as_array() else {
return Ok(grants);
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
grants.push(DailyQuotaGrant {
entitlement_id: entitlement_id.to_string(),
daily_quota_usd,
usage_date: daily_quota_usage_date(
item.get("reset_timezone")
.and_then(serde_json::Value::as_str),
now,
)?,
allow_wallet_overage: item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
});
}
Ok(grants)
}
async fn consume_daily_quota_mysql(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
user_id: &str,
request_id: &str,
total_cost_usd: f64,
wallet_available_usd: Option<f64>,
now_unix_secs: i64,
) -> Result<DailyQuotaDebitResult, DataLayerError> {
if total_cost_usd <= 0.0 {
return Ok(DailyQuotaDebitResult::default());
}
let rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND starts_at <= ?
AND expires_at > ?
ORDER BY expires_at ASC, created_at ASC, id ASC
FOR UPDATE
"#,
)
.bind(user_id)
.bind(now_unix_secs)
.bind(now_unix_secs)
.fetch_all(&mut **tx)
.await
.map_sql_err()?;
let now = chrono::Utc::now();
let mut grants = Vec::new();
for row in rows {
let entitlement_id: String = row.try_get("id").map_sql_err()?;
let entitlements_raw: String = row.try_get("entitlements_snapshot").map_sql_err()?;
let entitlements =
serde_json::from_str::<serde_json::Value>(&entitlements_raw).map_err(|err| {
DataLayerError::UnexpectedValue(format!(
"user_plan_entitlements.entitlements_snapshot invalid json: {err}"
))
})?;
grants.extend(daily_quota_grants_from_entitlement(
&entitlement_id,
&entitlements,
now,
)?);
}
if grants.is_empty() {
return Ok(DailyQuotaDebitResult::default());
}
let mut grants_with_remaining = Vec::new();
let mut total_remaining = 0.0;
let mut allow_wallet_overage = true;
for grant in grants {
allow_wallet_overage &= grant.allow_wallet_overage;
let used = sqlx::query_scalar::<_, f64>(
r#"
SELECT COALESCE(SUM(amount_usd), 0)
FROM entitlement_usage_ledgers
WHERE user_entitlement_id = ?
AND usage_date = ?
"#,
)
.bind(&grant.entitlement_id)
.bind(&grant.usage_date)
.fetch_one(&mut **tx)
.await
.map_sql_err()?;
let remaining = (grant.daily_quota_usd - used).max(0.0);
total_remaining += remaining;
grants_with_remaining.push((grant, remaining));
}
if !allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd {
return Ok(DailyQuotaDebitResult {
debited_usd: 0.0,
insufficient: true,
});
}
if allow_wallet_overage
&& wallet_available_usd.is_some_and(|available| {
total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
})
{
return Ok(DailyQuotaDebitResult {
debited_usd: 0.0,
insufficient: true,
});
}
let mut remaining_cost = total_cost_usd;
let mut debited = 0.0;
for (grant, balance_before) in grants_with_remaining {
if remaining_cost <= 0.000_000_01 || balance_before <= 0.0 {
continue;
}
let amount = remaining_cost.min(balance_before);
let balance_after = balance_before - amount;
sqlx::query(
r#"
INSERT IGNORE INTO entitlement_usage_ledgers (
id, user_entitlement_id, user_id, request_id, amount_usd,
balance_before, balance_after, usage_date, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&grant.entitlement_id)
.bind(user_id)
.bind(request_id)
.bind(amount)
.bind(balance_before)
.bind(balance_after)
.bind(&grant.usage_date)
.bind(now_unix_secs)
.execute(&mut **tx)
.await
.map_sql_err()?;
remaining_cost -= amount;
debited += amount;
}
Ok(DailyQuotaDebitResult {
debited_usd: debited,
insufficient: false,
})
}
#[async_trait]
impl SettlementWriteRepository for MysqlSettlementRepository {
async fn settle_usage(
@@ -161,21 +355,24 @@ impl SettlementWriteRepository for MysqlSettlementRepository {
};
let current_billing_status: String = usage_row.try_get("billing_status").map_sql_err()?;
if current_billing_status == "settled" || current_billing_status == "void" {
if matches!(
current_billing_status.as_str(),
"settled" | "void" | "insufficient_quota"
) {
let settlement = settlement_from_row(&usage_row)?;
tx.commit().await.map_sql_err()?;
return Ok(Some(settlement));
}
let final_billing_status = if input.status == "completed" {
"settled"
let mut final_billing_status = if input.status == "completed" {
"settled".to_string()
} else {
"void"
"void".to_string()
};
let mut settlement = StoredUsageSettlement {
request_id: input.request_id.clone(),
wallet_id: None,
billing_status: final_billing_status.to_string(),
billing_status: final_billing_status.clone(),
wallet_balance_before: None,
wallet_balance_after: None,
wallet_recharge_balance_before: None,
@@ -253,22 +450,101 @@ FOR UPDATE
None
};
if let Some(wallet_row) = wallet_row {
let wallet_id: String = wallet_row.try_get("id").map_sql_err()?;
let before_recharge: f64 = wallet_row.try_get("balance").map_sql_err()?;
let before_gift: f64 = wallet_row.try_get("gift_balance").map_sql_err()?;
let limit_mode: String = wallet_row.try_get("limit_mode").map_sql_err()?;
let before_total = before_recharge + before_gift;
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
if !limit_mode.eq_ignore_ascii_case("unlimited") {
let gift_deduction = before_gift.max(0.0).min(input.total_cost_usd);
let recharge_deduction = input.total_cost_usd - gift_deduction;
after_gift = before_gift - gift_deduction;
after_recharge = before_recharge - recharge_deduction;
let wallet_available_usd = match wallet_row.as_ref() {
Some(row) => {
let limit_mode: String = row.try_get("limit_mode").map_sql_err()?;
if limit_mode.eq_ignore_ascii_case("unlimited") {
None
} else {
Some(finite_wallet_available_usd(
row.try_get("balance").map_sql_err()?,
row.try_get("gift_balance").map_sql_err()?,
))
}
}
sqlx::query(
r#"
None => Some(0.0),
};
let wallet_debit_cost_usd = if !api_key_is_standalone {
if let Some(user_id) = input.user_id.as_deref().filter(|value| !value.is_empty()) {
let quota = consume_daily_quota_mysql(
&mut tx,
user_id,
&input.request_id,
input.total_cost_usd,
wallet_available_usd,
updated_at,
)
.await?;
if quota.insufficient {
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
0.0
} else {
(input.total_cost_usd - quota.debited_usd).max(0.0)
}
} else {
input.total_cost_usd
}
} else {
input.total_cost_usd
};
if final_billing_status != "settled" {
sqlx::query(UPSERT_USAGE_SETTLEMENT_SNAPSHOT_SQL)
.bind(&settlement.request_id)
.bind(&settlement.billing_status)
.bind(settlement.wallet_id.as_deref())
.bind(settlement.wallet_balance_before)
.bind(settlement.wallet_balance_after)
.bind(settlement.wallet_recharge_balance_before)
.bind(settlement.wallet_recharge_balance_after)
.bind(settlement.wallet_gift_balance_before)
.bind(settlement.wallet_gift_balance_after)
.bind(settlement.provider_monthly_used_usd)
.bind(settlement.finalized_at_unix_secs.map(|value| value as i64))
.bind(updated_at)
.bind(updated_at)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&final_billing_status)
.bind(finalized_at)
.bind(&input.request_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
tx.commit().await.map_sql_err()?;
return Ok(Some(settlement));
}
if wallet_debit_cost_usd > SETTLEMENT_EPSILON_USD {
if let Some(wallet_row) = wallet_row {
let wallet_id: String = wallet_row.try_get("id").map_sql_err()?;
let before_recharge: f64 = wallet_row.try_get("balance").map_sql_err()?;
let before_gift: f64 = wallet_row.try_get("gift_balance").map_sql_err()?;
let limit_mode: String = wallet_row.try_get("limit_mode").map_sql_err()?;
let before_total = before_recharge + before_gift;
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
if !limit_mode.eq_ignore_ascii_case("unlimited") {
let debit_plan = plan_finite_wallet_debit(
before_recharge,
before_gift,
wallet_debit_cost_usd,
);
if debit_plan.covered_usd() + SETTLEMENT_EPSILON_USD < wallet_debit_cost_usd
{
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
} else {
after_recharge = before_recharge - debit_plan.recharge_deduction;
after_gift = before_gift - debit_plan.gift_deduction;
}
}
if final_billing_status == "settled" {
sqlx::query(
r#"
UPDATE wallets
SET
balance = ?,
@@ -277,23 +553,57 @@ SET
updated_at = ?
WHERE id = ?
"#,
)
.bind(after_recharge)
.bind(after_gift)
.bind(input.total_cost_usd)
.bind(updated_at)
.bind(&wallet_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
)
.bind(after_recharge)
.bind(after_gift)
.bind(wallet_debit_cost_usd)
.bind(updated_at)
.bind(&wallet_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
}
settlement.wallet_id = Some(wallet_id);
settlement.wallet_balance_before = Some(before_total);
settlement.wallet_balance_after = Some(after_recharge + after_gift);
settlement.wallet_recharge_balance_before = Some(before_recharge);
settlement.wallet_recharge_balance_after = Some(after_recharge);
settlement.wallet_gift_balance_before = Some(before_gift);
settlement.wallet_gift_balance_after = Some(after_gift);
settlement.wallet_id = Some(wallet_id);
settlement.wallet_balance_before = Some(before_total);
settlement.wallet_balance_after = Some(after_recharge + after_gift);
settlement.wallet_recharge_balance_before = Some(before_recharge);
settlement.wallet_recharge_balance_after = Some(after_recharge);
settlement.wallet_gift_balance_before = Some(before_gift);
settlement.wallet_gift_balance_after = Some(after_gift);
} else {
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
}
}
if final_billing_status != "settled" {
sqlx::query(UPSERT_USAGE_SETTLEMENT_SNAPSHOT_SQL)
.bind(&settlement.request_id)
.bind(&settlement.billing_status)
.bind(settlement.wallet_id.as_deref())
.bind(settlement.wallet_balance_before)
.bind(settlement.wallet_balance_after)
.bind(settlement.wallet_recharge_balance_before)
.bind(settlement.wallet_recharge_balance_after)
.bind(settlement.wallet_gift_balance_before)
.bind(settlement.wallet_gift_balance_after)
.bind(settlement.provider_monthly_used_usd)
.bind(settlement.finalized_at_unix_secs.map(|value| value as i64))
.bind(updated_at)
.bind(updated_at)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&final_billing_status)
.bind(finalized_at)
.bind(&input.request_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
tx.commit().await.map_sql_err()?;
return Ok(Some(settlement));
}
if let Some(provider_id) = input
@@ -347,7 +657,7 @@ WHERE id = ?
.map_sql_err()?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(final_billing_status)
.bind(&final_billing_status)
.bind(finalized_at)
.bind(&input.request_id)
.execute(&mut *tx)

View File

@@ -1,7 +1,10 @@
use async_trait::async_trait;
use sqlx::{PgPool, Row};
use super::{SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput};
use super::{
finite_wallet_available_usd, plan_finite_wallet_debit, SettlementWriteRepository,
StoredUsageSettlement, UsageSettlementInput, SETTLEMENT_EPSILON_USD,
};
use crate::driver::postgres::PostgresTransactionRunner;
use crate::error::SqlxResultExt;
use crate::DataLayerError;
@@ -190,6 +193,192 @@ where
Ok(())
}
#[derive(Debug, Default)]
struct DailyQuotaDebitResult {
debited_usd: f64,
insufficient: bool,
}
#[derive(Debug)]
struct DailyQuotaGrant {
entitlement_id: String,
daily_quota_usd: f64,
usage_date: String,
allow_wallet_overage: bool,
}
fn daily_quota_usage_date(
reset_timezone: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<String, DataLayerError> {
let timezone = reset_timezone
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Asia/Shanghai")
.parse::<chrono_tz::Tz>()
.map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?;
Ok(now.with_timezone(&timezone).date_naive().to_string())
}
fn daily_quota_grants_from_entitlement(
entitlement_id: &str,
entitlements: &serde_json::Value,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<DailyQuotaGrant>, DataLayerError> {
let mut grants = Vec::new();
let Some(items) = entitlements.as_array() else {
return Ok(grants);
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
let usage_date = daily_quota_usage_date(
item.get("reset_timezone")
.and_then(serde_json::Value::as_str),
now,
)?;
grants.push(DailyQuotaGrant {
entitlement_id: entitlement_id.to_string(),
daily_quota_usd,
usage_date,
allow_wallet_overage: item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
});
}
Ok(grants)
}
async fn consume_daily_quota_postgres(
tx: &mut crate::driver::postgres::PostgresTransaction,
user_id: &str,
request_id: &str,
total_cost_usd: f64,
wallet_available_usd: Option<f64>,
) -> Result<DailyQuotaDebitResult, DataLayerError> {
if total_cost_usd <= 0.0 {
return Ok(DailyQuotaDebitResult::default());
}
let now = chrono::Utc::now();
let entitlement_rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = $1
AND status = 'active'
AND starts_at <= NOW()
AND expires_at > NOW()
ORDER BY expires_at ASC, created_at ASC, id ASC
FOR UPDATE
"#,
)
.bind(user_id)
.fetch_all(&mut **tx)
.await
.map_postgres_err()?;
let mut grants = Vec::new();
for row in entitlement_rows {
let entitlement_id: String = row.try_get("id").map_postgres_err()?;
let entitlements: serde_json::Value =
row.try_get("entitlements_snapshot").map_postgres_err()?;
grants.extend(daily_quota_grants_from_entitlement(
&entitlement_id,
&entitlements,
now,
)?);
}
if grants.is_empty() {
return Ok(DailyQuotaDebitResult::default());
}
let mut grants_with_remaining = Vec::new();
let mut total_remaining = 0.0;
let mut allow_wallet_overage = true;
for grant in grants {
allow_wallet_overage &= grant.allow_wallet_overage;
let used = sqlx::query_scalar::<_, Option<f64>>(
r#"
SELECT CAST(COALESCE(SUM(amount_usd), 0) AS DOUBLE PRECISION)
FROM entitlement_usage_ledgers
WHERE user_entitlement_id = $1
AND usage_date = $2
"#,
)
.bind(&grant.entitlement_id)
.bind(&grant.usage_date)
.fetch_one(&mut **tx)
.await
.map_postgres_err()?
.unwrap_or(0.0);
let remaining = (grant.daily_quota_usd - used).max(0.0);
total_remaining += remaining;
grants_with_remaining.push((grant, remaining));
}
if !allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd {
return Ok(DailyQuotaDebitResult {
debited_usd: 0.0,
insufficient: true,
});
}
if allow_wallet_overage
&& wallet_available_usd.is_some_and(|available| {
total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
})
{
return Ok(DailyQuotaDebitResult {
debited_usd: 0.0,
insufficient: true,
});
}
let mut remaining_cost = total_cost_usd;
let mut debited = 0.0;
for (grant, balance_before) in grants_with_remaining {
if remaining_cost <= 0.000_000_01 || balance_before <= 0.0 {
continue;
}
let amount = remaining_cost.min(balance_before);
let balance_after = balance_before - amount;
sqlx::query(
r#"
INSERT INTO entitlement_usage_ledgers (
id, user_entitlement_id, user_id, request_id, amount_usd,
balance_before, balance_after, usage_date, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT (user_entitlement_id, request_id) DO NOTHING
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&grant.entitlement_id)
.bind(user_id)
.bind(request_id)
.bind(amount)
.bind(balance_before)
.bind(balance_after)
.bind(&grant.usage_date)
.execute(&mut **tx)
.await
.map_postgres_err()?;
remaining_cost -= amount;
debited += amount;
}
Ok(DailyQuotaDebitResult {
debited_usd: debited,
insufficient: false,
})
}
#[async_trait]
impl SettlementWriteRepository for SqlxSettlementRepository {
async fn settle_usage(
@@ -212,14 +401,17 @@ impl SettlementWriteRepository for SqlxSettlementRepository {
let current_billing_status: String =
usage_row.try_get("billing_status").map_postgres_err()?;
if current_billing_status == "settled" || current_billing_status == "void" {
if matches!(
current_billing_status.as_str(),
"settled" | "void" | "insufficient_quota"
) {
return settlement_from_row(&usage_row).map(Some);
}
let final_billing_status = if input.status == "completed" {
"settled"
let mut final_billing_status = if input.status == "completed" {
"settled".to_string()
} else {
"void"
"void".to_string()
};
let finalized_at =
i64::try_from(input.finalized_at_unix_secs.unwrap_or_else(|| {
@@ -323,25 +515,92 @@ LIMIT 1
None
};
if let Some(wallet_row) = wallet_row {
let wallet_id: String = wallet_row.try_get("id").map_postgres_err()?;
let before_recharge: f64 =
wallet_row.try_get("balance").map_postgres_err()?;
let before_gift: f64 =
wallet_row.try_get("gift_balance").map_postgres_err()?;
let limit_mode: String =
wallet_row.try_get("limit_mode").map_postgres_err()?;
let before_total = before_recharge + before_gift;
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
if !limit_mode.eq_ignore_ascii_case("unlimited") {
let gift_deduction = before_gift.max(0.0).min(input.total_cost_usd);
let recharge_deduction = input.total_cost_usd - gift_deduction;
after_gift = before_gift - gift_deduction;
after_recharge = before_recharge - recharge_deduction;
let wallet_available_usd = match wallet_row.as_ref() {
Some(row) => {
let limit_mode: String =
row.try_get("limit_mode").map_postgres_err()?;
if limit_mode.eq_ignore_ascii_case("unlimited") {
None
} else {
Some(finite_wallet_available_usd(
row.try_get("balance").map_postgres_err()?,
row.try_get("gift_balance").map_postgres_err()?,
))
}
}
sqlx::query(
r#"
None => Some(0.0),
};
let wallet_debit_cost_usd = if !api_key_is_standalone {
if let Some(user_id) =
input.user_id.as_deref().filter(|value| !value.is_empty())
{
let quota = consume_daily_quota_postgres(
tx,
user_id,
&input.request_id,
input.total_cost_usd,
wallet_available_usd,
)
.await?;
if quota.insufficient {
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
0.0
} else {
(input.total_cost_usd - quota.debited_usd).max(0.0)
}
} else {
input.total_cost_usd
}
} else {
input.total_cost_usd
};
if final_billing_status != "settled" {
sync_usage_settlement_snapshot(&mut **tx, &settlement).await?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&input.request_id)
.bind(&final_billing_status)
.bind(finalized_at)
.execute(&mut **tx)
.await
.map_postgres_err()?;
return Ok(Some(settlement));
}
if wallet_debit_cost_usd > SETTLEMENT_EPSILON_USD {
if let Some(wallet_row) = wallet_row {
let wallet_id: String =
wallet_row.try_get("id").map_postgres_err()?;
let before_recharge: f64 =
wallet_row.try_get("balance").map_postgres_err()?;
let before_gift: f64 =
wallet_row.try_get("gift_balance").map_postgres_err()?;
let limit_mode: String =
wallet_row.try_get("limit_mode").map_postgres_err()?;
let before_total = before_recharge + before_gift;
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
if !limit_mode.eq_ignore_ascii_case("unlimited") {
let debit_plan = plan_finite_wallet_debit(
before_recharge,
before_gift,
wallet_debit_cost_usd,
);
if debit_plan.covered_usd() + SETTLEMENT_EPSILON_USD
< wallet_debit_cost_usd
{
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
} else {
after_recharge =
before_recharge - debit_plan.recharge_deduction;
after_gift = before_gift - debit_plan.gift_deduction;
}
}
if final_billing_status == "settled" {
sqlx::query(
r#"
UPDATE wallets
SET
balance = $2,
@@ -350,22 +609,39 @@ SET
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(&wallet_id)
.bind(after_recharge)
.bind(after_gift)
.bind(input.total_cost_usd)
.execute(&mut **tx)
.await
.map_postgres_err()?;
)
.bind(&wallet_id)
.bind(after_recharge)
.bind(after_gift)
.bind(wallet_debit_cost_usd)
.execute(&mut **tx)
.await
.map_postgres_err()?;
}
settlement.wallet_id = Some(wallet_id.clone());
settlement.wallet_balance_before = Some(before_total);
settlement.wallet_balance_after = Some(after_recharge + after_gift);
settlement.wallet_recharge_balance_before = Some(before_recharge);
settlement.wallet_recharge_balance_after = Some(after_recharge);
settlement.wallet_gift_balance_before = Some(before_gift);
settlement.wallet_gift_balance_after = Some(after_gift);
settlement.wallet_id = Some(wallet_id.clone());
settlement.wallet_balance_before = Some(before_total);
settlement.wallet_balance_after = Some(after_recharge + after_gift);
settlement.wallet_recharge_balance_before = Some(before_recharge);
settlement.wallet_recharge_balance_after = Some(after_recharge);
settlement.wallet_gift_balance_before = Some(before_gift);
settlement.wallet_gift_balance_after = Some(after_gift);
} else {
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
}
}
if final_billing_status != "settled" {
sync_usage_settlement_snapshot(&mut **tx, &settlement).await?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&input.request_id)
.bind(&final_billing_status)
.bind(finalized_at)
.execute(&mut **tx)
.await
.map_postgres_err()?;
return Ok(Some(settlement));
}
if let Some(provider_id) = input
@@ -396,7 +672,7 @@ RETURNING CAST(monthly_used_usd AS DOUBLE PRECISION) AS monthly_used_usd
sync_usage_settlement_snapshot(&mut **tx, &settlement).await?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&input.request_id)
.bind(final_billing_status)
.bind(&final_billing_status)
.bind(finalized_at)
.execute(&mut **tx)
.await

View File

@@ -1,7 +1,10 @@
use async_trait::async_trait;
use sqlx::{sqlite::SqliteRow, Row};
use super::{SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput};
use super::{
finite_wallet_available_usd, plan_finite_wallet_debit, SettlementWriteRepository,
StoredUsageSettlement, UsageSettlementInput, SETTLEMENT_EPSILON_USD,
};
use crate::driver::sqlite::{sqlite_optional_real, sqlite_real, SqlitePool};
use crate::error::SqlResultExt;
use crate::DataLayerError;
@@ -147,6 +150,196 @@ fn now_unix_secs() -> Result<i64, DataLayerError> {
.map_err(|_| DataLayerError::InvalidInput("timestamp overflow".to_string()))
}
#[derive(Debug, Default)]
struct DailyQuotaDebitResult {
debited_usd: f64,
insufficient: bool,
}
#[derive(Debug)]
struct DailyQuotaGrant {
entitlement_id: String,
daily_quota_usd: f64,
usage_date: String,
allow_wallet_overage: bool,
}
fn daily_quota_usage_date(
reset_timezone: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<String, DataLayerError> {
let timezone = reset_timezone
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Asia/Shanghai")
.parse::<chrono_tz::Tz>()
.map_err(|err| DataLayerError::InvalidInput(format!("invalid reset_timezone: {err}")))?;
Ok(now.with_timezone(&timezone).date_naive().to_string())
}
fn daily_quota_grants_from_entitlement(
entitlement_id: &str,
entitlements: &serde_json::Value,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<DailyQuotaGrant>, DataLayerError> {
let mut grants = Vec::new();
let Some(items) = entitlements.as_array() else {
return Ok(grants);
};
for item in items {
if item.get("type").and_then(serde_json::Value::as_str) != Some("daily_quota") {
continue;
}
let daily_quota_usd = item
.get("daily_quota_usd")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
if !daily_quota_usd.is_finite() || daily_quota_usd <= 0.0 {
continue;
}
grants.push(DailyQuotaGrant {
entitlement_id: entitlement_id.to_string(),
daily_quota_usd,
usage_date: daily_quota_usage_date(
item.get("reset_timezone")
.and_then(serde_json::Value::as_str),
now,
)?,
allow_wallet_overage: item
.get("allow_wallet_overage")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
});
}
Ok(grants)
}
async fn consume_daily_quota_sqlite(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
user_id: &str,
request_id: &str,
total_cost_usd: f64,
wallet_available_usd: Option<f64>,
now_unix_secs: i64,
) -> Result<DailyQuotaDebitResult, DataLayerError> {
if total_cost_usd <= 0.0 {
return Ok(DailyQuotaDebitResult::default());
}
let rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND starts_at <= ?
AND expires_at > ?
ORDER BY expires_at ASC, created_at ASC, id ASC
"#,
)
.bind(user_id)
.bind(now_unix_secs)
.bind(now_unix_secs)
.fetch_all(&mut **tx)
.await
.map_sql_err()?;
let now = chrono::Utc::now();
let mut grants = Vec::new();
for row in rows {
let entitlement_id: String = row.try_get("id").map_sql_err()?;
let entitlements_raw: String = row.try_get("entitlements_snapshot").map_sql_err()?;
let entitlements =
serde_json::from_str::<serde_json::Value>(&entitlements_raw).map_err(|err| {
DataLayerError::UnexpectedValue(format!(
"user_plan_entitlements.entitlements_snapshot invalid json: {err}"
))
})?;
grants.extend(daily_quota_grants_from_entitlement(
&entitlement_id,
&entitlements,
now,
)?);
}
if grants.is_empty() {
return Ok(DailyQuotaDebitResult::default());
}
let mut grants_with_remaining = Vec::new();
let mut total_remaining = 0.0;
let mut allow_wallet_overage = true;
for grant in grants {
allow_wallet_overage &= grant.allow_wallet_overage;
let used = sqlx::query_scalar::<_, f64>(
r#"
SELECT COALESCE(SUM(amount_usd), 0)
FROM entitlement_usage_ledgers
WHERE user_entitlement_id = ?
AND usage_date = ?
"#,
)
.bind(&grant.entitlement_id)
.bind(&grant.usage_date)
.fetch_one(&mut **tx)
.await
.map_sql_err()?;
let remaining = (grant.daily_quota_usd - used).max(0.0);
total_remaining += remaining;
grants_with_remaining.push((grant, remaining));
}
if !allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd {
return Ok(DailyQuotaDebitResult {
debited_usd: 0.0,
insufficient: true,
});
}
if allow_wallet_overage
&& wallet_available_usd.is_some_and(|available| {
total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
})
{
return Ok(DailyQuotaDebitResult {
debited_usd: 0.0,
insufficient: true,
});
}
let mut remaining_cost = total_cost_usd;
let mut debited = 0.0;
for (grant, balance_before) in grants_with_remaining {
if remaining_cost <= 0.000_000_01 || balance_before <= 0.0 {
continue;
}
let amount = remaining_cost.min(balance_before);
let balance_after = balance_before - amount;
sqlx::query(
r#"
INSERT OR IGNORE INTO entitlement_usage_ledgers (
id, user_entitlement_id, user_id, request_id, amount_usd,
balance_before, balance_after, usage_date, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&grant.entitlement_id)
.bind(user_id)
.bind(request_id)
.bind(amount)
.bind(balance_before)
.bind(balance_after)
.bind(&grant.usage_date)
.bind(now_unix_secs)
.execute(&mut **tx)
.await
.map_sql_err()?;
remaining_cost -= amount;
debited += amount;
}
Ok(DailyQuotaDebitResult {
debited_usd: debited,
insufficient: false,
})
}
#[async_trait]
impl SettlementWriteRepository for SqliteSettlementRepository {
async fn settle_usage(
@@ -175,21 +368,24 @@ impl SettlementWriteRepository for SqliteSettlementRepository {
};
let current_billing_status: String = usage_row.try_get("billing_status").map_sql_err()?;
if current_billing_status == "settled" || current_billing_status == "void" {
if matches!(
current_billing_status.as_str(),
"settled" | "void" | "insufficient_quota"
) {
let settlement = settlement_from_row(&usage_row)?;
tx.commit().await.map_sql_err()?;
return Ok(Some(settlement));
}
let final_billing_status = if input.status == "completed" {
"settled"
let mut final_billing_status = if input.status == "completed" {
"settled".to_string()
} else {
"void"
"void".to_string()
};
let mut settlement = StoredUsageSettlement {
request_id: input.request_id.clone(),
wallet_id: None,
billing_status: final_billing_status.to_string(),
billing_status: final_billing_status.clone(),
wallet_balance_before: None,
wallet_balance_after: None,
wallet_recharge_balance_before: None,
@@ -265,22 +461,101 @@ LIMIT 1
None
};
if let Some(wallet_row) = wallet_row {
let wallet_id: String = wallet_row.try_get("id").map_sql_err()?;
let before_recharge = sqlite_real(&wallet_row, "balance")?;
let before_gift = sqlite_real(&wallet_row, "gift_balance")?;
let limit_mode: String = wallet_row.try_get("limit_mode").map_sql_err()?;
let before_total = before_recharge + before_gift;
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
if !limit_mode.eq_ignore_ascii_case("unlimited") {
let gift_deduction = before_gift.max(0.0).min(input.total_cost_usd);
let recharge_deduction = input.total_cost_usd - gift_deduction;
after_gift = before_gift - gift_deduction;
after_recharge = before_recharge - recharge_deduction;
let wallet_available_usd = match wallet_row.as_ref() {
Some(row) => {
let limit_mode: String = row.try_get("limit_mode").map_sql_err()?;
if limit_mode.eq_ignore_ascii_case("unlimited") {
None
} else {
Some(finite_wallet_available_usd(
sqlite_real(row, "balance")?,
sqlite_real(row, "gift_balance")?,
))
}
}
sqlx::query(
r#"
None => Some(0.0),
};
let wallet_debit_cost_usd = if !api_key_is_standalone {
if let Some(user_id) = input.user_id.as_deref().filter(|value| !value.is_empty()) {
let quota = consume_daily_quota_sqlite(
&mut tx,
user_id,
&input.request_id,
input.total_cost_usd,
wallet_available_usd,
updated_at,
)
.await?;
if quota.insufficient {
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
0.0
} else {
(input.total_cost_usd - quota.debited_usd).max(0.0)
}
} else {
input.total_cost_usd
}
} else {
input.total_cost_usd
};
if final_billing_status != "settled" {
sqlx::query(UPSERT_USAGE_SETTLEMENT_SNAPSHOT_SQL)
.bind(&settlement.request_id)
.bind(&settlement.billing_status)
.bind(settlement.wallet_id.as_deref())
.bind(settlement.wallet_balance_before)
.bind(settlement.wallet_balance_after)
.bind(settlement.wallet_recharge_balance_before)
.bind(settlement.wallet_recharge_balance_after)
.bind(settlement.wallet_gift_balance_before)
.bind(settlement.wallet_gift_balance_after)
.bind(settlement.provider_monthly_used_usd)
.bind(settlement.finalized_at_unix_secs.map(|value| value as i64))
.bind(updated_at)
.bind(updated_at)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&final_billing_status)
.bind(finalized_at)
.bind(&input.request_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
tx.commit().await.map_sql_err()?;
return Ok(Some(settlement));
}
if wallet_debit_cost_usd > SETTLEMENT_EPSILON_USD {
if let Some(wallet_row) = wallet_row {
let wallet_id: String = wallet_row.try_get("id").map_sql_err()?;
let before_recharge = sqlite_real(&wallet_row, "balance")?;
let before_gift = sqlite_real(&wallet_row, "gift_balance")?;
let limit_mode: String = wallet_row.try_get("limit_mode").map_sql_err()?;
let before_total = before_recharge + before_gift;
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
if !limit_mode.eq_ignore_ascii_case("unlimited") {
let debit_plan = plan_finite_wallet_debit(
before_recharge,
before_gift,
wallet_debit_cost_usd,
);
if debit_plan.covered_usd() + SETTLEMENT_EPSILON_USD < wallet_debit_cost_usd
{
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
} else {
after_recharge = before_recharge - debit_plan.recharge_deduction;
after_gift = before_gift - debit_plan.gift_deduction;
}
}
if final_billing_status == "settled" {
sqlx::query(
r#"
UPDATE wallets
SET
balance = ?,
@@ -289,23 +564,57 @@ SET
updated_at = ?
WHERE id = ?
"#,
)
.bind(after_recharge)
.bind(after_gift)
.bind(input.total_cost_usd)
.bind(updated_at)
.bind(&wallet_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
)
.bind(after_recharge)
.bind(after_gift)
.bind(wallet_debit_cost_usd)
.bind(updated_at)
.bind(&wallet_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
}
settlement.wallet_id = Some(wallet_id);
settlement.wallet_balance_before = Some(before_total);
settlement.wallet_balance_after = Some(after_recharge + after_gift);
settlement.wallet_recharge_balance_before = Some(before_recharge);
settlement.wallet_recharge_balance_after = Some(after_recharge);
settlement.wallet_gift_balance_before = Some(before_gift);
settlement.wallet_gift_balance_after = Some(after_gift);
settlement.wallet_id = Some(wallet_id);
settlement.wallet_balance_before = Some(before_total);
settlement.wallet_balance_after = Some(after_recharge + after_gift);
settlement.wallet_recharge_balance_before = Some(before_recharge);
settlement.wallet_recharge_balance_after = Some(after_recharge);
settlement.wallet_gift_balance_before = Some(before_gift);
settlement.wallet_gift_balance_after = Some(after_gift);
} else {
final_billing_status = "insufficient_quota".to_string();
settlement.billing_status = final_billing_status.clone();
}
}
if final_billing_status != "settled" {
sqlx::query(UPSERT_USAGE_SETTLEMENT_SNAPSHOT_SQL)
.bind(&settlement.request_id)
.bind(&settlement.billing_status)
.bind(settlement.wallet_id.as_deref())
.bind(settlement.wallet_balance_before)
.bind(settlement.wallet_balance_after)
.bind(settlement.wallet_recharge_balance_before)
.bind(settlement.wallet_recharge_balance_after)
.bind(settlement.wallet_gift_balance_before)
.bind(settlement.wallet_gift_balance_after)
.bind(settlement.provider_monthly_used_usd)
.bind(settlement.finalized_at_unix_secs.map(|value| value as i64))
.bind(updated_at)
.bind(updated_at)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(&final_billing_status)
.bind(finalized_at)
.bind(&input.request_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
tx.commit().await.map_sql_err()?;
return Ok(Some(settlement));
}
if let Some(provider_id) = input
@@ -360,7 +669,7 @@ WHERE id = ?
.map_sql_err()?;
sqlx::query(FINALIZE_USAGE_BILLING_SQL)
.bind(final_billing_status)
.bind(&final_billing_status)
.bind(finalized_at)
.bind(&input.request_id)
.execute(&mut *tx)
@@ -413,8 +722,8 @@ mod tests {
assert_eq!(settlement.wallet_id.as_deref(), Some("wallet-1"));
assert_eq!(settlement.wallet_balance_before, Some(12.0));
assert_eq!(settlement.wallet_balance_after, Some(9.0));
assert_eq!(settlement.wallet_recharge_balance_after, Some(9.0));
assert_eq!(settlement.wallet_gift_balance_after, Some(0.0));
assert_eq!(settlement.wallet_recharge_balance_after, Some(7.0));
assert_eq!(settlement.wallet_gift_balance_after, Some(2.0));
assert_eq!(settlement.provider_monthly_used_usd, Some(7.0));
let wallet = sqlx::query(
@@ -423,8 +732,8 @@ mod tests {
.fetch_one(&pool)
.await
.expect("wallet should load");
assert_eq!(wallet.try_get::<f64, _>("balance").unwrap(), 9.0);
assert_eq!(wallet.try_get::<f64, _>("gift_balance").unwrap(), 0.0);
assert_eq!(wallet.try_get::<f64, _>("balance").unwrap(), 7.0);
assert_eq!(wallet.try_get::<f64, _>("gift_balance").unwrap(), 2.0);
assert_eq!(wallet.try_get::<f64, _>("total_consumed").unwrap(), 3.0);
let second = repository

View File

@@ -9,10 +9,10 @@ use super::types::{
AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery, AdminWalletLedgerQuery,
AdminWalletListQuery, AdminWalletRefundRequestListQuery, CompleteAdminWalletRefundInput,
CreateAdminRedeemCodeBatchInput, CreateAdminRedeemCodeBatchResult,
CreateManualWalletRechargeInput, CreateWalletRechargeOrderInput,
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
CreateWalletRefundRequestOutcome, CreatedAdminRedeemCodePlaintext,
CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
CreateManualWalletRechargeInput, CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome,
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome,
CreatedAdminRedeemCodePlaintext, CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
RedeemWalletCodeInput, RedeemWalletCodeOutcome, StoredAdminPaymentCallback,
@@ -947,6 +947,118 @@ impl WalletWriteRepository for InMemoryWalletRepository {
Ok(CreateWalletRechargeOrderOutcome::Created(order))
}
async fn create_plan_purchase_order(
&self,
input: CreatePlanPurchaseOrderInput,
) -> Result<CreatePlanPurchaseOrderOutcome, DataLayerError> {
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 input.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,
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.payment_orders_by_id
.write()
.expect("wallet repo lock")
.insert(order.id.clone(), order.clone());
Ok(CreatePlanPurchaseOrderOutcome::Created(order))
}
async fn create_wallet_refund_request(
&self,
input: CreateWalletRefundRequestInput,
@@ -1541,9 +1653,11 @@ impl WalletWriteRepository for InMemoryWalletRepository {
mod tests {
use super::{InMemoryWalletRepository, WalletReadSeed};
use crate::repository::wallet::{
AdminWalletListQuery, StoredAdminPaymentOrder, StoredAdminWalletRefund,
StoredWalletSnapshot, WalletLookupKey, WalletReadRepository,
AdminWalletListQuery, CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome,
StoredAdminPaymentOrder, StoredAdminWalletRefund, StoredWalletSnapshot, WalletLookupKey,
WalletReadRepository, WalletWriteRepository,
};
use serde_json::json;
fn sample_wallet() -> StoredWalletSnapshot {
StoredWalletSnapshot::new(
@@ -1762,6 +1876,110 @@ mod tests {
assert!(history.items.is_empty());
}
#[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 counts_pending_user_refunds_and_payment_orders() {
let repository = InMemoryWalletRepository::seed_read_model(WalletReadSeed {

View File

@@ -15,9 +15,10 @@ pub use types::{
AdminWalletRefundRequestListQuery, AdminWalletTransactionRecord,
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
CreateAdminRedeemCodeBatchResult, CreateManualWalletRechargeInput,
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome,
CreatedAdminRedeemCodePlaintext, CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome, CreateWalletRechargeOrderInput,
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
CreateWalletRefundRequestOutcome, CreatedAdminRedeemCodePlaintext,
CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
RedeemWalletCodeInput, RedeemWalletCodeOutcome, StoredAdminPaymentCallback,

View File

@@ -11,10 +11,10 @@ use super::{
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
AdminWalletRefundRequestListQuery, CompleteAdminWalletRefundInput,
CreateAdminRedeemCodeBatchInput, CreateAdminRedeemCodeBatchResult,
CreateManualWalletRechargeInput, CreateWalletRechargeOrderInput,
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
CreateWalletRefundRequestOutcome, CreatedAdminRedeemCodePlaintext,
CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
CreateManualWalletRechargeInput, CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome,
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome,
CreatedAdminRedeemCodePlaintext, CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
InMemoryWalletRepository, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
ProcessPaymentCallbackOutcome, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
@@ -75,6 +75,7 @@ FROM wallets
SELECT
id, order_no, wallet_id, user_id, amount_usd, pay_amount, pay_currency,
exchange_rate, refunded_amount_usd, refundable_amount_usd, payment_method,
payment_provider, payment_channel, order_kind, product_id, product_snapshot,
gateway_order_id, gateway_response, status,
created_at AS created_at_unix_ms,
paid_at AS paid_at_unix_secs,
@@ -653,9 +654,10 @@ VALUES (?, ?, 0, 0, 'finite', 'USD', 'active', 0, 0, 0, 0, ?, ?)
INSERT INTO payment_orders (
id, order_no, wallet_id, user_id, amount_usd, pay_amount, pay_currency,
exchange_rate, refunded_amount_usd, refundable_amount_usd, payment_method,
payment_provider, payment_channel, order_kind, fulfillment_status,
gateway_order_id, gateway_response, status, created_at, expires_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, 'pending', ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, 'wallet_recharge', 'pending', ?, ?, 'pending', ?, ?)
"#,
)
.bind(&order_id)
@@ -667,6 +669,8 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, 'pending', ?, ?)
.bind(input.pay_currency.as_deref())
.bind(input.exchange_rate)
.bind(&input.payment_method)
.bind(input.payment_provider.as_deref())
.bind(input.payment_channel.as_deref())
.bind(&input.gateway_order_id)
.bind(gateway_response)
.bind(now)
@@ -682,6 +686,163 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, 'pending', ?, ?)
))
}
async fn create_plan_purchase_order(
&self,
input: CreatePlanPurchaseOrderInput,
) -> Result<CreatePlanPurchaseOrderOutcome, DataLayerError> {
let now = current_unix_secs_i64();
let expires_at = i64::try_from(input.expires_at_unix_secs).map_err(|_| {
DataLayerError::InvalidInput("plan purchase expires_at overflow".to_string())
})?;
let gateway_response =
json_string(&input.gateway_response, "payment_orders.gateway_response")?;
let product_snapshot =
json_string(&input.product_snapshot, "payment_orders.product_snapshot")?;
let mut tx = self.pool.begin().await.map_sql_err()?;
let wallet_row = sqlx::query(
r#"
SELECT id, status
FROM wallets
WHERE user_id = ?
LIMIT 1
FOR UPDATE
"#,
)
.bind(&input.user_id)
.fetch_optional(&mut *tx)
.await
.map_sql_err()?;
let (wallet_id, wallet_status) = if let Some(row) = wallet_row {
(get::<String>(&row, "id")?, get::<String>(&row, "status")?)
} else {
let wallet_id = input
.preferred_wallet_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
sqlx::query(
r#"
INSERT INTO wallets (
id, user_id, balance, gift_balance, limit_mode, currency, status,
total_recharged, total_consumed, total_refunded, total_adjusted,
created_at, updated_at
)
VALUES (?, ?, 0, 0, 'finite', 'USD', 'active', 0, 0, 0, 0, ?, ?)
"#,
)
.bind(&wallet_id)
.bind(&input.user_id)
.bind(now)
.bind(now)
.execute(&mut *tx)
.await
.map_sql_err()?;
(wallet_id, "active".to_string())
};
if wallet_status != "active" {
tx.commit().await.map_sql_err()?;
return Ok(CreatePlanPurchaseOrderOutcome::WalletInactive);
}
let purchase_limit_scope = plan_purchase_limit_scope(&input.product_snapshot);
if purchase_limit_scope != "unlimited" {
let max_active_per_user = plan_max_active_per_user(&input.product_snapshot);
let mut active_count = if purchase_limit_scope == "lifetime" {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM user_plan_entitlements
WHERE user_id = ?
AND plan_id = ?
AND status = 'active'
"#,
)
.bind(&input.user_id)
.bind(&input.product_id)
.fetch_one(&mut *tx)
.await
.map_sql_err()?
} else {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM user_plan_entitlements
WHERE user_id = ?
AND plan_id = ?
AND status = 'active'
AND expires_at > ?
"#,
)
.bind(&input.user_id)
.bind(&input.product_id)
.bind(now)
.fetch_one(&mut *tx)
.await
.map_sql_err()?
};
active_count += sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM payment_orders
WHERE user_id = ?
AND product_id = ?
AND order_kind = 'plan_purchase'
AND status = 'pending'
AND expires_at > ?
"#,
)
.bind(&input.user_id)
.bind(&input.product_id)
.bind(now)
.fetch_one(&mut *tx)
.await
.map_sql_err()?;
if active_count >= max_active_per_user {
tx.commit().await.map_sql_err()?;
return Ok(CreatePlanPurchaseOrderOutcome::ActivePlanLimitReached);
}
}
let order_id = uuid::Uuid::new_v4().to_string();
sqlx::query(
r#"
INSERT INTO payment_orders (
id, order_no, wallet_id, user_id, amount_usd, pay_amount, pay_currency,
exchange_rate, refunded_amount_usd, refundable_amount_usd, payment_method,
payment_provider, payment_channel, order_kind, product_id, product_snapshot,
fulfillment_status, gateway_order_id, gateway_response, status, created_at, expires_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, 'plan_purchase', ?, ?, 'pending', ?, ?, 'pending', ?, ?)
"#,
)
.bind(&order_id)
.bind(&input.order_no)
.bind(&wallet_id)
.bind(&input.user_id)
.bind(input.amount_usd)
.bind(input.pay_amount)
.bind(&input.pay_currency)
.bind(input.exchange_rate)
.bind(&input.payment_method)
.bind(input.payment_provider.as_deref())
.bind(input.payment_channel.as_deref())
.bind(&input.product_id)
.bind(product_snapshot)
.bind(&input.gateway_order_id)
.bind(gateway_response)
.bind(now)
.bind(expires_at)
.execute(&mut *tx)
.await
.map_sql_err()?;
let row = mysql_payment_order_by_id(&mut tx, &order_id).await?;
tx.commit().await.map_sql_err()?;
Ok(CreatePlanPurchaseOrderOutcome::Created(
map_payment_order_row(&row)?,
))
}
async fn create_wallet_refund_request(
&self,
input: CreateWalletRefundRequestInput,
@@ -949,11 +1110,22 @@ VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'received', ?, NULL, ?, NULL)
let order_no: String = get(&order_row, "order_no")?;
let order_wallet_id: String = get(&order_row, "wallet_id")?;
let order_payment_method: String = get(&order_row, "payment_method")?;
let order_payment_provider: Option<String> = get(&order_row, "payment_provider")?;
let order_payment_channel: Option<String> = get(&order_row, "payment_channel")?;
let order_kind: String = get(&order_row, "order_kind")?;
let order_amount_usd: f64 = get(&order_row, "amount_usd")?;
let order_pay_amount: Option<f64> = get(&order_row, "pay_amount")?;
let order_status: String = get(&order_row, "status")?;
let expires_at_unix_secs: Option<i64> = get(&order_row, "expires_at_unix_secs")?;
if (input.amount_usd - order_amount_usd).abs() > f64::EPSILON {
let amount_matches = if let (Some(callback_pay_amount), Some(order_pay_amount)) =
(input.pay_amount, order_pay_amount)
{
(callback_pay_amount - order_pay_amount).abs() <= 0.01
} else {
(input.amount_usd - order_amount_usd).abs() <= f64::EPSILON
};
if !amount_matches {
update_mysql_payment_callback_failure(
&mut tx,
&callback_id,
@@ -983,6 +1155,46 @@ VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'received', ?, NULL, ?, NULL)
error: "payment method mismatch".to_string(),
});
}
if let Some(expected_provider) = input.payment_provider.as_deref() {
if order_payment_provider
.as_deref()
.is_some_and(|value| !value.eq_ignore_ascii_case(expected_provider))
{
update_mysql_payment_callback_failure(
&mut tx,
&callback_id,
&input,
&payload,
"payment provider mismatch",
)
.await?;
tx.commit().await.map_sql_err()?;
return Ok(ProcessPaymentCallbackOutcome::Failed {
duplicate,
error: "payment provider mismatch".to_string(),
});
}
}
if let Some(expected_channel) = input.payment_channel.as_deref() {
if order_payment_channel
.as_deref()
.is_some_and(|value| !value.eq_ignore_ascii_case(expected_channel))
{
update_mysql_payment_callback_failure(
&mut tx,
&callback_id,
&input,
&payload,
"payment channel mismatch",
)
.await?;
tx.commit().await.map_sql_err()?;
return Ok(ProcessPaymentCallbackOutcome::Failed {
duplicate,
error: "payment channel mismatch".to_string(),
});
}
}
if order_status == "credited" {
mark_mysql_payment_callback_processed(
&mut tx,
@@ -1029,6 +1241,185 @@ VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'received', ?, NULL, ?, NULL)
});
}
if order_kind == "plan_purchase" {
let order_user_id: Option<String> = get(&order_row, "user_id")?;
let Some(user_id) = order_user_id else {
update_mysql_payment_callback_failure(
&mut tx,
&callback_id,
&input,
&payload,
"payment order user missing",
)
.await?;
tx.commit().await.map_sql_err()?;
return Ok(ProcessPaymentCallbackOutcome::Failed {
duplicate,
error: "payment order user missing".to_string(),
});
};
let product_id: Option<String> = get(&order_row, "product_id")?;
let snapshot = optional_json(
get::<Option<String>>(&order_row, "product_snapshot")?,
"payment_orders.product_snapshot",
)?
.unwrap_or_else(|| serde_json::json!({}));
let plan_id = product_id.unwrap_or_else(|| {
snapshot
.get("id")
.and_then(|value| value.as_str())
.unwrap_or("unknown")
.to_string()
});
let entitlements = plan_entitlements_snapshot(&snapshot);
let existing_entitlement_id = sqlx::query_scalar::<_, String>(
"SELECT id FROM user_plan_entitlements WHERE payment_order_id = ? LIMIT 1",
)
.bind(&order_id)
.fetch_optional(&mut *tx)
.await
.map_sql_err()?;
if existing_entitlement_id.is_none() {
sqlx::query("SELECT id FROM wallets WHERE id = ? LIMIT 1 FOR UPDATE")
.bind(&order_wallet_id)
.fetch_optional(&mut *tx)
.await
.map_sql_err()?;
let purchase_limit_scope = plan_purchase_limit_scope(&snapshot);
if purchase_limit_scope != "unlimited" {
let max_active_per_user = plan_max_active_per_user(&snapshot);
let active_count = if purchase_limit_scope == "lifetime" {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM user_plan_entitlements
WHERE user_id = ?
AND plan_id = ?
AND status = 'active'
"#,
)
.bind(&user_id)
.bind(&plan_id)
.fetch_one(&mut *tx)
.await
.map_sql_err()?
} else {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM user_plan_entitlements
WHERE user_id = ?
AND plan_id = ?
AND status = 'active'
AND expires_at > ?
"#,
)
.bind(&user_id)
.bind(&plan_id)
.bind(now)
.fetch_one(&mut *tx)
.await
.map_sql_err()?
};
if active_count >= max_active_per_user {
update_mysql_payment_callback_failure(
&mut tx,
&callback_id,
&input,
&payload,
"plan purchase limit reached",
)
.await?;
tx.commit().await.map_sql_err()?;
return Ok(ProcessPaymentCallbackOutcome::Failed {
duplicate,
error: "plan purchase limit reached".to_string(),
});
}
}
replace_matching_plan_entitlements_mysql(&mut tx, &user_id, &snapshot, now).await?;
sqlx::query(
r#"
INSERT INTO user_plan_entitlements (
id, user_id, plan_id, payment_order_id, status, starts_at, expires_at,
entitlements_snapshot, created_at, updated_at
)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&user_id)
.bind(&plan_id)
.bind(&order_id)
.bind(now)
.bind(plan_expires_at_unix(&snapshot, now))
.bind(json_string(
&entitlements,
"user_plan_entitlements.entitlements_snapshot",
)?)
.bind(now)
.bind(now)
.execute(&mut *tx)
.await
.map_sql_err()?;
apply_plan_wallet_credit_mysql(
&mut tx,
&order_wallet_id,
&order_id,
&input.payment_method,
&entitlements,
now,
)
.await?;
}
sqlx::query(
r#"
UPDATE payment_orders
SET gateway_order_id = COALESCE(?, gateway_order_id),
gateway_response = ?,
pay_amount = COALESCE(?, pay_amount),
pay_currency = COALESCE(?, pay_currency),
exchange_rate = COALESCE(?, exchange_rate),
status = 'credited',
fulfillment_status = 'fulfilled',
fulfillment_error = NULL,
paid_at = COALESCE(paid_at, ?),
credited_at = ?,
refundable_amount_usd = 0
WHERE id = ?
"#,
)
.bind(input.gateway_order_id.as_deref())
.bind(&payload)
.bind(input.pay_amount)
.bind(input.pay_currency.as_deref())
.bind(input.exchange_rate)
.bind(now)
.bind(now)
.bind(&order_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
let updated_order_row = mysql_payment_order_by_id(&mut tx, &order_id).await?;
mark_mysql_payment_callback_processed(
&mut tx,
&callback_id,
&input,
&payload,
&order_id,
&order_no,
)
.await?;
tx.commit().await.map_sql_err()?;
return Ok(ProcessPaymentCallbackOutcome::Applied {
duplicate,
order_id,
order_no,
wallet_id: order_wallet_id,
order: map_payment_order_row(&updated_order_row)?,
});
}
let Some(wallet_row) = sqlx::query(
r#"
SELECT id, status, balance, gift_balance
@@ -1912,6 +2303,173 @@ WHERE id = ? AND wallet_id = ?
));
}
let order_kind: String = get(&order_row, "order_kind")?;
if order_kind == "plan_purchase" {
let order_user_id: Option<String> = get(&order_row, "user_id")?;
let Some(user_id) = order_user_id else {
tx.commit().await.map_sql_err()?;
return Ok(WalletMutationOutcome::Invalid(
"payment order user missing".to_string(),
));
};
let product_id: Option<String> = get(&order_row, "product_id")?;
let snapshot = optional_json(
get::<Option<String>>(&order_row, "product_snapshot")?,
"payment_orders.product_snapshot",
)?
.unwrap_or_else(|| serde_json::json!({}));
let plan_id = product_id.unwrap_or_else(|| {
snapshot
.get("id")
.and_then(|value| value.as_str())
.unwrap_or("unknown")
.to_string()
});
let entitlements = plan_entitlements_snapshot(&snapshot);
let existing_entitlement_id = sqlx::query_scalar::<_, String>(
"SELECT id FROM user_plan_entitlements WHERE payment_order_id = ? LIMIT 1",
)
.bind(&input.order_id)
.fetch_optional(&mut *tx)
.await
.map_sql_err()?;
if existing_entitlement_id.is_none() {
let purchase_limit_scope = plan_purchase_limit_scope(&snapshot);
if purchase_limit_scope != "unlimited" {
let max_active_per_user = plan_max_active_per_user(&snapshot);
let active_count = if purchase_limit_scope == "lifetime" {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM user_plan_entitlements
WHERE user_id = ?
AND plan_id = ?
AND status = 'active'
"#,
)
.bind(&user_id)
.bind(&plan_id)
.fetch_one(&mut *tx)
.await
.map_sql_err()?
} else {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM user_plan_entitlements
WHERE user_id = ?
AND plan_id = ?
AND status = 'active'
AND expires_at > ?
"#,
)
.bind(&user_id)
.bind(&plan_id)
.bind(now)
.fetch_one(&mut *tx)
.await
.map_sql_err()?
};
if active_count >= max_active_per_user {
tx.commit().await.map_sql_err()?;
return Ok(WalletMutationOutcome::Invalid(
"plan purchase limit reached".to_string(),
));
}
}
replace_matching_plan_entitlements_mysql(&mut tx, &user_id, &snapshot, now).await?;
sqlx::query(
r#"
INSERT INTO user_plan_entitlements (
id, user_id, plan_id, payment_order_id, status, starts_at, expires_at,
entitlements_snapshot, created_at, updated_at
)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&user_id)
.bind(&plan_id)
.bind(&input.order_id)
.bind(now)
.bind(plan_expires_at_unix(&snapshot, now))
.bind(json_string(
&entitlements,
"user_plan_entitlements.entitlements_snapshot",
)?)
.bind(now)
.bind(now)
.execute(&mut *tx)
.await
.map_sql_err()?;
apply_plan_wallet_credit_mysql(
&mut tx,
&order.wallet_id,
&input.order_id,
&order.payment_method,
&entitlements,
now,
)
.await?;
}
let mut gateway_response = payment_gateway_response_map(order.gateway_response.clone());
if let Some(serde_json::Value::Object(map)) = input.gateway_response_patch.clone() {
gateway_response.extend(map);
}
gateway_response.insert("manual_credit".to_string(), serde_json::Value::Bool(true));
gateway_response.insert(
"credited_by".to_string(),
input
.operator_id
.clone()
.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null),
);
let gateway_response = json_string(
&serde_json::Value::Object(gateway_response),
"payment_orders.gateway_response",
)?;
let next_gateway_order_id = input.gateway_order_id.clone().or(order.gateway_order_id);
let next_pay_amount = input.pay_amount.or(order.pay_amount);
let next_pay_currency = input.pay_currency.clone().or(order.pay_currency);
let next_exchange_rate = input.exchange_rate.or(order.exchange_rate);
let next_paid_at = order.paid_at_unix_secs.unwrap_or(now as u64) as i64;
sqlx::query(
r#"
UPDATE payment_orders
SET gateway_order_id = ?,
gateway_response = ?,
pay_amount = ?,
pay_currency = ?,
exchange_rate = ?,
status = 'credited',
fulfillment_status = 'fulfilled',
fulfillment_error = NULL,
paid_at = ?,
credited_at = ?,
refundable_amount_usd = 0
WHERE id = ?
"#,
)
.bind(next_gateway_order_id.as_deref())
.bind(&gateway_response)
.bind(next_pay_amount)
.bind(next_pay_currency.as_deref())
.bind(next_exchange_rate)
.bind(next_paid_at)
.bind(now)
.bind(&input.order_id)
.execute(&mut *tx)
.await
.map_sql_err()?;
let order =
map_payment_order_row(&mysql_payment_order_by_id(&mut tx, &input.order_id).await?)?;
tx.commit().await.map_sql_err()?;
return Ok(WalletMutationOutcome::Applied((order, true)));
}
let Some(wallet_row) = mysql_wallet_by_id_for_update(&mut tx, &order.wallet_id).await?
else {
tx.commit().await.map_sql_err()?;
@@ -2592,6 +3150,240 @@ fn json_string(value: &serde_json::Value, field_name: &str) -> Result<String, Da
})
}
fn plan_entitlements_snapshot(snapshot: &serde_json::Value) -> serde_json::Value {
snapshot
.get("entitlements")
.or_else(|| snapshot.get("entitlements_json"))
.cloned()
.unwrap_or_else(|| serde_json::json!([]))
}
fn plan_max_active_per_user(snapshot: &serde_json::Value) -> i64 {
snapshot
.get("max_active_per_user")
.and_then(|value| value.as_i64())
.unwrap_or(1)
.max(1)
}
fn plan_purchase_limit_scope(snapshot: &serde_json::Value) -> &str {
match snapshot
.get("purchase_limit_scope")
.and_then(|value| value.as_str())
{
Some("lifetime") => "lifetime",
Some("unlimited") => "unlimited",
_ => "active_period",
}
}
fn plan_replacement_entitlement_types(snapshot: &serde_json::Value) -> Vec<&'static str> {
let entitlements = plan_entitlements_snapshot(snapshot);
let mut kinds = Vec::new();
if entitlement_snapshot_has_type(&entitlements, "daily_quota") {
kinds.push("daily_quota");
}
if entitlement_snapshot_has_type(&entitlements, "membership_group") {
kinds.push("membership_group");
}
kinds
}
fn entitlement_snapshot_has_type(snapshot: &serde_json::Value, entitlement_type: &str) -> bool {
snapshot.as_array().is_some_and(|items| {
items
.iter()
.any(|item| item.get("type").and_then(|value| value.as_str()) == Some(entitlement_type))
})
}
async fn replace_matching_plan_entitlements_mysql(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
user_id: &str,
snapshot: &serde_json::Value,
now: i64,
) -> Result<(), DataLayerError> {
let replacement_types = plan_replacement_entitlement_types(snapshot);
if replacement_types.is_empty() {
return Ok(());
}
let rows = sqlx::query(
r#"
SELECT id, entitlements_snapshot
FROM user_plan_entitlements
WHERE user_id = ?
AND status = 'active'
AND expires_at > ?
"#,
)
.bind(user_id)
.bind(now)
.fetch_all(&mut **tx)
.await
.map_sql_err()?;
for row in rows {
let entitlements = optional_json(
get::<Option<String>>(&row, "entitlements_snapshot")?,
"user_plan_entitlements.entitlements_snapshot",
)?
.unwrap_or_else(|| serde_json::json!([]));
let should_replace = replacement_types
.iter()
.any(|kind| entitlement_snapshot_has_type(&entitlements, kind));
if !should_replace {
continue;
}
let entitlement_id: String = get(&row, "id")?;
sqlx::query(
r#"
UPDATE user_plan_entitlements
SET status = 'replaced',
expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END,
updated_at = ?
WHERE id = ?
AND status = 'active'
AND expires_at > ?
"#,
)
.bind(now)
.bind(now)
.bind(now)
.bind(entitlement_id)
.bind(now)
.execute(&mut **tx)
.await
.map_sql_err()?;
}
Ok(())
}
fn plan_expires_at_unix(snapshot: &serde_json::Value, starts_at_unix_secs: i64) -> i64 {
let duration_value = snapshot
.get("duration_value")
.and_then(|value| value.as_i64())
.unwrap_or(1)
.max(1);
let days = match snapshot
.get("duration_unit")
.and_then(|value| value.as_str())
.unwrap_or("month")
{
"day" | "custom" => duration_value,
"year" => 365 * duration_value,
_ => 30 * duration_value,
};
starts_at_unix_secs.saturating_add(days.saturating_mul(86_400))
}
async fn apply_plan_wallet_credit_mysql(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
wallet_id: &str,
order_id: &str,
payment_method: &str,
entitlements: &serde_json::Value,
now: i64,
) -> Result<(), DataLayerError> {
let credits = entitlements
.as_array()
.into_iter()
.flatten()
.filter(|item| item.get("type").and_then(|value| value.as_str()) == Some("wallet_credit"))
.filter_map(|item| {
let amount = item.get("amount_usd").and_then(|value| value.as_f64())?;
if amount <= 0.0 || !amount.is_finite() {
return None;
}
let bucket = item
.get("balance_bucket")
.and_then(|value| value.as_str())
.unwrap_or("gift")
.to_ascii_lowercase();
Some((amount, bucket))
})
.collect::<Vec<_>>();
if credits.is_empty() {
return Ok(());
}
let Some(wallet_row) = sqlx::query(
"SELECT id, status, balance, gift_balance FROM wallets WHERE id = ? LIMIT 1 FOR UPDATE",
)
.bind(wallet_id)
.fetch_optional(&mut **tx)
.await
.map_sql_err()?
else {
return Err(DataLayerError::UnexpectedValue(
"wallet not found for plan wallet_credit".to_string(),
));
};
let status: String = get(&wallet_row, "status")?;
if status != "active" {
return Err(DataLayerError::UnexpectedValue(
"wallet is not active for plan wallet_credit".to_string(),
));
}
let mut recharge_balance: f64 = get(&wallet_row, "balance")?;
let mut gift_balance: f64 = get(&wallet_row, "gift_balance")?;
for (amount, bucket) in credits {
let before_recharge = recharge_balance;
let before_gift = gift_balance;
let before_total = before_recharge + before_gift;
let credits_recharge = bucket == "recharge";
if credits_recharge {
recharge_balance += amount;
} else {
gift_balance += amount;
}
let after_total = recharge_balance + gift_balance;
sqlx::query(
r#"
UPDATE wallets
SET balance = ?,
gift_balance = ?,
total_recharged = total_recharged + ?,
updated_at = ?
WHERE id = ?
"#,
)
.bind(recharge_balance)
.bind(gift_balance)
.bind(if credits_recharge { amount } else { 0.0 })
.bind(now)
.bind(wallet_id)
.execute(&mut **tx)
.await
.map_sql_err()?;
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 (?, ?, 'recharge', 'plan_wallet_credit', ?, ?, ?, ?, ?, ?, ?, 'payment_order', ?, NULL, ?, ?)
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(wallet_id)
.bind(amount)
.bind(before_total)
.bind(after_total)
.bind(before_recharge)
.bind(recharge_balance)
.bind(before_gift)
.bind(gift_balance)
.bind(order_id)
.bind(format!("套餐附赠余额({payment_method})"))
.bind(now)
.execute(&mut **tx)
.await
.map_sql_err()?;
}
Ok(())
}
fn default_refund_mode_for_payment_method(payment_method: &str) -> &'static str {
if matches!(
payment_method,
@@ -2752,6 +3544,7 @@ fn payment_order_select_sql(where_clause: &str) -> String {
SELECT
id, order_no, wallet_id, user_id, amount_usd, pay_amount, pay_currency,
exchange_rate, refunded_amount_usd, refundable_amount_usd, payment_method,
payment_provider, payment_channel, order_kind, product_id, product_snapshot,
gateway_order_id, gateway_response, status,
created_at AS created_at_unix_ms,
paid_at AS paid_at_unix_secs,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -606,6 +606,8 @@ pub struct CreateWalletRechargeOrderInput {
pub pay_currency: Option<String>,
pub exchange_rate: Option<f64>,
pub payment_method: String,
pub payment_provider: Option<String>,
pub payment_channel: Option<String>,
pub gateway_order_id: String,
pub gateway_response: serde_json::Value,
pub order_no: String,
@@ -619,6 +621,33 @@ pub enum CreateWalletRechargeOrderOutcome {
WalletInactive,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CreatePlanPurchaseOrderInput {
pub preferred_wallet_id: Option<String>,
pub user_id: String,
pub amount_usd: f64,
pub pay_amount: f64,
pub pay_currency: String,
pub exchange_rate: f64,
pub payment_method: String,
pub payment_provider: Option<String>,
pub payment_channel: Option<String>,
pub gateway_order_id: String,
pub gateway_response: serde_json::Value,
pub order_no: String,
pub product_id: String,
pub product_snapshot: serde_json::Value,
pub expires_at_unix_secs: u64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[allow(clippy::large_enum_variant)]
pub enum CreatePlanPurchaseOrderOutcome {
Created(StoredAdminPaymentOrder),
WalletInactive,
ActivePlanLimitReached,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CreateWalletRefundRequestInput {
pub wallet_id: String,
@@ -648,6 +677,8 @@ pub enum CreateWalletRefundRequestOutcome {
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ProcessPaymentCallbackInput {
pub payment_method: String,
pub payment_provider: Option<String>,
pub payment_channel: Option<String>,
pub callback_key: String,
pub order_no: Option<String>,
pub gateway_order_id: Option<String>,
@@ -932,6 +963,16 @@ pub trait WalletWriteRepository: Send + Sync {
input: CreateWalletRechargeOrderInput,
) -> Result<CreateWalletRechargeOrderOutcome, crate::DataLayerError>;
async fn create_plan_purchase_order(
&self,
input: CreatePlanPurchaseOrderInput,
) -> Result<CreatePlanPurchaseOrderOutcome, crate::DataLayerError> {
let _ = input;
Err(crate::DataLayerError::InvalidInput(
"plan purchase order creation is not available".to_string(),
))
}
async fn create_wallet_refund_request(
&self,
input: CreateWalletRefundRequestInput,