2026-05-13 01:18:38 +08:00
|
|
|
use super::{
|
|
|
|
|
build_admin_payments_backend_unavailable_response, build_admin_payments_bad_request_response,
|
|
|
|
|
};
|
|
|
|
|
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
2026-05-19 19:01:10 +08:00
|
|
|
use crate::handlers::shared::{
|
2026-09-04 03:45:52 +08:00
|
|
|
normalize_payment_callback_base_url, normalize_payment_currency, normalize_payment_https_url,
|
2026-05-19 19:01:10 +08:00
|
|
|
payment_gateway_allow_user_refund, payment_gateway_channels_config_json,
|
|
|
|
|
payment_gateway_channels_json, payment_gateway_config_json, payment_gateway_refund_enabled,
|
2026-09-04 03:45:52 +08:00
|
|
|
payment_gateway_secret_is_legacy_unbound, payment_gateway_secret_keys_json,
|
|
|
|
|
PaymentGatewaySecretBinding,
|
2026-05-19 19:01:10 +08:00
|
|
|
};
|
2026-05-13 01:18:38 +08:00
|
|
|
use crate::{GatewayError, LocalMutationOutcome};
|
2026-09-04 03:45:52 +08:00
|
|
|
use aether_data_contracts::repository::billing::{
|
|
|
|
|
PaymentGatewayConfigCasWriteInput, PaymentGatewayConfigWriteInput,
|
|
|
|
|
};
|
2026-05-13 01:18:38 +08:00
|
|
|
use axum::{
|
|
|
|
|
body::Body,
|
|
|
|
|
http,
|
|
|
|
|
response::{IntoResponse, Response},
|
|
|
|
|
Json,
|
|
|
|
|
};
|
|
|
|
|
use serde::Deserialize;
|
2026-05-19 19:01:10 +08:00
|
|
|
use serde_json::{json, Value};
|
2026-05-13 01:18:38 +08:00
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
const PAYMENT_GATEWAY_CONFIG_CAS_MAX_ATTEMPTS: usize = 8;
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
2026-05-19 19:01:10 +08:00
|
|
|
struct PaymentGatewayConfigRequest {
|
2026-05-13 01:18:38 +08:00
|
|
|
#[serde(default)]
|
|
|
|
|
enabled: bool,
|
2026-05-19 19:01:10 +08:00
|
|
|
#[serde(default)]
|
2026-05-13 01:18:38 +08:00
|
|
|
endpoint_url: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
callback_base_url: Option<String>,
|
2026-05-19 19:01:10 +08:00
|
|
|
#[serde(default)]
|
2026-05-13 01:18:38 +08:00
|
|
|
merchant_id: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
merchant_key: Option<String>,
|
|
|
|
|
#[serde(default = "default_pay_currency")]
|
|
|
|
|
pay_currency: String,
|
|
|
|
|
#[serde(default = "default_usd_exchange_rate")]
|
|
|
|
|
usd_exchange_rate: f64,
|
|
|
|
|
#[serde(default = "default_min_recharge_usd")]
|
|
|
|
|
min_recharge_usd: f64,
|
|
|
|
|
#[serde(default = "default_channels")]
|
2026-05-19 19:01:10 +08:00
|
|
|
channels: Value,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
refund_enabled: bool,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
allow_user_refund: bool,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
config: Value,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
secrets: Value,
|
2026-05-13 01:18:38 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn default_pay_currency() -> String {
|
|
|
|
|
"CNY".to_string()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn default_usd_exchange_rate() -> f64 {
|
|
|
|
|
7.2
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn default_min_recharge_usd() -> f64 {
|
|
|
|
|
1.0
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
fn build_payment_gateway_conflict_response(detail: impl Into<String>) -> Response<Body> {
|
|
|
|
|
(
|
|
|
|
|
http::StatusCode::CONFLICT,
|
|
|
|
|
Json(json!({ "detail": detail.into() })),
|
|
|
|
|
)
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 19:01:10 +08:00
|
|
|
fn default_channels() -> Value {
|
2026-05-13 01:18:38 +08:00
|
|
|
json!([
|
2026-05-19 19:01:10 +08:00
|
|
|
{"channel": "alipay", "display_name": "支付宝", "fee_rate": 0.0},
|
|
|
|
|
{"channel": "wxpay", "display_name": "微信支付", "fee_rate": 0.0}
|
2026-05-13 01:18:38 +08:00
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_text(value: impl Into<String>, field: &str, max_len: usize) -> Result<String, String> {
|
|
|
|
|
let value = value.into();
|
|
|
|
|
let trimmed = value.trim();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
return Err(format!("{field} must not be empty"));
|
|
|
|
|
}
|
|
|
|
|
if trimmed.chars().count() > max_len {
|
|
|
|
|
return Err(format!("{field} exceeds maximum length {max_len}"));
|
|
|
|
|
}
|
|
|
|
|
Ok(trimmed.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_optional_text(
|
|
|
|
|
value: Option<String>,
|
|
|
|
|
max_len: usize,
|
|
|
|
|
) -> Result<Option<String>, String> {
|
|
|
|
|
let Some(value) = value else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let trimmed = value.trim();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
if trimmed.chars().count() > max_len {
|
|
|
|
|
return Err(format!("field exceeds maximum length {max_len}"));
|
|
|
|
|
}
|
|
|
|
|
Ok(Some(trimmed.to_string()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 19:01:10 +08:00
|
|
|
fn supported_payment_gateway_provider(provider: &str) -> bool {
|
|
|
|
|
matches!(provider, "epay" | "alipay" | "wxpay" | "stripe")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn admin_payment_gateway_provider_from_path(path: &str) -> Option<String> {
|
|
|
|
|
let trimmed = path.trim_end_matches('/');
|
|
|
|
|
let provider = trimmed
|
|
|
|
|
.strip_prefix("/api/admin/payments/gateways/")?
|
|
|
|
|
.strip_suffix("/test")
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
trimmed
|
|
|
|
|
.strip_prefix("/api/admin/payments/gateways/")
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
})
|
|
|
|
|
.trim()
|
|
|
|
|
.to_ascii_lowercase();
|
|
|
|
|
if provider.is_empty()
|
|
|
|
|
|| provider.contains('/')
|
|
|
|
|
|| !supported_payment_gateway_provider(&provider)
|
|
|
|
|
{
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some(provider)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
fn resolve_admin_payment_gateway_provider(path: &str, route_kind: &str) -> Option<String> {
|
|
|
|
|
match route_kind {
|
|
|
|
|
"get_epay_gateway" | "update_epay_gateway" | "test_epay_gateway" => {
|
|
|
|
|
Some("epay".to_string())
|
|
|
|
|
}
|
|
|
|
|
"get_payment_gateway" | "update_payment_gateway" | "test_payment_gateway" => {
|
|
|
|
|
admin_payment_gateway_provider_from_path(path)
|
|
|
|
|
}
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 19:01:10 +08:00
|
|
|
fn default_provider_channels(provider: &str) -> Value {
|
|
|
|
|
match provider {
|
|
|
|
|
"epay" => default_channels(),
|
|
|
|
|
"alipay" => json!([{"channel": "alipay", "display_name": "支付宝官方", "fee_rate": 0.0}]),
|
|
|
|
|
"wxpay" => json!([
|
|
|
|
|
{"channel": "native", "display_name": "微信 Native", "fee_rate": 0.0},
|
|
|
|
|
{"channel": "h5", "display_name": "微信 H5", "fee_rate": 0.0}
|
|
|
|
|
]),
|
|
|
|
|
"stripe" => json!([
|
|
|
|
|
{"channel": "card", "display_name": "Card", "fee_rate": 0.0},
|
|
|
|
|
{"channel": "alipay", "display_name": "Alipay", "fee_rate": 0.0},
|
|
|
|
|
{"channel": "wechat_pay", "display_name": "WeChat Pay", "fee_rate": 0.0},
|
|
|
|
|
{"channel": "link", "display_name": "Link", "fee_rate": 0.0}
|
|
|
|
|
]),
|
|
|
|
|
_ => json!([]),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn split_gateway_channels_config(
|
|
|
|
|
record: &aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
|
|
|
|
|
) -> (Value, Value, Value, bool, bool) {
|
|
|
|
|
(
|
|
|
|
|
payment_gateway_channels_json(&record.channels_json),
|
|
|
|
|
payment_gateway_config_json(&record.channels_json),
|
|
|
|
|
payment_gateway_secret_keys_json(&record.channels_json),
|
|
|
|
|
payment_gateway_refund_enabled(&record.channels_json),
|
|
|
|
|
payment_gateway_allow_user_refund(&record.channels_json),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 01:18:38 +08:00
|
|
|
fn gateway_config_payload(
|
|
|
|
|
record: aether_data_contracts::repository::billing::PaymentGatewayConfigRecord,
|
2026-05-19 19:01:10 +08:00
|
|
|
) -> Value {
|
|
|
|
|
let (channels, config, secret_keys, refund_enabled, allow_user_refund) =
|
|
|
|
|
split_gateway_channels_config(&record);
|
2026-05-13 01:18:38 +08:00
|
|
|
json!({
|
|
|
|
|
"provider": record.provider,
|
|
|
|
|
"enabled": record.enabled,
|
|
|
|
|
"endpoint_url": record.endpoint_url,
|
|
|
|
|
"callback_base_url": record.callback_base_url,
|
|
|
|
|
"merchant_id": record.merchant_id,
|
|
|
|
|
"has_secret": record.merchant_key_encrypted.as_deref().is_some_and(|value| !value.trim().is_empty()),
|
2026-05-19 19:01:10 +08:00
|
|
|
"has_secret_keys": secret_keys,
|
2026-05-13 01:18:38 +08:00
|
|
|
"pay_currency": record.pay_currency,
|
|
|
|
|
"usd_exchange_rate": record.usd_exchange_rate,
|
|
|
|
|
"min_recharge_usd": record.min_recharge_usd,
|
2026-05-19 19:01:10 +08:00
|
|
|
"channels": channels,
|
|
|
|
|
"refund_enabled": refund_enabled,
|
|
|
|
|
"allow_user_refund": allow_user_refund,
|
|
|
|
|
"config": config,
|
2026-05-13 01:18:38 +08:00
|
|
|
"created_at": record.created_at_unix_secs,
|
|
|
|
|
"updated_at": record.updated_at_unix_secs,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 19:01:10 +08:00
|
|
|
fn gateway_config_not_found_payload(provider: &str) -> Value {
|
|
|
|
|
json!({
|
|
|
|
|
"provider": provider,
|
|
|
|
|
"enabled": false,
|
|
|
|
|
"has_secret": false,
|
|
|
|
|
"has_secret_keys": [],
|
|
|
|
|
"channels": default_provider_channels(provider),
|
|
|
|
|
"refund_enabled": false,
|
|
|
|
|
"allow_user_refund": false,
|
|
|
|
|
"config": {},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_gateway_channel_fee_rate(value: Option<&Value>, index: usize) -> Result<f64, String> {
|
|
|
|
|
let Some(value) = value else {
|
|
|
|
|
return Ok(0.0);
|
|
|
|
|
};
|
|
|
|
|
let fee_rate = match value {
|
|
|
|
|
Value::Null => 0.0,
|
|
|
|
|
Value::Number(number) => number
|
|
|
|
|
.as_f64()
|
|
|
|
|
.ok_or_else(|| format!("channels[{index}].fee_rate must be a number"))?,
|
|
|
|
|
Value::String(value) => value
|
|
|
|
|
.trim()
|
|
|
|
|
.parse::<f64>()
|
|
|
|
|
.map_err(|_| format!("channels[{index}].fee_rate must be a number"))?,
|
|
|
|
|
_ => return Err(format!("channels[{index}].fee_rate must be a number")),
|
|
|
|
|
};
|
|
|
|
|
if !fee_rate.is_finite() || fee_rate < 0.0 {
|
|
|
|
|
return Err(format!("channels[{index}].fee_rate must be non-negative"));
|
|
|
|
|
}
|
|
|
|
|
Ok(fee_rate)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_gateway_channels(provider: &str, channels: Value) -> Result<Value, String> {
|
|
|
|
|
if channels.is_null() {
|
|
|
|
|
return Ok(default_provider_channels(provider));
|
|
|
|
|
}
|
|
|
|
|
let Some(items) = channels.as_array() else {
|
|
|
|
|
return Err("channels must be an array".to_string());
|
|
|
|
|
};
|
|
|
|
|
if items.is_empty() {
|
|
|
|
|
return Ok(default_provider_channels(provider));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let normalized = items
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(index, item)| {
|
|
|
|
|
let Some(object) = item.as_object() else {
|
|
|
|
|
return Err(format!("channels[{index}] must be an object"));
|
|
|
|
|
};
|
|
|
|
|
let channel = object
|
|
|
|
|
.get("channel")
|
|
|
|
|
.or_else(|| object.get("type"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.ok_or_else(|| format!("channels[{index}].channel must not be empty"))?;
|
|
|
|
|
let display_name = object
|
|
|
|
|
.get("display_name")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or(channel);
|
|
|
|
|
let fee_rate = normalize_gateway_channel_fee_rate(object.get("fee_rate"), index)?;
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"channel": channel,
|
|
|
|
|
"display_name": display_name,
|
|
|
|
|
"fee_rate": fee_rate,
|
|
|
|
|
}))
|
|
|
|
|
})
|
|
|
|
|
.collect::<Result<Vec<_>, String>>()?;
|
|
|
|
|
|
|
|
|
|
Ok(Value::Array(normalized))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_config_object(config: Value) -> Result<Value, String> {
|
|
|
|
|
if config.is_null() {
|
|
|
|
|
return Ok(json!({}));
|
|
|
|
|
}
|
|
|
|
|
if config.is_object() {
|
|
|
|
|
return Ok(config);
|
|
|
|
|
}
|
|
|
|
|
Err("config must be an object".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
fn merge_gateway_secret_maps(
|
|
|
|
|
existing_plaintext: Option<&str>,
|
|
|
|
|
updates: serde_json::Map<String, Value>,
|
|
|
|
|
) -> Result<serde_json::Map<String, Value>, &'static str> {
|
|
|
|
|
let mut merged = match existing_plaintext {
|
|
|
|
|
Some(plaintext) => serde_json::from_str::<Value>(plaintext)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|value| value.as_object().cloned())
|
|
|
|
|
.ok_or("existing gateway secrets have invalid format")?,
|
|
|
|
|
None => serde_json::Map::new(),
|
|
|
|
|
};
|
|
|
|
|
merged.extend(updates);
|
|
|
|
|
Ok(merged)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A legacy gateway ciphertext has no authenticated destination (or only the
|
|
|
|
|
/// provider in v2). Reusing it while changing endpoint/merchant would carry
|
|
|
|
|
/// an unknown credential into a different payment account. Require the
|
|
|
|
|
/// administrator to provide a replacement secret in that case.
|
|
|
|
|
fn legacy_secret_reuse_requires_reentry(
|
|
|
|
|
existing: Option<&aether_data_contracts::repository::billing::PaymentGatewayConfigRecord>,
|
|
|
|
|
requested_binding: &PaymentGatewaySecretBinding,
|
|
|
|
|
) -> bool {
|
|
|
|
|
let Some(record) = existing else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let Some(ciphertext) = record.merchant_key_encrypted.as_deref() else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
if !payment_gateway_secret_is_legacy_unbound(ciphertext) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// An invalid historical binding cannot establish that the legacy value
|
|
|
|
|
// belongs to the requested destination, so fail closed as well.
|
|
|
|
|
PaymentGatewaySecretBinding::from_record(record)
|
|
|
|
|
.map(|stored_binding| stored_binding != requested_binding.clone())
|
|
|
|
|
.unwrap_or(true)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 19:01:10 +08:00
|
|
|
fn encrypted_gateway_secret(
|
|
|
|
|
state: &AdminAppState<'_>,
|
2026-09-04 03:45:52 +08:00
|
|
|
binding: &PaymentGatewaySecretBinding,
|
2026-05-19 19:01:10 +08:00
|
|
|
payload: &PaymentGatewayConfigRequest,
|
2026-09-04 03:45:52 +08:00
|
|
|
existing: Option<&aether_data_contracts::repository::billing::PaymentGatewayConfigRecord>,
|
|
|
|
|
) -> Result<(Option<String>, Vec<Value>), Response<Body>> {
|
|
|
|
|
let provider = binding.provider.as_str();
|
|
|
|
|
let decrypt_existing = || {
|
|
|
|
|
if legacy_secret_reuse_requires_reentry(existing, binding) {
|
|
|
|
|
return Err(build_admin_payments_bad_request_response(
|
|
|
|
|
"endpoint_url or merchant_id changed; re-enter the gateway secret",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
existing
|
|
|
|
|
.and_then(|record| record.merchant_key_encrypted.as_deref())
|
|
|
|
|
.map(|ciphertext| {
|
|
|
|
|
crate::handlers::shared::open_payment_gateway_secret(
|
|
|
|
|
state.app(), binding, ciphertext,
|
|
|
|
|
)
|
|
|
|
|
.map(|projection| projection.plaintext)
|
|
|
|
|
.map_err(|_| {
|
|
|
|
|
build_admin_payments_backend_unavailable_response(
|
|
|
|
|
"existing gateway secrets are not valid for the requested destination; re-enter the secret",
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.transpose()
|
|
|
|
|
};
|
2026-05-19 19:01:10 +08:00
|
|
|
let secret_plaintext = if provider == "epay" {
|
2026-09-04 03:45:52 +08:00
|
|
|
let supplied = payload
|
2026-05-19 19:01:10 +08:00
|
|
|
.merchant_key
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
2026-09-04 03:45:52 +08:00
|
|
|
.map(ToOwned::to_owned);
|
|
|
|
|
if supplied.is_none() {
|
|
|
|
|
decrypt_existing()?;
|
|
|
|
|
}
|
|
|
|
|
supplied
|
2026-05-19 19:01:10 +08:00
|
|
|
} else {
|
|
|
|
|
let Some(secrets) = payload.secrets.as_object() else {
|
|
|
|
|
return if payload.secrets.is_null() {
|
2026-09-04 03:45:52 +08:00
|
|
|
decrypt_existing()?;
|
|
|
|
|
Ok((None, existing_gateway_secret_keys(existing)))
|
2026-05-19 19:01:10 +08:00
|
|
|
} else {
|
|
|
|
|
Err(build_admin_payments_bad_request_response(
|
|
|
|
|
"secrets must be an object",
|
|
|
|
|
))
|
|
|
|
|
};
|
|
|
|
|
};
|
2026-09-04 03:45:52 +08:00
|
|
|
let updates = secrets
|
2026-05-19 19:01:10 +08:00
|
|
|
.iter()
|
|
|
|
|
.filter_map(|(key, value)| {
|
|
|
|
|
let value = value.as_str()?.trim();
|
|
|
|
|
(!key.trim().is_empty() && !value.is_empty())
|
|
|
|
|
.then(|| (key.trim().to_string(), Value::String(value.to_string())))
|
|
|
|
|
})
|
|
|
|
|
.collect::<serde_json::Map<_, _>>();
|
2026-09-04 03:45:52 +08:00
|
|
|
if updates.is_empty() {
|
|
|
|
|
decrypt_existing()?;
|
|
|
|
|
return Ok((None, existing_gateway_secret_keys(existing)));
|
2026-05-19 19:01:10 +08:00
|
|
|
}
|
2026-09-04 03:45:52 +08:00
|
|
|
|
|
|
|
|
let existing_plaintext = decrypt_existing()?;
|
|
|
|
|
let merged = match merge_gateway_secret_maps(existing_plaintext.as_deref(), updates) {
|
|
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(detail) => {
|
|
|
|
|
return Err(build_admin_payments_backend_unavailable_response(detail));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
Some(Value::Object(merged).to_string())
|
2026-05-19 19:01:10 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let Some(secret_plaintext) = secret_plaintext else {
|
2026-09-04 03:45:52 +08:00
|
|
|
return Ok((None, existing_gateway_secret_keys(existing)));
|
2026-05-19 19:01:10 +08:00
|
|
|
};
|
2026-09-04 03:45:52 +08:00
|
|
|
let encrypted = crate::handlers::shared::seal_payment_gateway_secret(
|
|
|
|
|
state.app(),
|
|
|
|
|
binding,
|
|
|
|
|
&secret_plaintext,
|
|
|
|
|
)
|
|
|
|
|
.map_err(build_admin_payments_backend_unavailable_response)?;
|
|
|
|
|
let secret_keys = if provider == "epay" {
|
|
|
|
|
Vec::new()
|
|
|
|
|
} else {
|
|
|
|
|
let mut keys = serde_json::from_str::<Value>(&secret_plaintext)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|value| value.as_object().cloned())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|(key, _)| Value::String(key))
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
keys.sort_by(|left, right| left.as_str().cmp(&right.as_str()));
|
|
|
|
|
keys
|
|
|
|
|
};
|
|
|
|
|
Ok((Some(encrypted), secret_keys))
|
2026-05-19 19:01:10 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
fn existing_gateway_secret_keys(
|
|
|
|
|
record: Option<&aether_data_contracts::repository::billing::PaymentGatewayConfigRecord>,
|
|
|
|
|
) -> Vec<Value> {
|
|
|
|
|
let Some(record) = record else {
|
|
|
|
|
return Vec::new();
|
2026-05-19 19:01:10 +08:00
|
|
|
};
|
2026-09-04 03:45:52 +08:00
|
|
|
let (_, _, secret_keys, _, _) = split_gateway_channels_config(record);
|
|
|
|
|
secret_keys
|
2026-05-19 19:01:10 +08:00
|
|
|
.as_array()
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter(|value| value.as_str().is_some_and(|item| !item.trim().is_empty()))
|
2026-09-04 03:45:52 +08:00
|
|
|
.collect()
|
2026-05-19 19:01:10 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 01:18:38 +08:00
|
|
|
pub(super) async fn maybe_build_local_admin_payment_gateways_response(
|
|
|
|
|
state: &AdminAppState<'_>,
|
2026-05-19 19:01:10 +08:00
|
|
|
request_context: &AdminRequestContext<'_>,
|
2026-05-13 01:18:38 +08:00
|
|
|
request_body: Option<&axum::body::Bytes>,
|
|
|
|
|
route_kind: Option<&str>,
|
|
|
|
|
) -> Result<Option<Response<Body>>, GatewayError> {
|
|
|
|
|
match route_kind {
|
2026-05-19 19:01:10 +08:00
|
|
|
Some("get_epay_gateway") | Some("get_payment_gateway") => {
|
2026-09-04 03:45:52 +08:00
|
|
|
let Some(provider) = resolve_admin_payment_gateway_provider(
|
|
|
|
|
request_context.path(),
|
|
|
|
|
route_kind.expect("matched payment gateway route kind"),
|
|
|
|
|
) else {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(
|
|
|
|
|
"unsupported payment gateway provider",
|
|
|
|
|
)));
|
|
|
|
|
};
|
2026-05-19 19:01:10 +08:00
|
|
|
let record = state.app().find_payment_gateway_config(&provider).await?;
|
|
|
|
|
let payload = record
|
|
|
|
|
.map(gateway_config_payload)
|
|
|
|
|
.unwrap_or_else(|| gateway_config_not_found_payload(&provider));
|
2026-05-13 01:18:38 +08:00
|
|
|
Ok(Some(Json(payload).into_response()))
|
|
|
|
|
}
|
2026-05-19 19:01:10 +08:00
|
|
|
Some("update_epay_gateway") | Some("update_payment_gateway") => {
|
2026-09-04 03:45:52 +08:00
|
|
|
let Some(provider) = resolve_admin_payment_gateway_provider(
|
|
|
|
|
request_context.path(),
|
|
|
|
|
route_kind.expect("matched payment gateway route kind"),
|
|
|
|
|
) else {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(
|
|
|
|
|
"unsupported payment gateway provider",
|
|
|
|
|
)));
|
|
|
|
|
};
|
2026-05-13 01:18:38 +08:00
|
|
|
let Some(body) = request_body else {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(
|
|
|
|
|
"缺少请求体",
|
|
|
|
|
)));
|
|
|
|
|
};
|
2026-05-19 19:01:10 +08:00
|
|
|
let payload = match serde_json::from_slice::<PaymentGatewayConfigRequest>(body) {
|
2026-05-13 01:18:38 +08:00
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(_) => {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(
|
|
|
|
|
"输入验证失败",
|
|
|
|
|
)))
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if !payload.usd_exchange_rate.is_finite() || payload.usd_exchange_rate <= 0.0 {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(
|
|
|
|
|
"usd_exchange_rate must be positive",
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
if !payload.min_recharge_usd.is_finite() || payload.min_recharge_usd <= 0.0 {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(
|
|
|
|
|
"min_recharge_usd must be positive",
|
|
|
|
|
)));
|
|
|
|
|
}
|
2026-05-19 19:01:10 +08:00
|
|
|
|
|
|
|
|
let endpoint_url = if provider == "epay" {
|
2026-09-04 03:45:52 +08:00
|
|
|
match normalize_text(payload.endpoint_url.clone(), "endpoint_url", 512) {
|
|
|
|
|
Ok(value) => match normalize_payment_https_url(&value, "endpoint_url") {
|
|
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(detail) => {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-05-19 19:01:10 +08:00
|
|
|
Err(detail) => {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-09-04 03:45:52 +08:00
|
|
|
match normalize_optional_text(Some(payload.endpoint_url.clone()), 512) {
|
|
|
|
|
Ok(Some(value)) => match normalize_payment_https_url(&value, "endpoint_url") {
|
|
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(detail) => {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Ok(None) => String::new(),
|
2026-05-19 19:01:10 +08:00
|
|
|
Err(detail) => {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 01:18:38 +08:00
|
|
|
};
|
2026-09-04 03:45:52 +08:00
|
|
|
let callback_base_url =
|
|
|
|
|
match normalize_optional_text(payload.callback_base_url.clone(), 512) {
|
|
|
|
|
Ok(Some(value)) => match normalize_payment_callback_base_url(&value) {
|
|
|
|
|
Ok(value) => Some(value),
|
|
|
|
|
Err(detail) => {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
Ok(None) => None,
|
|
|
|
|
Err(detail) => {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-05-19 19:01:10 +08:00
|
|
|
let merchant_id = if provider == "epay" {
|
2026-09-04 03:45:52 +08:00
|
|
|
match normalize_text(payload.merchant_id.clone(), "merchant_id", 128) {
|
2026-05-19 19:01:10 +08:00
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(detail) => {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-09-04 03:45:52 +08:00
|
|
|
match normalize_optional_text(Some(payload.merchant_id.clone()), 128) {
|
2026-05-19 19:01:10 +08:00
|
|
|
Ok(value) => value.unwrap_or_default(),
|
|
|
|
|
Err(detail) => {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 01:18:38 +08:00
|
|
|
};
|
2026-09-04 03:45:52 +08:00
|
|
|
let pay_currency =
|
|
|
|
|
match normalize_payment_currency(&payload.pay_currency, "pay_currency") {
|
|
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(detail) => {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let config = match normalize_config_object(payload.config.clone()) {
|
2026-05-13 01:18:38 +08:00
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
|
|
|
|
};
|
2026-09-04 03:45:52 +08:00
|
|
|
let channels = match normalize_gateway_channels(&provider, payload.channels.clone()) {
|
2026-05-19 19:01:10 +08:00
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(detail) => return Ok(Some(build_admin_payments_bad_request_response(detail))),
|
|
|
|
|
};
|
|
|
|
|
let refund_enabled = payload.refund_enabled;
|
|
|
|
|
let allow_user_refund = refund_enabled && payload.allow_user_refund;
|
2026-09-04 03:45:52 +08:00
|
|
|
let binding =
|
|
|
|
|
match PaymentGatewaySecretBinding::new(&provider, &endpoint_url, &merchant_id) {
|
|
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(detail) => {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(detail)))
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let mut existing_record = state.app().find_payment_gateway_config(&provider).await?;
|
|
|
|
|
let expected_existing = existing_record.is_some();
|
|
|
|
|
for _ in 0..PAYMENT_GATEWAY_CONFIG_CAS_MAX_ATTEMPTS {
|
|
|
|
|
if expected_existing && existing_record.is_none() {
|
|
|
|
|
return Ok(Some(build_payment_gateway_conflict_response(
|
|
|
|
|
"payment gateway config was removed concurrently",
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
let (merchant_key_encrypted, secret_keys) = match encrypted_gateway_secret(
|
|
|
|
|
state,
|
|
|
|
|
&binding,
|
|
|
|
|
&payload,
|
|
|
|
|
existing_record.as_ref(),
|
|
|
|
|
) {
|
|
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(response) => return Ok(Some(response)),
|
|
|
|
|
};
|
|
|
|
|
let channels_json = payment_gateway_channels_config_json(
|
|
|
|
|
channels.clone(),
|
|
|
|
|
config.clone(),
|
|
|
|
|
Value::Array(secret_keys),
|
|
|
|
|
refund_enabled,
|
|
|
|
|
allow_user_refund,
|
|
|
|
|
);
|
|
|
|
|
let mutation = PaymentGatewayConfigCasWriteInput {
|
|
|
|
|
input: PaymentGatewayConfigWriteInput {
|
|
|
|
|
provider: provider.clone(),
|
|
|
|
|
enabled: payload.enabled,
|
|
|
|
|
endpoint_url: endpoint_url.clone(),
|
|
|
|
|
callback_base_url: callback_base_url.clone(),
|
|
|
|
|
merchant_id: merchant_id.clone(),
|
|
|
|
|
preserve_existing_secret: merchant_key_encrypted.is_none(),
|
|
|
|
|
merchant_key_encrypted,
|
|
|
|
|
pay_currency: pay_currency.clone(),
|
|
|
|
|
usd_exchange_rate: payload.usd_exchange_rate,
|
|
|
|
|
min_recharge_usd: payload.min_recharge_usd,
|
|
|
|
|
channels_json,
|
|
|
|
|
},
|
|
|
|
|
expected_existing,
|
|
|
|
|
expected_merchant_key_encrypted: existing_record
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|record| record.merchant_key_encrypted.clone()),
|
|
|
|
|
};
|
|
|
|
|
match state
|
|
|
|
|
.app()
|
|
|
|
|
.compare_and_swap_payment_gateway_config(&mutation)
|
|
|
|
|
.await?
|
|
|
|
|
{
|
|
|
|
|
LocalMutationOutcome::Applied(record) => {
|
|
|
|
|
return Ok(Some(Json(gateway_config_payload(record)).into_response()));
|
|
|
|
|
}
|
|
|
|
|
LocalMutationOutcome::NotFound if !expected_existing => {
|
|
|
|
|
return Ok(Some(build_payment_gateway_conflict_response(
|
|
|
|
|
"payment gateway config was created concurrently",
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
LocalMutationOutcome::NotFound => {
|
|
|
|
|
existing_record =
|
|
|
|
|
state.app().find_payment_gateway_config(&provider).await?;
|
|
|
|
|
}
|
|
|
|
|
LocalMutationOutcome::Invalid(detail) => {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(detail)));
|
|
|
|
|
}
|
|
|
|
|
LocalMutationOutcome::Unavailable => {
|
|
|
|
|
return Ok(Some(build_admin_payments_backend_unavailable_response(
|
|
|
|
|
"payment gateway config backend unavailable",
|
|
|
|
|
)));
|
|
|
|
|
}
|
2026-05-13 01:18:38 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-04 03:45:52 +08:00
|
|
|
Ok(Some(build_payment_gateway_conflict_response(
|
|
|
|
|
"payment gateway config changed too frequently; retry the request",
|
|
|
|
|
)))
|
2026-05-13 01:18:38 +08:00
|
|
|
}
|
2026-05-19 19:01:10 +08:00
|
|
|
Some("test_epay_gateway") | Some("test_payment_gateway") => {
|
2026-09-04 03:45:52 +08:00
|
|
|
let Some(provider) = resolve_admin_payment_gateway_provider(
|
|
|
|
|
request_context.path(),
|
|
|
|
|
route_kind.expect("matched payment gateway route kind"),
|
|
|
|
|
) else {
|
|
|
|
|
return Ok(Some(build_admin_payments_bad_request_response(
|
|
|
|
|
"unsupported payment gateway provider",
|
|
|
|
|
)));
|
|
|
|
|
};
|
2026-05-19 19:01:10 +08:00
|
|
|
let status = state.app().find_payment_gateway_config(&provider).await?;
|
2026-05-13 01:18:38 +08:00
|
|
|
let ok = status
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(|record| record.enabled && record.merchant_key_encrypted.is_some());
|
|
|
|
|
Ok(Some(
|
|
|
|
|
(
|
|
|
|
|
if ok {
|
|
|
|
|
http::StatusCode::OK
|
|
|
|
|
} else {
|
|
|
|
|
http::StatusCode::BAD_REQUEST
|
|
|
|
|
},
|
2026-05-19 19:01:10 +08:00
|
|
|
Json(json!({"ok": ok, "provider": provider})),
|
2026-05-13 01:18:38 +08:00
|
|
|
)
|
|
|
|
|
.into_response(),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
_ => Ok(None),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-04 03:45:52 +08:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
|
|
|
|
use aether_data_contracts::repository::billing::PaymentGatewayConfigRecord;
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
|
|
|
|
|
use super::{
|
|
|
|
|
legacy_secret_reuse_requires_reentry, merge_gateway_secret_maps,
|
|
|
|
|
resolve_admin_payment_gateway_provider,
|
|
|
|
|
};
|
|
|
|
|
use crate::handlers::shared::PaymentGatewaySecretBinding;
|
|
|
|
|
|
|
|
|
|
fn gateway_record(
|
|
|
|
|
endpoint_url: &str,
|
|
|
|
|
merchant_id: &str,
|
|
|
|
|
merchant_key_encrypted: Option<String>,
|
|
|
|
|
) -> PaymentGatewayConfigRecord {
|
|
|
|
|
PaymentGatewayConfigRecord {
|
|
|
|
|
provider: "stripe".to_string(),
|
|
|
|
|
enabled: true,
|
|
|
|
|
endpoint_url: endpoint_url.to_string(),
|
|
|
|
|
callback_base_url: None,
|
|
|
|
|
merchant_id: merchant_id.to_string(),
|
|
|
|
|
merchant_key_encrypted,
|
|
|
|
|
pay_currency: "USD".to_string(),
|
|
|
|
|
usd_exchange_rate: 1.0,
|
|
|
|
|
min_recharge_usd: 1.0,
|
|
|
|
|
channels_json: json!({}),
|
|
|
|
|
created_at_unix_secs: 1,
|
|
|
|
|
updated_at_unix_secs: 1,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn legacy_secret_reuse_requires_reentry_after_binding_change() {
|
|
|
|
|
let legacy = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "legacy-secret")
|
|
|
|
|
.expect("legacy secret should encrypt");
|
|
|
|
|
let old_record = gateway_record("https://api.stripe.com", "merchant-old", Some(legacy));
|
|
|
|
|
let changed_binding =
|
|
|
|
|
PaymentGatewaySecretBinding::new("stripe", "https://api.stripe.com", "merchant-new")
|
|
|
|
|
.expect("changed binding should be valid");
|
|
|
|
|
assert!(legacy_secret_reuse_requires_reentry(
|
|
|
|
|
Some(&old_record),
|
|
|
|
|
&changed_binding,
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let v2_record = gateway_record(
|
|
|
|
|
"https://api.stripe.com",
|
|
|
|
|
"merchant-old",
|
|
|
|
|
Some("aether-payment-gateway-secret-v2:legacy".to_string()),
|
|
|
|
|
);
|
|
|
|
|
assert!(legacy_secret_reuse_requires_reentry(
|
|
|
|
|
Some(&v2_record),
|
|
|
|
|
&changed_binding,
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn legacy_secret_reuse_is_allowed_only_for_same_binding_or_bound_v3() {
|
|
|
|
|
let legacy = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "legacy-secret")
|
|
|
|
|
.expect("legacy secret should encrypt");
|
|
|
|
|
let old_record =
|
|
|
|
|
gateway_record("https://API.STRIPE.COM:443/", "merchant-old", Some(legacy));
|
|
|
|
|
let same_binding = PaymentGatewaySecretBinding::new(
|
|
|
|
|
"stripe",
|
|
|
|
|
"https://api.stripe.com:443/",
|
|
|
|
|
" merchant-old ",
|
|
|
|
|
)
|
|
|
|
|
.expect("same binding should be valid");
|
|
|
|
|
assert!(!legacy_secret_reuse_requires_reentry(
|
|
|
|
|
Some(&old_record),
|
|
|
|
|
&same_binding,
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let v3_record = gateway_record(
|
|
|
|
|
"https://api.stripe.com",
|
|
|
|
|
"merchant-old",
|
|
|
|
|
Some("aether-payment-gateway-secret-v3:bound".to_string()),
|
|
|
|
|
);
|
|
|
|
|
let changed_binding =
|
|
|
|
|
PaymentGatewaySecretBinding::new("stripe", "https://api.stripe.com", "merchant-new")
|
|
|
|
|
.expect("changed binding should be valid");
|
|
|
|
|
assert!(!legacy_secret_reuse_requires_reentry(
|
|
|
|
|
Some(&v3_record),
|
|
|
|
|
&changed_binding,
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn stripe_secret_rotation_preserves_omitted_secret_fields() {
|
|
|
|
|
let existing = json!({
|
|
|
|
|
"secret_key": "old-secret-key",
|
|
|
|
|
"webhook_secret": "old-webhook"
|
|
|
|
|
})
|
|
|
|
|
.to_string();
|
|
|
|
|
let updates = json!({"webhook_secret": "new-webhook"})
|
|
|
|
|
.as_object()
|
|
|
|
|
.cloned()
|
|
|
|
|
.expect("updates should be an object");
|
|
|
|
|
|
|
|
|
|
let merged = Value::Object(
|
|
|
|
|
merge_gateway_secret_maps(Some(&existing), updates)
|
|
|
|
|
.expect("valid secret maps should merge"),
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(merged["secret_key"], "old-secret-key");
|
|
|
|
|
assert_eq!(merged["webhook_secret"], "new-webhook");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn wxpay_secret_rotation_preserves_omitted_secret_fields() {
|
|
|
|
|
let existing = json!({
|
|
|
|
|
"private_key": "old-private",
|
|
|
|
|
"api_v3_key": "old-api-v3-key",
|
|
|
|
|
"public_key": "old-public"
|
|
|
|
|
})
|
|
|
|
|
.to_string();
|
|
|
|
|
let updates = json!({"api_v3_key": "new-api-v3-key"})
|
|
|
|
|
.as_object()
|
|
|
|
|
.cloned()
|
|
|
|
|
.expect("updates should be an object");
|
|
|
|
|
|
|
|
|
|
let merged = Value::Object(
|
|
|
|
|
merge_gateway_secret_maps(Some(&existing), updates)
|
|
|
|
|
.expect("valid secret maps should merge"),
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(merged["private_key"], "old-private");
|
|
|
|
|
assert_eq!(merged["api_v3_key"], "new-api-v3-key");
|
|
|
|
|
assert_eq!(merged["public_key"], "old-public");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn generic_gateway_routes_never_fall_back_to_epay() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_admin_payment_gateway_provider(
|
|
|
|
|
"/api/admin/payments/gateways/stripe",
|
|
|
|
|
"update_payment_gateway",
|
|
|
|
|
)
|
|
|
|
|
.as_deref(),
|
|
|
|
|
Some("stripe")
|
|
|
|
|
);
|
|
|
|
|
assert!(resolve_admin_payment_gateway_provider(
|
|
|
|
|
"/api/admin/payments/gateways/unsupported",
|
|
|
|
|
"update_payment_gateway",
|
|
|
|
|
)
|
|
|
|
|
.is_none());
|
|
|
|
|
assert!(resolve_admin_payment_gateway_provider(
|
|
|
|
|
"/api/admin/payments/gateways/stripe/extra",
|
|
|
|
|
"get_payment_gateway",
|
|
|
|
|
)
|
|
|
|
|
.is_none());
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_admin_payment_gateway_provider(
|
|
|
|
|
"/api/admin/payments/epay",
|
|
|
|
|
"update_epay_gateway",
|
|
|
|
|
)
|
|
|
|
|
.as_deref(),
|
|
|
|
|
Some("epay")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|