Skip to main content

max / makenotwork

16.8 KB · 440 lines History Blame Raw
1 //! Helper functions for checkout webhook handlers: email notifications,
2 //! license key generation, revenue splits, and pending refund processing.
3
4 use crate::{
5 db,
6 helpers,
7 AppState,
8 };
9
10 /// Generate a license key for the purchased item if keys are enabled.
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 /// Send purchase confirmation to buyer and sale notification to seller (fire-and-forget).
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 // Purchase confirmation to buyer
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 // Sale notification to seller
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 /// Subscribe buyer to the item's project content mailing list (fire-and-forget).
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 /// Send tip notification to recipient (fire-and-forget).
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 /// Check if a pending refund exists for this payment intent and process it.
148 ///
149 /// Called after a transaction is completed to handle out-of-order webhook
150 /// delivery (refund arrived before payment confirmation).
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 /// Record revenue splits for a completed item purchase.
182 ///
183 /// Looks up the item's project and its members. If the project has members
184 /// with split percentages, creates split records for each member. The owner
185 /// receives the remainder (100% minus all member splits).
186 ///
187 /// Splits are recorded as obligations; actual payment transfer to members
188 /// is handled by the project owner outside the platform for now.
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 /// Record revenue splits for a completed tip on a project with members.
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 /// Compute per-member split amounts with rounding.
236 ///
237 /// Uses floor division and distributes the remainder (one cent at a time)
238 /// to the first members in list order so the total always equals
239 /// `amount_cents * total_split_percent / 100`.
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 // If members sum past 100%, scale each split proportionally so the total
247 // matches `amount`. The previous "Defensive clamp" only capped
248 // `expected_total`; per-member amounts were left at literal percent,
249 // crediting >100% of revenue (e.g. two members at 60%+60% on $10 → $12 paid out).
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 /// Send sale notification to the seller for a guest purchase.
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 // floor(1001*50/100) = 500 each, expected total = floor(1001*100/100) = 1001
355 // remainder = 1001 - 1000 = 1, first member gets +1
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 // expected_total = floor(100 * 100 / 100) = 100
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 // floor(1*50/100) = 0 each, expected_total = floor(1*100/100) = 1
395 // remainder = 1, first member gets +1
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 // Regression: previously the "Defensive clamp" comment promised this
402 // case was handled, but per-member amounts were computed at literal
403 // percent and only `expected_total` was clamped. A 60%+60% split on
404 // $10 paid out $12.
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 // expected_total = floor(1*100/100) = 1
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 // expected_total = floor(1_000_000 * 100 / 100) = 1_000_000
435 assert_eq!(total, 1_000_000);
436 // Verify individual amounts are reasonable
437 assert_eq!(splits[0].1 + splits[1].1, 2 * 330_000);
438 }
439 }
440