Skip to main content

max / makenotwork

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