rustlavel_payment/
webhook.rs1use crate::charge::Charge;
4use crate::transfer::Transfer;
5use rustlavel_core::{Json, Result};
6use std::collections::HashMap;
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Mutex;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum EventKind {
13 ChargePaid,
14 ChargeExpired,
15 ChargeFailed,
16 TransferCompleted,
17 TransferFailed,
18 Other(String),
22}
23
24impl EventKind {
25 pub fn as_str(&self) -> &str {
26 match self {
27 EventKind::ChargePaid => "charge.paid",
28 EventKind::ChargeExpired => "charge.expired",
29 EventKind::ChargeFailed => "charge.failed",
30 EventKind::TransferCompleted => "transfer.completed",
31 EventKind::TransferFailed => "transfer.failed",
32 EventKind::Other(name) => name,
33 }
34 }
35}
36
37#[derive(Debug, Clone, PartialEq)]
43pub struct WebhookEvent {
44 pub id: String,
48 pub kind: EventKind,
49 pub charge: Option<Charge>,
50 pub transfer: Option<Transfer>,
51 pub raw: Json,
54}
55
56impl WebhookEvent {
57 pub fn dedup_key(&self) -> String {
59 format!("{}:{}", self.kind.as_str(), self.id)
60 }
61}
62
63pub type LogFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
64
65pub trait WebhookLog: Send + Sync + 'static {
72 fn record<'a>(&'a self, event: &'a WebhookEvent) -> LogFuture<'a, bool>;
75
76 fn raw<'a>(&'a self, dedup_key: &'a str) -> LogFuture<'a, Option<Json>>;
78
79 fn withdraw<'a>(&'a self, dedup_key: &'a str) -> LogFuture<'a, ()>;
84}
85
86#[derive(Default)]
90pub struct MemoryWebhookLog {
91 seen: Mutex<HashMap<String, Json>>,
92}
93
94impl MemoryWebhookLog {
95 pub fn new() -> MemoryWebhookLog {
96 MemoryWebhookLog::default()
97 }
98
99 pub fn len(&self) -> usize {
100 self.seen.lock().expect("the webhook log is poisoned").len()
101 }
102
103 pub fn is_empty(&self) -> bool {
104 self.len() == 0
105 }
106}
107
108impl WebhookLog for MemoryWebhookLog {
109 fn record<'a>(&'a self, event: &'a WebhookEvent) -> LogFuture<'a, bool> {
110 Box::pin(async move {
111 let mut seen = self.seen.lock().expect("the webhook log is poisoned");
112 if seen.contains_key(&event.dedup_key()) {
115 return Ok(false);
116 }
117 seen.insert(event.dedup_key(), event.raw.clone());
118 Ok(true)
119 })
120 }
121
122 fn raw<'a>(&'a self, dedup_key: &'a str) -> LogFuture<'a, Option<Json>> {
123 Box::pin(async move {
124 Ok(self.seen.lock().expect("the webhook log is poisoned").get(dedup_key).cloned())
125 })
126 }
127
128 fn withdraw<'a>(&'a self, dedup_key: &'a str) -> LogFuture<'a, ()> {
129 Box::pin(async move {
130 self.seen.lock().expect("the webhook log is poisoned").remove(dedup_key);
131 Ok(())
132 })
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 fn event(id: &str, kind: EventKind) -> WebhookEvent {
141 WebhookEvent { id: id.into(), kind, charge: None, transfer: None, raw: Json::from(id) }
142 }
143
144 #[tokio::test]
145 async fn the_same_event_is_recorded_once() {
146 let log = MemoryWebhookLog::new();
147 assert!(log.record(&event("ch_1", EventKind::ChargePaid)).await.unwrap());
148 assert!(!log.record(&event("ch_1", EventKind::ChargePaid)).await.unwrap(), "a duplicate was accepted");
149 assert_eq!(log.len(), 1);
150 }
151
152 #[tokio::test]
155 async fn the_same_id_under_another_kind_is_another_event() {
156 let log = MemoryWebhookLog::new();
157 assert!(log.record(&event("ch_1", EventKind::ChargePaid)).await.unwrap());
158 assert!(log.record(&event("ch_1", EventKind::ChargeExpired)).await.unwrap());
159 }
160
161 #[tokio::test]
164 async fn a_withdrawn_event_can_be_recorded_again() {
165 let log = MemoryWebhookLog::new();
166 let e = event("ch_1", EventKind::ChargePaid);
167 assert!(log.record(&e).await.unwrap());
168 log.withdraw(&e.dedup_key()).await.unwrap();
169 assert!(log.record(&e).await.unwrap(), "the retry was dropped as a duplicate");
170 }
171
172 #[tokio::test]
173 async fn the_raw_body_is_kept_for_replay() {
174 let log = MemoryWebhookLog::new();
175 let e = event("ch_9", EventKind::ChargePaid);
176 log.record(&e).await.unwrap();
177 assert_eq!(log.raw(&e.dedup_key()).await.unwrap(), Some(Json::from("ch_9")));
178 assert_eq!(log.raw("charge.paid:nothing").await.unwrap(), None);
179 }
180}