Skip to main content

max / makenotwork

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