1use crate::privacy::{LoopFingerprint, PrivacyLayer, PrivacyError};
28use hmac::{Hmac, Mac};
29use sha2::Sha256;
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32use tracing::{error, info, warn, instrument};
33
34type HmacSha256 = Hmac<Sha256>;
35
36#[derive(Debug, Clone)]
42pub struct WebhookSecrets {
43 pub fidel: FidelSecrets,
45 pub square: Option<String>,
47 pub stripe: Option<String>,
49}
50
51#[derive(Debug, Clone)]
52pub struct FidelSecrets {
53 pub auth: String,
55 pub clearing: String,
57 pub card_linked: String,
59}
60
61impl WebhookSecrets {
62 pub fn from_env() -> Self {
64 Self {
65 fidel: FidelSecrets {
66 auth: std::env::var("FIDEL_WEBHOOK_SECRET_AUTH")
67 .unwrap_or_default(),
68 clearing: std::env::var("FIDEL_WEBHOOK_SECRET_CLEARING")
69 .unwrap_or_default(),
70 card_linked: std::env::var("FIDEL_WEBHOOK_SECRET_CARD_LINKED")
71 .unwrap_or_default(),
72 },
73 square: std::env::var("SQUARE_WEBHOOK_SIGNATURE_KEY").ok(),
74 stripe: std::env::var("STRIPE_WEBHOOK_SECRET").ok(),
75 }
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85pub enum WebhookSource {
86 Fidel,
87 Square,
88 Stripe,
89 Unknown,
90}
91
92impl WebhookSource {
93 pub fn detect(headers: &HashMap<String, String>) -> Self {
95 if headers.contains_key("x-fidel-signature") || headers.contains_key("X-Fidel-Signature") {
97 return Self::Fidel;
98 }
99
100 if headers.contains_key("x-square-signature") || headers.contains_key("X-Square-Signature") {
102 return Self::Square;
103 }
104
105 if headers.contains_key("stripe-signature") || headers.contains_key("Stripe-Signature") {
107 return Self::Stripe;
108 }
109
110 Self::Unknown
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct NormalizedTransaction {
124 pub fingerprint: String,
126 pub card_last4: String,
128 pub card_brand: String,
130 pub amount_cents: u64,
132 pub currency: String,
134 pub merchant_id: String,
136 pub merchant_name: Option<String>,
138 pub location_id: Option<String>,
140 pub timestamp: i64,
142 pub source_txn_id: String,
144 pub source: WebhookSource,
146 pub txn_type: TransactionType,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151pub enum TransactionType {
152 Auth,
154 Clearing,
156 Refund,
158}
159
160#[derive(Debug, Deserialize)]
166pub struct FidelWebhookPayload {
167 pub transaction: FidelTransaction,
169 #[serde(rename = "type")]
171 pub event_type: String,
172}
173
174#[derive(Debug, Deserialize)]
175pub struct FidelTransaction {
176 pub id: String,
178 #[serde(rename = "cardId")]
180 pub card_id: String,
181 #[serde(rename = "lastNumbers")]
183 pub last_numbers: String,
184 pub scheme: String,
186 pub amount: f64,
188 pub currency: String,
190 #[serde(rename = "brandId")]
192 pub brand_id: Option<String>,
193 #[serde(rename = "merchantName")]
194 pub merchant_name: Option<String>,
195 #[serde(rename = "locationId")]
196 pub location_id: Option<String>,
197 #[serde(rename = "datetime")]
199 pub datetime: String,
200 #[serde(rename = "auth")]
202 pub is_auth: Option<bool>,
203}
204
205#[instrument(skip(body, secret))]
210pub fn verify_fidel_signature(
211 signature_header: &str,
212 body: &[u8],
213 secret: &str,
214) -> Result<bool, WebhookError> {
215 let signature = signature_header
217 .strip_prefix("sha256=")
218 .ok_or_else(|| WebhookError::InvalidSignature("Missing sha256= prefix".into()))?;
219
220 let expected_bytes = hex::decode(signature)
221 .map_err(|e| WebhookError::InvalidSignature(format!("Invalid hex: {}", e)))?;
222
223 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
224 .map_err(|e| WebhookError::InvalidSignature(format!("HMAC error: {}", e)))?;
225
226 mac.update(body);
227
228 match mac.verify_slice(&expected_bytes) {
230 Ok(_) => {
231 info!("Fidel signature verified");
232 Ok(true)
233 }
234 Err(_) => {
235 warn!("Fidel signature verification failed");
236 Ok(false)
237 }
238 }
239}
240
241#[instrument(skip(body, privacy))]
251pub fn parse_fidel_webhook(
252 body: &[u8],
253 privacy: &PrivacyLayer,
254) -> Result<NormalizedTransaction, WebhookError> {
255 let payload: FidelWebhookPayload = serde_json::from_slice(body)
257 .map_err(|e| WebhookError::ParseError(format!("Invalid JSON: {}", e)))?;
258
259 let txn = payload.transaction;
260
261 let fingerprint = privacy.hash_card_id(txn.card_id);
264
265 let timestamp = chrono::DateTime::parse_from_rfc3339(&txn.datetime)
270 .map(|dt| dt.timestamp())
271 .unwrap_or_else(|_| chrono::Utc::now().timestamp());
272
273 let txn_type = match payload.event_type.as_str() {
275 "transaction.auth" => TransactionType::Auth,
276 "transaction.clearing" => TransactionType::Clearing,
277 "transaction.refund" => TransactionType::Refund,
278 _ => TransactionType::Clearing, };
280
281 let amount_cents = (txn.amount * 100.0).round() as u64;
283
284 Ok(NormalizedTransaction {
285 fingerprint: fingerprint.into_string(),
286 card_last4: txn.last_numbers,
287 card_brand: txn.scheme,
288 amount_cents,
289 currency: txn.currency,
290 merchant_id: txn.brand_id.unwrap_or_else(|| "unknown".to_string()),
291 merchant_name: txn.merchant_name,
292 location_id: txn.location_id,
293 timestamp,
294 source_txn_id: txn.id,
295 source: WebhookSource::Fidel,
296 txn_type,
297 })
298}
299
300#[derive(Debug, Deserialize)]
306pub struct SquareWebhookPayload {
307 #[serde(rename = "type")]
309 pub event_type: String,
310 pub data: SquareEventData,
312}
313
314#[derive(Debug, Deserialize)]
315pub struct SquareEventData {
316 pub object: SquarePaymentObject,
318}
319
320#[derive(Debug, Deserialize)]
321pub struct SquarePaymentObject {
322 pub payment: SquarePayment,
323}
324
325#[derive(Debug, Deserialize)]
326pub struct SquarePayment {
327 pub id: String,
328 #[serde(rename = "amount_money")]
329 pub amount_money: SquareMoney,
330 #[serde(rename = "card_details")]
331 pub card_details: Option<SquareCardDetails>,
332 #[serde(rename = "location_id")]
333 pub location_id: String,
334 #[serde(rename = "created_at")]
335 pub created_at: String,
336}
337
338#[derive(Debug, Deserialize)]
339pub struct SquareMoney {
340 pub amount: i64,
341 pub currency: String,
342}
343
344#[derive(Debug, Deserialize)]
345pub struct SquareCardDetails {
346 pub card: SquareCard,
347}
348
349#[derive(Debug, Deserialize)]
350pub struct SquareCard {
351 pub fingerprint: Option<String>,
353 #[serde(rename = "last_4")]
354 pub last_4: Option<String>,
355 #[serde(rename = "card_brand")]
356 pub card_brand: Option<String>,
357}
358
359#[instrument(skip(body, signature_key))]
361pub fn verify_square_signature(
362 signature_header: &str,
363 body: &[u8],
364 notification_url: &str,
365 signature_key: &str,
366) -> Result<bool, WebhookError> {
367 let mut mac = HmacSha256::new_from_slice(signature_key.as_bytes())
369 .map_err(|e| WebhookError::InvalidSignature(format!("HMAC error: {}", e)))?;
370
371 mac.update(notification_url.as_bytes());
372 mac.update(body);
373
374 let computed = base64::Engine::encode(
375 &base64::engine::general_purpose::STANDARD,
376 mac.finalize().into_bytes(),
377 );
378
379 if computed == signature_header {
380 info!("Square signature verified");
381 Ok(true)
382 } else {
383 warn!("Square signature verification failed");
384 Ok(false)
385 }
386}
387
388#[instrument(skip(body, privacy))]
390pub fn parse_square_webhook(
391 body: &[u8],
392 privacy: &PrivacyLayer,
393 merchant_id: &str, ) -> Result<NormalizedTransaction, WebhookError> {
395 let payload: SquareWebhookPayload = serde_json::from_slice(body)
396 .map_err(|e| WebhookError::ParseError(format!("Invalid JSON: {}", e)))?;
397
398 let payment = payload.data.object.payment;
399
400 let card_details = payment.card_details
402 .ok_or_else(|| WebhookError::ParseError("No card details".into()))?;
403
404 let card = card_details.card;
405
406 let raw_fingerprint = card.fingerprint
408 .ok_or_else(|| WebhookError::ParseError("No card fingerprint".into()))?;
409
410 let fingerprint = privacy.hash_card_id(raw_fingerprint);
412
413 let timestamp = chrono::DateTime::parse_from_rfc3339(&payment.created_at)
415 .map(|dt| dt.timestamp())
416 .unwrap_or_else(|_| chrono::Utc::now().timestamp());
417
418 Ok(NormalizedTransaction {
419 fingerprint: fingerprint.into_string(),
420 card_last4: card.last_4.unwrap_or_default(),
421 card_brand: card.card_brand.unwrap_or_default(),
422 amount_cents: payment.amount_money.amount as u64,
423 currency: payment.amount_money.currency,
424 merchant_id: merchant_id.to_string(),
425 merchant_name: None,
426 location_id: Some(payment.location_id),
427 timestamp,
428 source_txn_id: payment.id,
429 source: WebhookSource::Square,
430 txn_type: TransactionType::Clearing,
431 })
432}
433
434#[instrument(skip(body, secret))]
440pub fn verify_stripe_signature(
441 signature_header: &str,
442 body: &[u8],
443 secret: &str,
444) -> Result<bool, WebhookError> {
445 let parts: HashMap<&str, &str> = signature_header
447 .split(',')
448 .filter_map(|part| {
449 let mut kv = part.splitn(2, '=');
450 Some((kv.next()?, kv.next()?))
451 })
452 .collect();
453
454 let timestamp = parts.get("t")
455 .ok_or_else(|| WebhookError::InvalidSignature("Missing timestamp".into()))?;
456
457 let signature = parts.get("v1")
458 .ok_or_else(|| WebhookError::InvalidSignature("Missing v1 signature".into()))?;
459
460 let signed_payload = format!("{}.{}", timestamp, String::from_utf8_lossy(body));
462
463 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
464 .map_err(|e| WebhookError::InvalidSignature(format!("HMAC error: {}", e)))?;
465
466 mac.update(signed_payload.as_bytes());
467
468 let computed = hex::encode(mac.finalize().into_bytes());
469
470 if computed == *signature {
471 info!("Stripe signature verified");
472 Ok(true)
473 } else {
474 warn!("Stripe signature verification failed");
475 Ok(false)
476 }
477}
478
479pub struct WebhookHandler {
488 privacy: PrivacyLayer,
489 secrets: WebhookSecrets,
490}
491
492impl WebhookHandler {
493 pub async fn new(secrets: WebhookSecrets) -> Result<Self, WebhookError> {
495 let privacy = PrivacyLayer::new(&Default::default()).await
496 .map_err(|e| WebhookError::InitError(e.to_string()))?;
497
498 Ok(Self { privacy, secrets })
499 }
500
501 #[instrument(skip(self, body))]
505 pub async fn process(
506 &self,
507 headers: &HashMap<String, String>,
508 body: &[u8],
509 context: &WebhookContext,
510 ) -> Result<NormalizedTransaction, WebhookError> {
511 let source = WebhookSource::detect(headers);
513 info!(?source, "Detected webhook source");
514
515 match source {
517 WebhookSource::Fidel => {
518 let sig = headers.get("x-fidel-signature")
519 .or_else(|| headers.get("X-Fidel-Signature"))
520 .ok_or_else(|| WebhookError::InvalidSignature("Missing signature header".into()))?;
521
522 let secret = self.get_fidel_secret(body)?;
524
525 if !verify_fidel_signature(sig, body, &secret)? {
526 return Err(WebhookError::SignatureVerificationFailed);
527 }
528 }
529 WebhookSource::Square => {
530 let sig = headers.get("x-square-signature")
531 .or_else(|| headers.get("X-Square-Signature"))
532 .ok_or_else(|| WebhookError::InvalidSignature("Missing signature header".into()))?;
533
534 let secret = self.secrets.square.as_ref()
535 .ok_or_else(|| WebhookError::MissingSecret("Square".into()))?;
536
537 let url = context.webhook_url.as_ref()
538 .ok_or_else(|| WebhookError::MissingContext("webhook_url".into()))?;
539
540 if !verify_square_signature(sig, body, url, secret)? {
541 return Err(WebhookError::SignatureVerificationFailed);
542 }
543 }
544 WebhookSource::Stripe => {
545 let sig = headers.get("stripe-signature")
546 .or_else(|| headers.get("Stripe-Signature"))
547 .ok_or_else(|| WebhookError::InvalidSignature("Missing signature header".into()))?;
548
549 let secret = self.secrets.stripe.as_ref()
550 .ok_or_else(|| WebhookError::MissingSecret("Stripe".into()))?;
551
552 if !verify_stripe_signature(sig, body, secret)? {
553 return Err(WebhookError::SignatureVerificationFailed);
554 }
555 }
556 WebhookSource::Unknown => {
557 return Err(WebhookError::UnknownSource);
558 }
559 }
560
561 let transaction = match source {
563 WebhookSource::Fidel => parse_fidel_webhook(body, &self.privacy)?,
564 WebhookSource::Square => {
565 let merchant_id = context.merchant_id.as_ref()
566 .ok_or_else(|| WebhookError::MissingContext("merchant_id".into()))?;
567 parse_square_webhook(body, &self.privacy, merchant_id)?
568 }
569 WebhookSource::Stripe => {
570 return Err(WebhookError::NotImplemented("Stripe parsing".into()));
572 }
573 WebhookSource::Unknown => unreachable!(),
574 };
575
576 info!(
577 source = ?source,
578 fingerprint = %transaction.fingerprint,
579 amount_cents = transaction.amount_cents,
580 "Transaction normalized"
581 );
582
583 Ok(transaction)
584 }
585
586 fn get_fidel_secret(&self, body: &[u8]) -> Result<String, WebhookError> {
588 #[derive(Deserialize)]
590 struct EventPeek {
591 #[serde(rename = "type")]
592 event_type: String,
593 }
594
595 let peek: EventPeek = serde_json::from_slice(body)
596 .map_err(|e| WebhookError::ParseError(format!("Cannot determine event type: {}", e)))?;
597
598 let secret = match peek.event_type.as_str() {
599 "transaction.auth" => &self.secrets.fidel.auth,
600 "transaction.clearing" => &self.secrets.fidel.clearing,
601 "card.linked" => &self.secrets.fidel.card_linked,
602 _ => &self.secrets.fidel.clearing, };
604
605 if secret.is_empty() {
606 return Err(WebhookError::MissingSecret(format!("Fidel {}", peek.event_type)));
607 }
608
609 Ok(secret.clone())
610 }
611}
612
613#[derive(Debug, Default)]
615pub struct WebhookContext {
616 pub webhook_url: Option<String>,
618 pub merchant_id: Option<String>,
620}
621
622#[derive(Debug, Clone)]
627pub enum WebhookError {
628 InvalidSignature(String),
630 SignatureVerificationFailed,
632 ParseError(String),
634 UnknownSource,
636 MissingSecret(String),
638 MissingContext(String),
640 NotImplemented(String),
642 InitError(String),
644}
645
646impl std::fmt::Display for WebhookError {
647 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
648 match self {
649 Self::InvalidSignature(msg) => write!(f, "Invalid signature: {}", msg),
650 Self::SignatureVerificationFailed => write!(f, "Signature verification failed"),
651 Self::ParseError(msg) => write!(f, "Parse error: {}", msg),
652 Self::UnknownSource => write!(f, "Unknown webhook source"),
653 Self::MissingSecret(name) => write!(f, "Missing secret: {}", name),
654 Self::MissingContext(name) => write!(f, "Missing context: {}", name),
655 Self::NotImplemented(feature) => write!(f, "Not implemented: {}", feature),
656 Self::InitError(msg) => write!(f, "Initialization error: {}", msg),
657 }
658 }
659}
660
661impl std::error::Error for WebhookError {}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666
667 #[test]
668 fn detect_fidel_source() {
669 let mut headers = HashMap::new();
670 headers.insert("x-fidel-signature".to_string(), "sha256=abc".to_string());
671
672 assert_eq!(WebhookSource::detect(&headers), WebhookSource::Fidel);
673 }
674
675 #[test]
676 fn detect_square_source() {
677 let mut headers = HashMap::new();
678 headers.insert("x-square-signature".to_string(), "abc".to_string());
679
680 assert_eq!(WebhookSource::detect(&headers), WebhookSource::Square);
681 }
682
683 #[test]
684 fn detect_unknown_source() {
685 let headers = HashMap::new();
686 assert_eq!(WebhookSource::detect(&headers), WebhookSource::Unknown);
687 }
688}