//! Helper functions for checkout webhook handlers: email notifications, //! license key generation, revenue splits, and pending refund processing. use crate::{config::Config, db, email::EmailClient, helpers, wam_client::WamClient}; use sqlx::PgPool; /// Generate a license key for the purchased item if keys are enabled. pub(crate) async fn maybe_generate_license_key( db: &PgPool, wam: Option<&WamClient>, item_id: db::ItemId, buyer_id: db::UserId, transaction_id: db::TransactionId, ) { let item = match db::items::get_item_by_id(db, item_id).await { Ok(Some(item)) if item.enable_license_keys => item, _ => return, }; // Idempotency pre-check: a crash-recovery redelivery re-runs finalize, but a // purchase mints at most one auto key. If one already exists for this // transaction, skip the mint. The `license_keys_transaction_id_key` partial // unique index is the structural backstop if this check is ever bypassed. match db::license_keys::get_license_key_by_transaction_id(db, transaction_id).await { Ok(Some(_)) => { tracing::debug!(transaction_id = %transaction_id, item_id = %item_id, "license key already minted for transaction; skipping"); return; } Ok(None) => {} Err(e) => { tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to check for existing license key; skipping mint to avoid duplicate"); return; } } let key_code = helpers::generate_key_code(); match db::license_keys::create_license_key( 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(wam) = 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; } } } } /// Run every secondary effect of a completed (logged-in) purchase, in order: /// bundle grants, contact-revocation clear, revenue splits, license-key mint, /// mailing-list subscribe, and the purchase/sale emails. /// /// This is the single place the purchase and cart handlers funnel their effect /// blocks through, so the two can't drift, and it is safe to re-run: a /// crash-recovery redelivery (transaction already completed, event not yet /// marked processed) re-invokes it. Every DB effect here is now idempotent /// (ON CONFLICT writes or a pre-check guarded by a unique index). The fire-and- /// forget emails may re-send on that rare redelivery; that is acceptable and /// consistent with the existing webhook architecture (handlers are idempotent /// on data, best-effort on notifications). #[allow(clippy::too_many_arguments)] pub(super) async fn finalize_purchase_transaction( db: &PgPool, bg: &crate::background::BackgroundTx, email: &EmailClient, wam: Option<&WamClient>, config: &Config, tx: &db::DbTransaction, buyer_id: db::UserId, seller_id: db::UserId, ) { // Grant access to bundle child items (if this purchase is a bundle). if let Some(item_id) = tx.item_id && let Ok(Some(purchased_item)) = db::items::get_item_by_id(db, item_id).await && purchased_item.item_type == db::ItemType::Bundle { crate::routes::stripe::checkout::grant_bundle_items( db, item_id, buyer_id, seller_id, Some(tx.id), ) .await; } // Contact-revocation clear (if the buyer opted to share contact). if tx.share_contact && let Err(e) = db::transactions::clear_contact_revocation(db, buyer_id, seller_id).await { tracing::error!(transaction_id = %tx.id, error = ?e, "failed to clear contact revocation after purchase"); } // Revenue splits, license key, mailing list (each keyed to the item). if let Some(item_id) = tx.item_id { record_transaction_splits(db, tx.id, item_id, tx.amount_cents).await; maybe_generate_license_key(db, wam, item_id, buyer_id, tx.id).await; subscribe_buyer_to_mailing_list(db, bg, item_id, buyer_id); } // Purchase confirmation + sale notification (fire-and-forget). send_purchase_emails(db, bg, email, config, tx, buyer_id, seller_id); } /// Send purchase confirmation to buyer and sale notification to seller (fire-and-forget). pub(super) fn send_purchase_emails( db: &PgPool, bg: &crate::background::BackgroundTx, email: &EmailClient, config: &Config, tx: &db::DbTransaction, buyer_id: db::UserId, seller_id: db::UserId, ) { let db = db.clone(); let email = email.clone(); let amount_cents = tx.amount_cents; let seller_currency = tx.currency(); let item_title = tx.item_title.clone(); let host_url = config.host_url.clone(); let signing_secret = config.signing_secret.clone(); 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, seller_currency); 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. The Sale preference is the send path's // question now; this only decides whether there is a seller to notify. if let Some(ref seller) = seller { let price = helpers::format_price(amount_cents, seller_currency); let title = item_title.unwrap_or_else(|| "an item".to_string()); let buyer_username = buyer .as_ref() .map_or_else(|| "Someone".to_string(), |b| b.username.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.id, &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( db: &PgPool, bg: &crate::background::BackgroundTx, item_id: db::ItemId, buyer_id: db::UserId, ) { let db = db.clone(); 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( db: &PgPool, bg: &crate::background::BackgroundTx, email: &EmailClient, config: &Config, tip: &db::DbTip, tipper_id: db::UserId, recipient_id: db::UserId, ) { let db = db.clone(); let email = email.clone(); let amount_cents = tip.amount_cents; let seller_currency = tip.currency; let message = tip.message.clone(); let host_url = config.host_url.clone(); let signing_secret = config.signing_secret.clone(); 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 { let price = helpers::format_price(amount_cents, seller_currency); let tipper_name = tipper.as_ref().map_or_else( || "Someone".to_string(), |t| t.display_name.as_deref().unwrap_or(&t.username).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.id, &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(db: &PgPool, payment_intent_id: &str) { let pending = match db::pending_refunds::claim_pending_refund(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, }; // requeue_if_unmatched = false: this row is already claimed, so an unmatched // result must release the claim (below), not insert a duplicate pending row. match super::billing::handle_charge_refunded(db, &refund_data, false).await { Ok(()) => { // Record completion only after the refund work succeeded. If the process // dies between the claim and this point, the row stays matched-but- // incomplete and the stale-refund sweep escalates it for manual // reconciliation (PAY-S1) instead of silently dropping the refund. if let Err(e) = db::pending_refunds::mark_refund_completed(db, pending.id).await { tracing::error!( error = ?e, pending_refund_id = %pending.id, "processed pending refund but failed to mark it completed, \ the sweep will escalate it for manual confirmation" ); } } Err(e) => { tracing::error!( error = ?e, pending_refund_id = %pending.id, "failed to process pending refund after payment completion, releasing claim" ); // `handle_charge_refunded` is atomic, so on a graceful error nothing // committed; release the claim so a later delivery can re-claim and // retry, and the sweep escalates it in the meantime. if let Err(e2) = db::pending_refunds::unclaim_pending_refund(db, pending.id).await { tracing::error!( error = ?e2, pending_refund_id = %pending.id, "failed to release pending refund claim after a processing failure, \ refund needs manual intervention" ); } } } } /// 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( db: &PgPool, transaction_id: db::TransactionId, item_id: db::ItemId, amount_cents: db::Cents, ) { let Ok(Some(item)) = db::items::get_item_by_id(db, item_id).await else { return; }; let members = match db::project_members::get_project_members(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(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( db: &PgPool, tip_id: db::TipId, project_id: db::ProjectId, amount_cents: db::Cents, ) { let members = match db::project_members::get_project_members(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(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(); // Pending invitations earn nothing. The percentage stays reserved against // the project (see `get_total_split_percent`) so the owner cannot promise it // twice, but a share is only paid to someone who has agreed to take it, and // to the currency it will arrive in. Filtering here rather than in the query // keeps the owner's member list showing pending rows. let members: Vec<&db::DbProjectMemberWithUser> = members.iter().filter(|m| m.is_accepted()).collect(); if members.is_empty() { return Vec::new(); } // One basis for BOTH the per-member share and the payout total, so the // remainder is provably the sum of the floor truncations (in 0..members.len) // rather than correct only by a max/min clamp coincidence (Run 11 surprise). // // `denom = max(sum, 100)`: // - members summing to <= 100%: each is paid their literal fraction and the // platform keeps the rest (denom is 100). // - members summing to > 100%: each is scaled down proportionally so the // whole `amount` is distributed and no one is over-credited (denom is the // sum), e.g. 60%+60% on $10 pays $10, not $12. 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(); // Exact (un-floored) members' share over the same denom, manifestly the sum // of the per-member shares before flooring, so the remainder reconciles. let payout_total = amount * raw_total_pct / denom; let actual_total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); let mut remainder = payout_total - actual_total; for split in &mut splits { if remainder <= 0 { break; } split.1 += 1; remainder -= 1; } splits } /// Run every secondary effect of a completed guest purchase, in order: revenue /// splits, the guest purchase confirmation (with claim + download links), and /// the seller sale notification. /// /// Mirrors [`finalize_purchase_transaction`] but for the guest path, which has /// no buyer account yet (the license key, if any, is minted at claim time in /// `claim_purchase`, not here). Re-runnable on a crash-recovery redelivery: /// splits go through an ON CONFLICT write and the emails are fire-and-forget /// (a re-send on that rare redelivery is acceptable). #[allow(clippy::too_many_arguments)] pub(super) fn finalize_guest_transaction( db: &PgPool, bg: &crate::background::BackgroundTx, email: &EmailClient, config: &Config, tx: &db::DbTransaction, guest_email: &str, item_id: db::ItemId, seller_id: db::UserId, ) { // Revenue splits (idempotent). let db_for_splits = db.clone(); let tx_id = tx.id; let amount_cents = tx.amount_cents; bg.spawn("guest revenue splits", async move { record_transaction_splits(&db_for_splits, tx_id, item_id, amount_cents).await; }); // Guest purchase confirmation with the claim link (fire-and-forget). if let (Some(download_token), Some(claim_token)) = (tx.download_token, tx.claim_token) { let email_client = email.clone(); let host_url = config.host_url.clone(); let item_title = tx .item_title .clone() .unwrap_or_else(|| "your item".to_string()); let price = helpers::format_price(tx.amount_cents, tx.currency()); let guest_email_addr = guest_email.to_string(); let download_url = format!("{host_url}/download/{download_token}"); let claim_url = format!("{host_url}/claim?token={claim_token}"); bg.spawn("guest purchase confirmation", async move { if let Err(e) = email_client .send_guest_purchase_confirmation( &guest_email_addr, &item_title, &price, &download_url, &claim_url, ) .await { tracing::error!(error = ?e, "failed to send guest purchase confirmation email"); } }); } // Sale notification to the seller (fire-and-forget). send_guest_sale_notification(db, bg, email, config, tx, guest_email, seller_id); } /// Send sale notification to the seller for a guest purchase. pub(super) fn send_guest_sale_notification( db: &PgPool, bg: &crate::background::BackgroundTx, email: &EmailClient, config: &Config, tx: &db::DbTransaction, guest_email: &str, seller_id: db::UserId, ) { let db = db.clone(); let email_client = email.clone(); let host_url = config.host_url.clone(); let signing_secret = config.signing_secret.clone(); let amount_cents = tx.amount_cents; let seller_currency = tx.currency(); let item_title = tx.item_title.clone(); let buyer_label = guest_email.to_string(); bg.spawn("guest sale notification", async move { let Some(seller) = db::users::get_user_by_id(&db, seller_id) .await .ok() .flatten() else { return; }; let price = helpers::format_price(amount_cents, seller_currency); 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.id, &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(), // Accepted, because these fixtures exercise the split arithmetic. // The pending case has its own test below. accepted_at: Some(Utc::now()), username: String::new(), display_name: None, stripe_account_id: None, stripe_charges_enabled: false, settlement_currency: crate::currency::SettlementCurrency::Usd, } } /// A member who has not accepted is not paid. fn pending_member(user_id: db::UserId, split_percent: i16) -> db::DbProjectMemberWithUser { db::DbProjectMemberWithUser { accepted_at: None, ..member(user_id, split_percent) } } #[test] fn a_pending_invitation_earns_nothing() { let accepted = db::UserId::new(); let pending = db::UserId::new(); let splits = compute_splits( db::Cents::new(10_000), &[member(accepted, 30), pending_member(pending, 30)], ); assert_eq!(splits.len(), 1, "only the accepted member is paid"); assert_eq!(splits[0].0, accepted); // 30% of $100, undiluted by the pending 30%: the reserved percentage is // held against the project, not handed to the other collaborator. assert_eq!(splits[0].1, 3_000); } #[test] fn a_project_where_nobody_has_accepted_pays_nobody() { let splits = compute_splits( db::Cents::new(10_000), &[pending_member(db::UserId::new(), 50)], ); assert!(splits.is_empty()); } #[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 under_100_percent_platform_keeps_remainder() { // Members sum to 70%, they receive exactly 70% of the amount and the // platform keeps the other 30%. Pins the single-basis payout_total so the // denom(max)/total(min) asymmetry can't drift back in (Run 11 surprise). let u1 = db::UserId::new(); let u2 = db::UserId::new(); let members = vec![member(u1, 30), member(u2, 40)]; let splits = compute_splits(db::Cents::new(1000), &members); let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); assert_eq!(splits, vec![(u1, 300, 30), (u2, 400, 40)]); assert_eq!(total, 700, "members get 70%, platform keeps 300"); } #[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); } }