Skip to main content

max / makenotwork

30.2 KB · 818 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 /// Verify a Stripe webhook signature (v1 scheme, shared by v1 and v2 endpoints).
485 ///
486 /// Parses `t={ts},v1={hex}`, computes HMAC-SHA256 over `{ts}.{payload}`, and
487 /// compares in constant time. Rejects timestamps outside the configured
488 /// tolerance to prevent replay attacks.
489 pub fn verify_signature(
490 payload: &str,
491 header: &str,
492 secret: &str,
493 ) -> std::result::Result<(), String> {
494 let mut timestamp = None;
495 // Stripe emits a `v1=` value per active secret during rotation; collect
496 // them all and accept if any matches. The previous single-Option only
497 // kept the last value parsed, which silently broke rotation.
498 let mut signatures: Vec<&str> = Vec::new();
499 for part in header.split(',') {
500 if let Some(t) = part.strip_prefix("t=") {
501 timestamp = Some(t);
502 } else if let Some(s) = part.strip_prefix("v1=") {
503 signatures.push(s);
504 }
505 }
506
507 let timestamp = timestamp.ok_or("missing timestamp in signature header")?;
508 if signatures.is_empty() {
509 return Err("missing v1 signature in header".to_string());
510 }
511
512 let ts_secs: u64 = timestamp.parse().map_err(|_| "invalid timestamp")?;
513 let now_secs = std::time::SystemTime::now()
514 .duration_since(std::time::UNIX_EPOCH)
515 .map_err(|_| "system clock error")?
516 .as_secs();
517 let tolerance = crate::constants::WEBHOOK_TIMESTAMP_TOLERANCE_SECS;
518 if now_secs > ts_secs && now_secs - ts_secs > tolerance {
519 return Err("timestamp too old".to_string());
520 }
521 if ts_secs > now_secs && ts_secs - now_secs > tolerance {
522 return Err("timestamp too far in the future".to_string());
523 }
524
525 let signed_payload = format!("{timestamp}.{payload}");
526 let mut last_err = "signature mismatch".to_string();
527
528 for expected_sig in &signatures {
529 let Ok(expected_bytes) = hex::decode(expected_sig) else {
530 last_err = "invalid hex in v1 signature".to_string();
531 continue;
532 };
533 let mut mac =
534 HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| "invalid HMAC key")?;
535 mac.update(signed_payload.as_bytes());
536 if mac.verify_slice(&expected_bytes).is_ok() {
537 return Ok(());
538 }
539 }
540
541 Err(last_err)
542 }
543
544 #[cfg(test)]
545 mod tests {
546 use super::*;
547 use serde_json::json;
548
549 #[test]
550 fn parse_envelope_extracts_id_type_and_object() {
551 let payload =
552 r#"{"id":"evt_1","type":"checkout.session.completed","data":{"object":{"id":"cs_1"}}}"#;
553 let evt = UntypedEvent::from_payload(payload).unwrap();
554 assert_eq!(evt.id, "evt_1");
555 assert_eq!(evt.type_, "checkout.session.completed");
556 assert_eq!(evt.data_object["id"], "cs_1");
557 }
558
559 #[test]
560 fn parse_envelope_missing_data_object_errors() {
561 assert!(UntypedEvent::from_payload(r#"{"id":"x","type":"y"}"#).is_err());
562 }
563
564 #[test]
565 fn parse_envelope_error_messages_name_the_field() {
566 // Each failure mode should produce a body distinct enough that a future
567 // debugger reading Stripe Dashboard or our error logs knows exactly
568 // what was wrong, rather than a generic "Invalid webhook signature".
569 let missing_id =
570 UntypedEvent::from_payload(r#"{"type":"t","data":{"object":{}}}"#).unwrap_err();
571 assert!(
572 format!("{missing_id:?}").contains("id"),
573 "got: {missing_id:?}"
574 );
575
576 let missing_type =
577 UntypedEvent::from_payload(r#"{"id":"i","data":{"object":{}}}"#).unwrap_err();
578 assert!(
579 format!("{missing_type:?}").contains("type"),
580 "got: {missing_type:?}"
581 );
582
583 let missing_obj = UntypedEvent::from_payload(r#"{"id":"i","type":"t"}"#).unwrap_err();
584 assert!(
585 format!("{missing_obj:?}").contains("data.object"),
586 "got: {missing_obj:?}"
587 );
588
589 let bad_json = UntypedEvent::from_payload(r"not json").unwrap_err();
590 assert!(
591 format!("{bad_json:?}").contains("parse failed"),
592 "got: {bad_json:?}"
593 );
594 }
595
596 // CheckoutSession parses from a real captured webhook fixture.
597 #[test]
598 fn checkout_session_parses_from_fixture() {
599 let raw =
600 include_str!("../../tests/fixtures/webhooks/checkout.session.completed.connect.json");
601 let evt = UntypedEvent::from_payload(raw).unwrap();
602 let session: stripe_shared::CheckoutSession =
603 serde_json::from_value(evt.data_object).unwrap();
604 assert_eq!(session.mode, stripe_shared::CheckoutSessionMode::Payment);
605 }
606
607 // --- CheckoutSessionView payment settlement gate ---
608
609 fn view_with_status(status: Option<&str>) -> CheckoutSessionView {
610 CheckoutSessionView {
611 payment_status: status.map(str::to_string),
612 ..Default::default()
613 }
614 }
615
616 #[test]
617 fn payment_settled_true_for_paid_and_no_payment_required() {
618 assert!(view_with_status(Some("paid")).payment_settled());
619 assert!(view_with_status(Some("no_payment_required")).payment_settled());
620 }
621
622 #[test]
623 fn payment_settled_false_only_for_explicit_unpaid() {
624 // The async-method case: `checkout.session.completed` arrives with
625 // "unpaid" and goods must NOT be delivered until settlement.
626 assert!(!view_with_status(Some("unpaid")).payment_settled());
627 }
628
629 #[test]
630 fn payment_settled_true_when_absent_preserves_legacy_behaviour() {
631 // Older/edge events without the field must still finalize (synchronous
632 // card sessions predating the field, and any event Stripe omits it on).
633 assert!(view_with_status(None).payment_settled());
634 assert!(!view_with_status(Some("something_new")).payment_settled());
635 }
636
637 #[test]
638 fn payment_status_and_currency_deserialize_from_session_json() {
639 let session: CheckoutSessionView = serde_json::from_value(json!({
640 "id": "cs_1",
641 "payment_status": "unpaid",
642 "currency": "usd",
643 }))
644 .unwrap();
645 assert_eq!(session.payment_status.as_deref(), Some("unpaid"));
646 assert_eq!(session.currency.as_deref(), Some("usd"));
647 assert!(!session.payment_settled());
648
649 // Absent fields default to None (settled).
650 let bare: CheckoutSessionView = serde_json::from_value(json!({"id": "cs_2"})).unwrap();
651 assert!(bare.payment_status.is_none());
652 assert!(bare.currency.is_none());
653 assert!(bare.payment_settled());
654 }
655
656 // Subscription parses with current_period_* on items.data[0].
657 #[test]
658 fn subscription_parses_from_fixture_with_items_period() {
659 let raw = include_str!("../../tests/fixtures/webhooks/customer.subscription.updated.json");
660 let evt = UntypedEvent::from_payload(raw).unwrap();
661 let sub: stripe_shared::Subscription = serde_json::from_value(evt.data_object).unwrap();
662 let item = sub
663 .items
664 .data
665 .first()
666 .expect("subscription has at least one item");
667 assert!(item.current_period_start > 0);
668 assert!(item.current_period_end > item.current_period_start);
669 }
670
671 // Invoice carries the new parent.subscription_details shape.
672 #[test]
673 fn invoice_parses_from_fixture() {
674 let raw = include_str!("../../tests/fixtures/webhooks/invoice.payment_succeeded.json");
675 let evt = UntypedEvent::from_payload(raw).unwrap();
676 let inv: stripe_shared::Invoice = serde_json::from_value(evt.data_object).unwrap();
677 assert!(inv.period_start > 0);
678 }
679
680 #[test]
681 fn account_update_conversion() {
682 let a: stripe_shared::Account = serde_json::from_value(json!({
683 "id": "acct_test123",
684 "object": "account",
685 "charges_enabled": true,
686 "payouts_enabled": true,
687 "details_submitted": true,
688 }))
689 .unwrap();
690 let u: AccountUpdate = a.into();
691 assert_eq!(u.account_id, "acct_test123");
692 assert!(u.charges_enabled);
693 assert!(u.payouts_enabled);
694 assert!(u.details_submitted);
695 }
696
697 #[test]
698 fn account_update_defaults_to_false_when_missing() {
699 let a: stripe_shared::Account = serde_json::from_value(json!({
700 "id": "acct_x",
701 "object": "account",
702 }))
703 .unwrap();
704 let u: AccountUpdate = a.into();
705 assert!(!u.charges_enabled);
706 assert!(!u.payouts_enabled);
707 assert!(!u.details_submitted);
708 }
709
710 // ChargeRefundData::from_charge JSON-roundtrip is covered by integration
711 // tests against real `charge.refunded` payloads, rc.5's `Charge` struct
712 // has ~30 non-Optional fields which makes hand-constructing a minimal one
713 // brittle. is_full_refund_* tests below pin the predicate semantics.
714
715 #[test]
716 fn is_full_refund_boundary() {
717 let exactly = ChargeRefundData {
718 payment_intent_id: "pi_a".to_string(),
719 amount: Cents::new(1000),
720 amount_refunded: Cents::new(1000),
721 };
722 assert!(exactly.is_full_refund());
723 let one_under = ChargeRefundData {
724 payment_intent_id: "pi_b".to_string(),
725 amount: Cents::new(1000),
726 amount_refunded: Cents::new(999),
727 };
728 assert!(!one_under.is_full_refund());
729 }
730
731 #[test]
732 fn is_full_refund_over_refunded_still_full() {
733 let over = ChargeRefundData {
734 payment_intent_id: "pi_c".to_string(),
735 amount: Cents::new(1000),
736 amount_refunded: Cents::new(1500),
737 };
738 assert!(over.is_full_refund());
739 }
740
741 #[test]
742 fn is_full_refund_zero_amount_is_not_full() {
743 // Stripe sometimes emits `charge.refunded` events with amount=0 for $0
744 // verification charges. Treating those as full refunds previously
745 // triggered `refund_transaction_by_payment_intent("unknown")`.
746 let zero = ChargeRefundData {
747 payment_intent_id: "pi_d".to_string(),
748 amount: Cents::new(0),
749 amount_refunded: Cents::new(0),
750 };
751 assert!(!zero.is_full_refund());
752 }
753
754 // --- verify_signature ---
755
756 fn sign_at(payload: &str, secret: &str, timestamp: u64) -> String {
757 use hmac::Mac;
758 let signed_payload = format!("{timestamp}.{payload}");
759 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
760 mac.update(signed_payload.as_bytes());
761 let hex_sig = hex::encode(mac.finalize().into_bytes());
762 format!("t={timestamp},v1={hex_sig}")
763 }
764
765 fn now_secs() -> u64 {
766 std::time::SystemTime::now()
767 .duration_since(std::time::UNIX_EPOCH)
768 .unwrap()
769 .as_secs()
770 }
771
772 #[test]
773 fn signature_matches_the_reference_hmac() {
774 // Stripe is the counterparty and its HMAC is fixed, so these bytes are
775 // an external contract no round-trip test can check, signing and
776 // verifying with the same crate agrees with itself even if the crate
777 // changed. Pinned against an independent HMAC-SHA256 over Stripe's
778 // documented signed payload, "{timestamp}.{body}".
779 assert_eq!(
780 sign_at(r#"{"id":"evt_1"}"#, "whsec_test", 1_700_000_000),
781 "t=1700000000,v1=c89214b5b5da833daed6f0b8c5bb6bd58cea9022bd80ccc78230f3942d632925"
782 );
783 }
784
785 #[test]
786 fn verify_signature_valid_current() {
787 let header = sign_at(r#"{"id":"evt_1"}"#, "whsec_test", now_secs());
788 assert!(verify_signature(r#"{"id":"evt_1"}"#, &header, "whsec_test").is_ok());
789 }
790
791 #[test]
792 fn verify_signature_rejected_stale_timestamp() {
793 let header = sign_at(r#"{"id":"evt_3"}"#, "whsec_test", now_secs() - 600);
794 let err = verify_signature(r#"{"id":"evt_3"}"#, &header, "whsec_test").unwrap_err();
795 assert!(err.contains("timestamp too old"), "got: {err}");
796 }
797
798 #[test]
799 fn verify_signature_rejected_future_timestamp() {
800 let header = sign_at(r#"{"id":"evt_4"}"#, "whsec_test", now_secs() + 600);
801 let err = verify_signature(r#"{"id":"evt_4"}"#, &header, "whsec_test").unwrap_err();
802 assert!(err.contains("future"), "got: {err}");
803 }
804
805 #[test]
806 fn verify_signature_accepted_within_tolerance() {
807 let header = sign_at(r#"{"id":"evt_5"}"#, "whsec_test", now_secs() - 240);
808 assert!(verify_signature(r#"{"id":"evt_5"}"#, &header, "whsec_test").is_ok());
809 }
810
811 #[test]
812 fn verify_signature_wrong_secret() {
813 let header = sign_at(r#"{"id":"evt_6"}"#, "whsec_test", now_secs());
814 let err = verify_signature(r#"{"id":"evt_6"}"#, &header, "wrong").unwrap_err();
815 assert!(err.contains("mismatch"), "got: {err}");
816 }
817 }
818