Skip to main content

max / makenotwork

15.6 KB · 437 lines History Blame Raw
1 //! Webhook handlers for subscription lifecycle events (updated, deleted).
2
3 use crate::{
4 db::{self, SubscriptionStatus},
5 email::EmailClient,
6 error::{Result, ResultExt},
7 payments::{MnwEventName, SubscriptionProduct},
8 };
9 use sqlx::PgPool;
10
11 /// Parse a Stripe subscription status, returning `None` for unknown values.
12 ///
13 /// Stripe periodically adds statuses (e.g. `paused`). Returning an error here
14 /// would propagate `Err` from the webhook handler and pin Stripe in an infinite
15 /// retry storm for any subscription stuck in the new state. Instead, log and
16 /// no-op so the next known-status update naturally resyncs.
17 fn parse_status_or_log(
18 status_str: &str,
19 event_id: &str,
20 stripe_sub_id: &str,
21 ) -> Option<SubscriptionStatus> {
22 match status_str.parse::<SubscriptionStatus>() {
23 Ok(s) => Some(s),
24 Err(_) => {
25 tracing::warn!(
26 event_id = %event_id,
27 stripe_sub_id = %stripe_sub_id,
28 status = %status_str,
29 "skipping subscription update: unknown stripe status (treat as no-op so stripe stops retrying)"
30 );
31 None
32 }
33 }
34 }
35
36 /// Handle customer.subscription.updated; update status + period
37 pub(super) async fn handle_subscription_updated(
38 db: &PgPool,
39 sub: &crate::payments::SubscriptionLifecycle,
40 event_id: &str,
41 ) -> Result<()> {
42 let stripe_sub_id = sub.stripe_subscription_id.clone();
43 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription updated");
44
45 // SyncKit v2 developer subscription? If Stripe moved it to past_due/unpaid,
46 // mirror that as suspended_unpaid. Active or trialing → 'active'.
47 if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(db, &stripe_sub_id)
48 .await
49 .context("fetch synckit app by stripe sub id")?
50 {
51 let new_status = match sub.status.as_str() {
52 "past_due" | "unpaid" => Some("suspended_unpaid"),
53 "canceled" => Some("canceled"),
54 "active" | "trialing" => Some("active"),
55 _ => None,
56 };
57 if let Some(s) = new_status {
58 db::synckit_billing::apply_billing_update(db, app_id, Some(s), None)
59 .await
60 .context("synckit apply_billing_update")?;
61 }
62 if let Err(e) = db::subscriptions::log_subscription_event(
63 db,
64 None,
65 event_id,
66 MnwEventName::SubscriptionUpdated(SubscriptionProduct::SyncKit),
67 &serde_json::json!({"status": sub.status, "synckit_app_id": app_id.to_string()}),
68 )
69 .await
70 {
71 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
72 }
73 return Ok(());
74 }
75
76 // Check if this is an end-user SyncKit app subscription.
77 if db::synckit::get_subscription_by_stripe_id(db, &stripe_sub_id)
78 .await
79 .context("fetch app sync subscription by stripe id")?
80 .is_some()
81 {
82 db::synckit::update_app_sync_subscription_status(
83 db,
84 &stripe_sub_id,
85 sub.status.as_str(),
86 sub.current_period.map(|(_, end)| end),
87 )
88 .await
89 .context("update app sync subscription status")?;
90 if let Err(e) = db::subscriptions::log_subscription_event(
91 db,
92 None,
93 event_id,
94 MnwEventName::SubscriptionUpdated(SubscriptionProduct::SyncKitAppSub),
95 &serde_json::json!({"status": sub.status}),
96 )
97 .await
98 {
99 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
100 }
101 return Ok(());
102 }
103
104 // Check if this is a Fan+ subscription
105 if let Some(_fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(db, &stripe_sub_id)
106 .await
107 .context("fetch fan plus by stripe id")?
108 {
109 let status_str = sub.status.as_str();
110 let Some(status) = parse_status_or_log(status_str, event_id, &stripe_sub_id) else {
111 return Ok(());
112 };
113
114 // Status + period in one guarded write: a canceled Fan+ sub is neither
115 // revived nor period-refreshed by an out-of-order update. The raw Stripe
116 // period goes straight to the writer, which drops a missing/zero end so
117 // an active row never gets an epoch period (CHRONIC C is sealed there).
118 db::fan_plus::apply_stripe_update(db, &stripe_sub_id, Some(status), sub.current_period)
119 .await
120 .context("apply fan plus update")?;
121
122 // Keep the dashboard flag in sync with Stripe, covers cancellation
123 // initiated via the customer portal as well as our dashboard route.
124 db::fan_plus::set_cancel_at_period_end(db, &stripe_sub_id, sub.cancel_at_period_end)
125 .await
126 .context("sync fan plus cancel_at_period_end")?;
127
128 if let Err(e) = db::subscriptions::log_subscription_event(
129 db,
130 None,
131 event_id,
132 MnwEventName::SubscriptionUpdated(SubscriptionProduct::FanPlus),
133 &serde_json::json!({"status": status_str}),
134 )
135 .await
136 {
137 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
138 }
139 return Ok(());
140 }
141
142 // Check if this is a creator tier subscription
143 if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_stripe_id(db, &stripe_sub_id)
144 .await
145 .context("fetch creator sub by stripe id")?
146 {
147 let status_str = sub.status.as_str();
148 let Some(status) = parse_status_or_log(status_str, event_id, &stripe_sub_id) else {
149 return Ok(());
150 };
151
152 // Status + period in one guarded write (canceled is terminal for both).
153 // Raw Stripe period to the writer; it drops a missing/zero end so an
154 // active row never gets an epoch period (CHRONIC C is sealed there).
155 db::creator_tiers::apply_stripe_update(
156 db,
157 &stripe_sub_id,
158 Some(status),
159 sub.current_period,
160 )
161 .await
162 .context("apply creator sub update")?;
163
164 // Sync the denormalized creator_tier column on users
165 db::creator_tiers::sync_user_creator_tier(db, ct_sub.user_id)
166 .await
167 .context("sync user creator tier")?;
168
169 if let Err(e) = db::subscriptions::log_subscription_event(
170 db,
171 None,
172 event_id,
173 MnwEventName::SubscriptionUpdated(SubscriptionProduct::CreatorTier),
174 &serde_json::json!({"status": status_str}),
175 )
176 .await
177 {
178 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
179 }
180 return Ok(());
181 }
182
183 let status_str = sub.status.as_str();
184 let Some(status) = parse_status_or_log(status_str, event_id, &stripe_sub_id) else {
185 return Ok(());
186 };
187
188 // Status + period in one guarded statement, `canceled` is terminal for both,
189 // so a late `updated`(active) can neither revive the row nor refresh its
190 // period. The raw Stripe period goes straight to the writer, which drops a
191 // missing/zero end (the old `unwrap_or((0,0))` + `stripe_timestamp(0)` here
192 // stamped 1970-01-01 and cut off paying fans, CHRONIC C, now sealed).
193 let updated = db::subscriptions::apply_stripe_update(
194 db,
195 &stripe_sub_id,
196 Some(status),
197 sub.current_period,
198 )
199 .await
200 .context("apply subscription update")?;
201
202 // Log event
203 let sub_id = updated.as_ref().map(|s| s.id);
204 if let Err(e) = db::subscriptions::log_subscription_event(
205 db,
206 sub_id,
207 event_id,
208 MnwEventName::SubscriptionUpdated(SubscriptionProduct::Undetermined),
209 &serde_json::json!({"status": status.to_string()}),
210 )
211 .await
212 {
213 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
214 }
215
216 Ok(())
217 }
218
219 /// Handle customer.subscription.deleted; mark canceled, send email
220 pub(super) async fn handle_subscription_deleted(
221 db: &PgPool,
222 bg: &crate::background::BackgroundTx,
223 email: &EmailClient,
224 sub: &crate::payments::SubscriptionLifecycle,
225 event_id: &str,
226 ) -> Result<()> {
227 let stripe_sub_id = sub.stripe_subscription_id.clone();
228 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription deleted");
229
230 // SyncKit v2 developer subscription? Flip to 'canceled'.
231 if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(db, &stripe_sub_id)
232 .await
233 .context("fetch synckit app by stripe sub id")?
234 {
235 db::synckit_billing::apply_billing_update(db, app_id, Some("canceled"), None)
236 .await
237 .context("synckit billing -> canceled")?;
238 if let Err(e) = db::subscriptions::log_subscription_event(
239 db, None, event_id, MnwEventName::SubscriptionDeleted(SubscriptionProduct::SyncKit),
240 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}),
241 ).await {
242 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
243 }
244 return Ok(());
245 }
246
247 // Check if this is an end-user SyncKit app subscription.
248 if db::synckit::get_subscription_by_stripe_id(db, &stripe_sub_id)
249 .await
250 .context("fetch app sync subscription by stripe id")?
251 .is_some()
252 {
253 db::synckit::update_app_sync_subscription_status(
254 db,
255 &stripe_sub_id,
256 "canceled",
257 None::<i64>,
258 )
259 .await
260 .context("cancel app sync subscription")?;
261 if let Err(e) = db::subscriptions::log_subscription_event(
262 db,
263 None,
264 event_id,
265 MnwEventName::SubscriptionDeleted(SubscriptionProduct::SyncKitAppSub),
266 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
267 )
268 .await
269 {
270 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
271 }
272 return Ok(());
273 }
274
275 // Check if this is a Fan+ subscription
276 if let Some(fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(db, &stripe_sub_id)
277 .await
278 .context("fetch fan plus by stripe id")?
279 {
280 db::fan_plus::cancel_fan_plus(db, &stripe_sub_id)
281 .await
282 .context("cancel fan plus")?;
283
284 // Send cancellation email (fire-and-forget)
285 if let Ok(Some(user)) = db::users::get_user_by_id(db, fan_sub.user_id).await {
286 let period_end = fan_sub.current_period_end;
287 let user_email = user.email.clone();
288 let user_name = user.display_name;
289 let email = email.clone();
290 bg.spawn("Fan+ cancelled", async move {
291 if let Err(e) = email
292 .send_fan_plus_cancelled(&user_email, user_name.as_deref(), period_end.as_ref())
293 .await
294 {
295 tracing::error!(error = ?e, "failed to send Fan+ cancelled");
296 }
297 });
298 }
299
300 if let Err(e) = db::subscriptions::log_subscription_event(
301 db,
302 None,
303 event_id,
304 MnwEventName::SubscriptionDeleted(SubscriptionProduct::FanPlus),
305 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
306 )
307 .await
308 {
309 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
310 }
311 return Ok(());
312 }
313
314 // Check if this is a creator tier subscription
315 if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_stripe_id(db, &stripe_sub_id)
316 .await
317 .context("fetch creator sub by stripe id")?
318 {
319 db::creator_tiers::cancel_creator_sub(db, &stripe_sub_id)
320 .await
321 .context("cancel creator sub")?;
322 db::creator_tiers::sync_user_creator_tier(db, ct_sub.user_id)
323 .await
324 .context("sync user creator tier after cancel")?;
325
326 tracing::info!(
327 user_id = %ct_sub.user_id, tier = %ct_sub.tier,
328 "creator tier subscription canceled"
329 );
330
331 if let Err(e) = db::subscriptions::log_subscription_event(
332 db,
333 None,
334 event_id,
335 MnwEventName::SubscriptionDeleted(SubscriptionProduct::CreatorTier),
336 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
337 )
338 .await
339 {
340 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
341 }
342 return Ok(());
343 }
344
345 let canceled = db::subscriptions::cancel_subscription(db, &stripe_sub_id)
346 .await
347 .context("cancel subscription")?;
348
349 if let Some(ref db_sub) = canceled {
350 // Send cancellation email (fire-and-forget)
351 if let (Ok(Some(subscriber)), Ok(Some(tier)), Ok(Some(project))) = (
352 db::users::get_user_by_id(db, db_sub.subscriber_id).await,
353 db::subscriptions::get_subscription_tier_by_id(db, db_sub.tier_id).await,
354 async {
355 match db_sub.project_id {
356 Some(pid) => db::projects::get_project_by_id(db, pid).await,
357 None => Ok(None),
358 }
359 }
360 .await,
361 ) {
362 let sub_email = subscriber.email.clone();
363 let sub_name = subscriber.display_name;
364 let tier_name = tier.name;
365 let project_title = project.title;
366 let email = email.clone();
367 bg.spawn("subscription cancelled", async move {
368 if let Err(e) = email
369 .send_subscription_cancelled(
370 &sub_email,
371 sub_name.as_deref(),
372 &tier_name,
373 &project_title,
374 )
375 .await
376 {
377 tracing::error!(error = ?e, "failed to send subscription cancelled");
378 }
379 });
380 }
381 }
382
383 // Log event
384 let sub_id = canceled.as_ref().map(|s| s.id);
385 if let Err(e) = db::subscriptions::log_subscription_event(
386 db,
387 sub_id,
388 event_id,
389 MnwEventName::SubscriptionDeleted(SubscriptionProduct::Undetermined),
390 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
391 )
392 .await
393 {
394 tracing::error!(event_id = %event_id, error = ?e, "failed to log subscription event");
395 }
396
397 Ok(())
398 }
399
400 #[cfg(test)]
401 mod tests {
402 //! Status parsing for subscription webhooks. The contract that matters here
403 //! is what happens to a status Stripe has added since we last looked: it
404 //! must be a no-op, not an error, or the handler returns Err and Stripe
405 //! retries that event forever.
406
407 use super::*;
408
409 #[test]
410 fn every_known_status_parses() {
411 for s in [
412 "active",
413 "trialing",
414 "incomplete",
415 "incomplete_expired",
416 "past_due",
417 "canceled",
418 "unpaid",
419 ] {
420 assert!(
421 parse_status_or_log(s, "evt_1", "sub_1").is_some(),
422 "{s} is a status we handle and must parse"
423 );
424 }
425 }
426
427 #[test]
428 fn an_unknown_status_is_a_no_op_and_not_an_error() {
429 // `paused` is the real example: Stripe added it after this code was
430 // written. Returning Err here would pin Stripe in a retry storm for
431 // every subscription stuck in the new state.
432 assert!(parse_status_or_log("paused", "evt_1", "sub_1").is_none());
433 assert!(parse_status_or_log("", "evt_1", "sub_1").is_none());
434 assert!(parse_status_or_log("ACTIVE", "evt_1", "sub_1").is_none());
435 }
436 }
437