Skip to main content

rustlavel_payment/
webhook.rs

1//! What a gateway says happened, and the record that stops it being heard twice.
2
3use 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    /// A kind this crate does not model. Stored and handed on unchanged, so a
19    /// gateway adding an event type does not make the receiver drop it on the
20    /// floor — and does not make it something it is not.
21    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/// A verified event.
38///
39/// Built by a driver's `verify_webhook`, which is the only constructor that
40/// makes sense: an event that has not had its signature checked is a string
41/// somebody posted.
42#[derive(Debug, Clone, PartialEq)]
43pub struct WebhookEvent {
44    /// The gateway's id for *the event*, or for the charge when the gateway
45    /// gives events no id of their own. Deduplication keys on this together
46    /// with the kind, so "charge X paid" and "charge X expired" are two events.
47    pub id: String,
48    pub kind: EventKind,
49    pub charge: Option<Charge>,
50    pub transfer: Option<Transfer>,
51    /// The body exactly as received, for storage and for replaying to a
52    /// handler that was broken at the time.
53    pub raw: Json,
54}
55
56impl WebhookEvent {
57    /// The key a duplicate is recognised by.
58    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
65/// Where received events are recorded.
66///
67/// **`record` must be atomic**: two deliveries of one event racing each other
68/// must see exactly one `true`. A read followed by a write leaves a window in
69/// which both do, and both credit the customer. The database implementation
70/// does it with a unique index and an insert; the memory one with a mutex.
71pub trait WebhookLog: Send + Sync + 'static {
72    /// Record the event, returning whether this call was the first to. `false`
73    /// means it has been seen: hand it back as handled and do nothing else.
74    fn record<'a>(&'a self, event: &'a WebhookEvent) -> LogFuture<'a, bool>;
75
76    /// The raw body of a recorded event, for replay.
77    fn raw<'a>(&'a self, dedup_key: &'a str) -> LogFuture<'a, Option<Json>>;
78
79    /// Forget an event whose handler failed, so the gateway's retry is
80    /// processed rather than dropped as a duplicate of something nothing
81    /// handled. Without this, a handler that crashed once would have crashed
82    /// forever as far as that payment is concerned.
83    fn withdraw<'a>(&'a self, dedup_key: &'a str) -> LogFuture<'a, ()>;
84}
85
86/// Events held in this process's memory. Right for tests; wrong for a
87/// deployment, where a duplicate that arrives after a restart is still a
88/// duplicate.
89#[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            // One critical section for the check and the insert — the whole
113            // of what "atomic" means here.
114            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    /// "charge X paid" and "charge X expired" are two events, and a gateway
153    /// that sends both must not have the second dropped as a duplicate.
154    #[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    /// A handler that crashed once must not have crashed forever as far as
162    /// that payment is concerned.
163    #[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}