use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{error, info, warn};
use payrix::{
webhooks::{ChargebackEvent, WebhookServer, WebhookServerConfig},
workflows::dispute_handling::{
ActiveDispute, ChargebackDispute, Evidence, TypedChargeback,
},
Environment, PayrixClient,
};
#[allow(dead_code)]
struct AppState {
client: PayrixClient,
processing: RwLock<std::collections::HashSet<String>>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("payrix=debug".parse()?)
.add_directive("webhook_dispute_handler=debug".parse()?),
)
.init();
let api_key = std::env::var("TEST_PAYRIX_API_KEY").unwrap_or_else(|_| {
warn!("TEST_PAYRIX_API_KEY not set, using demo mode (no API calls)");
"demo-key".to_string()
});
let client = PayrixClient::new(&api_key, Environment::Test)?;
let state = Arc::new(AppState {
client,
processing: RwLock::new(std::collections::HashSet::new()),
});
let config = WebhookServerConfig::new()
.with_auth_header("X-Webhook-Secret", "test-secret")
.with_stdout_logging(true);
let (server, mut events) = WebhookServer::with_config(config);
let handler_state = state.clone();
tokio::spawn(async move {
info!("Dispute handler started, waiting for events...");
while let Some(event) = events.recv().await {
if let Some(cb_event) = event.as_chargeback_event() {
let chargeback_id = cb_event.chargeback_id().to_string();
{
let processing = handler_state.processing.read().await;
if processing.contains(&chargeback_id) {
info!(chargeback_id, "Skipping duplicate event");
continue;
}
}
{
let mut processing = handler_state.processing.write().await;
processing.insert(chargeback_id.clone());
}
let state = handler_state.clone();
tokio::spawn(async move {
if let Err(e) = handle_chargeback_event(&state, cb_event).await {
error!(chargeback_id, error = %e, "Failed to handle chargeback event");
}
let mut processing = state.processing.write().await;
processing.remove(&chargeback_id);
});
}
}
});
let addr: SocketAddr = "0.0.0.0:13847".parse()?;
println!();
println!("╔════════════════════════════════════════════════════════════╗");
println!("║ Webhook Dispute Handler Example ║");
println!("╠════════════════════════════════════════════════════════════╣");
println!("║ Webhook endpoint: POST http://localhost:13847/webhooks/payrix");
println!("║ Health check: GET http://localhost:13847/health ║");
println!("║ ║");
println!("║ Auth header: X-Webhook-Secret: test-secret ║");
println!("╚════════════════════════════════════════════════════════════╝");
println!();
println!("Press Ctrl+C to stop");
println!();
server.run(addr).await?;
Ok(())
}
async fn handle_chargeback_event(
state: &AppState,
event: ChargebackEvent,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let chargeback_id = event.chargeback_id().to_string();
let event_type = event_type_name(&event);
info!(
chargeback_id = chargeback_id.as_str(),
event_type,
"Processing chargeback event"
);
match event {
ChargebackEvent::Created { data, .. } | ChargebackEvent::Opened { data, .. } => {
info!(
chargeback_id = chargeback_id.as_str(),
total = data.total,
cycle = ?data.cycle,
"New chargeback received"
);
let dispute = ChargebackDispute::from_chargeback(data);
match dispute {
ChargebackDispute::Active(active) => {
handle_active_dispute(state, active).await?;
}
ChargebackDispute::Terminal(terminal) => {
info!(
chargeback_id = terminal.id().as_str(),
"Dispute is already terminal, no action needed"
);
}
}
}
ChargebackEvent::Won { chargeback_id, .. } => {
info!(chargeback_id, "Dispute WON - merchant prevailed");
}
ChargebackEvent::Lost { chargeback_id, .. } => {
warn!(chargeback_id, "Dispute LOST - merchant lost");
}
ChargebackEvent::Closed { chargeback_id, .. } => {
info!(chargeback_id, "Dispute closed");
}
ChargebackEvent::Other { chargeback_id, event_type, .. } => {
info!(chargeback_id, event_type, "Other chargeback event");
}
}
Ok(())
}
async fn handle_active_dispute(
state: &AppState,
dispute: ActiveDispute,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match dispute {
ActiveDispute::First(first) => {
handle_first_chargeback(state, first).await?;
}
ActiveDispute::PreArbitration(pre_arb) => {
handle_pre_arbitration(state, pre_arb).await?;
}
ActiveDispute::SecondChargeback(second) => {
info!(
chargeback_id = second.id().as_str(),
"Second chargeback received - requires manual review"
);
}
ActiveDispute::Retrieval(retrieval) => {
info!(
chargeback_id = retrieval.id().as_str(),
"Retrieval request - awaiting first chargeback"
);
}
ActiveDispute::Representment(rep) => {
info!(
chargeback_id = rep.id().as_str(),
"In representment - awaiting issuer decision"
);
}
ActiveDispute::Arbitration(arb) => {
info!(
chargeback_id = arb.id().as_str(),
"In arbitration - awaiting card network decision"
);
}
}
Ok(())
}
async fn handle_first_chargeback(
_state: &AppState,
first: TypedChargeback<payrix::workflows::dispute_handling::First>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let chargeback_id = first.id().as_str();
let total = first.inner().total.unwrap_or(0);
let reason_code = first.inner().reason_code.clone();
info!(
chargeback_id,
total,
reason_code = reason_code.as_deref().unwrap_or("unknown"),
"Evaluating first chargeback for response"
);
if total < 2500 {
info!(
chargeback_id,
total,
"Amount below threshold, accepting liability"
);
info!(chargeback_id, "Would accept liability (demo mode)");
} else {
if should_represent(&first) {
info!(chargeback_id, "Decision: REPRESENT with evidence");
let _evidence = Evidence::new(format!(
"Customer received goods/services. Transaction ID: {}. \
Order was fulfilled on the delivery date.",
chargeback_id
));
info!(chargeback_id, "Would submit representment (demo mode)");
} else {
info!(chargeback_id, "Decision: FLAG FOR MANUAL REVIEW");
}
}
Ok(())
}
async fn handle_pre_arbitration(
_state: &AppState,
pre_arb: TypedChargeback<payrix::workflows::dispute_handling::PreArbitration>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let chargeback_id = pre_arb.id().as_str();
let total = pre_arb.inner().total.unwrap_or(0);
info!(
chargeback_id,
total,
"Pre-arbitration received - issuer rejected representment"
);
if total >= 50000 {
info!(
chargeback_id,
"High value dispute - consider arbitration"
);
} else {
info!(
chargeback_id,
"Below arbitration threshold - accepting liability"
);
}
Ok(())
}
fn should_represent(
chargeback: &TypedChargeback<payrix::workflows::dispute_handling::First>,
) -> bool {
let inner = chargeback.inner();
if let Some(ref reason) = inner.reason_code {
let winnable_codes = ["4837", "4853", "10.4", "13.1"];
if winnable_codes.iter().any(|c| reason.contains(c)) {
return true;
}
}
inner.total.unwrap_or(0) >= 5000
}
fn event_type_name(event: &ChargebackEvent) -> &'static str {
match event {
ChargebackEvent::Created { .. } => "created",
ChargebackEvent::Opened { .. } => "opened",
ChargebackEvent::Closed { .. } => "closed",
ChargebackEvent::Won { .. } => "won",
ChargebackEvent::Lost { .. } => "lost",
ChargebackEvent::Other { .. } => "other",
}
}