//! Helper functions for checkout webhook handlers: email notifications, //! license key generation, revenue splits, and pending refund processing. use crate::{ db, helpers, AppState, }; /// Generate a license key for the purchased item if keys are enabled. pub(super) async fn maybe_generate_license_key( state: &AppState, item_id: db::ItemId, buyer_id: db::UserId, transaction_id: db::TransactionId, ) { let item = match db::items::get_item_by_id(&state.db, item_id).await { Ok(Some(item)) if item.enable_license_keys => item, _ => return, }; let key_code = helpers::generate_key_code(); match db::license_keys::create_license_key( &state.db, item_id, buyer_id, Some(transaction_id), &key_code, item.default_max_activations, ).await { Ok(key) => { tracing::info!(key_id = %key.id, buyer_id = %buyer_id, item_id = %item_id, "license key generated for purchase"); } Err(e) => { tracing::error!(buyer_id = %buyer_id, item_id = %item_id, error = ?e, "failed to generate license key for purchase"); if let Some(ref wam) = state.wam { let title = format!("License key not issued: item {item_id}"); let body = format!( "Buyer {buyer_id} purchased item {item_id} (tx {transaction_id}) but \ license key generation failed: {e}\n\nManually issue a key.", ); wam.create_ticket(&title, Some(&body), "critical", "license-key-gen-failed", Some(&transaction_id.to_string())).await; } } } } /// Send purchase confirmation to buyer and sale notification to seller (fire-and-forget). pub(super) fn send_purchase_emails( state: &AppState, tx: &db::DbTransaction, buyer_id: db::UserId, seller_id: db::UserId, ) { let db = state.db.clone(); let email = state.email.clone(); let amount_cents = tx.amount_cents; let item_title = tx.item_title.clone(); let host_url = state.config.host_url.clone(); let signing_secret = state.config.signing_secret.clone(); state.bg.spawn("purchase confirmation + sale notification", async move { let buyer = db::users::get_user_by_id(&db, buyer_id).await.ok().flatten(); let seller = db::users::get_user_by_id(&db, seller_id).await.ok().flatten(); // Purchase confirmation to buyer if let Some(ref buyer) = buyer { let price = helpers::format_price(amount_cents); let title = item_title.clone().unwrap_or_else(|| "your item".to_string()); if let Err(e) = email.send_purchase_confirmation( &buyer.email, buyer.display_name.as_deref(), &title, &price, ).await { tracing::error!(error = ?e, "failed to send purchase confirmation email"); } } // Sale notification to seller if let Some(ref seller) = seller && seller.notify_sale { let price = helpers::format_price(amount_cents); let title = item_title.unwrap_or_else(|| "an item".to_string()); let buyer_username = buyer.as_ref() .map(|b| b.username.to_string()) .unwrap_or_else(|| "Someone".to_string()); let unsub_url = crate::email::generate_unsubscribe_url( &host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &signing_secret, ); if let Err(e) = email.send_sale_notification( &seller.email, seller.display_name.as_deref(), &buyer_username, &title, &price, Some(&unsub_url), ).await { tracing::error!(error = ?e, "failed to send sale notification email"); } } }); } /// Subscribe buyer to the item's project content mailing list (fire-and-forget). pub(super) fn subscribe_buyer_to_mailing_list(state: &AppState, item_id: db::ItemId, buyer_id: db::UserId) { let db = state.db.clone(); state.bg.spawn("mailing list subscribe", async move { if let Ok(Some(item)) = db::items::get_item_by_id(&db, item_id).await && let Err(e) = db::mailing_lists::subscribe_to_content_list( &db, item.project_id, buyer_id, ).await { tracing::warn!( project_id = %item.project_id, buyer_id = %buyer_id, error = ?e, "failed to subscribe buyer to content mailing list" ); } }); } /// Send tip notification to recipient (fire-and-forget). pub(super) fn send_tip_email( state: &AppState, tip: &db::DbTip, tipper_id: db::UserId, recipient_id: db::UserId, ) { let db = state.db.clone(); let email = state.email.clone(); let amount_cents = tip.amount_cents; let message = tip.message.clone(); let host_url = state.config.host_url.clone(); let signing_secret = state.config.signing_secret.clone(); state.bg.spawn("tip notification", async move { let tipper = db::users::get_user_by_id(&db, tipper_id).await.ok().flatten(); let recipient = db::users::get_user_by_id(&db, recipient_id).await.ok().flatten(); if let Some(ref recipient) = recipient && recipient.notify_tip { let price = helpers::format_price(amount_cents); let tipper_name = tipper.as_ref() .map(|t| t.display_name.as_deref().unwrap_or(&t.username).to_string()) .unwrap_or_else(|| "Someone".to_string()); let unsub_url = crate::email::generate_unsubscribe_url( &host_url, recipient.id, crate::email::UnsubscribeAction::NotifyTip, &recipient.id.to_string(), &signing_secret, ); if let Err(e) = email.send_tip_notification( &recipient.email, recipient.display_name.as_deref(), &tipper_name, &price, message.as_deref(), Some(&unsub_url), ).await { tracing::error!(error = ?e, "failed to send tip notification email"); } } }); } /// Check if a pending refund exists for this payment intent and process it. /// /// Called after a transaction is completed to handle out-of-order webhook /// delivery (refund arrived before payment confirmation). pub(super) async fn check_pending_refund(state: &AppState, payment_intent_id: &str) { let pending = match db::pending_refunds::claim_pending_refund(&state.db, payment_intent_id).await { Ok(Some(p)) => p, Ok(None) => return, Err(e) => { tracing::error!(error = ?e, "failed to check pending refunds"); return; } }; tracing::info!( payment_intent_id = %payment_intent_id, pending_refund_id = %pending.id, "found pending refund — processing now" ); let refund_data = crate::payments::ChargeRefundData { payment_intent_id: pending.payment_intent_id, amount: pending.amount, amount_refunded: pending.amount_refunded, }; if let Err(e) = super::billing::handle_charge_refunded(state, &refund_data).await { tracing::error!( error = ?e, pending_refund_id = %pending.id, "failed to process pending refund after payment completion" ); } } /// Record revenue splits for a completed item purchase. /// /// Looks up the item's project and its members. If the project has members /// with split percentages, creates split records for each member. The owner /// receives the remainder (100% minus all member splits). /// /// Splits are recorded as obligations; actual payment transfer to members /// is handled by the project owner outside the platform for now. pub(super) async fn record_transaction_splits( state: &AppState, transaction_id: db::TransactionId, item_id: db::ItemId, amount_cents: db::Cents, ) { let item = match db::items::get_item_by_id(&state.db, item_id).await { Ok(Some(item)) => item, _ => return, }; let members = match db::project_members::get_project_members(&state.db, item.project_id).await { Ok(m) if !m.is_empty() => m, _ => return, }; let splits = compute_splits(amount_cents, &members); if let Err(e) = db::project_members::create_transaction_splits(&state.db, transaction_id, &splits).await { tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to record transaction splits"); } else { tracing::info!(transaction_id = %transaction_id, member_count = splits.len(), "revenue splits recorded"); } } /// Record revenue splits for a completed tip on a project with members. pub(super) async fn record_tip_splits( state: &AppState, tip_id: db::TipId, project_id: db::ProjectId, amount_cents: db::Cents, ) { let members = match db::project_members::get_project_members(&state.db, project_id).await { Ok(m) if !m.is_empty() => m, _ => return, }; let splits = compute_splits(amount_cents, &members); if let Err(e) = db::project_members::create_tip_splits(&state.db, tip_id, &splits).await { tracing::error!(tip_id = %tip_id, error = ?e, "failed to record tip splits"); } else { tracing::info!(tip_id = %tip_id, member_count = splits.len(), "tip splits recorded"); } } /// Compute per-member split amounts with rounding. /// /// Uses floor division and distributes the remainder (one cent at a time) /// to the first members in list order so the total always equals /// `amount_cents * total_split_percent / 100`. fn compute_splits( amount_cents: db::Cents, members: &[db::DbProjectMemberWithUser], ) -> Vec<(db::UserId, i64, i16)> { let amount = amount_cents.as_i64(); // If members sum past 100%, scale each split proportionally so the total // matches `amount`. The previous "Defensive clamp" only capped // `expected_total`; per-member amounts were left at literal percent, // crediting >100% of revenue (e.g. two members at 60%+60% on $10 → $12 paid out). let raw_total_pct: i64 = members.iter().map(|m| m.split_percent as i64).sum(); let denom = raw_total_pct.max(100); let mut splits: Vec<(db::UserId, i64, i16)> = members .iter() .map(|m| { let member_amount = amount * m.split_percent as i64 / denom; (m.user_id, member_amount, m.split_percent) }) .collect(); let expected_total = (amount * raw_total_pct.min(100) / 100).min(amount); let actual_total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); let mut remainder = expected_total - actual_total; for split in &mut splits { if remainder <= 0 { break; } split.1 += 1; remainder -= 1; } splits } /// Send sale notification to the seller for a guest purchase. pub(super) fn send_guest_sale_notification( state: &AppState, tx: &db::DbTransaction, guest_email: &str, seller_id: db::UserId, ) { let db = state.db.clone(); let email_client = state.email.clone(); let host_url = state.config.host_url.clone(); let signing_secret = state.config.signing_secret.clone(); let amount_cents = tx.amount_cents; let item_title = tx.item_title.clone(); let buyer_label = guest_email.to_string(); state.bg.spawn("guest sale notification", async move { let seller = match db::users::get_user_by_id(&db, seller_id).await.ok().flatten() { Some(s) if s.notify_sale => s, _ => return, }; let price = helpers::format_price(amount_cents); let title = item_title.unwrap_or_else(|| "an item".to_string()); let unsub_url = crate::email::generate_unsubscribe_url( &host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &signing_secret, ); if let Err(e) = email_client.send_sale_notification( &seller.email, seller.display_name.as_deref(), &buyer_label, &title, &price, Some(&unsub_url), ).await { tracing::error!(error = ?e, "failed to send sale notification for guest purchase"); } }); } #[cfg(test)] mod tests { use super::*; use chrono::Utc; fn member(user_id: db::UserId, split_percent: i16) -> db::DbProjectMemberWithUser { db::DbProjectMemberWithUser { id: db::ProjectMemberId::new(), project_id: db::ProjectId::new(), user_id, role: db::ProjectRole::Member, split_percent, added_at: Utc::now(), username: String::new(), display_name: None, stripe_account_id: None, stripe_charges_enabled: false, } } #[test] fn single_member_100_percent() { let uid = db::UserId::new(); let members = vec![member(uid, 100)]; let splits = compute_splits(db::Cents::new(1000), &members); assert_eq!(splits.len(), 1); assert_eq!(splits[0], (uid, 1000, 100)); } #[test] fn two_members_50_50_even() { let u1 = db::UserId::new(); let u2 = db::UserId::new(); let members = vec![member(u1, 50), member(u2, 50)]; let splits = compute_splits(db::Cents::new(1000), &members); assert_eq!(splits, vec![(u1, 500, 50), (u2, 500, 50)]); } #[test] fn two_members_50_50_odd() { let u1 = db::UserId::new(); let u2 = db::UserId::new(); let members = vec![member(u1, 50), member(u2, 50)]; let splits = compute_splits(db::Cents::new(1001), &members); // floor(1001*50/100) = 500 each, expected total = floor(1001*100/100) = 1001 // remainder = 1001 - 1000 = 1, first member gets +1 assert_eq!(splits, vec![(u1, 501, 50), (u2, 500, 50)]); } #[test] fn three_members_33_33_34() { let u1 = db::UserId::new(); let u2 = db::UserId::new(); let u3 = db::UserId::new(); let members = vec![member(u1, 33), member(u2, 33), member(u3, 34)]; let splits = compute_splits(db::Cents::new(100), &members); let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); // expected_total = floor(100 * 100 / 100) = 100 assert_eq!(total, 100); } #[test] fn single_member_50_percent() { let uid = db::UserId::new(); let members = vec![member(uid, 50)]; let splits = compute_splits(db::Cents::new(1000), &members); assert_eq!(splits, vec![(uid, 500, 50)]); } #[test] fn zero_amount() { let u1 = db::UserId::new(); let u2 = db::UserId::new(); let members = vec![member(u1, 50), member(u2, 50)]; let splits = compute_splits(db::Cents::new(0), &members); assert_eq!(splits, vec![(u1, 0, 50), (u2, 0, 50)]); } #[test] fn single_cent_two_members() { let u1 = db::UserId::new(); let u2 = db::UserId::new(); let members = vec![member(u1, 50), member(u2, 50)]; let splits = compute_splits(db::Cents::new(1), &members); // floor(1*50/100) = 0 each, expected_total = floor(1*100/100) = 1 // remainder = 1, first member gets +1 assert_eq!(splits, vec![(u1, 1, 50), (u2, 0, 50)]); } #[test] fn two_members_60_60_misconfig_cannot_overcredit() { // Regression: previously the "Defensive clamp" comment promised this // case was handled, but per-member amounts were computed at literal // percent and only `expected_total` was clamped. A 60%+60% split on // $10 paid out $12. let u1 = db::UserId::new(); let u2 = db::UserId::new(); let members = vec![member(u1, 60), member(u2, 60)]; let splits = compute_splits(db::Cents::new(1000), &members); let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); assert!(total <= 1000, "splits sum {total} must not exceed amount 1000"); assert_eq!(total, 1000, "splits should distribute the full amount when sum>=100%"); } #[test] fn single_cent_three_members_no_panic() { let u1 = db::UserId::new(); let u2 = db::UserId::new(); let u3 = db::UserId::new(); let members = vec![member(u1, 33), member(u2, 33), member(u3, 34)]; let splits = compute_splits(db::Cents::new(1), &members); let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); // expected_total = floor(1*100/100) = 1 assert_eq!(total, 1); } #[test] fn large_amount_three_members() { let u1 = db::UserId::new(); let u2 = db::UserId::new(); let u3 = db::UserId::new(); let members = vec![member(u1, 33), member(u2, 33), member(u3, 34)]; let splits = compute_splits(db::Cents::new(1_000_000), &members); let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); // expected_total = floor(1_000_000 * 100 / 100) = 1_000_000 assert_eq!(total, 1_000_000); // Verify individual amounts are reasonable assert_eq!(splits[0].1 + splits[1].1, 2 * 330_000); } }