mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 20:50:20 +08:00
Merge remote-tracking branch 'origin/pr/604'
This commit is contained in:
@@ -715,6 +715,21 @@ impl GatewayDataState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn find_pending_plan_purchase_order_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
product_id: &str,
|
||||
) -> Result<Option<StoredAdminPaymentOrder>, DataLayerError> {
|
||||
match &self.wallet_reader {
|
||||
Some(repository) => {
|
||||
repository
|
||||
.find_pending_plan_purchase_order_by_user_id(user_id, product_id)
|
||||
.await
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn find_wallet_refund(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
|
||||
@@ -958,6 +958,64 @@ impl GatewayDataState {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_user_billing_and_wallet_for_tests<T>(
|
||||
user_repository: Arc<dyn UserReadRepository>,
|
||||
billing_repository: Arc<dyn BillingReadRepository>,
|
||||
wallet_repository: Arc<T>,
|
||||
) -> Self
|
||||
where
|
||||
T: aether_data::repository::wallet::WalletRepository + 'static,
|
||||
{
|
||||
let wallet_reader: Arc<dyn WalletReadRepository> = wallet_repository.clone();
|
||||
let wallet_writer: Arc<dyn WalletWriteRepository> = wallet_repository;
|
||||
Self {
|
||||
config: GatewayDataConfig::disabled(),
|
||||
backends: None,
|
||||
auth_api_key_reader: None,
|
||||
auth_api_key_writer: None,
|
||||
auth_module_reader: None,
|
||||
auth_module_writer: None,
|
||||
announcement_reader: None,
|
||||
announcement_writer: None,
|
||||
management_token_reader: None,
|
||||
management_token_writer: None,
|
||||
oauth_provider_reader: None,
|
||||
oauth_provider_writer: None,
|
||||
proxy_node_reader: None,
|
||||
proxy_node_writer: None,
|
||||
billing_reader: Some(billing_repository),
|
||||
gemini_file_mapping_reader: None,
|
||||
gemini_file_mapping_writer: None,
|
||||
global_model_reader: None,
|
||||
global_model_writer: None,
|
||||
minimal_candidate_selection_reader: None,
|
||||
request_candidate_reader: None,
|
||||
request_candidate_writer: None,
|
||||
provider_catalog_reader: None,
|
||||
provider_catalog_writer: None,
|
||||
pool_score_reader: None,
|
||||
pool_score_writer: None,
|
||||
provider_quota_reader: None,
|
||||
provider_quota_writer: None,
|
||||
routing_group_reader: None,
|
||||
routing_group_writer: None,
|
||||
usage_reader: None,
|
||||
usage_writer: None,
|
||||
user_reader: Some(user_repository),
|
||||
user_preferences: None,
|
||||
usage_worker_queue: None,
|
||||
video_task_reader: None,
|
||||
video_task_writer: None,
|
||||
background_task_reader: None,
|
||||
background_task_writer: None,
|
||||
wallet_reader: Some(wallet_reader),
|
||||
wallet_writer: Some(wallet_writer),
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_user_wallet_and_usage_for_tests<TUsage, TWallet>(
|
||||
user_repository: Arc<dyn UserReadRepository>,
|
||||
|
||||
@@ -327,6 +327,32 @@ pub(super) async fn handle_billing_plan_checkout(
|
||||
false,
|
||||
);
|
||||
}
|
||||
match state
|
||||
.find_pending_plan_purchase_order_by_user_id(&auth.user.id, &plan.id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(order)) => {
|
||||
return build_auth_json_response(
|
||||
http::StatusCode::OK,
|
||||
json!({
|
||||
"order": payment_order_payload(&order, &plan),
|
||||
"payment_instructions": sanitize_wallet_gateway_response(
|
||||
order.gateway_response.clone()
|
||||
),
|
||||
"reused_pending_order": true,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("pending billing checkout lookup failed: {err:?}"),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
let now = Utc::now();
|
||||
let order_no = billing_order_no(now);
|
||||
let expires_at = now + chrono::Duration::minutes(30);
|
||||
|
||||
@@ -138,6 +138,18 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn find_pending_plan_purchase_order_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
product_id: &str,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredAdminPaymentOrder>, GatewayError>
|
||||
{
|
||||
self.data
|
||||
.find_pending_plan_purchase_order_by_user_id(user_id, product_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn find_wallet_refund(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
|
||||
@@ -24,6 +24,7 @@ use aether_data::repository::auth::{
|
||||
use aether_data::repository::auth_modules::{
|
||||
InMemoryAuthModuleReadRepository, StoredLdapModuleConfig, StoredOAuthProviderModuleConfig,
|
||||
};
|
||||
use aether_data::repository::billing::InMemoryBillingReadRepository;
|
||||
use aether_data::repository::management_tokens::{
|
||||
InMemoryManagementTokenRepository, StoredManagementToken, StoredManagementTokenUserSummary,
|
||||
StoredManagementTokenWithUser,
|
||||
@@ -36,6 +37,10 @@ use aether_data::repository::users::{
|
||||
use aether_data::repository::wallet::{
|
||||
InMemoryWalletRepository, StoredWalletSnapshot, WalletWriteRepository,
|
||||
};
|
||||
use aether_data_contracts::repository::billing::{
|
||||
AdminBillingMutationOutcome, BillingPlanWriteInput, BillingReadRepository,
|
||||
PaymentGatewayConfigWriteInput,
|
||||
};
|
||||
use aether_data_contracts::repository::global_models::StoredProviderActiveGlobalModel;
|
||||
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
|
||||
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageRepository};
|
||||
@@ -3901,6 +3906,209 @@ async fn gateway_creates_wallet_recharge_orders_locally_without_proxying_upstrea
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_reuses_pending_billing_plan_checkout_order_without_proxying_upstream() {
|
||||
let now = Utc::now();
|
||||
let user = StoredUserAuthRecord::new(
|
||||
"user-billing-checkout-reuse".to_string(),
|
||||
Some("billing-checkout-reuse@example.com".to_string()),
|
||||
true,
|
||||
"billing_checkout_reuse_user".to_string(),
|
||||
Some("$2y$10$.OBQfixAECpsb8V/VS3csOMf00x2E/jD/gnud20t6RG0yiQosyOZ2".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
Some(json!(["openai"])),
|
||||
Some(json!(["openai:chat"])),
|
||||
Some(json!(["gpt-5"])),
|
||||
true,
|
||||
false,
|
||||
Some(now),
|
||||
Some(now),
|
||||
)
|
||||
.expect("auth user should build");
|
||||
let wallet = StoredWalletSnapshot::new(
|
||||
"wallet-billing-checkout-reuse".to_string(),
|
||||
Some(user.id.clone()),
|
||||
None,
|
||||
12.5,
|
||||
3.0,
|
||||
"finite".to_string(),
|
||||
"USD".to_string(),
|
||||
"active".to_string(),
|
||||
20.0,
|
||||
4.5,
|
||||
0.0,
|
||||
0.0,
|
||||
now.timestamp(),
|
||||
)
|
||||
.expect("wallet should build");
|
||||
let access_token = build_test_auth_token(
|
||||
"access",
|
||||
serde_json::Map::from_iter([
|
||||
("user_id".to_string(), json!(user.id.clone())),
|
||||
("role".to_string(), json!(user.role.clone())),
|
||||
(
|
||||
"created_at".to_string(),
|
||||
json!(user.created_at.map(|value| value.to_rfc3339())),
|
||||
),
|
||||
(
|
||||
"session_id".to_string(),
|
||||
json!("session-billing-checkout-reuse"),
|
||||
),
|
||||
]),
|
||||
now + chrono::Duration::hours(1),
|
||||
);
|
||||
let billing_repository = Arc::new(InMemoryBillingReadRepository::seed(Vec::new()));
|
||||
let encrypted_key = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "epay-secret")
|
||||
.expect("merchant key should encrypt");
|
||||
let AdminBillingMutationOutcome::Applied(_) = billing_repository
|
||||
.upsert_payment_gateway_config(&PaymentGatewayConfigWriteInput {
|
||||
provider: "epay".to_string(),
|
||||
enabled: true,
|
||||
endpoint_url: "https://pay.example.com/".to_string(),
|
||||
callback_base_url: Some("https://app.example.com".to_string()),
|
||||
merchant_id: "merchant-1".to_string(),
|
||||
merchant_key_encrypted: Some(encrypted_key),
|
||||
preserve_existing_secret: false,
|
||||
pay_currency: "CNY".to_string(),
|
||||
usd_exchange_rate: 7.25,
|
||||
min_recharge_usd: 1.0,
|
||||
channels_json: json!([
|
||||
{
|
||||
"channel": "alipay",
|
||||
"display_name": "支付宝"
|
||||
}
|
||||
]),
|
||||
})
|
||||
.await
|
||||
.expect("gateway config should create")
|
||||
else {
|
||||
panic!("gateway config should apply");
|
||||
};
|
||||
let plan = match billing_repository
|
||||
.create_billing_plan(&BillingPlanWriteInput {
|
||||
title: "每日额度月卡".to_string(),
|
||||
description: Some("测试套餐".to_string()),
|
||||
price_amount: 100.0,
|
||||
price_currency: "CNY".to_string(),
|
||||
duration_unit: "month".to_string(),
|
||||
duration_value: 1,
|
||||
enabled: true,
|
||||
sort_order: 1,
|
||||
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
|
||||
}
|
||||
]),
|
||||
})
|
||||
.await
|
||||
.expect("billing plan should create")
|
||||
{
|
||||
AdminBillingMutationOutcome::Applied(plan) => plan,
|
||||
other => panic!("billing plan should apply, got {other:?}"),
|
||||
};
|
||||
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/{*path}",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("proxied"))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let (_upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
|
||||
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![wallet]));
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_user_billing_and_wallet_for_tests(
|
||||
user_repository,
|
||||
billing_repository,
|
||||
wallet_repository,
|
||||
))
|
||||
.with_auth_sessions_for_tests([sample_auth_session(
|
||||
"user-billing-checkout-reuse",
|
||||
"session-billing-checkout-reuse",
|
||||
"device-billing-checkout-reuse",
|
||||
"refresh-token-placeholder",
|
||||
now,
|
||||
)]);
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let checkout_body = json!({
|
||||
"payment_provider": "epay",
|
||||
"payment_method": "epay",
|
||||
"payment_channel": "alipay",
|
||||
});
|
||||
let first_response = client
|
||||
.post(format!(
|
||||
"{gateway_url}/api/billing/plans/{}/checkout",
|
||||
plan.id
|
||||
))
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("x-client-device-id", "device-billing-checkout-reuse")
|
||||
.header("user-agent", "AetherTest/1.0")
|
||||
.json(&checkout_body)
|
||||
.send()
|
||||
.await
|
||||
.expect("first checkout request should succeed");
|
||||
assert_eq!(first_response.status(), StatusCode::OK);
|
||||
let first_payload: serde_json::Value = first_response
|
||||
.json()
|
||||
.await
|
||||
.expect("first checkout json should parse");
|
||||
let first_order_id = first_payload["order"]["id"]
|
||||
.as_str()
|
||||
.expect("first order id should exist")
|
||||
.to_string();
|
||||
assert_eq!(first_payload["order"]["status"], "pending");
|
||||
assert_eq!(first_payload["order"]["product_id"], plan.id);
|
||||
assert_eq!(
|
||||
first_payload["reused_pending_order"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
|
||||
let second_response = client
|
||||
.post(format!(
|
||||
"{gateway_url}/api/billing/plans/{}/checkout",
|
||||
plan.id
|
||||
))
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("x-client-device-id", "device-billing-checkout-reuse")
|
||||
.header("user-agent", "AetherTest/1.0")
|
||||
.json(&checkout_body)
|
||||
.send()
|
||||
.await
|
||||
.expect("second checkout request should succeed");
|
||||
assert_eq!(second_response.status(), StatusCode::OK);
|
||||
let second_payload: serde_json::Value = second_response
|
||||
.json()
|
||||
.await
|
||||
.expect("second checkout json should parse");
|
||||
assert_eq!(second_payload["order"]["id"], first_order_id);
|
||||
assert_eq!(second_payload["reused_pending_order"], true);
|
||||
assert_eq!(
|
||||
second_payload["payment_instructions"],
|
||||
first_payload["payment_instructions"]
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_creates_wallet_refunds_locally_without_proxying_upstream() {
|
||||
let now = Utc::now();
|
||||
|
||||
@@ -769,6 +769,41 @@ impl WalletReadRepository for InMemoryWalletRepository {
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn find_pending_plan_purchase_order_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
product_id: &str,
|
||||
) -> Result<Option<StoredAdminPaymentOrder>, DataLayerError> {
|
||||
let now = current_unix_secs();
|
||||
Ok(self
|
||||
.payment_orders_by_id
|
||||
.read()
|
||||
.expect("wallet repo lock")
|
||||
.values()
|
||||
.filter(|order| {
|
||||
order.user_id.as_deref() == Some(user_id)
|
||||
&& order.status == "pending"
|
||||
&& order
|
||||
.expires_at_unix_secs
|
||||
.is_some_and(|expires_at| expires_at > now)
|
||||
&& order
|
||||
.gateway_response
|
||||
.as_ref()
|
||||
.is_some_and(|gateway_response| {
|
||||
gateway_response
|
||||
.get("order_kind")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some("plan_purchase")
|
||||
&& gateway_response
|
||||
.get("product_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(product_id)
|
||||
})
|
||||
})
|
||||
.max_by_key(|order| order.created_at_unix_ms)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn find_wallet_refund(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
|
||||
@@ -536,6 +536,32 @@ WHERE wallet_id = ?
|
||||
.await
|
||||
}
|
||||
|
||||
async fn find_pending_plan_purchase_order_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
product_id: &str,
|
||||
) -> Result<Option<StoredAdminPaymentOrder>, DataLayerError> {
|
||||
let sql = payment_order_select_sql(
|
||||
r#"
|
||||
WHERE user_id = ?
|
||||
AND product_id = ?
|
||||
AND order_kind = 'plan_purchase'
|
||||
AND status = 'pending'
|
||||
AND expires_at > ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
);
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(user_id)
|
||||
.bind(product_id)
|
||||
.bind(current_unix_secs_i64())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
row.as_ref().map(map_payment_order_row).transpose()
|
||||
}
|
||||
|
||||
async fn find_wallet_refund(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
|
||||
@@ -577,6 +577,41 @@ WHERE user_id = $1
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
const FIND_PENDING_PLAN_PURCHASE_ORDER_BY_USER_SQL: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
order_no,
|
||||
wallet_id,
|
||||
user_id,
|
||||
CAST(amount_usd AS DOUBLE PRECISION) AS amount_usd,
|
||||
CAST(pay_amount AS DOUBLE PRECISION) AS pay_amount,
|
||||
pay_currency,
|
||||
CAST(exchange_rate AS DOUBLE PRECISION) AS exchange_rate,
|
||||
CAST(refunded_amount_usd AS DOUBLE PRECISION) AS refunded_amount_usd,
|
||||
CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd,
|
||||
payment_method,
|
||||
payment_provider,
|
||||
payment_channel,
|
||||
order_kind,
|
||||
product_id,
|
||||
product_snapshot,
|
||||
gateway_order_id,
|
||||
gateway_response,
|
||||
status,
|
||||
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_ms,
|
||||
CAST(EXTRACT(EPOCH FROM paid_at) AS BIGINT) AS paid_at_unix_secs,
|
||||
CAST(EXTRACT(EPOCH FROM credited_at) AS BIGINT) AS credited_at_unix_secs,
|
||||
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs
|
||||
FROM payment_orders
|
||||
WHERE user_id = $1
|
||||
AND product_id = $2
|
||||
AND order_kind = 'plan_purchase'
|
||||
AND status = 'pending'
|
||||
AND expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
const FIND_WALLET_REFUND_SQL: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
@@ -1112,6 +1147,20 @@ impl WalletReadRepository for SqlxWalletRepository {
|
||||
row.as_ref().map(map_admin_payment_order_row).transpose()
|
||||
}
|
||||
|
||||
async fn find_pending_plan_purchase_order_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
product_id: &str,
|
||||
) -> Result<Option<StoredAdminPaymentOrder>, DataLayerError> {
|
||||
let row = sqlx::query(FIND_PENDING_PLAN_PURCHASE_ORDER_BY_USER_SQL)
|
||||
.bind(user_id)
|
||||
.bind(product_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
row.as_ref().map(map_admin_payment_order_row).transpose()
|
||||
}
|
||||
|
||||
async fn find_wallet_refund(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
|
||||
@@ -783,6 +783,32 @@ LIMIT 1
|
||||
row.as_ref().map(map_payment_order_row).transpose()
|
||||
}
|
||||
|
||||
async fn find_pending_plan_purchase_order_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
product_id: &str,
|
||||
) -> Result<Option<StoredAdminPaymentOrder>, DataLayerError> {
|
||||
let sql = payment_order_select_sql(
|
||||
r#"
|
||||
WHERE user_id = ?
|
||||
AND product_id = ?
|
||||
AND order_kind = 'plan_purchase'
|
||||
AND status = 'pending'
|
||||
AND expires_at > ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
);
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(user_id)
|
||||
.bind(product_id)
|
||||
.bind(current_unix_secs_i64())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_sql_err()?;
|
||||
row.as_ref().map(map_payment_order_row).transpose()
|
||||
}
|
||||
|
||||
async fn find_wallet_refund(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
@@ -5460,6 +5486,175 @@ INSERT INTO billing_plans (
|
||||
assert_eq!(wallet_balance, 0.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_finds_reusable_pending_plan_purchase_order() {
|
||||
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 = SqliteWalletReadRepository::new(pool);
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, email, auth_source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind("user-pending-plan-1")
|
||||
.bind("Pending Buyer")
|
||||
.bind("pending-plan@example.com")
|
||||
.bind("local")
|
||||
.bind(1_i64)
|
||||
.bind(1_i64)
|
||||
.execute(repository.pool())
|
||||
.await
|
||||
.expect("user should seed");
|
||||
|
||||
let _wallet_order = match repository
|
||||
.create_wallet_recharge_order(CreateWalletRechargeOrderInput {
|
||||
preferred_wallet_id: Some("wallet-pending-plan-1".to_string()),
|
||||
user_id: "user-pending-plan-1".to_string(),
|
||||
amount_usd: 1.0,
|
||||
pay_amount: Some(1.0),
|
||||
pay_currency: Some("USD".to_string()),
|
||||
exchange_rate: Some(1.0),
|
||||
payment_method: "bootstrap".to_string(),
|
||||
payment_provider: None,
|
||||
payment_channel: None,
|
||||
gateway_order_id: "gateway-bootstrap-pending-plan-1".to_string(),
|
||||
gateway_response: json!({ "bootstrap": true }),
|
||||
order_no: "order-bootstrap-pending-plan-1".to_string(),
|
||||
expires_at_unix_secs: 4_102_444_800,
|
||||
})
|
||||
.await
|
||||
.expect("wallet should be created")
|
||||
{
|
||||
CreateWalletRechargeOrderOutcome::Created(order) => order,
|
||||
CreateWalletRechargeOrderOutcome::WalletInactive => {
|
||||
panic!("new wallet should be active")
|
||||
}
|
||||
};
|
||||
|
||||
let plan_snapshot = json!({
|
||||
"id": "pending-plan",
|
||||
"title": "每日额度月卡",
|
||||
"duration_unit": "month",
|
||||
"duration_value": 1,
|
||||
"max_active_per_user": 1,
|
||||
"purchase_limit_scope": "active_period",
|
||||
"entitlements": [
|
||||
{
|
||||
"type": "daily_quota",
|
||||
"daily_quota_usd": 50.0,
|
||||
"reset_timezone": "Asia/Shanghai",
|
||||
"allow_wallet_overage": false
|
||||
}
|
||||
]
|
||||
});
|
||||
let pending_order = match repository
|
||||
.create_plan_purchase_order(CreatePlanPurchaseOrderInput {
|
||||
preferred_wallet_id: None,
|
||||
user_id: "user-pending-plan-1".to_string(),
|
||||
amount_usd: 13.8,
|
||||
pay_amount: 100.0,
|
||||
pay_currency: "CNY".to_string(),
|
||||
exchange_rate: 7.24637681,
|
||||
payment_method: "alipay".to_string(),
|
||||
payment_provider: Some("epay".to_string()),
|
||||
payment_channel: Some("alipay".to_string()),
|
||||
gateway_order_id: "gateway-pending-plan-1".to_string(),
|
||||
gateway_response: json!({ "checkout": true }),
|
||||
order_no: "order-pending-plan-1".to_string(),
|
||||
product_id: "pending-plan".to_string(),
|
||||
product_snapshot: plan_snapshot.clone(),
|
||||
expires_at_unix_secs: 4_102_444_800,
|
||||
})
|
||||
.await
|
||||
.expect("pending plan order should create")
|
||||
{
|
||||
CreatePlanPurchaseOrderOutcome::Created(order) => order,
|
||||
other => panic!("pending plan order should be created, got {other:?}"),
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp().max(0);
|
||||
for (id, order_no, status, product_id, user_id, expires_at, created_at) in [
|
||||
(
|
||||
"expired-pending-plan-order",
|
||||
"order-expired-pending-plan",
|
||||
"pending",
|
||||
"pending-plan",
|
||||
"user-pending-plan-1",
|
||||
now - 10,
|
||||
now + 10,
|
||||
),
|
||||
(
|
||||
"credited-pending-plan-order",
|
||||
"order-credited-pending-plan",
|
||||
"credited",
|
||||
"pending-plan",
|
||||
"user-pending-plan-1",
|
||||
now + 3_600,
|
||||
now + 20,
|
||||
),
|
||||
(
|
||||
"other-user-pending-plan-order",
|
||||
"order-other-user-pending-plan",
|
||||
"pending",
|
||||
"pending-plan",
|
||||
"other-user",
|
||||
now + 3_600,
|
||||
now + 30,
|
||||
),
|
||||
] {
|
||||
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 (?, ?, ?, ?, 13.8, 100.0, 'CNY', 7.24637681, 0, 0, 'alipay',
|
||||
'epay', 'alipay', 'plan_purchase', ?, ?, 'pending', ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(order_no)
|
||||
.bind("wallet-pending-plan-1")
|
||||
.bind(user_id)
|
||||
.bind(product_id)
|
||||
.bind(plan_snapshot.to_string())
|
||||
.bind(format!("gateway-{id}"))
|
||||
.bind(json!({ "checkout": id }).to_string())
|
||||
.bind(status)
|
||||
.bind(created_at)
|
||||
.bind(expires_at)
|
||||
.execute(repository.pool())
|
||||
.await
|
||||
.expect("extra payment order should seed");
|
||||
}
|
||||
|
||||
let found = repository
|
||||
.find_pending_plan_purchase_order_by_user_id("user-pending-plan-1", "pending-plan")
|
||||
.await
|
||||
.expect("pending plan lookup should run")
|
||||
.expect("pending plan order should be found");
|
||||
assert_eq!(found.id, pending_order.id);
|
||||
assert_eq!(
|
||||
repository
|
||||
.find_pending_plan_purchase_order_by_user_id("user-pending-plan-1", "missing-plan")
|
||||
.await
|
||||
.expect("missing plan lookup should run"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
repository
|
||||
.find_pending_plan_purchase_order_by_user_id("missing-user", "pending-plan")
|
||||
.await
|
||||
.expect("missing user lookup should run"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_plan_purchase_replaces_same_class_entitlements_on_manual_credit() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
|
||||
@@ -927,6 +927,12 @@ pub trait WalletReadRepository: Send + Sync {
|
||||
order_id: &str,
|
||||
) -> Result<Option<StoredAdminPaymentOrder>, crate::DataLayerError>;
|
||||
|
||||
async fn find_pending_plan_purchase_order_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
product_id: &str,
|
||||
) -> Result<Option<StoredAdminPaymentOrder>, crate::DataLayerError>;
|
||||
|
||||
async fn find_wallet_refund(
|
||||
&self,
|
||||
wallet_id: &str,
|
||||
|
||||
@@ -126,6 +126,7 @@ export interface BillingCheckoutResponse {
|
||||
product?: BillingPlan | null
|
||||
}
|
||||
payment_instructions: Record<string, unknown>
|
||||
reused_pending_order?: boolean
|
||||
}
|
||||
|
||||
export interface UserPlanEntitlement {
|
||||
|
||||
@@ -351,7 +351,11 @@ async function checkoutPlan(plan: BillingPlan) {
|
||||
payment_channel: option.payment_channel,
|
||||
})
|
||||
latestCheckout.value = response
|
||||
success('套餐订单已创建')
|
||||
success(
|
||||
response.reused_pending_order
|
||||
? '已有待支付订单,已打开原支付链接'
|
||||
: '套餐订单已创建'
|
||||
)
|
||||
submitPaymentInstructions(response.payment_instructions)
|
||||
} catch (err) {
|
||||
log.error('创建套餐订单失败:', err)
|
||||
|
||||
Reference in New Issue
Block a user