Skip to main content

max / makenotwork

34.6 KB · 856 lines History Blame Raw
1 //! The MNW webhook event vocabulary: what a Stripe delivery *means* to MNW,
2 //! and the one place its names are written down.
3 //!
4 //! Ruled 2026-08-25 (mnw-server `9e45feec`): ratify the names the audit log
5 //! already writes, and normalize in `payments/`. Every handler was already
6 //! calling `log_subscription_event` with an MNW-side name Stripe never emits —
7 //! `checkout.session.completed.tip`, `invoice.payment_failed.creator_tier` —
8 //! so the vocabulary existed and was persisted; it just lived as 26 string
9 //! literals scattered across three files, where a typo was a silently
10 //! unhandled event and a renamed concept drifted one call site at a time.
11 //!
12 //! Two types, because there are two jobs and conflating them is what made the
13 //! old dispatch stringly typed:
14 //!
15 //! - [`MnwEvent`] is what a delivery becomes. Normalization happens here in
16 //! `payments/`, so the live v1 handler, the retry worker and the v2 thin-event
17 //! path all inherit one, and the Stripe-shaped `*View` structs stop crossing
18 //! out to the handlers. Dispatch matches on this.
19 //! - [`MnwEventName`] is the audit-log vocabulary: one member per name in
20 //! `subscription_events`, and the only place those strings are spelled.
21 //!
22 //! They are not one enum because the product suffix is not knowable at
23 //! normalization time for half of them. See [`SubscriptionProduct`].
24
25 use std::collections::HashMap;
26
27 // ── The audit-log vocabulary ──
28
29 /// Which product a subscription-shaped delivery turned out to concern.
30 ///
31 /// Stripe does not say. `customer.subscription.updated` carries a subscription
32 /// id and nothing else; which of our five products it belongs to is settled by
33 /// looking that id up in four different tables, in order. That is a database
34 /// fact, so it is the handler's to establish, not the normalizer's — putting
35 /// those lookups in `payments/` would move product routing into the payment
36 /// provider layer and make the handlers redo the work anyway.
37 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
38 pub enum SubscriptionProduct {
39 /// A SyncKit developer subscription (`sync_apps`).
40 SyncKit,
41 /// An end-user subscription to a SyncKit app (`app_sync_subscriptions`).
42 SyncKitAppSub,
43 /// Fan+ (`fan_plus_subscriptions`).
44 FanPlus,
45 /// A creator tier (`creator_subscriptions`).
46 CreatorTier,
47 /// No product table claimed the subscription id.
48 ///
49 /// **This is an answer, not a missing one**, and it is the explicit member
50 /// the ruling asked for rather than a silent collapse into a sibling. It is
51 /// what the four bare names in the log have always meant: the handler fell
52 /// through every lookup and wrote the generic `subscriptions` row. Keeping
53 /// it distinct is the point — a Fan+ renewal and a renewal for a
54 /// subscription we cannot place are different events, and merging them
55 /// would erase the only signal that something is unrouted.
56 ///
57 /// The alternative the ruling offered (resolve the product during
58 /// normalization and make this unrepresentable) was rejected: it is the DB
59 /// lookups above, and they do not belong in `payments/`.
60 Undetermined,
61 }
62
63 /// Which checkout a completed session was.
64 ///
65 /// Unlike [`SubscriptionProduct`] this *is* knowable at normalization time: the
66 /// answer is in the session's own metadata, which `payments/` already owns the
67 /// vocabulary for (`is_tip_checkout` and friends). So there is no
68 /// "undetermined" member here — a session that matches no specific shape is a
69 /// [`CheckoutKind::Purchase`], which is what the dispatcher's final `else`
70 /// branch has always meant.
71 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
72 pub enum CheckoutKind {
73 FanPlus,
74 CreatorTier,
75 SyncKitAppSub,
76 /// A subscription to a creator's project tier.
77 ProjectSubscription,
78 Tip,
79 Guest,
80 Cart,
81 /// A single item purchase: the shape with no distinguishing metadata.
82 Purchase,
83 }
84
85 impl CheckoutKind {
86 /// True for the subscription-mode checkouts, which capture no funds at
87 /// checkout and so are not gated on settlement.
88 ///
89 /// The gate this feeds is load-bearing: without it, enabling an async
90 /// payment method (ACH, SEPA, Bacs) on a connected account would mint
91 /// license keys and grant downloads before the money settles.
92 pub fn captures_funds_at_checkout(self) -> bool {
93 match self {
94 CheckoutKind::FanPlus
95 | CheckoutKind::CreatorTier
96 | CheckoutKind::SyncKitAppSub
97 | CheckoutKind::ProjectSubscription => false,
98 CheckoutKind::Tip
99 | CheckoutKind::Guest
100 | CheckoutKind::Cart
101 | CheckoutKind::Purchase => true,
102 }
103 }
104 }
105
106 /// Every name written to `subscription_events.event_type`.
107 ///
108 /// One member per name already in the table, verbatim: renaming any of them
109 /// would cost a migration for historical rows and buy nothing, since the names
110 /// are already business-meaningful rather than Stripe-shaped. Nothing reads
111 /// that table today (the only reference is the `INSERT`), which is exactly why
112 /// a rename gets more expensive every day and was declined while it was still
113 /// free.
114 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
115 pub enum MnwEventName {
116 CheckoutCompletedCart,
117 CheckoutCompletedCreatorTier,
118 CheckoutCompletedFanPlus,
119 CheckoutCompletedPurchase,
120 CheckoutCompletedSubscription,
121 CheckoutCompletedTip,
122 SubscriptionUpdated(SubscriptionProduct),
123 SubscriptionDeleted(SubscriptionProduct),
124 InvoicePaymentSucceeded(SubscriptionProduct),
125 InvoicePaymentFailed(SubscriptionProduct),
126 }
127
128 impl MnwEventName {
129 /// The string as it is persisted. The single home for these 25 literals.
130 ///
131 /// `invoice.payment_failed` has no `.synckit_app_sub` spelling because no
132 /// handler ever wrote one: an end-user app subscription's failed invoice
133 /// falls through to the generic path. Spelling it here would invent a name
134 /// the table has never held, so the arm maps to the bare form and says so.
135 pub fn as_str(self) -> &'static str {
136 use SubscriptionProduct as P;
137 match self {
138 MnwEventName::CheckoutCompletedCart => "checkout.session.completed.cart",
139 MnwEventName::CheckoutCompletedCreatorTier => "checkout.session.completed.creator_tier",
140 MnwEventName::CheckoutCompletedFanPlus => "checkout.session.completed.fan_plus",
141 MnwEventName::CheckoutCompletedPurchase => "checkout.session.completed.purchase",
142 MnwEventName::CheckoutCompletedSubscription => {
143 "checkout.session.completed.subscription"
144 }
145 MnwEventName::CheckoutCompletedTip => "checkout.session.completed.tip",
146
147 MnwEventName::SubscriptionUpdated(P::SyncKit) => {
148 "customer.subscription.updated.synckit"
149 }
150 MnwEventName::SubscriptionUpdated(P::SyncKitAppSub) => {
151 "customer.subscription.updated.synckit_app_sub"
152 }
153 MnwEventName::SubscriptionUpdated(P::FanPlus) => {
154 "customer.subscription.updated.fan_plus"
155 }
156 MnwEventName::SubscriptionUpdated(P::CreatorTier) => {
157 "customer.subscription.updated.creator_tier"
158 }
159 MnwEventName::SubscriptionUpdated(P::Undetermined) => "customer.subscription.updated",
160
161 MnwEventName::SubscriptionDeleted(P::SyncKit) => {
162 "customer.subscription.deleted.synckit"
163 }
164 MnwEventName::SubscriptionDeleted(P::SyncKitAppSub) => {
165 "customer.subscription.deleted.synckit_app_sub"
166 }
167 MnwEventName::SubscriptionDeleted(P::FanPlus) => {
168 "customer.subscription.deleted.fan_plus"
169 }
170 MnwEventName::SubscriptionDeleted(P::CreatorTier) => {
171 "customer.subscription.deleted.creator_tier"
172 }
173 MnwEventName::SubscriptionDeleted(P::Undetermined) => "customer.subscription.deleted",
174
175 MnwEventName::InvoicePaymentSucceeded(P::SyncKit) => {
176 "invoice.payment_succeeded.synckit"
177 }
178 MnwEventName::InvoicePaymentSucceeded(P::SyncKitAppSub) => {
179 "invoice.payment_succeeded.synckit_app_sub"
180 }
181 MnwEventName::InvoicePaymentSucceeded(P::FanPlus) => {
182 "invoice.payment_succeeded.fan_plus"
183 }
184 MnwEventName::InvoicePaymentSucceeded(P::CreatorTier) => {
185 "invoice.payment_succeeded.creator_tier"
186 }
187 MnwEventName::InvoicePaymentSucceeded(P::Undetermined) => "invoice.payment_succeeded",
188
189 MnwEventName::InvoicePaymentFailed(P::SyncKit) => "invoice.payment_failed.synckit",
190 MnwEventName::InvoicePaymentFailed(P::FanPlus) => "invoice.payment_failed.fan_plus",
191 MnwEventName::InvoicePaymentFailed(P::CreatorTier) => {
192 "invoice.payment_failed.creator_tier"
193 }
194 // No `.synckit_app_sub` spelling has ever been written for a failed
195 // invoice; that path falls through to the generic handler.
196 MnwEventName::InvoicePaymentFailed(P::SyncKitAppSub | P::Undetermined) => {
197 "invoice.payment_failed"
198 }
199 }
200 }
201 }
202
203 impl std::fmt::Display for MnwEventName {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.write_str(self.as_str())
206 }
207 }
208
209 // ── The normalized delivery ──
210
211 /// What the buyer was presented with, when it differed from the sale currency.
212 ///
213 /// Captured at checkout because that is the one moment the converted figure is
214 /// knowable; it is stored on the transaction rather than re-derived later from
215 /// a rate we do not have.
216 #[derive(Debug, Default, Clone)]
217 pub struct Presentment {
218 pub amount: Option<i64>,
219 pub currency: Option<String>,
220 }
221
222 /// A completed (or settled) checkout session, in MNW's terms.
223 ///
224 /// The Stripe-shaped deserialization target stays inside `payments/`; this is
225 /// what leaves it. The substantive normalization is `settled`: Stripe reports a
226 /// three-valued `payment_status` string, and what a handler needs to know is
227 /// the single question "may goods be delivered".
228 #[derive(Debug, Default, Clone)]
229 pub struct CheckoutCompletion {
230 pub session_id: String,
231 /// The session metadata MNW itself wrote at checkout creation. Read through
232 /// the typed `*CheckoutMetadata` extractors, never by key here.
233 pub metadata: Option<HashMap<String, String>>,
234 pub payment_intent_id: Option<String>,
235 pub subscription_id: Option<String>,
236 pub customer_id: Option<String>,
237 /// Buyer email as Stripe collected it, for guest checkout.
238 pub customer_email: Option<String>,
239 /// Pre-tax line-item total Stripe computed, for reconciliation against our
240 /// server-built line items. Absent on older/edge events.
241 pub amount_subtotal: Option<i64>,
242 pub presentment: Option<Presentment>,
243 pub currency: Option<String>,
244 /// Whether funds are captured (or none were required).
245 ///
246 /// An absent `payment_status` is treated as settled, preserving behaviour
247 /// for legacy events that predate the field; only an explicit `"unpaid"` —
248 /// an async method awaiting settlement — is withheld.
249 pub settled: bool,
250 }
251
252 /// A subscription lifecycle delivery, in MNW's terms.
253 #[derive(Debug, Clone)]
254 pub struct SubscriptionLifecycle {
255 pub stripe_subscription_id: String,
256 /// Stripe's status string, deliberately unparsed.
257 ///
258 /// Stripe adds statuses (`paused` arrived after this code was written), and
259 /// the handlers treat an unknown one as a no-op rather than an error so a
260 /// subscription stuck in a new state does not pin Stripe in a retry storm.
261 /// Parsing here would have to choose between erroring and inventing a
262 /// member, and both are worse than letting each handler decide.
263 pub status: String,
264 pub cancel_at_period_end: bool,
265 /// `(current_period_start, current_period_end)` as Unix seconds, from
266 /// `items.data[0]` where rc.5 moved them.
267 pub current_period: Option<(i64, i64)>,
268 }
269
270 /// An invoice delivery, in MNW's terms.
271 #[derive(Debug, Clone)]
272 pub struct InvoiceOutcome {
273 /// Resolved from either the legacy `subscription` field or the rc.5
274 /// `parent.subscription_details.subscription` path, so a handler never has
275 /// to know which shape arrived.
276 pub subscription_id: Option<String>,
277 pub period_start: i64,
278 pub period_end: i64,
279 /// True when Stripe's billing reason is `subscription_cycle`, i.e. this is
280 /// a renewal rather than the first invoice.
281 pub is_renewal: bool,
282 }
283
284 /// A refund delivery, in MNW's terms.
285 #[derive(Debug, Clone)]
286 pub struct RefundOutcome {
287 pub amount: i64,
288 /// Stripe marks a completed refund `succeeded`; only then is money back.
289 pub succeeded: bool,
290 /// The MNW transaction this refund was tagged with at creation. Absent for
291 /// out-of-band refunds (e.g. issued from the Stripe dashboard), which are
292 /// no-ops on the line-scoped path.
293 pub mnw_transaction_id: Option<String>,
294 pub payment_intent_id: Option<String>,
295 }
296
297 /// A verified webhook delivery, normalized into what MNW does about it.
298 ///
299 /// Dispatch matches on this instead of on a Stripe event-name string, so an
300 /// unhandled type is [`MnwEvent::Unhandled`] by construction rather than a
301 /// typo that silently falls through a `match` arm.
302 #[derive(Debug)]
303 pub enum MnwEvent {
304 /// A checkout that completed and is ready for its handler.
305 ///
306 /// `checkout.session.completed` and `checkout.session.async_payment_succeeded`
307 /// both land here: the first fires immediately, and for asynchronous payment
308 /// methods it arrives unsettled and the second re-delivers the settled
309 /// session. The distinction a handler cares about is
310 /// [`CheckoutCompletion::settled`], not which of the two arrived.
311 Checkout {
312 kind: CheckoutKind,
313 session: Box<CheckoutCompletion>,
314 },
315 /// The buyer's async payment never cleared. No funds were captured, so
316 /// there is nothing to deliver; the pending rows are released by the
317 /// stale-pending sweeper.
318 CheckoutAsyncPaymentFailed {
319 session_id: String,
320 },
321 SubscriptionUpdated(Box<SubscriptionLifecycle>),
322 SubscriptionDeleted(Box<SubscriptionLifecycle>),
323 InvoicePaymentSucceeded(Box<InvoiceOutcome>),
324 InvoicePaymentFailed(Box<InvoiceOutcome>),
325 AccountUpdated(Box<super::AccountUpdate>),
326 /// A charge-level refund, which is the out-of-band (dashboard) full-refund
327 /// path. `None` where the charge carried no payment intent.
328 ChargeRefunded(Option<Box<super::ChargeRefundData>>),
329 /// A refund object event (`refund.created` / `refund.updated`), which is
330 /// the line-scoped self-service path.
331 RefundSettled(Box<RefundOutcome>),
332 /// A Stripe event type MNW does not act on. Carries the type so the log
333 /// still says what arrived.
334 Unhandled {
335 stripe_type: String,
336 },
337 }
338
339 // ── Normalization ──
340
341 use super::{
342 AccountView, ChargeRefundData, ChargeView, CheckoutSessionView, InvoiceView, RefundView,
343 SubscriptionView, UntypedEvent,
344 };
345 use crate::error::{AppError, Result};
346
347 impl MnwEvent {
348 /// Turn a verified Stripe delivery into what MNW does about it.
349 ///
350 /// The one normalization the two payload-bearing entry points share: the
351 /// live v1 handler and the retry worker re-parsing a stored payload outside
352 /// `verify_webhook`. Before this, each grew its own
353 /// `serde_json::from_value` calls and its own string match, which is how
354 /// they drifted.
355 ///
356 /// The third entry point, the v2 thin-event path, needs no step here and
357 /// that is not an omission: a thin event carries only a reference, so it
358 /// fetches the object through `PaymentProvider::fetch_account`, which
359 /// returns an [`super::AccountUpdate`] — already the normalized type. There
360 /// is no Stripe-shaped view to strip, and it converges on the same handler.
361 ///
362 /// `data_object` is consumed exactly once. A parse failure is a
363 /// `BadRequest` naming the object that would not parse, which is what the
364 /// Stripe Dashboard shows for a failed delivery — a past incident (an API
365 /// version mismatch producing serde `missing field` errors) was misread as
366 /// a signature failure because the wording did not distinguish them.
367 pub fn normalize(event_type: &str, data_object: serde_json::Value) -> Result<Self> {
368 let parse = |what: &str, e: serde_json::Error| {
369 AppError::BadRequest(format!("Failed to parse {what}: {e}"))
370 };
371
372 Ok(match event_type {
373 // Both route to one place. `completed` fires immediately; for
374 // asynchronous methods it arrives with payment_status="unpaid" and
375 // `async_payment_succeeded` re-delivers the settled session. What a
376 // handler needs is `settled`, not which of the two arrived.
377 "checkout.session.completed" | "checkout.session.async_payment_succeeded" => {
378 let view: CheckoutSessionView =
379 serde_json::from_value(data_object).map_err(|e| parse("CheckoutSession", e))?;
380 let kind = checkout_kind(view.metadata.as_ref());
381 MnwEvent::Checkout {
382 kind,
383 session: Box::new(CheckoutCompletion::from(view)),
384 }
385 }
386 "checkout.session.async_payment_failed" => {
387 let view: CheckoutSessionView =
388 serde_json::from_value(data_object).map_err(|e| parse("CheckoutSession", e))?;
389 MnwEvent::CheckoutAsyncPaymentFailed {
390 session_id: view.id,
391 }
392 }
393 "account.updated" => {
394 let view: AccountView =
395 serde_json::from_value(data_object).map_err(|e| parse("Account", e))?;
396 MnwEvent::AccountUpdated(Box::new(view.into()))
397 }
398 "charge.refunded" => {
399 let view: ChargeView =
400 serde_json::from_value(data_object).map_err(|e| parse("Charge", e))?;
401 MnwEvent::ChargeRefunded(ChargeRefundData::from_view(view).map(Box::new))
402 }
403 "refund.created" | "refund.updated" => {
404 let view: RefundView =
405 serde_json::from_value(data_object).map_err(|e| parse("Refund", e))?;
406 MnwEvent::RefundSettled(Box::new(RefundOutcome::from(view)))
407 }
408 "customer.subscription.updated" => {
409 let view: SubscriptionView =
410 serde_json::from_value(data_object).map_err(|e| parse("Subscription", e))?;
411 MnwEvent::SubscriptionUpdated(Box::new(SubscriptionLifecycle::from(view)))
412 }
413 "customer.subscription.deleted" => {
414 let view: SubscriptionView =
415 serde_json::from_value(data_object).map_err(|e| parse("Subscription", e))?;
416 MnwEvent::SubscriptionDeleted(Box::new(SubscriptionLifecycle::from(view)))
417 }
418 "invoice.payment_succeeded" => {
419 let view: InvoiceView =
420 serde_json::from_value(data_object).map_err(|e| parse("Invoice", e))?;
421 MnwEvent::InvoicePaymentSucceeded(Box::new(InvoiceOutcome::from(view)))
422 }
423 "invoice.payment_failed" => {
424 let view: InvoiceView =
425 serde_json::from_value(data_object).map_err(|e| parse("Invoice", e))?;
426 MnwEvent::InvoicePaymentFailed(Box::new(InvoiceOutcome::from(view)))
427 }
428 other => MnwEvent::Unhandled {
429 stripe_type: other.to_string(),
430 },
431 })
432 }
433
434 /// Normalize a whole verified envelope, discarding the id and type the
435 /// caller has already taken for the dedup and retry-queue paths.
436 pub fn from_untyped(event: UntypedEvent) -> Result<Self> {
437 Self::normalize(&event.type_, event.data_object)
438 }
439 }
440
441 /// Which checkout a session is, from the metadata MNW wrote at creation.
442 ///
443 /// The fall-through is [`CheckoutKind::Purchase`] rather than an error: a
444 /// single item purchase is the shape with no distinguishing `checkout_type`,
445 /// and that has always been the dispatcher's final `else`.
446 fn checkout_kind(meta: Option<&HashMap<String, String>>) -> CheckoutKind {
447 use super::{
448 is_cart_checkout, is_creator_tier_checkout, is_fan_plus_checkout, is_guest_checkout,
449 is_subscription_checkout, is_synckit_app_sub_checkout, is_tip_checkout,
450 };
451 if is_fan_plus_checkout(meta) {
452 CheckoutKind::FanPlus
453 } else if is_creator_tier_checkout(meta) {
454 CheckoutKind::CreatorTier
455 } else if is_synckit_app_sub_checkout(meta) {
456 CheckoutKind::SyncKitAppSub
457 } else if is_subscription_checkout(meta) {
458 CheckoutKind::ProjectSubscription
459 } else if is_tip_checkout(meta) {
460 CheckoutKind::Tip
461 } else if is_guest_checkout(meta) {
462 CheckoutKind::Guest
463 } else if is_cart_checkout(meta) {
464 CheckoutKind::Cart
465 } else {
466 CheckoutKind::Purchase
467 }
468 }
469
470 impl From<CheckoutSessionView> for CheckoutCompletion {
471 fn from(v: CheckoutSessionView) -> Self {
472 let settled = v.payment_settled();
473 CheckoutCompletion {
474 session_id: v.id,
475 metadata: v.metadata,
476 payment_intent_id: v.payment_intent,
477 subscription_id: v.subscription,
478 customer_id: v.customer,
479 customer_email: v.customer_details.and_then(|d| d.email),
480 amount_subtotal: v.amount_subtotal,
481 presentment: v.presentment_details.map(|p| Presentment {
482 amount: p.presentment_amount,
483 currency: p.presentment_currency,
484 }),
485 currency: v.currency,
486 settled,
487 }
488 }
489 }
490
491 impl From<SubscriptionView> for SubscriptionLifecycle {
492 fn from(v: SubscriptionView) -> Self {
493 let current_period = v.current_period();
494 SubscriptionLifecycle {
495 stripe_subscription_id: v.id,
496 status: v.status,
497 cancel_at_period_end: v.cancel_at_period_end,
498 current_period,
499 }
500 }
501 }
502
503 impl From<InvoiceView> for InvoiceOutcome {
504 fn from(v: InvoiceView) -> Self {
505 InvoiceOutcome {
506 subscription_id: v.subscription_id().map(str::to_string),
507 period_start: v.period_start,
508 period_end: v.period_end,
509 is_renewal: v.is_renewal(),
510 }
511 }
512 }
513
514 impl From<RefundView> for RefundOutcome {
515 fn from(v: RefundView) -> Self {
516 RefundOutcome {
517 amount: v.amount,
518 succeeded: v.is_succeeded(),
519 mnw_transaction_id: v.mnw_transaction_id().map(str::to_string),
520 payment_intent_id: v.payment_intent,
521 }
522 }
523 }
524
525 #[cfg(test)]
526 mod tests {
527 use super::*;
528 use SubscriptionProduct as P;
529
530 /// The names are the contract with `subscription_events`, and nothing reads
531 /// that table yet, so a silent change would be invisible until someone
532 /// finally queried it. Pinning every one here is what makes a rename a
533 /// deliberate act.
534 #[test]
535 fn every_name_is_the_one_already_in_the_log() {
536 let expected = [
537 (
538 MnwEventName::CheckoutCompletedCart,
539 "checkout.session.completed.cart",
540 ),
541 (
542 MnwEventName::CheckoutCompletedCreatorTier,
543 "checkout.session.completed.creator_tier",
544 ),
545 (
546 MnwEventName::CheckoutCompletedFanPlus,
547 "checkout.session.completed.fan_plus",
548 ),
549 (
550 MnwEventName::CheckoutCompletedPurchase,
551 "checkout.session.completed.purchase",
552 ),
553 (
554 MnwEventName::CheckoutCompletedSubscription,
555 "checkout.session.completed.subscription",
556 ),
557 (
558 MnwEventName::CheckoutCompletedTip,
559 "checkout.session.completed.tip",
560 ),
561 (
562 MnwEventName::SubscriptionUpdated(P::SyncKit),
563 "customer.subscription.updated.synckit",
564 ),
565 (
566 MnwEventName::SubscriptionUpdated(P::SyncKitAppSub),
567 "customer.subscription.updated.synckit_app_sub",
568 ),
569 (
570 MnwEventName::SubscriptionUpdated(P::FanPlus),
571 "customer.subscription.updated.fan_plus",
572 ),
573 (
574 MnwEventName::SubscriptionUpdated(P::CreatorTier),
575 "customer.subscription.updated.creator_tier",
576 ),
577 (
578 MnwEventName::SubscriptionUpdated(P::Undetermined),
579 "customer.subscription.updated",
580 ),
581 (
582 MnwEventName::SubscriptionDeleted(P::SyncKit),
583 "customer.subscription.deleted.synckit",
584 ),
585 (
586 MnwEventName::SubscriptionDeleted(P::SyncKitAppSub),
587 "customer.subscription.deleted.synckit_app_sub",
588 ),
589 (
590 MnwEventName::SubscriptionDeleted(P::FanPlus),
591 "customer.subscription.deleted.fan_plus",
592 ),
593 (
594 MnwEventName::SubscriptionDeleted(P::CreatorTier),
595 "customer.subscription.deleted.creator_tier",
596 ),
597 (
598 MnwEventName::SubscriptionDeleted(P::Undetermined),
599 "customer.subscription.deleted",
600 ),
601 (
602 MnwEventName::InvoicePaymentSucceeded(P::SyncKit),
603 "invoice.payment_succeeded.synckit",
604 ),
605 (
606 MnwEventName::InvoicePaymentSucceeded(P::SyncKitAppSub),
607 "invoice.payment_succeeded.synckit_app_sub",
608 ),
609 (
610 MnwEventName::InvoicePaymentSucceeded(P::FanPlus),
611 "invoice.payment_succeeded.fan_plus",
612 ),
613 (
614 MnwEventName::InvoicePaymentSucceeded(P::CreatorTier),
615 "invoice.payment_succeeded.creator_tier",
616 ),
617 (
618 MnwEventName::InvoicePaymentSucceeded(P::Undetermined),
619 "invoice.payment_succeeded",
620 ),
621 (
622 MnwEventName::InvoicePaymentFailed(P::SyncKit),
623 "invoice.payment_failed.synckit",
624 ),
625 (
626 MnwEventName::InvoicePaymentFailed(P::FanPlus),
627 "invoice.payment_failed.fan_plus",
628 ),
629 (
630 MnwEventName::InvoicePaymentFailed(P::CreatorTier),
631 "invoice.payment_failed.creator_tier",
632 ),
633 (
634 MnwEventName::InvoicePaymentFailed(P::Undetermined),
635 "invoice.payment_failed",
636 ),
637 ];
638 for (name, want) in expected {
639 assert_eq!(name.as_str(), want, "{name:?} changed spelling");
640 }
641 }
642
643 #[test]
644 fn the_undetermined_product_is_the_bare_name_not_a_siblings_name() {
645 // The four bare names must stay distinct from every suffixed sibling.
646 // Collapsing them would erase the only record that a subscription could
647 // not be routed to a product.
648 for bare in [
649 MnwEventName::SubscriptionUpdated(P::Undetermined),
650 MnwEventName::SubscriptionDeleted(P::Undetermined),
651 MnwEventName::InvoicePaymentSucceeded(P::Undetermined),
652 ] {
653 assert!(!bare.as_str().ends_with("_tier"));
654 assert!(!bare.as_str().ends_with("fan_plus"));
655 assert!(!bare.as_str().ends_with("synckit"));
656 assert!(!bare.as_str().ends_with("synckit_app_sub"));
657 }
658 }
659
660 // ── Normalization ──
661
662 fn normalize(type_: &str, object: serde_json::Value) -> MnwEvent {
663 MnwEvent::normalize(type_, object).expect("payload should normalize")
664 }
665
666 #[test]
667 fn a_checkout_kind_comes_from_the_metadata_mnw_wrote() {
668 let event = normalize(
669 "checkout.session.completed",
670 serde_json::json!({
671 "id": "cs_1",
672 "metadata": {"checkout_type": "tip"},
673 "payment_status": "paid",
674 }),
675 );
676 let MnwEvent::Checkout { kind, session } = event else {
677 panic!("expected a checkout");
678 };
679 assert_eq!(kind, CheckoutKind::Tip);
680 assert_eq!(session.session_id, "cs_1");
681 assert!(session.settled);
682 }
683
684 #[test]
685 fn a_session_with_no_checkout_type_is_a_purchase() {
686 // The dispatcher's final `else` for as long as it has existed: a single
687 // item purchase is the shape with no distinguishing metadata.
688 let event = normalize(
689 "checkout.session.completed",
690 serde_json::json!({"id": "cs_1", "metadata": {}}),
691 );
692 let MnwEvent::Checkout { kind, .. } = event else {
693 panic!("expected a checkout");
694 };
695 assert_eq!(kind, CheckoutKind::Purchase);
696 }
697
698 #[test]
699 fn an_unpaid_session_is_not_settled_and_an_absent_status_is() {
700 // The absent case preserves behaviour for legacy events that predate
701 // `payment_status`; only an explicit "unpaid" is withheld.
702 let unpaid = normalize(
703 "checkout.session.completed",
704 serde_json::json!({"id": "cs_1", "payment_status": "unpaid"}),
705 );
706 let MnwEvent::Checkout { session, .. } = unpaid else {
707 panic!("expected a checkout")
708 };
709 assert!(!session.settled);
710
711 let legacy = normalize(
712 "checkout.session.completed",
713 serde_json::json!({"id": "cs_2"}),
714 );
715 let MnwEvent::Checkout { session, .. } = legacy else {
716 panic!("expected a checkout")
717 };
718 assert!(session.settled);
719 }
720
721 #[test]
722 fn async_payment_succeeded_normalizes_to_the_same_checkout_as_completed() {
723 // A handler cares about `settled`, not which of the two arrived.
724 for type_ in [
725 "checkout.session.completed",
726 "checkout.session.async_payment_succeeded",
727 ] {
728 let event = normalize(
729 type_,
730 serde_json::json!({
731 "id": "cs_1",
732 "metadata": {"checkout_type": "cart"},
733 "payment_status": "paid",
734 }),
735 );
736 assert!(
737 matches!(
738 event,
739 MnwEvent::Checkout {
740 kind: CheckoutKind::Cart,
741 ..
742 }
743 ),
744 "{type_} should be a settled cart checkout"
745 );
746 }
747 }
748
749 #[test]
750 fn an_invoice_resolves_its_subscription_from_either_field_path() {
751 // rc.5 moved the subscription id under parent.subscription_details; a
752 // handler should never have to know which shape arrived.
753 let legacy = normalize(
754 "invoice.payment_succeeded",
755 serde_json::json!({"subscription": "sub_1", "billing_reason": "subscription_cycle"}),
756 );
757 let MnwEvent::InvoicePaymentSucceeded(invoice) = legacy else {
758 panic!("expected an invoice")
759 };
760 assert_eq!(invoice.subscription_id.as_deref(), Some("sub_1"));
761 assert!(invoice.is_renewal);
762
763 let rc5 = normalize(
764 "invoice.payment_failed",
765 serde_json::json!({
766 "parent": {"subscription_details": {"subscription": "sub_2"}},
767 "billing_reason": "subscription_create",
768 }),
769 );
770 let MnwEvent::InvoicePaymentFailed(invoice) = rc5 else {
771 panic!("expected an invoice")
772 };
773 assert_eq!(invoice.subscription_id.as_deref(), Some("sub_2"));
774 assert!(!invoice.is_renewal);
775 }
776
777 #[test]
778 fn a_subscription_keeps_stripes_status_string_unparsed() {
779 // Parsing here would have to choose between erroring on a status Stripe
780 // added and inventing a member; both are worse than letting the handler
781 // treat an unknown status as a no-op.
782 let event = normalize(
783 "customer.subscription.updated",
784 serde_json::json!({
785 "id": "sub_1",
786 "status": "paused",
787 "cancel_at_period_end": true,
788 "items": {"data": [{"current_period_start": 1, "current_period_end": 2}]},
789 }),
790 );
791 let MnwEvent::SubscriptionUpdated(sub) = event else {
792 panic!("expected a subscription update")
793 };
794 assert_eq!(sub.status, "paused");
795 assert!(sub.cancel_at_period_end);
796 assert_eq!(sub.current_period, Some((1, 2)));
797 }
798
799 #[test]
800 fn a_charge_with_no_payment_intent_normalizes_to_nothing_to_do() {
801 // Out of scope rather than an error: there is no payment to refund
802 // against, which is what `ChargeRefundData::from_view` has always said.
803 let event = normalize(
804 "charge.refunded",
805 serde_json::json!({"amount": 100, "amount_refunded": 100}),
806 );
807 assert!(matches!(event, MnwEvent::ChargeRefunded(None)));
808 }
809
810 #[test]
811 fn an_unhandled_type_is_a_member_not_a_fallthrough() {
812 // The whole reason dispatch matches on an enum: a type MNW does not act
813 // on is representable, so a misspelt arm cannot silently swallow one.
814 let event = normalize("payment_intent.succeeded", serde_json::json!({}));
815 let MnwEvent::Unhandled { stripe_type } = event else {
816 panic!("expected an unhandled event")
817 };
818 assert_eq!(stripe_type, "payment_intent.succeeded");
819 }
820
821 #[test]
822 fn a_payload_that_will_not_parse_names_the_object() {
823 // The Stripe Dashboard shows this body for a failed delivery, and a past
824 // incident (an API version mismatch producing serde `missing field`
825 // errors) was misread as a signature failure because the wording did not
826 // distinguish them.
827 let err = MnwEvent::normalize(
828 "customer.subscription.updated",
829 serde_json::json!({"status": "active"}),
830 )
831 .unwrap_err();
832 let msg = format!("{err:?}");
833 assert!(msg.contains("Subscription"), "{msg}");
834 }
835
836 #[test]
837 fn subscription_mode_checkouts_do_not_wait_on_settlement() {
838 for kind in [
839 CheckoutKind::FanPlus,
840 CheckoutKind::CreatorTier,
841 CheckoutKind::SyncKitAppSub,
842 CheckoutKind::ProjectSubscription,
843 ] {
844 assert!(!kind.captures_funds_at_checkout(), "{kind:?}");
845 }
846 for kind in [
847 CheckoutKind::Tip,
848 CheckoutKind::Guest,
849 CheckoutKind::Cart,
850 CheckoutKind::Purchase,
851 ] {
852 assert!(kind.captures_funds_at_checkout(), "{kind:?}");
853 }
854 }
855 }
856