Skip to main content

max / makenotwork

12.9 KB · 278 lines History Blame Raw
1 //! Webhook handlers for subscription lifecycle events (updated, deleted).
2
3 use crate::{
4 db::{self, SubscriptionStatus},
5 error::{Result, ResultExt},
6 helpers::{spawn_email, stripe_timestamp},
7 AppState,
8 };
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(status_str: &str, event_id: &str, stripe_sub_id: &str) -> Option<SubscriptionStatus> {
17 match status_str.parse::<SubscriptionStatus>() {
18 Ok(s) => Some(s),
19 Err(_) => {
20 tracing::warn!(
21 event_id = %event_id,
22 stripe_sub_id = %stripe_sub_id,
23 status = %status_str,
24 "skipping subscription update: unknown stripe status (treat as no-op so stripe stops retrying)"
25 );
26 None
27 }
28 }
29 }
30
31 /// Handle customer.subscription.updated; update status + period
32 pub(super) async fn handle_subscription_updated(
33 state: &AppState,
34 sub: &crate::payments::SubscriptionView,
35 event_id: &str,
36 ) -> Result<()> {
37 let stripe_sub_id = sub.id.clone();
38 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription updated");
39
40 // SyncKit v2 developer subscription? If Stripe moved it to past_due/unpaid,
41 // mirror that as suspended_unpaid. Active or trialing → 'active'.
42 if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? {
43 let new_status = match sub.status.as_str() {
44 "past_due" | "unpaid" => Some("suspended_unpaid"),
45 "canceled" => Some("canceled"),
46 "active" | "trialing" => Some("active"),
47 _ => None,
48 };
49 if let Some(s) = new_status {
50 db::synckit_billing::set_billing_status(&state.db, app_id, s).await.context("synckit set_billing_status")?;
51 }
52 if let Err(e) = db::subscriptions::log_subscription_event(
53 &state.db, None, event_id, "customer.subscription.updated.synckit",
54 &serde_json::json!({"status": sub.status, "synckit_app_id": app_id.to_string()}),
55 ).await {
56 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
57 }
58 return Ok(());
59 }
60
61 // Check if this is an end-user SyncKit app subscription.
62 if db::synckit::get_subscription_by_stripe_id(&state.db, &stripe_sub_id)
63 .await
64 .context("fetch app sync subscription by stripe id")?
65 .is_some()
66 {
67 let (_, end_ts) = sub.current_period().unwrap_or((0, 0));
68 let period_end = if end_ts > 0 { Some(stripe_timestamp(end_ts)) } else { None };
69 db::synckit::update_app_sync_subscription_status(
70 &state.db,
71 &stripe_sub_id,
72 sub.status.as_str(),
73 period_end,
74 )
75 .await
76 .context("update app sync subscription status")?;
77 if let Err(e) = db::subscriptions::log_subscription_event(
78 &state.db, None, event_id, "customer.subscription.updated.synckit_app_sub",
79 &serde_json::json!({"status": sub.status}),
80 ).await {
81 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
82 }
83 return Ok(());
84 }
85
86 // Check if this is a Fan+ subscription
87 if let Some(_fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan plus by stripe id")? {
88 let status_str = sub.status.as_str();
89 let Some(status) = parse_status_or_log(status_str, event_id, &stripe_sub_id) else { return Ok(()); };
90 db::fan_plus::update_fan_plus_status(&state.db, &stripe_sub_id, status).await.context("update fan plus status")?;
91
92 let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0));
93 let period_start = stripe_timestamp(start_ts);
94 let period_end = stripe_timestamp(end_ts);
95 db::fan_plus::update_fan_plus_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update fan plus period")?;
96
97 // Keep the dashboard flag in sync with Stripe — covers cancellation
98 // initiated via the customer portal as well as our dashboard route.
99 db::fan_plus::set_cancel_at_period_end(&state.db, &stripe_sub_id, sub.cancel_at_period_end)
100 .await
101 .context("sync fan plus cancel_at_period_end")?;
102
103 if let Err(e) = db::subscriptions::log_subscription_event(
104 &state.db, None, event_id, "customer.subscription.updated.fan_plus",
105 &serde_json::json!({"status": status_str}),
106 ).await {
107 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
108 }
109 return Ok(());
110 }
111
112 // Check if this is a creator tier subscription
113 if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch creator sub by stripe id")? {
114 let status_str = sub.status.as_str();
115 let Some(status) = parse_status_or_log(status_str, event_id, &stripe_sub_id) else { return Ok(()); };
116 db::creator_tiers::update_creator_sub_status(&state.db, &stripe_sub_id, status).await.context("update creator sub status")?;
117
118 let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0));
119 let period_start = stripe_timestamp(start_ts);
120 let period_end = stripe_timestamp(end_ts);
121 db::creator_tiers::update_creator_sub_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update creator sub period")?;
122
123 // Sync the denormalized creator_tier column on users
124 db::creator_tiers::sync_user_creator_tier(&state.db, ct_sub.user_id).await.context("sync user creator tier")?;
125
126 if let Err(e) = db::subscriptions::log_subscription_event(
127 &state.db, None, event_id, "customer.subscription.updated.creator_tier",
128 &serde_json::json!({"status": status_str}),
129 ).await {
130 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
131 }
132 return Ok(());
133 }
134
135 let status_str = sub.status.as_str();
136 let Some(status) = parse_status_or_log(status_str, event_id, &stripe_sub_id) else { return Ok(()); };
137
138 // Update status + period atomically in a single transaction
139 let mut tx = state.db.begin().await.context("begin subscription update transaction")?;
140 let updated = db::subscriptions::update_subscription_status(&mut *tx, &stripe_sub_id, status).await.context("update subscription status")?;
141
142 let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0));
143 let period_start = stripe_timestamp(start_ts);
144 let period_end = stripe_timestamp(end_ts);
145 db::subscriptions::update_subscription_period(&mut *tx, &stripe_sub_id, period_start, period_end).await.context("update subscription period")?;
146 tx.commit().await.context("commit subscription update")?;
147
148 // Log event
149 let sub_id = updated.as_ref().map(|s| s.id);
150 if let Err(e) = db::subscriptions::log_subscription_event(
151 &state.db, sub_id, event_id, "customer.subscription.updated",
152 &serde_json::json!({"status": status.to_string()}),
153 ).await {
154 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
155 }
156
157 Ok(())
158 }
159
160 /// Handle customer.subscription.deleted; mark canceled, send email
161 pub(super) async fn handle_subscription_deleted(
162 state: &AppState,
163 sub: &crate::payments::SubscriptionView,
164 event_id: &str,
165 ) -> Result<()> {
166 let stripe_sub_id = sub.id.clone();
167 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription deleted");
168
169 // SyncKit v2 developer subscription? Flip to 'canceled'.
170 if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? {
171 db::synckit_billing::set_billing_status(&state.db, app_id, "canceled").await.context("synckit billing -> canceled")?;
172 if let Err(e) = db::subscriptions::log_subscription_event(
173 &state.db, None, event_id, "customer.subscription.deleted.synckit",
174 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}),
175 ).await {
176 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
177 }
178 return Ok(());
179 }
180
181 // Check if this is an end-user SyncKit app subscription.
182 if db::synckit::get_subscription_by_stripe_id(&state.db, &stripe_sub_id)
183 .await
184 .context("fetch app sync subscription by stripe id")?
185 .is_some()
186 {
187 db::synckit::update_app_sync_subscription_status(
188 &state.db, &stripe_sub_id, "canceled", None,
189 )
190 .await
191 .context("cancel app sync subscription")?;
192 if let Err(e) = db::subscriptions::log_subscription_event(
193 &state.db, None, event_id, "customer.subscription.deleted.synckit_app_sub",
194 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
195 ).await {
196 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
197 }
198 return Ok(());
199 }
200
201 // Check if this is a Fan+ subscription
202 if let Some(fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan plus by stripe id")? {
203 db::fan_plus::cancel_fan_plus(&state.db, &stripe_sub_id).await.context("cancel fan plus")?;
204
205 // Send cancellation email (fire-and-forget)
206 if let Ok(Some(user)) = db::users::get_user_by_id(&state.db, fan_sub.user_id).await {
207 let period_end = fan_sub.current_period_end;
208 let user_email = user.email.clone();
209 let user_name = user.display_name.clone();
210 spawn_email!(state, "Fan+ cancelled", |email| {
211 email.send_fan_plus_cancelled(&user_email, user_name.as_deref(), period_end.as_ref())
212 });
213 }
214
215 if let Err(e) = db::subscriptions::log_subscription_event(
216 &state.db, None, event_id, "customer.subscription.deleted.fan_plus",
217 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
218 ).await {
219 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
220 }
221 return Ok(());
222 }
223
224 // Check if this is a creator tier subscription
225 if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch creator sub by stripe id")? {
226 db::creator_tiers::cancel_creator_sub(&state.db, &stripe_sub_id).await.context("cancel creator sub")?;
227 db::creator_tiers::sync_user_creator_tier(&state.db, ct_sub.user_id).await.context("sync user creator tier after cancel")?;
228
229 tracing::info!(
230 user_id = %ct_sub.user_id, tier = %ct_sub.tier,
231 "creator tier subscription canceled"
232 );
233
234 if let Err(e) = db::subscriptions::log_subscription_event(
235 &state.db, None, event_id, "customer.subscription.deleted.creator_tier",
236 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
237 ).await {
238 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
239 }
240 return Ok(());
241 }
242
243 let canceled = db::subscriptions::cancel_subscription(&state.db, &stripe_sub_id).await.context("cancel subscription")?;
244
245 if let Some(ref db_sub) = canceled {
246 // Send cancellation email (fire-and-forget)
247 if let (Ok(Some(subscriber)), Ok(Some(tier)), Ok(Some(project))) = (
248 db::users::get_user_by_id(&state.db, db_sub.subscriber_id).await,
249 db::subscriptions::get_subscription_tier_by_id(&state.db, db_sub.tier_id).await,
250 async { match db_sub.project_id { Some(pid) => db::projects::get_project_by_id(&state.db, pid).await, None => Ok(None) } }.await,
251 ) {
252 let sub_email = subscriber.email.clone();
253 let sub_name = subscriber.display_name.clone();
254 let tier_name = tier.name.clone();
255 let project_title = project.title.clone();
256 spawn_email!(state, "subscription cancelled", |email| {
257 email.send_subscription_cancelled(
258 &sub_email,
259 sub_name.as_deref(),
260 &tier_name,
261 &project_title,
262 )
263 });
264 }
265 }
266
267 // Log event
268 let sub_id = canceled.as_ref().map(|s| s.id);
269 if let Err(e) = db::subscriptions::log_subscription_event(
270 &state.db, sub_id, event_id, "customer.subscription.deleted",
271 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
272 ).await {
273 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
274 }
275
276 Ok(())
277 }
278