Skip to main content

max / makenotwork

17.5 KB · 382 lines History Blame Raw
1 //! Webhook handlers for billing events (invoice payments, refunds).
2
3 use crate::{
4 db::{self, SubscriptionStatus},
5 error::{Result, ResultExt},
6 helpers::{self, spawn_email, stripe_timestamp},
7 AppState,
8 };
9
10 /// Handle invoice.payment_succeeded; update period, send renewal email (not first invoice)
11 pub(super) async fn handle_invoice_payment_succeeded(
12 state: &AppState,
13 invoice: &crate::payments::InvoiceView,
14 event_id: &str,
15 ) -> Result<()> {
16 let stripe_sub_id = match invoice.subscription_id() {
17 Some(s) => s.to_string(),
18 None => return Ok(()), // Not a subscription invoice
19 };
20
21 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing invoice payment succeeded");
22
23 let is_renewal = invoice.is_renewal();
24
25 // End-user SyncKit app subscription? Apply any pending storage-cap change
26 // and refresh the period. Only meaningful on renewals; the first invoice's
27 // cap was set at checkout.
28 if db::synckit::get_subscription_by_stripe_id(&state.db, &stripe_sub_id)
29 .await
30 .context("fetch app sync subscription by stripe id")?
31 .is_some()
32 {
33 let period_end = stripe_timestamp(invoice.period_end);
34 db::synckit::update_app_sync_subscription_status(
35 &state.db, &stripe_sub_id, "active", Some(period_end),
36 )
37 .await
38 .context("refresh app sync subscription period")?;
39 if is_renewal {
40 db::synckit::apply_pending_storage_cap(&state.db, &stripe_sub_id)
41 .await
42 .context("apply pending storage cap")?;
43 }
44 if let Err(e) = db::subscriptions::log_subscription_event(
45 &state.db, None, event_id, "invoice.payment_succeeded.synckit_app_sub",
46 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}),
47 ).await {
48 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
49 }
50 return Ok(());
51 }
52
53 // SyncKit v2 developer subscription? Identified by the local sync_apps row.
54 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")? {
55 let period_start = stripe_timestamp(invoice.period_start);
56 let period_end = stripe_timestamp(invoice.period_end);
57 let mut tx = state.db.begin().await.context("begin synckit invoice.paid transaction")?;
58 db::synckit_billing::set_billing_status(&mut *tx, app_id, "active").await.context("synckit billing -> active")?;
59 db::synckit_billing::set_period(&mut *tx, app_id, period_start, period_end).await.context("synckit set_period")?;
60 db::synckit_billing::reset_period_usage(&mut *tx, app_id).await.context("synckit reset_period_usage")?;
61 tx.commit().await.context("commit synckit invoice.paid")?;
62 if let Err(e) = db::subscriptions::log_subscription_event(
63 &state.db, None, event_id, "invoice.payment_succeeded.synckit",
64 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}),
65 ).await {
66 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
67 }
68 return Ok(());
69 }
70
71 // Check if this is a Fan+ subscription
72 if let Some(fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan+ by stripe id")? {
73 // Update period
74 let period_start = stripe_timestamp(invoice.period_start);
75 let period_end = stripe_timestamp(invoice.period_end);
76 db::fan_plus::update_fan_plus_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update fan+ period")?;
77
78 // On renewal, generate a $5 platform-wide promo code and email it
79 if is_renewal {
80 let period_end = chrono::DateTime::from_timestamp(invoice.period_end, 0);
81
82 // Uniqueness of the generated code is enforced by the DB-level
83 // `UNIQUE(creator_id, upper(code))` partial index on `promo_codes`
84 // (see migration 019, idx_promo_codes_creator_code). The wordlist
85 // gives ~66 bits of entropy (6 words × log₂2048) so a collision
86 // within a single creator's history is astronomically unlikely;
87 // if one ever lands, the INSERT errors out as DB error 23505 and
88 // surfaces to the operator log — no silent overwrite.
89 let code = helpers::generate_key_code();
90 match db::promo_codes::create_platform_promo_code(
91 &state.db,
92 fan_sub.user_id,
93 code.as_str(),
94 db::CodePurpose::Discount,
95 Some(db::DiscountType::Fixed),
96 Some(500), // $5 credit
97 0,
98 None,
99 Some(1), // single use
100 period_end,
101 ).await {
102 Ok(pc) => {
103 tracing::info!(
104 promo_code_id = %pc.id, user_id = %fan_sub.user_id,
105 "Fan+ monthly credit promo code generated"
106 );
107
108 // Email the credit code (fire-and-forget)
109 if let Ok(Some(user)) = db::users::get_user_by_id(&state.db, fan_sub.user_id).await {
110 let code_str = code.to_string();
111 let expiry = period_end;
112 let user_email = user.email.clone();
113 let user_name = user.display_name.clone();
114 spawn_email!(state, "Fan+ credit", |email| {
115 email.send_fan_plus_credit(
116 &user_email,
117 user_name.as_deref(),
118 &code_str,
119 expiry.as_ref(),
120 )
121 });
122 }
123 }
124 Err(e) => {
125 tracing::error!(
126 user_id = %fan_sub.user_id, error = ?e,
127 "failed to generate Fan+ monthly credit promo code"
128 );
129 if let Some(ref wam) = state.wam {
130 let title = format!("Fan+ credit not issued: user {}", fan_sub.user_id);
131 let body = format!(
132 "Fan+ subscriber {} paid renewal but $5 credit promo code \
133 generation failed: {e}\n\nManually create a promo code.",
134 fan_sub.user_id,
135 );
136 wam.create_ticket(&title, Some(&body), "high", "fan-plus-credit-failed", Some(&fan_sub.user_id.to_string())).await;
137 }
138 }
139 }
140 }
141
142 if let Err(e) = db::subscriptions::log_subscription_event(
143 &state.db, None, event_id, "invoice.payment_succeeded.fan_plus",
144 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}),
145 ).await {
146 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
147 }
148 return Ok(());
149 }
150
151 // Check if this is a creator tier subscription
152 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")? {
153 let period_start = stripe_timestamp(invoice.period_start);
154 let period_end = stripe_timestamp(invoice.period_end);
155 db::creator_tiers::update_creator_sub_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update creator sub period")?;
156
157 if let Err(e) = db::subscriptions::log_subscription_event(
158 &state.db, None, event_id, "invoice.payment_succeeded.creator_tier",
159 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}),
160 ).await {
161 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
162 }
163 return Ok(());
164 }
165
166 // Update period for creator subscriptions
167 let period_start = stripe_timestamp(invoice.period_start);
168 let period_end = stripe_timestamp(invoice.period_end);
169 db::subscriptions::update_subscription_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update subscription period")?;
170
171 // Send renewal email only for renewals (not the first invoice)
172 let db_sub = db::subscriptions::get_subscription_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch subscription by stripe id")?;
173
174 if is_renewal
175 && let Some(ref db_sub) = db_sub
176 && let (Ok(Some(subscriber)), Ok(Some(tier))) = (
177 db::users::get_user_by_id(&state.db, db_sub.subscriber_id).await,
178 db::subscriptions::get_subscription_tier_by_id(&state.db, db_sub.tier_id).await,
179 )
180 {
181 let price = helpers::format_price(tier.price_cents);
182 let sub_email = subscriber.email.clone();
183 let sub_name = subscriber.display_name.clone();
184 let tier_name = tier.name.clone();
185 spawn_email!(state, "subscription renewed", |email| {
186 email.send_subscription_renewed(
187 &sub_email,
188 sub_name.as_deref(),
189 &tier_name,
190 &price,
191 )
192 });
193 }
194
195 // Log event
196 let sub_id = db_sub.as_ref().map(|s| s.id);
197 if let Err(e) = db::subscriptions::log_subscription_event(
198 &state.db, sub_id, event_id, "invoice.payment_succeeded",
199 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}),
200 ).await {
201 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
202 }
203
204 Ok(())
205 }
206
207 /// Handle invoice.payment_failed; set status to past_due
208 pub(super) async fn handle_invoice_payment_failed(
209 state: &AppState,
210 invoice: &crate::payments::InvoiceView,
211 event_id: &str,
212 ) -> Result<()> {
213 let stripe_sub_id = match invoice.subscription_id() {
214 Some(s) => s.to_string(),
215 None => return Ok(()), // Not a subscription invoice
216 };
217
218 tracing::info!(stripe_sub_id = %stripe_sub_id, "processing invoice payment failed");
219
220 // SyncKit v2 developer subscription? Mark suspended_unpaid.
221 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")? {
222 db::synckit_billing::set_billing_status(&state.db, app_id, "suspended_unpaid").await.context("synckit billing -> suspended_unpaid")?;
223 if let Err(e) = db::subscriptions::log_subscription_event(
224 &state.db, None, event_id, "invoice.payment_failed.synckit",
225 &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}),
226 ).await {
227 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
228 }
229 if let Some(ref wam) = state.wam {
230 let title = format!("SyncKit app payment failed: {app_id}");
231 wam.create_ticket(&title, None, "medium", "synckit-payment-failed", Some(&app_id.to_string())).await;
232 }
233 return Ok(());
234 }
235
236 // Check if this is a Fan+ subscription
237 if let Some(_fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan+ by stripe id")? {
238 db::fan_plus::update_fan_plus_status(&state.db, &stripe_sub_id, SubscriptionStatus::PastDue).await.context("update fan+ status to past_due")?;
239
240 if let Err(e) = db::subscriptions::log_subscription_event(
241 &state.db, None, event_id, "invoice.payment_failed.fan_plus",
242 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
243 ).await {
244 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
245 }
246 return Ok(());
247 }
248
249 // Check if this is a creator tier subscription
250 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")? {
251 db::creator_tiers::update_creator_sub_status(&state.db, &stripe_sub_id, SubscriptionStatus::PastDue).await.context("update creator sub status to past_due")?;
252 db::creator_tiers::sync_user_creator_tier(&state.db, ct_sub.user_id).await.context("sync user creator tier")?;
253
254 if let Err(e) = db::subscriptions::log_subscription_event(
255 &state.db, None, event_id, "invoice.payment_failed.creator_tier",
256 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
257 ).await {
258 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
259 }
260 return Ok(());
261 }
262
263 let updated = db::subscriptions::update_subscription_status(&state.db, &stripe_sub_id, SubscriptionStatus::PastDue).await.context("update subscription status to past_due")?;
264
265 // Log event
266 let sub_id = updated.as_ref().map(|s| s.id);
267 if let Err(e) = db::subscriptions::log_subscription_event(
268 &state.db, sub_id, event_id, "invoice.payment_failed",
269 &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
270 ).await {
271 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
272 }
273
274 // Create WAM ticket for subscription payment failures
275 if let Some(ref wam) = state.wam {
276 let title = format!("Subscription payment failed: {stripe_sub_id}");
277 wam.create_ticket(&title, None, "medium", "subscription-payment-failed", Some(&stripe_sub_id)).await;
278 }
279
280 Ok(())
281 }
282
283 /// Handle charge.refunded webhook; revoke license keys on full refund,
284 /// log partial refunds without revoking access.
285 pub(super) async fn handle_charge_refunded(
286 state: &AppState,
287 refund_data: &crate::payments::ChargeRefundData,
288 ) -> Result<()> {
289 let payment_intent_id = &refund_data.payment_intent_id;
290 tracing::info!(
291 payment_intent_id = %payment_intent_id,
292 amount = refund_data.amount.as_i64(),
293 amount_refunded = refund_data.amount_refunded.as_i64(),
294 is_full = refund_data.is_full_refund(),
295 "processing charge refund"
296 );
297
298 // Partial refund: log but do not revoke access or keys
299 if !refund_data.is_full_refund() {
300 tracing::info!(
301 payment_intent_id = %payment_intent_id,
302 "partial refund — access and license keys preserved"
303 );
304 return Ok(());
305 }
306
307 let mut db_tx = state.db.begin().await.context("begin refund transaction")?;
308
309 // Mark transactions as refunded and get their IDs + item_ids
310 // (cart checkouts can have multiple transactions per payment_intent_id)
311 let refunded = db::transactions::refund_transaction_by_payment_intent(&mut *db_tx, payment_intent_id).await.context("refund transaction")?;
312
313 if !refunded.is_empty() {
314 let mut total_keys_revoked = 0u64;
315 let mut total_children_revoked = 0usize;
316
317 for (tx_id, item_id) in &refunded {
318 // Project-level transactions store item_id IS NULL — skip the item-scoped
319 // updates for those; the project-members split rows aren't sales-counted.
320 if let Some(item_id) = item_id {
321 db::items::decrement_sales_count(&mut *db_tx, *item_id).await.context("decrement sales count")?;
322 }
323
324 let revoked = db::license_keys::revoke_keys_by_transaction(&mut db_tx, *tx_id).await.context("revoke license keys")?;
325 total_keys_revoked += revoked;
326
327 // Revoke child transactions granted via bundle purchase
328 let revoked_children = db::transactions::revoke_child_transactions(&mut *db_tx, *tx_id)
329 .await.context("revoke bundle child transactions")?;
330 for child_item_id in &revoked_children {
331 db::items::decrement_sales_count(&mut *db_tx, *child_item_id)
332 .await
333 .context("decrement child item sales count")?;
334 }
335 total_children_revoked += revoked_children.len();
336 }
337
338 // Commit the refund atomically
339 db_tx.commit().await.context("commit refund transaction")?;
340
341 tracing::info!(
342 transactions_refunded = refunded.len(),
343 keys_revoked = total_keys_revoked,
344 bundle_children_revoked = total_children_revoked,
345 "refund processed"
346 );
347 } else {
348 // No transaction found — check if this was a tip refund
349 let tip_refunded = db::tips::refund_tip_by_payment_intent(&state.db, payment_intent_id)
350 .await
351 .inspect_err(|e| {
352 tracing::error!(
353 payment_intent_id = %payment_intent_id,
354 error = ?e,
355 "tip refund lookup failed"
356 );
357 })
358 .context("refund tip")?;
359 if tip_refunded {
360 tracing::info!(payment_intent_id = %payment_intent_id, "tip refund processed");
361 } else {
362 // No matching transaction or tip — the payment webhook likely hasn't
363 // arrived yet. Queue the refund for later matching rather than
364 // silently dropping it.
365 tracing::warn!(
366 payment_intent_id = %payment_intent_id,
367 "no completed transaction or tip found — queuing as pending refund"
368 );
369 db::pending_refunds::insert_pending_refund(
370 &state.db,
371 payment_intent_id,
372 refund_data.amount.as_i64(),
373 refund_data.amount_refunded.as_i64(),
374 )
375 .await
376 .context("insert pending refund")?;
377 }
378 }
379
380 Ok(())
381 }
382