Skip to main content

max / makenotwork

23.4 KB · 586 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 //! The names the audit log writes are ratified here, and normalization happens
5 //! in `payments/`. Handlers call `log_subscription_event` with MNW-side names
6 //! Stripe never emits (`checkout.session.completed.tip`,
7 //! `invoice.payment_failed.creator_tier`), so the vocabulary belongs in one
8 //! place: as scattered string literals a typo is a silently unhandled event and
9 //! a renamed concept drifts one call site at a time.
10 //!
11 //! Two types, because there are two jobs and conflating them makes the dispatch
12 //! stringly typed:
13 //!
14 //! - [`MnwEvent`] is what a delivery becomes. Normalization happens here in
15 //! `payments/`, so the live v1 handler, the retry worker and the v2 thin-event
16 //! path all inherit one, and the Stripe-shaped `*View` structs stop crossing
17 //! out to the handlers. Dispatch matches on this.
18 //! - [`MnwEventName`] is the audit-log vocabulary: one member per name in
19 //! `subscription_events`, and the only place those strings are spelled.
20 //!
21 //! They are not one enum because the product suffix is not knowable at
22 //! normalization time for half of them. See [`SubscriptionProduct`].
23
24 use std::collections::HashMap;
25
26 // ── The audit-log vocabulary ──
27
28 /// Which product a subscription-shaped delivery turned out to concern.
29 ///
30 /// Stripe does not say. `customer.subscription.updated` carries a subscription
31 /// id and nothing else; which of our five products it belongs to is settled by
32 /// looking that id up in four different tables, in order. That is a database
33 /// fact, so it is the handler's to establish, not the normalizer's — putting
34 /// those lookups in `payments/` would move product routing into the payment
35 /// provider layer and make the handlers redo the work anyway.
36 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
37 pub enum SubscriptionProduct {
38 /// A SyncKit developer subscription (`sync_apps`).
39 SyncKit,
40 /// An end-user subscription to a SyncKit app (`app_sync_subscriptions`).
41 SyncKitAppSub,
42 /// Fan+ (`fan_plus_subscriptions`).
43 FanPlus,
44 /// A creator tier (`creator_subscriptions`).
45 CreatorTier,
46 /// No product table claimed the subscription id.
47 ///
48 /// **This is an answer, not a missing one**, and it is the explicit member
49 /// the ruling asked for rather than a silent collapse into a sibling. It is
50 /// what the four bare names in the log have always meant: the handler fell
51 /// through every lookup and wrote the generic `subscriptions` row. Keeping
52 /// it distinct is the point — a Fan+ renewal and a renewal for a
53 /// subscription we cannot place are different events, and merging them
54 /// would erase the only signal that something is unrouted.
55 ///
56 /// The alternative the ruling offered (resolve the product during
57 /// normalization and make this unrepresentable) was rejected: it is the DB
58 /// lookups above, and they do not belong in `payments/`.
59 Undetermined,
60 }
61
62 /// Which checkout a completed session was.
63 ///
64 /// Unlike [`SubscriptionProduct`] this *is* knowable at normalization time: the
65 /// answer is in the session's own metadata, which `payments/` already owns the
66 /// vocabulary for (`is_tip_checkout` and friends). So there is no
67 /// "undetermined" member here — a session that matches no specific shape is a
68 /// [`CheckoutKind::Purchase`], which is what the dispatcher's final `else`
69 /// branch has always meant.
70 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
71 pub enum CheckoutKind {
72 FanPlus,
73 CreatorTier,
74 SyncKitAppSub,
75 /// A subscription to a creator's project tier.
76 ProjectSubscription,
77 Tip,
78 Guest,
79 Cart,
80 /// A single item purchase: the shape with no distinguishing metadata.
81 Purchase,
82 }
83
84 impl CheckoutKind {
85 /// True for the subscription-mode checkouts, which capture no funds at
86 /// checkout and so are not gated on settlement.
87 ///
88 /// The gate this feeds is load-bearing: without it, enabling an async
89 /// payment method (ACH, SEPA, Bacs) on a connected account would mint
90 /// license keys and grant downloads before the money settles.
91 pub fn captures_funds_at_checkout(self) -> bool {
92 match self {
93 CheckoutKind::FanPlus
94 | CheckoutKind::CreatorTier
95 | CheckoutKind::SyncKitAppSub
96 | CheckoutKind::ProjectSubscription => false,
97 CheckoutKind::Tip
98 | CheckoutKind::Guest
99 | CheckoutKind::Cart
100 | CheckoutKind::Purchase => true,
101 }
102 }
103 }
104
105 /// Every name written to `subscription_events.event_type`.
106 ///
107 /// One member per name already in the table, verbatim: renaming any of them
108 /// would cost a migration for historical rows and buy nothing, since the names
109 /// are already business-meaningful rather than Stripe-shaped. Nothing reads
110 /// that table today (the only reference is the `INSERT`), which is exactly why
111 /// a rename gets more expensive every day and was declined while it was still
112 /// free.
113 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
114 pub enum MnwEventName {
115 CheckoutCompletedCart,
116 CheckoutCompletedCreatorTier,
117 CheckoutCompletedFanPlus,
118 CheckoutCompletedPurchase,
119 CheckoutCompletedSubscription,
120 CheckoutCompletedTip,
121 SubscriptionUpdated(SubscriptionProduct),
122 SubscriptionDeleted(SubscriptionProduct),
123 InvoicePaymentSucceeded(SubscriptionProduct),
124 InvoicePaymentFailed(SubscriptionProduct),
125 }
126
127 impl MnwEventName {
128 /// The string as it is persisted. The single home for these 25 literals.
129 ///
130 /// `invoice.payment_failed` has no `.synckit_app_sub` spelling because no
131 /// handler ever wrote one: an end-user app subscription's failed invoice
132 /// falls through to the generic path. Spelling it here would invent a name
133 /// the table has never held, so the arm maps to the bare form and says so.
134 pub fn as_str(self) -> &'static str {
135 use SubscriptionProduct as P;
136 match self {
137 MnwEventName::CheckoutCompletedCart => "checkout.session.completed.cart",
138 MnwEventName::CheckoutCompletedCreatorTier => "checkout.session.completed.creator_tier",
139 MnwEventName::CheckoutCompletedFanPlus => "checkout.session.completed.fan_plus",
140 MnwEventName::CheckoutCompletedPurchase => "checkout.session.completed.purchase",
141 MnwEventName::CheckoutCompletedSubscription => {
142 "checkout.session.completed.subscription"
143 }
144 MnwEventName::CheckoutCompletedTip => "checkout.session.completed.tip",
145
146 MnwEventName::SubscriptionUpdated(P::SyncKit) => {
147 "customer.subscription.updated.synckit"
148 }
149 MnwEventName::SubscriptionUpdated(P::SyncKitAppSub) => {
150 "customer.subscription.updated.synckit_app_sub"
151 }
152 MnwEventName::SubscriptionUpdated(P::FanPlus) => {
153 "customer.subscription.updated.fan_plus"
154 }
155 MnwEventName::SubscriptionUpdated(P::CreatorTier) => {
156 "customer.subscription.updated.creator_tier"
157 }
158 MnwEventName::SubscriptionUpdated(P::Undetermined) => "customer.subscription.updated",
159
160 MnwEventName::SubscriptionDeleted(P::SyncKit) => {
161 "customer.subscription.deleted.synckit"
162 }
163 MnwEventName::SubscriptionDeleted(P::SyncKitAppSub) => {
164 "customer.subscription.deleted.synckit_app_sub"
165 }
166 MnwEventName::SubscriptionDeleted(P::FanPlus) => {
167 "customer.subscription.deleted.fan_plus"
168 }
169 MnwEventName::SubscriptionDeleted(P::CreatorTier) => {
170 "customer.subscription.deleted.creator_tier"
171 }
172 MnwEventName::SubscriptionDeleted(P::Undetermined) => "customer.subscription.deleted",
173
174 MnwEventName::InvoicePaymentSucceeded(P::SyncKit) => {
175 "invoice.payment_succeeded.synckit"
176 }
177 MnwEventName::InvoicePaymentSucceeded(P::SyncKitAppSub) => {
178 "invoice.payment_succeeded.synckit_app_sub"
179 }
180 MnwEventName::InvoicePaymentSucceeded(P::FanPlus) => {
181 "invoice.payment_succeeded.fan_plus"
182 }
183 MnwEventName::InvoicePaymentSucceeded(P::CreatorTier) => {
184 "invoice.payment_succeeded.creator_tier"
185 }
186 MnwEventName::InvoicePaymentSucceeded(P::Undetermined) => "invoice.payment_succeeded",
187
188 MnwEventName::InvoicePaymentFailed(P::SyncKit) => "invoice.payment_failed.synckit",
189 MnwEventName::InvoicePaymentFailed(P::FanPlus) => "invoice.payment_failed.fan_plus",
190 MnwEventName::InvoicePaymentFailed(P::CreatorTier) => {
191 "invoice.payment_failed.creator_tier"
192 }
193 // No `.synckit_app_sub` spelling has ever been written for a failed
194 // invoice; that path falls through to the generic handler.
195 MnwEventName::InvoicePaymentFailed(P::SyncKitAppSub | P::Undetermined) => {
196 "invoice.payment_failed"
197 }
198 }
199 }
200 }
201
202 impl std::fmt::Display for MnwEventName {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 f.write_str(self.as_str())
205 }
206 }
207
208 // ── The normalized delivery ──
209
210 /// What the buyer was presented with, when it differed from the sale currency.
211 ///
212 /// Captured at checkout because that is the one moment the converted figure is
213 /// knowable; it is stored on the transaction rather than re-derived later from
214 /// a rate we do not have.
215 #[derive(Debug, Default, Clone)]
216 pub struct Presentment {
217 pub amount: Option<i64>,
218 pub currency: Option<String>,
219 }
220
221 /// A completed (or settled) checkout session, in MNW's terms.
222 ///
223 /// The Stripe-shaped deserialization target stays inside `payments/`; this is
224 /// what leaves it. The substantive normalization is `settled`: Stripe reports a
225 /// three-valued `payment_status` string, and what a handler needs to know is
226 /// the single question "may goods be delivered".
227 #[derive(Debug, Default, Clone)]
228 pub struct CheckoutCompletion {
229 pub session_id: String,
230 /// The session metadata MNW itself wrote at checkout creation. Read through
231 /// the typed `*CheckoutMetadata` extractors, never by key here.
232 pub metadata: Option<HashMap<String, String>>,
233 pub payment_intent_id: Option<String>,
234 pub subscription_id: Option<String>,
235 pub customer_id: Option<String>,
236 /// Buyer email as Stripe collected it, for guest checkout.
237 pub customer_email: Option<String>,
238 /// Pre-tax line-item total Stripe computed, for reconciliation against our
239 /// server-built line items. Absent on older/edge events.
240 pub amount_subtotal: Option<i64>,
241 pub presentment: Option<Presentment>,
242 pub currency: Option<String>,
243 /// Whether funds are captured (or none were required).
244 ///
245 /// An absent `payment_status` is treated as settled, preserving behaviour
246 /// for legacy events that predate the field; only an explicit `"unpaid"` —
247 /// an async method awaiting settlement — is withheld.
248 pub settled: bool,
249 }
250
251 /// A subscription lifecycle delivery, in MNW's terms.
252 #[derive(Debug, Clone)]
253 pub struct SubscriptionLifecycle {
254 pub stripe_subscription_id: String,
255 /// Stripe's status string, deliberately unparsed.
256 ///
257 /// Stripe adds statuses (`paused` arrived after this code was written), and
258 /// the handlers treat an unknown one as a no-op rather than an error so a
259 /// subscription stuck in a new state does not pin Stripe in a retry storm.
260 /// Parsing here would have to choose between erroring and inventing a
261 /// member, and both are worse than letting each handler decide.
262 pub status: String,
263 pub cancel_at_period_end: bool,
264 /// `(current_period_start, current_period_end)` as Unix seconds, from
265 /// `items.data[0]` where rc.5 moved them.
266 pub current_period: Option<(i64, i64)>,
267 }
268
269 /// An invoice delivery, in MNW's terms.
270 #[derive(Debug, Clone)]
271 pub struct InvoiceOutcome {
272 /// Resolved from either the legacy `subscription` field or the rc.5
273 /// `parent.subscription_details.subscription` path, so a handler never has
274 /// to know which shape arrived.
275 pub subscription_id: Option<String>,
276 pub period_start: i64,
277 pub period_end: i64,
278 /// True when Stripe's billing reason is `subscription_cycle`, i.e. this is
279 /// a renewal rather than the first invoice.
280 pub is_renewal: bool,
281 }
282
283 /// A refund delivery, in MNW's terms.
284 #[derive(Debug, Clone)]
285 pub struct RefundOutcome {
286 pub amount: i64,
287 /// Stripe marks a completed refund `succeeded`; only then is money back.
288 pub succeeded: bool,
289 /// The MNW transaction this refund was tagged with at creation. Absent for
290 /// out-of-band refunds (e.g. issued from the Stripe dashboard), which are
291 /// no-ops on the line-scoped path.
292 pub mnw_transaction_id: Option<String>,
293 pub payment_intent_id: Option<String>,
294 }
295
296 /// A verified webhook delivery, normalized into what MNW does about it.
297 ///
298 /// Dispatch matches on this instead of on a Stripe event-name string, so an
299 /// unhandled type is [`MnwEvent::Unhandled`] by construction rather than a
300 /// typo that silently falls through a `match` arm.
301 #[derive(Debug)]
302 pub enum MnwEvent {
303 /// A checkout that completed and is ready for its handler.
304 ///
305 /// `checkout.session.completed` and `checkout.session.async_payment_succeeded`
306 /// both land here: the first fires immediately, and for asynchronous payment
307 /// methods it arrives unsettled and the second re-delivers the settled
308 /// session. The distinction a handler cares about is
309 /// [`CheckoutCompletion::settled`], not which of the two arrived.
310 Checkout {
311 kind: CheckoutKind,
312 session: Box<CheckoutCompletion>,
313 },
314 /// The buyer's async payment never cleared. No funds were captured, so
315 /// there is nothing to deliver; the pending rows are released by the
316 /// stale-pending sweeper.
317 CheckoutAsyncPaymentFailed {
318 session_id: String,
319 },
320 SubscriptionUpdated(Box<SubscriptionLifecycle>),
321 SubscriptionDeleted(Box<SubscriptionLifecycle>),
322 InvoicePaymentSucceeded(Box<InvoiceOutcome>),
323 InvoicePaymentFailed(Box<InvoiceOutcome>),
324 AccountUpdated(Box<super::AccountUpdate>),
325 /// A charge-level refund, which is the out-of-band (dashboard) full-refund
326 /// path. `None` where the charge carried no payment intent.
327 ChargeRefunded(Option<Box<super::ChargeRefundData>>),
328 /// A refund object event (`refund.created` / `refund.updated`), which is
329 /// the line-scoped self-service path.
330 RefundSettled(Box<RefundOutcome>),
331 /// A Stripe event type MNW does not act on. Carries the type so the log
332 /// still says what arrived.
333 Unhandled {
334 stripe_type: String,
335 },
336 }
337
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.
344
345 use super::{CheckoutSessionView, InvoiceView, RefundView, SubscriptionView};
346
347 /// Which checkout a session is, from the metadata MNW wrote at creation.
348 ///
349 /// The fall-through is [`CheckoutKind::Purchase`] rather than an error: a
350 /// single item purchase is the shape with no distinguishing `checkout_type`,
351 /// and that has always been the dispatcher's final `else`.
352 pub(in crate::payments) fn checkout_kind(meta: Option<&HashMap<String, String>>) -> CheckoutKind {
353 use super::{
354 is_cart_checkout, is_creator_tier_checkout, is_fan_plus_checkout, is_guest_checkout,
355 is_subscription_checkout, is_synckit_app_sub_checkout, is_tip_checkout,
356 };
357 if is_fan_plus_checkout(meta) {
358 CheckoutKind::FanPlus
359 } else if is_creator_tier_checkout(meta) {
360 CheckoutKind::CreatorTier
361 } else if is_synckit_app_sub_checkout(meta) {
362 CheckoutKind::SyncKitAppSub
363 } else if is_subscription_checkout(meta) {
364 CheckoutKind::ProjectSubscription
365 } else if is_tip_checkout(meta) {
366 CheckoutKind::Tip
367 } else if is_guest_checkout(meta) {
368 CheckoutKind::Guest
369 } else if is_cart_checkout(meta) {
370 CheckoutKind::Cart
371 } else {
372 CheckoutKind::Purchase
373 }
374 }
375
376 impl From<CheckoutSessionView> for CheckoutCompletion {
377 fn from(v: CheckoutSessionView) -> Self {
378 let settled = v.payment_settled();
379 CheckoutCompletion {
380 session_id: v.id,
381 metadata: v.metadata,
382 payment_intent_id: v.payment_intent,
383 subscription_id: v.subscription,
384 customer_id: v.customer,
385 customer_email: v.customer_details.and_then(|d| d.email),
386 amount_subtotal: v.amount_subtotal,
387 presentment: v.presentment_details.map(|p| Presentment {
388 amount: p.presentment_amount,
389 currency: p.presentment_currency,
390 }),
391 currency: v.currency,
392 settled,
393 }
394 }
395 }
396
397 impl From<SubscriptionView> for SubscriptionLifecycle {
398 fn from(v: SubscriptionView) -> Self {
399 let current_period = v.current_period();
400 SubscriptionLifecycle {
401 stripe_subscription_id: v.id,
402 status: v.status,
403 cancel_at_period_end: v.cancel_at_period_end,
404 current_period,
405 }
406 }
407 }
408
409 impl From<InvoiceView> for InvoiceOutcome {
410 fn from(v: InvoiceView) -> Self {
411 InvoiceOutcome {
412 subscription_id: v.subscription_id().map(str::to_string),
413 period_start: v.period_start,
414 period_end: v.period_end,
415 is_renewal: v.is_renewal(),
416 }
417 }
418 }
419
420 impl From<RefundView> for RefundOutcome {
421 fn from(v: RefundView) -> Self {
422 RefundOutcome {
423 amount: v.amount,
424 succeeded: v.is_succeeded(),
425 mnw_transaction_id: v.mnw_transaction_id().map(str::to_string),
426 payment_intent_id: v.payment_intent,
427 }
428 }
429 }
430
431 #[cfg(test)]
432 mod tests {
433 use super::*;
434 use SubscriptionProduct as P;
435
436 /// The names are the contract with `subscription_events`, and nothing reads
437 /// that table yet, so a silent change would be invisible until someone
438 /// finally queried it. Pinning every one here is what makes a rename a
439 /// deliberate act.
440 #[test]
441 fn every_name_is_the_one_already_in_the_log() {
442 let expected = [
443 (
444 MnwEventName::CheckoutCompletedCart,
445 "checkout.session.completed.cart",
446 ),
447 (
448 MnwEventName::CheckoutCompletedCreatorTier,
449 "checkout.session.completed.creator_tier",
450 ),
451 (
452 MnwEventName::CheckoutCompletedFanPlus,
453 "checkout.session.completed.fan_plus",
454 ),
455 (
456 MnwEventName::CheckoutCompletedPurchase,
457 "checkout.session.completed.purchase",
458 ),
459 (
460 MnwEventName::CheckoutCompletedSubscription,
461 "checkout.session.completed.subscription",
462 ),
463 (
464 MnwEventName::CheckoutCompletedTip,
465 "checkout.session.completed.tip",
466 ),
467 (
468 MnwEventName::SubscriptionUpdated(P::SyncKit),
469 "customer.subscription.updated.synckit",
470 ),
471 (
472 MnwEventName::SubscriptionUpdated(P::SyncKitAppSub),
473 "customer.subscription.updated.synckit_app_sub",
474 ),
475 (
476 MnwEventName::SubscriptionUpdated(P::FanPlus),
477 "customer.subscription.updated.fan_plus",
478 ),
479 (
480 MnwEventName::SubscriptionUpdated(P::CreatorTier),
481 "customer.subscription.updated.creator_tier",
482 ),
483 (
484 MnwEventName::SubscriptionUpdated(P::Undetermined),
485 "customer.subscription.updated",
486 ),
487 (
488 MnwEventName::SubscriptionDeleted(P::SyncKit),
489 "customer.subscription.deleted.synckit",
490 ),
491 (
492 MnwEventName::SubscriptionDeleted(P::SyncKitAppSub),
493 "customer.subscription.deleted.synckit_app_sub",
494 ),
495 (
496 MnwEventName::SubscriptionDeleted(P::FanPlus),
497 "customer.subscription.deleted.fan_plus",
498 ),
499 (
500 MnwEventName::SubscriptionDeleted(P::CreatorTier),
501 "customer.subscription.deleted.creator_tier",
502 ),
503 (
504 MnwEventName::SubscriptionDeleted(P::Undetermined),
505 "customer.subscription.deleted",
506 ),
507 (
508 MnwEventName::InvoicePaymentSucceeded(P::SyncKit),
509 "invoice.payment_succeeded.synckit",
510 ),
511 (
512 MnwEventName::InvoicePaymentSucceeded(P::SyncKitAppSub),
513 "invoice.payment_succeeded.synckit_app_sub",
514 ),
515 (
516 MnwEventName::InvoicePaymentSucceeded(P::FanPlus),
517 "invoice.payment_succeeded.fan_plus",
518 ),
519 (
520 MnwEventName::InvoicePaymentSucceeded(P::CreatorTier),
521 "invoice.payment_succeeded.creator_tier",
522 ),
523 (
524 MnwEventName::InvoicePaymentSucceeded(P::Undetermined),
525 "invoice.payment_succeeded",
526 ),
527 (
528 MnwEventName::InvoicePaymentFailed(P::SyncKit),
529 "invoice.payment_failed.synckit",
530 ),
531 (
532 MnwEventName::InvoicePaymentFailed(P::FanPlus),
533 "invoice.payment_failed.fan_plus",
534 ),
535 (
536 MnwEventName::InvoicePaymentFailed(P::CreatorTier),
537 "invoice.payment_failed.creator_tier",
538 ),
539 (
540 MnwEventName::InvoicePaymentFailed(P::Undetermined),
541 "invoice.payment_failed",
542 ),
543 ];
544 for (name, want) in expected {
545 assert_eq!(name.as_str(), want, "{name:?} changed spelling");
546 }
547 }
548
549 #[test]
550 fn the_undetermined_product_is_the_bare_name_not_a_siblings_name() {
551 // The four bare names must stay distinct from every suffixed sibling.
552 // Collapsing them would erase the only record that a subscription could
553 // not be routed to a product.
554 for bare in [
555 MnwEventName::SubscriptionUpdated(P::Undetermined),
556 MnwEventName::SubscriptionDeleted(P::Undetermined),
557 MnwEventName::InvoicePaymentSucceeded(P::Undetermined),
558 ] {
559 assert!(!bare.as_str().ends_with("_tier"));
560 assert!(!bare.as_str().ends_with("fan_plus"));
561 assert!(!bare.as_str().ends_with("synckit"));
562 assert!(!bare.as_str().ends_with("synckit_app_sub"));
563 }
564 }
565
566 #[test]
567 fn subscription_mode_checkouts_do_not_wait_on_settlement() {
568 for kind in [
569 CheckoutKind::FanPlus,
570 CheckoutKind::CreatorTier,
571 CheckoutKind::SyncKitAppSub,
572 CheckoutKind::ProjectSubscription,
573 ] {
574 assert!(!kind.captures_funds_at_checkout(), "{kind:?}");
575 }
576 for kind in [
577 CheckoutKind::Tip,
578 CheckoutKind::Guest,
579 CheckoutKind::Cart,
580 CheckoutKind::Purchase,
581 ] {
582 assert!(kind.captures_funds_at_checkout(), "{kind:?}");
583 }
584 }
585 }
586