use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio_retry::{strategy::ExponentialBackoff, Retry};
#[cfg(feature = "acp")]
use reqwest::Client;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WebhookEvent {
pub event_type: String,
pub checkout_session_id: String,
pub data: serde_json::Value,
pub timestamp: i64,
}
#[derive(Debug)]
pub struct WebhookDelivery {
#[cfg(feature = "acp")]
client: Client,
hmac_secret: Vec<u8>,
max_retries: usize,
}
impl WebhookDelivery {
pub fn new(hmac_secret: Vec<u8>) -> Self {
Self {
#[cfg(feature = "acp")]
client: Client::builder()
.timeout(Duration::from_secs(10))
.build()
.unwrap(),
hmac_secret,
max_retries: 5,
}
}
pub fn with_max_retries(mut self, max_retries: usize) -> Self {
self.max_retries = max_retries;
self
}
#[cfg(feature = "acp")]
pub async fn deliver(
&self,
endpoint: &str,
event: WebhookEvent,
) -> Result<DeliveryResult, String> {
let payload = serde_json::to_vec(&event)
.map_err(|e| format!("Serialization failed: {}", e))?;
let signature = crate::acp::hmac::generate_signature(&self.hmac_secret, &payload)?;
let retry_strategy = ExponentialBackoff::from_millis(10)
.max_delay(Duration::from_secs(8))
.take(self.max_retries);
let result = Retry::spawn(retry_strategy, || async {
self.send_webhook(endpoint, &payload, &signature).await
})
.await;
match result {
Ok(status) => Ok(DeliveryResult::Success { status_code: status }),
Err(e) => Ok(DeliveryResult::Failed(e.to_string())),
}
}
#[cfg(not(feature = "acp"))]
pub async fn deliver(
&self,
_endpoint: &str,
_event: WebhookEvent,
) -> Result<DeliveryResult, String> {
Err("ACP feature not enabled. Enable 'acp' feature to use webhook delivery.".to_string())
}
#[cfg(feature = "acp")]
async fn send_webhook(
&self,
endpoint: &str,
payload: &[u8],
signature: &str,
) -> Result<u16, String> {
let response = self
.client
.post(endpoint)
.header("Content-Type", "application/json")
.header("Merchant-Signature", signature)
.body(payload.to_vec())
.send()
.await
.map_err(|e| format!("HTTP request failed: {}", e))?;
let status = response.status().as_u16();
if response.status().is_success() {
Ok(status)
} else {
Err(format!("Webhook delivery failed with status: {}", status))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeliveryResult {
Success { status_code: u16 },
Failed(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_webhook_delivery_creation() {
let delivery = WebhookDelivery::new(b"test_secret".to_vec());
assert_eq!(delivery.max_retries, 5);
}
#[test]
fn test_webhook_delivery_custom_retries() {
let delivery = WebhookDelivery::new(b"test_secret".to_vec())
.with_max_retries(3);
assert_eq!(delivery.max_retries, 3);
}
#[test]
fn test_webhook_event_creation() {
let event = WebhookEvent {
event_type: "order.created".to_string(),
checkout_session_id: "cs_test_123".to_string(),
data: serde_json::json!({
"order_id": "ord_456",
"amount": 1999,
"currency": "USD"
}),
timestamp: 1234567890,
};
assert_eq!(event.event_type, "order.created");
assert_eq!(event.checkout_session_id, "cs_test_123");
}
#[test]
fn test_webhook_event_serialization() {
let event = WebhookEvent {
event_type: "order.updated".to_string(),
checkout_session_id: "cs_789".to_string(),
data: serde_json::json!({"status": "confirmed"}),
timestamp: 1234567890,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("order.updated"));
assert!(json.contains("cs_789"));
assert!(json.contains("confirmed"));
}
#[test]
fn test_webhook_event_deserialization() {
let json = r#"{
"event_type": "order.shipped",
"checkout_session_id": "cs_xyz",
"data": {"tracking": "1Z999AA10123456784"},
"timestamp": 1234567890
}"#;
let event: WebhookEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.event_type, "order.shipped");
assert_eq!(event.checkout_session_id, "cs_xyz");
}
#[test]
fn test_delivery_result_success() {
let result = DeliveryResult::Success { status_code: 200 };
match result {
DeliveryResult::Success { status_code } => assert_eq!(status_code, 200),
DeliveryResult::Failed(_) => panic!("Expected success"),
}
}
#[test]
fn test_delivery_result_failed() {
let result = DeliveryResult::Failed("Connection timeout".to_string());
match result {
DeliveryResult::Success { .. } => panic!("Expected failure"),
DeliveryResult::Failed(msg) => assert!(msg.contains("timeout")),
}
}
#[cfg(feature = "acp")]
#[tokio::test]
async fn test_webhook_delivery_invalid_url() {
let delivery = WebhookDelivery::new(b"test_secret".to_vec())
.with_max_retries(1);
let event = WebhookEvent {
event_type: "test.event".to_string(),
checkout_session_id: "cs_test".to_string(),
data: serde_json::json!({}),
timestamp: 1234567890,
};
let result = delivery
.deliver("http://invalid-domain-that-does-not-exist-12345.com", event)
.await
.unwrap();
match result {
DeliveryResult::Failed(_) => (),
DeliveryResult::Success { .. } => panic!("Expected failure for invalid URL"),
}
}
#[test]
fn test_webhook_event_equality() {
let event1 = WebhookEvent {
event_type: "test.event".to_string(),
checkout_session_id: "cs_123".to_string(),
data: serde_json::json!({"key": "value"}),
timestamp: 1234567890,
};
let event2 = WebhookEvent {
event_type: "test.event".to_string(),
checkout_session_id: "cs_123".to_string(),
data: serde_json::json!({"key": "value"}),
timestamp: 1234567890,
};
assert_eq!(event1, event2);
}
#[test]
fn test_webhook_event_inequality() {
let event1 = WebhookEvent {
event_type: "test.event".to_string(),
checkout_session_id: "cs_123".to_string(),
data: serde_json::json!({"key": "value1"}),
timestamp: 1234567890,
};
let event2 = WebhookEvent {
event_type: "test.event".to_string(),
checkout_session_id: "cs_123".to_string(),
data: serde_json::json!({"key": "value2"}),
timestamp: 1234567890,
};
assert_ne!(event1, event2);
}
}