use serde::Serialize;
use crate::entity::EntityType;
use crate::error::{Error, Result};
use crate::types::{Alert, AlertAction, AlertActionType, AlertTrigger};
use crate::PayrixClient;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WebhookEventType {
Create,
Update,
Delete,
Ownership,
Batch,
Account,
AccountCreated,
AccountUpdated,
Chargeback,
ChargebackCreated,
ChargebackOpened,
ChargebackClosed,
ChargebackWon,
ChargebackLost,
TransactionCreated,
TransactionApproved,
TransactionFailed,
TransactionCaptured,
TransactionSettled,
TransactionReturned,
MerchantCreated,
MerchantBoarding,
MerchantBoarded,
MerchantClosed,
MerchantFailed,
MerchantHeld,
DisbursementRequested,
DisbursementProcessing,
DisbursementProcessed,
DisbursementFailed,
DisbursementDenied,
DisbursementReturned,
Payout,
Fee,
}
impl WebhookEventType {
pub fn as_event_str(&self) -> &'static str {
match self {
Self::Create => "create",
Self::Update => "update",
Self::Delete => "delete",
Self::Ownership => "ownership",
Self::Batch => "batch",
Self::Account => "account",
Self::AccountCreated => "account.created",
Self::AccountUpdated => "account.updated",
Self::Chargeback => "chargeback",
Self::ChargebackCreated => "chargeback.created",
Self::ChargebackOpened => "chargeback.opened",
Self::ChargebackClosed => "chargeback.closed",
Self::ChargebackWon => "chargeback.won",
Self::ChargebackLost => "chargeback.lost",
Self::TransactionCreated => "txn.created",
Self::TransactionApproved => "txn.approved",
Self::TransactionFailed => "txn.failed",
Self::TransactionCaptured => "txn.captured",
Self::TransactionSettled => "txn.settled",
Self::TransactionReturned => "txn.returned",
Self::MerchantCreated => "merchant.created",
Self::MerchantBoarding => "merchant.boarding",
Self::MerchantBoarded => "merchant.boarded",
Self::MerchantClosed => "merchant.closed",
Self::MerchantFailed => "merchant.failed",
Self::MerchantHeld => "merchant.held",
Self::DisbursementRequested => "disbursement.requested",
Self::DisbursementProcessing => "disbursement.processing",
Self::DisbursementProcessed => "disbursement.processed",
Self::DisbursementFailed => "disbursement.failed",
Self::DisbursementDenied => "disbursement.denied",
Self::DisbursementReturned => "disbursement.returned",
Self::Payout => "payout",
Self::Fee => "fee",
}
}
pub fn all_chargeback_events() -> Vec<Self> {
vec![
Self::ChargebackCreated,
Self::ChargebackOpened,
Self::ChargebackClosed,
Self::ChargebackWon,
Self::ChargebackLost,
]
}
pub fn all_transaction_events() -> Vec<Self> {
vec![
Self::TransactionCreated,
Self::TransactionApproved,
Self::TransactionFailed,
Self::TransactionCaptured,
Self::TransactionSettled,
Self::TransactionReturned,
]
}
pub fn all_merchant_events() -> Vec<Self> {
vec![
Self::MerchantCreated,
Self::MerchantBoarding,
Self::MerchantBoarded,
Self::MerchantClosed,
Self::MerchantFailed,
Self::MerchantHeld,
]
}
pub fn all_disbursement_events() -> Vec<Self> {
vec![
Self::DisbursementRequested,
Self::DisbursementProcessing,
Self::DisbursementProcessed,
Self::DisbursementFailed,
Self::DisbursementDenied,
Self::DisbursementReturned,
]
}
}
impl std::fmt::Display for WebhookEventType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_event_str())
}
}
#[derive(Debug, Clone)]
pub struct WebhookConfig {
pub base_url: String,
pub webhook_path: String,
pub header_name: Option<String>,
pub header_value: Option<String>,
pub events: Vec<WebhookEventType>,
pub alert_name: Option<String>,
pub alert_description: Option<String>,
pub retries: Option<i32>,
}
impl WebhookConfig {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
webhook_path: "/webhooks/payrix".to_string(),
header_name: None,
header_value: None,
events: Vec::new(),
alert_name: None,
alert_description: None,
retries: None,
}
}
pub fn with_path(mut self, path: impl Into<String>) -> Self {
self.webhook_path = path.into();
self
}
pub fn with_events(mut self, events: Vec<WebhookEventType>) -> Self {
self.events = events;
self
}
pub fn with_all_chargeback_events(mut self) -> Self {
self.events.extend(WebhookEventType::all_chargeback_events());
self
}
pub fn with_all_transaction_events(mut self) -> Self {
self.events.extend(WebhookEventType::all_transaction_events());
self
}
pub fn with_all_merchant_events(mut self) -> Self {
self.events.extend(WebhookEventType::all_merchant_events());
self
}
pub fn with_auth(mut self, header_name: impl Into<String>, header_value: impl Into<String>) -> Self {
self.header_name = Some(header_name.into());
self.header_value = Some(header_value.into());
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.alert_name = Some(name.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.alert_description = Some(description.into());
self
}
pub fn with_retries(mut self, retries: i32) -> Self {
self.retries = Some(retries);
self
}
pub fn webhook_url(&self) -> String {
let base = self.base_url.trim_end_matches('/');
let path = if self.webhook_path.starts_with('/') {
self.webhook_path.clone()
} else {
format!("/{}", self.webhook_path)
};
format!("{}{}", base, path)
}
pub fn validate(&self) -> Result<()> {
if self.base_url.is_empty() {
return Err(Error::Validation("base_url is required".to_string()));
}
if !self.base_url.starts_with("https://") && !self.base_url.starts_with("http://") {
return Err(Error::Validation(
"base_url must start with http:// or https://".to_string(),
));
}
if self.events.is_empty() {
return Err(Error::Validation(
"at least one event type is required".to_string(),
));
}
if self.header_name.is_some() != self.header_value.is_some() {
return Err(Error::Validation(
"both header_name and header_value must be set together".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct WebhookSetupResult {
pub alert_id: String,
pub action_id: String,
pub triggers_created: Vec<String>,
pub was_updated: bool,
}
#[derive(Debug, Clone)]
pub struct WebhookAlertInfo {
pub id: String,
pub name: String,
pub endpoint: String,
pub events: Vec<String>,
pub is_active: bool,
pub auth_header: Option<String>,
}
#[derive(Debug, Clone)]
pub struct WebhookStatus {
pub alerts: Vec<WebhookAlertInfo>,
}
impl WebhookStatus {
pub fn is_configured(&self) -> bool {
!self.alerts.is_empty()
}
pub fn all_events(&self) -> Vec<&str> {
self.alerts
.iter()
.flat_map(|a| a.events.iter().map(String::as_str))
.collect()
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct NewAlert {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct NewAlertAction {
alert: String,
#[serde(rename = "type")]
action_type: AlertActionType,
value: String,
#[serde(skip_serializing_if = "Option::is_none")]
header_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
header_value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
retries: Option<i32>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct NewAlertTrigger {
alert: String,
event: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
pub async fn setup_webhooks(client: &PayrixClient, config: WebhookConfig) -> Result<WebhookSetupResult> {
config.validate()?;
let mut events: Vec<WebhookEventType> = config.events.clone();
events.sort_by_key(|e| e.as_event_str());
events.dedup_by_key(|e| e.as_event_str());
let alert_name = config.alert_name.clone().unwrap_or_else(|| {
format!("Webhook Alert - {}", chrono::Utc::now().format("%Y-%m-%d"))
});
let new_alert = NewAlert {
name: alert_name,
description: config.alert_description.clone(),
};
let alert: Alert = client.create(EntityType::Alerts, &new_alert).await?;
let alert_id = alert.id.to_string();
let new_action = NewAlertAction {
alert: alert_id.clone(),
action_type: AlertActionType::Web,
value: config.webhook_url(),
header_name: config.header_name.clone(),
header_value: config.header_value.clone(),
retries: config.retries,
};
let action: AlertAction = client.create(EntityType::AlertActions, &new_action).await?;
let action_id = action.id.to_string();
let mut triggers_created = Vec::new();
for event in &events {
let event_str = event.as_event_str();
let new_trigger = NewAlertTrigger {
alert: alert_id.clone(),
event: event_str.to_string(),
name: Some(format!("{} trigger", event_str)),
};
let _trigger: AlertTrigger = client.create(EntityType::AlertTriggers, &new_trigger).await?;
triggers_created.push(event_str.to_string());
}
Ok(WebhookSetupResult {
alert_id,
action_id,
triggers_created,
was_updated: false,
})
}
pub async fn get_webhook_status(client: &PayrixClient) -> Result<WebhookStatus> {
use crate::SearchBuilder;
let alerts: Vec<Alert> = client.search(EntityType::Alerts, &SearchBuilder::new().build()).await?;
let mut alert_infos = Vec::new();
for alert in alerts {
let alert_id = alert.id.to_string();
let action_search = SearchBuilder::new()
.field("alert", &alert_id)
.build();
let actions: Vec<AlertAction> = client.search(EntityType::AlertActions, &action_search).await?;
let web_action = actions.iter().find(|a| {
a.action_type == Some(AlertActionType::Web)
});
if let Some(action) = web_action {
let trigger_search = SearchBuilder::new()
.field("alert", &alert_id)
.build();
let triggers: Vec<AlertTrigger> = client.search(EntityType::AlertTriggers, &trigger_search).await?;
let events: Vec<String> = triggers
.iter()
.filter_map(|t| t.event.clone())
.collect();
alert_infos.push(WebhookAlertInfo {
id: alert_id,
name: alert.name.unwrap_or_else(|| "Unnamed".to_string()),
endpoint: action.value.clone().unwrap_or_default(),
events,
is_active: !alert.inactive,
auth_header: action.header_name.clone(),
});
}
}
Ok(WebhookStatus { alerts: alert_infos })
}
pub async fn remove_webhooks(client: &PayrixClient) -> Result<usize> {
use crate::SearchBuilder;
let status = get_webhook_status(client).await?;
let count = status.alerts.len();
for alert in &status.alerts {
let trigger_search = SearchBuilder::new()
.field("alert", &alert.id)
.build();
let triggers: Vec<AlertTrigger> = client.search(EntityType::AlertTriggers, &trigger_search).await?;
for trigger in triggers {
let _: AlertTrigger = client.remove(EntityType::AlertTriggers, trigger.id.as_str()).await?;
}
let action_search = SearchBuilder::new()
.field("alert", &alert.id)
.build();
let actions: Vec<AlertAction> = client.search(EntityType::AlertActions, &action_search).await?;
for action in actions {
let _: AlertAction = client.remove(EntityType::AlertActions, action.id.as_str()).await?;
}
let _: Alert = client.remove(EntityType::Alerts, &alert.id).await?;
}
Ok(count)
}
pub async fn remove_webhook_by_id(client: &PayrixClient, alert_id: &str) -> Result<()> {
use crate::SearchBuilder;
let trigger_search = SearchBuilder::new()
.field("alert", alert_id)
.build();
let triggers: Vec<AlertTrigger> = client.search(EntityType::AlertTriggers, &trigger_search).await?;
for trigger in triggers {
let _: AlertTrigger = client.remove(EntityType::AlertTriggers, trigger.id.as_str()).await?;
}
let action_search = SearchBuilder::new()
.field("alert", alert_id)
.build();
let actions: Vec<AlertAction> = client.search(EntityType::AlertActions, &action_search).await?;
for action in actions {
let _: AlertAction = client.remove(EntityType::AlertActions, action.id.as_str()).await?;
}
let _: Alert = client.remove(EntityType::Alerts, alert_id).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_webhook_event_type_as_str() {
assert_eq!(WebhookEventType::ChargebackCreated.as_event_str(), "chargeback.created");
assert_eq!(WebhookEventType::ChargebackOpened.as_event_str(), "chargeback.opened");
assert_eq!(WebhookEventType::ChargebackClosed.as_event_str(), "chargeback.closed");
assert_eq!(WebhookEventType::ChargebackWon.as_event_str(), "chargeback.won");
assert_eq!(WebhookEventType::ChargebackLost.as_event_str(), "chargeback.lost");
assert_eq!(WebhookEventType::TransactionCreated.as_event_str(), "txn.created");
assert_eq!(WebhookEventType::TransactionApproved.as_event_str(), "txn.approved");
assert_eq!(WebhookEventType::MerchantBoarded.as_event_str(), "merchant.boarded");
}
#[test]
fn test_webhook_event_type_display() {
assert_eq!(format!("{}", WebhookEventType::ChargebackCreated), "chargeback.created");
}
#[test]
fn test_webhook_event_type_all_chargeback() {
let events = WebhookEventType::all_chargeback_events();
assert_eq!(events.len(), 5);
assert!(events.contains(&WebhookEventType::ChargebackCreated));
assert!(events.contains(&WebhookEventType::ChargebackWon));
assert!(events.contains(&WebhookEventType::ChargebackLost));
}
#[test]
fn test_webhook_config_builder() {
let config = WebhookConfig::new("https://api.example.com")
.with_path("/hooks")
.with_events(vec![WebhookEventType::ChargebackCreated])
.with_auth("X-Secret", "my-secret")
.with_name("Test Alert")
.with_retries(3);
assert_eq!(config.base_url, "https://api.example.com");
assert_eq!(config.webhook_path, "/hooks");
assert_eq!(config.events.len(), 1);
assert_eq!(config.header_name, Some("X-Secret".to_string()));
assert_eq!(config.header_value, Some("my-secret".to_string()));
assert_eq!(config.alert_name, Some("Test Alert".to_string()));
assert_eq!(config.retries, Some(3));
}
#[test]
fn test_webhook_config_url() {
let config = WebhookConfig::new("https://api.example.com/");
assert_eq!(config.webhook_url(), "https://api.example.com/webhooks/payrix");
let config2 = WebhookConfig::new("https://api.example.com")
.with_path("custom/path");
assert_eq!(config2.webhook_url(), "https://api.example.com/custom/path");
}
#[test]
fn test_webhook_config_validation() {
let config = WebhookConfig::new("")
.with_events(vec![WebhookEventType::ChargebackCreated]);
assert!(config.validate().is_err());
let config = WebhookConfig::new("ftp://example.com")
.with_events(vec![WebhookEventType::ChargebackCreated]);
assert!(config.validate().is_err());
let config = WebhookConfig::new("https://example.com");
assert!(config.validate().is_err());
let config = WebhookConfig::new("https://example.com")
.with_events(vec![WebhookEventType::ChargebackCreated]);
let mut config = config;
config.header_name = Some("X-Secret".to_string());
assert!(config.validate().is_err());
let config = WebhookConfig::new("https://example.com")
.with_events(vec![WebhookEventType::ChargebackCreated])
.with_auth("X-Secret", "value");
assert!(config.validate().is_ok());
}
#[test]
fn test_webhook_status_helpers() {
let status = WebhookStatus {
alerts: vec![
WebhookAlertInfo {
id: "alert1".to_string(),
name: "Alert 1".to_string(),
endpoint: "https://example.com".to_string(),
events: vec!["chargeback.created".to_string()],
is_active: true,
auth_header: None,
},
],
};
assert!(status.is_configured());
assert_eq!(status.all_events(), vec!["chargeback.created"]);
let empty_status = WebhookStatus { alerts: vec![] };
assert!(!empty_status.is_configured());
}
#[test]
fn test_webhook_config_with_all_events() {
let config = WebhookConfig::new("https://example.com")
.with_all_chargeback_events()
.with_all_transaction_events();
assert_eq!(config.events.len(), 11); }
}