//! Webhook signature verification, the Stripe-shaped structs we read a payload //! into, and the wire-name match that turns one into an [`MnwEvent`]. //! //! Everything Stripe-specific about an inbound webhook stops at this module. //! A second provider implements [`super::PaymentProvider::normalize_webhook`] //! with its own mapping and never sees these names or these views. //! //! rc.5 ships no webhook helper, so we keep the local HMAC `verify_signature` //! and a thin [`UntypedEvent`] envelope. //! //! **The `*View` structs are `pub(in crate::payments)` on purpose.** They are //! Stripe's shapes, defined ad-hoc rather than via `stripe_shared::*` to stay //! resilient against new required fields Stripe adds — the original migration //! bug was an over-strict typed struct. Letting them reach a handler is what //! makes the rest of the codebase depend on Stripe's field names, so they stop //! here: [`super::mnw_event`] converts each into an MNW-shaped type, and that //! is what crosses out. The compiler enforces it, so this is not a convention //! anyone can forget. use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; use super::mnw_event::checkout_kind; use super::{ CheckoutCompletion, InvoiceOutcome, MnwEvent, RefundOutcome, StripeClient, SubscriptionLifecycle, }; use crate::db::Cents; use crate::error::{AppError, Result}; type HmacSha256 = Hmac; /// A Stripe webhook envelope after signature verification and JSON parsing. /// /// `data_object` is the raw `data.object` JSON value, ready to be consumed /// by `serde_json::from_value` into a typed rc.5 struct. #[derive(Debug, Clone)] pub struct UntypedEvent { pub id: String, pub type_: String, pub data_object: serde_json::Value, } impl UntypedEvent { /// Parse a JSON webhook payload. Caller must verify the signature first. pub fn from_payload(payload: &str) -> Result { let mut v: serde_json::Value = serde_json::from_str(payload).map_err(|e| { tracing::warn!(error.kind = "envelope_json", error = %e, "webhook envelope JSON parse failed"); AppError::BadRequest(format!("Webhook envelope JSON parse failed: {e}")) })?; let id = take_string(&mut v, "id").ok_or_else(|| { tracing::warn!( error.kind = "envelope_missing_field", missing = "id", "webhook envelope missing required field" ); AppError::BadRequest("Webhook envelope missing required field: id".to_string()) })?; let type_ = take_string(&mut v, "type").ok_or_else(|| { tracing::warn!( error.kind = "envelope_missing_field", missing = "type", "webhook envelope missing required field" ); AppError::BadRequest("Webhook envelope missing required field: type".to_string()) })?; let data_object = v .get_mut("data") .and_then(|d| d.get_mut("object")) .map(std::mem::take) .ok_or_else(|| { tracing::warn!( error.kind = "envelope_missing_field", missing = "data.object", "webhook envelope missing required field" ); AppError::BadRequest( "Webhook envelope missing required field: data.object".to_string(), ) })?; Ok(UntypedEvent { id, type_, data_object, }) } } fn take_string(v: &mut serde_json::Value, key: &str) -> Option { v.get_mut(key).and_then(|s| match std::mem::take(s) { serde_json::Value::String(s) => Some(s), _ => None, }) } impl StripeClient { /// Verify the webhook signature and return the parsed envelope. /// /// Tries each configured signing secret in turn and accepts on the first /// match. We run multiple endpoints (`mnw-connect`, `mnw-you`), each with /// its own secret; signatures don't carry an endpoint id, so checking /// every secret is the only option. /// /// On failure the returned `AppError::BadRequest` body is specific enough /// to distinguish signature failures ("Invalid webhook signature: ...") from /// payload-shape failures ("Webhook envelope JSON parse failed: ...", /// "Webhook envelope missing required field: ..."). The Stripe Dashboard /// surfaces these bodies for failed webhook deliveries, so wording matters. /// Past incidents (Stripe API version mismatch producing serde /// `missing field` errors) were initially misread as signature failures. #[tracing::instrument(skip_all, name = "payments::verify_webhook")] pub fn verify_webhook(&self, payload: &str, signature: &str) -> Result { let mut last_err: Option = None; for secret in &self.config.webhook_secret { match verify_signature(payload, signature, secret) { Ok(()) => return UntypedEvent::from_payload(payload), Err(e) => last_err = Some(e), } } let reason = last_err.unwrap_or_else(|| "no signing secrets configured".to_string()); tracing::warn!(error.kind = "signature", reason = %reason, "webhook signature verification failed against all configured secrets"); Err(AppError::BadRequest(format!( "Invalid webhook signature: {reason}" ))) } /// Verify a v2 thin event webhook and return the parsed JSON body. /// /// See `verify_webhook` for the failure-mode taxonomy. #[tracing::instrument(skip_all, name = "payments::verify_webhook_v2")] pub fn verify_webhook_v2(&self, payload: &str, signature: &str) -> Result { let secret = self.config.webhook_secret_v2.as_deref().ok_or_else(|| { AppError::ServiceUnavailable("Stripe v2 webhook secret not configured".to_string()) })?; verify_signature(payload, signature, secret).map_err(|e| { tracing::warn!(error.kind = "signature", reason = %e, "v2 webhook signature verification failed"); AppError::BadRequest(format!("Invalid webhook signature: {e}")) })?; serde_json::from_str(payload).map_err(|e| { tracing::warn!(error.kind = "envelope_json", error = %e, "v2 webhook payload parse failed"); AppError::BadRequest(format!("Webhook payload JSON parse failed: {e}")) }) } /// Normalize a verified envelope into the MNW vocabulary. /// /// Takes the whole envelope rather than `(type, object)` because that is /// what the retry worker can produce: it re-parses a stored payload that /// was verified once already and has no signature to re-check, so it calls /// this without `verify_webhook` in front of it. The live handler composes /// the two, verify then normalize. /// /// The Stripe wire names live in [`normalize_event`] below, which is the /// point of the member: a second provider brings its own mapping and no /// Stripe event-name literal escapes this module. #[tracing::instrument(skip_all, name = "payments::normalize_webhook")] pub fn normalize_webhook(&self, event: UntypedEvent) -> Result { normalize_event(&event.type_, event.data_object) } } /// Turn a verified Stripe delivery into what MNW does about it. /// /// The one normalization the two payload-bearing entry points share: the /// live v1 handler and the retry worker re-parsing a stored payload outside /// `verify_webhook`. Before this, each grew its own /// `serde_json::from_value` calls and its own string match, which is how /// they drifted. /// /// The third entry point, the v2 thin-event path, needs no step here and /// that is not an omission: a thin event carries only a reference, so it /// fetches the object through `PaymentProvider::fetch_account`, which /// returns an [`super::AccountUpdate`] — already the normalized type. There /// is no Stripe-shaped view to strip, and it converges on the same handler. /// /// `data_object` is consumed exactly once. A parse failure is a /// `BadRequest` naming the object that would not parse, which is what the /// Stripe Dashboard shows for a failed delivery — a past incident (an API /// version mismatch producing serde `missing field` errors) was misread as /// a signature failure because the wording did not distinguish them. /// /// Public for the integration harness's `MockPaymentProvider`, which drives /// real Stripe-shaped payloads through the webhook route and so needs the real /// mapping. Same reason [`verify_signature`] is public. Exposing the entry /// point does not move the wire names, which stay in this module. pub fn normalize_event(event_type: &str, data_object: serde_json::Value) -> Result { let parse = |what: &str, e: serde_json::Error| { AppError::BadRequest(format!("Failed to parse {what}: {e}")) }; Ok(match event_type { // Both route to one place. `completed` fires immediately; for // asynchronous methods it arrives with payment_status="unpaid" and // `async_payment_succeeded` re-delivers the settled session. What a // handler needs is `settled`, not which of the two arrived. "checkout.session.completed" | "checkout.session.async_payment_succeeded" => { let view: CheckoutSessionView = serde_json::from_value(data_object).map_err(|e| parse("CheckoutSession", e))?; let kind = checkout_kind(view.metadata.as_ref()); MnwEvent::Checkout { kind, session: Box::new(CheckoutCompletion::from(view)), } } "checkout.session.async_payment_failed" => { let view: CheckoutSessionView = serde_json::from_value(data_object).map_err(|e| parse("CheckoutSession", e))?; MnwEvent::CheckoutAsyncPaymentFailed { session_id: view.id, } } "account.updated" => { let view: AccountView = serde_json::from_value(data_object).map_err(|e| parse("Account", e))?; MnwEvent::AccountUpdated(Box::new(view.into())) } "charge.refunded" => { let view: ChargeView = serde_json::from_value(data_object).map_err(|e| parse("Charge", e))?; MnwEvent::ChargeRefunded(ChargeRefundData::from_view(view).map(Box::new)) } "refund.created" | "refund.updated" => { let view: RefundView = serde_json::from_value(data_object).map_err(|e| parse("Refund", e))?; MnwEvent::RefundSettled(Box::new(RefundOutcome::from(view))) } "customer.subscription.updated" => { let view: SubscriptionView = serde_json::from_value(data_object).map_err(|e| parse("Subscription", e))?; MnwEvent::SubscriptionUpdated(Box::new(SubscriptionLifecycle::from(view))) } "customer.subscription.deleted" => { let view: SubscriptionView = serde_json::from_value(data_object).map_err(|e| parse("Subscription", e))?; MnwEvent::SubscriptionDeleted(Box::new(SubscriptionLifecycle::from(view))) } "invoice.payment_succeeded" => { let view: InvoiceView = serde_json::from_value(data_object).map_err(|e| parse("Invoice", e))?; MnwEvent::InvoicePaymentSucceeded(Box::new(InvoiceOutcome::from(view))) } "invoice.payment_failed" => { let view: InvoiceView = serde_json::from_value(data_object).map_err(|e| parse("Invoice", e))?; MnwEvent::InvoicePaymentFailed(Box::new(InvoiceOutcome::from(view))) } other => MnwEvent::Unhandled { stripe_type: other.to_string(), }, }) } /// Narrow view of a CheckoutSession: only the fields any handler reads. /// /// Built ad-hoc rather than via `stripe_shared::CheckoutSession` to stay /// resilient against new required fields Stripe adds. The original migration /// bug was caused by an over-strict typed struct. #[derive(Debug, Default, serde::Deserialize)] pub(in crate::payments) struct CheckoutSessionView { pub id: String, #[serde(default)] pub metadata: Option>, #[serde(default, deserialize_with = "deserialize_expandable_id")] pub payment_intent: Option, #[serde(default, deserialize_with = "deserialize_expandable_id")] pub subscription: Option, #[serde(default, deserialize_with = "deserialize_expandable_id")] pub customer: Option, #[serde(default)] pub customer_details: Option, /// Pre-tax line-item total (cents) Stripe computed for the session. Used /// only as a defense-in-depth reconciliation against our server-built line /// items; absent on older/edge events, hence `Option`. #[serde(default)] pub amount_subtotal: Option, /// What Stripe actually charged the buyer, when it converted at checkout. /// /// Present only when Adaptive Pricing converted; absent when the buyer paid /// in the seller's currency. This is the one moment the converted figure is /// knowable, so it is captured here and stored on the transaction rather /// than re-derived later from a rate we do not have. #[serde(default)] pub presentment_details: Option, /// Whether Stripe has captured funds for this session: `"paid"`, /// `"unpaid"`, or `"no_payment_required"`. Synchronous card payments report /// `"paid"` on `checkout.session.completed`; asynchronous methods (ACH, /// SEPA, Bacs) report `"unpaid"` there and settle later via /// `checkout.session.async_payment_succeeded`. Absent on older/edge events, /// hence `Option`, see `payment_settled`. #[serde(default)] pub payment_status: Option, /// ISO currency of the session (e.g. `"usd"`). Sessions are built /// server-side as USD; a non-USD value makes the integer-cents subtotal /// reconciliation meaningless and is itself an anomaly. Absent on /// older/edge events, hence `Option`. #[serde(default)] pub currency: Option, } impl CheckoutSessionView { /// True when funds are captured (or none were required) and it is safe to /// deliver goods. Treats an absent field as settled to preserve behaviour /// for legacy/edge events that predate the field; only an explicit /// `"unpaid"` (an async method awaiting settlement) is withheld. pub(in crate::payments) fn payment_settled(&self) -> bool { matches!( self.payment_status.as_deref(), None | Some("paid" | "no_payment_required") ) } } #[derive(Debug, Default, serde::Deserialize)] pub(in crate::payments) struct CheckoutCustomerDetailsView { pub email: Option, } /// Narrow view of a Subscription: id, status, cancellation flag, and the /// item-level period fields rc.5 promoted from the top level. #[derive(Debug, serde::Deserialize)] pub(in crate::payments) struct SubscriptionView { pub id: String, pub status: String, #[serde(default)] pub cancel_at_period_end: bool, #[serde(default)] pub items: SubscriptionItemList, } impl SubscriptionView { /// Period from `items.data[0]` (rc.5 moved these off the top-level Subscription). pub(in crate::payments) fn current_period(&self) -> Option<(i64, i64)> { self.items .data .first() .map(|it| (it.current_period_start, it.current_period_end)) } } #[derive(Debug, Default, serde::Deserialize)] pub(in crate::payments) struct SubscriptionItemList { #[serde(default)] pub data: Vec, } #[derive(Debug, serde::Deserialize)] pub(in crate::payments) struct SubscriptionItemView { #[serde(default)] pub current_period_start: i64, #[serde(default)] pub current_period_end: i64, } /// Narrow view of an Invoice: subscription id (via legacy `subscription` or /// the rc.5 `parent.subscription_details.subscription` path), period bounds, /// and billing reason. #[derive(Debug, serde::Deserialize)] pub(in crate::payments) struct InvoiceView { #[serde(default)] pub period_start: i64, #[serde(default)] pub period_end: i64, #[serde(default)] pub billing_reason: Option, #[serde(default, deserialize_with = "deserialize_expandable_id")] pub subscription: Option, #[serde(default)] pub parent: Option, } impl InvoiceView { /// Pull the subscription id from either the legacy or new field path. pub(in crate::payments) fn subscription_id(&self) -> Option<&str> { if let Some(s) = &self.subscription { return Some(s.as_str()); } self.parent .as_ref()? .subscription_details .as_ref()? .subscription .as_deref() } pub(in crate::payments) fn is_renewal(&self) -> bool { self.billing_reason.as_deref() == Some("subscription_cycle") } } #[derive(Debug, serde::Deserialize)] pub(in crate::payments) struct InvoiceParentView { #[serde(default)] pub subscription_details: Option, } #[derive(Debug, serde::Deserialize)] pub(in crate::payments) struct InvoiceSubscriptionDetailsView { #[serde(default, deserialize_with = "deserialize_expandable_id")] pub subscription: Option, } /// Stripe expandable fields are either a bare id string or a full object with /// an `id` field. Pluck the id either way. fn deserialize_expandable_id<'de, D>( deserializer: D, ) -> std::result::Result, D::Error> where D: serde::Deserializer<'de>, { use serde::Deserialize; let v = serde_json::Value::deserialize(deserializer)?; Ok(match v { serde_json::Value::Null => None, serde_json::Value::String(s) => Some(s), serde_json::Value::Object(mut map) => match map.remove("id") { Some(serde_json::Value::String(s)) => Some(s), _ => None, }, _ => None, }) } /// Account update fields the dispatcher hands to the handler. #[derive(Debug)] pub struct AccountUpdate { pub account_id: String, pub charges_enabled: bool, pub payouts_enabled: bool, pub details_submitted: bool, /// The account's settlement currency, when Stripe reports one we support. /// /// `None` covers two different situations and the handler treats them the /// same way, by leaving the stored currency alone: Stripe reported nothing /// (an account too early in onboarding to have a default currency), or it /// reported a currency outside our six. Neither is a reason to fail a /// webhook, and neither is a reason to silently rewrite a creator's prices /// into USD. pub settlement_currency: Option, } /// Read `default_currency` off a Stripe account, keeping only what we support. /// /// Logs the unsupported case: it is the signal that a creator has connected an /// account MNW cannot denominate prices in, and it is invisible otherwise. fn settlement_currency_of( account_id: &str, default_currency: Option<&str>, ) -> Option { let code = default_currency?; let parsed = crate::currency::SettlementCurrency::from_code(code); if parsed.is_none() { tracing::warn!( %account_id, default_currency = %code, "Stripe account settles in an unsupported currency; leaving the stored one unchanged" ); } parsed } impl From for AccountUpdate { fn from(a: stripe_shared::Account) -> Self { let account_id = a.id.to_string(); AccountUpdate { charges_enabled: a.charges_enabled.unwrap_or(false), payouts_enabled: a.payouts_enabled.unwrap_or(false), details_submitted: a.details_submitted.unwrap_or(false), settlement_currency: settlement_currency_of( &account_id, a.default_currency.map(|c| c.to_string()).as_deref(), ), account_id, } } } /// Narrow view of an Account: only the fields we react to. #[derive(Debug, serde::Deserialize)] pub(in crate::payments) struct AccountView { pub id: String, #[serde(default)] pub charges_enabled: bool, #[serde(default)] pub payouts_enabled: bool, #[serde(default)] pub details_submitted: bool, /// Absent on accounts too early in onboarding to have one. #[serde(default)] pub default_currency: Option, } impl From for AccountUpdate { fn from(a: AccountView) -> Self { AccountUpdate { charges_enabled: a.charges_enabled, payouts_enabled: a.payouts_enabled, details_submitted: a.details_submitted, settlement_currency: settlement_currency_of(&a.id, a.default_currency.as_deref()), account_id: a.id, } } } /// What the buyer was presented with, when it differed from the sale currency. #[derive(Debug, serde::Deserialize)] pub(in crate::payments) struct PresentmentDetailsView { #[serde(default)] pub presentment_amount: Option, #[serde(default)] pub presentment_currency: Option, } /// Narrow view of a Charge for refund processing. #[derive(Debug, serde::Deserialize)] pub(in crate::payments) struct ChargeView { #[serde(default)] pub amount: i64, #[serde(default)] pub amount_refunded: i64, #[serde(default, deserialize_with = "deserialize_expandable_id")] pub payment_intent: Option, } /// Data extracted from a charge.refunded webhook event. #[derive(Debug)] pub struct ChargeRefundData { pub payment_intent_id: String, pub amount: Cents, pub amount_refunded: Cents, } impl ChargeRefundData { pub fn is_full_refund(&self) -> bool { // Require `amount > 0` so $0 verification charges (which Stripe occasionally // emits with `amount=0, amount_refunded=0`) are not treated as full refunds, // that previously triggered `refund_transaction_by_payment_intent` with a // default `unknown` intent ID. self.amount > Cents::new(0) && self.amount_refunded >= self.amount } /// Build from a parsed charge view. Returns None when there is no /// payment_intent; these events are out of scope here. pub(in crate::payments) fn from_view(charge: ChargeView) -> Option { Some(ChargeRefundData { payment_intent_id: charge.payment_intent?, amount: Cents::new(charge.amount), amount_refunded: Cents::new(charge.amount_refunded), }) } } /// Narrow view of a Refund object (`refund.created` / `refund.updated` events). /// /// The line-scoped self-service refund tags the Stripe refund with /// `metadata.mnw_transaction_id`; the webhook reads it back so a cart line refund /// marks/revokes exactly its own transaction rather than the whole order. #[derive(Debug, serde::Deserialize)] pub(in crate::payments) struct RefundView { #[serde(default)] pub amount: i64, pub status: Option, #[serde(default, deserialize_with = "deserialize_expandable_id")] pub payment_intent: Option, #[serde(default)] pub metadata: Option>, } impl RefundView { /// The MNW transaction id this refund was tagged with at creation, if any. /// Absent for out-of-band refunds (e.g. issued from the Stripe dashboard). pub(in crate::payments) fn mnw_transaction_id(&self) -> Option<&str> { self.metadata .as_ref()? .get("mnw_transaction_id") .map(String::as_str) } /// Stripe marks a completed refund `succeeded`; only then is the money back. pub(in crate::payments) fn is_succeeded(&self) -> bool { self.status.as_deref() == Some("succeeded") } } // v2 thin event types /// A Stripe v2 "thin" event: contains only the event type and a reference to /// the related object, not the full object snapshot. #[derive(Debug, serde::Deserialize)] pub struct ThinEvent { pub id: String, #[serde(rename = "type")] pub event_type: String, pub related_object: Option, } /// Reference to the object that triggered a v2 event. #[derive(Debug, serde::Deserialize)] pub struct RelatedObject { pub id: String, #[serde(rename = "type")] pub object_type: String, } /// Reject a webhook timestamp further than `tolerance` seconds from now, in /// either direction, naming which direction it was. /// /// Split out of [`verify_signature`] because it is the only part of the replay /// guard that is a decision rather than a clock read, and a test that has to /// call `SystemTime::now()` to reach the boundary cannot sit exactly on it. /// `saturating_sub` rather than a guarded subtraction: an ordering test around /// a subtraction that already cannot underflow has no observable effect, so it /// is a branch no test could ever justify. fn check_timestamp_skew( ts_secs: u64, now_secs: u64, tolerance: u64, ) -> std::result::Result<(), String> { if now_secs.saturating_sub(ts_secs) > tolerance { return Err("timestamp too old".to_string()); } if ts_secs.saturating_sub(now_secs) > tolerance { return Err("timestamp too far in the future".to_string()); } Ok(()) } /// Verify a Stripe webhook signature (v1 scheme, shared by v1 and v2 endpoints). /// /// Parses `t={ts},v1={hex}`, computes HMAC-SHA256 over `{ts}.{payload}`, and /// compares in constant time. Rejects timestamps outside the configured /// tolerance to prevent replay attacks. pub fn verify_signature( payload: &str, header: &str, secret: &str, ) -> std::result::Result<(), String> { let mut timestamp = None; // Stripe emits a `v1=` value per active secret during rotation; collect // them all and accept if any matches. The previous single-Option only // kept the last value parsed, which silently broke rotation. let mut signatures: Vec<&str> = Vec::new(); for part in header.split(',') { if let Some(t) = part.strip_prefix("t=") { timestamp = Some(t); } else if let Some(s) = part.strip_prefix("v1=") { signatures.push(s); } } let timestamp = timestamp.ok_or("missing timestamp in signature header")?; if signatures.is_empty() { return Err("missing v1 signature in header".to_string()); } let ts_secs: u64 = timestamp.parse().map_err(|_| "invalid timestamp")?; let now_secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|_| "system clock error")? .as_secs(); check_timestamp_skew( ts_secs, now_secs, crate::constants::WEBHOOK_TIMESTAMP_TOLERANCE_SECS, )?; let signed_payload = format!("{timestamp}.{payload}"); let mut last_err = "signature mismatch".to_string(); for expected_sig in &signatures { let Ok(expected_bytes) = hex::decode(expected_sig) else { last_err = "invalid hex in v1 signature".to_string(); continue; }; let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| "invalid HMAC key")?; mac.update(signed_payload.as_bytes()); if mac.verify_slice(&expected_bytes).is_ok() { return Ok(()); } } Err(last_err) } #[cfg(test)] mod tests { use super::*; use crate::payments::CheckoutKind; use serde_json::json; #[test] fn parse_envelope_extracts_id_type_and_object() { let payload = r#"{"id":"evt_1","type":"checkout.session.completed","data":{"object":{"id":"cs_1"}}}"#; let evt = UntypedEvent::from_payload(payload).unwrap(); assert_eq!(evt.id, "evt_1"); assert_eq!(evt.type_, "checkout.session.completed"); assert_eq!(evt.data_object["id"], "cs_1"); } #[test] fn parse_envelope_missing_data_object_errors() { assert!(UntypedEvent::from_payload(r#"{"id":"x","type":"y"}"#).is_err()); } #[test] fn parse_envelope_error_messages_name_the_field() { // Each failure mode should produce a body distinct enough that a future // debugger reading Stripe Dashboard or our error logs knows exactly // what was wrong, rather than a generic "Invalid webhook signature". let missing_id = UntypedEvent::from_payload(r#"{"type":"t","data":{"object":{}}}"#).unwrap_err(); assert!( format!("{missing_id:?}").contains("id"), "got: {missing_id:?}" ); let missing_type = UntypedEvent::from_payload(r#"{"id":"i","data":{"object":{}}}"#).unwrap_err(); assert!( format!("{missing_type:?}").contains("type"), "got: {missing_type:?}" ); let missing_obj = UntypedEvent::from_payload(r#"{"id":"i","type":"t"}"#).unwrap_err(); assert!( format!("{missing_obj:?}").contains("data.object"), "got: {missing_obj:?}" ); let bad_json = UntypedEvent::from_payload(r"not json").unwrap_err(); assert!( format!("{bad_json:?}").contains("parse failed"), "got: {bad_json:?}" ); } // CheckoutSession parses from a real captured webhook fixture. #[test] fn checkout_session_parses_from_fixture() { let raw = include_str!("../../tests/fixtures/webhooks/checkout.session.completed.connect.json"); let evt = UntypedEvent::from_payload(raw).unwrap(); let session: stripe_shared::CheckoutSession = serde_json::from_value(evt.data_object).unwrap(); assert_eq!(session.mode, stripe_shared::CheckoutSessionMode::Payment); } // --- CheckoutSessionView payment settlement gate --- fn view_with_status(status: Option<&str>) -> CheckoutSessionView { CheckoutSessionView { payment_status: status.map(str::to_string), ..Default::default() } } #[test] fn payment_settled_true_for_paid_and_no_payment_required() { assert!(view_with_status(Some("paid")).payment_settled()); assert!(view_with_status(Some("no_payment_required")).payment_settled()); } #[test] fn payment_settled_false_only_for_explicit_unpaid() { // The async-method case: `checkout.session.completed` arrives with // "unpaid" and goods must NOT be delivered until settlement. assert!(!view_with_status(Some("unpaid")).payment_settled()); } #[test] fn payment_settled_true_when_absent_preserves_legacy_behaviour() { // Older/edge events without the field must still finalize (synchronous // card sessions predating the field, and any event Stripe omits it on). assert!(view_with_status(None).payment_settled()); assert!(!view_with_status(Some("something_new")).payment_settled()); } #[test] fn payment_status_and_currency_deserialize_from_session_json() { let session: CheckoutSessionView = serde_json::from_value(json!({ "id": "cs_1", "payment_status": "unpaid", "currency": "usd", })) .unwrap(); assert_eq!(session.payment_status.as_deref(), Some("unpaid")); assert_eq!(session.currency.as_deref(), Some("usd")); assert!(!session.payment_settled()); // Absent fields default to None (settled). let bare: CheckoutSessionView = serde_json::from_value(json!({"id": "cs_2"})).unwrap(); assert!(bare.payment_status.is_none()); assert!(bare.currency.is_none()); assert!(bare.payment_settled()); } // Subscription parses with current_period_* on items.data[0]. #[test] fn subscription_parses_from_fixture_with_items_period() { let raw = include_str!("../../tests/fixtures/webhooks/customer.subscription.updated.json"); let evt = UntypedEvent::from_payload(raw).unwrap(); let sub: stripe_shared::Subscription = serde_json::from_value(evt.data_object).unwrap(); let item = sub .items .data .first() .expect("subscription has at least one item"); assert!(item.current_period_start > 0); assert!(item.current_period_end > item.current_period_start); } // Invoice carries the new parent.subscription_details shape. #[test] fn invoice_parses_from_fixture() { let raw = include_str!("../../tests/fixtures/webhooks/invoice.payment_succeeded.json"); let evt = UntypedEvent::from_payload(raw).unwrap(); let inv: stripe_shared::Invoice = serde_json::from_value(evt.data_object).unwrap(); assert!(inv.period_start > 0); } #[test] fn account_update_conversion() { let a: stripe_shared::Account = serde_json::from_value(json!({ "id": "acct_test123", "object": "account", "charges_enabled": true, "payouts_enabled": true, "details_submitted": true, })) .unwrap(); let u: AccountUpdate = a.into(); assert_eq!(u.account_id, "acct_test123"); assert!(u.charges_enabled); assert!(u.payouts_enabled); assert!(u.details_submitted); } #[test] fn account_update_defaults_to_false_when_missing() { let a: stripe_shared::Account = serde_json::from_value(json!({ "id": "acct_x", "object": "account", })) .unwrap(); let u: AccountUpdate = a.into(); assert!(!u.charges_enabled); assert!(!u.payouts_enabled); assert!(!u.details_submitted); } // ChargeRefundData::from_charge JSON-roundtrip is covered by integration // tests against real `charge.refunded` payloads, rc.5's `Charge` struct // has ~30 non-Optional fields which makes hand-constructing a minimal one // brittle. is_full_refund_* tests below pin the predicate semantics. #[test] fn is_full_refund_boundary() { let exactly = ChargeRefundData { payment_intent_id: "pi_a".to_string(), amount: Cents::new(1000), amount_refunded: Cents::new(1000), }; assert!(exactly.is_full_refund()); let one_under = ChargeRefundData { payment_intent_id: "pi_b".to_string(), amount: Cents::new(1000), amount_refunded: Cents::new(999), }; assert!(!one_under.is_full_refund()); } #[test] fn is_full_refund_over_refunded_still_full() { let over = ChargeRefundData { payment_intent_id: "pi_c".to_string(), amount: Cents::new(1000), amount_refunded: Cents::new(1500), }; assert!(over.is_full_refund()); } #[test] fn is_full_refund_zero_amount_is_not_full() { // Stripe sometimes emits `charge.refunded` events with amount=0 for $0 // verification charges. Treating those as full refunds previously // triggered `refund_transaction_by_payment_intent("unknown")`. let zero = ChargeRefundData { payment_intent_id: "pi_d".to_string(), amount: Cents::new(0), amount_refunded: Cents::new(0), }; assert!(!zero.is_full_refund()); } // --- verify_signature --- fn sign_at(payload: &str, secret: &str, timestamp: u64) -> String { use hmac::Mac; let signed_payload = format!("{timestamp}.{payload}"); let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); mac.update(signed_payload.as_bytes()); let hex_sig = hex::encode(mac.finalize().into_bytes()); format!("t={timestamp},v1={hex_sig}") } fn now_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() } #[test] fn signature_matches_the_reference_hmac() { // Stripe is the counterparty and its HMAC is fixed, so these bytes are // an external contract no round-trip test can check, signing and // verifying with the same crate agrees with itself even if the crate // changed. Pinned against an independent HMAC-SHA256 over Stripe's // documented signed payload, "{timestamp}.{body}". assert_eq!( sign_at(r#"{"id":"evt_1"}"#, "whsec_test", 1_700_000_000), "t=1700000000,v1=c89214b5b5da833daed6f0b8c5bb6bd58cea9022bd80ccc78230f3942d632925" ); } #[test] fn verify_signature_valid_current() { let header = sign_at(r#"{"id":"evt_1"}"#, "whsec_test", now_secs()); assert!(verify_signature(r#"{"id":"evt_1"}"#, &header, "whsec_test").is_ok()); } #[test] fn verify_signature_rejected_stale_timestamp() { let header = sign_at(r#"{"id":"evt_3"}"#, "whsec_test", now_secs() - 600); let err = verify_signature(r#"{"id":"evt_3"}"#, &header, "whsec_test").unwrap_err(); assert!(err.contains("timestamp too old"), "got: {err}"); } #[test] fn verify_signature_rejected_future_timestamp() { let header = sign_at(r#"{"id":"evt_4"}"#, "whsec_test", now_secs() + 600); let err = verify_signature(r#"{"id":"evt_4"}"#, &header, "whsec_test").unwrap_err(); assert!(err.contains("future"), "got: {err}"); } #[test] fn verify_signature_accepted_within_tolerance() { let header = sign_at(r#"{"id":"evt_5"}"#, "whsec_test", now_secs() - 240); assert!(verify_signature(r#"{"id":"evt_5"}"#, &header, "whsec_test").is_ok()); } #[test] fn verify_signature_wrong_secret() { let header = sign_at(r#"{"id":"evt_6"}"#, "whsec_test", now_secs()); let err = verify_signature(r#"{"id":"evt_6"}"#, &header, "wrong").unwrap_err(); assert!(err.contains("mismatch"), "got: {err}"); } // --- check_timestamp_skew --- // // The tests above sign against the real clock, so they can only land near // the tolerance edge, never on it. Every mutant of the two comparisons // survived Phase 0 for that reason. These sit on the boundary exactly. const TOL: u64 = 300; const NOW: u64 = 1_700_000_000; #[test] fn skew_accepts_exactly_at_tolerance_in_both_directions() { assert!(check_timestamp_skew(NOW - TOL, NOW, TOL).is_ok()); assert!(check_timestamp_skew(NOW + TOL, NOW, TOL).is_ok()); assert!(check_timestamp_skew(NOW, NOW, TOL).is_ok()); } #[test] fn skew_rejects_one_second_past_tolerance_in_both_directions() { let old = check_timestamp_skew(NOW - TOL - 1, NOW, TOL).unwrap_err(); assert!(old.contains("too old"), "got: {old}"); let future = check_timestamp_skew(NOW + TOL + 1, NOW, TOL).unwrap_err(); assert!(future.contains("future"), "got: {future}"); } #[test] fn skew_reads_the_two_directions_separately() { // A timestamp ahead of now is not stale, and one behind is not from the // future: the guard that mixes the two operands passes this only by // accident of small numbers, so keep the values epoch-sized. assert!(check_timestamp_skew(NOW + 60, NOW, TOL).is_ok()); assert!(check_timestamp_skew(NOW - 60, NOW, TOL).is_ok()); } // --- narrow view accessors --- // // Parsed from JSON rather than hand-built: these types exist to read // Stripe's payload shapes, so the shape is half of what is under test. fn subscription(json: serde_json::Value) -> SubscriptionView { serde_json::from_value(json).expect("subscription view parses") } fn invoice(json: serde_json::Value) -> InvoiceView { serde_json::from_value(json).expect("invoice view parses") } fn refund(json: serde_json::Value) -> RefundView { serde_json::from_value(json).expect("refund view parses") } #[test] fn current_period_reads_the_first_item() { let sub = subscription(json!({ "id": "sub_1", "status": "active", "items": {"data": [ {"current_period_start": 1_700_000_000i64, "current_period_end": 1_702_592_000i64}, {"current_period_start": 1i64, "current_period_end": 2i64}, ]}, })); assert_eq!( sub.current_period(), Some((1_700_000_000, 1_702_592_000)), "the period comes from items.data[0], not from a later item" ); } #[test] fn current_period_is_none_without_items() { let sub = subscription(json!({"id": "sub_2", "status": "active"})); assert_eq!(sub.current_period(), None); } #[test] fn subscription_id_prefers_the_legacy_field() { let inv = invoice(json!({ "subscription": "sub_legacy", "parent": {"subscription_details": {"subscription": "sub_new"}}, })); assert_eq!(inv.subscription_id(), Some("sub_legacy")); } #[test] fn subscription_id_falls_back_to_the_parent_path() { let inv = invoice(json!({ "parent": {"subscription_details": {"subscription": "sub_new"}}, })); assert_eq!(inv.subscription_id(), Some("sub_new")); } #[test] fn subscription_id_is_none_when_neither_path_carries_one() { assert_eq!(invoice(json!({})).subscription_id(), None); assert_eq!(invoice(json!({"parent": {}})).subscription_id(), None); assert_eq!( invoice(json!({"parent": {"subscription_details": {}}})).subscription_id(), None ); } #[test] fn is_renewal_only_for_subscription_cycle() { assert!(invoice(json!({"billing_reason": "subscription_cycle"})).is_renewal()); assert!(!invoice(json!({"billing_reason": "subscription_create"})).is_renewal()); assert!(!invoice(json!({})).is_renewal()); } #[test] fn expandable_id_reads_a_bare_string_or_an_object() { assert_eq!( invoice(json!({"subscription": "sub_bare"})).subscription, Some("sub_bare".to_string()), "the bare-id form" ); assert_eq!( invoice(json!({"subscription": {"id": "sub_expanded", "object": "subscription"}})) .subscription, Some("sub_expanded".to_string()), "the expanded-object form" ); } #[test] fn expandable_id_is_none_for_null_or_an_object_without_a_string_id() { assert_eq!(invoice(json!({"subscription": null})).subscription, None); assert_eq!(invoice(json!({"subscription": {}})).subscription, None); assert_eq!( invoice(json!({"subscription": {"id": 7}})).subscription, None, "a numeric id is not an id we can use" ); assert_eq!(invoice(json!({"subscription": 7})).subscription, None); } #[test] fn refund_transaction_id_comes_from_metadata() { let tagged = refund(json!({ "status": "succeeded", "metadata": {"mnw_transaction_id": "txn_9"}, })); assert_eq!(tagged.mnw_transaction_id(), Some("txn_9")); let other_metadata = refund(json!({"metadata": {"something_else": "x"}})); assert_eq!(other_metadata.mnw_transaction_id(), None); assert_eq!(refund(json!({})).mnw_transaction_id(), None); } #[test] fn refund_is_succeeded_only_for_succeeded() { assert!(refund(json!({"status": "succeeded"})).is_succeeded()); assert!(!refund(json!({"status": "pending"})).is_succeeded()); assert!(!refund(json!({"status": "failed"})).is_succeeded()); assert!(!refund(json!({})).is_succeeded()); } #[test] fn charge_refund_data_needs_a_payment_intent() { let with_pi: ChargeView = serde_json::from_value(json!({ "amount": 1000, "amount_refunded": 1000, "payment_intent": "pi_1", })) .unwrap(); let data = ChargeRefundData::from_view(with_pi).expect("a charge with an intent converts"); assert_eq!(data.payment_intent_id, "pi_1"); assert_eq!(data.amount, Cents::new(1000)); assert_eq!(data.amount_refunded, Cents::new(1000)); let without_pi: ChargeView = serde_json::from_value(json!({"amount": 1000, "amount_refunded": 0})).unwrap(); assert!(ChargeRefundData::from_view(without_pi).is_none()); } #[test] fn settlement_currency_keeps_only_supported_codes() { assert_eq!( settlement_currency_of("acct_1", Some("usd")), Some(crate::currency::SettlementCurrency::Usd) ); assert_eq!( settlement_currency_of("acct_2", Some("xyz")), None, "an unsupported currency leaves the stored one alone" ); assert_eq!(settlement_currency_of("acct_3", None), None); } // ── Normalization ── fn normalize(type_: &str, object: serde_json::Value) -> MnwEvent { normalize_event(type_, object).expect("payload should normalize") } #[test] fn a_checkout_kind_comes_from_the_metadata_mnw_wrote() { let event = normalize( "checkout.session.completed", serde_json::json!({ "id": "cs_1", "metadata": {"checkout_type": "tip"}, "payment_status": "paid", }), ); let MnwEvent::Checkout { kind, session } = event else { panic!("expected a checkout"); }; assert_eq!(kind, CheckoutKind::Tip); assert_eq!(session.session_id, "cs_1"); assert!(session.settled); } #[test] fn a_session_with_no_checkout_type_is_a_purchase() { // The dispatcher's final `else` for as long as it has existed: a single // item purchase is the shape with no distinguishing metadata. let event = normalize( "checkout.session.completed", serde_json::json!({"id": "cs_1", "metadata": {}}), ); let MnwEvent::Checkout { kind, .. } = event else { panic!("expected a checkout"); }; assert_eq!(kind, CheckoutKind::Purchase); } #[test] fn an_unpaid_session_is_not_settled_and_an_absent_status_is() { // The absent case preserves behaviour for legacy events that predate // `payment_status`; only an explicit "unpaid" is withheld. let unpaid = normalize( "checkout.session.completed", serde_json::json!({"id": "cs_1", "payment_status": "unpaid"}), ); let MnwEvent::Checkout { session, .. } = unpaid else { panic!("expected a checkout") }; assert!(!session.settled); let legacy = normalize( "checkout.session.completed", serde_json::json!({"id": "cs_2"}), ); let MnwEvent::Checkout { session, .. } = legacy else { panic!("expected a checkout") }; assert!(session.settled); } #[test] fn async_payment_succeeded_normalizes_to_the_same_checkout_as_completed() { // A handler cares about `settled`, not which of the two arrived. for type_ in [ "checkout.session.completed", "checkout.session.async_payment_succeeded", ] { let event = normalize( type_, serde_json::json!({ "id": "cs_1", "metadata": {"checkout_type": "cart"}, "payment_status": "paid", }), ); assert!( matches!( event, MnwEvent::Checkout { kind: CheckoutKind::Cart, .. } ), "{type_} should be a settled cart checkout" ); } } #[test] fn an_invoice_resolves_its_subscription_from_either_field_path() { // rc.5 moved the subscription id under parent.subscription_details; a // handler should never have to know which shape arrived. let legacy = normalize( "invoice.payment_succeeded", serde_json::json!({"subscription": "sub_1", "billing_reason": "subscription_cycle"}), ); let MnwEvent::InvoicePaymentSucceeded(invoice) = legacy else { panic!("expected an invoice") }; assert_eq!(invoice.subscription_id.as_deref(), Some("sub_1")); assert!(invoice.is_renewal); let rc5 = normalize( "invoice.payment_failed", serde_json::json!({ "parent": {"subscription_details": {"subscription": "sub_2"}}, "billing_reason": "subscription_create", }), ); let MnwEvent::InvoicePaymentFailed(invoice) = rc5 else { panic!("expected an invoice") }; assert_eq!(invoice.subscription_id.as_deref(), Some("sub_2")); assert!(!invoice.is_renewal); } #[test] fn a_subscription_keeps_stripes_status_string_unparsed() { // Parsing here would have to choose between erroring on a status Stripe // added and inventing a member; both are worse than letting the handler // treat an unknown status as a no-op. let event = normalize( "customer.subscription.updated", serde_json::json!({ "id": "sub_1", "status": "paused", "cancel_at_period_end": true, "items": {"data": [{"current_period_start": 1, "current_period_end": 2}]}, }), ); let MnwEvent::SubscriptionUpdated(sub) = event else { panic!("expected a subscription update") }; assert_eq!(sub.status, "paused"); assert!(sub.cancel_at_period_end); assert_eq!(sub.current_period, Some((1, 2))); } #[test] fn a_charge_with_no_payment_intent_normalizes_to_nothing_to_do() { // Out of scope rather than an error: there is no payment to refund // against, which is what `ChargeRefundData::from_view` has always said. let event = normalize( "charge.refunded", serde_json::json!({"amount": 100, "amount_refunded": 100}), ); assert!(matches!(event, MnwEvent::ChargeRefunded(None))); } #[test] fn an_unhandled_type_is_a_member_not_a_fallthrough() { // The whole reason dispatch matches on an enum: a type MNW does not act // on is representable, so a misspelt arm cannot silently swallow one. let event = normalize("payment_intent.succeeded", serde_json::json!({})); let MnwEvent::Unhandled { stripe_type } = event else { panic!("expected an unhandled event") }; assert_eq!(stripe_type, "payment_intent.succeeded"); } #[test] fn a_payload_that_will_not_parse_names_the_object() { // The Stripe Dashboard shows this body for a failed delivery, and a past // incident (an API version mismatch producing serde `missing field` // errors) was misread as a signature failure because the wording did not // distinguish them. let err = normalize_event( "customer.subscription.updated", serde_json::json!({"status": "active"}), ) .unwrap_err(); let msg = format!("{err:?}"); assert!(msg.contains("Subscription"), "{msg}"); } }