This commit is contained in:
Ember Moth
2026-07-05 20:27:58 +08:00
commit 2744c70c5c
837 changed files with 53059 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "payment"
version = "0.1.0"
edition = "2021"
[dependencies]
alipay_sdk_rust = "1"
stripe = { version = "0.40", package = "async-stripe", features = ["runtime-tokio-hyper"] }
md5 = "0.7"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_urlencoded = "0.7"
thiserror = "2"
tracing = "0.1"
url = "2"
+162
View File
@@ -0,0 +1,162 @@
use alipay_sdk_rust::{
biz::{BizContenter, TradePrecreateBiz, TradeQueryBiz},
pay::{Payer, PayClient},
};
use crate::error::PaymentError;
use crate::types::Cents;
#[derive(Debug, Clone)]
pub struct Config {
pub app_id: String,
pub private_key: String,
pub public_key: String,
pub invoice_name: String,
pub notify_url: String,
pub sandbox: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OrderStatus {
Success,
Pending,
Closed,
Finished,
Error(String),
}
pub struct Notification {
pub order_no: String,
pub amount: Cents,
pub status: OrderStatus,
}
pub struct Provider {
client: Box<dyn Payer>,
config: Config,
}
impl Provider {
pub fn new(config: Config) -> Result<Self, PaymentError> {
let api_url = if config.sandbox {
"https://openapi-sandbox.dl.alipaydev.com/gateway.do"
} else {
"https://openapi.dl.alipaydev.com/gateway.do"
};
let app_id = config.app_id.clone();
let private_key = config.private_key.clone();
let public_key = config.public_key.clone();
let client: Box<dyn Payer> = Box::new(
PayClient::builder()
.api_url(api_url)
.app_id(&app_id)
.private_key(&private_key)
.public_key(&public_key)
.sign_type_rsa2()
.charset_utf8()
.format_json()
.version_1_0()
.build()
.map_err(|e| PaymentError::Config(format!("Alipay config error: {e}")))?,
);
Ok(Provider { client, config })
}
pub fn pre_create_trade(&self, order_no: &str, amount: Cents) -> Result<String, PaymentError> {
let mut biz = TradePrecreateBiz::new();
biz.set_out_trade_no(order_no.to_string().into());
biz.set_total_amount(amount.to_yuan_string().into());
biz.set_subject(self.config.invoice_name.clone().into());
biz.set("notify_url", self.config.notify_url.clone().into());
let resp = self
.client
.trade_precreate(&biz)
.map_err(|e| PaymentError::Alipay(e.to_string()))?;
if resp.response.code.as_deref() != Some("10000") {
return Err(PaymentError::Alipay(
resp.response
.sub_msg
.unwrap_or_else(|| "unknown alipay error".into()),
));
}
resp.response
.qr_code
.ok_or_else(|| PaymentError::Alipay("QR code not returned".into()))
}
pub fn query_trade(&self, order_no: &str) -> Result<OrderStatus, PaymentError> {
let mut biz = TradeQueryBiz::new();
biz.set_out_trade_no(order_no.to_string().into());
let resp = self
.client
.trade_query(&biz)
.map_err(|e| PaymentError::Alipay(e.to_string()))?;
match resp.response.trade_status.as_deref() {
Some("TRADE_SUCCESS") | Some("TRADE_FINISHED") => Ok(OrderStatus::Success),
Some("WAIT_BUYER_PAY") => Ok(OrderStatus::Pending),
Some("TRADE_CLOSED") => Ok(OrderStatus::Closed),
Some(s) => Ok(OrderStatus::Error(s.into())),
None => Ok(OrderStatus::Error("no trade status".into())),
}
}
pub fn decode_notification(&self, body: &[u8]) -> Result<Notification, PaymentError> {
let verified = self
.client
.async_verify_sign(body)
.map_err(|e| PaymentError::Alipay(format!("notification verify failed: {e}")))?;
if !verified {
return Err(PaymentError::Alipay(
"notification sign verification failed".into(),
));
}
let parsed: serde_json::Value = serde_json::from_slice(body)
.map_err(|e| PaymentError::Alipay(format!("invalid notify body: {e}")))?;
let response = parsed
.as_object()
.and_then(|m| m.values().next())
.and_then(|v| v.as_object())
.ok_or_else(|| PaymentError::Alipay("cannot parse notification".into()))?;
let trade_status = response
.get("trade_status")
.and_then(|v| v.as_str())
.unwrap_or("");
let out_trade_no = response
.get("out_trade_no")
.and_then(|v| v.as_str())
.unwrap_or("");
let total_amount = response
.get("total_amount")
.and_then(|v| v.as_str())
.unwrap_or("0");
let status = match trade_status {
"TRADE_SUCCESS" => OrderStatus::Success,
"WAIT_BUYER_PAY" => OrderStatus::Pending,
"TRADE_CLOSED" => OrderStatus::Closed,
"TRADE_FINISHED" => OrderStatus::Finished,
s => OrderStatus::Error(s.into()),
};
let amount = Cents::from_yuan(total_amount)
.map_err(|e| PaymentError::Alipay(format!("Invalid amount: {e}")))?;
Ok(Notification {
order_no: out_trade_no.into(),
amount,
status,
})
}
}
+146
View File
@@ -0,0 +1,146 @@
use std::collections::BTreeMap;
use crate::error::PaymentError;
use crate::types::Cents;
#[derive(Debug, Clone)]
pub struct Config {
pub pid: String,
pub url: String,
pub key: String,
pub pay_type: String,
}
pub struct Order {
pub name: String,
pub order_no: String,
pub amount: Cents,
pub sign_type: String,
pub notify_url: String,
pub return_url: String,
}
pub struct Provider {
pub pid: String,
pub url: String,
pub key: String,
pub pay_type: String,
}
impl Provider {
pub fn new(config: Config) -> Self {
Provider {
pid: config.pid,
url: config.url,
key: config.key,
pay_type: config.pay_type,
}
}
fn params_map(&self, order: &Order) -> BTreeMap<&str, String> {
let mut m = BTreeMap::new();
m.insert("pid", self.pid.clone());
m.insert("type", self.pay_type.clone());
m.insert("out_trade_no", order.order_no.clone());
m.insert("money", order.amount.to_yuan_string());
m.insert("name", order.name.clone());
m.insert("notify_url", order.notify_url.clone());
m.insert("return_url", order.return_url.clone());
m
}
fn create_sign(&self, params: &BTreeMap<&str, String>) -> String {
let query: String = params
.iter()
.filter(|(k, v)| !v.is_empty() && **k != "sign" && **k != "sign_type")
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&");
let text = format!("{}{}", query, self.key);
format!("{:x}", md5::compute(text))
}
pub fn create_pay_url(&self, order: &Order) -> Result<String, PaymentError> {
let params = self.params_map(order);
let sign = self.create_sign(&params);
let mut base_url = url::Url::parse(&self.url)
.map_err(|_| PaymentError::Config("invalid EPay URL".into()))?;
base_url = base_url
.join("/submit.php")
.map_err(|_| PaymentError::Config("invalid EPay path".into()))?;
{
let mut pairs = base_url.query_pairs_mut();
for (k, v) in &params {
pairs.append_pair(k, v);
}
pairs.append_pair("sign", &sign);
pairs.append_pair("sign_type", "MD5");
}
Ok(base_url.to_string())
}
pub fn verify_sign(&self, params: &std::collections::HashMap<String, String>) -> bool {
let mut sorted = BTreeMap::new();
for (k, v) in params {
sorted.insert(k.as_str(), v.clone());
}
let expected = params.get("sign").cloned().unwrap_or_default();
self.create_sign(&sorted) == expected
}
pub async fn query_order_status(&self, order_no: &str) -> Result<bool, PaymentError> {
let query_url = format!(
"{}/api.php?act=order&pid={}&out_trade_no={}",
self.url, self.pid, order_no
);
let resp = reqwest::get(&query_url).await?;
let body: serde_json::Value = resp.json().await?;
let status = body.get("status").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(status == 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_epay_sign() {
let provider = Provider::new(Config {
pid: "1654".into(),
url: "http://127.0.0.1".into(),
key: "LbTabbB580zWyhXhyyww7wwvy5u8k0wl".into(),
pay_type: "alipay".into(),
});
let order = Order {
name: "product".into(),
order_no: "202412152115078262977262254".into(),
amount: Cents(1000),
sign_type: "MD5".into(),
notify_url: "".into(),
return_url: "".into(),
};
let url = provider.create_pay_url(&order).unwrap();
assert!(url.contains("sign="));
assert!(url.contains("sign_type=MD5"));
// Verify sign from callback params (matches Go test data)
let params = std::collections::HashMap::from([
("pid".into(), "1654".into()),
("trade_no".into(), "2024121521150860990".into()),
("out_trade_no".into(), "202412152115078262977262254".into()),
("type".into(), "alipay".into()),
("name".into(), "product".into()),
("money".into(), "10".into()),
("trade_status".into(), "TRADE_SUCCESS".into()),
("sign".into(), "d3181f18ebdf9821f0ab6ee93faa82d1".into()),
("sign_type".into(), "MD5".into()),
]);
assert!(provider.verify_sign(&params));
}
}
+28
View File
@@ -0,0 +1,28 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PaymentError {
#[error("Stripe error: {0}")]
Stripe(#[from] stripe::StripeError),
#[error("Stripe webhook error: {0}")]
StripeWebhook(String),
#[error("Alipay error: {0}")]
Alipay(String),
#[error("EPay error: {0}")]
EPay(String),
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("URL parse error: {0}")]
Url(#[from] url::ParseError),
#[error("Serialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("Invalid configuration: {0}")]
Config(String),
}
+10
View File
@@ -0,0 +1,10 @@
pub mod alipay;
pub mod epay;
pub mod error;
pub mod platform;
pub mod stripe;
pub mod types;
pub use error::PaymentError;
pub use platform::{get_supported_platforms, Platform, PlatformInfo};
pub use types::{Cents, Notification, Order, PaymentSheet, User};
+146
View File
@@ -0,0 +1,146 @@
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Stripe,
AlipayF2F,
EPay,
Balance,
CryptoSaaS,
Unsupported,
}
const PLATFORM_NAMES: &[(&str, Platform)] = &[
("CryptoSaaS", Platform::CryptoSaaS),
("Stripe", Platform::Stripe),
("AlipayF2F", Platform::AlipayF2F),
("EPay", Platform::EPay),
("balance", Platform::Balance),
];
impl Platform {
pub fn from_str(s: &str) -> Self {
for &(name, platform) in PLATFORM_NAMES {
if name == s {
return platform;
}
}
Platform::Unsupported
}
pub fn as_str(&self) -> &'static str {
for &(name, platform) in PLATFORM_NAMES {
if platform == *self {
return name;
}
}
"unsupported"
}
}
impl fmt::Display for Platform {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for Platform {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match Self::from_str(s) {
Platform::Unsupported => Err(()),
p => Ok(p),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PlatformInfo {
pub platform: String,
pub platform_url: String,
pub platform_field_description: std::collections::HashMap<String, String>,
}
pub fn get_supported_platforms() -> Vec<PlatformInfo> {
vec![
PlatformInfo {
platform: "Stripe".into(),
platform_url: "https://stripe.com".into(),
platform_field_description: {
let mut m = std::collections::HashMap::new();
m.insert("public_key".into(), "Publishable key".into());
m.insert("secret_key".into(), "Secret key".into());
m.insert("webhook_secret".into(), "Webhook secret".into());
m.insert("payment".into(), "Payment Method, only supported card/alipay/wechat_pay".into());
m
},
},
PlatformInfo {
platform: "AlipayF2F".into(),
platform_url: "https://alipay.com".into(),
platform_field_description: {
let mut m = std::collections::HashMap::new();
m.insert("app_id".into(), "App ID".into());
m.insert("private_key".into(), "Private Key".into());
m.insert("public_key".into(), "Public Key".into());
m.insert("invoice_name".into(), "Invoice Name".into());
m.insert("sandbox".into(), "Sandbox Mode".into());
m
},
},
PlatformInfo {
platform: "EPay".into(),
platform_url: String::new(),
platform_field_description: {
let mut m = std::collections::HashMap::new();
m.insert("pid".into(), "PID".into());
m.insert("url".into(), "URL".into());
m.insert("key".into(), "Key".into());
m.insert("type".into(), "Type".into());
m
},
},
PlatformInfo {
platform: "CryptoSaaS".into(),
platform_url: "https://t.me/CryptoSaaSBot".into(),
platform_field_description: {
let mut m = std::collections::HashMap::new();
m.insert("endpoint".into(), "API Endpoint".into());
m.insert("account_id".into(), "Account ID".into());
m.insert("secret_key".into(), "Secret Key".into());
m
},
},
]
}
use std::fmt;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_platform_parse() {
assert_eq!(Platform::from_str("Stripe"), Platform::Stripe);
assert_eq!(Platform::from_str("AlipayF2F"), Platform::AlipayF2F);
assert_eq!(Platform::from_str("EPay"), Platform::EPay);
assert_eq!(Platform::from_str("balance"), Platform::Balance);
assert_eq!(Platform::from_str("CryptoSaaS"), Platform::CryptoSaaS);
assert_eq!(Platform::from_str("unknown"), Platform::Unsupported);
}
#[test]
fn test_platform_display() {
assert_eq!(Platform::Stripe.to_string(), "Stripe");
assert_eq!(Platform::Unsupported.to_string(), "unsupported");
}
#[test]
fn test_get_supported_platforms() {
let platforms = get_supported_platforms();
assert_eq!(platforms.len(), 4);
assert!(platforms.iter().any(|p| p.platform == "Stripe"));
assert!(platforms.iter().any(|p| p.platform == "EPay"));
}
}
+171
View File
@@ -0,0 +1,171 @@
use std::collections::HashMap;
use std::str::FromStr;
use stripe::{
Client as StripeClient, CreateCustomer, CreateEphemeralKey, CreatePaymentIntent,
CreateWebhookEndpoint, Customer, CustomerSearchParams, EphemeralKey, EventFilter,
EventObject, Expandable, PaymentIntent, PaymentIntentId, PaymentIntentStatus,
PaymentMethod, PaymentMethodId, Webhook, WebhookEndpoint,
};
use crate::error::PaymentError;
use crate::types::{Cents, Notification, Order, PaymentSheet, User};
pub const API_VERSION: &str = "2024-04-10";
#[derive(Debug, Clone)]
pub struct Config {
pub public_key: String,
pub secret_key: String,
pub webhook_secret: String,
}
pub struct Provider {
client: StripeClient,
config: Config,
}
impl Provider {
pub fn new(config: Config) -> Self {
let client = StripeClient::new(&config.secret_key);
Provider { client, config }
}
pub async fn create_payment_sheet(
&self,
order: &Order,
user: &User,
) -> Result<PaymentSheet, PaymentError> {
let customer = self.find_or_create_customer(user).await?;
let mut ek_params = CreateEphemeralKey::new();
ek_params.customer = Some(customer.id.clone());
let ek = EphemeralKey::create(&self.client, ek_params).await?;
let mut metadata = HashMap::new();
metadata.insert("order_no".to_string(), order.order_no.clone());
metadata.insert("user_id".to_string(), user.user_id.to_string());
metadata.insert("subscribe".to_string(), order.subscribe.clone());
let currency = stripe::Currency::from_str(&order.currency)
.map_err(|e| PaymentError::Config(format!("invalid currency: {e}")))?;
let mut pi_params = CreatePaymentIntent::new(order.amount.0, currency);
pi_params.customer = Some(customer.id.clone());
pi_params.payment_method_types = Some(vec![order.payment.clone()]);
pi_params.metadata = Some(metadata);
let pi = PaymentIntent::create(&self.client, pi_params).await?;
Ok(PaymentSheet {
client_secret: pi.client_secret.unwrap_or_default(),
ephemeral_key: ek.secret.unwrap_or_default(),
customer: customer.id.to_string(),
publishable_key: self.config.public_key.clone(),
trade_no: pi.id.to_string(),
})
}
pub async fn find_or_create_customer(&self, user: &User) -> Result<Customer, PaymentError> {
if let Some(customer) = self.search_customer(user).await? {
return Ok(customer);
}
self.create_customer(user).await
}
pub async fn search_customer(&self, user: &User) -> Result<Option<Customer>, PaymentError> {
let query = if !user.email.is_empty() {
format!("email:'{}'", user.email)
} else {
format!("metadata['user_id']:'{}'", user.user_id)
};
let mut params = CustomerSearchParams::new();
params.query = query;
let result = Customer::search(&self.client, params).await?;
Ok(result.data.into_iter().next())
}
pub async fn create_customer(&self, user: &User) -> Result<Customer, PaymentError> {
let mut params = CreateCustomer::new();
if !user.email.is_empty() {
params.email = Some(&user.email);
}
let mut metadata = HashMap::new();
metadata.insert("user_id".to_string(), user.user_id.to_string());
params.metadata = Some(metadata);
Ok(Customer::create(&self.client, params).await?)
}
pub async fn query_order_status(&self, trade_no: &str) -> Result<bool, PaymentError> {
let id = PaymentIntentId::from_str(trade_no)
.map_err(|_| PaymentError::Config("invalid PaymentIntent ID".into()))?;
let intent = PaymentIntent::retrieve(&self.client, &id, &[]).await?;
Ok(intent.status == PaymentIntentStatus::Succeeded)
}
pub fn parse_notify(
&self,
payload: &[u8],
signature: &str,
) -> Result<Notification, PaymentError> {
let payload_str =
std::str::from_utf8(payload).map_err(|e| PaymentError::StripeWebhook(e.to_string()))?;
let event = Webhook::construct_event(payload_str, signature, &self.config.webhook_secret)
.map_err(|e| PaymentError::StripeWebhook(e.to_string()))?;
let pi = match event.data.object {
EventObject::PaymentIntent(pi) => pi,
_ => {
return Err(PaymentError::StripeWebhook(
"unexpected event object type".into(),
))
}
};
let order_no = pi.metadata.get("order_no").cloned().unwrap_or_default();
let user_id: i64 = pi
.metadata
.get("user_id")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let method = match pi.payment_method {
Some(Expandable::Object(ref pm)) => Some(pm.type_.to_string()),
_ => None,
};
Ok(Notification {
event_type: event.type_.to_string(),
order_no,
trade_no: pi.id.to_string(),
user_id,
amount: Cents(pi.amount),
method,
})
}
pub async fn retrieve_payment_method(
&self,
id: &str,
) -> Result<PaymentMethod, PaymentError> {
let pm_id = PaymentMethodId::from_str(id)
.map_err(|_| PaymentError::Config("invalid PaymentMethod ID".into()))?;
Ok(PaymentMethod::retrieve(&self.client, &pm_id, &[]).await?)
}
pub async fn create_webhook_endpoint(
&self,
url: &str,
) -> Result<WebhookEndpoint, PaymentError> {
let params = CreateWebhookEndpoint::new(
vec![EventFilter::PaymentIntentSucceeded, EventFilter::PaymentIntentPaymentFailed],
url,
);
Ok(WebhookEndpoint::create(&self.client, params).await?)
}
}
+105
View File
@@ -0,0 +1,105 @@
use serde::{Deserialize, Serialize};
use std::fmt;
use std::ops::{Div, Mul};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Cents(pub i64);
impl Cents {
pub fn from_yuan(s: &str) -> Result<Self, std::num::ParseFloatError> {
let yuan: f64 = s.parse()?;
Ok(Cents((yuan * 100.0).round() as i64))
}
pub fn to_yuan_f64(&self) -> f64 {
self.0 as f64 / 100.0
}
pub fn to_yuan_string(&self) -> String {
format!("{:.2}", self.to_yuan_f64())
}
}
impl fmt::Display for Cents {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Mul<i64> for Cents {
type Output = Cents;
fn mul(self, rhs: i64) -> Cents {
Cents(self.0 * rhs)
}
}
impl Div<i64> for Cents {
type Output = Cents;
fn div(self, rhs: i64) -> Cents {
Cents(self.0 / rhs)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cents_from_yuan() {
let c = Cents::from_yuan("10.00").unwrap();
assert_eq!(c.0, 1000);
assert_eq!(c.to_yuan_string(), "10.00");
}
#[test]
fn test_cents_from_yuan_rounding() {
let c = Cents::from_yuan("9.99").unwrap();
assert_eq!(c.0, 999);
assert_eq!(c.to_yuan_string(), "9.99");
}
#[test]
fn test_cents_display() {
let c = Cents(100);
assert_eq!(c.to_string(), "100");
assert_eq!(c.to_yuan_f64(), 1.0);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Order {
pub order_no: String,
pub amount: Cents,
pub currency: String,
pub payment: String,
pub subscribe: String,
pub name: String,
pub notify_url: String,
pub return_url: String,
}
#[derive(Debug, Clone)]
pub struct User {
pub user_id: i64,
pub email: String,
}
#[derive(Debug, Clone)]
pub struct Notification {
pub event_type: String,
pub order_no: String,
pub trade_no: String,
pub user_id: i64,
pub amount: Cents,
pub method: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PaymentSheet {
pub client_secret: String,
pub ephemeral_key: String,
pub customer: String,
pub publishable_key: String,
pub trade_no: String,
}