Skip to main content

pakasir_sdk/
webhook.rs

1// Copyright 2026 H0llyW00dzZ
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Webhook payload parser.
16//!
17//! Pakasir delivers webhook events as a JSON `POST` body. This module turns
18//! that body into an [`Event`] and rejects payloads that are missing, too
19//! large, or malformed. Use [`Parser::parse_reader`] for streaming sources
20//! (e.g. the body of an HTTP handler) and [`Parser::parse_bytes`] for
21//! payloads you already have in memory.
22//!
23//! The parser caps the body it will read at [`DEFAULT_MAX_BODY_SIZE`]
24//! (1 MiB) to keep a misbehaving sender from forcing the process to buffer
25//! arbitrary amounts. Override with [`Parser::with_max_body_size`].
26
27use chrono::{DateTime, FixedOffset};
28use serde::{Deserialize, Serialize};
29use std::fmt;
30use std::io::Read;
31
32use crate::constants::{PaymentMethod, TransactionStatus};
33use crate::timefmt;
34
35/// Default cap on a single webhook body, in bytes (1 MiB).
36pub const DEFAULT_MAX_BODY_SIZE: usize = 1 << 20;
37
38/// Things that can go wrong while parsing a webhook.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum WebhookError {
41    /// Reader is missing where one was expected.
42    ///
43    /// Kept for parity with the original API surface — the typed
44    /// [`Parser::parse_reader`] signature makes it impossible to hit from
45    /// safe Rust code today.
46    NilReader,
47    /// The body was zero bytes.
48    EmptyBody,
49    /// The body grew past the configured size cap.
50    BodyTooLarge {
51        /// The cap that was exceeded.
52        limit: usize,
53    },
54    /// Reading from the supplied source failed. `String` is the underlying
55    /// IO error message, kept by value so [`WebhookError`] can stay
56    /// `Clone + PartialEq`.
57    ReadBody(String),
58    /// JSON decoding failed. `String` is the underlying `serde_json` error
59    /// message, for the same reason as above.
60    DecodeBody(String),
61    /// [`Event::validate`] saw an empty `order_id`.
62    InvalidOrderId,
63    /// [`Event::validate`] saw a non-positive `amount`.
64    InvalidAmount,
65}
66
67impl fmt::Display for WebhookError {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            Self::NilReader => f.write_str("webhook: nil reader"),
71            Self::EmptyBody => f.write_str("webhook: empty body"),
72            Self::BodyTooLarge { limit } => {
73                write!(f, "webhook: body too large: exceeds {limit} bytes")
74            }
75            Self::ReadBody(message) => write!(f, "webhook: read body failed: {message}"),
76            Self::DecodeBody(message) => write!(f, "webhook: decode body failed: {message}"),
77            Self::InvalidOrderId => f.write_str("webhook: invalid order id"),
78            Self::InvalidAmount => f.write_str("webhook: invalid amount"),
79        }
80    }
81}
82
83impl std::error::Error for WebhookError {}
84
85/// Reusable parser carrying the configured body size limit.
86#[derive(Debug, Clone)]
87pub struct Parser {
88    max_body_size: usize,
89}
90
91impl Default for Parser {
92    fn default() -> Self {
93        Self {
94            max_body_size: DEFAULT_MAX_BODY_SIZE,
95        }
96    }
97}
98
99impl Parser {
100    /// New parser with default settings.
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Override the body size limit. Zero is ignored so the default is
106    /// kept.
107    pub fn with_max_body_size(mut self, max_body_size: usize) -> Self {
108        if max_body_size > 0 {
109            self.max_body_size = max_body_size;
110        }
111        self
112    }
113
114    /// Read at most `max_body_size + 1` bytes from `reader`, then parse.
115    ///
116    /// The extra byte is what lets us detect "body grew past the limit"
117    /// reliably: if the read came back with more than the limit, we know
118    /// the sender had more to give and reject with
119    /// [`WebhookError::BodyTooLarge`] before touching `serde_json`.
120    pub fn parse_reader<R>(&self, mut reader: R) -> Result<Event, WebhookError>
121    where
122        R: Read,
123    {
124        let mut limited = (&mut reader).take((self.max_body_size + 1) as u64);
125        let mut data = Vec::new();
126        limited
127            .read_to_end(&mut data)
128            .map_err(|err| WebhookError::ReadBody(err.to_string()))?;
129
130        if data.len() > self.max_body_size {
131            return Err(WebhookError::BodyTooLarge {
132                limit: self.max_body_size,
133            });
134        }
135        if data.is_empty() {
136            return Err(WebhookError::EmptyBody);
137        }
138
139        self.parse_bytes(&data)
140    }
141
142    /// Parse `data` directly. Use this when you already have the body in
143    /// memory (e.g. a framework that buffered it for you).
144    pub fn parse_bytes(&self, data: &[u8]) -> Result<Event, WebhookError> {
145        if data.is_empty() {
146            return Err(WebhookError::EmptyBody);
147        }
148
149        serde_json::from_slice(data).map_err(|err| WebhookError::DecodeBody(err.to_string()))
150    }
151}
152
153/// Decoded webhook event.
154///
155/// Shape matches the JSON body Pakasir sends; field order in this struct is
156/// purely cosmetic.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct Event {
159    /// Transaction amount.
160    pub amount: i64,
161    /// Order identifier.
162    pub order_id: String,
163    /// Project slug the transaction belongs to.
164    pub project: String,
165    /// Lifecycle status at the moment the webhook fired.
166    pub status: TransactionStatus,
167    /// Payment method used for the transaction.
168    pub payment_method: PaymentMethod,
169    /// RFC 3339 completion timestamp.
170    pub completed_at: String,
171    /// `true` when this event came from a sandbox project.
172    ///
173    /// Not part of the public Pakasir webhook contract — the upstream
174    /// payload documented at <https://pakasir.com/p/docs> does not include
175    /// this field. Defaults to `false` when the JSON body omits it so
176    /// production webhooks continue to deserialize cleanly.
177    #[serde(default)]
178    pub is_sandbox: bool,
179}
180
181impl Event {
182    /// Parse [`Event::completed_at`] into a [`DateTime`].
183    pub fn parse_time(&self) -> Result<DateTime<FixedOffset>, chrono::ParseError> {
184        timefmt::parse_rfc3339(&self.completed_at)
185    }
186
187    /// Sanity-check the fields most handlers rely on.
188    ///
189    /// Returns [`WebhookError::InvalidOrderId`] for an empty `order_id` and
190    /// [`WebhookError::InvalidAmount`] for a non-positive `amount`. Other
191    /// fields are not inspected.
192    pub fn validate(&self) -> Result<(), WebhookError> {
193        if self.order_id.is_empty() {
194            return Err(WebhookError::InvalidOrderId);
195        }
196        if self.amount <= 0 {
197            return Err(WebhookError::InvalidAmount);
198        }
199        Ok(())
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use std::io::{self, Read};
207
208    const VALID_PAYLOAD: &[u8] = br#"{"amount":22000,"order_id":"INV1","project":"p","status":"completed","payment_method":"qris","completed_at":"2024-09-10T08:07:02.819+07:00","is_sandbox":false}"#;
209
210    /// Mirrors the exact payload shape documented at
211    /// <https://pakasir.com/p/docs>, which omits `is_sandbox`.
212    const DOC_PAYLOAD: &[u8] = br#"{"amount":22000,"order_id":"240910HDE7C9","project":"depodomain","status":"completed","payment_method":"qris","completed_at":"2024-09-10T08:07:02.819+07:00"}"#;
213
214    fn sample_event() -> Event {
215        Event {
216            amount: 22_000,
217            order_id: "INV1".into(),
218            project: "p".into(),
219            status: TransactionStatus::Completed,
220            payment_method: PaymentMethod::Qris,
221            completed_at: "2024-09-10T08:07:02.819+07:00".into(),
222            is_sandbox: false,
223        }
224    }
225
226    #[test]
227    fn webhook_error_display_covers_every_variant() {
228        assert_eq!(WebhookError::NilReader.to_string(), "webhook: nil reader");
229        assert_eq!(WebhookError::EmptyBody.to_string(), "webhook: empty body");
230        assert_eq!(
231            WebhookError::BodyTooLarge { limit: 1024 }.to_string(),
232            "webhook: body too large: exceeds 1024 bytes"
233        );
234        assert_eq!(
235            WebhookError::ReadBody("io broke".into()).to_string(),
236            "webhook: read body failed: io broke"
237        );
238        assert_eq!(
239            WebhookError::DecodeBody("bad json".into()).to_string(),
240            "webhook: decode body failed: bad json"
241        );
242        assert_eq!(
243            WebhookError::InvalidOrderId.to_string(),
244            "webhook: invalid order id"
245        );
246        assert_eq!(
247            WebhookError::InvalidAmount.to_string(),
248            "webhook: invalid amount"
249        );
250    }
251
252    #[test]
253    fn parser_default_uses_documented_limit() {
254        let parser = Parser::default();
255        assert_eq!(parser.max_body_size, DEFAULT_MAX_BODY_SIZE);
256    }
257
258    #[test]
259    fn parser_new_matches_default() {
260        let a = Parser::new();
261        let b = Parser::default();
262        assert_eq!(a.max_body_size, b.max_body_size);
263    }
264
265    #[test]
266    fn with_max_body_size_zero_is_a_no_op() {
267        let parser = Parser::new().with_max_body_size(0);
268        assert_eq!(parser.max_body_size, DEFAULT_MAX_BODY_SIZE);
269    }
270
271    #[test]
272    fn with_max_body_size_applies_positive_values() {
273        let parser = Parser::new().with_max_body_size(64);
274        assert_eq!(parser.max_body_size, 64);
275    }
276
277    #[test]
278    fn parse_bytes_decodes_a_valid_event() {
279        let event = Parser::new().parse_bytes(VALID_PAYLOAD).unwrap();
280        assert_eq!(event.order_id, "INV1");
281        assert_eq!(event.amount, 22_000);
282        assert_eq!(event.payment_method, PaymentMethod::Qris);
283        assert_eq!(event.status, TransactionStatus::Completed);
284        assert!(!event.is_sandbox);
285    }
286
287    #[test]
288    fn parse_bytes_accepts_doc_payload_without_is_sandbox() {
289        // The documented webhook body does not include `is_sandbox`; serde
290        // must default it to `false` instead of failing the whole decode.
291        let event = Parser::new().parse_bytes(DOC_PAYLOAD).unwrap();
292        assert_eq!(event.order_id, "240910HDE7C9");
293        assert!(!event.is_sandbox);
294    }
295
296    #[test]
297    fn parse_bytes_rejects_empty_input() {
298        let err = Parser::new().parse_bytes(b"").unwrap_err();
299        assert_eq!(err, WebhookError::EmptyBody);
300    }
301
302    #[test]
303    fn parse_bytes_rejects_malformed_json() {
304        let err = Parser::new().parse_bytes(b"not json").unwrap_err();
305        assert!(
306            matches!(&err, WebhookError::DecodeBody(message) if !message.is_empty()),
307            "expected non-empty DecodeBody, got: {err:?}"
308        );
309    }
310
311    #[test]
312    fn parse_reader_decodes_a_valid_event() {
313        let event = Parser::new().parse_reader(VALID_PAYLOAD).unwrap();
314        assert_eq!(event.order_id, "INV1");
315    }
316
317    #[test]
318    fn parse_reader_rejects_empty_reader() {
319        let err = Parser::new().parse_reader(&b""[..]).unwrap_err();
320        assert_eq!(err, WebhookError::EmptyBody);
321    }
322
323    #[test]
324    fn parse_reader_rejects_oversize_body() {
325        // Configure a tiny limit so even the small valid payload is "too large".
326        let parser = Parser::new().with_max_body_size(8);
327        let err = parser.parse_reader(VALID_PAYLOAD).unwrap_err();
328        assert_eq!(err, WebhookError::BodyTooLarge { limit: 8 });
329    }
330
331    #[test]
332    fn parse_reader_surfaces_read_errors() {
333        struct FailingReader;
334        impl Read for FailingReader {
335            fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
336                Err(io::Error::other("reader broke"))
337            }
338        }
339
340        let err = Parser::new().parse_reader(FailingReader).unwrap_err();
341        assert!(
342            matches!(&err, WebhookError::ReadBody(message) if message.contains("reader broke")),
343            "expected ReadBody containing 'reader broke', got: {err:?}"
344        );
345    }
346
347    #[test]
348    fn event_validate_accepts_a_well_formed_event() {
349        sample_event().validate().unwrap();
350    }
351
352    #[test]
353    fn event_validate_rejects_empty_order_id() {
354        let mut event = sample_event();
355        event.order_id.clear();
356        assert_eq!(event.validate().unwrap_err(), WebhookError::InvalidOrderId);
357    }
358
359    #[test]
360    fn event_validate_rejects_non_positive_amount() {
361        let mut event = sample_event();
362        event.amount = 0;
363        assert_eq!(event.validate().unwrap_err(), WebhookError::InvalidAmount);
364
365        event.amount = -1;
366        assert_eq!(event.validate().unwrap_err(), WebhookError::InvalidAmount);
367    }
368
369    #[test]
370    fn event_parse_time_round_trips_a_known_timestamp() {
371        let parsed = sample_event().parse_time().unwrap();
372        assert_eq!(parsed.to_rfc3339(), "2024-09-10T08:07:02.819+07:00");
373    }
374
375    #[test]
376    fn webhook_error_implements_std_error_trait() {
377        // Sanity: ensure the impl compiles + the trait method is callable.
378        let err: &dyn std::error::Error = &WebhookError::EmptyBody;
379        assert_eq!(err.to_string(), "webhook: empty body");
380    }
381}