//! The MNW webhook event vocabulary: what a Stripe delivery *means* to MNW, //! and the one place its names are written down. //! //! Ruled 2026-08-25 (mnw-server `9e45feec`): ratify the names the audit log //! already writes, and normalize in `payments/`. Every handler was already //! calling `log_subscription_event` with an MNW-side name Stripe never emits — //! `checkout.session.completed.tip`, `invoice.payment_failed.creator_tier` — //! so the vocabulary existed and was persisted; it just lived as 26 string //! literals scattered across three files, where a typo was a silently //! unhandled event and a renamed concept drifted one call site at a time. //! //! Two types, because there are two jobs and conflating them is what made the //! old dispatch stringly typed: //! //! - [`MnwEvent`] is what a delivery becomes. Normalization happens here in //! `payments/`, so the live v1 handler, the retry worker and the v2 thin-event //! path all inherit one, and the Stripe-shaped `*View` structs stop crossing //! out to the handlers. Dispatch matches on this. //! - [`MnwEventName`] is the audit-log vocabulary: one member per name in //! `subscription_events`, and the only place those strings are spelled. //! //! They are not one enum because the product suffix is not knowable at //! normalization time for half of them. See [`SubscriptionProduct`]. use std::collections::HashMap; // ── The audit-log vocabulary ── /// Which product a subscription-shaped delivery turned out to concern. /// /// Stripe does not say. `customer.subscription.updated` carries a subscription /// id and nothing else; which of our five products it belongs to is settled by /// looking that id up in four different tables, in order. That is a database /// fact, so it is the handler's to establish, not the normalizer's — putting /// those lookups in `payments/` would move product routing into the payment /// provider layer and make the handlers redo the work anyway. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SubscriptionProduct { /// A SyncKit developer subscription (`sync_apps`). SyncKit, /// An end-user subscription to a SyncKit app (`app_sync_subscriptions`). SyncKitAppSub, /// Fan+ (`fan_plus_subscriptions`). FanPlus, /// A creator tier (`creator_subscriptions`). CreatorTier, /// No product table claimed the subscription id. /// /// **This is an answer, not a missing one**, and it is the explicit member /// the ruling asked for rather than a silent collapse into a sibling. It is /// what the four bare names in the log have always meant: the handler fell /// through every lookup and wrote the generic `subscriptions` row. Keeping /// it distinct is the point — a Fan+ renewal and a renewal for a /// subscription we cannot place are different events, and merging them /// would erase the only signal that something is unrouted. /// /// The alternative the ruling offered (resolve the product during /// normalization and make this unrepresentable) was rejected: it is the DB /// lookups above, and they do not belong in `payments/`. Undetermined, } /// Which checkout a completed session was. /// /// Unlike [`SubscriptionProduct`] this *is* knowable at normalization time: the /// answer is in the session's own metadata, which `payments/` already owns the /// vocabulary for (`is_tip_checkout` and friends). So there is no /// "undetermined" member here — a session that matches no specific shape is a /// [`CheckoutKind::Purchase`], which is what the dispatcher's final `else` /// branch has always meant. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CheckoutKind { FanPlus, CreatorTier, SyncKitAppSub, /// A subscription to a creator's project tier. ProjectSubscription, Tip, Guest, Cart, /// A single item purchase: the shape with no distinguishing metadata. Purchase, } impl CheckoutKind { /// True for the subscription-mode checkouts, which capture no funds at /// checkout and so are not gated on settlement. /// /// The gate this feeds is load-bearing: without it, enabling an async /// payment method (ACH, SEPA, Bacs) on a connected account would mint /// license keys and grant downloads before the money settles. pub fn captures_funds_at_checkout(self) -> bool { match self { CheckoutKind::FanPlus | CheckoutKind::CreatorTier | CheckoutKind::SyncKitAppSub | CheckoutKind::ProjectSubscription => false, CheckoutKind::Tip | CheckoutKind::Guest | CheckoutKind::Cart | CheckoutKind::Purchase => true, } } } /// Every name written to `subscription_events.event_type`. /// /// One member per name already in the table, verbatim: renaming any of them /// would cost a migration for historical rows and buy nothing, since the names /// are already business-meaningful rather than Stripe-shaped. Nothing reads /// that table today (the only reference is the `INSERT`), which is exactly why /// a rename gets more expensive every day and was declined while it was still /// free. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MnwEventName { CheckoutCompletedCart, CheckoutCompletedCreatorTier, CheckoutCompletedFanPlus, CheckoutCompletedPurchase, CheckoutCompletedSubscription, CheckoutCompletedTip, SubscriptionUpdated(SubscriptionProduct), SubscriptionDeleted(SubscriptionProduct), InvoicePaymentSucceeded(SubscriptionProduct), InvoicePaymentFailed(SubscriptionProduct), } impl MnwEventName { /// The string as it is persisted. The single home for these 25 literals. /// /// `invoice.payment_failed` has no `.synckit_app_sub` spelling because no /// handler ever wrote one: an end-user app subscription's failed invoice /// falls through to the generic path. Spelling it here would invent a name /// the table has never held, so the arm maps to the bare form and says so. pub fn as_str(self) -> &'static str { use SubscriptionProduct as P; match self { MnwEventName::CheckoutCompletedCart => "checkout.session.completed.cart", MnwEventName::CheckoutCompletedCreatorTier => "checkout.session.completed.creator_tier", MnwEventName::CheckoutCompletedFanPlus => "checkout.session.completed.fan_plus", MnwEventName::CheckoutCompletedPurchase => "checkout.session.completed.purchase", MnwEventName::CheckoutCompletedSubscription => { "checkout.session.completed.subscription" } MnwEventName::CheckoutCompletedTip => "checkout.session.completed.tip", MnwEventName::SubscriptionUpdated(P::SyncKit) => { "customer.subscription.updated.synckit" } MnwEventName::SubscriptionUpdated(P::SyncKitAppSub) => { "customer.subscription.updated.synckit_app_sub" } MnwEventName::SubscriptionUpdated(P::FanPlus) => { "customer.subscription.updated.fan_plus" } MnwEventName::SubscriptionUpdated(P::CreatorTier) => { "customer.subscription.updated.creator_tier" } MnwEventName::SubscriptionUpdated(P::Undetermined) => "customer.subscription.updated", MnwEventName::SubscriptionDeleted(P::SyncKit) => { "customer.subscription.deleted.synckit" } MnwEventName::SubscriptionDeleted(P::SyncKitAppSub) => { "customer.subscription.deleted.synckit_app_sub" } MnwEventName::SubscriptionDeleted(P::FanPlus) => { "customer.subscription.deleted.fan_plus" } MnwEventName::SubscriptionDeleted(P::CreatorTier) => { "customer.subscription.deleted.creator_tier" } MnwEventName::SubscriptionDeleted(P::Undetermined) => "customer.subscription.deleted", MnwEventName::InvoicePaymentSucceeded(P::SyncKit) => { "invoice.payment_succeeded.synckit" } MnwEventName::InvoicePaymentSucceeded(P::SyncKitAppSub) => { "invoice.payment_succeeded.synckit_app_sub" } MnwEventName::InvoicePaymentSucceeded(P::FanPlus) => { "invoice.payment_succeeded.fan_plus" } MnwEventName::InvoicePaymentSucceeded(P::CreatorTier) => { "invoice.payment_succeeded.creator_tier" } MnwEventName::InvoicePaymentSucceeded(P::Undetermined) => "invoice.payment_succeeded", MnwEventName::InvoicePaymentFailed(P::SyncKit) => "invoice.payment_failed.synckit", MnwEventName::InvoicePaymentFailed(P::FanPlus) => "invoice.payment_failed.fan_plus", MnwEventName::InvoicePaymentFailed(P::CreatorTier) => { "invoice.payment_failed.creator_tier" } // No `.synckit_app_sub` spelling has ever been written for a failed // invoice; that path falls through to the generic handler. MnwEventName::InvoicePaymentFailed(P::SyncKitAppSub | P::Undetermined) => { "invoice.payment_failed" } } } } impl std::fmt::Display for MnwEventName { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } // ── The normalized delivery ── /// What the buyer was presented with, when it differed from the sale currency. /// /// Captured at checkout because that is the one moment the converted figure is /// knowable; it is stored on the transaction rather than re-derived later from /// a rate we do not have. #[derive(Debug, Default, Clone)] pub struct Presentment { pub amount: Option, pub currency: Option, } /// A completed (or settled) checkout session, in MNW's terms. /// /// The Stripe-shaped deserialization target stays inside `payments/`; this is /// what leaves it. The substantive normalization is `settled`: Stripe reports a /// three-valued `payment_status` string, and what a handler needs to know is /// the single question "may goods be delivered". #[derive(Debug, Default, Clone)] pub struct CheckoutCompletion { pub session_id: String, /// The session metadata MNW itself wrote at checkout creation. Read through /// the typed `*CheckoutMetadata` extractors, never by key here. pub metadata: Option>, pub payment_intent_id: Option, pub subscription_id: Option, pub customer_id: Option, /// Buyer email as Stripe collected it, for guest checkout. pub customer_email: Option, /// Pre-tax line-item total Stripe computed, for reconciliation against our /// server-built line items. Absent on older/edge events. pub amount_subtotal: Option, pub presentment: Option, pub currency: Option, /// Whether funds are captured (or none were required). /// /// An absent `payment_status` is treated as settled, preserving behaviour /// for legacy events that predate the field; only an explicit `"unpaid"` — /// an async method awaiting settlement — is withheld. pub settled: bool, } /// A subscription lifecycle delivery, in MNW's terms. #[derive(Debug, Clone)] pub struct SubscriptionLifecycle { pub stripe_subscription_id: String, /// Stripe's status string, deliberately unparsed. /// /// Stripe adds statuses (`paused` arrived after this code was written), and /// the handlers treat an unknown one as a no-op rather than an error so a /// subscription stuck in a new state does not pin Stripe in a retry storm. /// Parsing here would have to choose between erroring and inventing a /// member, and both are worse than letting each handler decide. pub status: String, pub cancel_at_period_end: bool, /// `(current_period_start, current_period_end)` as Unix seconds, from /// `items.data[0]` where rc.5 moved them. pub current_period: Option<(i64, i64)>, } /// An invoice delivery, in MNW's terms. #[derive(Debug, Clone)] pub struct InvoiceOutcome { /// Resolved from either the legacy `subscription` field or the rc.5 /// `parent.subscription_details.subscription` path, so a handler never has /// to know which shape arrived. pub subscription_id: Option, pub period_start: i64, pub period_end: i64, /// True when Stripe's billing reason is `subscription_cycle`, i.e. this is /// a renewal rather than the first invoice. pub is_renewal: bool, } /// A refund delivery, in MNW's terms. #[derive(Debug, Clone)] pub struct RefundOutcome { pub amount: i64, /// Stripe marks a completed refund `succeeded`; only then is money back. pub succeeded: bool, /// The MNW transaction this refund was tagged with at creation. Absent for /// out-of-band refunds (e.g. issued from the Stripe dashboard), which are /// no-ops on the line-scoped path. pub mnw_transaction_id: Option, pub payment_intent_id: Option, } /// A verified webhook delivery, normalized into what MNW does about it. /// /// Dispatch matches on this instead of on a Stripe event-name string, so an /// unhandled type is [`MnwEvent::Unhandled`] by construction rather than a /// typo that silently falls through a `match` arm. #[derive(Debug)] pub enum MnwEvent { /// A checkout that completed and is ready for its handler. /// /// `checkout.session.completed` and `checkout.session.async_payment_succeeded` /// both land here: the first fires immediately, and for asynchronous payment /// methods it arrives unsettled and the second re-delivers the settled /// session. The distinction a handler cares about is /// [`CheckoutCompletion::settled`], not which of the two arrived. Checkout { kind: CheckoutKind, session: Box, }, /// The buyer's async payment never cleared. No funds were captured, so /// there is nothing to deliver; the pending rows are released by the /// stale-pending sweeper. CheckoutAsyncPaymentFailed { session_id: String, }, SubscriptionUpdated(Box), SubscriptionDeleted(Box), InvoicePaymentSucceeded(Box), InvoicePaymentFailed(Box), AccountUpdated(Box), /// A charge-level refund, which is the out-of-band (dashboard) full-refund /// path. `None` where the charge carried no payment intent. ChargeRefunded(Option>), /// A refund object event (`refund.created` / `refund.updated`), which is /// the line-scoped self-service path. RefundSettled(Box), /// A Stripe event type MNW does not act on. Carries the type so the log /// still says what arrived. Unhandled { stripe_type: String, }, } // ── Normalization ── use super::{ AccountView, ChargeRefundData, ChargeView, CheckoutSessionView, InvoiceView, RefundView, SubscriptionView, UntypedEvent, }; use crate::error::{AppError, Result}; impl MnwEvent { /// 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. pub fn normalize(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(), }, }) } /// Normalize a whole verified envelope, discarding the id and type the /// caller has already taken for the dedup and retry-queue paths. pub fn from_untyped(event: UntypedEvent) -> Result { Self::normalize(&event.type_, event.data_object) } } /// Which checkout a session is, from the metadata MNW wrote at creation. /// /// The fall-through is [`CheckoutKind::Purchase`] rather than an error: a /// single item purchase is the shape with no distinguishing `checkout_type`, /// and that has always been the dispatcher's final `else`. fn checkout_kind(meta: Option<&HashMap>) -> CheckoutKind { use super::{ is_cart_checkout, is_creator_tier_checkout, is_fan_plus_checkout, is_guest_checkout, is_subscription_checkout, is_synckit_app_sub_checkout, is_tip_checkout, }; if is_fan_plus_checkout(meta) { CheckoutKind::FanPlus } else if is_creator_tier_checkout(meta) { CheckoutKind::CreatorTier } else if is_synckit_app_sub_checkout(meta) { CheckoutKind::SyncKitAppSub } else if is_subscription_checkout(meta) { CheckoutKind::ProjectSubscription } else if is_tip_checkout(meta) { CheckoutKind::Tip } else if is_guest_checkout(meta) { CheckoutKind::Guest } else if is_cart_checkout(meta) { CheckoutKind::Cart } else { CheckoutKind::Purchase } } impl From for CheckoutCompletion { fn from(v: CheckoutSessionView) -> Self { let settled = v.payment_settled(); CheckoutCompletion { session_id: v.id, metadata: v.metadata, payment_intent_id: v.payment_intent, subscription_id: v.subscription, customer_id: v.customer, customer_email: v.customer_details.and_then(|d| d.email), amount_subtotal: v.amount_subtotal, presentment: v.presentment_details.map(|p| Presentment { amount: p.presentment_amount, currency: p.presentment_currency, }), currency: v.currency, settled, } } } impl From for SubscriptionLifecycle { fn from(v: SubscriptionView) -> Self { let current_period = v.current_period(); SubscriptionLifecycle { stripe_subscription_id: v.id, status: v.status, cancel_at_period_end: v.cancel_at_period_end, current_period, } } } impl From for InvoiceOutcome { fn from(v: InvoiceView) -> Self { InvoiceOutcome { subscription_id: v.subscription_id().map(str::to_string), period_start: v.period_start, period_end: v.period_end, is_renewal: v.is_renewal(), } } } impl From for RefundOutcome { fn from(v: RefundView) -> Self { RefundOutcome { amount: v.amount, succeeded: v.is_succeeded(), mnw_transaction_id: v.mnw_transaction_id().map(str::to_string), payment_intent_id: v.payment_intent, } } } #[cfg(test)] mod tests { use super::*; use SubscriptionProduct as P; /// The names are the contract with `subscription_events`, and nothing reads /// that table yet, so a silent change would be invisible until someone /// finally queried it. Pinning every one here is what makes a rename a /// deliberate act. #[test] fn every_name_is_the_one_already_in_the_log() { let expected = [ ( MnwEventName::CheckoutCompletedCart, "checkout.session.completed.cart", ), ( MnwEventName::CheckoutCompletedCreatorTier, "checkout.session.completed.creator_tier", ), ( MnwEventName::CheckoutCompletedFanPlus, "checkout.session.completed.fan_plus", ), ( MnwEventName::CheckoutCompletedPurchase, "checkout.session.completed.purchase", ), ( MnwEventName::CheckoutCompletedSubscription, "checkout.session.completed.subscription", ), ( MnwEventName::CheckoutCompletedTip, "checkout.session.completed.tip", ), ( MnwEventName::SubscriptionUpdated(P::SyncKit), "customer.subscription.updated.synckit", ), ( MnwEventName::SubscriptionUpdated(P::SyncKitAppSub), "customer.subscription.updated.synckit_app_sub", ), ( MnwEventName::SubscriptionUpdated(P::FanPlus), "customer.subscription.updated.fan_plus", ), ( MnwEventName::SubscriptionUpdated(P::CreatorTier), "customer.subscription.updated.creator_tier", ), ( MnwEventName::SubscriptionUpdated(P::Undetermined), "customer.subscription.updated", ), ( MnwEventName::SubscriptionDeleted(P::SyncKit), "customer.subscription.deleted.synckit", ), ( MnwEventName::SubscriptionDeleted(P::SyncKitAppSub), "customer.subscription.deleted.synckit_app_sub", ), ( MnwEventName::SubscriptionDeleted(P::FanPlus), "customer.subscription.deleted.fan_plus", ), ( MnwEventName::SubscriptionDeleted(P::CreatorTier), "customer.subscription.deleted.creator_tier", ), ( MnwEventName::SubscriptionDeleted(P::Undetermined), "customer.subscription.deleted", ), ( MnwEventName::InvoicePaymentSucceeded(P::SyncKit), "invoice.payment_succeeded.synckit", ), ( MnwEventName::InvoicePaymentSucceeded(P::SyncKitAppSub), "invoice.payment_succeeded.synckit_app_sub", ), ( MnwEventName::InvoicePaymentSucceeded(P::FanPlus), "invoice.payment_succeeded.fan_plus", ), ( MnwEventName::InvoicePaymentSucceeded(P::CreatorTier), "invoice.payment_succeeded.creator_tier", ), ( MnwEventName::InvoicePaymentSucceeded(P::Undetermined), "invoice.payment_succeeded", ), ( MnwEventName::InvoicePaymentFailed(P::SyncKit), "invoice.payment_failed.synckit", ), ( MnwEventName::InvoicePaymentFailed(P::FanPlus), "invoice.payment_failed.fan_plus", ), ( MnwEventName::InvoicePaymentFailed(P::CreatorTier), "invoice.payment_failed.creator_tier", ), ( MnwEventName::InvoicePaymentFailed(P::Undetermined), "invoice.payment_failed", ), ]; for (name, want) in expected { assert_eq!(name.as_str(), want, "{name:?} changed spelling"); } } #[test] fn the_undetermined_product_is_the_bare_name_not_a_siblings_name() { // The four bare names must stay distinct from every suffixed sibling. // Collapsing them would erase the only record that a subscription could // not be routed to a product. for bare in [ MnwEventName::SubscriptionUpdated(P::Undetermined), MnwEventName::SubscriptionDeleted(P::Undetermined), MnwEventName::InvoicePaymentSucceeded(P::Undetermined), ] { assert!(!bare.as_str().ends_with("_tier")); assert!(!bare.as_str().ends_with("fan_plus")); assert!(!bare.as_str().ends_with("synckit")); assert!(!bare.as_str().ends_with("synckit_app_sub")); } } // ── Normalization ── fn normalize(type_: &str, object: serde_json::Value) -> MnwEvent { MnwEvent::normalize(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 = MnwEvent::normalize( "customer.subscription.updated", serde_json::json!({"status": "active"}), ) .unwrap_err(); let msg = format!("{err:?}"); assert!(msg.contains("Subscription"), "{msg}"); } #[test] fn subscription_mode_checkouts_do_not_wait_on_settlement() { for kind in [ CheckoutKind::FanPlus, CheckoutKind::CreatorTier, CheckoutKind::SyncKitAppSub, CheckoutKind::ProjectSubscription, ] { assert!(!kind.captures_funds_at_checkout(), "{kind:?}"); } for kind in [ CheckoutKind::Tip, CheckoutKind::Guest, CheckoutKind::Cart, CheckoutKind::Purchase, ] { assert!(kind.captures_funds_at_checkout(), "{kind:?}"); } } }