Skip to main content

max / makenotwork

27.7 KB · 763 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 /// Whether Stripe has captured funds for this session: `"paid"`,
157 /// `"unpaid"`, or `"no_payment_required"`. Synchronous card payments report
158 /// `"paid"` on `checkout.session.completed`; asynchronous methods (ACH,
159 /// SEPA, Bacs) report `"unpaid"` there and settle later via
160 /// `checkout.session.async_payment_succeeded`. Absent on older/edge events,
161 /// hence `Option`, see `payment_settled`.
162 #[serde(default)]
163 pub payment_status: Option<String>,
164 /// ISO currency of the session (e.g. `"usd"`). Sessions are built
165 /// server-side as USD; a non-USD value makes the integer-cents subtotal
166 /// reconciliation meaningless and is itself an anomaly. Absent on
167 /// older/edge events, hence `Option`.
168 #[serde(default)]
169 pub currency: Option<String>,
170 }
171
172 impl CheckoutSessionView {
173 /// True when funds are captured (or none were required) and it is safe to
174 /// deliver goods. Treats an absent field as settled to preserve behaviour
175 /// for legacy/edge events that predate the field; only an explicit
176 /// `"unpaid"` (an async method awaiting settlement) is withheld.
177 pub fn payment_settled(&self) -> bool {
178 matches!(
179 self.payment_status.as_deref(),
180 None | Some("paid" | "no_payment_required")
181 )
182 }
183 }
184
185 #[derive(Debug, Default, serde::Deserialize)]
186 pub struct CheckoutCustomerDetailsView {
187 pub email: Option<String>,
188 }
189
190 /// Narrow view of a Subscription: id, status, cancellation flag, and the
191 /// item-level period fields rc.5 promoted from the top level.
192 #[derive(Debug, serde::Deserialize)]
193 pub struct SubscriptionView {
194 pub id: String,
195 pub status: String,
196 #[serde(default)]
197 pub cancel_at_period_end: bool,
198 #[serde(default)]
199 pub items: SubscriptionItemList,
200 }
201
202 impl SubscriptionView {
203 /// Period from `items.data[0]` (rc.5 moved these off the top-level Subscription).
204 pub fn current_period(&self) -> Option<(i64, i64)> {
205 self.items
206 .data
207 .first()
208 .map(|it| (it.current_period_start, it.current_period_end))
209 }
210 }
211
212 #[derive(Debug, Default, serde::Deserialize)]
213 pub struct SubscriptionItemList {
214 #[serde(default)]
215 pub data: Vec<SubscriptionItemView>,
216 }
217
218 #[derive(Debug, serde::Deserialize)]
219 pub struct SubscriptionItemView {
220 #[serde(default)]
221 pub current_period_start: i64,
222 #[serde(default)]
223 pub current_period_end: i64,
224 }
225
226 /// Narrow view of an Invoice: subscription id (via legacy `subscription` or
227 /// the rc.5 `parent.subscription_details.subscription` path), period bounds,
228 /// and billing reason.
229 #[derive(Debug, serde::Deserialize)]
230 pub struct InvoiceView {
231 #[serde(default)]
232 pub period_start: i64,
233 #[serde(default)]
234 pub period_end: i64,
235 #[serde(default)]
236 pub billing_reason: Option<String>,
237 #[serde(default, deserialize_with = "deserialize_expandable_id")]
238 pub subscription: Option<String>,
239 #[serde(default)]
240 pub parent: Option<InvoiceParentView>,
241 }
242
243 impl InvoiceView {
244 /// Pull the subscription id from either the legacy or new field path.
245 pub fn subscription_id(&self) -> Option<&str> {
246 if let Some(s) = &self.subscription {
247 return Some(s.as_str());
248 }
249 self.parent
250 .as_ref()?
251 .subscription_details
252 .as_ref()?
253 .subscription
254 .as_deref()
255 }
256
257 pub fn is_renewal(&self) -> bool {
258 self.billing_reason.as_deref() == Some("subscription_cycle")
259 }
260 }
261
262 #[derive(Debug, serde::Deserialize)]
263 pub struct InvoiceParentView {
264 #[serde(default)]
265 pub subscription_details: Option<InvoiceSubscriptionDetailsView>,
266 }
267
268 #[derive(Debug, serde::Deserialize)]
269 pub struct InvoiceSubscriptionDetailsView {
270 #[serde(default, deserialize_with = "deserialize_expandable_id")]
271 pub subscription: Option<String>,
272 }
273
274 /// Stripe expandable fields are either a bare id string or a full object with
275 /// an `id` field. Pluck the id either way.
276 fn deserialize_expandable_id<'de, D>(
277 deserializer: D,
278 ) -> std::result::Result<Option<String>, D::Error>
279 where
280 D: serde::Deserializer<'de>,
281 {
282 use serde::Deserialize;
283 let v = serde_json::Value::deserialize(deserializer)?;
284 Ok(match v {
285 serde_json::Value::Null => None,
286 serde_json::Value::String(s) => Some(s),
287 serde_json::Value::Object(mut map) => match map.remove("id") {
288 Some(serde_json::Value::String(s)) => Some(s),
289 _ => None,
290 },
291 _ => None,
292 })
293 }
294
295 /// Account update fields the dispatcher hands to the handler.
296 #[derive(Debug)]
297 pub struct AccountUpdate {
298 pub account_id: String,
299 pub charges_enabled: bool,
300 pub payouts_enabled: bool,
301 pub details_submitted: bool,
302 }
303
304 impl From<stripe_shared::Account> for AccountUpdate {
305 fn from(a: stripe_shared::Account) -> Self {
306 AccountUpdate {
307 account_id: a.id.to_string(),
308 charges_enabled: a.charges_enabled.unwrap_or(false),
309 payouts_enabled: a.payouts_enabled.unwrap_or(false),
310 details_submitted: a.details_submitted.unwrap_or(false),
311 }
312 }
313 }
314
315 /// Narrow view of an Account: only the fields we react to.
316 #[derive(Debug, serde::Deserialize)]
317 pub struct AccountView {
318 pub id: String,
319 #[serde(default)]
320 pub charges_enabled: bool,
321 #[serde(default)]
322 pub payouts_enabled: bool,
323 #[serde(default)]
324 pub details_submitted: bool,
325 }
326
327 impl From<AccountView> for AccountUpdate {
328 fn from(a: AccountView) -> Self {
329 AccountUpdate {
330 account_id: a.id,
331 charges_enabled: a.charges_enabled,
332 payouts_enabled: a.payouts_enabled,
333 details_submitted: a.details_submitted,
334 }
335 }
336 }
337
338 /// Narrow view of a Charge for refund processing.
339 #[derive(Debug, serde::Deserialize)]
340 pub struct ChargeView {
341 #[serde(default)]
342 pub amount: i64,
343 #[serde(default)]
344 pub amount_refunded: i64,
345 #[serde(default, deserialize_with = "deserialize_expandable_id")]
346 pub payment_intent: Option<String>,
347 }
348
349 /// Data extracted from a charge.refunded webhook event.
350 #[derive(Debug)]
351 pub struct ChargeRefundData {
352 pub payment_intent_id: String,
353 pub amount: Cents,
354 pub amount_refunded: Cents,
355 }
356
357 impl ChargeRefundData {
358 pub fn is_full_refund(&self) -> bool {
359 // Require `amount > 0` so $0 verification charges (which Stripe occasionally
360 // emits with `amount=0, amount_refunded=0`) are not treated as full refunds,
361 // that previously triggered `refund_transaction_by_payment_intent` with a
362 // default `unknown` intent ID.
363 self.amount > Cents::new(0) && self.amount_refunded >= self.amount
364 }
365
366 /// Build from a parsed charge view. Returns None when there is no
367 /// payment_intent; these events are out of scope here.
368 pub fn from_view(charge: ChargeView) -> Option<Self> {
369 Some(ChargeRefundData {
370 payment_intent_id: charge.payment_intent?,
371 amount: Cents::new(charge.amount),
372 amount_refunded: Cents::new(charge.amount_refunded),
373 })
374 }
375 }
376
377 /// Narrow view of a Refund object (`refund.created` / `refund.updated` events).
378 ///
379 /// The line-scoped self-service refund tags the Stripe refund with
380 /// `metadata.mnw_transaction_id`; the webhook reads it back so a cart line refund
381 /// marks/revokes exactly its own transaction rather than the whole order.
382 #[derive(Debug, serde::Deserialize)]
383 pub struct RefundView {
384 #[serde(default)]
385 pub amount: i64,
386 pub status: Option<String>,
387 #[serde(default, deserialize_with = "deserialize_expandable_id")]
388 pub payment_intent: Option<String>,
389 #[serde(default)]
390 pub metadata: Option<std::collections::HashMap<String, String>>,
391 }
392
393 impl RefundView {
394 /// The MNW transaction id this refund was tagged with at creation, if any.
395 /// Absent for out-of-band refunds (e.g. issued from the Stripe dashboard).
396 pub fn mnw_transaction_id(&self) -> Option<&str> {
397 self.metadata
398 .as_ref()?
399 .get("mnw_transaction_id")
400 .map(String::as_str)
401 }
402
403 /// Stripe marks a completed refund `succeeded`; only then is the money back.
404 pub fn is_succeeded(&self) -> bool {
405 self.status.as_deref() == Some("succeeded")
406 }
407 }
408
409 // v2 thin event types
410
411 /// A Stripe v2 "thin" event: contains only the event type and a reference to
412 /// the related object, not the full object snapshot.
413 #[derive(Debug, serde::Deserialize)]
414 pub struct ThinEvent {
415 pub id: String,
416 #[serde(rename = "type")]
417 pub event_type: String,
418 pub related_object: Option<RelatedObject>,
419 }
420
421 /// Reference to the object that triggered a v2 event.
422 #[derive(Debug, serde::Deserialize)]
423 pub struct RelatedObject {
424 pub id: String,
425 #[serde(rename = "type")]
426 pub object_type: String,
427 }
428
429 /// Verify a Stripe webhook signature (v1 scheme, shared by v1 and v2 endpoints).
430 ///
431 /// Parses `t={ts},v1={hex}`, computes HMAC-SHA256 over `{ts}.{payload}`, and
432 /// compares in constant time. Rejects timestamps outside the configured
433 /// tolerance to prevent replay attacks.
434 pub fn verify_signature(
435 payload: &str,
436 header: &str,
437 secret: &str,
438 ) -> std::result::Result<(), String> {
439 let mut timestamp = None;
440 // Stripe emits a `v1=` value per active secret during rotation; collect
441 // them all and accept if any matches. The previous single-Option only
442 // kept the last value parsed, which silently broke rotation.
443 let mut signatures: Vec<&str> = Vec::new();
444 for part in header.split(',') {
445 if let Some(t) = part.strip_prefix("t=") {
446 timestamp = Some(t);
447 } else if let Some(s) = part.strip_prefix("v1=") {
448 signatures.push(s);
449 }
450 }
451
452 let timestamp = timestamp.ok_or("missing timestamp in signature header")?;
453 if signatures.is_empty() {
454 return Err("missing v1 signature in header".to_string());
455 }
456
457 let ts_secs: u64 = timestamp.parse().map_err(|_| "invalid timestamp")?;
458 let now_secs = std::time::SystemTime::now()
459 .duration_since(std::time::UNIX_EPOCH)
460 .map_err(|_| "system clock error")?
461 .as_secs();
462 let tolerance = crate::constants::WEBHOOK_TIMESTAMP_TOLERANCE_SECS;
463 if now_secs > ts_secs && now_secs - ts_secs > tolerance {
464 return Err("timestamp too old".to_string());
465 }
466 if ts_secs > now_secs && ts_secs - now_secs > tolerance {
467 return Err("timestamp too far in the future".to_string());
468 }
469
470 let signed_payload = format!("{timestamp}.{payload}");
471 let mut last_err = "signature mismatch".to_string();
472
473 for expected_sig in &signatures {
474 let Ok(expected_bytes) = hex::decode(expected_sig) else {
475 last_err = "invalid hex in v1 signature".to_string();
476 continue;
477 };
478 let mut mac =
479 HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| "invalid HMAC key")?;
480 mac.update(signed_payload.as_bytes());
481 if mac.verify_slice(&expected_bytes).is_ok() {
482 return Ok(());
483 }
484 }
485
486 Err(last_err)
487 }
488
489 #[cfg(test)]
490 mod tests {
491 use super::*;
492 use serde_json::json;
493
494 #[test]
495 fn parse_envelope_extracts_id_type_and_object() {
496 let payload =
497 r#"{"id":"evt_1","type":"checkout.session.completed","data":{"object":{"id":"cs_1"}}}"#;
498 let evt = UntypedEvent::from_payload(payload).unwrap();
499 assert_eq!(evt.id, "evt_1");
500 assert_eq!(evt.type_, "checkout.session.completed");
501 assert_eq!(evt.data_object["id"], "cs_1");
502 }
503
504 #[test]
505 fn parse_envelope_missing_data_object_errors() {
506 assert!(UntypedEvent::from_payload(r#"{"id":"x","type":"y"}"#).is_err());
507 }
508
509 #[test]
510 fn parse_envelope_error_messages_name_the_field() {
511 // Each failure mode should produce a body distinct enough that a future
512 // debugger reading Stripe Dashboard or our error logs knows exactly
513 // what was wrong, rather than a generic "Invalid webhook signature".
514 let missing_id =
515 UntypedEvent::from_payload(r#"{"type":"t","data":{"object":{}}}"#).unwrap_err();
516 assert!(
517 format!("{missing_id:?}").contains("id"),
518 "got: {missing_id:?}"
519 );
520
521 let missing_type =
522 UntypedEvent::from_payload(r#"{"id":"i","data":{"object":{}}}"#).unwrap_err();
523 assert!(
524 format!("{missing_type:?}").contains("type"),
525 "got: {missing_type:?}"
526 );
527
528 let missing_obj = UntypedEvent::from_payload(r#"{"id":"i","type":"t"}"#).unwrap_err();
529 assert!(
530 format!("{missing_obj:?}").contains("data.object"),
531 "got: {missing_obj:?}"
532 );
533
534 let bad_json = UntypedEvent::from_payload(r"not json").unwrap_err();
535 assert!(
536 format!("{bad_json:?}").contains("parse failed"),
537 "got: {bad_json:?}"
538 );
539 }
540
541 // CheckoutSession parses from a real captured webhook fixture.
542 #[test]
543 fn checkout_session_parses_from_fixture() {
544 let raw =
545 include_str!("../../tests/fixtures/webhooks/checkout.session.completed.connect.json");
546 let evt = UntypedEvent::from_payload(raw).unwrap();
547 let session: stripe_shared::CheckoutSession =
548 serde_json::from_value(evt.data_object).unwrap();
549 assert_eq!(session.mode, stripe_shared::CheckoutSessionMode::Payment);
550 }
551
552 // --- CheckoutSessionView payment settlement gate ---
553
554 fn view_with_status(status: Option<&str>) -> CheckoutSessionView {
555 CheckoutSessionView {
556 payment_status: status.map(str::to_string),
557 ..Default::default()
558 }
559 }
560
561 #[test]
562 fn payment_settled_true_for_paid_and_no_payment_required() {
563 assert!(view_with_status(Some("paid")).payment_settled());
564 assert!(view_with_status(Some("no_payment_required")).payment_settled());
565 }
566
567 #[test]
568 fn payment_settled_false_only_for_explicit_unpaid() {
569 // The async-method case: `checkout.session.completed` arrives with
570 // "unpaid" and goods must NOT be delivered until settlement.
571 assert!(!view_with_status(Some("unpaid")).payment_settled());
572 }
573
574 #[test]
575 fn payment_settled_true_when_absent_preserves_legacy_behaviour() {
576 // Older/edge events without the field must still finalize (synchronous
577 // card sessions predating the field, and any event Stripe omits it on).
578 assert!(view_with_status(None).payment_settled());
579 assert!(!view_with_status(Some("something_new")).payment_settled());
580 }
581
582 #[test]
583 fn payment_status_and_currency_deserialize_from_session_json() {
584 let session: CheckoutSessionView = serde_json::from_value(json!({
585 "id": "cs_1",
586 "payment_status": "unpaid",
587 "currency": "usd",
588 }))
589 .unwrap();
590 assert_eq!(session.payment_status.as_deref(), Some("unpaid"));
591 assert_eq!(session.currency.as_deref(), Some("usd"));
592 assert!(!session.payment_settled());
593
594 // Absent fields default to None (settled).
595 let bare: CheckoutSessionView = serde_json::from_value(json!({"id": "cs_2"})).unwrap();
596 assert!(bare.payment_status.is_none());
597 assert!(bare.currency.is_none());
598 assert!(bare.payment_settled());
599 }
600
601 // Subscription parses with current_period_* on items.data[0].
602 #[test]
603 fn subscription_parses_from_fixture_with_items_period() {
604 let raw = include_str!("../../tests/fixtures/webhooks/customer.subscription.updated.json");
605 let evt = UntypedEvent::from_payload(raw).unwrap();
606 let sub: stripe_shared::Subscription = serde_json::from_value(evt.data_object).unwrap();
607 let item = sub
608 .items
609 .data
610 .first()
611 .expect("subscription has at least one item");
612 assert!(item.current_period_start > 0);
613 assert!(item.current_period_end > item.current_period_start);
614 }
615
616 // Invoice carries the new parent.subscription_details shape.
617 #[test]
618 fn invoice_parses_from_fixture() {
619 let raw = include_str!("../../tests/fixtures/webhooks/invoice.payment_succeeded.json");
620 let evt = UntypedEvent::from_payload(raw).unwrap();
621 let inv: stripe_shared::Invoice = serde_json::from_value(evt.data_object).unwrap();
622 assert!(inv.period_start > 0);
623 }
624
625 #[test]
626 fn account_update_conversion() {
627 let a: stripe_shared::Account = serde_json::from_value(json!({
628 "id": "acct_test123",
629 "object": "account",
630 "charges_enabled": true,
631 "payouts_enabled": true,
632 "details_submitted": true,
633 }))
634 .unwrap();
635 let u: AccountUpdate = a.into();
636 assert_eq!(u.account_id, "acct_test123");
637 assert!(u.charges_enabled);
638 assert!(u.payouts_enabled);
639 assert!(u.details_submitted);
640 }
641
642 #[test]
643 fn account_update_defaults_to_false_when_missing() {
644 let a: stripe_shared::Account = serde_json::from_value(json!({
645 "id": "acct_x",
646 "object": "account",
647 }))
648 .unwrap();
649 let u: AccountUpdate = a.into();
650 assert!(!u.charges_enabled);
651 assert!(!u.payouts_enabled);
652 assert!(!u.details_submitted);
653 }
654
655 // ChargeRefundData::from_charge JSON-roundtrip is covered by integration
656 // tests against real `charge.refunded` payloads, rc.5's `Charge` struct
657 // has ~30 non-Optional fields which makes hand-constructing a minimal one
658 // brittle. is_full_refund_* tests below pin the predicate semantics.
659
660 #[test]
661 fn is_full_refund_boundary() {
662 let exactly = ChargeRefundData {
663 payment_intent_id: "pi_a".to_string(),
664 amount: Cents::new(1000),
665 amount_refunded: Cents::new(1000),
666 };
667 assert!(exactly.is_full_refund());
668 let one_under = ChargeRefundData {
669 payment_intent_id: "pi_b".to_string(),
670 amount: Cents::new(1000),
671 amount_refunded: Cents::new(999),
672 };
673 assert!(!one_under.is_full_refund());
674 }
675
676 #[test]
677 fn is_full_refund_over_refunded_still_full() {
678 let over = ChargeRefundData {
679 payment_intent_id: "pi_c".to_string(),
680 amount: Cents::new(1000),
681 amount_refunded: Cents::new(1500),
682 };
683 assert!(over.is_full_refund());
684 }
685
686 #[test]
687 fn is_full_refund_zero_amount_is_not_full() {
688 // Stripe sometimes emits `charge.refunded` events with amount=0 for $0
689 // verification charges. Treating those as full refunds previously
690 // triggered `refund_transaction_by_payment_intent("unknown")`.
691 let zero = ChargeRefundData {
692 payment_intent_id: "pi_d".to_string(),
693 amount: Cents::new(0),
694 amount_refunded: Cents::new(0),
695 };
696 assert!(!zero.is_full_refund());
697 }
698
699 // --- verify_signature ---
700
701 fn sign_at(payload: &str, secret: &str, timestamp: u64) -> String {
702 use hmac::Mac;
703 let signed_payload = format!("{timestamp}.{payload}");
704 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
705 mac.update(signed_payload.as_bytes());
706 let hex_sig = hex::encode(mac.finalize().into_bytes());
707 format!("t={timestamp},v1={hex_sig}")
708 }
709
710 fn now_secs() -> u64 {
711 std::time::SystemTime::now()
712 .duration_since(std::time::UNIX_EPOCH)
713 .unwrap()
714 .as_secs()
715 }
716
717 #[test]
718 fn signature_matches_the_reference_hmac() {
719 // Stripe is the counterparty and its HMAC is fixed, so these bytes are
720 // an external contract no round-trip test can check, signing and
721 // verifying with the same crate agrees with itself even if the crate
722 // changed. Pinned against an independent HMAC-SHA256 over Stripe's
723 // documented signed payload, "{timestamp}.{body}".
724 assert_eq!(
725 sign_at(r#"{"id":"evt_1"}"#, "whsec_test", 1_700_000_000),
726 "t=1700000000,v1=c89214b5b5da833daed6f0b8c5bb6bd58cea9022bd80ccc78230f3942d632925"
727 );
728 }
729
730 #[test]
731 fn verify_signature_valid_current() {
732 let header = sign_at(r#"{"id":"evt_1"}"#, "whsec_test", now_secs());
733 assert!(verify_signature(r#"{"id":"evt_1"}"#, &header, "whsec_test").is_ok());
734 }
735
736 #[test]
737 fn verify_signature_rejected_stale_timestamp() {
738 let header = sign_at(r#"{"id":"evt_3"}"#, "whsec_test", now_secs() - 600);
739 let err = verify_signature(r#"{"id":"evt_3"}"#, &header, "whsec_test").unwrap_err();
740 assert!(err.contains("timestamp too old"), "got: {err}");
741 }
742
743 #[test]
744 fn verify_signature_rejected_future_timestamp() {
745 let header = sign_at(r#"{"id":"evt_4"}"#, "whsec_test", now_secs() + 600);
746 let err = verify_signature(r#"{"id":"evt_4"}"#, &header, "whsec_test").unwrap_err();
747 assert!(err.contains("future"), "got: {err}");
748 }
749
750 #[test]
751 fn verify_signature_accepted_within_tolerance() {
752 let header = sign_at(r#"{"id":"evt_5"}"#, "whsec_test", now_secs() - 240);
753 assert!(verify_signature(r#"{"id":"evt_5"}"#, &header, "whsec_test").is_ok());
754 }
755
756 #[test]
757 fn verify_signature_wrong_secret() {
758 let header = sign_at(r#"{"id":"evt_6"}"#, "whsec_test", now_secs());
759 let err = verify_signature(r#"{"id":"evt_6"}"#, &header, "wrong").unwrap_err();
760 assert!(err.contains("mismatch"), "got: {err}");
761 }
762 }
763