mirror of
https://github.com/perfect-panel/ppanel-web.git
synced 2026-08-29 05:52:08 -04:00
Initial
This commit is contained in:
@@ -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"
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms);
|
||||
|
||||
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 ¶ms {
|
||||
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(¶ms));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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?)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user