Skip to main content

max / makenotwork

28.5 KB · 763 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::{config::Config, db, email::EmailClient, helpers, wam_client::WamClient};
5 use sqlx::PgPool;
6
7 /// Generate a license key for the purchased item if keys are enabled.
8 pub(crate) async fn maybe_generate_license_key(
9 db: &PgPool,
10 wam: Option<&WamClient>,
11 item_id: db::ItemId,
12 buyer_id: db::UserId,
13 transaction_id: db::TransactionId,
14 ) {
15 let item = match db::items::get_item_by_id(db, item_id).await {
16 Ok(Some(item)) if item.enable_license_keys => item,
17 _ => return,
18 };
19
20 // Idempotency pre-check: a crash-recovery redelivery re-runs finalize, but a
21 // purchase mints at most one auto key. If one already exists for this
22 // transaction, skip the mint. The `license_keys_transaction_id_key` partial
23 // unique index is the structural backstop if this check is ever bypassed.
24 match db::license_keys::get_license_key_by_transaction_id(db, transaction_id).await {
25 Ok(Some(_)) => {
26 tracing::debug!(transaction_id = %transaction_id, item_id = %item_id, "license key already minted for transaction; skipping");
27 return;
28 }
29 Ok(None) => {}
30 Err(e) => {
31 tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to check for existing license key; skipping mint to avoid duplicate");
32 return;
33 }
34 }
35
36 let key_code = helpers::generate_key_code();
37 match db::license_keys::create_license_key(
38 db,
39 item_id,
40 buyer_id,
41 Some(transaction_id),
42 &key_code,
43 item.default_max_activations,
44 )
45 .await
46 {
47 Ok(key) => {
48 tracing::info!(key_id = %key.id, buyer_id = %buyer_id, item_id = %item_id, "license key generated for purchase");
49 }
50 Err(e) => {
51 tracing::error!(buyer_id = %buyer_id, item_id = %item_id, error = ?e, "failed to generate license key for purchase");
52 if let Some(wam) = wam {
53 let title = format!("License key not issued: item {item_id}");
54 let body = format!(
55 "Buyer {buyer_id} purchased item {item_id} (tx {transaction_id}) but \
56 license key generation failed: {e}\n\nManually issue a key.",
57 );
58 wam.create_ticket(
59 &title,
60 Some(&body),
61 "critical",
62 "license-key-gen-failed",
63 Some(&transaction_id.to_string()),
64 )
65 .await;
66 }
67 }
68 }
69 }
70
71 /// Run every secondary effect of a completed (logged-in) purchase, in order:
72 /// bundle grants, contact-revocation clear, revenue splits, license-key mint,
73 /// mailing-list subscribe, and the purchase/sale emails.
74 ///
75 /// This is the single place the purchase and cart handlers funnel their effect
76 /// blocks through, so the two can't drift, and it is safe to re-run: a
77 /// crash-recovery redelivery (transaction already completed, event not yet
78 /// marked processed) re-invokes it. Every DB effect here is now idempotent
79 /// (ON CONFLICT writes or a pre-check guarded by a unique index). The fire-and-
80 /// forget emails may re-send on that rare redelivery; that is acceptable and
81 /// consistent with the existing webhook architecture (handlers are idempotent
82 /// on data, best-effort on notifications).
83 #[allow(clippy::too_many_arguments)]
84 pub(super) async fn finalize_purchase_transaction(
85 db: &PgPool,
86 bg: &crate::background::BackgroundTx,
87 email: &EmailClient,
88 wam: Option<&WamClient>,
89 config: &Config,
90 tx: &db::DbTransaction,
91 buyer_id: db::UserId,
92 seller_id: db::UserId,
93 ) {
94 // Grant access to bundle child items (if this purchase is a bundle).
95 if let Some(item_id) = tx.item_id
96 && let Ok(Some(purchased_item)) = db::items::get_item_by_id(db, item_id).await
97 && purchased_item.item_type == db::ItemType::Bundle
98 {
99 crate::routes::stripe::checkout::grant_bundle_items(
100 db,
101 item_id,
102 buyer_id,
103 seller_id,
104 Some(tx.id),
105 )
106 .await;
107 }
108
109 // Contact-revocation clear (if the buyer opted to share contact).
110 if tx.share_contact
111 && let Err(e) = db::transactions::clear_contact_revocation(db, buyer_id, seller_id).await
112 {
113 tracing::error!(transaction_id = %tx.id, error = ?e, "failed to clear contact revocation after purchase");
114 }
115
116 // Revenue splits, license key, mailing list (each keyed to the item).
117 if let Some(item_id) = tx.item_id {
118 record_transaction_splits(db, tx.id, item_id, tx.amount_cents).await;
119 maybe_generate_license_key(db, wam, item_id, buyer_id, tx.id).await;
120 subscribe_buyer_to_mailing_list(db, bg, item_id, buyer_id);
121 }
122
123 // Purchase confirmation + sale notification (fire-and-forget).
124 send_purchase_emails(db, bg, email, config, tx, buyer_id, seller_id);
125 }
126
127 /// Send purchase confirmation to buyer and sale notification to seller (fire-and-forget).
128 pub(super) fn send_purchase_emails(
129 db: &PgPool,
130 bg: &crate::background::BackgroundTx,
131 email: &EmailClient,
132 config: &Config,
133 tx: &db::DbTransaction,
134 buyer_id: db::UserId,
135 seller_id: db::UserId,
136 ) {
137 let db = db.clone();
138 let email = email.clone();
139 let amount_cents = tx.amount_cents;
140 let seller_currency = tx.currency();
141 let item_title = tx.item_title.clone();
142 let host_url = config.host_url.clone();
143 let signing_secret = config.signing_secret.clone();
144
145 bg.spawn("purchase confirmation + sale notification", async move {
146 let buyer = db::users::get_user_by_id(&db, buyer_id)
147 .await
148 .ok()
149 .flatten();
150 let seller = db::users::get_user_by_id(&db, seller_id)
151 .await
152 .ok()
153 .flatten();
154
155 // Purchase confirmation to buyer
156 if let Some(ref buyer) = buyer {
157 let price = helpers::format_price(amount_cents, seller_currency);
158 let title = item_title
159 .clone()
160 .unwrap_or_else(|| "your item".to_string());
161 if let Err(e) = email
162 .send_purchase_confirmation(
163 &buyer.email,
164 buyer.display_name.as_deref(),
165 &title,
166 &price,
167 )
168 .await
169 {
170 tracing::error!(error = ?e, "failed to send purchase confirmation email");
171 }
172 }
173
174 // Sale notification to seller. The Sale preference is the send path's
175 // question now; this only decides whether there is a seller to notify.
176 if let Some(ref seller) = seller {
177 let price = helpers::format_price(amount_cents, seller_currency);
178 let title = item_title.unwrap_or_else(|| "an item".to_string());
179 let buyer_username = buyer
180 .as_ref()
181 .map_or_else(|| "Someone".to_string(), |b| b.username.to_string());
182 let unsub_url = crate::email::generate_unsubscribe_url(
183 &host_url,
184 seller.id,
185 crate::email::UnsubscribeAction::Sale,
186 &seller.id.to_string(),
187 &signing_secret,
188 );
189 if let Err(e) = email
190 .send_sale_notification(
191 seller.id,
192 &seller.email,
193 seller.display_name.as_deref(),
194 &buyer_username,
195 &title,
196 &price,
197 Some(&unsub_url),
198 )
199 .await
200 {
201 tracing::error!(error = ?e, "failed to send sale notification email");
202 }
203 }
204 });
205 }
206
207 /// Subscribe buyer to the item's project content mailing list (fire-and-forget).
208 pub(super) fn subscribe_buyer_to_mailing_list(
209 db: &PgPool,
210 bg: &crate::background::BackgroundTx,
211 item_id: db::ItemId,
212 buyer_id: db::UserId,
213 ) {
214 let db = db.clone();
215 bg.spawn("mailing list subscribe", async move {
216 if let Ok(Some(item)) = db::items::get_item_by_id(&db, item_id).await
217 && let Err(e) =
218 db::mailing_lists::subscribe_to_content_list(&db, item.project_id, buyer_id).await
219 {
220 tracing::warn!(
221 project_id = %item.project_id, buyer_id = %buyer_id,
222 error = ?e, "failed to subscribe buyer to content mailing list"
223 );
224 }
225 });
226 }
227
228 /// Send tip notification to recipient (fire-and-forget).
229 pub(super) fn send_tip_email(
230 db: &PgPool,
231 bg: &crate::background::BackgroundTx,
232 email: &EmailClient,
233 config: &Config,
234 tip: &db::DbTip,
235 tipper_id: db::UserId,
236 recipient_id: db::UserId,
237 ) {
238 let db = db.clone();
239 let email = email.clone();
240 let amount_cents = tip.amount_cents;
241 let seller_currency = tip.currency;
242 let message = tip.message.clone();
243 let host_url = config.host_url.clone();
244 let signing_secret = config.signing_secret.clone();
245
246 bg.spawn("tip notification", async move {
247 let tipper = db::users::get_user_by_id(&db, tipper_id)
248 .await
249 .ok()
250 .flatten();
251 let recipient = db::users::get_user_by_id(&db, recipient_id)
252 .await
253 .ok()
254 .flatten();
255
256 if let Some(ref recipient) = recipient {
257 let price = helpers::format_price(amount_cents, seller_currency);
258 let tipper_name = tipper.as_ref().map_or_else(
259 || "Someone".to_string(),
260 |t| t.display_name.as_deref().unwrap_or(&t.username).to_string(),
261 );
262
263 let unsub_url = crate::email::generate_unsubscribe_url(
264 &host_url,
265 recipient.id,
266 crate::email::UnsubscribeAction::NotifyTip,
267 &recipient.id.to_string(),
268 &signing_secret,
269 );
270 if let Err(e) = email
271 .send_tip_notification(
272 recipient.id,
273 &recipient.email,
274 recipient.display_name.as_deref(),
275 &tipper_name,
276 &price,
277 message.as_deref(),
278 Some(&unsub_url),
279 )
280 .await
281 {
282 tracing::error!(error = ?e, "failed to send tip notification email");
283 }
284 }
285 });
286 }
287
288 /// Check if a pending refund exists for this payment intent and process it.
289 ///
290 /// Called after a transaction is completed to handle out-of-order webhook
291 /// delivery (refund arrived before payment confirmation).
292 pub(super) async fn check_pending_refund(db: &PgPool, payment_intent_id: &str) {
293 let pending = match db::pending_refunds::claim_pending_refund(db, payment_intent_id).await {
294 Ok(Some(p)) => p,
295 Ok(None) => return,
296 Err(e) => {
297 tracing::error!(error = ?e, "failed to check pending refunds");
298 return;
299 }
300 };
301
302 tracing::info!(
303 payment_intent_id = %payment_intent_id,
304 pending_refund_id = %pending.id,
305 "found pending refund, processing now"
306 );
307
308 let refund_data = crate::payments::ChargeRefundData {
309 payment_intent_id: pending.payment_intent_id,
310 amount: pending.amount,
311 amount_refunded: pending.amount_refunded,
312 };
313
314 // requeue_if_unmatched = false: this row is already claimed, so an unmatched
315 // result must release the claim (below), not insert a duplicate pending row.
316 match super::billing::handle_charge_refunded(db, &refund_data, false).await {
317 Ok(()) => {
318 // Record completion only after the refund work succeeded. If the process
319 // dies between the claim and this point, the row stays matched-but-
320 // incomplete and the stale-refund sweep escalates it for manual
321 // reconciliation (PAY-S1) instead of silently dropping the refund.
322 if let Err(e) = db::pending_refunds::mark_refund_completed(db, pending.id).await {
323 tracing::error!(
324 error = ?e, pending_refund_id = %pending.id,
325 "processed pending refund but failed to mark it completed, \
326 the sweep will escalate it for manual confirmation"
327 );
328 }
329 }
330 Err(e) => {
331 tracing::error!(
332 error = ?e, pending_refund_id = %pending.id,
333 "failed to process pending refund after payment completion, releasing claim"
334 );
335 // `handle_charge_refunded` is atomic, so on a graceful error nothing
336 // committed; release the claim so a later delivery can re-claim and
337 // retry, and the sweep escalates it in the meantime.
338 if let Err(e2) = db::pending_refunds::unclaim_pending_refund(db, pending.id).await {
339 tracing::error!(
340 error = ?e2, pending_refund_id = %pending.id,
341 "failed to release pending refund claim after a processing failure, \
342 refund needs manual intervention"
343 );
344 }
345 }
346 }
347 }
348
349 /// Record revenue splits for a completed item purchase.
350 ///
351 /// Looks up the item's project and its members. If the project has members
352 /// with split percentages, creates split records for each member. The owner
353 /// receives the remainder (100% minus all member splits).
354 ///
355 /// Splits are recorded as obligations; actual payment transfer to members
356 /// is handled by the project owner outside the platform for now.
357 pub(super) async fn record_transaction_splits(
358 db: &PgPool,
359 transaction_id: db::TransactionId,
360 item_id: db::ItemId,
361 amount_cents: db::Cents,
362 ) {
363 let Ok(Some(item)) = db::items::get_item_by_id(db, item_id).await else {
364 return;
365 };
366
367 let members = match db::project_members::get_project_members(db, item.project_id).await {
368 Ok(m) if !m.is_empty() => m,
369 _ => return,
370 };
371
372 let splits = compute_splits(amount_cents, &members);
373
374 if let Err(e) =
375 db::project_members::create_transaction_splits(db, transaction_id, &splits).await
376 {
377 tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to record transaction splits");
378 } else {
379 tracing::info!(transaction_id = %transaction_id, member_count = splits.len(), "revenue splits recorded");
380 }
381 }
382
383 /// Record revenue splits for a completed tip on a project with members.
384 pub(super) async fn record_tip_splits(
385 db: &PgPool,
386 tip_id: db::TipId,
387 project_id: db::ProjectId,
388 amount_cents: db::Cents,
389 ) {
390 let members = match db::project_members::get_project_members(db, project_id).await {
391 Ok(m) if !m.is_empty() => m,
392 _ => return,
393 };
394
395 let splits = compute_splits(amount_cents, &members);
396
397 if let Err(e) = db::project_members::create_tip_splits(db, tip_id, &splits).await {
398 tracing::error!(tip_id = %tip_id, error = ?e, "failed to record tip splits");
399 } else {
400 tracing::info!(tip_id = %tip_id, member_count = splits.len(), "tip splits recorded");
401 }
402 }
403
404 /// Compute per-member split amounts with rounding.
405 ///
406 /// Uses floor division and distributes the remainder (one cent at a time)
407 /// to the first members in list order so the total always equals
408 /// `amount_cents * total_split_percent / 100`.
409 fn compute_splits(
410 amount_cents: db::Cents,
411 members: &[db::DbProjectMemberWithUser],
412 ) -> Vec<(db::UserId, i64, i16)> {
413 let amount = amount_cents.as_i64();
414
415 // Pending invitations earn nothing. The percentage stays reserved against
416 // the project (see `get_total_split_percent`) so the owner cannot promise it
417 // twice, but a share is only paid to someone who has agreed to take it, and
418 // to the currency it will arrive in. Filtering here rather than in the query
419 // keeps the owner's member list showing pending rows.
420 let members: Vec<&db::DbProjectMemberWithUser> =
421 members.iter().filter(|m| m.is_accepted()).collect();
422 if members.is_empty() {
423 return Vec::new();
424 }
425
426 // One basis for BOTH the per-member share and the payout total, so the
427 // remainder is provably the sum of the floor truncations (in 0..members.len)
428 // rather than correct only by a max/min clamp coincidence (Run 11 surprise).
429 //
430 // `denom = max(sum, 100)`:
431 // - members summing to <= 100%: each is paid their literal fraction and the
432 // platform keeps the rest (denom is 100).
433 // - members summing to > 100%: each is scaled down proportionally so the
434 // whole `amount` is distributed and no one is over-credited (denom is the
435 // sum), e.g. 60%+60% on $10 pays $10, not $12.
436 let raw_total_pct: i64 = members.iter().map(|m| m.split_percent as i64).sum();
437 let denom = raw_total_pct.max(100);
438
439 let mut splits: Vec<(db::UserId, i64, i16)> = members
440 .iter()
441 .map(|m| {
442 let member_amount = amount * m.split_percent as i64 / denom;
443 (m.user_id, member_amount, m.split_percent)
444 })
445 .collect();
446
447 // Exact (un-floored) members' share over the same denom, manifestly the sum
448 // of the per-member shares before flooring, so the remainder reconciles.
449 let payout_total = amount * raw_total_pct / denom;
450 let actual_total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum();
451 let mut remainder = payout_total - actual_total;
452 for split in &mut splits {
453 if remainder <= 0 {
454 break;
455 }
456 split.1 += 1;
457 remainder -= 1;
458 }
459
460 splits
461 }
462
463 /// Run every secondary effect of a completed guest purchase, in order: revenue
464 /// splits, the guest purchase confirmation (with claim + download links), and
465 /// the seller sale notification.
466 ///
467 /// Mirrors [`finalize_purchase_transaction`] but for the guest path, which has
468 /// no buyer account yet (the license key, if any, is minted at claim time in
469 /// `claim_purchase`, not here). Re-runnable on a crash-recovery redelivery:
470 /// splits go through an ON CONFLICT write and the emails are fire-and-forget
471 /// (a re-send on that rare redelivery is acceptable).
472 #[allow(clippy::too_many_arguments)]
473 pub(super) fn finalize_guest_transaction(
474 db: &PgPool,
475 bg: &crate::background::BackgroundTx,
476 email: &EmailClient,
477 config: &Config,
478 tx: &db::DbTransaction,
479 guest_email: &str,
480 item_id: db::ItemId,
481 seller_id: db::UserId,
482 ) {
483 // Revenue splits (idempotent).
484 let db_for_splits = db.clone();
485 let tx_id = tx.id;
486 let amount_cents = tx.amount_cents;
487 bg.spawn("guest revenue splits", async move {
488 record_transaction_splits(&db_for_splits, tx_id, item_id, amount_cents).await;
489 });
490
491 // Guest purchase confirmation with the claim link (fire-and-forget).
492 if let (Some(download_token), Some(claim_token)) = (tx.download_token, tx.claim_token) {
493 let email_client = email.clone();
494 let host_url = config.host_url.clone();
495 let item_title = tx
496 .item_title
497 .clone()
498 .unwrap_or_else(|| "your item".to_string());
499 let price = helpers::format_price(tx.amount_cents, tx.currency());
500 let guest_email_addr = guest_email.to_string();
501 let download_url = format!("{host_url}/download/{download_token}");
502 let claim_url = format!("{host_url}/claim?token={claim_token}");
503
504 bg.spawn("guest purchase confirmation", async move {
505 if let Err(e) = email_client
506 .send_guest_purchase_confirmation(
507 &guest_email_addr,
508 &item_title,
509 &price,
510 &download_url,
511 &claim_url,
512 )
513 .await
514 {
515 tracing::error!(error = ?e, "failed to send guest purchase confirmation email");
516 }
517 });
518 }
519
520 // Sale notification to the seller (fire-and-forget).
521 send_guest_sale_notification(db, bg, email, config, tx, guest_email, seller_id);
522 }
523
524 /// Send sale notification to the seller for a guest purchase.
525 pub(super) fn send_guest_sale_notification(
526 db: &PgPool,
527 bg: &crate::background::BackgroundTx,
528 email: &EmailClient,
529 config: &Config,
530 tx: &db::DbTransaction,
531 guest_email: &str,
532 seller_id: db::UserId,
533 ) {
534 let db = db.clone();
535 let email_client = email.clone();
536 let host_url = config.host_url.clone();
537 let signing_secret = config.signing_secret.clone();
538 let amount_cents = tx.amount_cents;
539 let seller_currency = tx.currency();
540 let item_title = tx.item_title.clone();
541 let buyer_label = guest_email.to_string();
542
543 bg.spawn("guest sale notification", async move {
544 let Some(seller) = db::users::get_user_by_id(&db, seller_id)
545 .await
546 .ok()
547 .flatten()
548 else {
549 return;
550 };
551 let price = helpers::format_price(amount_cents, seller_currency);
552 let title = item_title.unwrap_or_else(|| "an item".to_string());
553 let unsub_url = crate::email::generate_unsubscribe_url(
554 &host_url,
555 seller.id,
556 crate::email::UnsubscribeAction::Sale,
557 &seller.id.to_string(),
558 &signing_secret,
559 );
560 if let Err(e) = email_client
561 .send_sale_notification(
562 seller.id,
563 &seller.email,
564 seller.display_name.as_deref(),
565 &buyer_label,
566 &title,
567 &price,
568 Some(&unsub_url),
569 )
570 .await
571 {
572 tracing::error!(error = ?e, "failed to send sale notification for guest purchase");
573 }
574 });
575 }
576
577 #[cfg(test)]
578 mod tests {
579 use super::*;
580 use chrono::Utc;
581
582 fn member(user_id: db::UserId, split_percent: i16) -> db::DbProjectMemberWithUser {
583 db::DbProjectMemberWithUser {
584 id: db::ProjectMemberId::new(),
585 project_id: db::ProjectId::new(),
586 user_id,
587 role: db::ProjectRole::Member,
588 split_percent,
589 added_at: Utc::now(),
590 // Accepted, because these fixtures exercise the split arithmetic.
591 // The pending case has its own test below.
592 accepted_at: Some(Utc::now()),
593 username: String::new(),
594 display_name: None,
595 stripe_account_id: None,
596 stripe_charges_enabled: false,
597 settlement_currency: crate::currency::SettlementCurrency::Usd,
598 }
599 }
600
601 /// A member who has not accepted is not paid.
602 fn pending_member(user_id: db::UserId, split_percent: i16) -> db::DbProjectMemberWithUser {
603 db::DbProjectMemberWithUser {
604 accepted_at: None,
605 ..member(user_id, split_percent)
606 }
607 }
608
609 #[test]
610 fn a_pending_invitation_earns_nothing() {
611 let accepted = db::UserId::new();
612 let pending = db::UserId::new();
613 let splits = compute_splits(
614 db::Cents::new(10_000),
615 &[member(accepted, 30), pending_member(pending, 30)],
616 );
617 assert_eq!(splits.len(), 1, "only the accepted member is paid");
618 assert_eq!(splits[0].0, accepted);
619 // 30% of $100, undiluted by the pending 30%: the reserved percentage is
620 // held against the project, not handed to the other collaborator.
621 assert_eq!(splits[0].1, 3_000);
622 }
623
624 #[test]
625 fn a_project_where_nobody_has_accepted_pays_nobody() {
626 let splits = compute_splits(
627 db::Cents::new(10_000),
628 &[pending_member(db::UserId::new(), 50)],
629 );
630 assert!(splits.is_empty());
631 }
632
633 #[test]
634 fn single_member_100_percent() {
635 let uid = db::UserId::new();
636 let members = vec![member(uid, 100)];
637 let splits = compute_splits(db::Cents::new(1000), &members);
638 assert_eq!(splits.len(), 1);
639 assert_eq!(splits[0], (uid, 1000, 100));
640 }
641
642 #[test]
643 fn two_members_50_50_even() {
644 let u1 = db::UserId::new();
645 let u2 = db::UserId::new();
646 let members = vec![member(u1, 50), member(u2, 50)];
647 let splits = compute_splits(db::Cents::new(1000), &members);
648 assert_eq!(splits, vec![(u1, 500, 50), (u2, 500, 50)]);
649 }
650
651 #[test]
652 fn two_members_50_50_odd() {
653 let u1 = db::UserId::new();
654 let u2 = db::UserId::new();
655 let members = vec![member(u1, 50), member(u2, 50)];
656 let splits = compute_splits(db::Cents::new(1001), &members);
657 // floor(1001*50/100) = 500 each, expected total = floor(1001*100/100) = 1001
658 // remainder = 1001 - 1000 = 1, first member gets +1
659 assert_eq!(splits, vec![(u1, 501, 50), (u2, 500, 50)]);
660 }
661
662 #[test]
663 fn three_members_33_33_34() {
664 let u1 = db::UserId::new();
665 let u2 = db::UserId::new();
666 let u3 = db::UserId::new();
667 let members = vec![member(u1, 33), member(u2, 33), member(u3, 34)];
668 let splits = compute_splits(db::Cents::new(100), &members);
669 let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum();
670 // expected_total = floor(100 * 100 / 100) = 100
671 assert_eq!(total, 100);
672 }
673
674 #[test]
675 fn single_member_50_percent() {
676 let uid = db::UserId::new();
677 let members = vec![member(uid, 50)];
678 let splits = compute_splits(db::Cents::new(1000), &members);
679 assert_eq!(splits, vec![(uid, 500, 50)]);
680 }
681
682 #[test]
683 fn zero_amount() {
684 let u1 = db::UserId::new();
685 let u2 = db::UserId::new();
686 let members = vec![member(u1, 50), member(u2, 50)];
687 let splits = compute_splits(db::Cents::new(0), &members);
688 assert_eq!(splits, vec![(u1, 0, 50), (u2, 0, 50)]);
689 }
690
691 #[test]
692 fn single_cent_two_members() {
693 let u1 = db::UserId::new();
694 let u2 = db::UserId::new();
695 let members = vec![member(u1, 50), member(u2, 50)];
696 let splits = compute_splits(db::Cents::new(1), &members);
697 // floor(1*50/100) = 0 each, expected_total = floor(1*100/100) = 1
698 // remainder = 1, first member gets +1
699 assert_eq!(splits, vec![(u1, 1, 50), (u2, 0, 50)]);
700 }
701
702 #[test]
703 fn two_members_60_60_misconfig_cannot_overcredit() {
704 // Regression: previously the "Defensive clamp" comment promised this
705 // case was handled, but per-member amounts were computed at literal
706 // percent and only `expected_total` was clamped. A 60%+60% split on
707 // $10 paid out $12.
708 let u1 = db::UserId::new();
709 let u2 = db::UserId::new();
710 let members = vec![member(u1, 60), member(u2, 60)];
711 let splits = compute_splits(db::Cents::new(1000), &members);
712 let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum();
713 assert!(
714 total <= 1000,
715 "splits sum {total} must not exceed amount 1000"
716 );
717 assert_eq!(
718 total, 1000,
719 "splits should distribute the full amount when sum>=100%"
720 );
721 }
722
723 #[test]
724 fn under_100_percent_platform_keeps_remainder() {
725 // Members sum to 70%, they receive exactly 70% of the amount and the
726 // platform keeps the other 30%. Pins the single-basis payout_total so the
727 // denom(max)/total(min) asymmetry can't drift back in (Run 11 surprise).
728 let u1 = db::UserId::new();
729 let u2 = db::UserId::new();
730 let members = vec![member(u1, 30), member(u2, 40)];
731 let splits = compute_splits(db::Cents::new(1000), &members);
732 let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum();
733 assert_eq!(splits, vec![(u1, 300, 30), (u2, 400, 40)]);
734 assert_eq!(total, 700, "members get 70%, platform keeps 300");
735 }
736
737 #[test]
738 fn single_cent_three_members_no_panic() {
739 let u1 = db::UserId::new();
740 let u2 = db::UserId::new();
741 let u3 = db::UserId::new();
742 let members = vec![member(u1, 33), member(u2, 33), member(u3, 34)];
743 let splits = compute_splits(db::Cents::new(1), &members);
744 let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum();
745 // expected_total = floor(1*100/100) = 1
746 assert_eq!(total, 1);
747 }
748
749 #[test]
750 fn large_amount_three_members() {
751 let u1 = db::UserId::new();
752 let u2 = db::UserId::new();
753 let u3 = db::UserId::new();
754 let members = vec![member(u1, 33), member(u2, 33), member(u3, 34)];
755 let splits = compute_splits(db::Cents::new(1_000_000), &members);
756 let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum();
757 // expected_total = floor(1_000_000 * 100 / 100) = 1_000_000
758 assert_eq!(total, 1_000_000);
759 // Verify individual amounts are reasonable
760 assert_eq!(splits[0].1 + splits[1].1, 2 * 330_000);
761 }
762 }
763