Skip to main content

max / makenotwork

Dispatch webhooks on an MNW event enum, not on Stripe event-name strings Implements the ruling on 9e45feec: ratify the names the audit log already writes, and normalize in payments/. The vocabulary was never missing. Every handler already called log_subscription_event with an MNW-side name Stripe does not emit -- checkout.session.completed.tip, invoice.payment_failed.creator_tier -- so 25 business-meaningful names existed and were persisted. They just lived as 26 string literals across three files, where a typo produced a row nobody would ever match on. MnwEventName is now the only place they are spelled, and log_subscription_event takes it instead of a &str. MnwEvent::normalize turns a verified delivery into what MNW does about it, in payments/, so the live v1 handler and the retry worker inherit one normalization instead of each growing its own from_value calls and its own string match. Dispatch matches on the enum, so an unhandled Stripe type is MnwEvent::Unhandled by construction rather than a match arm someone can misspell. The v2 thin-event path needs no step: fetch_account already returns the normalized AccountUpdate, and it says so where a reader would ask. The *View structs are pub(in crate::payments) now, so this is enforced rather than agreed. They are Stripe's shapes, held ad-hoc to survive new required fields; letting them reach a handler is what made the rest of the codebase depend on Stripe's field names. CheckoutCompletion, SubscriptionLifecycle, InvoiceOutcome and RefundOutcome cross out instead, joining AccountUpdate and ChargeRefundData which already worked this way. The substantive normalization is CheckoutCompletion::settled: a three-valued Stripe string becomes the one question a handler asks. On the four bare names, the ruling asked for an explicit choice: they are SubscriptionProduct::Undetermined, a real member carrying why, not a silent collapse into a sibling. Resolving the product during normalization was rejected because it is four DB lookups across four product tables, and product routing does not belong in the payment provider layer. CheckoutKind is settled during normalization, from metadata MNW itself wrote, so dispatch_checkout_session is a match rather than a ladder of predicates -- and the settlement gate is asked once, up front, through an exhaustive captures_funds_at_checkout. Adding a funds-capturing kind that forgets the gate is now a compile error rather than a way to grant downloads before money settles. 47 webhook integration tests pass unchanged in behaviour.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-25 17:57 UTC
Signed with PGP, not checked
Commit: 2c6fa1d499028dae3cbb918a7c6432c3ff3271e2
Parent: 2babc3b
12 files changed, +1122 insertions, -251 deletions
@@ -36,8 +36,8 @@
36 36 /// via each family's `create_*`/`ON CONFLICT DO UPDATE` path.
37 37 ///
38 38 /// `period` is the **raw Stripe period** `(current_period_start,
39 - /// current_period_end)` as Unix seconds, exactly what `SubscriptionView::
40 - /// current_period()` / an invoice yields. The funnel owns the only legal
39 + /// current_period_end)` as Unix seconds, exactly what
40 + /// `SubscriptionLifecycle::current_period` / an invoice yields. The funnel owns the only legal
41 41 /// conversion: a `None`, or a non-positive `end` (thin/zero webhook shapes),
42 42 /// writes no period at all (the `COALESCE` keeps the existing value). This is
43 43 /// the structural close to the recurring epoch-period bug (CHRONIC C): handlers
@@ -786,14 +786,21 @@
786 786 /// redelivered event a silent no-op, never an error, so an `Err` return is
787 787 /// always a genuine DB failure, which callers log at `error!` (a dropped
788 788 /// reconciliation row is worth alerting on, not burying at `warn!`).
789 + ///
790 + /// `event_type` is an [`MnwEventName`], not a string. The names in this table
791 + /// are MNW's own vocabulary rather than Stripe's, and they were previously
792 + /// spelled out at 26 call sites across three files, where a typo produced a row
793 + /// nobody would ever match on. Taking the enum makes the spelling
794 + /// unmisspellable and puts every name in one place.
789 795 #[tracing::instrument(skip_all)]
790 796 pub(crate) async fn log_subscription_event(
791 797 pool: &PgPool,
792 798 subscription_id: Option<SubscriptionId>,
793 799 stripe_event_id: &str,
794 - event_type: &str,
800 + event_type: crate::payments::MnwEventName,
795 801 payload: &serde_json::Value,
796 802 ) -> Result<()> {
803 + let event_type = event_type.as_str();
797 804 sqlx::query!(
798 805 r#"
799 806 INSERT INTO subscription_events (subscription_id, stripe_event_id, event_type, payload)
@@ -18,12 +18,15 @@
18 18 mod checkout_metadata;
19 19 mod connect;
20 20 pub mod fan_ops;
21 + /// The MNW event vocabulary the webhook dispatcher reasons in.
22 + pub mod mnw_event;
21 23 pub mod synckit_app_pricing;
22 24 pub mod synckit_billing;
23 25 mod webhooks;
24 26
25 27 pub use checkout::*;
26 28 pub use checkout_metadata::*;
29 + pub use mnw_event::*;
27 30 pub use synckit_app_pricing::{
28 31 ANNUAL_MULTIPLIER, MAX_CAP_BYTES, MIN_CAP_BYTES, MIN_CHARGE_CENTS, SyncBillingInterval,
29 32 quote_price_cents,
@@ -1,8 +1,17 @@
1 - //! Webhook signature verification and event extraction.
1 + //! Webhook signature verification and the Stripe-shaped structs we read a
2 + //! payload into.
2 3 //!
3 4 //! rc.5 ships no webhook helper, so we keep the local HMAC `verify_signature`
4 - //! and a thin `UntypedEvent` envelope. The webhook dispatcher matches on
5 - //! `type_` and consumes `data_object` (no per-extractor clones).
5 + //! and a thin [`UntypedEvent`] envelope.
6 + //!
7 + //! **The `*View` structs are `pub(in crate::payments)` on purpose.** They are
8 + //! Stripe's shapes, defined ad-hoc rather than via `stripe_shared::*` to stay
9 + //! resilient against new required fields Stripe adds — the original migration
10 + //! bug was an over-strict typed struct. Letting them reach a handler is what
11 + //! makes the rest of the codebase depend on Stripe's field names, so they stop
12 + //! here: [`super::mnw_event`] converts each into an MNW-shaped type, and that
13 + //! is what crosses out. The compiler enforces it, so this is not a convention
14 + //! anyone can forget.
6 15
7 16 use hmac::{Hmac, KeyInit, Mac};
8 17 use sha2::Sha256;
@@ -136,7 +145,7 @@
136 145 /// resilient against new required fields Stripe adds. The original migration
137 146 /// bug was caused by an over-strict typed struct.
138 147 #[derive(Debug, Default, serde::Deserialize)]
139 - pub struct CheckoutSessionView {
148 + pub(in crate::payments) struct CheckoutSessionView {
140 149 pub id: String,
141 150 #[serde(default)]
142 151 pub metadata: Option<std::collections::HashMap<String, String>>,
@@ -182,7 +191,7 @@
182 191 /// deliver goods. Treats an absent field as settled to preserve behaviour
183 192 /// for legacy/edge events that predate the field; only an explicit
184 193 /// `"unpaid"` (an async method awaiting settlement) is withheld.
185 - pub fn payment_settled(&self) -> bool {
194 + pub(in crate::payments) fn payment_settled(&self) -> bool {
186 195 matches!(
187 196 self.payment_status.as_deref(),
188 197 None | Some("paid" | "no_payment_required")
@@ -191,14 +200,14 @@
191 200 }
192 201
193 202 #[derive(Debug, Default, serde::Deserialize)]
194 - pub struct CheckoutCustomerDetailsView {
203 + pub(in crate::payments) struct CheckoutCustomerDetailsView {
195 204 pub email: Option<String>,
196 205 }
197 206
198 207 /// Narrow view of a Subscription: id, status, cancellation flag, and the
199 208 /// item-level period fields rc.5 promoted from the top level.
200 209 #[derive(Debug, serde::Deserialize)]
201 - pub struct SubscriptionView {
210 + pub(in crate::payments) struct SubscriptionView {
202 211 pub id: String,
203 212 pub status: String,
204 213 #[serde(default)]
@@ -209,7 +218,7 @@
209 218
210 219 impl SubscriptionView {
211 220 /// Period from `items.data[0]` (rc.5 moved these off the top-level Subscription).
212 - pub fn current_period(&self) -> Option<(i64, i64)> {
221 + pub(in crate::payments) fn current_period(&self) -> Option<(i64, i64)> {
213 222 self.items
214 223 .data
215 224 .first()
@@ -218,13 +227,13 @@
218 227 }
219 228
220 229 #[derive(Debug, Default, serde::Deserialize)]
221 - pub struct SubscriptionItemList {
230 + pub(in crate::payments) struct SubscriptionItemList {
222 231 #[serde(default)]
223 232 pub data: Vec<SubscriptionItemView>,
224 233 }
225 234
226 235 #[derive(Debug, serde::Deserialize)]
227 - pub struct SubscriptionItemView {
236 + pub(in crate::payments) struct SubscriptionItemView {
228 237 #[serde(default)]
229 238 pub current_period_start: i64,
230 239 #[serde(default)]
@@ -235,7 +244,7 @@
235 244 /// the rc.5 `parent.subscription_details.subscription` path), period bounds,
236 245 /// and billing reason.
237 246 #[derive(Debug, serde::Deserialize)]
238 - pub struct InvoiceView {
247 + pub(in crate::payments) struct InvoiceView {
239 248 #[serde(default)]
240 249 pub period_start: i64,
241 250 #[serde(default)]
@@ -250,7 +259,7 @@
250 259
251 260 impl InvoiceView {
252 261 /// Pull the subscription id from either the legacy or new field path.
253 - pub fn subscription_id(&self) -> Option<&str> {
262 + pub(in crate::payments) fn subscription_id(&self) -> Option<&str> {
254 263 if let Some(s) = &self.subscription {
255 264 return Some(s.as_str());
256 265 }
@@ -262,19 +271,19 @@
262 271 .as_deref()
263 272 }
264 273
265 - pub fn is_renewal(&self) -> bool {
274 + pub(in crate::payments) fn is_renewal(&self) -> bool {
266 275 self.billing_reason.as_deref() == Some("subscription_cycle")
267 276 }
268 277 }
269 278
270 279 #[derive(Debug, serde::Deserialize)]
271 - pub struct InvoiceParentView {
280 + pub(in crate::payments) struct InvoiceParentView {
272 281 #[serde(default)]
273 282 pub subscription_details: Option<InvoiceSubscriptionDetailsView>,
274 283 }
275 284
276 285 #[derive(Debug, serde::Deserialize)]
277 - pub struct InvoiceSubscriptionDetailsView {
286 + pub(in crate::payments) struct InvoiceSubscriptionDetailsView {
278 287 #[serde(default, deserialize_with = "deserialize_expandable_id")]
279 288 pub subscription: Option<String>,
280 289 }
@@ -356,7 +365,7 @@
356 365
357 366 /// Narrow view of an Account: only the fields we react to.
358 367 #[derive(Debug, serde::Deserialize)]
359 - pub struct AccountView {
368 + pub(in crate::payments) struct AccountView {
360 369 pub id: String,
361 370 #[serde(default)]
362 371 pub charges_enabled: bool,
@@ -383,7 +392,7 @@
383 392
384 393 /// What the buyer was presented with, when it differed from the sale currency.
385 394 #[derive(Debug, serde::Deserialize)]
386 - pub struct PresentmentDetailsView {
395 + pub(in crate::payments) struct PresentmentDetailsView {
387 396 #[serde(default)]
388 397 pub presentment_amount: Option<i64>,
389 398 #[serde(default)]
@@ -392,7 +401,7 @@
392 401
393 402 /// Narrow view of a Charge for refund processing.
394 403 #[derive(Debug, serde::Deserialize)]
395 - pub struct ChargeView {
404 + pub(in crate::payments) struct ChargeView {
396 405 #[serde(default)]
397 406 pub amount: i64,
398 407 #[serde(default)]
@@ -420,7 +429,7 @@
420 429
421 430 /// Build from a parsed charge view. Returns None when there is no
422 431 /// payment_intent; these events are out of scope here.
423 - pub fn from_view(charge: ChargeView) -> Option<Self> {
432 + pub(in crate::payments) fn from_view(charge: ChargeView) -> Option<Self> {
424 433 Some(ChargeRefundData {
425 434 payment_intent_id: charge.payment_intent?,
426 435 amount: Cents::new(charge.amount),
@@ -435,7 +444,7 @@
435 444 /// `metadata.mnw_transaction_id`; the webhook reads it back so a cart line refund
436 445 /// marks/revokes exactly its own transaction rather than the whole order.
437 446 #[derive(Debug, serde::Deserialize)]
438 - pub struct RefundView {
447 + pub(in crate::payments) struct RefundView {
439 448 #[serde(default)]
440 449 pub amount: i64,
441 450 pub status: Option<String>,
@@ -448,7 +457,7 @@
448 457 impl RefundView {
449 458 /// The MNW transaction id this refund was tagged with at creation, if any.
450 459 /// Absent for out-of-band refunds (e.g. issued from the Stripe dashboard).
451 - pub fn mnw_transaction_id(&self) -> Option<&str> {
460 + pub(in crate::payments) fn mnw_transaction_id(&self) -> Option<&str> {
452 461 self.metadata
453 462 .as_ref()?
454 463 .get("mnw_transaction_id")
@@ -456,7 +465,7 @@
456 465 }
457 466
458 467 /// Stripe marks a completed refund `succeeded`; only then is the money back.
459 - pub fn is_succeeded(&self) -> bool {
468 + pub(in crate::payments) fn is_succeeded(&self) -> bool {
460 469 self.status.as_deref() == Some("succeeded")
461 470 }
462 471 }
@@ -93,13 +93,15 @@
93 93 // (use ON CONFLICT / WHERE status='pending' guards) since steps completed
94 94 // before the original failure are not rolled back.
95 95 let result = if event.source == "stripe" {
96 - match crate::payments::UntypedEvent::from_payload(&event.payload) {
97 - Ok(parsed) => {
98 - let crate::payments::UntypedEvent {
99 - id,
100 - type_,
101 - data_object,
102 - } = parsed;
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.
100 + match crate::payments::UntypedEvent::from_payload(&event.payload).and_then(|parsed| {
101 + let id = parsed.id.clone();
102 + crate::payments::MnwEvent::from_untyped(parsed).map(|e| (id, e))
103 + }) {
104 + Ok((id, mnw_event)) => {
103 105 crate::routes::stripe::process_webhook_event(
104 106 &state.db,
105 107 &state.bg,
@@ -107,9 +109,8 @@
107 109 state.wam.as_ref(),
108 110 &crate::Billing::from_ref(state),
109 111 &state.config,
110 - &type_,
112 + mnw_event,
111 113 &id,
112 - data_object,
113 114 )
114 115 .await
115 116 }
@@ -148,7 +148,7 @@
148 148 /// reactivation happens at checkout through `create_app_sync_subscription`'s
149 149 /// DO UPDATE path. Consistent with every other subscription family's setter.
150 150 /// `current_period_end_unix` is the **raw Stripe** period end (Unix seconds), as
151 - /// `SubscriptionView::current_period()` / an invoice yields. The conversion is
151 + /// `SubscriptionLifecycle::current_period` / an invoice yields. The conversion is
152 152 /// sealed here: a `None` or a non-positive value writes no period (the COALESCE
153 153 /// keeps the existing value), so a thin/zero webhook can never stamp a 1970
154 154 /// period onto an active row. Handlers pass the raw value and cannot construct a
@@ -141,6 +141,12 @@
141 141 /// Route a verified v2 thin event to its handler. Shared by the live webhook and
142 142 /// the scheduler retry worker (which re-parses the stored payload, the
143 143 /// signature was already verified when the event was first received).
144 + ///
145 + /// There is no `MnwEvent::normalize` step here, unlike the v1 paths. A thin
146 + /// event carries a reference rather than an object, so the account is fetched
147 + /// through `PaymentProvider::fetch_account`, which hands back an `AccountUpdate`
148 + /// — the normalized MNW type, with no Stripe-shaped view in between. Both paths
149 + /// still converge on the one `handle_account_updated`.
144 150 pub(crate) async fn process_v2_thin_event(
145 151 db: &PgPool,
146 152 wam: Option<&WamClient>,
@@ -5,6 +5,7 @@
5 5 email::EmailClient,
6 6 error::{Result, ResultExt},
7 7 helpers,
8 + payments::{MnwEventName, SubscriptionProduct},
8 9 wam_client::WamClient,
9 10 };
10 11 use sqlx::PgPool;
@@ -15,17 +16,17 @@
15 16 bg: &crate::background::BackgroundTx,
16 17 email: &EmailClient,
17 18 wam: Option<&WamClient>,
18 - invoice: &crate::payments::InvoiceView,
19 + invoice: &crate::payments::InvoiceOutcome,
19 20 event_id: &str,
20 21 ) -> Result<()> {
21 - let stripe_sub_id = match invoice.subscription_id() {
22 + let stripe_sub_id = match invoice.subscription_id.as_deref() {
22 23 Some(s) => s.to_string(),
23 24 None => return Ok(()), // Not a subscription invoice
24 25 };
25 26
26 27 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing invoice payment succeeded");
27 28
28 - let is_renewal = invoice.is_renewal();
29 + let is_renewal = invoice.is_renewal;
29 30
30 31 // End-user SyncKit app subscription? Apply any pending storage-cap change
31 32 // and refresh the period. Only meaningful on renewals; the first invoice's
@@ -52,7 +53,7 @@
52 53 db,
53 54 None,
54 55 event_id,
55 - "invoice.payment_succeeded.synckit_app_sub",
56 + MnwEventName::InvoicePaymentSucceeded(SubscriptionProduct::SyncKitAppSub),
56 57 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}),
57 58 )
58 59 .await
@@ -89,7 +90,7 @@
89 90 }
90 91 tx.commit().await.context("commit synckit invoice.paid")?;
91 92 if let Err(e) = db::subscriptions::log_subscription_event(
92 - db, None, event_id, "invoice.payment_succeeded.synckit",
93 + db, None, event_id, MnwEventName::InvoicePaymentSucceeded(SubscriptionProduct::SyncKit),
93 94 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}),
94 95 ).await {
95 96 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -206,7 +207,7 @@
206 207 db,
207 208 None,
208 209 event_id,
209 - "invoice.payment_succeeded.fan_plus",
210 + MnwEventName::InvoicePaymentSucceeded(SubscriptionProduct::FanPlus),
210 211 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}),
211 212 )
212 213 .await
@@ -234,7 +235,7 @@
234 235 db,
235 236 None,
236 237 event_id,
237 - "invoice.payment_succeeded.creator_tier",
238 + MnwEventName::InvoicePaymentSucceeded(SubscriptionProduct::CreatorTier),
238 239 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}),
239 240 )
240 241 .await
@@ -306,7 +307,7 @@
306 307 db,
307 308 sub_id,
308 309 event_id,
309 - "invoice.payment_succeeded",
310 + MnwEventName::InvoicePaymentSucceeded(SubscriptionProduct::Undetermined),
310 311 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}),
311 312 )
312 313 .await
@@ -321,10 +322,10 @@
321 322 pub(super) async fn handle_invoice_payment_failed(
322 323 db: &PgPool,
323 324 wam: Option<&WamClient>,
324 - invoice: &crate::payments::InvoiceView,
325 + invoice: &crate::payments::InvoiceOutcome,
325 326 event_id: &str,
326 327 ) -> Result<()> {
327 - let stripe_sub_id = match invoice.subscription_id() {
328 + let stripe_sub_id = match invoice.subscription_id.as_deref() {
328 329 Some(s) => s.to_string(),
329 330 None => return Ok(()), // Not a subscription invoice
330 331 };
@@ -340,7 +341,7 @@
340 341 .await
341 342 .context("synckit billing -> suspended_unpaid")?;
342 343 if let Err(e) = db::subscriptions::log_subscription_event(
343 - db, None, event_id, "invoice.payment_failed.synckit",
344 + db, None, event_id, MnwEventName::InvoicePaymentFailed(SubscriptionProduct::SyncKit),
344 345 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}),
345 346 ).await {
346 347 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -377,7 +378,7 @@
377 378 db,
378 379 None,
379 380 event_id,
380 - "invoice.payment_failed.fan_plus",
381 + MnwEventName::InvoicePaymentFailed(SubscriptionProduct::FanPlus),
381 382 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
382 383 )
383 384 .await
@@ -408,7 +409,7 @@
408 409 db,
409 410 None,
410 411 event_id,
411 - "invoice.payment_failed.creator_tier",
412 + MnwEventName::InvoicePaymentFailed(SubscriptionProduct::CreatorTier),
412 413 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
413 414 )
414 415 .await
@@ -433,7 +434,7 @@
433 434 db,
434 435 sub_id,
435 436 event_id,
436 - "invoice.payment_failed",
437 + MnwEventName::InvoicePaymentFailed(SubscriptionProduct::Undetermined),
437 438 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
438 439 )
439 440 .await
@@ -503,12 +504,12 @@
503 504 /// later `charge.refunded` for the same refund finds nothing left to mark.
504 505 pub(super) async fn handle_refund_created(
505 506 db: &PgPool,
506 - refund: &crate::payments::RefundView,
507 + refund: &crate::payments::RefundOutcome,
507 508 ) -> Result<()> {
508 - if !refund.is_succeeded() {
509 + if !refund.succeeded {
509 510 return Ok(());
510 511 }
511 - let Some(tx_id_str) = refund.mnw_transaction_id() else {
512 + let Some(tx_id_str) = refund.mnw_transaction_id.as_deref() else {
512 513 return Ok(()); // out-of-band refund; charge.refunded handles full ones
513 514 };
514 515 let Ok(tx_id) = tx_id_str.parse::<db::TransactionId>() else {
@@ -8,7 +8,7 @@
8 8 error::{AppError, Result, ResultExt},
9 9 helpers,
10 10 payments::{
11 - CheckoutMetadata, CreatorTierCheckoutMetadata, FanPlusCheckoutMetadata,
11 + CheckoutMetadata, CreatorTierCheckoutMetadata, FanPlusCheckoutMetadata, MnwEventName,
12 12 SubscriptionCheckoutMetadata, SynckitAppSubCheckoutMetadata, TipCheckoutMetadata,
13 13 },
14 14 wam_client::WamClient,
@@ -79,7 +79,7 @@
79 79 bg: &crate::background::BackgroundTx,
80 80 wam: Option<&WamClient>,
81 81 session_id: &str,
82 - session: &crate::payments::CheckoutSessionView,
82 + session: &crate::payments::CheckoutCompletion,
83 83 credited_cents: i64,
84 84 label: &str,
85 85 ) {
@@ -137,10 +137,10 @@
137 137 email: &EmailClient,
138 138 wam: Option<&WamClient>,
139 139 config: &Config,
140 - session: &crate::payments::CheckoutSessionView,
140 + session: &crate::payments::CheckoutCompletion,
141 141 event_id: &str,
142 142 ) -> Result<()> {
143 - let session_id = session.id.clone();
143 + let session_id = session.session_id.clone();
144 144
145 145 tracing::info!(session_id = %session_id, "processing completed purchase checkout");
146 146
@@ -153,10 +153,10 @@
153 153 let item_id_display = item_id.map_or_else(|| "project".to_string(), |id| id.to_string());
154 154
155 155 // Get the payment intent ID
156 - // Display/logging copy only; the DB write below passes `session.payment_intent`
156 + // Display/logging copy only; the DB write below passes `session.payment_intent_id`
157 157 // directly so a PI-less session stores NULL, not a literal "unknown" that would
158 158 // collide with other PI-less rows in the money-keyed lookup column (Run 9).
159 - let payment_intent_id = session.payment_intent.clone().unwrap_or_default();
159 + let payment_intent_id = session.payment_intent_id.clone().unwrap_or_default();
160 160
161 161 // Complete the transaction (idempotent - returns None if already completed).
162 162 // Steps 1-3 (complete_transaction, increment_sales_count, discount code increment)
@@ -169,14 +169,14 @@
169 169 // Both halves or neither, matching the column pair's CHECK: a currency with
170 170 // no amount is not something we can put on a receipt.
171 171 let presentment = session
172 - .presentment_details
172 + .presentment
173 173 .as_ref()
174 - .and_then(|p| Some((p.presentment_amount?, p.presentment_currency.as_deref()?)));
174 + .and_then(|p| Some((p.amount?, p.currency.as_deref()?)));
175 175
176 176 match db::transactions::complete_transaction(
177 177 &mut *db_tx,
178 178 &session_id,
179 - session.payment_intent.as_deref(),
179 + session.payment_intent_id.as_deref(),
180 180 presentment,
181 181 )
182 182 .await
@@ -224,7 +224,7 @@
224 224 db,
225 225 None,
226 226 event_id,
227 - "checkout.session.completed.purchase",
227 + MnwEventName::CheckoutCompletedPurchase,
228 228 &serde_json::json!({"session_id": session_id}),
229 229 )
230 230 .await
@@ -288,20 +288,20 @@
288 288 email: &EmailClient,
289 289 wam: Option<&WamClient>,
290 290 config: &Config,
291 - session: &crate::payments::CheckoutSessionView,
291 + session: &crate::payments::CheckoutCompletion,
292 292 event_id: &str,
293 293 ) -> Result<()> {
294 - let session_id = session.id.clone();
294 + let session_id = session.session_id.clone();
295 295 tracing::info!(session_id = %session_id, "processing completed cart checkout");
296 296
297 297 let meta = crate::payments::CartCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
298 298 let buyer_id = meta.buyer_id;
299 299 let seller_id = meta.seller_id;
300 300
301 - // Display/logging copy only; the DB write below passes `session.payment_intent`
301 + // Display/logging copy only; the DB write below passes `session.payment_intent_id`
302 302 // directly so a PI-less session stores NULL, not a literal "unknown" that would
303 303 // collide with other PI-less rows in the money-keyed lookup column (Run 9).
304 - let payment_intent_id = session.payment_intent.clone().unwrap_or_default();
304 + let payment_intent_id = session.payment_intent_id.clone().unwrap_or_default();
305 305
306 306 // Complete ALL pending transactions for this session in a single DB transaction
307 307 let mut db_tx = db.begin().await.context("begin cart webhook transaction")?;
@@ -309,7 +309,7 @@
309 309 let completed_txs = db::transactions::complete_cart_transactions(
310 310 &mut *db_tx,
311 311 &session_id,
312 - session.payment_intent.as_deref(),
312 + session.payment_intent_id.as_deref(),
313 313 )
314 314 .await
315 315 .context("complete cart transactions")?;
@@ -391,7 +391,7 @@
391 391 db,
392 392 None,
393 393 event_id,
394 - "checkout.session.completed.cart",
394 + MnwEventName::CheckoutCompletedCart,
395 395 &serde_json::json!({"session_id": session_id, "item_count": completed_txs.len()}),
396 396 )
397 397 .await
@@ -411,10 +411,10 @@
411 411 db: &PgPool,
412 412 bg: &crate::background::BackgroundTx,
413 413 email: &EmailClient,
414 - session: &crate::payments::CheckoutSessionView,
414 + session: &crate::payments::CheckoutCompletion,
415 415 event_id: &str,
416 416 ) -> Result<()> {
417 - let session_id = session.id.clone();
417 + let session_id = session.session_id.clone();
418 418 tracing::info!(session_id = %session_id, "processing completed subscription checkout");
419 419
420 420 // Extract subscription-specific metadata (already typed IDs)
@@ -424,13 +424,13 @@
424 424 let tier_id = raw_metadata.tier_id;
425 425
426 426 // Get the Stripe subscription ID from the session
427 - let stripe_subscription_id = session.subscription.clone().ok_or_else(|| {
427 + let stripe_subscription_id = session.subscription_id.clone().ok_or_else(|| {
428 428 tracing::error!("Subscription checkout completed but no subscription ID on session");
429 429 AppError::BadRequest("Missing subscription ID on session".to_string())
430 430 })?;
431 431
432 432 // Get the Stripe customer ID from the session
433 - let stripe_customer_id = session.customer.clone().ok_or_else(|| {
433 + let stripe_customer_id = session.customer_id.clone().ok_or_else(|| {
434 434 tracing::error!("Subscription checkout completed but no customer ID on session");
435 435 AppError::BadRequest("Missing customer ID on session".to_string())
436 436 })?;
@@ -513,7 +513,7 @@
513 513 }
514 514
515 515 if let Err(e) = db::subscriptions::log_subscription_event(
516 - db, Some(sub.id), event_id, "checkout.session.completed.subscription",
516 + db, Some(sub.id), event_id, MnwEventName::CheckoutCompletedSubscription,
517 517 &serde_json::json!({"session_id": session_id, "stripe_subscription_id": stripe_subscription_id}),
518 518 ).await {
519 519 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -528,23 +528,23 @@
528 528 db: &PgPool,
529 529 bg: &crate::background::BackgroundTx,
530 530 email: &EmailClient,
531 - session: &crate::payments::CheckoutSessionView,
531 + session: &crate::payments::CheckoutCompletion,
532 532 event_id: &str,
533 533 ) -> Result<()> {
534 - let session_id = session.id.clone();
534 + let session_id = session.session_id.clone();
535 535 tracing::info!(session_id = %session_id, "processing completed Fan+ checkout");
536 536
537 537 let metadata = FanPlusCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
538 538 let user_id = metadata.user_id;
539 539
540 540 // Get the Stripe subscription ID from the session
541 - let stripe_subscription_id = session.subscription.clone().ok_or_else(|| {
541 + let stripe_subscription_id = session.subscription_id.clone().ok_or_else(|| {
542 542 tracing::error!("Fan+ checkout completed but no subscription ID on session");
543 543 AppError::BadRequest("Missing subscription ID on session".to_string())
544 544 })?;
545 545
546 546 // Get the Stripe customer ID from the session
547 - let stripe_customer_id = session.customer.clone().ok_or_else(|| {
547 + let stripe_customer_id = session.customer_id.clone().ok_or_else(|| {
548 548 tracing::error!("Fan+ checkout completed but no customer ID on session");
549 549 AppError::BadRequest("Missing customer ID on session".to_string())
550 550 })?;
@@ -587,7 +587,7 @@
587 587 }
588 588
589 589 if let Err(e) = db::subscriptions::log_subscription_event(
590 - db, None, event_id, "checkout.session.completed.fan_plus",
590 + db, None, event_id, MnwEventName::CheckoutCompletedFanPlus,
591 591 &serde_json::json!({"session_id": session_id, "stripe_subscription_id": stripe_subscription_id}),
592 592 ).await {
593 593 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -603,10 +603,10 @@
603 603 bg: &crate::background::BackgroundTx,
604 604 wam: Option<&WamClient>,
605 605 payments: &Billing,
606 - session: &crate::payments::CheckoutSessionView,
606 + session: &crate::payments::CheckoutCompletion,
607 607 event_id: &str,
608 608 ) -> Result<()> {
609 - let session_id = session.id.clone();
609 + let session_id = session.session_id.clone();
610 610 tracing::info!(session_id = %session_id, "processing completed creator tier checkout");
611 611
612 612 let metadata = CreatorTierCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
@@ -617,13 +617,13 @@
617 617 .map_err(|_| AppError::BadRequest(format!("Invalid tier: {}", metadata.tier)))?;
618 618
619 619 // Get the Stripe subscription ID from the session
620 - let stripe_subscription_id = session.subscription.clone().ok_or_else(|| {
620 + let stripe_subscription_id = session.subscription_id.clone().ok_or_else(|| {
621 621 tracing::error!("Creator tier checkout completed but no subscription ID on session");
622 622 AppError::BadRequest("Missing subscription ID on session".to_string())
623 623 })?;
624 624
625 625 // Get the Stripe customer ID from the session
626 - let stripe_customer_id = session.customer.clone().ok_or_else(|| {
626 + let stripe_customer_id = session.customer_id.clone().ok_or_else(|| {
627 627 tracing::error!("Creator tier checkout completed but no customer ID on session");
628 628 AppError::BadRequest("Missing customer ID on session".to_string())
629 629 })?;
@@ -708,7 +708,7 @@
708 708 db,
709 709 None,
710 710 event_id,
711 - "checkout.session.completed.creator_tier",
711 + MnwEventName::CheckoutCompletedCreatorTier,
712 712 &serde_json::json!({
713 713 "session_id": session_id,
714 714 "stripe_subscription_id": stripe_subscription_id,
@@ -731,10 +731,10 @@
731 731 email: &EmailClient,
732 732 wam: Option<&WamClient>,
733 733 config: &Config,
734 - session: &crate::payments::CheckoutSessionView,
734 + session: &crate::payments::CheckoutCompletion,
735 735 event_id: &str,
736 736 ) -> Result<()> {
737 - let session_id = session.id.clone();
737 + let session_id = session.session_id.clone();
738 738 tracing::info!(session_id = %session_id, "processing completed tip checkout");
739 739
740 740 let metadata = TipCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
@@ -743,7 +743,7 @@
743 743
744 744 // Complete the tip (idempotent). A PI-less session stores NULL (not a literal
745 745 // "unknown") in the money-keyed lookup column (Run 9).
746 - match db::tips::complete_tip(db, &session_id, session.payment_intent.as_deref())
746 + match db::tips::complete_tip(db, &session_id, session.payment_intent_id.as_deref())
747 747 .await
748 748 .context("complete tip")?
749 749 {
@@ -760,7 +760,7 @@
760 760 db,
761 761 None,
762 762 event_id,
763 - "checkout.session.completed.tip",
763 + MnwEventName::CheckoutCompletedTip,
764 764 &serde_json::json!({"session_id": session_id, "tip_id": tip.id}),
765 765 )
766 766 .await
@@ -808,7 +808,7 @@
808 808 // "duplicate", tips were the one checkout family without orphan
809 809 // escalation (Run 21 payments).
810 810 None => {
811 - let pi = session.payment_intent.as_deref().unwrap_or("");
811 + let pi = session.payment_intent_id.as_deref().unwrap_or("");
812 812 tracing::error!(
813 813 session_id = %session_id, payment_intent_id = %pi,
814 814 "orphaned paid session (tip): payment completed but no tip row exists, manual reconciliation required"
@@ -847,28 +847,28 @@
847 847 email: &EmailClient,
848 848 wam: Option<&WamClient>,
849 849 config: &Config,
850 - session: &crate::payments::CheckoutSessionView,
850 + session: &crate::payments::CheckoutCompletion,
851 851 _event_id: &str,
852 852 ) -> Result<()> {
853 853 use crate::payments::GuestCheckoutMetadata;
854 854
855 - let session_id = session.id.clone();
855 + let session_id = session.session_id.clone();
856 856 tracing::info!(session_id = %session_id, "processing completed guest checkout");
857 857
858 858 let meta = GuestCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
859 859
860 - // Extract buyer email from Stripe customer_details
860 + // Buyer email as Stripe collected it, flattened out of customer_details
861 + // during normalization.
861 862 let guest_email = session
862 - .customer_details
863 - .as_ref()
864 - .and_then(|cd| cd.email.as_deref())
863 + .customer_email
864 + .as_deref()
865 865 .unwrap_or("unknown@guest")
866 866 .to_string();
867 867
868 - // Display/logging copy only; the DB write below passes `session.payment_intent`
868 + // Display/logging copy only; the DB write below passes `session.payment_intent_id`
869 869 // directly so a PI-less session stores NULL, not a literal "unknown" that would
870 870 // collide with other PI-less rows in the money-keyed lookup column (Run 9).
871 - let payment_intent_id = session.payment_intent.clone().unwrap_or_default();
871 + let payment_intent_id = session.payment_intent_id.clone().unwrap_or_default();
872 872
873 873 // Complete the guest transaction and increment sales count in a single DB transaction
874 874 // (matching the non-guest path pattern to prevent counter drift on partial failure)
@@ -886,7 +886,7 @@
886 886 match db::transactions::complete_guest_transaction(
887 887 &mut *db_tx,
888 888 &session_id,
889 - session.payment_intent.as_deref(),
889 + session.payment_intent_id.as_deref(),
890 890 &guest_email,
891 891 )
892 892 .await?
@@ -989,20 +989,20 @@
989 989 #[tracing::instrument(skip_all, name = "stripe::handle_synckit_app_sub_checkout")]
990 990 pub(super) async fn handle_synckit_app_sub_checkout_completed(
991 991 db: &PgPool,
992 - session: &crate::payments::CheckoutSessionView,
992 + session: &crate::payments::CheckoutCompletion,
993 993 event_id: &str,
994 994 ) -> Result<()> {
995 - let session_id = session.id.clone();
995 + let session_id = session.session_id.clone();
996 996 tracing::info!(session_id = %session_id, "processing completed SyncKit app subscription checkout");
997 997
998 998 let meta = SynckitAppSubCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
999 999
1000 1000 let stripe_subscription_id = session
1001 - .subscription
1001 + .subscription_id
1002 1002 .clone()
1003 1003 .ok_or_else(|| AppError::BadRequest("Missing subscription ID on session".to_string()))?;
1004 1004 let stripe_customer_id = session
1005 - .customer
1005 + .customer_id
1006 1006 .clone()
1007 1007 .ok_or_else(|| AppError::BadRequest("Missing customer ID on session".to_string()))?;
1008 1008
@@ -19,10 +19,7 @@
19 19 db,
20 20 email::EmailClient,
21 21 error::{AppError, Result, ResultExt},
22 - payments::{
23 - self, AccountUpdate, AccountView, ChargeRefundData, ChargeView, CheckoutSessionView,
24 - InvoiceView, RefundView, SubscriptionView, UntypedEvent,
25 - },
22 + payments::{AccountUpdate, CheckoutCompletion, CheckoutKind, MnwEvent, UntypedEvent},
26 23 wam_client::WamClient,
27 24 };
28 25
@@ -109,18 +106,26 @@
109 106 type_: event_type_str,
110 107 data_object,
111 108 } = event;
112 - let result = process_webhook_event(
113 - &db,
114 - &bg,
115 - &email,
116 - integrations.wam.as_ref(),
117 - &payments,
118 - &config,
119 - &event_type_str,
120 - &event_id,
121 - data_object,
122 - )
123 - .await;
109 + // 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
111 + // fails here, with the same wording and the same retry-queue treatment it
112 + // had when each match arm parsed for itself.
113 + let result = match MnwEvent::normalize(&event_type_str, data_object) {
114 + Ok(mnw_event) => {
115 + process_webhook_event(
116 + &db,
117 + &bg,
118 + &email,
119 + integrations.wam.as_ref(),
120 + &payments,
121 + &config,
122 + mnw_event,
123 + &event_id,
124 + )
125 + .await
126 + }
127 + Err(e) => Err(e),
128 + };
124 129
125 130 match result {
126 131 Ok(()) => {
@@ -158,12 +163,16 @@
158 163 Ok(StatusCode::OK)
159 164 }
160 165
161 - /// Process a verified Stripe webhook event. Extracted to allow the caller
162 - /// to catch errors and persist to the retry queue. Also called by the
163 - /// scheduler's webhook retry worker.
164 - /// Dispatch a verified Stripe webhook event. Consumes `data_object` exactly
165 - /// once into a typed rc.5 struct based on `event_type`. Shared by the live
166 - /// webhook handler and the scheduler retry worker.
166 + /// Dispatch a normalized webhook event.
167 + ///
168 + /// Extracted so the caller can catch errors and persist to the retry queue;
169 + /// shared by the live webhook handler and the scheduler's retry worker, which
170 + /// is the point — both normalize through [`MnwEvent`] first, so neither can
171 + /// grow its own idea of what an event type means.
172 + ///
173 + /// The match is on an enum, so an unhandled Stripe type is
174 + /// [`MnwEvent::Unhandled`] by construction. A misspelt string can no longer
175 + /// become a silently ignored event.
167 176 #[allow(clippy::too_many_arguments)]
168 177 pub(crate) async fn process_webhook_event(
169 178 db: &PgPool,
@@ -172,107 +181,80 @@
172 181 wam: Option<&WamClient>,
173 182 payments: &Billing,
174 183 config: &Config,
175 - event_type: &str,
184 + event: MnwEvent,
176 185 event_id: &str,
177 - data_object: serde_json::Value,
178 186 ) -> Result<()> {
179 - match event_type {
180 - // Both events route through the same dispatcher. `completed` fires
181 - // immediately; for asynchronous payment methods (ACH/SEPA/Bacs) it
182 - // arrives with payment_status="unpaid" and the money-taking handlers
183 - // defer until `async_payment_succeeded` re-delivers the settled session.
184 - "checkout.session.completed" | "checkout.session.async_payment_succeeded" => {
185 - let session: CheckoutSessionView =
186 - serde_json::from_value(data_object).map_err(|e| {
187 - AppError::BadRequest(format!("Failed to parse CheckoutSession: {e}"))
188 - })?;
189 - dispatch_checkout_session(db, bg, email, wam, payments, config, &session, event_id)
190 - .await?;
191 - }
192 - // The buyer's async payment (ACH/SEPA/Bacs) never cleared. No funds were
193 - // captured, so there is nothing to deliver; the pending transaction (and
194 - // any reserved promo hold) is released by the stale-pending cleanup
195 - // sweeper. Logged for visibility rather than silently dropped.
196 - "checkout.session.async_payment_failed" => {
197 - let session: CheckoutSessionView =
198 - serde_json::from_value(data_object).map_err(|e| {
199 - AppError::BadRequest(format!("Failed to parse CheckoutSession: {e}"))
200 - })?;
201 - tracing::warn!(
202 - session_id = %session.id,
203 - "checkout async payment failed; no funds captured, pending rows will be released by cleanup"
204 - );
205 - }
206 - "account.updated" => {
207 - let account: AccountView = serde_json::from_value(data_object)
208 - .map_err(|e| AppError::BadRequest(format!("Failed to parse Account: {e}")))?;
209 - handle_account_updated(
210 - db,
211 - wam,
212 - &config.signing_secret,
213 - &AccountUpdate::from(account),
187 + match event {
188 + MnwEvent::Checkout { kind, session } => {
189 + dispatch_checkout_session(
190 + db, bg, email, wam, payments, config, kind, &session, event_id,
214 191 )
215 192 .await?;
216 193 }
217 - "charge.refunded" => {
218 - let charge: ChargeView = serde_json::from_value(data_object)
219 - .map_err(|e| AppError::BadRequest(format!("Failed to parse Charge: {e}")))?;
220 - if let Some(refund_data) = ChargeRefundData::from_view(charge) {
221 - // Direct webhook: queue as pending if the matching payment hasn't
222 - // landed yet. Out-of-band (dashboard) FULL refunds are handled
223 - // here; per-line refunds land via refund.created below.
224 - billing::handle_charge_refunded(db, &refund_data, true).await?;
225 - }
194 + // No funds were captured, so there is nothing to deliver; the pending
195 + // transaction (and any reserved promo hold) is released by the
196 + // stale-pending cleanup sweeper. Logged rather than silently dropped.
197 + MnwEvent::CheckoutAsyncPaymentFailed { session_id } => {
198 + tracing::warn!(
199 + %session_id,
200 + "checkout async payment failed; no funds captured, pending rows will be released by cleanup"
201 + );
226 202 }
227 - "refund.created" | "refund.updated" => {
228 - // Line-scoped self-service refunds tag the Stripe refund with
229 - // mnw_transaction_id; this marks/revokes exactly that transaction so
230 - // a cart line refund leaves the order's other lines untouched
231 - // (Run #2 Payments SERIOUS). Untagged refunds are no-ops here.
232 - let refund: RefundView = serde_json::from_value(data_object)
233 - .map_err(|e| AppError::BadRequest(format!("Failed to parse Refund: {e}")))?;
203 + MnwEvent::AccountUpdated(update) => {
204 + handle_account_updated(db, wam, &config.signing_secret, &update).await?;
205 + }
206 + // Direct webhook: queue as pending if the matching payment hasn't landed
207 + // yet. Out-of-band (dashboard) FULL refunds are handled here; per-line
208 + // refunds land via RefundSettled below. A charge with no payment intent
209 + // is out of scope and normalizes to `None`.
210 + MnwEvent::ChargeRefunded(Some(refund_data)) => {
211 + billing::handle_charge_refunded(db, &refund_data, true).await?;
212 + }
213 + MnwEvent::ChargeRefunded(None) => {}
214 + // Line-scoped self-service refunds tag the Stripe refund with
215 + // mnw_transaction_id; this marks/revokes exactly that transaction so a
216 + // cart line refund leaves the order's other lines untouched (Run #2
217 + // Payments SERIOUS). Untagged refunds are no-ops here.
218 + MnwEvent::RefundSettled(refund) => {
234 219 billing::handle_refund_created(db, &refund).await?;
235 220 }
236 - "customer.subscription.updated" => {
237 - let sub: SubscriptionView = serde_json::from_value(data_object)
238 - .map_err(|e| AppError::BadRequest(format!("Failed to parse Subscription: {e}")))?;
221 + MnwEvent::SubscriptionUpdated(sub) => {
239 222 subscriptions::handle_subscription_updated(db, &sub, event_id).await?;
240 223 }
241 - "customer.subscription.deleted" => {
242 - let sub: SubscriptionView = serde_json::from_value(data_object)
243 - .map_err(|e| AppError::BadRequest(format!("Failed to parse Subscription: {e}")))?;
224 + MnwEvent::SubscriptionDeleted(sub) => {
244 225 subscriptions::handle_subscription_deleted(db, bg, email, &sub, event_id).await?;
245 226 }
246 - "invoice.payment_succeeded" => {
247 - let invoice: InvoiceView = serde_json::from_value(data_object)
248 - .map_err(|e| AppError::BadRequest(format!("Failed to parse Invoice: {e}")))?;
227 + MnwEvent::InvoicePaymentSucceeded(invoice) => {
249 228 billing::handle_invoice_payment_succeeded(db, bg, email, wam, &invoice, event_id)
250 229 .await?;
251 230 }
252 - "invoice.payment_failed" => {
253 - let invoice: InvoiceView = serde_json::from_value(data_object)
254 - .map_err(|e| AppError::BadRequest(format!("Failed to parse Invoice: {e}")))?;
231 + MnwEvent::InvoicePaymentFailed(invoice) => {
255 232 billing::handle_invoice_payment_failed(db, wam, &invoice, event_id).await?;
256 233 }
257 - other => {
258 - tracing::debug!(event_type = %other, "unhandled webhook event type");
234 + MnwEvent::Unhandled { stripe_type } => {
235 + tracing::debug!(event_type = %stripe_type, "unhandled webhook event type");
259 236 }
260 237 }
261 238
262 239 Ok(())
263 240 }
264 241
265 - /// Route a checkout session to its handler by metadata shape.
242 + /// Route a completed checkout to its handler.
243 + ///
244 + /// The kind was settled during normalization, from the metadata MNW itself
245 + /// wrote at checkout creation, so this is a match rather than a ladder of
246 + /// predicates.
266 247 ///
267 248 /// Subscription-mode sessions (Fan+, creator tier, SyncKit app sub, project
268 - /// subscription) capture no funds at checkout, the subscription lifecycle bills
269 - /// separately, so they run unconditionally. One-time payment-mode sessions
270 - /// (tip, guest, cart, single purchase) capture funds now and are therefore
271 - /// gated on `payment_settled()`: an async method that reports `payment_status
272 - /// = "unpaid"` on `checkout.session.completed` is deferred until Stripe
273 - /// re-delivers the settled session via `checkout.session.async_payment_succeeded`.
274 - /// Without this gate, enabling any async payment method on a connected account
275 - /// would mint license keys and grant downloads before funds settle.
249 + /// subscription) capture no funds at checkout — the subscription lifecycle
250 + /// bills separately — so they run unconditionally. One-time payment-mode
251 + /// sessions (tip, guest, cart, single purchase) capture funds now and are
252 + /// therefore gated on `settled`: an async method that reports
253 + /// `payment_status = "unpaid"` on `checkout.session.completed` is deferred
254 + /// until Stripe re-delivers the settled session via
255 + /// `checkout.session.async_payment_succeeded`. Without this gate, enabling any
256 + /// async payment method on a connected account would mint license keys and
257 + /// grant downloads before funds settle.
276 258 #[allow(clippy::too_many_arguments)]
277 259 async fn dispatch_checkout_session(
278 260 db: &PgPool,
@@ -281,51 +263,57 @@
281 263 wam: Option<&WamClient>,
282 264 payments: &Billing,
283 265 config: &Config,
284 - session: &CheckoutSessionView,
266 + kind: CheckoutKind,
267 + session: &CheckoutCompletion,
285 268 event_id: &str,
286 269 ) -> Result<()> {
287 - let meta = session.metadata.as_ref();
288 -
289 - // Subscription-mode: no funds captured at checkout, no settlement gate.
290 - if payments::is_fan_plus_checkout(meta) {
291 - return checkout::handle_fan_plus_checkout_completed(db, bg, email, session, event_id)
292 - .await;
293 - }
294 - if payments::is_creator_tier_checkout(meta) {
295 - return checkout::handle_creator_tier_checkout_completed(
296 - db, bg, wam, payments, session, event_id,
297 - )
298 - .await;
299 - }
300 - if payments::is_synckit_app_sub_checkout(meta) {
301 - return checkout::handle_synckit_app_sub_checkout_completed(db, session, event_id).await;
302 - }
303 - if payments::is_subscription_checkout(meta) {
304 - return checkout::handle_subscription_checkout_completed(db, bg, email, session, event_id)
305 - .await;
306 - }
307 -
308 270 // One-time payment-mode: funds captured now. Deliver only once settled.
309 - if !session.payment_settled() {
271 + // Asked before the match so the gate cannot be forgotten on a new
272 + // funds-capturing kind: `captures_funds_at_checkout` is exhaustive over
273 + // `CheckoutKind`, so adding one is a compile error until it answers.
274 + if kind.captures_funds_at_checkout() && !session.settled {
310 275 tracing::info!(
311 - session_id = %session.id,
312 - payment_status = ?session.payment_status,
276 + session_id = %session.session_id,
277 + ?kind,
313 278 "one-time checkout not yet settled (async payment); deferring finalize until async_payment_succeeded"
314 279 );
315 280 return Ok(());
316 281 }
317 282
318 - if payments::is_tip_checkout(meta) {
319 - checkout::handle_tip_checkout_completed(db, bg, email, wam, config, session, event_id).await
320 - } else if payments::is_guest_checkout(meta) {
321 - checkout::handle_guest_checkout_completed(db, bg, email, wam, config, session, event_id)
283 + match kind {
284 + CheckoutKind::FanPlus => {
285 + checkout::handle_fan_plus_checkout_completed(db, bg, email, session, event_id).await
286 + }
287 + CheckoutKind::CreatorTier => {
288 + checkout::handle_creator_tier_checkout_completed(
289 + db, bg, wam, payments, session, event_id,
290 + )
322 291 .await
323 - } else if payments::is_cart_checkout(meta) {
324 - checkout::handle_cart_checkout_completed(db, bg, email, wam, config, session, event_id)
325 - .await
326 - } else {
327 - checkout::handle_purchase_checkout_completed(db, bg, email, wam, config, session, event_id)
292 + }
293 + CheckoutKind::SyncKitAppSub => {
294 + checkout::handle_synckit_app_sub_checkout_completed(db, session, event_id).await
295 + }
296 + CheckoutKind::ProjectSubscription => {
297 + checkout::handle_subscription_checkout_completed(db, bg, email, session, event_id).await
298 + }
299 + CheckoutKind::Tip => {
300 + checkout::handle_tip_checkout_completed(db, bg, email, wam, config, session, event_id)
301 + .await
302 + }
303 + CheckoutKind::Guest => {
304 + checkout::handle_guest_checkout_completed(db, bg, email, wam, config, session, event_id)
305 + .await
306 + }
307 + CheckoutKind::Cart => {
308 + checkout::handle_cart_checkout_completed(db, bg, email, wam, config, session, event_id)
309 + .await
310 + }
311 + CheckoutKind::Purchase => {
312 + checkout::handle_purchase_checkout_completed(
313 + db, bg, email, wam, config, session, event_id,
314 + )
328 315 .await
316 + }
329 317 }
330 318 }
331 319
@@ -4,6 +4,7 @@
4 4 db::{self, SubscriptionStatus},
5 5 email::EmailClient,
6 6 error::{Result, ResultExt},
7 + payments::{MnwEventName, SubscriptionProduct},
7 8 };
8 9 use sqlx::PgPool;
9 10
@@ -35,10 +36,10 @@
35 36 /// Handle customer.subscription.updated; update status + period
36 37 pub(super) async fn handle_subscription_updated(
37 38 db: &PgPool,
38 - sub: &crate::payments::SubscriptionView,
39 + sub: &crate::payments::SubscriptionLifecycle,
39 40 event_id: &str,
40 41 ) -> Result<()> {
41 - let stripe_sub_id = sub.id.clone();
42 + let stripe_sub_id = sub.stripe_subscription_id.clone();
42 43 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription updated");
43 44
44 45 // SyncKit v2 developer subscription? If Stripe moved it to past_due/unpaid,
@@ -62,7 +63,7 @@
62 63 db,
63 64 None,
64 65 event_id,
65 - "customer.subscription.updated.synckit",
66 + MnwEventName::SubscriptionUpdated(SubscriptionProduct::SyncKit),
66 67 &serde_json::json!({"status": sub.status, "synckit_app_id": app_id.to_string()}),
67 68 )
68 69 .await
@@ -82,7 +83,7 @@
82 83 db,
83 84 &stripe_sub_id,
84 85 sub.status.as_str(),
85 - sub.current_period().map(|(_, end)| end),
86 + sub.current_period.map(|(_, end)| end),
86 87 )
87 88 .await
88 89 .context("update app sync subscription status")?;
@@ -90,7 +91,7 @@
90 91 db,
91 92 None,
92 93 event_id,
93 - "customer.subscription.updated.synckit_app_sub",
94 + MnwEventName::SubscriptionUpdated(SubscriptionProduct::SyncKitAppSub),
94 95 &serde_json::json!({"status": sub.status}),
95 96 )
96 97 .await
@@ -114,7 +115,7 @@
114 115 // revived nor period-refreshed by an out-of-order update. The raw Stripe
115 116 // period goes straight to the writer, which drops a missing/zero end so
116 117 // an active row never gets an epoch period (CHRONIC C is sealed there).
117 - db::fan_plus::apply_stripe_update(db, &stripe_sub_id, Some(status), sub.current_period())
118 + db::fan_plus::apply_stripe_update(db, &stripe_sub_id, Some(status), sub.current_period)
118 119 .await
119 120 .context("apply fan plus update")?;
120 121
@@ -128,7 +129,7 @@
128 129 db,
129 130 None,
130 131 event_id,
131 - "customer.subscription.updated.fan_plus",
132 + MnwEventName::SubscriptionUpdated(SubscriptionProduct::FanPlus),
132 133 &serde_json::json!({"status": status_str}),
133 134 )
134 135 .await
@@ -155,7 +156,7 @@
155 156 db,
156 157 &stripe_sub_id,
157 158 Some(status),
158 - sub.current_period(),
159 + sub.current_period,
159 160 )
160 161 .await
161 162 .context("apply creator sub update")?;
@@ -169,7 +170,7 @@
169 170 db,
170 171 None,
171 172 event_id,
172 - "customer.subscription.updated.creator_tier",
173 + MnwEventName::SubscriptionUpdated(SubscriptionProduct::CreatorTier),
173 174 &serde_json::json!({"status": status_str}),
174 175 )
175 176 .await
@@ -193,7 +194,7 @@
193 194 db,
194 195 &stripe_sub_id,
195 196 Some(status),
196 - sub.current_period(),
197 + sub.current_period,
197 198 )
198 199 .await
199 200 .context("apply subscription update")?;
@@ -204,7 +205,7 @@
204 205 db,
205 206 sub_id,
206 207 event_id,
207 - "customer.subscription.updated",
208 + MnwEventName::SubscriptionUpdated(SubscriptionProduct::Undetermined),
208 209 &serde_json::json!({"status": status.to_string()}),
209 210 )
210 211 .await
@@ -220,10 +221,10 @@
220 221 db: &PgPool,
221 222 bg: &crate::background::BackgroundTx,
222 223 email: &EmailClient,
223 - sub: &crate::payments::SubscriptionView,
224 + sub: &crate::payments::SubscriptionLifecycle,
224 225 event_id: &str,
225 226 ) -> Result<()> {
226 - let stripe_sub_id = sub.id.clone();
227 + let stripe_sub_id = sub.stripe_subscription_id.clone();
227 228 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription deleted");
228 229
229 230 // SyncKit v2 developer subscription? Flip to 'canceled'.
@@ -235,7 +236,7 @@
235 236 .await
236 237 .context("synckit billing -> canceled")?;
237 238 if let Err(e) = db::subscriptions::log_subscription_event(
238 - db, None, event_id, "customer.subscription.deleted.synckit",
239 + db, None, event_id, MnwEventName::SubscriptionDeleted(SubscriptionProduct::SyncKit),
239 240 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}),
240 241 ).await {
241 242 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
@@ -261,7 +262,7 @@
261 262 db,
262 263 None,
263 264 event_id,
264 - "customer.subscription.deleted.synckit_app_sub",
265 + MnwEventName::SubscriptionDeleted(SubscriptionProduct::SyncKitAppSub),
265 266 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
266 267 )
267 268 .await
@@ -300,7 +301,7 @@
300 301 db,
301 302 None,
302 303 event_id,
303 - "customer.subscription.deleted.fan_plus",
304 + MnwEventName::SubscriptionDeleted(SubscriptionProduct::FanPlus),
304 305 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
305 306 )
306 307 .await
@@ -331,7 +332,7 @@
331 332 db,
332 333 None,
333 334 event_id,
334 - "customer.subscription.deleted.creator_tier",
335 + MnwEventName::SubscriptionDeleted(SubscriptionProduct::CreatorTier),
335 336 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
336 337 )
337 338 .await
@@ -385,7 +386,7 @@
385 386 db,
386 387 sub_id,
387 388 event_id,
388 - "customer.subscription.deleted",
389 + MnwEventName::SubscriptionDeleted(SubscriptionProduct::Undetermined),
389 390 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
390 391 )
391 392 .await