Skip to content
Clone

feat(payment): add payment crate (gateway, airwallex, repo, service, metrics)

zhenyi committed
parent 1c8ded5 commit 2184f13 Browse files

13 files changed

+543 −0

File tree 13
Added lib/payment/gateway/airwallex/client.rs View lib/payment/gateway/airwallex/client.rs
@@ -0,0 +1,52 @@
1use std::sync::Mutex;
2
3use async_trait::async_trait;
4use reqwest::Client as HttpClient;
5
6use crate::config::AirwallexConfig;
7use crate::error::{PaymentError, PaymentResult};
8use crate::gateway::PaymentGateway;
9use crate::types::{CreateIntentRequest, CreateRefundRequest, Customer, PaymentIntent, Refund};
10
11pub struct AirwallexClient {
12 http: HttpClient,
13 cfg: AirwallexConfig,
14 token: Mutex<Option<String>>,
15}
16
17impl AirwallexClient {
18 pub fn new(cfg: AirwallexConfig) -> Self {
19 Self {
20 http: HttpClient::new(),
21 cfg,
22 token: Mutex::new(None),
23 }
24 }
25
26 async fn access_token(&self) -> PaymentResult<String> {
27 Err(PaymentError::NotImplemented("airwallex::access_token"))
28 }
29}
30
31#[async_trait]
32impl PaymentGateway for AirwallexClient {
33 async fn create_intent(&self, _req: &CreateIntentRequest) -> PaymentResult<PaymentIntent> {
34 Err(PaymentError::NotImplemented("airwallex::create_intent"))
35 }
36 async fn get_intent(&self, _provider_id: &str) -> PaymentResult<PaymentIntent> {
37 Err(PaymentError::NotImplemented("airwallex::get_intent"))
38 }
39 async fn capture_intent(
40 &self,
41 _provider_id: &str,
42 _amount: Option<i64>,
43 ) -> PaymentResult<PaymentIntent> {
44 Err(PaymentError::NotImplemented("airwallex::capture_intent"))
45 }
46 async fn create_refund(&self, _req: &CreateRefundRequest) -> PaymentResult<Refund> {
47 Err(PaymentError::NotImplemented("airwallex::create_refund"))
48 }
49 async fn create_customer(&self, _email: &str) -> PaymentResult<Customer> {
50 Err(PaymentError::NotImplemented("airwallex::create_customer"))
51 }
52}
No newline at end of file
Added lib/payment/gateway/airwallex/mod.rs View lib/payment/gateway/airwallex/mod.rs
@@ -0,0 +1,6 @@
1pub mod client;
2pub mod types;
3pub mod webhook;
4
5pub use client::AirwallexClient;
6pub use crate::config::AirwallexConfig;
No newline at end of file
Added lib/payment/gateway/airwallex/types.rs View lib/payment/gateway/airwallex/types.rs
@@ -0,0 +1,37 @@
1use serde::Deserialize;
2use time::OffsetDateTime;
3
4#[derive(Debug, Deserialize)]
5pub struct IntentResponse {
6 pub id: String,
7 pub request_id: String,
8 pub merchant_order_id: Option<String>,
9 pub amount: i64,
10 pub currency: String,
11 pub status: String,
12 pub captured_amount: Option<i64>,
13 pub created_at: OffsetDateTime,
14}
15
16#[derive(Debug, Deserialize)]
17pub struct RefundResponse {
18 pub id: String,
19 pub request_id: String,
20 pub amount: i64,
21 pub currency: String,
22 pub status: String,
23 pub created_at: OffsetDateTime,
24}
25
26#[derive(Debug, Deserialize)]
27pub struct CustomerResponse {
28 pub id: String,
29 pub email: Option<String>,
30 pub created_at: OffsetDateTime,
31}
32
33#[derive(Debug, Deserialize)]
34pub struct ErrorResponse {
35 pub code: String,
36 pub message: String,
37}
No newline at end of file
Added lib/payment/gateway/airwallex/webhook.rs View lib/payment/gateway/airwallex/webhook.rs
@@ -0,0 +1,26 @@
1use serde::Deserialize;
2
3use crate::error::{PaymentError, PaymentResult};
4
5pub const SIGNATURE_HEADER: &str = "x-api-signature";
6
7#[derive(Debug, Deserialize)]
8pub struct WebhookEnvelope {
9 pub id: String,
10 pub event_type: String,
11 pub data: serde_json::Value,
12 pub created_at: String,
13}
14
15pub fn verify_signature(payload: &[u8], signature: &str, secret: &str) -> PaymentResult<()> {
16 if secret.is_empty() {
17 return Err(PaymentError::WebhookVerification("empty secret".into()));
18 }
19 let _ = (signature, payload);
20 Err(PaymentError::NotImplemented("airwallex::verify_signature"))
21}
22
23pub fn parse_event(payload: &str) -> PaymentResult<WebhookEnvelope> {
24 serde_json::from_str(payload)
25 .map_err(|e| PaymentError::BadRequest(format!("bad webhook payload: {e}")))
26}
No newline at end of file
Added lib/payment/gateway/mod.rs View lib/payment/gateway/mod.rs
@@ -0,0 +1,17 @@
1use async_trait::async_trait;
2
3use crate::error::PaymentResult;
4use crate::types::{CreateIntentRequest, CreateRefundRequest, Customer, PaymentIntent, Refund};
5
6#[async_trait]
7pub trait PaymentGateway: Send + Sync {
8 async fn create_intent(&self, req: &CreateIntentRequest) -> PaymentResult<PaymentIntent>;
9 async fn get_intent(&self, provider_id: &str) -> PaymentResult<PaymentIntent>;
10 async fn capture_intent(
11 &self,
12 provider_id: &str,
13 amount: Option<i64>,
14 ) -> PaymentResult<PaymentIntent>;
15 async fn create_refund(&self, req: &CreateRefundRequest) -> PaymentResult<Refund>;
16 async fn create_customer(&self, email: &str) -> PaymentResult<Customer>;
17}
Added lib/payment/repo/mod.rs View lib/payment/repo/mod.rs
@@ -0,0 +1,50 @@
1use async_trait::async_trait;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5use crate::error::PaymentResult;
6use db::payment::{payment_intents, payment_orders, payment_refunds, payment_wallets};
7
8#[async_trait]
9pub trait OrderRepository: Send + Sync {
10 async fn get_by_id(&self, id: Uuid) -> PaymentResult<Option<payment_orders::Model>>;
11 async fn get_by_external_txn(
12 &self,
13 external_txn_id: &str,
14 ) -> PaymentResult<Option<payment_orders::Model>>;
15 async fn mark_paid(&self, id: Uuid, paid_at: OffsetDateTime) -> PaymentResult<()>;
16 async fn mark_failed(&self, id: Uuid) -> PaymentResult<()>;
17 async fn mark_refunded(&self, id: Uuid) -> PaymentResult<()>;
18}
19
20#[async_trait]
21pub trait WalletRepository: Send + Sync {
22 async fn get_for_update(&self, user_id: Uuid) -> PaymentResult<Option<payment_wallets::Model>>;
23 async fn debit(&self, user_id: Uuid, amount: i64, expected_version: i32) -> PaymentResult<i64>;
24 async fn credit(&self, user_id: Uuid, amount: i64) -> PaymentResult<i64>;
25}
26
27#[async_trait]
28pub trait IntentRepository: Send + Sync {
29 async fn create(
30 &self,
31 model: payment_intents::ActiveModel,
32 ) -> PaymentResult<payment_intents::Model>;
33 async fn get_by_provider_id(
34 &self,
35 provider_id: &str,
36 ) -> PaymentResult<Option<payment_intents::Model>>;
37 async fn get_by_order_id(&self, order_id: Uuid) -> PaymentResult<Vec<payment_intents::Model>>;
38}
39
40#[async_trait]
41pub trait RefundRepository: Send + Sync {
42 async fn create(
43 &self,
44 model: payment_refunds::ActiveModel,
45 ) -> PaymentResult<payment_refunds::Model>;
46 async fn get_by_provider_id(
47 &self,
48 provider_id: &str,
49 ) -> PaymentResult<Option<payment_refunds::Model>>;
50}
Added lib/payment/Cargo.toml View lib/payment/Cargo.toml
@@ -0,0 +1,29 @@
1[package]
2name = "payment"
3version.workspace = true
4edition.workspace = true
5authors.workspace = true
6categories.workspace = true
7keywords.workspace = true
8repository.workspace = true
9homepage.workspace = true
10
11[lib]
12path = "lib.rs"
13name = "payment"
14[dependencies]
15db.workspace = true
16cache.workspace = true
17jwt.workspace = true
18export.workspace = true
19
20reqwest.workspace = true
21serde.workspace = true
22serde_json.workspace = true
23thiserror.workspace = true
24time.workspace = true
25uuid.workspace = true
26tracing.workspace = true
27async-trait.workspace = true
28metrics.workspace = true
29axum.workspace = true
No newline at end of file
Added lib/payment/config.rs View lib/payment/config.rs
@@ -0,0 +1,45 @@
1use std::sync::Arc;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct PaymentConfig {
7 #[serde(default = "default_currency")]
8 pub currency: String,
9 #[serde(default = "default_max_pagination")]
10 pub max_pagination: u64,
11 #[serde(default)]
12 pub airwallex: AirwallexConfig,
13}
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct AirwallexConfig {
17 #[serde(default)]
18 pub base_url: String,
19 #[serde(default)]
20 pub client_id: String,
21 #[serde(default)]
22 pub api_key: String,
23 #[serde(default)]
24 pub webhook_secret: String,
25}
26
27pub type SharedPaymentConfig = Arc<PaymentConfig>;
28
29impl Default for PaymentConfig {
30 fn default() -> Self {
31 Self {
32 currency: default_currency(),
33 max_pagination: default_max_pagination(),
34 airwallex: AirwallexConfig::default(),
35 }
36 }
37}
38
39fn default_currency() -> String {
40 "CNY".to_string()
41}
42
43fn default_max_pagination() -> u64 {
44 100
45}
Added lib/payment/error.rs View lib/payment/error.rs
@@ -0,0 +1,86 @@
1use axum::http::StatusCode;
2use axum::response::{IntoResponse, Response};
3use export::data::{AppResponseData, NoData};
4use thiserror::Error;
5
6pub type PaymentResult<T> = Result<T, PaymentError>;
7
8#[derive(Debug, Error)]
9pub enum PaymentError {
10 #[error("bad request: {0}")]
11 BadRequest(String),
12 #[error("not found: {0}")]
13 NotFound(String),
14 #[error("conflict: {0}")]
15 Conflict(String),
16 #[error("gateway error: {0}")]
17 Gateway(String),
18 #[error("webhook verification failed: {0}")]
19 WebhookVerification(String),
20 #[error("not implemented: {0}")]
21 NotImplemented(&'static str),
22 #[error("assertion failed: {0}")]
23 Assertion(String),
24 #[error("database error: {0}")]
25 Sqlx(#[from] db::conn::SqlxError),
26 #[error("uuid: {0}")]
27 Uuid(#[from] uuid::Error),
28 #[error("internal: {0}")]
29 Internal(String),
30}
31
32impl PaymentError {
33 fn status(&self) -> StatusCode {
34 match self {
35 PaymentError::BadRequest(_) => StatusCode::BAD_REQUEST,
36 PaymentError::NotFound(_) => StatusCode::NOT_FOUND,
37 PaymentError::Conflict(_) => StatusCode::CONFLICT,
38 PaymentError::Gateway(_) | PaymentError::WebhookVerification(_) => {
39 StatusCode::BAD_GATEWAY
40 }
41 PaymentError::NotImplemented(_) => StatusCode::NOT_IMPLEMENTED,
42 PaymentError::Assertion(_)
43 | PaymentError::Internal(_)
44 | PaymentError::Sqlx(_)
45 | PaymentError::Uuid(_) => StatusCode::INTERNAL_SERVER_ERROR,
46 }
47 }
48
49 fn is_user_facing(&self) -> bool {
50 matches!(
51 self,
52 PaymentError::BadRequest(_)
53 | PaymentError::NotFound(_)
54 | PaymentError::Conflict(_)
55 | PaymentError::NotImplemented(_)
56 )
57 }
58
59 pub fn error_kind(&self) -> &'static str {
60 match self {
61 PaymentError::BadRequest(_) => "bad_request",
62 PaymentError::NotFound(_) => "not_found",
63 PaymentError::Conflict(_) => "conflict",
64 PaymentError::Gateway(_) => "gateway",
65 PaymentError::WebhookVerification(_) => "webhook_verification",
66 PaymentError::NotImplemented(_) => "not_implemented",
67 PaymentError::Assertion(_) => "assertion",
68 PaymentError::Internal(_) => "internal_other",
69 PaymentError::Sqlx(_) => "internal_db",
70 PaymentError::Uuid(_) => "internal_uuid",
71 }
72 }
73}
74
75impl IntoResponse for PaymentError {
76 fn into_response(self) -> Response {
77 let status = self.status();
78 let msg = if self.is_user_facing() {
79 self.to_string()
80 } else {
81 tracing::error!(error = %self, "payment internal error");
82 "internal error".to_string()
83 };
84 AppResponseData::<NoData>::err_plain(status.as_u16() as i32, msg).into_response()
85 }
86}
Added lib/payment/lib.rs View lib/payment/lib.rs
@@ -0,0 +1,12 @@
1pub mod config;
2pub mod error;
3pub mod gateway;
4pub mod metrics;
5pub mod repo;
6pub mod service;
7pub mod types;
8
9pub use config::{PaymentConfig, SharedPaymentConfig};
10pub use error::{PaymentError, PaymentResult};
11pub use gateway::PaymentGateway;
12pub use service::PaymentService;
Added lib/payment/metrics.rs View lib/payment/metrics.rs
@@ -0,0 +1,94 @@
1use std::time::Instant;
2
3use metrics::{counter, describe_counter, describe_histogram, histogram};
4
5use crate::error::PaymentError;
6
7pub const FEATURE_INTENTS_CREATE: &str = "intents_create";
8pub const FEATURE_INTENTS_GET: &str = "intents_get";
9pub const FEATURE_REFUNDS_CREATE: &str = "refunds_create";
10pub const FEATURE_WALLETS_CREDIT: &str = "wallets_credit";
11pub const FEATURE_WALLETS_DEBIT: &str = "wallets_debit";
12pub const FEATURE_WEBHOOKS_PROCESS: &str = "webhooks_process";
13
14pub const OUTCOME_SUCCESS: &str = "success";
15
16static DESCRIBE_ONCE: std::sync::Once = std::sync::Once::new();
17
18pub fn ensure_described() {
19 DESCRIBE_ONCE.call_once(describe_all);
20}
21
22fn describe_all() {
23 describe_counter!(
24 "payment_usecase_invocations_total",
25 "Total usecase invocations labeled by feature and outcome"
26 );
27 describe_counter!(
28 "payment_usecase_errors_total",
29 "Total usecase errors labeled by feature and error_kind"
30 );
31 describe_histogram!(
32 "payment_usecase_duration_seconds",
33 metrics::Unit::Seconds,
34 "Payment usecase execution latency"
35 );
36 describe_counter!(
37 "payment_gateway_requests_total",
38 "Payment gateway (PSP) outbound requests labeled by provider and endpoint"
39 );
40}
41
42pub struct Timer {
43 pub feature: &'static str,
44 pub start: Instant,
45}
46
47impl Timer {
48 pub fn start(feature: &'static str) -> Self {
49 ensure_described();
50 Self {
51 feature,
52 start: Instant::now(),
53 }
54 }
55}
56
57impl Drop for Timer {
58 fn drop(&mut self) {
59 let elapsed = self.start.elapsed().as_secs_f64();
60 histogram!(
61 "payment_usecase_duration_seconds",
62 "feature" => self.feature.to_string()
63 )
64 .record(elapsed);
65 }
66}
67
68pub fn record_outcome<T>(feature: &'static str, result: &Result<T, PaymentError>) {
69 match result {
70 Ok(_) => {
71 counter!(
72 "payment_usecase_invocations_total",
73 "feature" => feature.to_string(),
74 "outcome" => OUTCOME_SUCCESS
75 )
76 .increment(1);
77 }
78 Err(e) => {
79 let kind = e.error_kind();
80 counter!(
81 "payment_usecase_invocations_total",
82 "feature" => feature.to_string(),
83 "outcome" => kind
84 )
85 .increment(1);
86 counter!(
87 "payment_usecase_errors_total",
88 "feature" => feature.to_string(),
89 "outcome" => kind
90 )
91 .increment(1);
92 }
93 }
94}
Added lib/payment/service.rs View lib/payment/service.rs
@@ -0,0 +1,39 @@
1use std::sync::Arc;
2
3use cache::redis::RedisCache;
4use db::conn::Db;
5use jwt::JwtState;
6
7use crate::config::{PaymentConfig, SharedPaymentConfig};
8use crate::gateway::PaymentGateway;
9
10#[derive(Clone)]
11pub struct PaymentService {
12 pub db: Db,
13 pub kv: Arc<RedisCache>,
14 pub config: SharedPaymentConfig,
15 pub jwt: JwtState,
16 pub gateway: Arc<dyn PaymentGateway>,
17}
18
19impl PaymentService {
20 pub fn new(
21 db: Db,
22 kv: Arc<RedisCache>,
23 jwt: JwtState,
24 gateway: Arc<dyn PaymentGateway>,
25 ) -> Self {
26 Self {
27 db,
28 kv,
29 config: Arc::new(PaymentConfig::default()),
30 jwt,
31 gateway,
32 }
33 }
34
35 pub fn with_config(mut self, config: SharedPaymentConfig) -> Self {
36 self.config = config;
37 self
38 }
39}
Added lib/payment/types.rs View lib/payment/types.rs
@@ -0,0 +1,50 @@
1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub struct Money {
7 pub amount: i64,
8 pub currency: String,
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct CreateIntentRequest {
13 pub order_id: Uuid,
14 pub amount: Money,
15 pub merchant_order_id: String,
16 pub request_id: String,
17 pub customer_id: Option<String>,
18 pub metadata: Option<serde_json::Value>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct CreateRefundRequest {
23 pub intent_id: String,
24 pub amount: Money,
25 pub reason: Option<String>,
26 pub request_id: String,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct PaymentIntent {
31 pub provider_id: String,
32 pub status: String,
33 pub amount: Money,
34 pub captured_amount: Option<i64>,
35 pub created_at: OffsetDateTime,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct Refund {
40 pub provider_id: String,
41 pub status: String,
42 pub amount: Money,
43 pub created_at: OffsetDateTime,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct Customer {
48 pub provider_id: String,
49 pub email: Option<String>,
50}