Skip to main content

max / makenotwork

12.7 KB · 273 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::apply_billing_update(&state.db, app_id, Some(s), None).await.context("synckit apply_billing_update")?;
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
91 // Status + period in one guarded write: a canceled Fan+ sub is neither
92 // revived nor period-refreshed by an out-of-order update.
93 let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0));
94 let period = Some((stripe_timestamp(start_ts), stripe_timestamp(end_ts)));
95 db::fan_plus::apply_stripe_update(&state.db, &stripe_sub_id, Some(status), period).await.context("apply fan plus update")?;
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
117 // Status + period in one guarded write (canceled is terminal for both).
118 let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0));
119 let period = Some((stripe_timestamp(start_ts), stripe_timestamp(end_ts)));
120 db::creator_tiers::apply_stripe_update(&state.db, &stripe_sub_id, Some(status), period).await.context("apply creator sub update")?;
121
122 // Sync the denormalized creator_tier column on users
123 db::creator_tiers::sync_user_creator_tier(&state.db, ct_sub.user_id).await.context("sync user creator tier")?;
124
125 if let Err(e) = db::subscriptions::log_subscription_event(
126 &state.db, None, event_id, "customer.subscription.updated.creator_tier",
127 &serde_json::json!({"status": status_str}),
128 ).await {
129 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
130 }
131 return Ok(());
132 }
133
134 let status_str = sub.status.as_str();
135 let Some(status) = parse_status_or_log(status_str, event_id, &stripe_sub_id) else { return Ok(()); };
136
137 // Status + period in one guarded statement — `canceled` is terminal for both,
138 // so a late `updated`(active) can neither revive the row nor refresh its period.
139 let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0));
140 let period = Some((stripe_timestamp(start_ts), stripe_timestamp(end_ts)));
141 let updated = db::subscriptions::apply_stripe_update(&state.db, &stripe_sub_id, Some(status), period).await.context("apply subscription update")?;
142
143 // Log event
144 let sub_id = updated.as_ref().map(|s| s.id);
145 if let Err(e) = db::subscriptions::log_subscription_event(
146 &state.db, sub_id, event_id, "customer.subscription.updated",
147 &serde_json::json!({"status": status.to_string()}),
148 ).await {
149 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
150 }
151
152 Ok(())
153 }
154
155 /// Handle customer.subscription.deleted; mark canceled, send email
156 pub(super) async fn handle_subscription_deleted(
157 state: &AppState,
158 sub: &crate::payments::SubscriptionView,
159 event_id: &str,
160 ) -> Result<()> {
161 let stripe_sub_id = sub.id.clone();
162 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription deleted");
163
164 // SyncKit v2 developer subscription? Flip to 'canceled'.
165 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")? {
166 db::synckit_billing::apply_billing_update(&state.db, app_id, Some("canceled"), None).await.context("synckit billing -> canceled")?;
167 if let Err(e) = db::subscriptions::log_subscription_event(
168 &state.db, None, event_id, "customer.subscription.deleted.synckit",
169 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}),
170 ).await {
171 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
172 }
173 return Ok(());
174 }
175
176 // Check if this is an end-user SyncKit app subscription.
177 if db::synckit::get_subscription_by_stripe_id(&state.db, &stripe_sub_id)
178 .await
179 .context("fetch app sync subscription by stripe id")?
180 .is_some()
181 {
182 db::synckit::update_app_sync_subscription_status(
183 &state.db, &stripe_sub_id, "canceled", None,
184 )
185 .await
186 .context("cancel app sync subscription")?;
187 if let Err(e) = db::subscriptions::log_subscription_event(
188 &state.db, None, event_id, "customer.subscription.deleted.synckit_app_sub",
189 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
190 ).await {
191 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
192 }
193 return Ok(());
194 }
195
196 // Check if this is a Fan+ subscription
197 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")? {
198 db::fan_plus::cancel_fan_plus(&state.db, &stripe_sub_id).await.context("cancel fan plus")?;
199
200 // Send cancellation email (fire-and-forget)
201 if let Ok(Some(user)) = db::users::get_user_by_id(&state.db, fan_sub.user_id).await {
202 let period_end = fan_sub.current_period_end;
203 let user_email = user.email.clone();
204 let user_name = user.display_name.clone();
205 spawn_email!(state, "Fan+ cancelled", |email| {
206 email.send_fan_plus_cancelled(&user_email, user_name.as_deref(), period_end.as_ref())
207 });
208 }
209
210 if let Err(e) = db::subscriptions::log_subscription_event(
211 &state.db, None, event_id, "customer.subscription.deleted.fan_plus",
212 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
213 ).await {
214 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
215 }
216 return Ok(());
217 }
218
219 // Check if this is a creator tier subscription
220 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")? {
221 db::creator_tiers::cancel_creator_sub(&state.db, &stripe_sub_id).await.context("cancel creator sub")?;
222 db::creator_tiers::sync_user_creator_tier(&state.db, ct_sub.user_id).await.context("sync user creator tier after cancel")?;
223
224 tracing::info!(
225 user_id = %ct_sub.user_id, tier = %ct_sub.tier,
226 "creator tier subscription canceled"
227 );
228
229 if let Err(e) = db::subscriptions::log_subscription_event(
230 &state.db, None, event_id, "customer.subscription.deleted.creator_tier",
231 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
232 ).await {
233 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
234 }
235 return Ok(());
236 }
237
238 let canceled = db::subscriptions::cancel_subscription(&state.db, &stripe_sub_id).await.context("cancel subscription")?;
239
240 if let Some(ref db_sub) = canceled {
241 // Send cancellation email (fire-and-forget)
242 if let (Ok(Some(subscriber)), Ok(Some(tier)), Ok(Some(project))) = (
243 db::users::get_user_by_id(&state.db, db_sub.subscriber_id).await,
244 db::subscriptions::get_subscription_tier_by_id(&state.db, db_sub.tier_id).await,
245 async { match db_sub.project_id { Some(pid) => db::projects::get_project_by_id(&state.db, pid).await, None => Ok(None) } }.await,
246 ) {
247 let sub_email = subscriber.email.clone();
248 let sub_name = subscriber.display_name.clone();
249 let tier_name = tier.name.clone();
250 let project_title = project.title.clone();
251 spawn_email!(state, "subscription cancelled", |email| {
252 email.send_subscription_cancelled(
253 &sub_email,
254 sub_name.as_deref(),
255 &tier_name,
256 &project_title,
257 )
258 });
259 }
260 }
261
262 // Log event
263 let sub_id = canceled.as_ref().map(|s| s.id);
264 if let Err(e) = db::subscriptions::log_subscription_event(
265 &state.db, sub_id, event_id, "customer.subscription.deleted",
266 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
267 ).await {
268 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
269 }
270
271 Ok(())
272 }
273