1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
//! Helpers for handling Mollie's payment webhook.
//!
//! Mollie does not sign webhook deliveries. Its webhook endpoint is a bare notification —
//! "something changed about payment `tr_xxx`, go look" — sent as an unauthenticated
//! `POST` with an `application/x-www-form-urlencoded` body containing a single `id` field.
//! Anyone can `POST` anything to your webhook URL; nothing in the request itself proves it
//! came from Mollie or reflects a real payment.
//!
//! Everything in this module exists to get the payment id out of that untrusted body
//! *cheaply* before you spend an outbound, authenticated API call re-fetching the real
//! state. Nothing here is, or is a substitute for, verification.
//!
//! # Security
//!
//! The only correct way to handle a Mollie webhook delivery is:
//!
//! 1. Read the raw request body and pass it to [`parse_webhook_body`] (or call
//! [`is_valid_payment_id`] yourself if you have already extracted the `id` field some
//! other way). This is a *format* check — it rejects obviously-malformed input (wrong
//! prefix, empty, oversized, non-alphanumeric) so you do not spend an HTTP request
//! finding that out from Mollie instead. It says nothing about whether the id refers to
//! a real payment, let alone one of yours.
//! 2. Re-fetch the payment from the Mollie API — never trust the amount, status, or
//! metadata in the webhook body itself, only the id — supplying the amount you expect
//! for the order you believe this payment is for. In this crate that is
//! [`PaymentProvider::fetch_verified`](crate::PaymentProvider::fetch_verified), which
//! checks two things before handing back a [`VerifiedPayment`](crate::VerifiedPayment):
//! the amount and currency Mollie reports must match the one you pass in — failing with
//! [`crate::Error::AmountMismatch`] on any mismatch (underpayment, overpayment, or wrong
//! currency) — and the payment's status must be paid — failing with
//! [`crate::Error::NotPaid`] otherwise. Either check failing means there is no
//! `VerifiedPayment` to obtain; you never have to remember to compare the amount or the
//! status yourself.
//! 3. Look your order up **by the payment id you stored yourself** when you created the
//! payment — never by a reference, description, or metadata field echoed back in the
//! webhook body or in the re-fetched payment. Those values round-trip through Mollie,
//! and some of them originate from input a customer can influence.
//! 4. Treat any status this crate does not recognise
//! ([`PaymentStatus::Unknown`](crate::PaymentStatus::Unknown)) as "defer, take no
//! action" — never as a failure and never as grounds to cancel the order. Mollie adding
//! a new status must not be able to make your webhook handler cancel a real, possibly
//! already-paid order.
//!
//! See the crate-level docs and `SECURITY.md` for the full integrator checklist (row-locked
//! forward-only status transitions, webhook response codes, reconciliation, rate limiting).
/// Returns `true` if `id` has the shape of a Mollie payment id: the literal prefix `tr_`
/// followed by 1 to 60 ASCII alphanumeric characters.
///
/// # Not an authenticity check
///
/// This is a **cheap format and denial-of-service filter**, not a verification of
/// anything. It exists solely to reject obviously-malformed input — the wrong prefix, an
/// empty or absurdly long suffix, or characters that have no business in an id — before an
/// outbound HTTP request is spent finding that out from the Mollie API.
///
/// Passing this check proves nothing about whether `id` refers to a payment that exists,
/// let alone one that belongs to you, was actually paid, or was paid the right amount.
/// Mollie does not sign its webhook deliveries, so there is no cryptographic authenticity
/// check available at this layer at all. The actual control is re-fetching the payment
/// from the Mollie API and comparing the amount it reports against your own stored order
/// total — see the module-level `# Security` section.
///
/// # Examples
///
/// ```
/// use paykit::providers::mollie::webhook::is_valid_payment_id;
///
/// assert!(is_valid_payment_id("tr_WDqYK6vllg"));
/// assert!(!is_valid_payment_id("not-a-payment-id"));
/// ```
/// Extracts and format-validates the `id` field from a Mollie webhook body.
///
/// Mollie posts `application/x-www-form-urlencoded` with a single field, `id=tr_xxx`.
/// This parser looks for an `id` key among `&`-separated `key=value` pairs — field order
/// and the presence of other fields do not matter, and if `id` appears more than once the
/// first occurrence wins.
///
/// Returns `None` if no `id` field is present, if it has no value, or if the value does
/// not pass [`is_valid_payment_id`].
///
/// # Caller must bound the input length
///
/// This function does not itself limit the size of `body`. Bounding the size of an
/// untrusted request body is an HTTP-layer concern (see the endpoint-hardening advice in
/// `SECURITY.md`) and has to happen before the body reaches here — by the time a `&str`
/// exists to pass in, an unbounded read has already happened. Do not call this on an
/// unbounded read of the request body.
///
/// # No percent-decoding
///
/// This parser is deliberately dependency-free and does not decode percent-encoding. That
/// is a deliberate strictness choice, not an oversight: `id` (the key) and every character
/// [`is_valid_payment_id`] accepts in the value are all in the URL-encoding "unreserved"
/// set, so a conformant encoder never percent-encodes either of them. A body where `id` is
/// spelled `%69%64`, or whose value contains a `%`, is not a well-formed Mollie webhook
/// delivery by this parser's stricter standard, even though a spec-compliant percent-decoder
/// would accept it (decoding `%69%64` to `id` and reading the value through). Rejecting it
/// here — rather than matching what a general-purpose decoder would eventually do — closes
/// off parameter smuggling between this parser and any other parser that might see the same
/// body (e.g. a framework's own form-decoding middleware, or a proxy in front of it) and
/// disagree with it about what `id` means.
///
/// # Examples
///
/// ```
/// use paykit::providers::mollie::webhook::parse_webhook_body;
///
/// assert_eq!(parse_webhook_body("id=tr_WDqYK6vllg"), Some("tr_WDqYK6vllg"));
/// assert_eq!(parse_webhook_body("foo=bar"), None);
/// ```