Skip to main content

max / makenotwork

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