max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 |