Skip to main content

max / makenotwork

26.3 KB · 715 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 item_title = tx.item_title.clone();
141 let host_url = config.host_url.clone();
142 let signing_secret = config.signing_secret.clone();
143
144 bg.spawn("purchase confirmation + sale notification", async move {
145 let buyer = db::users::get_user_by_id(&db, buyer_id)
146 .await
147 .ok()
148 .flatten();
149 let seller = db::users::get_user_by_id(&db, seller_id)
150 .await
151 .ok()
152 .flatten();
153
154 // Purchase confirmation to buyer
155 if let Some(ref buyer) = buyer {
156 let price = helpers::format_price(amount_cents);
157 let title = item_title
158 .clone()
159 .unwrap_or_else(|| "your item".to_string());
160 if let Err(e) = email
161 .send_purchase_confirmation(
162 &buyer.email,
163 buyer.display_name.as_deref(),
164 &title,
165 &price,
166 )
167 .await
168 {
169 tracing::error!(error = ?e, "failed to send purchase confirmation email");
170 }
171 }
172
173 // Sale notification to seller
174 if let Some(ref seller) = seller
175 && seller.notify_sale
176 {
177 let price = helpers::format_price(amount_cents);
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.email,
192 seller.display_name.as_deref(),
193 &buyer_username,
194 &title,
195 &price,
196 Some(&unsub_url),
197 )
198 .await
199 {
200 tracing::error!(error = ?e, "failed to send sale notification email");
201 }
202 }
203 });
204 }
205
206 /// Subscribe buyer to the item's project content mailing list (fire-and-forget).
207 pub(super) fn subscribe_buyer_to_mailing_list(
208 db: &PgPool,
209 bg: &crate::background::BackgroundTx,
210 item_id: db::ItemId,
211 buyer_id: db::UserId,
212 ) {
213 let db = db.clone();
214 bg.spawn("mailing list subscribe", async move {
215 if let Ok(Some(item)) = db::items::get_item_by_id(&db, item_id).await
216 && let Err(e) =
217 db::mailing_lists::subscribe_to_content_list(&db, item.project_id, buyer_id).await
218 {
219 tracing::warn!(
220 project_id = %item.project_id, buyer_id = %buyer_id,
221 error = ?e, "failed to subscribe buyer to content mailing list"
222 );
223 }
224 });
225 }
226
227 /// Send tip notification to recipient (fire-and-forget).
228 pub(super) fn send_tip_email(
229 db: &PgPool,
230 bg: &crate::background::BackgroundTx,
231 email: &EmailClient,
232 config: &Config,
233 tip: &db::DbTip,
234 tipper_id: db::UserId,
235 recipient_id: db::UserId,
236 ) {
237 let db = db.clone();
238 let email = email.clone();
239 let amount_cents = tip.amount_cents;
240 let message = tip.message.clone();
241 let host_url = config.host_url.clone();
242 let signing_secret = config.signing_secret.clone();
243
244 bg.spawn("tip notification", async move {
245 let tipper = db::users::get_user_by_id(&db, tipper_id)
246 .await
247 .ok()
248 .flatten();
249 let recipient = db::users::get_user_by_id(&db, recipient_id)
250 .await
251 .ok()
252 .flatten();
253
254 if let Some(ref recipient) = recipient
255 && recipient.notify_tip
256 {
257 let price = helpers::format_price(amount_cents);
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.email,
273 recipient.display_name.as_deref(),
274 &tipper_name,
275 &price,
276 message.as_deref(),
277 Some(&unsub_url),
278 )
279 .await
280 {
281 tracing::error!(error = ?e, "failed to send tip notification email");
282 }
283 }
284 });
285 }
286
287 /// Check if a pending refund exists for this payment intent and process it.
288 ///
289 /// Called after a transaction is completed to handle out-of-order webhook
290 /// delivery (refund arrived before payment confirmation).
291 pub(super) async fn check_pending_refund(db: &PgPool, payment_intent_id: &str) {
292 let pending = match db::pending_refunds::claim_pending_refund(db, payment_intent_id).await {
293 Ok(Some(p)) => p,
294 Ok(None) => return,
295 Err(e) => {
296 tracing::error!(error = ?e, "failed to check pending refunds");
297 return;
298 }
299 };
300
301 tracing::info!(
302 payment_intent_id = %payment_intent_id,
303 pending_refund_id = %pending.id,
304 "found pending refund, processing now"
305 );
306
307 let refund_data = crate::payments::ChargeRefundData {
308 payment_intent_id: pending.payment_intent_id,
309 amount: pending.amount,
310 amount_refunded: pending.amount_refunded,
311 };
312
313 // requeue_if_unmatched = false: this row is already claimed, so an unmatched
314 // result must release the claim (below), not insert a duplicate pending row.
315 match super::billing::handle_charge_refunded(db, &refund_data, false).await {
316 Ok(()) => {
317 // Record completion only after the refund work succeeded. If the process
318 // dies between the claim and this point, the row stays matched-but-
319 // incomplete and the stale-refund sweep escalates it for manual
320 // reconciliation (PAY-S1) instead of silently dropping the refund.
321 if let Err(e) = db::pending_refunds::mark_refund_completed(db, pending.id).await {
322 tracing::error!(
323 error = ?e, pending_refund_id = %pending.id,
324 "processed pending refund but failed to mark it completed, \
325 the sweep will escalate it for manual confirmation"
326 );
327 }
328 }
329 Err(e) => {
330 tracing::error!(
331 error = ?e, pending_refund_id = %pending.id,
332 "failed to process pending refund after payment completion, releasing claim"
333 );
334 // `handle_charge_refunded` is atomic, so on a graceful error nothing
335 // committed; release the claim so a later delivery can re-claim and
336 // retry, and the sweep escalates it in the meantime.
337 if let Err(e2) = db::pending_refunds::unclaim_pending_refund(db, pending.id).await {
338 tracing::error!(
339 error = ?e2, pending_refund_id = %pending.id,
340 "failed to release pending refund claim after a processing failure, \
341 refund needs manual intervention"
342 );
343 }
344 }
345 }
346 }
347
348 /// Record revenue splits for a completed item purchase.
349 ///
350 /// Looks up the item's project and its members. If the project has members
351 /// with split percentages, creates split records for each member. The owner
352 /// receives the remainder (100% minus all member splits).
353 ///
354 /// Splits are recorded as obligations; actual payment transfer to members
355 /// is handled by the project owner outside the platform for now.
356 pub(super) async fn record_transaction_splits(
357 db: &PgPool,
358 transaction_id: db::TransactionId,
359 item_id: db::ItemId,
360 amount_cents: db::Cents,
361 ) {
362 let Ok(Some(item)) = db::items::get_item_by_id(db, item_id).await else {
363 return;
364 };
365
366 let members = match db::project_members::get_project_members(db, item.project_id).await {
367 Ok(m) if !m.is_empty() => m,
368 _ => return,
369 };
370
371 let splits = compute_splits(amount_cents, &members);
372
373 if let Err(e) =
374 db::project_members::create_transaction_splits(db, transaction_id, &splits).await
375 {
376 tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to record transaction splits");
377 } else {
378 tracing::info!(transaction_id = %transaction_id, member_count = splits.len(), "revenue splits recorded");
379 }
380 }
381
382 /// Record revenue splits for a completed tip on a project with members.
383 pub(super) async fn record_tip_splits(
384 db: &PgPool,
385 tip_id: db::TipId,
386 project_id: db::ProjectId,
387 amount_cents: db::Cents,
388 ) {
389 let members = match db::project_members::get_project_members(db, project_id).await {
390 Ok(m) if !m.is_empty() => m,
391 _ => return,
392 };
393
394 let splits = compute_splits(amount_cents, &members);
395
396 if let Err(e) = db::project_members::create_tip_splits(db, tip_id, &splits).await {
397 tracing::error!(tip_id = %tip_id, error = ?e, "failed to record tip splits");
398 } else {
399 tracing::info!(tip_id = %tip_id, member_count = splits.len(), "tip splits recorded");
400 }
401 }
402
403 /// Compute per-member split amounts with rounding.
404 ///
405 /// Uses floor division and distributes the remainder (one cent at a time)
406 /// to the first members in list order so the total always equals
407 /// `amount_cents * total_split_percent / 100`.
408 fn compute_splits(
409 amount_cents: db::Cents,
410 members: &[db::DbProjectMemberWithUser],
411 ) -> Vec<(db::UserId, i64, i16)> {
412 let amount = amount_cents.as_i64();
413
414 // One basis for BOTH the per-member share and the payout total, so the
415 // remainder is provably the sum of the floor truncations (in 0..members.len)
416 // rather than correct only by a max/min clamp coincidence (Run 11 surprise).
417 //
418 // `denom = max(sum, 100)`:
419 // - members summing to <= 100%: each is paid their literal fraction and the
420 // platform keeps the rest (denom is 100).
421 // - members summing to > 100%: each is scaled down proportionally so the
422 // whole `amount` is distributed and no one is over-credited (denom is the
423 // sum), e.g. 60%+60% on $10 pays $10, not $12.
424 let raw_total_pct: i64 = members.iter().map(|m| m.split_percent as i64).sum();
425 let denom = raw_total_pct.max(100);
426
427 let mut splits: Vec<(db::UserId, i64, i16)> = members
428 .iter()
429 .map(|m| {
430 let member_amount = amount * m.split_percent as i64 / denom;
431 (m.user_id, member_amount, m.split_percent)
432 })
433 .collect();
434
435 // Exact (un-floored) members' share over the same denom, manifestly the sum
436 // of the per-member shares before flooring, so the remainder reconciles.
437 let payout_total = amount * raw_total_pct / denom;
438 let actual_total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum();
439 let mut remainder = payout_total - actual_total;
440 for split in &mut splits {
441 if remainder <= 0 {
442 break;
443 }
444 split.1 += 1;
445 remainder -= 1;
446 }
447
448 splits
449 }
450
451 /// Run every secondary effect of a completed guest purchase, in order: revenue
452 /// splits, the guest purchase confirmation (with claim + download links), and
453 /// the seller sale notification.
454 ///
455 /// Mirrors [`finalize_purchase_transaction`] but for the guest path, which has
456 /// no buyer account yet (the license key, if any, is minted at claim time in
457 /// `claim_purchase`, not here). Re-runnable on a crash-recovery redelivery:
458 /// splits go through an ON CONFLICT write and the emails are fire-and-forget
459 /// (a re-send on that rare redelivery is acceptable).
460 #[allow(clippy::too_many_arguments)]
461 pub(super) fn finalize_guest_transaction(
462 db: &PgPool,
463 bg: &crate::background::BackgroundTx,
464 email: &EmailClient,
465 config: &Config,
466 tx: &db::DbTransaction,
467 guest_email: &str,
468 item_id: db::ItemId,
469 seller_id: db::UserId,
470 ) {
471 // Revenue splits (idempotent).
472 let db_for_splits = db.clone();
473 let tx_id = tx.id;
474 let amount_cents = tx.amount_cents;
475 bg.spawn("guest revenue splits", async move {
476 record_transaction_splits(&db_for_splits, tx_id, item_id, amount_cents).await;
477 });
478
479 // Guest purchase confirmation with the claim link (fire-and-forget).
480 if let (Some(download_token), Some(claim_token)) = (tx.download_token, tx.claim_token) {
481 let email_client = email.clone();
482 let host_url = config.host_url.clone();
483 let item_title = tx
484 .item_title
485 .clone()
486 .unwrap_or_else(|| "your item".to_string());
487 let price = helpers::format_price(tx.amount_cents);
488 let guest_email_addr = guest_email.to_string();
489 let download_url = format!("{host_url}/download/{download_token}");
490 let claim_url = format!("{host_url}/claim?token={claim_token}");
491
492 bg.spawn("guest purchase confirmation", async move {
493 if let Err(e) = email_client
494 .send_guest_purchase_confirmation(
495 &guest_email_addr,
496 &item_title,
497 &price,
498 &download_url,
499 &claim_url,
500 )
501 .await
502 {
503 tracing::error!(error = ?e, "failed to send guest purchase confirmation email");
504 }
505 });
506 }
507
508 // Sale notification to the seller (fire-and-forget).
509 send_guest_sale_notification(db, bg, email, config, tx, guest_email, seller_id);
510 }
511
512 /// Send sale notification to the seller for a guest purchase.
513 pub(super) fn send_guest_sale_notification(
514 db: &PgPool,
515 bg: &crate::background::BackgroundTx,
516 email: &EmailClient,
517 config: &Config,
518 tx: &db::DbTransaction,
519 guest_email: &str,
520 seller_id: db::UserId,
521 ) {
522 let db = db.clone();
523 let email_client = email.clone();
524 let host_url = config.host_url.clone();
525 let signing_secret = config.signing_secret.clone();
526 let amount_cents = tx.amount_cents;
527 let item_title = tx.item_title.clone();
528 let buyer_label = guest_email.to_string();
529
530 bg.spawn("guest sale notification", async move {
531 let seller = match db::users::get_user_by_id(&db, seller_id)
532 .await
533 .ok()
534 .flatten()
535 {
536 Some(s) if s.notify_sale => s,
537 _ => return,
538 };
539
540 let price = helpers::format_price(amount_cents);
541 let title = item_title.unwrap_or_else(|| "an item".to_string());
542 let unsub_url = crate::email::generate_unsubscribe_url(
543 &host_url,
544 seller.id,
545 crate::email::UnsubscribeAction::Sale,
546 &seller.id.to_string(),
547 &signing_secret,
548 );
549 if let Err(e) = email_client
550 .send_sale_notification(
551 &seller.email,
552 seller.display_name.as_deref(),
553 &buyer_label,
554 &title,
555 &price,
556 Some(&unsub_url),
557 )
558 .await
559 {
560 tracing::error!(error = ?e, "failed to send sale notification for guest purchase");
561 }
562 });
563 }
564
565 #[cfg(test)]
566 mod tests {
567 use super::*;
568 use chrono::Utc;
569
570 fn member(user_id: db::UserId, split_percent: i16) -> db::DbProjectMemberWithUser {
571 db::DbProjectMemberWithUser {
572 id: db::ProjectMemberId::new(),
573 project_id: db::ProjectId::new(),
574 user_id,
575 role: db::ProjectRole::Member,
576 split_percent,
577 added_at: Utc::now(),
578 username: String::new(),
579 display_name: None,
580 stripe_account_id: None,
581 stripe_charges_enabled: false,
582 }
583 }
584
585 #[test]
586 fn single_member_100_percent() {
587 let uid = db::UserId::new();
588 let members = vec![member(uid, 100)];
589 let splits = compute_splits(db::Cents::new(1000), &members);
590 assert_eq!(splits.len(), 1);
591 assert_eq!(splits[0], (uid, 1000, 100));
592 }
593
594 #[test]
595 fn two_members_50_50_even() {
596 let u1 = db::UserId::new();
597 let u2 = db::UserId::new();
598 let members = vec![member(u1, 50), member(u2, 50)];
599 let splits = compute_splits(db::Cents::new(1000), &members);
600 assert_eq!(splits, vec![(u1, 500, 50), (u2, 500, 50)]);
601 }
602
603 #[test]
604 fn two_members_50_50_odd() {
605 let u1 = db::UserId::new();
606 let u2 = db::UserId::new();
607 let members = vec![member(u1, 50), member(u2, 50)];
608 let splits = compute_splits(db::Cents::new(1001), &members);
609 // floor(1001*50/100) = 500 each, expected total = floor(1001*100/100) = 1001
610 // remainder = 1001 - 1000 = 1, first member gets +1
611 assert_eq!(splits, vec![(u1, 501, 50), (u2, 500, 50)]);
612 }
613
614 #[test]
615 fn three_members_33_33_34() {
616 let u1 = db::UserId::new();
617 let u2 = db::UserId::new();
618 let u3 = db::UserId::new();
619 let members = vec![member(u1, 33), member(u2, 33), member(u3, 34)];
620 let splits = compute_splits(db::Cents::new(100), &members);
621 let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum();
622 // expected_total = floor(100 * 100 / 100) = 100
623 assert_eq!(total, 100);
624 }
625
626 #[test]
627 fn single_member_50_percent() {
628 let uid = db::UserId::new();
629 let members = vec![member(uid, 50)];
630 let splits = compute_splits(db::Cents::new(1000), &members);
631 assert_eq!(splits, vec![(uid, 500, 50)]);
632 }
633
634 #[test]
635 fn zero_amount() {
636 let u1 = db::UserId::new();
637 let u2 = db::UserId::new();
638 let members = vec![member(u1, 50), member(u2, 50)];
639 let splits = compute_splits(db::Cents::new(0), &members);
640 assert_eq!(splits, vec![(u1, 0, 50), (u2, 0, 50)]);
641 }
642
643 #[test]
644 fn single_cent_two_members() {
645 let u1 = db::UserId::new();
646 let u2 = db::UserId::new();
647 let members = vec![member(u1, 50), member(u2, 50)];
648 let splits = compute_splits(db::Cents::new(1), &members);
649 // floor(1*50/100) = 0 each, expected_total = floor(1*100/100) = 1
650 // remainder = 1, first member gets +1
651 assert_eq!(splits, vec![(u1, 1, 50), (u2, 0, 50)]);
652 }
653
654 #[test]
655 fn two_members_60_60_misconfig_cannot_overcredit() {
656 // Regression: previously the "Defensive clamp" comment promised this
657 // case was handled, but per-member amounts were computed at literal
658 // percent and only `expected_total` was clamped. A 60%+60% split on
659 // $10 paid out $12.
660 let u1 = db::UserId::new();
661 let u2 = db::UserId::new();
662 let members = vec![member(u1, 60), member(u2, 60)];
663 let splits = compute_splits(db::Cents::new(1000), &members);
664 let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum();
665 assert!(
666 total <= 1000,
667 "splits sum {total} must not exceed amount 1000"
668 );
669 assert_eq!(
670 total, 1000,
671 "splits should distribute the full amount when sum>=100%"
672 );
673 }
674
675 #[test]
676 fn under_100_percent_platform_keeps_remainder() {
677 // Members sum to 70%, they receive exactly 70% of the amount and the
678 // platform keeps the other 30%. Pins the single-basis payout_total so the
679 // denom(max)/total(min) asymmetry can't drift back in (Run 11 surprise).
680 let u1 = db::UserId::new();
681 let u2 = db::UserId::new();
682 let members = vec![member(u1, 30), member(u2, 40)];
683 let splits = compute_splits(db::Cents::new(1000), &members);
684 let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum();
685 assert_eq!(splits, vec![(u1, 300, 30), (u2, 400, 40)]);
686 assert_eq!(total, 700, "members get 70%, platform keeps 300");
687 }
688
689 #[test]
690 fn single_cent_three_members_no_panic() {
691 let u1 = db::UserId::new();
692 let u2 = db::UserId::new();
693 let u3 = db::UserId::new();
694 let members = vec![member(u1, 33), member(u2, 33), member(u3, 34)];
695 let splits = compute_splits(db::Cents::new(1), &members);
696 let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum();
697 // expected_total = floor(1*100/100) = 1
698 assert_eq!(total, 1);
699 }
700
701 #[test]
702 fn large_amount_three_members() {
703 let u1 = db::UserId::new();
704 let u2 = db::UserId::new();
705 let u3 = db::UserId::new();
706 let members = vec![member(u1, 33), member(u2, 33), member(u3, 34)];
707 let splits = compute_splits(db::Cents::new(1_000_000), &members);
708 let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum();
709 // expected_total = floor(1_000_000 * 100 / 100) = 1_000_000
710 assert_eq!(total, 1_000_000);
711 // Verify individual amounts are reasonable
712 assert_eq!(splits[0].1 + splits[1].1, 2 * 330_000);
713 }
714 }
715