//! The MNW webhook event vocabulary: what a Stripe delivery *means* to MNW, //! and the one place its names are written down. //! //! The names the audit log writes are ratified here, and normalization happens //! in `payments/`. Handlers call `log_subscription_event` with MNW-side names //! Stripe never emits (`checkout.session.completed.tip`, //! `invoice.payment_failed.creator_tier`), so the vocabulary belongs in one //! place: as scattered string literals a typo is a silently unhandled event and //! a renamed concept drifts one call site at a time. //! //! Two types, because there are two jobs and conflating them makes the 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, }, } // ── Stripe views to MNW vocabulary ── // // The Stripe wire-name match that chooses among these lives in // [`super::webhooks`], beside the signature check, so a second provider can // bring its own. What stays here is the vocabulary and the view-to-vocabulary // conversions, which name no Stripe event type. use super::{CheckoutSessionView, InvoiceView, RefundView, SubscriptionView}; /// 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`. pub(in crate::payments) 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")); } } #[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:?}"); } } }