Skip to main content

max / makenotwork

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