mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: add payment gateway and billing plans
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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#"
|
||||
|
||||
Reference in New Issue
Block a user