//! Stripe billing helpers: fee estimation and timestamp conversion. /// Convert a Unix timestamp from Stripe into a UTC datetime, falling back to now. pub fn stripe_timestamp(ts: i64) -> chrono::DateTime { chrono::DateTime::from_timestamp(ts, 0).unwrap_or_else(chrono::Utc::now) } /// Estimate Stripe's processing fee and the net amount the creator receives. /// /// Returns `(fee_cents, creator_receives_cents)`. Uses the standard /// Stripe rate from [`constants`](crate::constants). /// /// # US pricing only /// /// The rate here (2.9% + 30c) is Stripe's US card pricing. Stripe charges /// differently by country and currency — 1.5% + 20p in the UK, 1.5% + EUR 0.25 /// in much of Europe — and MNW does not model that. So this estimate is only /// meaningful for a creator settling in USD, and callers must not show it to /// anyone else: see [`stripe_fee_estimate_applies`]. Quoting a US fee to a /// British creator would be a specific wrong number, which is worse than /// saying nothing. pub fn estimate_stripe_fee(price_cents: i32) -> (i32, i32) { if price_cents <= 0 { return (0, 0); } let fee = (price_cents as f64 * crate::constants::STRIPE_FEE_PERCENTAGE + crate::constants::STRIPE_FEE_FIXED_CENTS) as i32; let creator_receives = (price_cents - fee).max(0); (fee.min(price_cents), creator_receives) } /// Whether the [`estimate_stripe_fee`] figure can honestly be shown. /// /// Only for USD, because that is the only pricing this codebase models. For /// every other currency the surface should say that Stripe's processing fee /// applies without putting a number on it. pub fn stripe_fee_estimate_applies(currency: crate::currency::SettlementCurrency) -> bool { currency == crate::currency::SettlementCurrency::Usd } #[cfg(test)] mod tests { use super::*; // ── estimate_stripe_fee ── #[test] fn stripe_fee_standard_price() { let (fee, receives) = estimate_stripe_fee(1000); assert_eq!(fee, 59); assert_eq!(receives, 941); } #[test] fn stripe_fee_small_price() { let (fee, receives) = estimate_stripe_fee(100); assert_eq!(fee, 32); assert_eq!(receives, 68); } #[test] fn stripe_fee_zero_is_free() { let (fee, receives) = estimate_stripe_fee(0); assert_eq!(fee, 0); assert_eq!(receives, 0); } #[test] fn stripe_fee_negative_price() { let (fee, receives) = estimate_stripe_fee(-100); assert_eq!(fee, 0); assert_eq!(receives, 0); } #[test] fn stripe_fee_plus_receives_equals_price() { for price in [50, 100, 250, 500, 999, 1000, 2500, 5000, 10000, 50000] { let (fee, receives) = estimate_stripe_fee(price); assert_eq!( fee + receives, price, "fee + receives should equal price for {price} cents" ); } } // ── stripe_fee_estimate_applies ── #[test] fn fee_estimate_applies_to_usd() { assert!(stripe_fee_estimate_applies( crate::currency::SettlementCurrency::Usd )); } #[test] fn fee_estimate_applies_to_nothing_but_usd() { for currency in crate::currency::SettlementCurrency::ALL { if currency == crate::currency::SettlementCurrency::Usd { continue; } assert!( !stripe_fee_estimate_applies(currency), "{currency} is not the pricing estimate_stripe_fee models, so no \ number may be shown for it" ); } } // ── stripe_timestamp ── #[test] fn stripe_timestamp_zero() { assert_eq!(stripe_timestamp(0).timestamp(), 0); } #[test] fn stripe_timestamp_valid() { assert_eq!(stripe_timestamp(1_714_400_000).timestamp(), 1_714_400_000); } // ── Property-based tests ── proptest::proptest! { #[test] fn prop_stripe_fee_invariant(price in 1..=1_000_000i32) { let (fee, receives) = estimate_stripe_fee(price); proptest::prop_assert_eq!(fee + receives, price, "fee ({}) + receives ({}) must equal price ({})", fee, receives, price); proptest::prop_assert!(fee > 0, "Fee should be positive for price {}", price); proptest::prop_assert!(receives >= 0, "Receives should be non-negative"); } } }