use crate::privacy::{LoopFingerprint, PrivacyLayer, PrivacyError};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::{error, info, warn, instrument};
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone)]
pub struct WebhookSecrets {
pub fidel: FidelSecrets,
pub square: Option<String>,
pub stripe: Option<String>,
}
#[derive(Debug, Clone)]
pub struct FidelSecrets {
pub auth: String,
pub clearing: String,
pub card_linked: String,
}
impl WebhookSecrets {
pub fn from_env() -> Self {
Self {
fidel: FidelSecrets {
auth: std::env::var("FIDEL_WEBHOOK_SECRET_AUTH")
.unwrap_or_default(),
clearing: std::env::var("FIDEL_WEBHOOK_SECRET_CLEARING")
.unwrap_or_default(),
card_linked: std::env::var("FIDEL_WEBHOOK_SECRET_CARD_LINKED")
.unwrap_or_default(),
},
square: std::env::var("SQUARE_WEBHOOK_SIGNATURE_KEY").ok(),
stripe: std::env::var("STRIPE_WEBHOOK_SECRET").ok(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WebhookSource {
Fidel,
Square,
Stripe,
Unknown,
}
impl WebhookSource {
pub fn detect(headers: &HashMap<String, String>) -> Self {
if headers.contains_key("x-fidel-signature") || headers.contains_key("X-Fidel-Signature") {
return Self::Fidel;
}
if headers.contains_key("x-square-signature") || headers.contains_key("X-Square-Signature") {
return Self::Square;
}
if headers.contains_key("stripe-signature") || headers.contains_key("Stripe-Signature") {
return Self::Stripe;
}
Self::Unknown
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NormalizedTransaction {
pub fingerprint: String,
pub card_last4: String,
pub card_brand: String,
pub amount_cents: u64,
pub currency: String,
pub merchant_id: String,
pub merchant_name: Option<String>,
pub location_id: Option<String>,
pub timestamp: i64,
pub source_txn_id: String,
pub source: WebhookSource,
pub txn_type: TransactionType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransactionType {
Auth,
Clearing,
Refund,
}
#[derive(Debug, Deserialize)]
pub struct FidelWebhookPayload {
pub transaction: FidelTransaction,
#[serde(rename = "type")]
pub event_type: String,
}
#[derive(Debug, Deserialize)]
pub struct FidelTransaction {
pub id: String,
#[serde(rename = "cardId")]
pub card_id: String,
#[serde(rename = "lastNumbers")]
pub last_numbers: String,
pub scheme: String,
pub amount: f64,
pub currency: String,
#[serde(rename = "brandId")]
pub brand_id: Option<String>,
#[serde(rename = "merchantName")]
pub merchant_name: Option<String>,
#[serde(rename = "locationId")]
pub location_id: Option<String>,
#[serde(rename = "datetime")]
pub datetime: String,
#[serde(rename = "auth")]
pub is_auth: Option<bool>,
}
#[instrument(skip(body, secret))]
pub fn verify_fidel_signature(
signature_header: &str,
body: &[u8],
secret: &str,
) -> Result<bool, WebhookError> {
let signature = signature_header
.strip_prefix("sha256=")
.ok_or_else(|| WebhookError::InvalidSignature("Missing sha256= prefix".into()))?;
let expected_bytes = hex::decode(signature)
.map_err(|e| WebhookError::InvalidSignature(format!("Invalid hex: {}", e)))?;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|e| WebhookError::InvalidSignature(format!("HMAC error: {}", e)))?;
mac.update(body);
match mac.verify_slice(&expected_bytes) {
Ok(_) => {
info!("Fidel signature verified");
Ok(true)
}
Err(_) => {
warn!("Fidel signature verification failed");
Ok(false)
}
}
}
#[instrument(skip(body, privacy))]
pub fn parse_fidel_webhook(
body: &[u8],
privacy: &PrivacyLayer,
) -> Result<NormalizedTransaction, WebhookError> {
let payload: FidelWebhookPayload = serde_json::from_slice(body)
.map_err(|e| WebhookError::ParseError(format!("Invalid JSON: {}", e)))?;
let txn = payload.transaction;
let fingerprint = privacy.hash_card_id(txn.card_id);
let timestamp = chrono::DateTime::parse_from_rfc3339(&txn.datetime)
.map(|dt| dt.timestamp())
.unwrap_or_else(|_| chrono::Utc::now().timestamp());
let txn_type = match payload.event_type.as_str() {
"transaction.auth" => TransactionType::Auth,
"transaction.clearing" => TransactionType::Clearing,
"transaction.refund" => TransactionType::Refund,
_ => TransactionType::Clearing, };
let amount_cents = (txn.amount * 100.0).round() as u64;
Ok(NormalizedTransaction {
fingerprint: fingerprint.into_string(),
card_last4: txn.last_numbers,
card_brand: txn.scheme,
amount_cents,
currency: txn.currency,
merchant_id: txn.brand_id.unwrap_or_else(|| "unknown".to_string()),
merchant_name: txn.merchant_name,
location_id: txn.location_id,
timestamp,
source_txn_id: txn.id,
source: WebhookSource::Fidel,
txn_type,
})
}
#[derive(Debug, Deserialize)]
pub struct SquareWebhookPayload {
#[serde(rename = "type")]
pub event_type: String,
pub data: SquareEventData,
}
#[derive(Debug, Deserialize)]
pub struct SquareEventData {
pub object: SquarePaymentObject,
}
#[derive(Debug, Deserialize)]
pub struct SquarePaymentObject {
pub payment: SquarePayment,
}
#[derive(Debug, Deserialize)]
pub struct SquarePayment {
pub id: String,
#[serde(rename = "amount_money")]
pub amount_money: SquareMoney,
#[serde(rename = "card_details")]
pub card_details: Option<SquareCardDetails>,
#[serde(rename = "location_id")]
pub location_id: String,
#[serde(rename = "created_at")]
pub created_at: String,
}
#[derive(Debug, Deserialize)]
pub struct SquareMoney {
pub amount: i64,
pub currency: String,
}
#[derive(Debug, Deserialize)]
pub struct SquareCardDetails {
pub card: SquareCard,
}
#[derive(Debug, Deserialize)]
pub struct SquareCard {
pub fingerprint: Option<String>,
#[serde(rename = "last_4")]
pub last_4: Option<String>,
#[serde(rename = "card_brand")]
pub card_brand: Option<String>,
}
#[instrument(skip(body, signature_key))]
pub fn verify_square_signature(
signature_header: &str,
body: &[u8],
notification_url: &str,
signature_key: &str,
) -> Result<bool, WebhookError> {
let mut mac = HmacSha256::new_from_slice(signature_key.as_bytes())
.map_err(|e| WebhookError::InvalidSignature(format!("HMAC error: {}", e)))?;
mac.update(notification_url.as_bytes());
mac.update(body);
let computed = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
mac.finalize().into_bytes(),
);
if computed == signature_header {
info!("Square signature verified");
Ok(true)
} else {
warn!("Square signature verification failed");
Ok(false)
}
}
#[instrument(skip(body, privacy))]
pub fn parse_square_webhook(
body: &[u8],
privacy: &PrivacyLayer,
merchant_id: &str, ) -> Result<NormalizedTransaction, WebhookError> {
let payload: SquareWebhookPayload = serde_json::from_slice(body)
.map_err(|e| WebhookError::ParseError(format!("Invalid JSON: {}", e)))?;
let payment = payload.data.object.payment;
let card_details = payment.card_details
.ok_or_else(|| WebhookError::ParseError("No card details".into()))?;
let card = card_details.card;
let raw_fingerprint = card.fingerprint
.ok_or_else(|| WebhookError::ParseError("No card fingerprint".into()))?;
let fingerprint = privacy.hash_card_id(raw_fingerprint);
let timestamp = chrono::DateTime::parse_from_rfc3339(&payment.created_at)
.map(|dt| dt.timestamp())
.unwrap_or_else(|_| chrono::Utc::now().timestamp());
Ok(NormalizedTransaction {
fingerprint: fingerprint.into_string(),
card_last4: card.last_4.unwrap_or_default(),
card_brand: card.card_brand.unwrap_or_default(),
amount_cents: payment.amount_money.amount as u64,
currency: payment.amount_money.currency,
merchant_id: merchant_id.to_string(),
merchant_name: None,
location_id: Some(payment.location_id),
timestamp,
source_txn_id: payment.id,
source: WebhookSource::Square,
txn_type: TransactionType::Clearing,
})
}
#[instrument(skip(body, secret))]
pub fn verify_stripe_signature(
signature_header: &str,
body: &[u8],
secret: &str,
) -> Result<bool, WebhookError> {
let parts: HashMap<&str, &str> = signature_header
.split(',')
.filter_map(|part| {
let mut kv = part.splitn(2, '=');
Some((kv.next()?, kv.next()?))
})
.collect();
let timestamp = parts.get("t")
.ok_or_else(|| WebhookError::InvalidSignature("Missing timestamp".into()))?;
let signature = parts.get("v1")
.ok_or_else(|| WebhookError::InvalidSignature("Missing v1 signature".into()))?;
let signed_payload = format!("{}.{}", timestamp, String::from_utf8_lossy(body));
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|e| WebhookError::InvalidSignature(format!("HMAC error: {}", e)))?;
mac.update(signed_payload.as_bytes());
let computed = hex::encode(mac.finalize().into_bytes());
if computed == *signature {
info!("Stripe signature verified");
Ok(true)
} else {
warn!("Stripe signature verification failed");
Ok(false)
}
}
pub struct WebhookHandler {
privacy: PrivacyLayer,
secrets: WebhookSecrets,
}
impl WebhookHandler {
pub async fn new(secrets: WebhookSecrets) -> Result<Self, WebhookError> {
let privacy = PrivacyLayer::new(&Default::default()).await
.map_err(|e| WebhookError::InitError(e.to_string()))?;
Ok(Self { privacy, secrets })
}
#[instrument(skip(self, body))]
pub async fn process(
&self,
headers: &HashMap<String, String>,
body: &[u8],
context: &WebhookContext,
) -> Result<NormalizedTransaction, WebhookError> {
let source = WebhookSource::detect(headers);
info!(?source, "Detected webhook source");
match source {
WebhookSource::Fidel => {
let sig = headers.get("x-fidel-signature")
.or_else(|| headers.get("X-Fidel-Signature"))
.ok_or_else(|| WebhookError::InvalidSignature("Missing signature header".into()))?;
let secret = self.get_fidel_secret(body)?;
if !verify_fidel_signature(sig, body, &secret)? {
return Err(WebhookError::SignatureVerificationFailed);
}
}
WebhookSource::Square => {
let sig = headers.get("x-square-signature")
.or_else(|| headers.get("X-Square-Signature"))
.ok_or_else(|| WebhookError::InvalidSignature("Missing signature header".into()))?;
let secret = self.secrets.square.as_ref()
.ok_or_else(|| WebhookError::MissingSecret("Square".into()))?;
let url = context.webhook_url.as_ref()
.ok_or_else(|| WebhookError::MissingContext("webhook_url".into()))?;
if !verify_square_signature(sig, body, url, secret)? {
return Err(WebhookError::SignatureVerificationFailed);
}
}
WebhookSource::Stripe => {
let sig = headers.get("stripe-signature")
.or_else(|| headers.get("Stripe-Signature"))
.ok_or_else(|| WebhookError::InvalidSignature("Missing signature header".into()))?;
let secret = self.secrets.stripe.as_ref()
.ok_or_else(|| WebhookError::MissingSecret("Stripe".into()))?;
if !verify_stripe_signature(sig, body, secret)? {
return Err(WebhookError::SignatureVerificationFailed);
}
}
WebhookSource::Unknown => {
return Err(WebhookError::UnknownSource);
}
}
let transaction = match source {
WebhookSource::Fidel => parse_fidel_webhook(body, &self.privacy)?,
WebhookSource::Square => {
let merchant_id = context.merchant_id.as_ref()
.ok_or_else(|| WebhookError::MissingContext("merchant_id".into()))?;
parse_square_webhook(body, &self.privacy, merchant_id)?
}
WebhookSource::Stripe => {
return Err(WebhookError::NotImplemented("Stripe parsing".into()));
}
WebhookSource::Unknown => unreachable!(),
};
info!(
source = ?source,
fingerprint = %transaction.fingerprint,
amount_cents = transaction.amount_cents,
"Transaction normalized"
);
Ok(transaction)
}
fn get_fidel_secret(&self, body: &[u8]) -> Result<String, WebhookError> {
#[derive(Deserialize)]
struct EventPeek {
#[serde(rename = "type")]
event_type: String,
}
let peek: EventPeek = serde_json::from_slice(body)
.map_err(|e| WebhookError::ParseError(format!("Cannot determine event type: {}", e)))?;
let secret = match peek.event_type.as_str() {
"transaction.auth" => &self.secrets.fidel.auth,
"transaction.clearing" => &self.secrets.fidel.clearing,
"card.linked" => &self.secrets.fidel.card_linked,
_ => &self.secrets.fidel.clearing, };
if secret.is_empty() {
return Err(WebhookError::MissingSecret(format!("Fidel {}", peek.event_type)));
}
Ok(secret.clone())
}
}
#[derive(Debug, Default)]
pub struct WebhookContext {
pub webhook_url: Option<String>,
pub merchant_id: Option<String>,
}
#[derive(Debug, Clone)]
pub enum WebhookError {
InvalidSignature(String),
SignatureVerificationFailed,
ParseError(String),
UnknownSource,
MissingSecret(String),
MissingContext(String),
NotImplemented(String),
InitError(String),
}
impl std::fmt::Display for WebhookError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidSignature(msg) => write!(f, "Invalid signature: {}", msg),
Self::SignatureVerificationFailed => write!(f, "Signature verification failed"),
Self::ParseError(msg) => write!(f, "Parse error: {}", msg),
Self::UnknownSource => write!(f, "Unknown webhook source"),
Self::MissingSecret(name) => write!(f, "Missing secret: {}", name),
Self::MissingContext(name) => write!(f, "Missing context: {}", name),
Self::NotImplemented(feature) => write!(f, "Not implemented: {}", feature),
Self::InitError(msg) => write!(f, "Initialization error: {}", msg),
}
}
}
impl std::error::Error for WebhookError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_fidel_source() {
let mut headers = HashMap::new();
headers.insert("x-fidel-signature".to_string(), "sha256=abc".to_string());
assert_eq!(WebhookSource::detect(&headers), WebhookSource::Fidel);
}
#[test]
fn detect_square_source() {
let mut headers = HashMap::new();
headers.insert("x-square-signature".to_string(), "abc".to_string());
assert_eq!(WebhookSource::detect(&headers), WebhookSource::Square);
}
#[test]
fn detect_unknown_source() {
let headers = HashMap::new();
assert_eq!(WebhookSource::detect(&headers), WebhookSource::Unknown);
}
}