Skip to main content

max / makenotwork

move webhook normalization behind the payments trait `MnwEvent::normalize` was a free function in `payments/mnw_event.rs` matching Stripe's 11 wire names, so a second provider had nowhere to put its own mapping. It becomes `PaymentProvider::normalize_webhook`, a base-trait member beside the two verify members: a provider that sends webhooks must be able to say what its events mean, so it is not a capability any provider can lack. The member 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 normalize alone; the live handler composes verify then normalize. Both `verify_webhook` and `verify_webhook_v2` stay, they are the v1 and v2 paths rather than a duplicate pair. The wire-name match moves into `payments/webhooks.rs` beside the signature check, joining the `*View` structs already fenced there. `MnwEvent`, `MnwEventName` and the view-to-vocabulary conversions stay in `mnw_event.rs`: they are the vocabulary, not the Stripe mapping, and they name no event type. `normalize_event` is public for the integration harness's mock provider, which drives real Stripe payloads and so needs the real mapping, the same reason `verify_signature` is public. The v2 retry branch is untouched; a thin event carries a reference rather than an object and has no normalize step by construction. 47 webhook workflow tests, 38 payment workflow tests and 163 payments lib tests pass unchanged.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01DwpiantpUgohzML4xr6KeQ
Author: Max Johnson <me@maxj.phd> · 2026-08-31 13:55 UTC
Signed with PGP, not checked
Commit: 6cd65988367aada54309a1cd61fe408c8fbd7e51
Parent: d120512
7 files changed, +351 insertions, -297 deletions
@@ -335,114 +335,21 @@
335 335 },
336 336 }
337 337
338 - // ── Normalization ──
338 + // ── Stripe views to MNW vocabulary ──
339 + //
340 + // The Stripe wire-name match that chooses among these lives in
341 + // [`super::webhooks`], beside the signature check, so a second provider can
342 + // bring its own. What stays here is the vocabulary and the view-to-vocabulary
343 + // conversions, which name no Stripe event type.
339 344
340 - use super::{
341 - AccountView, ChargeRefundData, ChargeView, CheckoutSessionView, InvoiceView, RefundView,
342 - SubscriptionView, UntypedEvent,
343 - };
344 - use crate::error::{AppError, Result};
345 -
346 - impl MnwEvent {
347 - /// Turn a verified Stripe delivery into what MNW does about it.
348 - ///
349 - /// The one normalization the two payload-bearing entry points share: the
350 - /// live v1 handler and the retry worker re-parsing a stored payload outside
351 - /// `verify_webhook`. Before this, each grew its own
352 - /// `serde_json::from_value` calls and its own string match, which is how
353 - /// they drifted.
354 - ///
355 - /// The third entry point, the v2 thin-event path, needs no step here and
356 - /// that is not an omission: a thin event carries only a reference, so it
357 - /// fetches the object through `PaymentProvider::fetch_account`, which
358 - /// returns an [`super::AccountUpdate`] — already the normalized type. There
359 - /// is no Stripe-shaped view to strip, and it converges on the same handler.
360 - ///
361 - /// `data_object` is consumed exactly once. A parse failure is a
362 - /// `BadRequest` naming the object that would not parse, which is what the
363 - /// Stripe Dashboard shows for a failed delivery — a past incident (an API
364 - /// version mismatch producing serde `missing field` errors) was misread as
365 - /// a signature failure because the wording did not distinguish them.
366 - pub fn normalize(event_type: &str, data_object: serde_json::Value) -> Result<Self> {
367 - let parse = |what: &str, e: serde_json::Error| {
368 - AppError::BadRequest(format!("Failed to parse {what}: {e}"))
369 - };
370 -
371 - Ok(match event_type {
372 - // Both route to one place. `completed` fires immediately; for
373 - // asynchronous methods it arrives with payment_status="unpaid" and
374 - // `async_payment_succeeded` re-delivers the settled session. What a
375 - // handler needs is `settled`, not which of the two arrived.
376 - "checkout.session.completed" | "checkout.session.async_payment_succeeded" => {
377 - let view: CheckoutSessionView =
378 - serde_json::from_value(data_object).map_err(|e| parse("CheckoutSession", e))?;
379 - let kind = checkout_kind(view.metadata.as_ref());
380 - MnwEvent::Checkout {
381 - kind,
382 - session: Box::new(CheckoutCompletion::from(view)),
383 - }
384 - }
385 - "checkout.session.async_payment_failed" => {
386 - let view: CheckoutSessionView =
387 - serde_json::from_value(data_object).map_err(|e| parse("CheckoutSession", e))?;
388 - MnwEvent::CheckoutAsyncPaymentFailed {
389 - session_id: view.id,
390 - }
391 - }
392 - "account.updated" => {
393 - let view: AccountView =
394 - serde_json::from_value(data_object).map_err(|e| parse("Account", e))?;
395 - MnwEvent::AccountUpdated(Box::new(view.into()))
396 - }
397 - "charge.refunded" => {
398 - let view: ChargeView =
399 - serde_json::from_value(data_object).map_err(|e| parse("Charge", e))?;
400 - MnwEvent::ChargeRefunded(ChargeRefundData::from_view(view).map(Box::new))
401 - }
402 - "refund.created" | "refund.updated" => {
403 - let view: RefundView =
404 - serde_json::from_value(data_object).map_err(|e| parse("Refund", e))?;
405 - MnwEvent::RefundSettled(Box::new(RefundOutcome::from(view)))
406 - }
407 - "customer.subscription.updated" => {
408 - let view: SubscriptionView =
409 - serde_json::from_value(data_object).map_err(|e| parse("Subscription", e))?;
410 - MnwEvent::SubscriptionUpdated(Box::new(SubscriptionLifecycle::from(view)))
411 - }
412 - "customer.subscription.deleted" => {
413 - let view: SubscriptionView =
414 - serde_json::from_value(data_object).map_err(|e| parse("Subscription", e))?;
415 - MnwEvent::SubscriptionDeleted(Box::new(SubscriptionLifecycle::from(view)))
416 - }
417 - "invoice.payment_succeeded" => {
418 - let view: InvoiceView =
419 - serde_json::from_value(data_object).map_err(|e| parse("Invoice", e))?;
420 - MnwEvent::InvoicePaymentSucceeded(Box::new(InvoiceOutcome::from(view)))
421 - }
422 - "invoice.payment_failed" => {
423 - let view: InvoiceView =
424 - serde_json::from_value(data_object).map_err(|e| parse("Invoice", e))?;
425 - MnwEvent::InvoicePaymentFailed(Box::new(InvoiceOutcome::from(view)))
426 - }
427 - other => MnwEvent::Unhandled {
428 - stripe_type: other.to_string(),
429 - },
430 - })
431 - }
432 -
433 - /// Normalize a whole verified envelope, discarding the id and type the
434 - /// caller has already taken for the dedup and retry-queue paths.
435 - pub fn from_untyped(event: UntypedEvent) -> Result<Self> {
436 - Self::normalize(&event.type_, event.data_object)
437 - }
438 - }
345 + use super::{CheckoutSessionView, InvoiceView, RefundView, SubscriptionView};
439 346
440 347 /// Which checkout a session is, from the metadata MNW wrote at creation.
441 348 ///
442 349 /// The fall-through is [`CheckoutKind::Purchase`] rather than an error: a
443 350 /// single item purchase is the shape with no distinguishing `checkout_type`,
444 351 /// and that has always been the dispatcher's final `else`.
445 - fn checkout_kind(meta: Option<&HashMap<String, String>>) -> CheckoutKind {
352 + pub(in crate::payments) fn checkout_kind(meta: Option<&HashMap<String, String>>) -> CheckoutKind {
446 353 use super::{
447 354 is_cart_checkout, is_creator_tier_checkout, is_fan_plus_checkout, is_guest_checkout,
448 355 is_subscription_checkout, is_synckit_app_sub_checkout, is_tip_checkout,
@@ -656,182 +563,6 @@
656 563 }
657 564 }
658 565
659 - // ── Normalization ──
660 -
661 - fn normalize(type_: &str, object: serde_json::Value) -> MnwEvent {
662 - MnwEvent::normalize(type_, object).expect("payload should normalize")
663 - }
664 -
665 - #[test]
666 - fn a_checkout_kind_comes_from_the_metadata_mnw_wrote() {
667 - let event = normalize(
668 - "checkout.session.completed",
669 - serde_json::json!({
670 - "id": "cs_1",
671 - "metadata": {"checkout_type": "tip"},
672 - "payment_status": "paid",
673 - }),
674 - );
675 - let MnwEvent::Checkout { kind, session } = event else {
676 - panic!("expected a checkout");
677 - };
678 - assert_eq!(kind, CheckoutKind::Tip);
679 - assert_eq!(session.session_id, "cs_1");
680 - assert!(session.settled);
681 - }
682 -
683 - #[test]
684 - fn a_session_with_no_checkout_type_is_a_purchase() {
685 - // The dispatcher's final `else` for as long as it has existed: a single
686 - // item purchase is the shape with no distinguishing metadata.
687 - let event = normalize(
688 - "checkout.session.completed",
689 - serde_json::json!({"id": "cs_1", "metadata": {}}),
690 - );
691 - let MnwEvent::Checkout { kind, .. } = event else {
692 - panic!("expected a checkout");
693 - };
694 - assert_eq!(kind, CheckoutKind::Purchase);
695 - }
696 -
697 - #[test]
698 - fn an_unpaid_session_is_not_settled_and_an_absent_status_is() {
699 - // The absent case preserves behaviour for legacy events that predate
700 - // `payment_status`; only an explicit "unpaid" is withheld.
701 - let unpaid = normalize(
702 - "checkout.session.completed",
703 - serde_json::json!({"id": "cs_1", "payment_status": "unpaid"}),
704 - );
705 - let MnwEvent::Checkout { session, .. } = unpaid else {
706 - panic!("expected a checkout")
707 - };
708 - assert!(!session.settled);
709 -
710 - let legacy = normalize(
711 - "checkout.session.completed",
712 - serde_json::json!({"id": "cs_2"}),
713 - );
714 - let MnwEvent::Checkout { session, .. } = legacy else {
715 - panic!("expected a checkout")
716 - };
717 - assert!(session.settled);
718 - }
719 -
720 - #[test]
721 - fn async_payment_succeeded_normalizes_to_the_same_checkout_as_completed() {
722 - // A handler cares about `settled`, not which of the two arrived.
723 - for type_ in [
724 - "checkout.session.completed",
725 - "checkout.session.async_payment_succeeded",
726 - ] {
727 - let event = normalize(
728 - type_,
729 - serde_json::json!({
730 - "id": "cs_1",
731 - "metadata": {"checkout_type": "cart"},
732 - "payment_status": "paid",
733 - }),
734 - );
735 - assert!(
736 - matches!(
737 - event,
738 - MnwEvent::Checkout {
739 - kind: CheckoutKind::Cart,
740 - ..
741 - }
742 - ),
743 - "{type_} should be a settled cart checkout"
744 - );
745 - }
746 - }
747 -
748 - #[test]
749 - fn an_invoice_resolves_its_subscription_from_either_field_path() {
750 - // rc.5 moved the subscription id under parent.subscription_details; a
751 - // handler should never have to know which shape arrived.
752 - let legacy = normalize(
753 - "invoice.payment_succeeded",
754 - serde_json::json!({"subscription": "sub_1", "billing_reason": "subscription_cycle"}),
755 - );
756 - let MnwEvent::InvoicePaymentSucceeded(invoice) = legacy else {
757 - panic!("expected an invoice")
758 - };
759 - assert_eq!(invoice.subscription_id.as_deref(), Some("sub_1"));
760 - assert!(invoice.is_renewal);
761 -
762 - let rc5 = normalize(
763 - "invoice.payment_failed",
764 - serde_json::json!({
765 - "parent": {"subscription_details": {"subscription": "sub_2"}},
766 - "billing_reason": "subscription_create",
767 - }),
768 - );
769 - let MnwEvent::InvoicePaymentFailed(invoice) = rc5 else {
770 - panic!("expected an invoice")
771 - };
772 - assert_eq!(invoice.subscription_id.as_deref(), Some("sub_2"));
773 - assert!(!invoice.is_renewal);
774 - }
775 -
776 - #[test]
777 - fn a_subscription_keeps_stripes_status_string_unparsed() {
778 - // Parsing here would have to choose between erroring on a status Stripe
779 - // added and inventing a member; both are worse than letting the handler
780 - // treat an unknown status as a no-op.
781 - let event = normalize(
782 - "customer.subscription.updated",
783 - serde_json::json!({
784 - "id": "sub_1",
785 - "status": "paused",
786 - "cancel_at_period_end": true,
787 - "items": {"data": [{"current_period_start": 1, "current_period_end": 2}]},
788 - }),
789 - );
790 - let MnwEvent::SubscriptionUpdated(sub) = event else {
791 - panic!("expected a subscription update")
792 - };
793 - assert_eq!(sub.status, "paused");
794 - assert!(sub.cancel_at_period_end);
795 - assert_eq!(sub.current_period, Some((1, 2)));
796 - }
797 -
798 - #[test]
799 - fn a_charge_with_no_payment_intent_normalizes_to_nothing_to_do() {
800 - // Out of scope rather than an error: there is no payment to refund
801 - // against, which is what `ChargeRefundData::from_view` has always said.
802 - let event = normalize(
803 - "charge.refunded",
804 - serde_json::json!({"amount": 100, "amount_refunded": 100}),
805 - );
806 - assert!(matches!(event, MnwEvent::ChargeRefunded(None)));
807 - }
808 -
809 - #[test]
810 - fn an_unhandled_type_is_a_member_not_a_fallthrough() {
811 - // The whole reason dispatch matches on an enum: a type MNW does not act
812 - // on is representable, so a misspelt arm cannot silently swallow one.
813 - let event = normalize("payment_intent.succeeded", serde_json::json!({}));
814 - let MnwEvent::Unhandled { stripe_type } = event else {
815 - panic!("expected an unhandled event")
816 - };
817 - assert_eq!(stripe_type, "payment_intent.succeeded");
818 - }
819 -
820 - #[test]
821 - fn a_payload_that_will_not_parse_names_the_object() {
822 - // The Stripe Dashboard shows this body for a failed delivery, and a past
823 - // incident (an API version mismatch producing serde `missing field`
824 - // errors) was misread as a signature failure because the wording did not
825 - // distinguish them.
826 - let err = MnwEvent::normalize(
827 - "customer.subscription.updated",
828 - serde_json::json!({"status": "active"}),
829 - )
830 - .unwrap_err();
831 - let msg = format!("{err:?}");
832 - assert!(msg.contains("Subscription"), "{msg}");
833 - }
834 -
835 566 #[test]
836 567 fn subscription_mode_checkouts_do_not_wait_on_settlement() {
837 568 for kind in [
@@ -253,6 +253,17 @@
253 253 payload: &str,
254 254 signature: &str,
255 255 ) -> crate::error::Result<serde_json::Value>;
256 + /// Turn a verified envelope into the MNW vocabulary.
257 + ///
258 + /// Base trait rather than an extension: a provider that sends webhooks must
259 + /// be able to say what its events mean, so this is not a capability any
260 + /// provider can lack.
261 + ///
262 + /// Takes the whole envelope rather than `(type, object)` because the retry
263 + /// worker calls it alone — a stored payload was verified once already and
264 + /// has no signature to re-check, so it has no `verify_webhook` result to
265 + /// destructure. The live path composes the two.
266 + fn normalize_webhook(&self, event: UntypedEvent) -> crate::error::Result<MnwEvent>;
256 267
257 268 // SyncKit subscription re-pricing and cancellation. Creating the customer
258 269 // and the subscription needs [`CustodialCustomers`]; changing the price of
@@ -638,6 +649,7 @@
638 649 create_platform_credit_reversal,
639 650 verify_webhook,
640 651 verify_webhook_v2,
652 + normalize_webhook,
641 653 create_synckit_customer,
642 654 create_synckit_subscription,
643 655 update_synckit_subscription_price,
@@ -774,6 +786,9 @@
774 786 ) -> crate::error::Result<serde_json::Value> {
775 787 ScriptedProvider::verify_webhook_v2(self)
776 788 }
789 + fn normalize_webhook(&self, _event: UntypedEvent) -> crate::error::Result<MnwEvent> {
790 + ScriptedProvider::normalize_webhook(self)
791 + }
777 792 async fn update_synckit_subscription_price(
778 793 &self,
779 794 _subscription_id: &str,
@@ -1102,6 +1117,10 @@
1102 1117 StripeClient::verify_webhook_v2(self, payload, signature)
1103 1118 }
1104 1119
1120 + fn normalize_webhook(&self, event: UntypedEvent) -> crate::error::Result<MnwEvent> {
1121 + StripeClient::normalize_webhook(self, event)
1122 + }
1123 +
1105 1124 async fn update_synckit_subscription_price(
1106 1125 &self,
1107 1126 subscription_id: &str,
@@ -1,5 +1,9 @@
1 - //! Webhook signature verification and the Stripe-shaped structs we read a
2 - //! payload into.
1 + //! Webhook signature verification, the Stripe-shaped structs we read a payload
2 + //! into, and the wire-name match that turns one into an [`MnwEvent`].
3 + //!
4 + //! Everything Stripe-specific about an inbound webhook stops at this module.
5 + //! A second provider implements [`super::PaymentProvider::normalize_webhook`]
6 + //! with its own mapping and never sees these names or these views.
3 7 //!
4 8 //! rc.5 ships no webhook helper, so we keep the local HMAC `verify_signature`
5 9 //! and a thin [`UntypedEvent`] envelope.
@@ -16,7 +20,11 @@
16 20 use hmac::{Hmac, KeyInit, Mac};
17 21 use sha2::Sha256;
18 22
19 - use super::StripeClient;
23 + use super::mnw_event::checkout_kind;
24 + use super::{
25 + CheckoutCompletion, InvoiceOutcome, MnwEvent, RefundOutcome, StripeClient,
26 + SubscriptionLifecycle,
27 + };
20 28 use crate::db::Cents;
21 29 use crate::error::{AppError, Result};
22 30
@@ -137,6 +145,113 @@
137 145 AppError::BadRequest(format!("Webhook payload JSON parse failed: {e}"))
138 146 })
139 147 }
148 +
149 + /// Normalize a verified envelope into the MNW vocabulary.
150 + ///
151 + /// Takes the whole envelope rather than `(type, object)` because that is
152 + /// what the retry worker can produce: it re-parses a stored payload that
153 + /// was verified once already and has no signature to re-check, so it calls
154 + /// this without `verify_webhook` in front of it. The live handler composes
155 + /// the two, verify then normalize.
156 + ///
157 + /// The Stripe wire names live in [`normalize_event`] below, which is the
158 + /// point of the member: a second provider brings its own mapping and no
159 + /// Stripe event-name literal escapes this module.
160 + #[tracing::instrument(skip_all, name = "payments::normalize_webhook")]
161 + pub fn normalize_webhook(&self, event: UntypedEvent) -> Result<MnwEvent> {
162 + normalize_event(&event.type_, event.data_object)
163 + }
164 + }
165 +
166 + /// Turn a verified Stripe delivery into what MNW does about it.
167 + ///
168 + /// The one normalization the two payload-bearing entry points share: the
169 + /// live v1 handler and the retry worker re-parsing a stored payload outside
170 + /// `verify_webhook`. Before this, each grew its own
171 + /// `serde_json::from_value` calls and its own string match, which is how
172 + /// they drifted.
173 + ///
174 + /// The third entry point, the v2 thin-event path, needs no step here and
175 + /// that is not an omission: a thin event carries only a reference, so it
176 + /// fetches the object through `PaymentProvider::fetch_account`, which
177 + /// returns an [`super::AccountUpdate`] — already the normalized type. There
178 + /// is no Stripe-shaped view to strip, and it converges on the same handler.
179 + ///
180 + /// `data_object` is consumed exactly once. A parse failure is a
181 + /// `BadRequest` naming the object that would not parse, which is what the
182 + /// Stripe Dashboard shows for a failed delivery — a past incident (an API
183 + /// version mismatch producing serde `missing field` errors) was misread as
184 + /// a signature failure because the wording did not distinguish them.
185 + ///
186 + /// Public for the integration harness's `MockPaymentProvider`, which drives
187 + /// real Stripe-shaped payloads through the webhook route and so needs the real
188 + /// mapping. Same reason [`verify_signature`] is public. Exposing the entry
189 + /// point does not move the wire names, which stay in this module.
190 + pub fn normalize_event(event_type: &str, data_object: serde_json::Value) -> Result<MnwEvent> {
191 + let parse = |what: &str, e: serde_json::Error| {
192 + AppError::BadRequest(format!("Failed to parse {what}: {e}"))
193 + };
194 +
195 + Ok(match event_type {
196 + // Both route to one place. `completed` fires immediately; for
197 + // asynchronous methods it arrives with payment_status="unpaid" and
198 + // `async_payment_succeeded` re-delivers the settled session. What a
199 + // handler needs is `settled`, not which of the two arrived.
200 + "checkout.session.completed" | "checkout.session.async_payment_succeeded" => {
201 + let view: CheckoutSessionView =
202 + serde_json::from_value(data_object).map_err(|e| parse("CheckoutSession", e))?;
203 + let kind = checkout_kind(view.metadata.as_ref());
204 + MnwEvent::Checkout {
205 + kind,
206 + session: Box::new(CheckoutCompletion::from(view)),
207 + }
208 + }
209 + "checkout.session.async_payment_failed" => {
210 + let view: CheckoutSessionView =
211 + serde_json::from_value(data_object).map_err(|e| parse("CheckoutSession", e))?;
212 + MnwEvent::CheckoutAsyncPaymentFailed {
213 + session_id: view.id,
214 + }
215 + }
216 + "account.updated" => {
217 + let view: AccountView =
218 + serde_json::from_value(data_object).map_err(|e| parse("Account", e))?;
219 + MnwEvent::AccountUpdated(Box::new(view.into()))
220 + }
221 + "charge.refunded" => {
222 + let view: ChargeView =
223 + serde_json::from_value(data_object).map_err(|e| parse("Charge", e))?;
224 + MnwEvent::ChargeRefunded(ChargeRefundData::from_view(view).map(Box::new))
225 + }
226 + "refund.created" | "refund.updated" => {
227 + let view: RefundView =
228 + serde_json::from_value(data_object).map_err(|e| parse("Refund", e))?;
229 + MnwEvent::RefundSettled(Box::new(RefundOutcome::from(view)))
230 + }
231 + "customer.subscription.updated" => {
232 + let view: SubscriptionView =
233 + serde_json::from_value(data_object).map_err(|e| parse("Subscription", e))?;
234 + MnwEvent::SubscriptionUpdated(Box::new(SubscriptionLifecycle::from(view)))
235 + }
236 + "customer.subscription.deleted" => {
237 + let view: SubscriptionView =
238 + serde_json::from_value(data_object).map_err(|e| parse("Subscription", e))?;
239 + MnwEvent::SubscriptionDeleted(Box::new(SubscriptionLifecycle::from(view)))
240 + }
241 + "invoice.payment_succeeded" => {
242 + let view: InvoiceView =
243 + serde_json::from_value(data_object).map_err(|e| parse("Invoice", e))?;
244 + MnwEvent::InvoicePaymentSucceeded(Box::new(InvoiceOutcome::from(view)))
245 + }
246 + "invoice.payment_failed" => {
247 + let view: InvoiceView =
248 + serde_json::from_value(data_object).map_err(|e| parse("Invoice", e))?;
249 + MnwEvent::InvoicePaymentFailed(Box::new(InvoiceOutcome::from(view)))
250 + }
251 + other => MnwEvent::Unhandled {
252 + stripe_type: other.to_string(),
253 + },
254 + })
140 255 }
141 256
142 257 /// Narrow view of a CheckoutSession: only the fields any handler reads.
@@ -574,6 +689,7 @@
574 689 #[cfg(test)]
575 690 mod tests {
576 691 use super::*;
692 + use crate::payments::CheckoutKind;
577 693 use serde_json::json;
578 694
579 695 #[test]
@@ -1031,4 +1147,180 @@
1031 1147 );
1032 1148 assert_eq!(settlement_currency_of("acct_3", None), None);
1033 1149 }
1150 +
1151 + // ── Normalization ──
1152 +
1153 + fn normalize(type_: &str, object: serde_json::Value) -> MnwEvent {
1154 + normalize_event(type_, object).expect("payload should normalize")
1155 + }
1156 +
1157 + #[test]
1158 + fn a_checkout_kind_comes_from_the_metadata_mnw_wrote() {
1159 + let event = normalize(
1160 + "checkout.session.completed",
1161 + serde_json::json!({
1162 + "id": "cs_1",
1163 + "metadata": {"checkout_type": "tip"},
1164 + "payment_status": "paid",
1165 + }),
1166 + );
1167 + let MnwEvent::Checkout { kind, session } = event else {
1168 + panic!("expected a checkout");
1169 + };
1170 + assert_eq!(kind, CheckoutKind::Tip);
1171 + assert_eq!(session.session_id, "cs_1");
1172 + assert!(session.settled);
1173 + }
1174 +
1175 + #[test]
1176 + fn a_session_with_no_checkout_type_is_a_purchase() {
1177 + // The dispatcher's final `else` for as long as it has existed: a single
1178 + // item purchase is the shape with no distinguishing metadata.
1179 + let event = normalize(
1180 + "checkout.session.completed",
1181 + serde_json::json!({"id": "cs_1", "metadata": {}}),
1182 + );
1183 + let MnwEvent::Checkout { kind, .. } = event else {
1184 + panic!("expected a checkout");
1185 + };
1186 + assert_eq!(kind, CheckoutKind::Purchase);
1187 + }
1188 +
1189 + #[test]
1190 + fn an_unpaid_session_is_not_settled_and_an_absent_status_is() {
1191 + // The absent case preserves behaviour for legacy events that predate
1192 + // `payment_status`; only an explicit "unpaid" is withheld.
1193 + let unpaid = normalize(
1194 + "checkout.session.completed",
1195 + serde_json::json!({"id": "cs_1", "payment_status": "unpaid"}),
1196 + );
1197 + let MnwEvent::Checkout { session, .. } = unpaid else {
1198 + panic!("expected a checkout")
1199 + };
1200 + assert!(!session.settled);
1201 +
1202 + let legacy = normalize(
1203 + "checkout.session.completed",
1204 + serde_json::json!({"id": "cs_2"}),
1205 + );
1206 + let MnwEvent::Checkout { session, .. } = legacy else {
1207 + panic!("expected a checkout")
1208 + };
1209 + assert!(session.settled);
1210 + }
1211 +
1212 + #[test]
1213 + fn async_payment_succeeded_normalizes_to_the_same_checkout_as_completed() {
1214 + // A handler cares about `settled`, not which of the two arrived.
1215 + for type_ in [
1216 + "checkout.session.completed",
1217 + "checkout.session.async_payment_succeeded",
1218 + ] {
1219 + let event = normalize(
1220 + type_,
1221 + serde_json::json!({
1222 + "id": "cs_1",
1223 + "metadata": {"checkout_type": "cart"},
1224 + "payment_status": "paid",
1225 + }),
1226 + );
1227 + assert!(
1228 + matches!(
1229 + event,
1230 + MnwEvent::Checkout {
1231 + kind: CheckoutKind::Cart,
1232 + ..
1233 + }
1234 + ),
1235 + "{type_} should be a settled cart checkout"
1236 + );
1237 + }
1238 + }
1239 +
1240 + #[test]
1241 + fn an_invoice_resolves_its_subscription_from_either_field_path() {
1242 + // rc.5 moved the subscription id under parent.subscription_details; a
1243 + // handler should never have to know which shape arrived.
1244 + let legacy = normalize(
1245 + "invoice.payment_succeeded",
1246 + serde_json::json!({"subscription": "sub_1", "billing_reason": "subscription_cycle"}),
1247 + );
1248 + let MnwEvent::InvoicePaymentSucceeded(invoice) = legacy else {
1249 + panic!("expected an invoice")
1250 + };
1251 + assert_eq!(invoice.subscription_id.as_deref(), Some("sub_1"));
1252 + assert!(invoice.is_renewal);
1253 +
1254 + let rc5 = normalize(
1255 + "invoice.payment_failed",
1256 + serde_json::json!({
1257 + "parent": {"subscription_details": {"subscription": "sub_2"}},
1258 + "billing_reason": "subscription_create",
1259 + }),
1260 + );
1261 + let MnwEvent::InvoicePaymentFailed(invoice) = rc5 else {
1262 + panic!("expected an invoice")
1263 + };
1264 + assert_eq!(invoice.subscription_id.as_deref(), Some("sub_2"));
1265 + assert!(!invoice.is_renewal);
1266 + }
1267 +
1268 + #[test]
1269 + fn a_subscription_keeps_stripes_status_string_unparsed() {
1270 + // Parsing here would have to choose between erroring on a status Stripe
1271 + // added and inventing a member; both are worse than letting the handler
1272 + // treat an unknown status as a no-op.
1273 + let event = normalize(
1274 + "customer.subscription.updated",
1275 + serde_json::json!({
1276 + "id": "sub_1",
1277 + "status": "paused",
1278 + "cancel_at_period_end": true,
1279 + "items": {"data": [{"current_period_start": 1, "current_period_end": 2}]},
1280 + }),
1281 + );
1282 + let MnwEvent::SubscriptionUpdated(sub) = event else {
1283 + panic!("expected a subscription update")
1284 + };
1285 + assert_eq!(sub.status, "paused");
1286 + assert!(sub.cancel_at_period_end);
1287 + assert_eq!(sub.current_period, Some((1, 2)));
1288 + }
1289 +
1290 + #[test]
1291 + fn a_charge_with_no_payment_intent_normalizes_to_nothing_to_do() {
1292 + // Out of scope rather than an error: there is no payment to refund
1293 + // against, which is what `ChargeRefundData::from_view` has always said.
1294 + let event = normalize(
1295 + "charge.refunded",
1296 + serde_json::json!({"amount": 100, "amount_refunded": 100}),
1297 + );
1298 + assert!(matches!(event, MnwEvent::ChargeRefunded(None)));
1299 + }
1300 +
1301 + #[test]
1302 + fn an_unhandled_type_is_a_member_not_a_fallthrough() {
1303 + // The whole reason dispatch matches on an enum: a type MNW does not act
1304 + // on is representable, so a misspelt arm cannot silently swallow one.
1305 + let event = normalize("payment_intent.succeeded", serde_json::json!({}));
1306 + let MnwEvent::Unhandled { stripe_type } = event else {
1307 + panic!("expected an unhandled event")
1308 + };
1309 + assert_eq!(stripe_type, "payment_intent.succeeded");
1310 + }
1311 +
1312 + #[test]
1313 + fn a_payload_that_will_not_parse_names_the_object() {
1314 + // The Stripe Dashboard shows this body for a failed delivery, and a past
1315 + // incident (an API version mismatch producing serde `missing field`
1316 + // errors) was misread as a signature failure because the wording did not
1317 + // distinguish them.
1318 + let err = normalize_event(
1319 + "customer.subscription.updated",
1320 + serde_json::json!({"status": "active"}),
1321 + )
1322 + .unwrap_err();
1323 + let msg = format!("{err:?}");
1324 + assert!(msg.contains("Subscription"), "{msg}");
1325 + }
1034 1326 }
@@ -24,9 +24,11 @@
24 24 }
25 25 };
26 26
27 - if state.payments.is_none() {
27 + // The provider is what normalizes a stored payload, so no provider means no
28 + // retry to run. Bound here rather than re-checked per event.
29 + let Some(provider) = state.payments.as_ref() else {
28 30 return;
29 - }
31 + };
30 32
31 33 for event in events {
32 34 let attempt = event.attempts + 1;
@@ -93,13 +95,13 @@
93 95 // (use ON CONFLICT / WHERE status='pending' guards) since steps completed
94 96 // before the original failure are not rolled back.
95 97 let result = if event.source == "stripe" {
96 - // Same normalization the live handler runs, which is the reason
97 - // it lives in `payments/`: this path re-parses a stored payload
98 - // outside `verify_webhook`, and before the shared `MnwEvent` step
99 - // it had its own idea of how a payload became a dispatch.
98 + // Same normalization the live handler runs, through the same trait
99 + // member: this path re-parses a stored payload outside
100 + // `verify_webhook`, and before the shared `MnwEvent` step it had its
101 + // own idea of how a payload became a dispatch.
100 102 match crate::payments::UntypedEvent::from_payload(&event.payload).and_then(|parsed| {
101 103 let id = parsed.id.clone();
102 - crate::payments::MnwEvent::from_untyped(parsed).map(|e| (id, e))
104 + provider.normalize_webhook(parsed).map(|e| (id, e))
103 105 }) {
104 106 Ok((id, mnw_event)) => {
105 107 crate::routes::stripe::process_webhook_event(
@@ -280,6 +280,16 @@
280 280 makenotwork::payments::UntypedEvent::from_payload(payload)
281 281 }
282 282
283 + fn normalize_webhook(
284 + &self,
285 + event: makenotwork::payments::UntypedEvent,
286 + ) -> Result<makenotwork::payments::MnwEvent> {
287 + self.faults.check("normalize_webhook")?;
288 + // The mock stands in for Stripe, so it maps Stripe's wire names. Same
289 + // reason `verify_webhook` above reuses the real `verify_signature`.
290 + makenotwork::payments::normalize_event(&event.type_, event.data_object)
291 + }
292 +
283 293 fn verify_webhook_v2(&self, payload: &str, signature: &str) -> Result<serde_json::Value> {
284 294 self.faults.check("verify_webhook_v2")?;
285 295 makenotwork::payments::verify_signature(payload, signature, TEST_WEBHOOK_SECRET_V2)
@@ -146,7 +146,7 @@
146 146 /// the scheduler retry worker (which re-parses the stored payload, the
147 147 /// signature was already verified when the event was first received).
148 148 ///
149 - /// There is no `MnwEvent::normalize` step here, unlike the v1 paths. A thin
149 + /// There is no `normalize_webhook` step here, unlike the v1 paths. A thin
150 150 /// event carries a reference rather than an object, so the account is fetched
151 151 /// through `PaymentProvider::fetch_account`, which hands back an `AccountUpdate`
152 152 /// — the normalized MNW type, with no Stripe-shaped view in between. Both paths
@@ -19,7 +19,7 @@
19 19 db,
20 20 email::EmailClient,
21 21 error::{AppError, Result, ResultExt},
22 - payments::{AccountUpdate, CheckoutCompletion, CheckoutKind, MnwEvent, UntypedEvent},
22 + payments::{AccountUpdate, CheckoutCompletion, CheckoutKind, MnwEvent},
23 23 wam_client::WamClient,
24 24 };
25 25
@@ -100,17 +100,17 @@
100 100 }
101 101
102 102 // For retry-queue persistence we need id+type after `event` is consumed.
103 - // Move both out without cloning the underlying allocations.
104 - let UntypedEvent {
105 - id: event_id,
106 - type_: event_type_str,
107 - data_object,
108 - } = event;
103 + // `normalize_webhook` takes the whole envelope (the shape the retry worker
104 + // can produce), so these are two short String clones against a path already
105 + // several DB round trips deep.
106 + let event_id = event.id.clone();
107 + let event_type_str = event.type_.clone();
109 108 // Normalize before dispatch, so what follows reasons about an MNW event
110 - // rather than a Stripe event-name string. A payload that will not parse
109 + // rather than a Stripe event-name string. Through the provider, so the
110 + // wire names stay in the Stripe implementor. A payload that will not parse
111 111 // fails here, with the same wording and the same retry-queue treatment it
112 112 // had when each match arm parsed for itself.
113 - let result = match MnwEvent::normalize(&event_type_str, data_object) {
113 + let result = match stripe.normalize_webhook(event) {
114 114 Ok(mnw_event) => {
115 115 process_webhook_event(
116 116 &db,