Skip to main content

max / makenotwork

50.9 KB · 1327 lines History Blame Raw
1 //! Webhook signature verification, the Stripe-shaped structs we read a payload
2 //! into, and the wire-name match that turns one into an [`MnwEvent`].
3 //!
4 //! Everything Stripe-specific about an inbound webhook stops at this module.
5 //! A second provider implements [`super::PaymentProvider::normalize_webhook`]
6 //! with its own mapping and never sees these names or these views.
7 //!
8 //! rc.5 ships no webhook helper, so we keep the local HMAC `verify_signature`
9 //! and a thin [`UntypedEvent`] envelope.
10 //!
11 //! **The `*View` structs are `pub(in crate::payments)` on purpose.** They are
12 //! Stripe's shapes, defined ad-hoc rather than via `stripe_shared::*` to stay
13 //! resilient against new required fields Stripe adds — the original migration
14 //! bug was an over-strict typed struct. Letting them reach a handler is what
15 //! makes the rest of the codebase depend on Stripe's field names, so they stop
16 //! here: [`super::mnw_event`] converts each into an MNW-shaped type, and that
17 //! is what crosses out. The compiler enforces it, so this is not a convention
18 //! anyone can forget.
19
20 use hmac::{Hmac, KeyInit, Mac};
21 use sha2::Sha256;
22
23 use super::mnw_event::checkout_kind;
24 use super::{
25 CheckoutCompletion, InvoiceOutcome, MnwEvent, RefundOutcome, StripeClient,
26 SubscriptionLifecycle,
27 };
28 use crate::db::Cents;
29 use crate::error::{AppError, Result};
30
31 type HmacSha256 = Hmac<Sha256>;
32
33 /// A Stripe webhook envelope after signature verification and JSON parsing.
34 ///
35 /// `data_object` is the raw `data.object` JSON value, ready to be consumed
36 /// by `serde_json::from_value` into a typed rc.5 struct.
37 #[derive(Debug, Clone)]
38 pub struct UntypedEvent {
39 pub id: String,
40 pub type_: String,
41 pub data_object: serde_json::Value,
42 }
43
44 impl UntypedEvent {
45 /// Parse a JSON webhook payload. Caller must verify the signature first.
46 pub fn from_payload(payload: &str) -> Result<Self> {
47 let mut v: serde_json::Value = serde_json::from_str(payload).map_err(|e| {
48 tracing::warn!(error.kind = "envelope_json", error = %e, "webhook envelope JSON parse failed");
49 AppError::BadRequest(format!("Webhook envelope JSON parse failed: {e}"))
50 })?;
51
52 let id = take_string(&mut v, "id").ok_or_else(|| {
53 tracing::warn!(
54 error.kind = "envelope_missing_field",
55 missing = "id",
56 "webhook envelope missing required field"
57 );
58 AppError::BadRequest("Webhook envelope missing required field: id".to_string())
59 })?;
60 let type_ = take_string(&mut v, "type").ok_or_else(|| {
61 tracing::warn!(
62 error.kind = "envelope_missing_field",
63 missing = "type",
64 "webhook envelope missing required field"
65 );
66 AppError::BadRequest("Webhook envelope missing required field: type".to_string())
67 })?;
68 let data_object = v
69 .get_mut("data")
70 .and_then(|d| d.get_mut("object"))
71 .map(std::mem::take)
72 .ok_or_else(|| {
73 tracing::warn!(
74 error.kind = "envelope_missing_field",
75 missing = "data.object",
76 "webhook envelope missing required field"
77 );
78 AppError::BadRequest(
79 "Webhook envelope missing required field: data.object".to_string(),
80 )
81 })?;
82
83 Ok(UntypedEvent {
84 id,
85 type_,
86 data_object,
87 })
88 }
89 }
90
91 fn take_string(v: &mut serde_json::Value, key: &str) -> Option<String> {
92 v.get_mut(key).and_then(|s| match std::mem::take(s) {
93 serde_json::Value::String(s) => Some(s),
94 _ => None,
95 })
96 }
97
98 impl StripeClient {
99 /// Verify the webhook signature and return the parsed envelope.
100 ///
101 /// Tries each configured signing secret in turn and accepts on the first
102 /// match. We run multiple endpoints (`mnw-connect`, `mnw-you`), each with
103 /// its own secret; signatures don't carry an endpoint id, so checking
104 /// every secret is the only option.
105 ///
106 /// On failure the returned `AppError::BadRequest` body is specific enough
107 /// to distinguish signature failures ("Invalid webhook signature: ...") from
108 /// payload-shape failures ("Webhook envelope JSON parse failed: ...",
109 /// "Webhook envelope missing required field: ..."). The Stripe Dashboard
110 /// surfaces these bodies for failed webhook deliveries, so wording matters.
111 /// Past incidents (Stripe API version mismatch producing serde
112 /// `missing field` errors) were initially misread as signature failures.
113 #[tracing::instrument(skip_all, name = "payments::verify_webhook")]
114 pub fn verify_webhook(&self, payload: &str, signature: &str) -> Result<UntypedEvent> {
115 let mut last_err: Option<String> = None;
116 for secret in &self.config.webhook_secret {
117 match verify_signature(payload, signature, secret) {
118 Ok(()) => return UntypedEvent::from_payload(payload),
119 Err(e) => last_err = Some(e),
120 }
121 }
122 let reason = last_err.unwrap_or_else(|| "no signing secrets configured".to_string());
123 tracing::warn!(error.kind = "signature", reason = %reason, "webhook signature verification failed against all configured secrets");
124 Err(AppError::BadRequest(format!(
125 "Invalid webhook signature: {reason}"
126 )))
127 }
128
129 /// Verify a v2 thin event webhook and return the parsed JSON body.
130 ///
131 /// See `verify_webhook` for the failure-mode taxonomy.
132 #[tracing::instrument(skip_all, name = "payments::verify_webhook_v2")]
133 pub fn verify_webhook_v2(&self, payload: &str, signature: &str) -> Result<serde_json::Value> {
134 let secret = self.config.webhook_secret_v2.as_deref().ok_or_else(|| {
135 AppError::ServiceUnavailable("Stripe v2 webhook secret not configured".to_string())
136 })?;
137
138 verify_signature(payload, signature, secret).map_err(|e| {
139 tracing::warn!(error.kind = "signature", reason = %e, "v2 webhook signature verification failed");
140 AppError::BadRequest(format!("Invalid webhook signature: {e}"))
141 })?;
142
143 serde_json::from_str(payload).map_err(|e| {
144 tracing::warn!(error.kind = "envelope_json", error = %e, "v2 webhook payload parse failed");
145 AppError::BadRequest(format!("Webhook payload JSON parse failed: {e}"))
146 })
147 }
148
149 /// Normalize a verified envelope into the MNW vocabulary.
150 ///
151 /// Takes the whole envelope rather than `(type, object)` because that is
152 /// what the retry worker can produce: it re-parses a stored payload that
153 /// was verified once already and has no signature to re-check, so it calls
154 /// this without `verify_webhook` in front of it. The live handler composes
155 /// the two, verify then normalize.
156 ///
157 /// The Stripe wire names live in [`normalize_event`] below, which is the
158 /// point of the member: a second provider brings its own mapping and no
159 /// Stripe event-name literal escapes this module.
160 #[tracing::instrument(skip_all, name = "payments::normalize_webhook")]
161 pub fn normalize_webhook(&self, event: UntypedEvent) -> Result<MnwEvent> {
162 normalize_event(&event.type_, event.data_object)
163 }
164 }
165
166 /// Turn a verified Stripe delivery into what MNW does about it.
167 ///
168 /// The one normalization the two payload-bearing entry points share: the
169 /// live v1 handler and the retry worker re-parsing a stored payload outside
170 /// `verify_webhook`. Before this, each grew its own
171 /// `serde_json::from_value` calls and its own string match, which is how
172 /// they drifted.
173 ///
174 /// The third entry point, the v2 thin-event path, needs no step here and
175 /// that is not an omission: a thin event carries only a reference, so it
176 /// fetches the object through `PaymentProvider::fetch_account`, which
177 /// returns an [`super::AccountUpdate`] — already the normalized type. There
178 /// is no Stripe-shaped view to strip, and it converges on the same handler.
179 ///
180 /// `data_object` is consumed exactly once. A parse failure is a
181 /// `BadRequest` naming the object that would not parse, which is what the
182 /// Stripe Dashboard shows for a failed delivery — a past incident (an API
183 /// version mismatch producing serde `missing field` errors) was misread as
184 /// a signature failure because the wording did not distinguish them.
185 ///
186 /// Public for the integration harness's `MockPaymentProvider`, which drives
187 /// real Stripe-shaped payloads through the webhook route and so needs the real
188 /// mapping. Same reason [`verify_signature`] is public. Exposing the entry
189 /// point does not move the wire names, which stay in this module.
190 pub fn normalize_event(event_type: &str, data_object: serde_json::Value) -> Result<MnwEvent> {
191 let parse = |what: &str, e: serde_json::Error| {
192 AppError::BadRequest(format!("Failed to parse {what}: {e}"))
193 };
194
195 Ok(match event_type {
196 // Both route to one place. `completed` fires immediately; for
197 // asynchronous methods it arrives with payment_status="unpaid" and
198 // `async_payment_succeeded` re-delivers the settled session. What a
199 // handler needs is `settled`, not which of the two arrived.
200 "checkout.session.completed" | "checkout.session.async_payment_succeeded" => {
201 let view: CheckoutSessionView =
202 serde_json::from_value(data_object).map_err(|e| parse("CheckoutSession", e))?;
203 let kind = checkout_kind(view.metadata.as_ref());
204 MnwEvent::Checkout {
205 kind,
206 session: Box::new(CheckoutCompletion::from(view)),
207 }
208 }
209 "checkout.session.async_payment_failed" => {
210 let view: CheckoutSessionView =
211 serde_json::from_value(data_object).map_err(|e| parse("CheckoutSession", e))?;
212 MnwEvent::CheckoutAsyncPaymentFailed {
213 session_id: view.id,
214 }
215 }
216 "account.updated" => {
217 let view: AccountView =
218 serde_json::from_value(data_object).map_err(|e| parse("Account", e))?;
219 MnwEvent::AccountUpdated(Box::new(view.into()))
220 }
221 "charge.refunded" => {
222 let view: ChargeView =
223 serde_json::from_value(data_object).map_err(|e| parse("Charge", e))?;
224 MnwEvent::ChargeRefunded(ChargeRefundData::from_view(view).map(Box::new))
225 }
226 "refund.created" | "refund.updated" => {
227 let view: RefundView =
228 serde_json::from_value(data_object).map_err(|e| parse("Refund", e))?;
229 MnwEvent::RefundSettled(Box::new(RefundOutcome::from(view)))
230 }
231 "customer.subscription.updated" => {
232 let view: SubscriptionView =
233 serde_json::from_value(data_object).map_err(|e| parse("Subscription", e))?;
234 MnwEvent::SubscriptionUpdated(Box::new(SubscriptionLifecycle::from(view)))
235 }
236 "customer.subscription.deleted" => {
237 let view: SubscriptionView =
238 serde_json::from_value(data_object).map_err(|e| parse("Subscription", e))?;
239 MnwEvent::SubscriptionDeleted(Box::new(SubscriptionLifecycle::from(view)))
240 }
241 "invoice.payment_succeeded" => {
242 let view: InvoiceView =
243 serde_json::from_value(data_object).map_err(|e| parse("Invoice", e))?;
244 MnwEvent::InvoicePaymentSucceeded(Box::new(InvoiceOutcome::from(view)))
245 }
246 "invoice.payment_failed" => {
247 let view: InvoiceView =
248 serde_json::from_value(data_object).map_err(|e| parse("Invoice", e))?;
249 MnwEvent::InvoicePaymentFailed(Box::new(InvoiceOutcome::from(view)))
250 }
251 other => MnwEvent::Unhandled {
252 stripe_type: other.to_string(),
253 },
254 })
255 }
256
257 /// Narrow view of a CheckoutSession: only the fields any handler reads.
258 ///
259 /// Built ad-hoc rather than via `stripe_shared::CheckoutSession` to stay
260 /// resilient against new required fields Stripe adds. The original migration
261 /// bug was caused by an over-strict typed struct.
262 #[derive(Debug, Default, serde::Deserialize)]
263 pub(in crate::payments) struct CheckoutSessionView {
264 pub id: String,
265 #[serde(default)]
266 pub metadata: Option<std::collections::HashMap<String, String>>,
267 #[serde(default, deserialize_with = "deserialize_expandable_id")]
268 pub payment_intent: Option<String>,
269 #[serde(default, deserialize_with = "deserialize_expandable_id")]
270 pub subscription: Option<String>,
271 #[serde(default, deserialize_with = "deserialize_expandable_id")]
272 pub customer: Option<String>,
273 #[serde(default)]
274 pub customer_details: Option<CheckoutCustomerDetailsView>,
275 /// Pre-tax line-item total (cents) Stripe computed for the session. Used
276 /// only as a defense-in-depth reconciliation against our server-built line
277 /// items; absent on older/edge events, hence `Option`.
278 #[serde(default)]
279 pub amount_subtotal: Option<i64>,
280 /// What Stripe actually charged the buyer, when it converted at checkout.
281 ///
282 /// Present only when Adaptive Pricing converted; absent when the buyer paid
283 /// in the seller's currency. This is the one moment the converted figure is
284 /// knowable, so it is captured here and stored on the transaction rather
285 /// than re-derived later from a rate we do not have.
286 #[serde(default)]
287 pub presentment_details: Option<PresentmentDetailsView>,
288 /// Whether Stripe has captured funds for this session: `"paid"`,
289 /// `"unpaid"`, or `"no_payment_required"`. Synchronous card payments report
290 /// `"paid"` on `checkout.session.completed`; asynchronous methods (ACH,
291 /// SEPA, Bacs) report `"unpaid"` there and settle later via
292 /// `checkout.session.async_payment_succeeded`. Absent on older/edge events,
293 /// hence `Option`, see `payment_settled`.
294 #[serde(default)]
295 pub payment_status: Option<String>,
296 /// ISO currency of the session (e.g. `"usd"`). Sessions are built
297 /// server-side as USD; a non-USD value makes the integer-cents subtotal
298 /// reconciliation meaningless and is itself an anomaly. Absent on
299 /// older/edge events, hence `Option`.
300 #[serde(default)]
301 pub currency: Option<String>,
302 }
303
304 impl CheckoutSessionView {
305 /// True when funds are captured (or none were required) and it is safe to
306 /// deliver goods. Treats an absent field as settled to preserve behaviour
307 /// for legacy/edge events that predate the field; only an explicit
308 /// `"unpaid"` (an async method awaiting settlement) is withheld.
309 pub(in crate::payments) fn payment_settled(&self) -> bool {
310 matches!(
311 self.payment_status.as_deref(),
312 None | Some("paid" | "no_payment_required")
313 )
314 }
315 }
316
317 #[derive(Debug, Default, serde::Deserialize)]
318 pub(in crate::payments) struct CheckoutCustomerDetailsView {
319 pub email: Option<String>,
320 }
321
322 /// Narrow view of a Subscription: id, status, cancellation flag, and the
323 /// item-level period fields rc.5 promoted from the top level.
324 #[derive(Debug, serde::Deserialize)]
325 pub(in crate::payments) struct SubscriptionView {
326 pub id: String,
327 pub status: String,
328 #[serde(default)]
329 pub cancel_at_period_end: bool,
330 #[serde(default)]
331 pub items: SubscriptionItemList,
332 }
333
334 impl SubscriptionView {
335 /// Period from `items.data[0]` (rc.5 moved these off the top-level Subscription).
336 pub(in crate::payments) fn current_period(&self) -> Option<(i64, i64)> {
337 self.items
338 .data
339 .first()
340 .map(|it| (it.current_period_start, it.current_period_end))
341 }
342 }
343
344 #[derive(Debug, Default, serde::Deserialize)]
345 pub(in crate::payments) struct SubscriptionItemList {
346 #[serde(default)]
347 pub data: Vec<SubscriptionItemView>,
348 }
349
350 #[derive(Debug, serde::Deserialize)]
351 pub(in crate::payments) struct SubscriptionItemView {
352 #[serde(default)]
353 pub current_period_start: i64,
354 #[serde(default)]
355 pub current_period_end: i64,
356 }
357
358 /// Narrow view of an Invoice: subscription id (via legacy `subscription` or
359 /// the rc.5 `parent.subscription_details.subscription` path), period bounds,
360 /// and billing reason.
361 #[derive(Debug, serde::Deserialize)]
362 pub(in crate::payments) struct InvoiceView {
363 #[serde(default)]
364 pub period_start: i64,
365 #[serde(default)]
366 pub period_end: i64,
367 #[serde(default)]
368 pub billing_reason: Option<String>,
369 #[serde(default, deserialize_with = "deserialize_expandable_id")]
370 pub subscription: Option<String>,
371 #[serde(default)]
372 pub parent: Option<InvoiceParentView>,
373 }
374
375 impl InvoiceView {
376 /// Pull the subscription id from either the legacy or new field path.
377 pub(in crate::payments) fn subscription_id(&self) -> Option<&str> {
378 if let Some(s) = &self.subscription {
379 return Some(s.as_str());
380 }
381 self.parent
382 .as_ref()?
383 .subscription_details
384 .as_ref()?
385 .subscription
386 .as_deref()
387 }
388
389 pub(in crate::payments) fn is_renewal(&self) -> bool {
390 self.billing_reason.as_deref() == Some("subscription_cycle")
391 }
392 }
393
394 #[derive(Debug, serde::Deserialize)]
395 pub(in crate::payments) struct InvoiceParentView {
396 #[serde(default)]
397 pub subscription_details: Option<InvoiceSubscriptionDetailsView>,
398 }
399
400 #[derive(Debug, serde::Deserialize)]
401 pub(in crate::payments) struct InvoiceSubscriptionDetailsView {
402 #[serde(default, deserialize_with = "deserialize_expandable_id")]
403 pub subscription: Option<String>,
404 }
405
406 /// Stripe expandable fields are either a bare id string or a full object with
407 /// an `id` field. Pluck the id either way.
408 fn deserialize_expandable_id<'de, D>(
409 deserializer: D,
410 ) -> std::result::Result<Option<String>, D::Error>
411 where
412 D: serde::Deserializer<'de>,
413 {
414 use serde::Deserialize;
415 let v = serde_json::Value::deserialize(deserializer)?;
416 Ok(match v {
417 serde_json::Value::Null => None,
418 serde_json::Value::String(s) => Some(s),
419 serde_json::Value::Object(mut map) => match map.remove("id") {
420 Some(serde_json::Value::String(s)) => Some(s),
421 _ => None,
422 },
423 _ => None,
424 })
425 }
426
427 /// Account update fields the dispatcher hands to the handler.
428 #[derive(Debug)]
429 pub struct AccountUpdate {
430 pub account_id: String,
431 pub charges_enabled: bool,
432 pub payouts_enabled: bool,
433 pub details_submitted: bool,
434 /// The account's settlement currency, when Stripe reports one we support.
435 ///
436 /// `None` covers two different situations and the handler treats them the
437 /// same way, by leaving the stored currency alone: Stripe reported nothing
438 /// (an account too early in onboarding to have a default currency), or it
439 /// reported a currency outside our six. Neither is a reason to fail a
440 /// webhook, and neither is a reason to silently rewrite a creator's prices
441 /// into USD.
442 pub settlement_currency: Option<crate::currency::SettlementCurrency>,
443 }
444
445 /// Read `default_currency` off a Stripe account, keeping only what we support.
446 ///
447 /// Logs the unsupported case: it is the signal that a creator has connected an
448 /// account MNW cannot denominate prices in, and it is invisible otherwise.
449 fn settlement_currency_of(
450 account_id: &str,
451 default_currency: Option<&str>,
452 ) -> Option<crate::currency::SettlementCurrency> {
453 let code = default_currency?;
454 let parsed = crate::currency::SettlementCurrency::from_code(code);
455 if parsed.is_none() {
456 tracing::warn!(
457 %account_id,
458 default_currency = %code,
459 "Stripe account settles in an unsupported currency; leaving the stored one unchanged"
460 );
461 }
462 parsed
463 }
464
465 impl From<stripe_shared::Account> for AccountUpdate {
466 fn from(a: stripe_shared::Account) -> Self {
467 let account_id = a.id.to_string();
468 AccountUpdate {
469 charges_enabled: a.charges_enabled.unwrap_or(false),
470 payouts_enabled: a.payouts_enabled.unwrap_or(false),
471 details_submitted: a.details_submitted.unwrap_or(false),
472 settlement_currency: settlement_currency_of(
473 &account_id,
474 a.default_currency.map(|c| c.to_string()).as_deref(),
475 ),
476 account_id,
477 }
478 }
479 }
480
481 /// Narrow view of an Account: only the fields we react to.
482 #[derive(Debug, serde::Deserialize)]
483 pub(in crate::payments) struct AccountView {
484 pub id: String,
485 #[serde(default)]
486 pub charges_enabled: bool,
487 #[serde(default)]
488 pub payouts_enabled: bool,
489 #[serde(default)]
490 pub details_submitted: bool,
491 /// Absent on accounts too early in onboarding to have one.
492 #[serde(default)]
493 pub default_currency: Option<String>,
494 }
495
496 impl From<AccountView> for AccountUpdate {
497 fn from(a: AccountView) -> Self {
498 AccountUpdate {
499 charges_enabled: a.charges_enabled,
500 payouts_enabled: a.payouts_enabled,
501 details_submitted: a.details_submitted,
502 settlement_currency: settlement_currency_of(&a.id, a.default_currency.as_deref()),
503 account_id: a.id,
504 }
505 }
506 }
507
508 /// What the buyer was presented with, when it differed from the sale currency.
509 #[derive(Debug, serde::Deserialize)]
510 pub(in crate::payments) struct PresentmentDetailsView {
511 #[serde(default)]
512 pub presentment_amount: Option<i64>,
513 #[serde(default)]
514 pub presentment_currency: Option<String>,
515 }
516
517 /// Narrow view of a Charge for refund processing.
518 #[derive(Debug, serde::Deserialize)]
519 pub(in crate::payments) struct ChargeView {
520 #[serde(default)]
521 pub amount: i64,
522 #[serde(default)]
523 pub amount_refunded: i64,
524 #[serde(default, deserialize_with = "deserialize_expandable_id")]
525 pub payment_intent: Option<String>,
526 }
527
528 /// Data extracted from a charge.refunded webhook event.
529 #[derive(Debug)]
530 pub struct ChargeRefundData {
531 pub payment_intent_id: String,
532 pub amount: Cents,
533 pub amount_refunded: Cents,
534 }
535
536 impl ChargeRefundData {
537 pub fn is_full_refund(&self) -> bool {
538 // Require `amount > 0` so $0 verification charges (which Stripe occasionally
539 // emits with `amount=0, amount_refunded=0`) are not treated as full refunds,
540 // that previously triggered `refund_transaction_by_payment_intent` with a
541 // default `unknown` intent ID.
542 self.amount > Cents::new(0) && self.amount_refunded >= self.amount
543 }
544
545 /// Build from a parsed charge view. Returns None when there is no
546 /// payment_intent; these events are out of scope here.
547 pub(in crate::payments) fn from_view(charge: ChargeView) -> Option<Self> {
548 Some(ChargeRefundData {
549 payment_intent_id: charge.payment_intent?,
550 amount: Cents::new(charge.amount),
551 amount_refunded: Cents::new(charge.amount_refunded),
552 })
553 }
554 }
555
556 /// Narrow view of a Refund object (`refund.created` / `refund.updated` events).
557 ///
558 /// The line-scoped self-service refund tags the Stripe refund with
559 /// `metadata.mnw_transaction_id`; the webhook reads it back so a cart line refund
560 /// marks/revokes exactly its own transaction rather than the whole order.
561 #[derive(Debug, serde::Deserialize)]
562 pub(in crate::payments) struct RefundView {
563 #[serde(default)]
564 pub amount: i64,
565 pub status: Option<String>,
566 #[serde(default, deserialize_with = "deserialize_expandable_id")]
567 pub payment_intent: Option<String>,
568 #[serde(default)]
569 pub metadata: Option<std::collections::HashMap<String, String>>,
570 }
571
572 impl RefundView {
573 /// The MNW transaction id this refund was tagged with at creation, if any.
574 /// Absent for out-of-band refunds (e.g. issued from the Stripe dashboard).
575 pub(in crate::payments) fn mnw_transaction_id(&self) -> Option<&str> {
576 self.metadata
577 .as_ref()?
578 .get("mnw_transaction_id")
579 .map(String::as_str)
580 }
581
582 /// Stripe marks a completed refund `succeeded`; only then is the money back.
583 pub(in crate::payments) fn is_succeeded(&self) -> bool {
584 self.status.as_deref() == Some("succeeded")
585 }
586 }
587
588 // v2 thin event types
589
590 /// A Stripe v2 "thin" event: contains only the event type and a reference to
591 /// the related object, not the full object snapshot.
592 #[derive(Debug, serde::Deserialize)]
593 pub struct ThinEvent {
594 pub id: String,
595 #[serde(rename = "type")]
596 pub event_type: String,
597 pub related_object: Option<RelatedObject>,
598 }
599
600 /// Reference to the object that triggered a v2 event.
601 #[derive(Debug, serde::Deserialize)]
602 pub struct RelatedObject {
603 pub id: String,
604 #[serde(rename = "type")]
605 pub object_type: String,
606 }
607
608 /// Reject a webhook timestamp further than `tolerance` seconds from now, in
609 /// either direction, naming which direction it was.
610 ///
611 /// Split out of [`verify_signature`] because it is the only part of the replay
612 /// guard that is a decision rather than a clock read, and a test that has to
613 /// call `SystemTime::now()` to reach the boundary cannot sit exactly on it.
614 /// `saturating_sub` rather than a guarded subtraction: an ordering test around
615 /// a subtraction that already cannot underflow has no observable effect, so it
616 /// is a branch no test could ever justify.
617 fn check_timestamp_skew(
618 ts_secs: u64,
619 now_secs: u64,
620 tolerance: u64,
621 ) -> std::result::Result<(), String> {
622 if now_secs.saturating_sub(ts_secs) > tolerance {
623 return Err("timestamp too old".to_string());
624 }
625 if ts_secs.saturating_sub(now_secs) > tolerance {
626 return Err("timestamp too far in the future".to_string());
627 }
628 Ok(())
629 }
630
631 /// Verify a Stripe webhook signature (v1 scheme, shared by v1 and v2 endpoints).
632 ///
633 /// Parses `t={ts},v1={hex}`, computes HMAC-SHA256 over `{ts}.{payload}`, and
634 /// compares in constant time. Rejects timestamps outside the configured
635 /// tolerance to prevent replay attacks.
636 pub fn verify_signature(
637 payload: &str,
638 header: &str,
639 secret: &str,
640 ) -> std::result::Result<(), String> {
641 let mut timestamp = None;
642 // Stripe emits a `v1=` value per active secret during rotation; collect
643 // them all and accept if any matches. The previous single-Option only
644 // kept the last value parsed, which silently broke rotation.
645 let mut signatures: Vec<&str> = Vec::new();
646 for part in header.split(',') {
647 if let Some(t) = part.strip_prefix("t=") {
648 timestamp = Some(t);
649 } else if let Some(s) = part.strip_prefix("v1=") {
650 signatures.push(s);
651 }
652 }
653
654 let timestamp = timestamp.ok_or("missing timestamp in signature header")?;
655 if signatures.is_empty() {
656 return Err("missing v1 signature in header".to_string());
657 }
658
659 let ts_secs: u64 = timestamp.parse().map_err(|_| "invalid timestamp")?;
660 let now_secs = std::time::SystemTime::now()
661 .duration_since(std::time::UNIX_EPOCH)
662 .map_err(|_| "system clock error")?
663 .as_secs();
664 check_timestamp_skew(
665 ts_secs,
666 now_secs,
667 crate::constants::WEBHOOK_TIMESTAMP_TOLERANCE_SECS,
668 )?;
669
670 let signed_payload = format!("{timestamp}.{payload}");
671 let mut last_err = "signature mismatch".to_string();
672
673 for expected_sig in &signatures {
674 let Ok(expected_bytes) = hex::decode(expected_sig) else {
675 last_err = "invalid hex in v1 signature".to_string();
676 continue;
677 };
678 let mut mac =
679 HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| "invalid HMAC key")?;
680 mac.update(signed_payload.as_bytes());
681 if mac.verify_slice(&expected_bytes).is_ok() {
682 return Ok(());
683 }
684 }
685
686 Err(last_err)
687 }
688
689 #[cfg(test)]
690 mod tests {
691 use super::*;
692 use crate::payments::CheckoutKind;
693 use serde_json::json;
694
695 #[test]
696 fn parse_envelope_extracts_id_type_and_object() {
697 let payload =
698 r#"{"id":"evt_1","type":"checkout.session.completed","data":{"object":{"id":"cs_1"}}}"#;
699 let evt = UntypedEvent::from_payload(payload).unwrap();
700 assert_eq!(evt.id, "evt_1");
701 assert_eq!(evt.type_, "checkout.session.completed");
702 assert_eq!(evt.data_object["id"], "cs_1");
703 }
704
705 #[test]
706 fn parse_envelope_missing_data_object_errors() {
707 assert!(UntypedEvent::from_payload(r#"{"id":"x","type":"y"}"#).is_err());
708 }
709
710 #[test]
711 fn parse_envelope_error_messages_name_the_field() {
712 // Each failure mode should produce a body distinct enough that a future
713 // debugger reading Stripe Dashboard or our error logs knows exactly
714 // what was wrong, rather than a generic "Invalid webhook signature".
715 let missing_id =
716 UntypedEvent::from_payload(r#"{"type":"t","data":{"object":{}}}"#).unwrap_err();
717 assert!(
718 format!("{missing_id:?}").contains("id"),
719 "got: {missing_id:?}"
720 );
721
722 let missing_type =
723 UntypedEvent::from_payload(r#"{"id":"i","data":{"object":{}}}"#).unwrap_err();
724 assert!(
725 format!("{missing_type:?}").contains("type"),
726 "got: {missing_type:?}"
727 );
728
729 let missing_obj = UntypedEvent::from_payload(r#"{"id":"i","type":"t"}"#).unwrap_err();
730 assert!(
731 format!("{missing_obj:?}").contains("data.object"),
732 "got: {missing_obj:?}"
733 );
734
735 let bad_json = UntypedEvent::from_payload(r"not json").unwrap_err();
736 assert!(
737 format!("{bad_json:?}").contains("parse failed"),
738 "got: {bad_json:?}"
739 );
740 }
741
742 // CheckoutSession parses from a real captured webhook fixture.
743 #[test]
744 fn checkout_session_parses_from_fixture() {
745 let raw =
746 include_str!("../../tests/fixtures/webhooks/checkout.session.completed.connect.json");
747 let evt = UntypedEvent::from_payload(raw).unwrap();
748 let session: stripe_shared::CheckoutSession =
749 serde_json::from_value(evt.data_object).unwrap();
750 assert_eq!(session.mode, stripe_shared::CheckoutSessionMode::Payment);
751 }
752
753 // --- CheckoutSessionView payment settlement gate ---
754
755 fn view_with_status(status: Option<&str>) -> CheckoutSessionView {
756 CheckoutSessionView {
757 payment_status: status.map(str::to_string),
758 ..Default::default()
759 }
760 }
761
762 #[test]
763 fn payment_settled_true_for_paid_and_no_payment_required() {
764 assert!(view_with_status(Some("paid")).payment_settled());
765 assert!(view_with_status(Some("no_payment_required")).payment_settled());
766 }
767
768 #[test]
769 fn payment_settled_false_only_for_explicit_unpaid() {
770 // The async-method case: `checkout.session.completed` arrives with
771 // "unpaid" and goods must NOT be delivered until settlement.
772 assert!(!view_with_status(Some("unpaid")).payment_settled());
773 }
774
775 #[test]
776 fn payment_settled_true_when_absent_preserves_legacy_behaviour() {
777 // Older/edge events without the field must still finalize (synchronous
778 // card sessions predating the field, and any event Stripe omits it on).
779 assert!(view_with_status(None).payment_settled());
780 assert!(!view_with_status(Some("something_new")).payment_settled());
781 }
782
783 #[test]
784 fn payment_status_and_currency_deserialize_from_session_json() {
785 let session: CheckoutSessionView = serde_json::from_value(json!({
786 "id": "cs_1",
787 "payment_status": "unpaid",
788 "currency": "usd",
789 }))
790 .unwrap();
791 assert_eq!(session.payment_status.as_deref(), Some("unpaid"));
792 assert_eq!(session.currency.as_deref(), Some("usd"));
793 assert!(!session.payment_settled());
794
795 // Absent fields default to None (settled).
796 let bare: CheckoutSessionView = serde_json::from_value(json!({"id": "cs_2"})).unwrap();
797 assert!(bare.payment_status.is_none());
798 assert!(bare.currency.is_none());
799 assert!(bare.payment_settled());
800 }
801
802 // Subscription parses with current_period_* on items.data[0].
803 #[test]
804 fn subscription_parses_from_fixture_with_items_period() {
805 let raw = include_str!("../../tests/fixtures/webhooks/customer.subscription.updated.json");
806 let evt = UntypedEvent::from_payload(raw).unwrap();
807 let sub: stripe_shared::Subscription = serde_json::from_value(evt.data_object).unwrap();
808 let item = sub
809 .items
810 .data
811 .first()
812 .expect("subscription has at least one item");
813 assert!(item.current_period_start > 0);
814 assert!(item.current_period_end > item.current_period_start);
815 }
816
817 // Invoice carries the new parent.subscription_details shape.
818 #[test]
819 fn invoice_parses_from_fixture() {
820 let raw = include_str!("../../tests/fixtures/webhooks/invoice.payment_succeeded.json");
821 let evt = UntypedEvent::from_payload(raw).unwrap();
822 let inv: stripe_shared::Invoice = serde_json::from_value(evt.data_object).unwrap();
823 assert!(inv.period_start > 0);
824 }
825
826 #[test]
827 fn account_update_conversion() {
828 let a: stripe_shared::Account = serde_json::from_value(json!({
829 "id": "acct_test123",
830 "object": "account",
831 "charges_enabled": true,
832 "payouts_enabled": true,
833 "details_submitted": true,
834 }))
835 .unwrap();
836 let u: AccountUpdate = a.into();
837 assert_eq!(u.account_id, "acct_test123");
838 assert!(u.charges_enabled);
839 assert!(u.payouts_enabled);
840 assert!(u.details_submitted);
841 }
842
843 #[test]
844 fn account_update_defaults_to_false_when_missing() {
845 let a: stripe_shared::Account = serde_json::from_value(json!({
846 "id": "acct_x",
847 "object": "account",
848 }))
849 .unwrap();
850 let u: AccountUpdate = a.into();
851 assert!(!u.charges_enabled);
852 assert!(!u.payouts_enabled);
853 assert!(!u.details_submitted);
854 }
855
856 // ChargeRefundData::from_charge JSON-roundtrip is covered by integration
857 // tests against real `charge.refunded` payloads, rc.5's `Charge` struct
858 // has ~30 non-Optional fields which makes hand-constructing a minimal one
859 // brittle. is_full_refund_* tests below pin the predicate semantics.
860
861 #[test]
862 fn is_full_refund_boundary() {
863 let exactly = ChargeRefundData {
864 payment_intent_id: "pi_a".to_string(),
865 amount: Cents::new(1000),
866 amount_refunded: Cents::new(1000),
867 };
868 assert!(exactly.is_full_refund());
869 let one_under = ChargeRefundData {
870 payment_intent_id: "pi_b".to_string(),
871 amount: Cents::new(1000),
872 amount_refunded: Cents::new(999),
873 };
874 assert!(!one_under.is_full_refund());
875 }
876
877 #[test]
878 fn is_full_refund_over_refunded_still_full() {
879 let over = ChargeRefundData {
880 payment_intent_id: "pi_c".to_string(),
881 amount: Cents::new(1000),
882 amount_refunded: Cents::new(1500),
883 };
884 assert!(over.is_full_refund());
885 }
886
887 #[test]
888 fn is_full_refund_zero_amount_is_not_full() {
889 // Stripe sometimes emits `charge.refunded` events with amount=0 for $0
890 // verification charges. Treating those as full refunds previously
891 // triggered `refund_transaction_by_payment_intent("unknown")`.
892 let zero = ChargeRefundData {
893 payment_intent_id: "pi_d".to_string(),
894 amount: Cents::new(0),
895 amount_refunded: Cents::new(0),
896 };
897 assert!(!zero.is_full_refund());
898 }
899
900 // --- verify_signature ---
901
902 fn sign_at(payload: &str, secret: &str, timestamp: u64) -> String {
903 use hmac::Mac;
904 let signed_payload = format!("{timestamp}.{payload}");
905 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
906 mac.update(signed_payload.as_bytes());
907 let hex_sig = hex::encode(mac.finalize().into_bytes());
908 format!("t={timestamp},v1={hex_sig}")
909 }
910
911 fn now_secs() -> u64 {
912 std::time::SystemTime::now()
913 .duration_since(std::time::UNIX_EPOCH)
914 .unwrap()
915 .as_secs()
916 }
917
918 #[test]
919 fn signature_matches_the_reference_hmac() {
920 // Stripe is the counterparty and its HMAC is fixed, so these bytes are
921 // an external contract no round-trip test can check, signing and
922 // verifying with the same crate agrees with itself even if the crate
923 // changed. Pinned against an independent HMAC-SHA256 over Stripe's
924 // documented signed payload, "{timestamp}.{body}".
925 assert_eq!(
926 sign_at(r#"{"id":"evt_1"}"#, "whsec_test", 1_700_000_000),
927 "t=1700000000,v1=c89214b5b5da833daed6f0b8c5bb6bd58cea9022bd80ccc78230f3942d632925"
928 );
929 }
930
931 #[test]
932 fn verify_signature_valid_current() {
933 let header = sign_at(r#"{"id":"evt_1"}"#, "whsec_test", now_secs());
934 assert!(verify_signature(r#"{"id":"evt_1"}"#, &header, "whsec_test").is_ok());
935 }
936
937 #[test]
938 fn verify_signature_rejected_stale_timestamp() {
939 let header = sign_at(r#"{"id":"evt_3"}"#, "whsec_test", now_secs() - 600);
940 let err = verify_signature(r#"{"id":"evt_3"}"#, &header, "whsec_test").unwrap_err();
941 assert!(err.contains("timestamp too old"), "got: {err}");
942 }
943
944 #[test]
945 fn verify_signature_rejected_future_timestamp() {
946 let header = sign_at(r#"{"id":"evt_4"}"#, "whsec_test", now_secs() + 600);
947 let err = verify_signature(r#"{"id":"evt_4"}"#, &header, "whsec_test").unwrap_err();
948 assert!(err.contains("future"), "got: {err}");
949 }
950
951 #[test]
952 fn verify_signature_accepted_within_tolerance() {
953 let header = sign_at(r#"{"id":"evt_5"}"#, "whsec_test", now_secs() - 240);
954 assert!(verify_signature(r#"{"id":"evt_5"}"#, &header, "whsec_test").is_ok());
955 }
956
957 #[test]
958 fn verify_signature_wrong_secret() {
959 let header = sign_at(r#"{"id":"evt_6"}"#, "whsec_test", now_secs());
960 let err = verify_signature(r#"{"id":"evt_6"}"#, &header, "wrong").unwrap_err();
961 assert!(err.contains("mismatch"), "got: {err}");
962 }
963
964 // --- check_timestamp_skew ---
965 //
966 // The tests above sign against the real clock, so they can only land near
967 // the tolerance edge, never on it. Every mutant of the two comparisons
968 // survived Phase 0 for that reason. These sit on the boundary exactly.
969
970 const TOL: u64 = 300;
971 const NOW: u64 = 1_700_000_000;
972
973 #[test]
974 fn skew_accepts_exactly_at_tolerance_in_both_directions() {
975 assert!(check_timestamp_skew(NOW - TOL, NOW, TOL).is_ok());
976 assert!(check_timestamp_skew(NOW + TOL, NOW, TOL).is_ok());
977 assert!(check_timestamp_skew(NOW, NOW, TOL).is_ok());
978 }
979
980 #[test]
981 fn skew_rejects_one_second_past_tolerance_in_both_directions() {
982 let old = check_timestamp_skew(NOW - TOL - 1, NOW, TOL).unwrap_err();
983 assert!(old.contains("too old"), "got: {old}");
984 let future = check_timestamp_skew(NOW + TOL + 1, NOW, TOL).unwrap_err();
985 assert!(future.contains("future"), "got: {future}");
986 }
987
988 #[test]
989 fn skew_reads_the_two_directions_separately() {
990 // A timestamp ahead of now is not stale, and one behind is not from the
991 // future: the guard that mixes the two operands passes this only by
992 // accident of small numbers, so keep the values epoch-sized.
993 assert!(check_timestamp_skew(NOW + 60, NOW, TOL).is_ok());
994 assert!(check_timestamp_skew(NOW - 60, NOW, TOL).is_ok());
995 }
996
997 // --- narrow view accessors ---
998 //
999 // Parsed from JSON rather than hand-built: these types exist to read
1000 // Stripe's payload shapes, so the shape is half of what is under test.
1001
1002 fn subscription(json: serde_json::Value) -> SubscriptionView {
1003 serde_json::from_value(json).expect("subscription view parses")
1004 }
1005
1006 fn invoice(json: serde_json::Value) -> InvoiceView {
1007 serde_json::from_value(json).expect("invoice view parses")
1008 }
1009
1010 fn refund(json: serde_json::Value) -> RefundView {
1011 serde_json::from_value(json).expect("refund view parses")
1012 }
1013
1014 #[test]
1015 fn current_period_reads_the_first_item() {
1016 let sub = subscription(json!({
1017 "id": "sub_1",
1018 "status": "active",
1019 "items": {"data": [
1020 {"current_period_start": 1_700_000_000i64, "current_period_end": 1_702_592_000i64},
1021 {"current_period_start": 1i64, "current_period_end": 2i64},
1022 ]},
1023 }));
1024 assert_eq!(
1025 sub.current_period(),
1026 Some((1_700_000_000, 1_702_592_000)),
1027 "the period comes from items.data[0], not from a later item"
1028 );
1029 }
1030
1031 #[test]
1032 fn current_period_is_none_without_items() {
1033 let sub = subscription(json!({"id": "sub_2", "status": "active"}));
1034 assert_eq!(sub.current_period(), None);
1035 }
1036
1037 #[test]
1038 fn subscription_id_prefers_the_legacy_field() {
1039 let inv = invoice(json!({
1040 "subscription": "sub_legacy",
1041 "parent": {"subscription_details": {"subscription": "sub_new"}},
1042 }));
1043 assert_eq!(inv.subscription_id(), Some("sub_legacy"));
1044 }
1045
1046 #[test]
1047 fn subscription_id_falls_back_to_the_parent_path() {
1048 let inv = invoice(json!({
1049 "parent": {"subscription_details": {"subscription": "sub_new"}},
1050 }));
1051 assert_eq!(inv.subscription_id(), Some("sub_new"));
1052 }
1053
1054 #[test]
1055 fn subscription_id_is_none_when_neither_path_carries_one() {
1056 assert_eq!(invoice(json!({})).subscription_id(), None);
1057 assert_eq!(invoice(json!({"parent": {}})).subscription_id(), None);
1058 assert_eq!(
1059 invoice(json!({"parent": {"subscription_details": {}}})).subscription_id(),
1060 None
1061 );
1062 }
1063
1064 #[test]
1065 fn is_renewal_only_for_subscription_cycle() {
1066 assert!(invoice(json!({"billing_reason": "subscription_cycle"})).is_renewal());
1067 assert!(!invoice(json!({"billing_reason": "subscription_create"})).is_renewal());
1068 assert!(!invoice(json!({})).is_renewal());
1069 }
1070
1071 #[test]
1072 fn expandable_id_reads_a_bare_string_or_an_object() {
1073 assert_eq!(
1074 invoice(json!({"subscription": "sub_bare"})).subscription,
1075 Some("sub_bare".to_string()),
1076 "the bare-id form"
1077 );
1078 assert_eq!(
1079 invoice(json!({"subscription": {"id": "sub_expanded", "object": "subscription"}}))
1080 .subscription,
1081 Some("sub_expanded".to_string()),
1082 "the expanded-object form"
1083 );
1084 }
1085
1086 #[test]
1087 fn expandable_id_is_none_for_null_or_an_object_without_a_string_id() {
1088 assert_eq!(invoice(json!({"subscription": null})).subscription, None);
1089 assert_eq!(invoice(json!({"subscription": {}})).subscription, None);
1090 assert_eq!(
1091 invoice(json!({"subscription": {"id": 7}})).subscription,
1092 None,
1093 "a numeric id is not an id we can use"
1094 );
1095 assert_eq!(invoice(json!({"subscription": 7})).subscription, None);
1096 }
1097
1098 #[test]
1099 fn refund_transaction_id_comes_from_metadata() {
1100 let tagged = refund(json!({
1101 "status": "succeeded",
1102 "metadata": {"mnw_transaction_id": "txn_9"},
1103 }));
1104 assert_eq!(tagged.mnw_transaction_id(), Some("txn_9"));
1105
1106 let other_metadata = refund(json!({"metadata": {"something_else": "x"}}));
1107 assert_eq!(other_metadata.mnw_transaction_id(), None);
1108 assert_eq!(refund(json!({})).mnw_transaction_id(), None);
1109 }
1110
1111 #[test]
1112 fn refund_is_succeeded_only_for_succeeded() {
1113 assert!(refund(json!({"status": "succeeded"})).is_succeeded());
1114 assert!(!refund(json!({"status": "pending"})).is_succeeded());
1115 assert!(!refund(json!({"status": "failed"})).is_succeeded());
1116 assert!(!refund(json!({})).is_succeeded());
1117 }
1118
1119 #[test]
1120 fn charge_refund_data_needs_a_payment_intent() {
1121 let with_pi: ChargeView = serde_json::from_value(json!({
1122 "amount": 1000,
1123 "amount_refunded": 1000,
1124 "payment_intent": "pi_1",
1125 }))
1126 .unwrap();
1127 let data = ChargeRefundData::from_view(with_pi).expect("a charge with an intent converts");
1128 assert_eq!(data.payment_intent_id, "pi_1");
1129 assert_eq!(data.amount, Cents::new(1000));
1130 assert_eq!(data.amount_refunded, Cents::new(1000));
1131
1132 let without_pi: ChargeView =
1133 serde_json::from_value(json!({"amount": 1000, "amount_refunded": 0})).unwrap();
1134 assert!(ChargeRefundData::from_view(without_pi).is_none());
1135 }
1136
1137 #[test]
1138 fn settlement_currency_keeps_only_supported_codes() {
1139 assert_eq!(
1140 settlement_currency_of("acct_1", Some("usd")),
1141 Some(crate::currency::SettlementCurrency::Usd)
1142 );
1143 assert_eq!(
1144 settlement_currency_of("acct_2", Some("xyz")),
1145 None,
1146 "an unsupported currency leaves the stored one alone"
1147 );
1148 assert_eq!(settlement_currency_of("acct_3", None), None);
1149 }
1150
1151 // ── Normalization ──
1152
1153 fn normalize(type_: &str, object: serde_json::Value) -> MnwEvent {
1154 normalize_event(type_, object).expect("payload should normalize")
1155 }
1156
1157 #[test]
1158 fn a_checkout_kind_comes_from_the_metadata_mnw_wrote() {
1159 let event = normalize(
1160 "checkout.session.completed",
1161 serde_json::json!({
1162 "id": "cs_1",
1163 "metadata": {"checkout_type": "tip"},
1164 "payment_status": "paid",
1165 }),
1166 );
1167 let MnwEvent::Checkout { kind, session } = event else {
1168 panic!("expected a checkout");
1169 };
1170 assert_eq!(kind, CheckoutKind::Tip);
1171 assert_eq!(session.session_id, "cs_1");
1172 assert!(session.settled);
1173 }
1174
1175 #[test]
1176 fn a_session_with_no_checkout_type_is_a_purchase() {
1177 // The dispatcher's final `else` for as long as it has existed: a single
1178 // item purchase is the shape with no distinguishing metadata.
1179 let event = normalize(
1180 "checkout.session.completed",
1181 serde_json::json!({"id": "cs_1", "metadata": {}}),
1182 );
1183 let MnwEvent::Checkout { kind, .. } = event else {
1184 panic!("expected a checkout");
1185 };
1186 assert_eq!(kind, CheckoutKind::Purchase);
1187 }
1188
1189 #[test]
1190 fn an_unpaid_session_is_not_settled_and_an_absent_status_is() {
1191 // The absent case preserves behaviour for legacy events that predate
1192 // `payment_status`; only an explicit "unpaid" is withheld.
1193 let unpaid = normalize(
1194 "checkout.session.completed",
1195 serde_json::json!({"id": "cs_1", "payment_status": "unpaid"}),
1196 );
1197 let MnwEvent::Checkout { session, .. } = unpaid else {
1198 panic!("expected a checkout")
1199 };
1200 assert!(!session.settled);
1201
1202 let legacy = normalize(
1203 "checkout.session.completed",
1204 serde_json::json!({"id": "cs_2"}),
1205 );
1206 let MnwEvent::Checkout { session, .. } = legacy else {
1207 panic!("expected a checkout")
1208 };
1209 assert!(session.settled);
1210 }
1211
1212 #[test]
1213 fn async_payment_succeeded_normalizes_to_the_same_checkout_as_completed() {
1214 // A handler cares about `settled`, not which of the two arrived.
1215 for type_ in [
1216 "checkout.session.completed",
1217 "checkout.session.async_payment_succeeded",
1218 ] {
1219 let event = normalize(
1220 type_,
1221 serde_json::json!({
1222 "id": "cs_1",
1223 "metadata": {"checkout_type": "cart"},
1224 "payment_status": "paid",
1225 }),
1226 );
1227 assert!(
1228 matches!(
1229 event,
1230 MnwEvent::Checkout {
1231 kind: CheckoutKind::Cart,
1232 ..
1233 }
1234 ),
1235 "{type_} should be a settled cart checkout"
1236 );
1237 }
1238 }
1239
1240 #[test]
1241 fn an_invoice_resolves_its_subscription_from_either_field_path() {
1242 // rc.5 moved the subscription id under parent.subscription_details; a
1243 // handler should never have to know which shape arrived.
1244 let legacy = normalize(
1245 "invoice.payment_succeeded",
1246 serde_json::json!({"subscription": "sub_1", "billing_reason": "subscription_cycle"}),
1247 );
1248 let MnwEvent::InvoicePaymentSucceeded(invoice) = legacy else {
1249 panic!("expected an invoice")
1250 };
1251 assert_eq!(invoice.subscription_id.as_deref(), Some("sub_1"));
1252 assert!(invoice.is_renewal);
1253
1254 let rc5 = normalize(
1255 "invoice.payment_failed",
1256 serde_json::json!({
1257 "parent": {"subscription_details": {"subscription": "sub_2"}},
1258 "billing_reason": "subscription_create",
1259 }),
1260 );
1261 let MnwEvent::InvoicePaymentFailed(invoice) = rc5 else {
1262 panic!("expected an invoice")
1263 };
1264 assert_eq!(invoice.subscription_id.as_deref(), Some("sub_2"));
1265 assert!(!invoice.is_renewal);
1266 }
1267
1268 #[test]
1269 fn a_subscription_keeps_stripes_status_string_unparsed() {
1270 // Parsing here would have to choose between erroring on a status Stripe
1271 // added and inventing a member; both are worse than letting the handler
1272 // treat an unknown status as a no-op.
1273 let event = normalize(
1274 "customer.subscription.updated",
1275 serde_json::json!({
1276 "id": "sub_1",
1277 "status": "paused",
1278 "cancel_at_period_end": true,
1279 "items": {"data": [{"current_period_start": 1, "current_period_end": 2}]},
1280 }),
1281 );
1282 let MnwEvent::SubscriptionUpdated(sub) = event else {
1283 panic!("expected a subscription update")
1284 };
1285 assert_eq!(sub.status, "paused");
1286 assert!(sub.cancel_at_period_end);
1287 assert_eq!(sub.current_period, Some((1, 2)));
1288 }
1289
1290 #[test]
1291 fn a_charge_with_no_payment_intent_normalizes_to_nothing_to_do() {
1292 // Out of scope rather than an error: there is no payment to refund
1293 // against, which is what `ChargeRefundData::from_view` has always said.
1294 let event = normalize(
1295 "charge.refunded",
1296 serde_json::json!({"amount": 100, "amount_refunded": 100}),
1297 );
1298 assert!(matches!(event, MnwEvent::ChargeRefunded(None)));
1299 }
1300
1301 #[test]
1302 fn an_unhandled_type_is_a_member_not_a_fallthrough() {
1303 // The whole reason dispatch matches on an enum: a type MNW does not act
1304 // on is representable, so a misspelt arm cannot silently swallow one.
1305 let event = normalize("payment_intent.succeeded", serde_json::json!({}));
1306 let MnwEvent::Unhandled { stripe_type } = event else {
1307 panic!("expected an unhandled event")
1308 };
1309 assert_eq!(stripe_type, "payment_intent.succeeded");
1310 }
1311
1312 #[test]
1313 fn a_payload_that_will_not_parse_names_the_object() {
1314 // The Stripe Dashboard shows this body for a failed delivery, and a past
1315 // incident (an API version mismatch producing serde `missing field`
1316 // errors) was misread as a signature failure because the wording did not
1317 // distinguish them.
1318 let err = normalize_event(
1319 "customer.subscription.updated",
1320 serde_json::json!({"status": "active"}),
1321 )
1322 .unwrap_err();
1323 let msg = format!("{err:?}");
1324 assert!(msg.contains("Subscription"), "{msg}");
1325 }
1326 }
1327