Skip to main content

max / makenotwork

27.9 KB · 692 lines History Blame Raw
1 //! Webhook handlers for checkout.session.completed events.
2
3 use crate::{
4 db,
5 error::{AppError, Result, ResultExt},
6 helpers::{self, spawn_email},
7 payments::{CheckoutMetadata, CreatorTierCheckoutMetadata, FanPlusCheckoutMetadata, SubscriptionCheckoutMetadata, SynckitAppSubCheckoutMetadata, TipCheckoutMetadata},
8 AppState,
9 };
10
11 use super::checkout_helpers::{
12 check_pending_refund, maybe_generate_license_key, record_tip_splits,
13 record_transaction_splits, send_guest_sale_notification, send_purchase_emails,
14 send_tip_email, subscribe_buyer_to_mailing_list,
15 };
16
17 /// Handle checkout.session.completed for one-time purchases
18 #[tracing::instrument(skip_all, name = "stripe::handle_purchase_checkout")]
19 pub(super) async fn handle_purchase_checkout_completed(
20 state: &AppState,
21 session: &crate::payments::CheckoutSessionView,
22 event_id: &str,
23 ) -> Result<()> {
24 let session_id = session.id.clone();
25
26 tracing::info!(session_id = %session_id, "processing completed purchase checkout");
27
28 // Extract metadata (already typed IDs from CheckoutMetadata)
29 let raw_metadata = CheckoutMetadata::from_metadata(session.metadata.as_ref())?;
30 let buyer_id = raw_metadata.buyer_id;
31 let seller_id = raw_metadata.seller_id;
32 let item_id = raw_metadata.item_id;
33 let _promo_code_id = raw_metadata.promo_code_id;
34
35 let item_id_display = item_id.map(|id| id.to_string()).unwrap_or_else(|| "project".to_string());
36
37 // Get the payment intent ID
38 let payment_intent_id = session.payment_intent.clone().unwrap_or_else(|| "unknown".to_string());
39
40 // Complete the transaction (idempotent - returns None if already completed).
41 // Steps 1-3 (complete_transaction, increment_sales_count, discount code increment)
42 // are wrapped in a single DB transaction to prevent inconsistent state if any step fails.
43 let mut db_tx = state.db.begin().await.context("begin purchase webhook transaction")?;
44
45 match db::transactions::complete_transaction(&mut *db_tx, &session_id, &payment_intent_id).await {
46 Ok(Some(tx)) => {
47 tracing::info!(
48 buyer_id = %buyer_id, seller_id = %seller_id, item_id = %item_id_display, amount_cents = %tx.amount_cents,
49 "transaction completed"
50 );
51
52 // Increment denormalized sales_count (inside transaction)
53 if let Some(iid) = item_id {
54 db::items::increment_sales_count(&mut *db_tx, iid)
55 .await
56 .with_context(|| format!("increment sales count for item {iid}"))?;
57 }
58
59 // Promo code use_count is reserved at checkout time (not here) to prevent
60 // concurrent checkouts from exceeding max_uses. No increment needed in webhook.
61
62 // Commit the critical data integrity operations
63 db_tx.commit().await.context("commit purchase webhook transaction")?;
64
65 // --- Secondary effects below (outside transaction) ---
66
67 // Grant access to bundle child items (if this is a bundle)
68 if let Some(iid) = item_id
69 && let Ok(Some(purchased_item)) = db::items::get_item_by_id(&state.db, iid).await
70 && purchased_item.item_type == db::ItemType::Bundle
71 {
72 crate::routes::stripe::checkout::grant_bundle_items(state, iid, buyer_id, seller_id, Some(tx.id)).await;
73 }
74
75 if tx.share_contact {
76 db::transactions::clear_contact_revocation(&state.db, buyer_id, seller_id)
77 .await
78 .context("clear contact revocation after purchase")?;
79 }
80
81 // Record revenue splits if the item's project has members
82 if let Some(iid) = item_id {
83 record_transaction_splits(state, tx.id, iid, tx.amount_cents).await;
84 maybe_generate_license_key(state, iid, buyer_id, tx.id).await;
85 subscribe_buyer_to_mailing_list(state, iid, buyer_id);
86 }
87
88 send_purchase_emails(state, &tx, buyer_id, seller_id);
89
90 if let Err(e) = db::subscriptions::log_subscription_event(
91 &state.db, None, event_id, "checkout.session.completed.purchase",
92 &serde_json::json!({"session_id": session_id}),
93 ).await {
94 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
95 }
96
97 // Check for a pending refund that arrived before this payment webhook.
98 // If found, process it now that the transaction is completed.
99 check_pending_refund(state, &payment_intent_id).await;
100 }
101 Ok(None) => {
102 tracing::info!(session_id = %session_id, "transaction already completed, ignoring duplicate webhook");
103 }
104 Err(e) => {
105 tracing::error!(session_id = %session_id, error = ?e, "failed to complete transaction");
106 return Err(e);
107 }
108 }
109
110 Ok(())
111 }
112
113 /// Handle checkout.session.completed for cart (multi-item) purchases
114 #[tracing::instrument(skip_all, name = "stripe::handle_cart_checkout")]
115 pub(super) async fn handle_cart_checkout_completed(
116 state: &AppState,
117 session: &crate::payments::CheckoutSessionView,
118 event_id: &str,
119 ) -> Result<()> {
120 let session_id = session.id.clone();
121 tracing::info!(session_id = %session_id, "processing completed cart checkout");
122
123 let meta = crate::payments::CartCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
124 let buyer_id = meta.buyer_id;
125 let seller_id = meta.seller_id;
126
127 let payment_intent_id = session.payment_intent.clone().unwrap_or_else(|| "unknown".to_string());
128
129 // Complete ALL pending transactions for this session in a single DB transaction
130 let mut db_tx = state.db.begin().await.context("begin cart webhook transaction")?;
131
132 let completed_txs = db::transactions::complete_cart_transactions(
133 &mut *db_tx, &session_id, &payment_intent_id,
134 )
135 .await
136 .context("complete cart transactions")?;
137
138 if completed_txs.is_empty() {
139 tracing::info!(session_id = %session_id, "cart transactions already completed, ignoring duplicate webhook");
140 return Ok(());
141 }
142
143 tracing::info!(
144 session_id = %session_id, buyer_id = %buyer_id, seller_id = %seller_id,
145 count = completed_txs.len(), "cart transactions completed"
146 );
147
148 // Increment sales count for each item
149 for tx in &completed_txs {
150 if let Some(item_id) = tx.item_id {
151 db::items::increment_sales_count(&mut *db_tx, item_id)
152 .await
153 .with_context(|| format!("increment sales count for item {item_id}"))?;
154 }
155 }
156
157 db_tx.commit().await.context("commit cart webhook transaction")?;
158
159 // Remove purchased items from cart (items stay in cart until payment succeeds,
160 // so cancelled checkouts don't lose cart contents)
161 db::cart::remove_seller_items_from_cart(&state.db, buyer_id, seller_id)
162 .await
163 .context("remove cart items after successful payment")?;
164
165 // --- Secondary effects (outside transaction) ---
166
167 for tx in &completed_txs {
168 if let Some(item_id) = tx.item_id {
169 // Bundle grants
170 if let Ok(Some(purchased_item)) = db::items::get_item_by_id(&state.db, item_id).await
171 && purchased_item.item_type == db::ItemType::Bundle
172 {
173 crate::routes::stripe::checkout::grant_bundle_items(
174 state, item_id, buyer_id, seller_id, Some(tx.id),
175 )
176 .await;
177 }
178
179 // Revenue splits
180 record_transaction_splits(state, tx.id, item_id, tx.amount_cents).await;
181
182 // License keys
183 maybe_generate_license_key(state, item_id, buyer_id, tx.id).await;
184
185 // Mailing list
186 subscribe_buyer_to_mailing_list(state, item_id, buyer_id);
187 }
188 }
189
190 // Contact sharing (once per seller)
191 if completed_txs.iter().any(|t| t.share_contact) {
192 db::transactions::clear_contact_revocation(&state.db, buyer_id, seller_id)
193 .await
194 .context("clear contact revocation after cart purchase")?;
195 }
196
197 // Send purchase emails for each item (reuse existing per-item emails)
198 for tx in &completed_txs {
199 send_purchase_emails(state, tx, buyer_id, seller_id);
200 }
201
202 // Log event
203 if let Err(e) = db::subscriptions::log_subscription_event(
204 &state.db, None, event_id, "checkout.session.completed.cart",
205 &serde_json::json!({"session_id": session_id, "item_count": completed_txs.len()}),
206 ).await {
207 tracing::warn!(event_id = %event_id, error = ?e, "failed to log cart checkout event");
208 }
209
210 // Check for pending refund
211 check_pending_refund(state, &payment_intent_id).await;
212
213 Ok(())
214 }
215
216 /// Handle checkout.session.completed for subscriptions
217 #[tracing::instrument(skip_all, name = "stripe::handle_subscription_checkout")]
218 pub(super) async fn handle_subscription_checkout_completed(
219 state: &AppState,
220 session: &crate::payments::CheckoutSessionView,
221 event_id: &str,
222 ) -> Result<()> {
223 let session_id = session.id.clone();
224 tracing::info!(session_id = %session_id, "processing completed subscription checkout");
225
226 // Extract subscription-specific metadata (already typed IDs)
227 let raw_metadata = SubscriptionCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
228 let subscriber_id = raw_metadata.subscriber_id;
229 let project_id = raw_metadata.project_id;
230 let tier_id = raw_metadata.tier_id;
231
232 // Get the Stripe subscription ID from the session
233 let stripe_subscription_id = session.subscription.clone()
234 .ok_or_else(|| {
235 tracing::error!("Subscription checkout completed but no subscription ID on session");
236 AppError::BadRequest("Missing subscription ID on session".to_string())
237 })?;
238
239 // Get the Stripe customer ID from the session
240 let stripe_customer_id = session.customer.clone()
241 .ok_or_else(|| {
242 tracing::error!("Subscription checkout completed but no customer ID on session");
243 AppError::BadRequest("Missing customer ID on session".to_string())
244 })?;
245
246 // Create the subscription record + increment promo code in a single transaction.
247 let mut tx = state.db.begin().await.context("begin subscription webhook transaction")?;
248
249 let sub = match db::subscriptions::create_subscription(
250 &mut *tx,
251 subscriber_id,
252 tier_id,
253 project_id,
254 &stripe_subscription_id,
255 &stripe_customer_id,
256 ).await
257 .context("create subscription record")? {
258 Some(sub) => sub,
259 None => {
260 tracing::info!(
261 subscriber_id = %subscriber_id, project_id = %project_id,
262 "subscription already exists, ignoring duplicate"
263 );
264 return Ok(());
265 }
266 };
267
268 // Promo code use_count is reserved at checkout time (not here) to prevent
269 // concurrent checkouts from exceeding max_uses. No increment needed in webhook.
270
271 // Delete the pending promo-hold transaction (created at checkout time so
272 // cleanup_stale_pending_transactions can release the code if abandoned).
273 db::transactions::delete_subscription_pending_transaction(&mut *tx, &session_id)
274 .await
275 .context("delete subscription pending promo-hold transaction")?;
276
277 tx.commit().await.context("commit subscription webhook transaction")?;
278
279 tracing::info!(
280 subscription_id = %sub.id, subscriber_id = %subscriber_id, project_id = %project_id, tier_id = %tier_id,
281 "subscription created"
282 );
283
284 // Send subscription started email (fire-and-forget)
285 if let (Ok(Some(subscriber)), Ok(Some(tier)), Ok(Some(project))) = (
286 db::users::get_user_by_id(&state.db, subscriber_id).await,
287 db::subscriptions::get_subscription_tier_by_id(&state.db, tier_id).await,
288 db::projects::get_project_by_id(&state.db, project_id).await,
289 ) {
290 let price = helpers::format_price(tier.price_cents);
291 let sub_email = subscriber.email.clone();
292 let sub_name = subscriber.display_name.clone();
293 let tier_name = tier.name.clone();
294 let project_title = project.title.clone();
295 spawn_email!(state, "subscription started", |email| {
296 email.send_subscription_started(
297 &sub_email,
298 sub_name.as_deref(),
299 &tier_name,
300 &project_title,
301 &price,
302 )
303 });
304 }
305
306 // Log event
307 if let Err(e) = db::subscriptions::log_subscription_event(
308 &state.db, Some(sub.id), event_id, "checkout.session.completed.subscription",
309 &serde_json::json!({"session_id": session_id, "stripe_subscription_id": stripe_subscription_id}),
310 ).await {
311 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
312 }
313
314 Ok(())
315 }
316
317 /// Handle checkout.session.completed for Fan+ subscriptions
318 #[tracing::instrument(skip_all, name = "stripe::handle_fan_plus_checkout")]
319 pub(super) async fn handle_fan_plus_checkout_completed(
320 state: &AppState,
321 session: &crate::payments::CheckoutSessionView,
322 event_id: &str,
323 ) -> Result<()> {
324 let session_id = session.id.clone();
325 tracing::info!(session_id = %session_id, "processing completed Fan+ checkout");
326
327 let metadata = FanPlusCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
328 let user_id = metadata.user_id;
329
330 // Get the Stripe subscription ID from the session
331 let stripe_subscription_id = session.subscription.clone()
332 .ok_or_else(|| {
333 tracing::error!("Fan+ checkout completed but no subscription ID on session");
334 AppError::BadRequest("Missing subscription ID on session".to_string())
335 })?;
336
337 // Get the Stripe customer ID from the session
338 let stripe_customer_id = session.customer.clone()
339 .ok_or_else(|| {
340 tracing::error!("Fan+ checkout completed but no customer ID on session");
341 AppError::BadRequest("Missing customer ID on session".to_string())
342 })?;
343
344 // Create the subscription record (idempotent via ON CONFLICT DO NOTHING on user_id)
345 let sub = match db::fan_plus::create_fan_plus_subscription(
346 &state.db, user_id, &stripe_subscription_id, &stripe_customer_id,
347 ).await
348 .with_context(|| format!("create Fan+ subscription for user {user_id}"))? {
349 Some(sub) => sub,
350 None => {
351 tracing::info!(user_id = %user_id, "Fan+ subscription already exists, ignoring duplicate");
352 return Ok(());
353 }
354 };
355
356 tracing::info!(
357 subscription_id = %sub.id, user_id = %user_id,
358 "Fan+ subscription created"
359 );
360
361 // Send welcome email (fire-and-forget)
362 if let Ok(Some(user)) = db::users::get_user_by_id(&state.db, user_id).await {
363 let user_email = user.email.clone();
364 let user_name = user.display_name.clone();
365 spawn_email!(state, "Fan+ welcome", |email| {
366 email.send_fan_plus_welcome(&user_email, user_name.as_deref())
367 });
368 }
369
370 // Log event
371 if let Err(e) = db::subscriptions::log_subscription_event(
372 &state.db, None, event_id, "checkout.session.completed.fan_plus",
373 &serde_json::json!({"session_id": session_id, "stripe_subscription_id": stripe_subscription_id}),
374 ).await {
375 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
376 }
377
378 Ok(())
379 }
380
381 /// Handle checkout.session.completed for creator tier subscriptions
382 #[tracing::instrument(skip_all, name = "stripe::handle_creator_tier_checkout")]
383 pub(super) async fn handle_creator_tier_checkout_completed(
384 state: &AppState,
385 session: &crate::payments::CheckoutSessionView,
386 event_id: &str,
387 ) -> Result<()> {
388 let session_id = session.id.clone();
389 tracing::info!(session_id = %session_id, "processing completed creator tier checkout");
390
391 let metadata = CreatorTierCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
392 let user_id = metadata.user_id;
393 let tier: db::CreatorTier = metadata.tier.parse()
394 .map_err(|_| AppError::BadRequest(format!("Invalid tier: {}", metadata.tier)))?;
395
396 // Get the Stripe subscription ID from the session
397 let stripe_subscription_id = session.subscription.clone()
398 .ok_or_else(|| {
399 tracing::error!("Creator tier checkout completed but no subscription ID on session");
400 AppError::BadRequest("Missing subscription ID on session".to_string())
401 })?;
402
403 // Get the Stripe customer ID from the session
404 let stripe_customer_id = session.customer.clone()
405 .ok_or_else(|| {
406 tracing::error!("Creator tier checkout completed but no customer ID on session");
407 AppError::BadRequest("Missing customer ID on session".to_string())
408 })?;
409
410 // Create the subscription record (idempotent via ON CONFLICT DO NOTHING on user_id)
411 let sub = match db::creator_tiers::create_creator_subscription(
412 &state.db, user_id, &stripe_subscription_id, &stripe_customer_id, tier,
413 ).await
414 .with_context(|| format!("create creator tier subscription for user {user_id}"))? {
415 Some(sub) => sub,
416 None => {
417 tracing::info!(user_id = %user_id, "Creator tier subscription already exists, ignoring duplicate");
418 return Ok(());
419 }
420 };
421
422 // Sync the denormalized creator_tier column on users
423 db::creator_tiers::sync_user_creator_tier(&state.db, user_id)
424 .await
425 .with_context(|| format!("sync creator tier for user {user_id}"))?;
426
427 // Auto-unhide: restore items hidden by post-grace enforcement
428 match db::items::unhide_all_items_for_user(&state.db, user_id).await {
429 Ok(count) if count > 0 => {
430 tracing::info!(user_id = %user_id, items_unhidden = count, "auto-unhidden items after tier re-subscription");
431 }
432 Err(e) => {
433 tracing::warn!(user_id = %user_id, error = ?e, "failed to unhide items after tier re-subscription");
434 }
435 _ => {}
436 }
437
438 // Auto-unpause: if this creator was paused and just re-subscribed, clear the pause
439 // and un-cancel any fan subscriptions that haven't expired yet.
440 if let Ok(Some(db_user)) = db::users::get_user_by_id(&state.db, user_id).await
441 && db_user.is_creator_paused()
442 {
443 db::users::unpause_creator(&state.db, user_id)
444 .await
445 .with_context(|| format!("unpause creator {user_id}"))?;
446
447 // Un-cancel active fan subscriptions (clear cancel_at_period_end)
448 if let (Some(stripe), Some(stripe_account_id)) = (&state.stripe, &db_user.stripe_account_id) {
449 let fan_subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, user_id)
450 .await
451 .with_context(|| format!("fetch active fan subs for unpause {user_id}"))?;
452 for fan_sub in &fan_subs {
453 if let Err(e) = stripe.set_cancel_at_period_end(
454 &fan_sub.stripe_subscription_id,
455 stripe_account_id,
456 false,
457 ).await {
458 tracing::warn!(
459 stripe_sub_id = %fan_sub.stripe_subscription_id,
460 error = ?e,
461 "failed to clear cancel_at_period_end on fan sub during unpause"
462 );
463 }
464 }
465 }
466
467 tracing::info!(user_id = %user_id, "creator auto-unpaused after re-subscribing to tier");
468 }
469
470 tracing::info!(
471 user_id = %user_id, tier = %tier,
472 "creator tier subscription created"
473 );
474
475 // Log event
476 if let Err(e) = db::subscriptions::log_subscription_event(
477 &state.db, None, event_id, "checkout.session.completed.creator_tier",
478 &serde_json::json!({
479 "session_id": session_id,
480 "stripe_subscription_id": stripe_subscription_id,
481 "tier": sub.tier,
482 }),
483 ).await {
484 tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
485 }
486
487 Ok(())
488 }
489
490 /// Handle checkout.session.completed for tips
491 #[tracing::instrument(skip_all, name = "stripe::handle_tip_checkout")]
492 pub(super) async fn handle_tip_checkout_completed(
493 state: &AppState,
494 session: &crate::payments::CheckoutSessionView,
495 _event_id: &str,
496 ) -> Result<()> {
497 let session_id = session.id.clone();
498 tracing::info!(session_id = %session_id, "processing completed tip checkout");
499
500 let metadata = TipCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
501 let tipper_id = metadata.tipper_id;
502 let recipient_id = metadata.recipient_id;
503
504 let payment_intent_id = session.payment_intent.clone().unwrap_or_else(|| "unknown".to_string());
505
506 // Complete the tip (idempotent)
507 match db::tips::complete_tip(&state.db, &session_id, &payment_intent_id)
508 .await
509 .context("complete tip")? {
510 Some(tip) => {
511 tracing::info!(
512 tip_id = %tip.id, tipper_id = %tipper_id, recipient_id = %recipient_id,
513 amount_cents = %tip.amount_cents, "tip completed"
514 );
515
516 // Record revenue splits if the tip's project has members
517 if let Some(project_id) = tip.project_id {
518 record_tip_splits(state, tip.id, project_id, tip.amount_cents).await;
519 }
520
521 // Send tip notification email (fire-and-forget)
522 send_tip_email(state, &tip, tipper_id, recipient_id);
523 }
524 None => {
525 tracing::info!(session_id = %session_id, "tip already completed, ignoring duplicate webhook");
526 }
527 }
528
529 Ok(())
530 }
531
532 /// Handle checkout.session.completed for guest purchases (no MNW account).
533 ///
534 /// Extracts the buyer's email from Stripe, completes the transaction, and
535 /// auto-attaches to an existing account if the email matches.
536 #[tracing::instrument(skip_all, name = "stripe::handle_guest_checkout")]
537 pub(super) async fn handle_guest_checkout_completed(
538 state: &AppState,
539 session: &crate::payments::CheckoutSessionView,
540 _event_id: &str,
541 ) -> Result<()> {
542 use crate::payments::GuestCheckoutMetadata;
543
544 let session_id = session.id.clone();
545 tracing::info!(session_id = %session_id, "processing completed guest checkout");
546
547 let meta = GuestCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
548
549 // Extract buyer email from Stripe customer_details
550 let guest_email = session.customer_details.as_ref()
551 .and_then(|cd| cd.email.as_deref())
552 .unwrap_or("unknown@guest")
553 .to_string();
554
555 let payment_intent_id = session.payment_intent.clone().unwrap_or_else(|| "unknown".to_string());
556
557 // Complete the guest transaction and increment sales count in a single DB transaction
558 // (matching the non-guest path pattern to prevent counter drift on partial failure)
559 let mut db_tx = state.db.begin().await.context("begin guest checkout webhook transaction")?;
560
561 // Check if email matches an existing user — auto-attach if so.
562 // `FOR SHARE` blocks a concurrent email-change UPDATE from racing the
563 // attach: if someone edits this user's email mid-checkout, the writer
564 // waits for our tx to commit so we either attach the row we matched or
565 // the writer wins and we see no row (treated as guest purchase).
566 let existing_user_id: Option<db::UserId> = sqlx::query_scalar(
567 "SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND email_verified = true FOR SHARE",
568 )
569 .bind(&guest_email)
570 .fetch_optional(&mut *db_tx)
571 .await?;
572
573 match db::transactions::complete_guest_transaction(
574 &mut *db_tx,
575 &session_id,
576 &payment_intent_id,
577 &guest_email,
578 existing_user_id,
579 ).await? {
580 Some(tx) => {
581 tracing::info!(
582 session_id = %session_id,
583 guest_email = %guest_email,
584 item_id = %meta.item_id,
585 auto_attached = tx.buyer_id.is_some(),
586 "guest transaction completed"
587 );
588
589 // Increment sales count inside transaction
590 db::items::increment_sales_count(&mut *db_tx, meta.item_id)
591 .await
592 .with_context(|| format!("increment sales count for guest item {}", meta.item_id))?;
593
594 db_tx.commit().await.context("commit guest checkout webhook transaction")?;
595
596 // --- Secondary effects below (outside transaction) ---
597
598 // Generate license key if applicable and buyer was auto-attached
599 if let Some(buyer_id) = tx.buyer_id {
600 maybe_generate_license_key(state, meta.item_id, buyer_id, tx.id).await;
601 }
602
603 // Record revenue splits
604 record_transaction_splits(state, tx.id, meta.item_id, tx.amount_cents).await;
605
606 // Send guest purchase confirmation email (only if not auto-attached to existing account)
607 if tx.buyer_id.is_none()
608 && let (Some(download_token), Some(claim_token)) = (tx.download_token, tx.claim_token)
609 {
610 let email_client = state.email.clone();
611 let host_url = state.config.host_url.clone();
612 let item_title = tx.item_title.clone().unwrap_or_else(|| "your item".to_string());
613 let price = helpers::format_price(tx.amount_cents);
614 let guest_email_addr = guest_email.clone();
615 let download_url = format!("{}/download/{}", host_url, download_token);
616 let claim_url = format!("{}/claim?token={}", host_url, claim_token);
617
618 state.bg.spawn("guest purchase confirmation", async move {
619 if let Err(e) = email_client.send_guest_purchase_confirmation(
620 &guest_email_addr, &item_title, &price, &download_url, &claim_url,
621 ).await {
622 tracing::error!(error = ?e, "failed to send guest purchase confirmation email");
623 }
624 });
625 }
626
627 // Send sale notification to seller
628 send_guest_sale_notification(state, &tx, &guest_email, meta.seller_id);
629 }
630 None => {
631 db_tx.commit().await.ok();
632 tracing::info!(session_id = %session_id, "guest transaction already completed, ignoring duplicate webhook");
633 }
634 }
635
636 Ok(())
637 }
638
639 /// Handle checkout.session.completed for an end-user SyncKit app subscription.
640 /// Inserts the `app_sync_subscriptions` row; subsequent
641 /// `customer.subscription.updated/.deleted` events keep it in sync.
642 #[tracing::instrument(skip_all, name = "stripe::handle_synckit_app_sub_checkout")]
643 pub(super) async fn handle_synckit_app_sub_checkout_completed(
644 state: &AppState,
645 session: &crate::payments::CheckoutSessionView,
646 event_id: &str,
647 ) -> Result<()> {
648 let session_id = session.id.clone();
649 tracing::info!(session_id = %session_id, "processing completed SyncKit app subscription checkout");
650
651 let meta = SynckitAppSubCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
652
653 let stripe_subscription_id = session
654 .subscription
655 .clone()
656 .ok_or_else(|| AppError::BadRequest("Missing subscription ID on session".to_string()))?;
657 let stripe_customer_id = session
658 .customer
659 .clone()
660 .ok_or_else(|| AppError::BadRequest("Missing customer ID on session".to_string()))?;
661
662 let inserted = db::synckit::create_app_sync_subscription(
663 &state.db,
664 &db::synckit::NewAppSyncSubscription {
665 user_id: meta.user_id,
666 app_id: meta.app_id,
667 stripe_subscription_id: &stripe_subscription_id,
668 stripe_customer_id: &stripe_customer_id,
669 interval: &meta.tier, // metadata "tier" carries the interval string ("monthly"/"annual")
670 storage_limit_bytes: meta.storage_limit_bytes.unwrap_or(0),
671 },
672 )
673 .await
674 .with_context(|| {
675 format!(
676 "create app sync subscription user={} app={}",
677 meta.user_id, meta.app_id
678 )
679 })?;
680
681 if !inserted {
682 tracing::info!(
683 user_id = %meta.user_id,
684 app_id = %meta.app_id,
685 "SyncKit app subscription already exists, ignoring duplicate webhook"
686 );
687 }
688
689 let _ = event_id;
690 Ok(())
691 }
692