| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
use crate::{ |
| 5 |
db, |
| 6 |
helpers, |
| 7 |
AppState, |
| 8 |
}; |
| 9 |
|
| 10 |
|
| 11 |
pub(super) async fn maybe_generate_license_key( |
| 12 |
state: &AppState, |
| 13 |
item_id: db::ItemId, |
| 14 |
buyer_id: db::UserId, |
| 15 |
transaction_id: db::TransactionId, |
| 16 |
) { |
| 17 |
let item = match db::items::get_item_by_id(&state.db, item_id).await { |
| 18 |
Ok(Some(item)) if item.enable_license_keys => item, |
| 19 |
_ => return, |
| 20 |
}; |
| 21 |
|
| 22 |
let key_code = helpers::generate_key_code(); |
| 23 |
match db::license_keys::create_license_key( |
| 24 |
&state.db, item_id, buyer_id, Some(transaction_id), |
| 25 |
&key_code, item.default_max_activations, |
| 26 |
).await { |
| 27 |
Ok(key) => { |
| 28 |
tracing::info!(key_id = %key.id, buyer_id = %buyer_id, item_id = %item_id, "license key generated for purchase"); |
| 29 |
} |
| 30 |
Err(e) => { |
| 31 |
tracing::error!(buyer_id = %buyer_id, item_id = %item_id, error = ?e, "failed to generate license key for purchase"); |
| 32 |
if let Some(ref wam) = state.wam { |
| 33 |
let title = format!("License key not issued: item {item_id}"); |
| 34 |
let body = format!( |
| 35 |
"Buyer {buyer_id} purchased item {item_id} (tx {transaction_id}) but \ |
| 36 |
license key generation failed: {e}\n\nManually issue a key.", |
| 37 |
); |
| 38 |
wam.create_ticket(&title, Some(&body), "critical", "license-key-gen-failed", Some(&transaction_id.to_string())).await; |
| 39 |
} |
| 40 |
} |
| 41 |
} |
| 42 |
} |
| 43 |
|
| 44 |
|
| 45 |
pub(super) fn send_purchase_emails( |
| 46 |
state: &AppState, |
| 47 |
tx: &db::DbTransaction, |
| 48 |
buyer_id: db::UserId, |
| 49 |
seller_id: db::UserId, |
| 50 |
) { |
| 51 |
let db = state.db.clone(); |
| 52 |
let email = state.email.clone(); |
| 53 |
let amount_cents = tx.amount_cents; |
| 54 |
let item_title = tx.item_title.clone(); |
| 55 |
let host_url = state.config.host_url.clone(); |
| 56 |
let signing_secret = state.config.signing_secret.clone(); |
| 57 |
|
| 58 |
state.bg.spawn("purchase confirmation + sale notification", async move { |
| 59 |
let buyer = db::users::get_user_by_id(&db, buyer_id).await.ok().flatten(); |
| 60 |
let seller = db::users::get_user_by_id(&db, seller_id).await.ok().flatten(); |
| 61 |
|
| 62 |
|
| 63 |
if let Some(ref buyer) = buyer { |
| 64 |
let price = helpers::format_price(amount_cents); |
| 65 |
let title = item_title.clone().unwrap_or_else(|| "your item".to_string()); |
| 66 |
if let Err(e) = email.send_purchase_confirmation( |
| 67 |
&buyer.email, buyer.display_name.as_deref(), &title, &price, |
| 68 |
).await { |
| 69 |
tracing::error!(error = ?e, "failed to send purchase confirmation email"); |
| 70 |
} |
| 71 |
} |
| 72 |
|
| 73 |
|
| 74 |
if let Some(ref seller) = seller && seller.notify_sale { |
| 75 |
let price = helpers::format_price(amount_cents); |
| 76 |
let title = item_title.unwrap_or_else(|| "an item".to_string()); |
| 77 |
let buyer_username = buyer.as_ref() |
| 78 |
.map(|b| b.username.to_string()) |
| 79 |
.unwrap_or_else(|| "Someone".to_string()); |
| 80 |
let unsub_url = crate::email::generate_unsubscribe_url( |
| 81 |
&host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &signing_secret, |
| 82 |
); |
| 83 |
if let Err(e) = email.send_sale_notification( |
| 84 |
&seller.email, seller.display_name.as_deref(), |
| 85 |
&buyer_username, &title, &price, Some(&unsub_url), |
| 86 |
).await { |
| 87 |
tracing::error!(error = ?e, "failed to send sale notification email"); |
| 88 |
} |
| 89 |
} |
| 90 |
}); |
| 91 |
} |
| 92 |
|
| 93 |
|
| 94 |
pub(super) fn subscribe_buyer_to_mailing_list(state: &AppState, item_id: db::ItemId, buyer_id: db::UserId) { |
| 95 |
let db = state.db.clone(); |
| 96 |
state.bg.spawn("mailing list subscribe", async move { |
| 97 |
if let Ok(Some(item)) = db::items::get_item_by_id(&db, item_id).await |
| 98 |
&& let Err(e) = db::mailing_lists::subscribe_to_content_list( |
| 99 |
&db, item.project_id, buyer_id, |
| 100 |
).await |
| 101 |
{ |
| 102 |
tracing::warn!( |
| 103 |
project_id = %item.project_id, buyer_id = %buyer_id, |
| 104 |
error = ?e, "failed to subscribe buyer to content mailing list" |
| 105 |
); |
| 106 |
} |
| 107 |
}); |
| 108 |
} |
| 109 |
|
| 110 |
|
| 111 |
pub(super) fn send_tip_email( |
| 112 |
state: &AppState, |
| 113 |
tip: &db::DbTip, |
| 114 |
tipper_id: db::UserId, |
| 115 |
recipient_id: db::UserId, |
| 116 |
) { |
| 117 |
let db = state.db.clone(); |
| 118 |
let email = state.email.clone(); |
| 119 |
let amount_cents = tip.amount_cents; |
| 120 |
let message = tip.message.clone(); |
| 121 |
let host_url = state.config.host_url.clone(); |
| 122 |
let signing_secret = state.config.signing_secret.clone(); |
| 123 |
|
| 124 |
state.bg.spawn("tip notification", async move { |
| 125 |
let tipper = db::users::get_user_by_id(&db, tipper_id).await.ok().flatten(); |
| 126 |
let recipient = db::users::get_user_by_id(&db, recipient_id).await.ok().flatten(); |
| 127 |
|
| 128 |
if let Some(ref recipient) = recipient && recipient.notify_tip { |
| 129 |
let price = helpers::format_price(amount_cents); |
| 130 |
let tipper_name = tipper.as_ref() |
| 131 |
.map(|t| t.display_name.as_deref().unwrap_or(&t.username).to_string()) |
| 132 |
.unwrap_or_else(|| "Someone".to_string()); |
| 133 |
|
| 134 |
let unsub_url = crate::email::generate_unsubscribe_url( |
| 135 |
&host_url, recipient.id, crate::email::UnsubscribeAction::NotifyTip, &recipient.id.to_string(), &signing_secret, |
| 136 |
); |
| 137 |
if let Err(e) = email.send_tip_notification( |
| 138 |
&recipient.email, recipient.display_name.as_deref(), |
| 139 |
&tipper_name, &price, message.as_deref(), Some(&unsub_url), |
| 140 |
).await { |
| 141 |
tracing::error!(error = ?e, "failed to send tip notification email"); |
| 142 |
} |
| 143 |
} |
| 144 |
}); |
| 145 |
} |
| 146 |
|
| 147 |
|
| 148 |
|
| 149 |
|
| 150 |
|
| 151 |
pub(super) async fn check_pending_refund(state: &AppState, payment_intent_id: &str) { |
| 152 |
let pending = match db::pending_refunds::claim_pending_refund(&state.db, payment_intent_id).await { |
| 153 |
Ok(Some(p)) => p, |
| 154 |
Ok(None) => return, |
| 155 |
Err(e) => { |
| 156 |
tracing::error!(error = ?e, "failed to check pending refunds"); |
| 157 |
return; |
| 158 |
} |
| 159 |
}; |
| 160 |
|
| 161 |
tracing::info!( |
| 162 |
payment_intent_id = %payment_intent_id, |
| 163 |
pending_refund_id = %pending.id, |
| 164 |
"found pending refund — processing now" |
| 165 |
); |
| 166 |
|
| 167 |
let refund_data = crate::payments::ChargeRefundData { |
| 168 |
payment_intent_id: pending.payment_intent_id, |
| 169 |
amount: pending.amount, |
| 170 |
amount_refunded: pending.amount_refunded, |
| 171 |
}; |
| 172 |
|
| 173 |
if let Err(e) = super::billing::handle_charge_refunded(state, &refund_data).await { |
| 174 |
tracing::error!( |
| 175 |
error = ?e, pending_refund_id = %pending.id, |
| 176 |
"failed to process pending refund after payment completion" |
| 177 |
); |
| 178 |
} |
| 179 |
} |
| 180 |
|
| 181 |
|
| 182 |
|
| 183 |
|
| 184 |
|
| 185 |
|
| 186 |
|
| 187 |
|
| 188 |
|
| 189 |
pub(super) async fn record_transaction_splits( |
| 190 |
state: &AppState, |
| 191 |
transaction_id: db::TransactionId, |
| 192 |
item_id: db::ItemId, |
| 193 |
amount_cents: db::Cents, |
| 194 |
) { |
| 195 |
let item = match db::items::get_item_by_id(&state.db, item_id).await { |
| 196 |
Ok(Some(item)) => item, |
| 197 |
_ => return, |
| 198 |
}; |
| 199 |
|
| 200 |
let members = match db::project_members::get_project_members(&state.db, item.project_id).await { |
| 201 |
Ok(m) if !m.is_empty() => m, |
| 202 |
_ => return, |
| 203 |
}; |
| 204 |
|
| 205 |
let splits = compute_splits(amount_cents, &members); |
| 206 |
|
| 207 |
if let Err(e) = db::project_members::create_transaction_splits(&state.db, transaction_id, &splits).await { |
| 208 |
tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to record transaction splits"); |
| 209 |
} else { |
| 210 |
tracing::info!(transaction_id = %transaction_id, member_count = splits.len(), "revenue splits recorded"); |
| 211 |
} |
| 212 |
} |
| 213 |
|
| 214 |
|
| 215 |
pub(super) async fn record_tip_splits( |
| 216 |
state: &AppState, |
| 217 |
tip_id: db::TipId, |
| 218 |
project_id: db::ProjectId, |
| 219 |
amount_cents: db::Cents, |
| 220 |
) { |
| 221 |
let members = match db::project_members::get_project_members(&state.db, project_id).await { |
| 222 |
Ok(m) if !m.is_empty() => m, |
| 223 |
_ => return, |
| 224 |
}; |
| 225 |
|
| 226 |
let splits = compute_splits(amount_cents, &members); |
| 227 |
|
| 228 |
if let Err(e) = db::project_members::create_tip_splits(&state.db, tip_id, &splits).await { |
| 229 |
tracing::error!(tip_id = %tip_id, error = ?e, "failed to record tip splits"); |
| 230 |
} else { |
| 231 |
tracing::info!(tip_id = %tip_id, member_count = splits.len(), "tip splits recorded"); |
| 232 |
} |
| 233 |
} |
| 234 |
|
| 235 |
|
| 236 |
|
| 237 |
|
| 238 |
|
| 239 |
|
| 240 |
fn compute_splits( |
| 241 |
amount_cents: db::Cents, |
| 242 |
members: &[db::DbProjectMemberWithUser], |
| 243 |
) -> Vec<(db::UserId, i64, i16)> { |
| 244 |
let amount = amount_cents.as_i64(); |
| 245 |
|
| 246 |
|
| 247 |
|
| 248 |
|
| 249 |
|
| 250 |
let raw_total_pct: i64 = members.iter().map(|m| m.split_percent as i64).sum(); |
| 251 |
let denom = raw_total_pct.max(100); |
| 252 |
|
| 253 |
let mut splits: Vec<(db::UserId, i64, i16)> = members |
| 254 |
.iter() |
| 255 |
.map(|m| { |
| 256 |
let member_amount = amount * m.split_percent as i64 / denom; |
| 257 |
(m.user_id, member_amount, m.split_percent) |
| 258 |
}) |
| 259 |
.collect(); |
| 260 |
|
| 261 |
let expected_total = (amount * raw_total_pct.min(100) / 100).min(amount); |
| 262 |
let actual_total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); |
| 263 |
let mut remainder = expected_total - actual_total; |
| 264 |
for split in &mut splits { |
| 265 |
if remainder <= 0 { |
| 266 |
break; |
| 267 |
} |
| 268 |
split.1 += 1; |
| 269 |
remainder -= 1; |
| 270 |
} |
| 271 |
|
| 272 |
splits |
| 273 |
} |
| 274 |
|
| 275 |
|
| 276 |
pub(super) fn send_guest_sale_notification( |
| 277 |
state: &AppState, |
| 278 |
tx: &db::DbTransaction, |
| 279 |
guest_email: &str, |
| 280 |
seller_id: db::UserId, |
| 281 |
) { |
| 282 |
let db = state.db.clone(); |
| 283 |
let email_client = state.email.clone(); |
| 284 |
let host_url = state.config.host_url.clone(); |
| 285 |
let signing_secret = state.config.signing_secret.clone(); |
| 286 |
let amount_cents = tx.amount_cents; |
| 287 |
let item_title = tx.item_title.clone(); |
| 288 |
let buyer_label = guest_email.to_string(); |
| 289 |
|
| 290 |
state.bg.spawn("guest sale notification", async move { |
| 291 |
let seller = match db::users::get_user_by_id(&db, seller_id).await.ok().flatten() { |
| 292 |
Some(s) if s.notify_sale => s, |
| 293 |
_ => return, |
| 294 |
}; |
| 295 |
|
| 296 |
let price = helpers::format_price(amount_cents); |
| 297 |
let title = item_title.unwrap_or_else(|| "an item".to_string()); |
| 298 |
let unsub_url = crate::email::generate_unsubscribe_url( |
| 299 |
&host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &signing_secret, |
| 300 |
); |
| 301 |
if let Err(e) = email_client.send_sale_notification( |
| 302 |
&seller.email, seller.display_name.as_deref(), |
| 303 |
&buyer_label, &title, &price, Some(&unsub_url), |
| 304 |
).await { |
| 305 |
tracing::error!(error = ?e, "failed to send sale notification for guest purchase"); |
| 306 |
} |
| 307 |
}); |
| 308 |
} |
| 309 |
|
| 310 |
#[cfg(test)] |
| 311 |
mod tests { |
| 312 |
use super::*; |
| 313 |
use chrono::Utc; |
| 314 |
|
| 315 |
fn member(user_id: db::UserId, split_percent: i16) -> db::DbProjectMemberWithUser { |
| 316 |
db::DbProjectMemberWithUser { |
| 317 |
id: db::ProjectMemberId::new(), |
| 318 |
project_id: db::ProjectId::new(), |
| 319 |
user_id, |
| 320 |
role: db::ProjectRole::Member, |
| 321 |
split_percent, |
| 322 |
added_at: Utc::now(), |
| 323 |
username: String::new(), |
| 324 |
display_name: None, |
| 325 |
stripe_account_id: None, |
| 326 |
stripe_charges_enabled: false, |
| 327 |
} |
| 328 |
} |
| 329 |
|
| 330 |
#[test] |
| 331 |
fn single_member_100_percent() { |
| 332 |
let uid = db::UserId::new(); |
| 333 |
let members = vec![member(uid, 100)]; |
| 334 |
let splits = compute_splits(db::Cents::new(1000), &members); |
| 335 |
assert_eq!(splits.len(), 1); |
| 336 |
assert_eq!(splits[0], (uid, 1000, 100)); |
| 337 |
} |
| 338 |
|
| 339 |
#[test] |
| 340 |
fn two_members_50_50_even() { |
| 341 |
let u1 = db::UserId::new(); |
| 342 |
let u2 = db::UserId::new(); |
| 343 |
let members = vec![member(u1, 50), member(u2, 50)]; |
| 344 |
let splits = compute_splits(db::Cents::new(1000), &members); |
| 345 |
assert_eq!(splits, vec![(u1, 500, 50), (u2, 500, 50)]); |
| 346 |
} |
| 347 |
|
| 348 |
#[test] |
| 349 |
fn two_members_50_50_odd() { |
| 350 |
let u1 = db::UserId::new(); |
| 351 |
let u2 = db::UserId::new(); |
| 352 |
let members = vec![member(u1, 50), member(u2, 50)]; |
| 353 |
let splits = compute_splits(db::Cents::new(1001), &members); |
| 354 |
|
| 355 |
|
| 356 |
assert_eq!(splits, vec![(u1, 501, 50), (u2, 500, 50)]); |
| 357 |
} |
| 358 |
|
| 359 |
#[test] |
| 360 |
fn three_members_33_33_34() { |
| 361 |
let u1 = db::UserId::new(); |
| 362 |
let u2 = db::UserId::new(); |
| 363 |
let u3 = db::UserId::new(); |
| 364 |
let members = vec![member(u1, 33), member(u2, 33), member(u3, 34)]; |
| 365 |
let splits = compute_splits(db::Cents::new(100), &members); |
| 366 |
let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); |
| 367 |
|
| 368 |
assert_eq!(total, 100); |
| 369 |
} |
| 370 |
|
| 371 |
#[test] |
| 372 |
fn single_member_50_percent() { |
| 373 |
let uid = db::UserId::new(); |
| 374 |
let members = vec![member(uid, 50)]; |
| 375 |
let splits = compute_splits(db::Cents::new(1000), &members); |
| 376 |
assert_eq!(splits, vec![(uid, 500, 50)]); |
| 377 |
} |
| 378 |
|
| 379 |
#[test] |
| 380 |
fn zero_amount() { |
| 381 |
let u1 = db::UserId::new(); |
| 382 |
let u2 = db::UserId::new(); |
| 383 |
let members = vec![member(u1, 50), member(u2, 50)]; |
| 384 |
let splits = compute_splits(db::Cents::new(0), &members); |
| 385 |
assert_eq!(splits, vec![(u1, 0, 50), (u2, 0, 50)]); |
| 386 |
} |
| 387 |
|
| 388 |
#[test] |
| 389 |
fn single_cent_two_members() { |
| 390 |
let u1 = db::UserId::new(); |
| 391 |
let u2 = db::UserId::new(); |
| 392 |
let members = vec![member(u1, 50), member(u2, 50)]; |
| 393 |
let splits = compute_splits(db::Cents::new(1), &members); |
| 394 |
|
| 395 |
|
| 396 |
assert_eq!(splits, vec![(u1, 1, 50), (u2, 0, 50)]); |
| 397 |
} |
| 398 |
|
| 399 |
#[test] |
| 400 |
fn two_members_60_60_misconfig_cannot_overcredit() { |
| 401 |
|
| 402 |
|
| 403 |
|
| 404 |
|
| 405 |
let u1 = db::UserId::new(); |
| 406 |
let u2 = db::UserId::new(); |
| 407 |
let members = vec![member(u1, 60), member(u2, 60)]; |
| 408 |
let splits = compute_splits(db::Cents::new(1000), &members); |
| 409 |
let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); |
| 410 |
assert!(total <= 1000, "splits sum {total} must not exceed amount 1000"); |
| 411 |
assert_eq!(total, 1000, "splits should distribute the full amount when sum>=100%"); |
| 412 |
} |
| 413 |
|
| 414 |
#[test] |
| 415 |
fn single_cent_three_members_no_panic() { |
| 416 |
let u1 = db::UserId::new(); |
| 417 |
let u2 = db::UserId::new(); |
| 418 |
let u3 = db::UserId::new(); |
| 419 |
let members = vec![member(u1, 33), member(u2, 33), member(u3, 34)]; |
| 420 |
let splits = compute_splits(db::Cents::new(1), &members); |
| 421 |
let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); |
| 422 |
|
| 423 |
assert_eq!(total, 1); |
| 424 |
} |
| 425 |
|
| 426 |
#[test] |
| 427 |
fn large_amount_three_members() { |
| 428 |
let u1 = db::UserId::new(); |
| 429 |
let u2 = db::UserId::new(); |
| 430 |
let u3 = db::UserId::new(); |
| 431 |
let members = vec![member(u1, 33), member(u2, 33), member(u3, 34)]; |
| 432 |
let splits = compute_splits(db::Cents::new(1_000_000), &members); |
| 433 |
let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); |
| 434 |
|
| 435 |
assert_eq!(total, 1_000_000); |
| 436 |
|
| 437 |
assert_eq!(splits[0].1 + splits[1].1, 2 * 330_000); |
| 438 |
} |
| 439 |
} |
| 440 |
|