//! Unified promo code management: creation, validation, usage tracking, and deletion. //! //! Replaces the old `discount_codes` and `download_codes` modules. Supports three //! code purposes: discount, free_access, and free_trial. use sqlx::PgPool; use super::enums::DiscountType; use super::models::*; use super::{CodePurpose, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, UserId}; use crate::error::{AppError, Result}; /// Create a new promo code for a creator. #[allow(clippy::too_many_arguments)] #[tracing::instrument(skip_all)] pub async fn create_promo_code( pool: &PgPool, creator_id: UserId, code: &str, code_purpose: super::CodePurpose, discount_type: Option, discount_value: Option, min_price_cents: i32, trial_days: Option, max_uses: Option, expires_at: Option>, starts_at: Option>, item_id: Option, project_id: Option, tier_id: Option, ) -> Result { let promo_code = sqlx::query_as::<_, DbPromoCode>( r#" INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, trial_days, max_uses, expires_at, starts_at, item_id, project_id, tier_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING * "#, ) .bind(creator_id) .bind(code) .bind(code_purpose) .bind(discount_type) .bind(discount_value) .bind(min_price_cents) .bind(trial_days) .bind(max_uses) .bind(expires_at) .bind(starts_at) .bind(item_id) .bind(project_id) .bind(tier_id) .fetch_one(pool) .await?; Ok(promo_code) } /// Fetch a promo code by primary key. #[tracing::instrument(skip_all)] pub async fn get_promo_code_by_id(pool: &PgPool, id: PromoCodeId) -> Result> { let code = sqlx::query_as::<_, DbPromoCode>( "SELECT * FROM promo_codes WHERE id = $1", ) .bind(id) .fetch_optional(pool) .await?; Ok(code) } /// Look up a promo code by creator ID and code string (case-insensitive). /// Used at checkout to validate discount codes. #[tracing::instrument(skip_all)] pub async fn get_promo_code_by_creator_and_code( pool: &PgPool, creator_id: UserId, code: &str, ) -> Result> { let promo_code = sqlx::query_as::<_, DbPromoCode>( "SELECT * FROM promo_codes WHERE creator_id = $1 AND upper(code) = upper($2)", ) .bind(creator_id) .bind(code) .fetch_optional(pool) .await?; Ok(promo_code) } /// Look up a free_access promo code by code string (case-insensitive, cross-creator). /// Used for free_access code claims where the buyer doesn't know the creator. /// Scoped to free_access purpose to prevent cross-creator collision with discount codes. #[tracing::instrument(skip_all)] pub async fn get_promo_code_by_code( pool: &PgPool, code: &str, ) -> Result> { let promo_code = sqlx::query_as::<_, DbPromoCode>( "SELECT * FROM promo_codes WHERE upper(code) = upper($1) AND code_purpose = 'free_access'", ) .bind(code) .fetch_optional(pool) .await?; Ok(promo_code) } /// SQL fragment for promo code listing queries: selects all promo_codes columns /// plus LEFT JOINed item and project titles. const PROMO_CODE_WITH_NAMES_SELECT: &str = r#" SELECT pc.*, i.title AS item_title, p.title AS project_title FROM promo_codes pc LEFT JOIN items i ON pc.item_id = i.id LEFT JOIN projects p ON pc.project_id = p.id "#; /// List all promo codes for a creator, newest first. Capped at 500. #[tracing::instrument(skip_all)] pub async fn get_promo_codes_by_creator(pool: &PgPool, creator_id: UserId) -> Result> { let query = format!("{PROMO_CODE_WITH_NAMES_SELECT} WHERE pc.creator_id = $1 ORDER BY pc.created_at DESC LIMIT 500"); let codes = sqlx::query_as::<_, DbPromoCodeWithNames>(&query) .bind(creator_id) .fetch_all(pool) .await?; Ok(codes) } /// List all promo codes scoped to a project, newest first. Capped at 500. #[tracing::instrument(skip_all)] pub async fn get_promo_codes_by_project(pool: &PgPool, project_id: ProjectId) -> Result> { let query = format!("{PROMO_CODE_WITH_NAMES_SELECT} WHERE pc.project_id = $1 ORDER BY pc.created_at DESC LIMIT 500"); let codes = sqlx::query_as::<_, DbPromoCodeWithNames>(&query) .bind(project_id) .fetch_all(pool) .await?; Ok(codes) } /// List all promo codes scoped to an item, newest first. Capped at 500. #[tracing::instrument(skip_all)] pub async fn get_promo_codes_by_item(pool: &PgPool, item_id: ItemId) -> Result> { let query = format!("{PROMO_CODE_WITH_NAMES_SELECT} WHERE pc.item_id = $1 ORDER BY pc.created_at DESC LIMIT 500"); let codes = sqlx::query_as::<_, DbPromoCodeWithNames>(&query) .bind(item_id) .fetch_all(pool) .await?; Ok(codes) } /// Batch-load item-scoped promo codes for multiple items, grouped by item_id. #[tracing::instrument(skip_all)] pub async fn get_promo_codes_by_items( pool: &PgPool, item_ids: &[ItemId], ) -> Result>> { let query = format!("{PROMO_CODE_WITH_NAMES_SELECT} WHERE pc.item_id = ANY($1) ORDER BY pc.item_id, pc.created_at DESC"); let codes = sqlx::query_as::<_, DbPromoCodeWithNames>(&query) .bind(item_ids) .fetch_all(pool) .await?; let mut map: std::collections::HashMap> = std::collections::HashMap::new(); for pc in codes { if let Some(item_id) = pc.item_id { map.entry(item_id).or_default().push(pc); } } Ok(map) } /// Atomically increment use_count, respecting the max_uses limit. /// /// Returns `true` if the increment succeeded, `false` if the code has already /// reached its usage limit. The `WHERE` clause enforces the limit at the /// database level, preventing TOCTOU races. /// /// Accepts any sqlx executor (`&PgPool`, `&mut Transaction`, etc.) so callers /// can include this in a larger transaction when needed. #[tracing::instrument(skip_all)] pub async fn try_increment_use_count<'e>( executor: impl sqlx::PgExecutor<'e>, id: PromoCodeId, ) -> Result { let result = sqlx::query( "UPDATE promo_codes SET use_count = use_count + 1 \ WHERE id = $1 \ AND (max_uses IS NULL OR use_count < max_uses) \ AND (expires_at IS NULL OR expires_at > NOW()) \ AND (starts_at IS NULL OR starts_at <= NOW())", ) .bind(id) .execute(executor) .await?; Ok(result.rows_affected() > 0) } /// Release a reserved use_count slot (decrement, clamped to 0). /// /// Used in two places that must coordinate so the count doesn't drop twice /// for the same reservation: /// 1. Route handlers, when a Stripe checkout creation or pending-tx /// insert fails AFTER the use_count was reserved. They call /// `release_use_count_and_detach` (below) which also nulls the /// `promo_code_id` on any pending transaction rows for this /// reservation, so `cleanup_stale_pending` can't fire a second /// release for the same buyer's promo hold. /// 2. `cleanup_stale_pending` itself, when it deletes stale pending /// rows past the 24h checkout-session expiry. Those rows still /// carry their `promo_code_id`, so this plain function is the /// right call from there. /// /// `GREATEST(0, ...)` makes a double-release harmless (count clamps at /// zero) but the structural fix above prevents it from happening at all. #[tracing::instrument(skip_all)] pub async fn release_use_count(pool: &PgPool, id: PromoCodeId) -> Result<()> { sqlx::query( "UPDATE promo_codes SET use_count = GREATEST(0, use_count - 1) WHERE id = $1", ) .bind(id) .execute(pool) .await?; Ok(()) } /// Release a use_count slot AND detach the same promo_code_id from any /// pending transactions for `buyer_id` so the scheduler's /// `cleanup_stale_pending` doesn't release it a second time when those /// stale rows eventually time out. /// /// Use this from route-level failure paths (Stripe session creation /// failed, pending-tx insert failed mid-cart, etc). The detach is a /// no-op when the failure happened BEFORE any pending row was inserted; /// it's the safety net for when a partial pending row may have landed. #[tracing::instrument(skip_all)] pub async fn release_use_count_and_detach( pool: &PgPool, id: PromoCodeId, buyer_id: UserId, ) -> Result<()> { let mut tx = pool.begin().await?; sqlx::query( "UPDATE transactions SET promo_code_id = NULL \ WHERE buyer_id = $1 AND promo_code_id = $2 AND status = 'pending'", ) .bind(buyer_id) .bind(id) .execute(&mut *tx) .await?; sqlx::query( "UPDATE promo_codes SET use_count = GREATEST(0, use_count - 1) WHERE id = $1", ) .bind(id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(()) } /// Update editable fields on a promo code (expires_at, starts_at, max_uses). #[tracing::instrument(skip_all)] pub async fn update_promo_code( pool: &PgPool, id: PromoCodeId, expires_at: Option>>, starts_at: Option>>, max_uses: Option>, ) -> Result { // Build SET clauses for provided fields only let mut sets = Vec::new(); let mut param_idx = 2u32; // $1 = id if expires_at.is_some() { sets.push(format!("expires_at = ${param_idx}")); param_idx += 1; } if starts_at.is_some() { sets.push(format!("starts_at = ${param_idx}")); param_idx += 1; } if max_uses.is_some() { sets.push(format!("max_uses = ${param_idx}")); // Final SET clause; param_idx is never read after this point, so the // increment is elided to avoid an unused_assignments warning. Restore // it if a new optional field is added below. } if sets.is_empty() { // Nothing to update — just return current state return get_promo_code_by_id(pool, id) .await? .ok_or_else(|| crate::error::AppError::NotFound); } let sql = format!("UPDATE promo_codes SET {} WHERE id = $1 RETURNING *", sets.join(", ")); let mut query = sqlx::query_as::<_, DbPromoCode>(&sql).bind(id); if let Some(val) = expires_at { query = query.bind(val); } if let Some(val) = starts_at { query = query.bind(val); } if let Some(val) = max_uses { query = query.bind(val); } let code = query.fetch_one(pool).await?; Ok(code) } /// Delete all expired promo codes for a creator. Returns number of rows deleted. #[tracing::instrument(skip_all)] pub async fn delete_expired_by_creator(pool: &PgPool, creator_id: UserId) -> Result { let result = sqlx::query( "DELETE FROM promo_codes WHERE creator_id = $1 AND expires_at IS NOT NULL AND expires_at < NOW()", ) .bind(creator_id) .execute(pool) .await?; Ok(result.rows_affected()) } /// Delete a promo code permanently. #[tracing::instrument(skip_all)] pub async fn delete_promo_code(pool: &PgPool, id: PromoCodeId) -> Result<()> { sqlx::query("DELETE FROM promo_codes WHERE id = $1") .bind(id) .execute(pool) .await?; Ok(()) } /// A single row in the "who redeemed this code" view. /// /// `display_name` / `username` are `None` for guest checkouts; `guest_email` /// fills that gap. `item_title` is denormalized on the transaction row so /// renaming an item later doesn't strand the audit trail. #[derive(Debug, sqlx::FromRow, serde::Serialize)] pub struct PromoRedemption { pub redeemed_at: chrono::DateTime, pub display_name: Option, pub username: Option, pub guest_email: Option, pub item_title: Option, pub amount_cents: i32, } /// List redemptions of a single promo code, newest first. /// /// Joins through to `users` for buyer identity but falls back to the /// transaction's `guest_email` for unauthenticated checkouts. Capped at 500 /// rows — promo codes that exceed that bound are an outlier worth its own /// CSV-export flow rather than a paginated UI. #[tracing::instrument(skip_all)] pub async fn list_redemptions( pool: &PgPool, id: PromoCodeId, ) -> Result> { let rows = sqlx::query_as::<_, PromoRedemption>( r#" SELECT COALESCE(t.completed_at, t.created_at) AS redeemed_at, u.display_name AS display_name, u.username AS username, t.guest_email AS guest_email, t.item_title AS item_title, t.amount_cents AS amount_cents FROM transactions t LEFT JOIN users u ON u.id = t.buyer_id WHERE t.promo_code_id = $1 AND t.status = 'completed' ORDER BY redeemed_at DESC LIMIT 500 "#, ) .bind(id) .fetch_all(pool) .await?; Ok(rows) } /// Create a platform-wide promo code (used for Fan+ monthly credits). /// /// Same as `create_promo_code` but sets `is_platform_wide = true`. /// Platform-wide codes are not scoped to a specific creator's items. #[allow(clippy::too_many_arguments)] #[tracing::instrument(skip_all)] pub async fn create_platform_promo_code( pool: &PgPool, creator_id: UserId, code: &str, code_purpose: super::CodePurpose, discount_type: Option, discount_value: Option, min_price_cents: i32, trial_days: Option, max_uses: Option, expires_at: Option>, ) -> Result { let promo_code = sqlx::query_as::<_, DbPromoCode>( r#" INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, trial_days, max_uses, expires_at, is_platform_wide) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true) RETURNING * "#, ) .bind(creator_id) .bind(code) .bind(code_purpose) .bind(discount_type) .bind(discount_value) .bind(min_price_cents) .bind(trial_days) .bind(max_uses) .bind(expires_at) .fetch_one(pool) .await?; Ok(promo_code) } /// Look up a platform-wide promo code by user ID and code string (case-insensitive). /// /// Used at checkout to validate Fan+ credits: the buyer owns the code, and it /// applies to any item on the platform. #[tracing::instrument(skip_all)] pub async fn get_platform_promo_code_by_user_and_code( pool: &PgPool, user_id: UserId, code: &str, ) -> Result> { let promo_code = sqlx::query_as::<_, DbPromoCode>( "SELECT * FROM promo_codes WHERE creator_id = $1 AND upper(code) = upper($2) AND is_platform_wide = true", ) .bind(user_id) .bind(code) .fetch_optional(pool) .await?; Ok(promo_code) } /// Apply a discount to a price, returning the discounted price in cents (minimum 0). /// Negative discount values are clamped to 0 to prevent price increases. #[tracing::instrument(skip_all)] pub fn apply_discount(price_cents: i32, discount_type: DiscountType, discount_value: i32) -> i32 { let discount_value = discount_value.max(0); match discount_type { DiscountType::Percentage => { let discount = (price_cents as i64 * discount_value as i64) / 100; (price_cents as i64 - discount).max(0) as i32 } // Subtract in i64 (like the Percentage arm) so a configuration where // `discount_value > i32::MAX - price_cents` can't underflow before the // `.max(0)` clamp catches it. discount_value is i32 so the sub is // bounded; we cast for parity with the Percentage path. DiscountType::Fixed => (price_cents as i64 - discount_value as i64).max(0) as i32, } } // ── Shared checkout promo validation ───────────────────────────────────────── // // Every checkout path (single item, guest, cart ×2) needs the same promo logic: // look the code up, run the code-level window/limit checks, then apply it to each // item with scope + minimum-price + discount math. These two functions are that // logic in one place, so a fix (the NULL-discount rejection, the min-price floor) // can't land in three copies and miss the fourth. /// A promo code that passed the code-level checks (exists, not a trial, inside /// its active window, under its use limit). Apply it per item with /// [`apply_promo_to_item`]; reserve it with [`try_increment_use_count`]. pub struct ValidatedPromo { pub code: DbPromoCode, /// A platform-wide Fan+ credit (valid on any seller's items) rather than a /// seller-scoped code; gates the scope and minimum-price checks. pub is_platform_wide: bool, } impl ValidatedPromo { pub fn id(&self) -> PromoCodeId { self.code.id } } /// Look up and code-level-validate a checkout promo. Tries the seller's code /// first; when `buyer_id` is `Some`, falls back to that buyer's platform-wide /// Fan+ credit. Returns `Ok(None)` for a blank code, `Err` for an /// unknown/not-yet-active/expired/exhausted/trial code. Per-item scope, minimum /// price, and discount math are done by [`apply_promo_to_item`], not here. #[tracing::instrument(skip_all)] pub async fn lookup_and_validate_promo( pool: &PgPool, seller_id: UserId, buyer_id: Option, raw_code: &str, ) -> Result> { let code_str = raw_code.trim().to_uppercase(); if code_str.is_empty() { return Ok(None); } let code = match get_promo_code_by_creator_and_code(pool, seller_id, &code_str).await? { Some(pc) => pc, None => match buyer_id { Some(uid) => get_platform_promo_code_by_user_and_code(pool, uid, &code_str) .await? .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?, None => return Err(AppError::BadRequest("Invalid promo code".to_string())), }, }; if code.code_purpose == CodePurpose::FreeTrial { return Err(AppError::BadRequest( "Trial codes can only be used for subscriptions".to_string(), )); } let now = chrono::Utc::now(); if let Some(starts) = code.starts_at && starts > now { return Err(AppError::BadRequest("This promo code is not yet active".to_string())); } if let Some(expires) = code.expires_at && expires < now { return Err(AppError::BadRequest("This promo code has expired".to_string())); } if let Some(max) = code.max_uses && code.use_count >= max { return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string())); } let is_platform_wide = code.is_platform_wide; Ok(Some(ValidatedPromo { code, is_platform_wide })) } /// Why a validated promo doesn't apply to a particular item (vs a hard error). pub enum PromoIneligible { /// The code is scoped to a different item or project. ScopeMismatch, /// The item's price is below the code's `min_price_cents` floor. BelowMinPrice, } /// Result of applying a validated promo to one item. pub enum PromoApplication { /// The item's price after the code (`0` for free-access, discounted otherwise). Apply(i32), /// The code doesn't apply to this item — cart skips it, single-item rejects. Ineligible(PromoIneligible), } /// Apply a validated promo to one item's base price. A misconfigured Discount /// code (NULL type/value) is a hard `Err` (never reserve-and-charge-full); /// scope or minimum-price ineligibility is `Ok(Ineligible(_))` so cart checkout /// can skip the item while single-item checkout turns it into an error. pub fn apply_promo_to_item( validated: &ValidatedPromo, item_id: ItemId, project_id: ProjectId, base_price_cents: i32, ) -> Result { let code = &validated.code; // Scope checks apply to seller codes only; a platform-wide credit is valid // on any item. if !validated.is_platform_wide { if let Some(scoped_item) = code.item_id && scoped_item != item_id { return Ok(PromoApplication::Ineligible(PromoIneligible::ScopeMismatch)); } if let Some(scoped_project) = code.project_id && project_id != scoped_project { return Ok(PromoApplication::Ineligible(PromoIneligible::ScopeMismatch)); } } match code.code_purpose { CodePurpose::FreeAccess => Ok(PromoApplication::Apply(0)), CodePurpose::Discount => { if !validated.is_platform_wide && base_price_cents < code.min_price_cents { return Ok(PromoApplication::Ineligible(PromoIneligible::BelowMinPrice)); } let (dt, dv) = match (code.discount_type, code.discount_value) { (Some(dt), Some(dv)) => (dt, dv), _ => { return Err(AppError::BadRequest( "This promo code is misconfigured. Please contact the creator.".to_string(), )); } }; Ok(PromoApplication::Apply(apply_discount(base_price_cents, dt, dv))) } // Rejected up front in `lookup_and_validate_promo`. CodePurpose::FreeTrial => Ok(PromoApplication::Apply(base_price_cents)), } } #[cfg(test)] mod tests { use super::*; #[test] fn percentage_discount_50() { assert_eq!(apply_discount(1000, DiscountType::Percentage, 50), 500); } #[test] fn percentage_discount_100() { assert_eq!(apply_discount(1000, DiscountType::Percentage, 100), 0); } #[test] fn percentage_discount_10() { // 999 * 10 / 100 = 99 (integer), 999 - 99 = 900 assert_eq!(apply_discount(999, DiscountType::Percentage, 10), 900); } #[test] fn fixed_discount() { assert_eq!(apply_discount(1000, DiscountType::Fixed, 300), 700); } #[test] fn fixed_discount_exceeds_price() { assert_eq!(apply_discount(100, DiscountType::Fixed, 500), 0); } // -- Percentage discount edge cases -- #[test] fn percentage_discount_0() { assert_eq!(apply_discount(1000, DiscountType::Percentage, 0), 1000); } #[test] fn percentage_discount_over_100() { // 150% discount should clamp to 0 assert_eq!(apply_discount(1000, DiscountType::Percentage, 150), 0); } #[test] fn percentage_discount_1_percent() { // 1000 * 1 / 100 = 10, result = 990 assert_eq!(apply_discount(1000, DiscountType::Percentage, 1), 990); } #[test] fn percentage_discount_99_percent() { // 1000 * 99 / 100 = 990, result = 10 assert_eq!(apply_discount(1000, DiscountType::Percentage, 99), 10); } #[test] fn percentage_discount_rounding() { // 1 cent * 50 / 100 = 0 (integer division), result = 1 assert_eq!(apply_discount(1, DiscountType::Percentage, 50), 1); // 3 * 33 / 100 = 0 (integer), result = 3 assert_eq!(apply_discount(3, DiscountType::Percentage, 33), 3); // 199 * 50 / 100 = 99, result = 100 assert_eq!(apply_discount(199, DiscountType::Percentage, 50), 100); } // -- Fixed discount edge cases -- #[test] fn fixed_discount_exact_price() { assert_eq!(apply_discount(500, DiscountType::Fixed, 500), 0); } #[test] fn fixed_discount_zero_value() { assert_eq!(apply_discount(1000, DiscountType::Fixed, 0), 1000); } #[test] fn fixed_discount_one_cent() { assert_eq!(apply_discount(1000, DiscountType::Fixed, 1), 999); } // -- Zero price -- #[test] fn zero_price_percentage() { assert_eq!(apply_discount(0, DiscountType::Percentage, 50), 0); } #[test] fn zero_price_fixed() { assert_eq!(apply_discount(0, DiscountType::Fixed, 100), 0); } // -- Negative values (defensive) -- #[test] fn negative_discount_value_percentage() { // Negative discount values are clamped to 0, so price is unchanged assert_eq!(apply_discount(1000, DiscountType::Percentage, -50), 1000); } #[test] fn negative_discount_value_fixed() { // Negative discount values are clamped to 0, so price is unchanged assert_eq!(apply_discount(1000, DiscountType::Fixed, -500), 1000); } #[test] fn negative_price_percentage() { // Negative price with percentage discount — documents current behavior // -1000 * 50 / 100 = -500, -1000 - (-500) = -500, max(0) = 0 assert_eq!(apply_discount(-1000, DiscountType::Percentage, 50), 0); } #[test] fn negative_price_fixed() { // -1000 - 500 = -1500, max(0) = 0 assert_eq!(apply_discount(-1000, DiscountType::Fixed, 500), 0); } // -- Large values (overflow safety) -- #[test] fn large_price_percentage_no_overflow() { // The function uses i64 intermediate to avoid overflow // i32::MAX = 2_147_483_647; 50% of that let price = i32::MAX; let result = apply_discount(price, DiscountType::Percentage, 50); assert_eq!(result, 1_073_741_824); // (MAX - MAX*50/100) } // ── Adversarial (test-fuzz) ── #[test] fn adversarial_percentage_max_price_max_percentage() { // i32::MAX price with 100% discount let result = apply_discount(i32::MAX, DiscountType::Percentage, 100); assert_eq!(result, 0, "100% discount on any price should be 0"); } #[test] fn adversarial_percentage_max_price_99_percent() { let result = apply_discount(i32::MAX, DiscountType::Percentage, 99); // i32::MAX * 99 / 100 via i64 = 2_125_999_810, remainder = 21_483_837 // Exact: 2_147_483_647 * 99 = 212_600_881_053 / 100 = 2_126_008_810 // 2_147_483_647 - 2_126_008_810 = 21_474_837 assert_eq!(result, 21_474_837); assert!(result > 0, "99% discount should leave some remaining"); } #[test] fn adversarial_fixed_max_price_max_discount() { let result = apply_discount(i32::MAX, DiscountType::Fixed, i32::MAX); assert_eq!(result, 0); } #[test] fn adversarial_both_negative() { // Both negative price and negative discount let result = apply_discount(-100, DiscountType::Fixed, -100); // -100 - (-100) = 0 assert_eq!(result, 0); } #[test] fn adversarial_percentage_discount_exactly_50_odd_price() { // Rounding: 1 cent * 50% = 0 (integer division), so result = 1 assert_eq!(apply_discount(1, DiscountType::Percentage, 50), 1); // 3 cents * 50% = 1 (via i64: 3*50/100=1), result = 2 assert_eq!(apply_discount(3, DiscountType::Percentage, 50), 2); } #[test] fn adversarial_apply_discount_invariant() { // For any valid (positive) price and percentage 0-100, // result should be in [0, price] for price in [1, 50, 100, 999, 10000, 1_000_000] { for pct in [0, 1, 10, 25, 33, 50, 75, 99, 100] { let result = apply_discount(price, DiscountType::Percentage, pct); assert!( result >= 0 && result <= price, "Invariant violated: price={}, pct={}, result={}", price, pct, result ); } } } #[test] fn adversarial_fixed_discount_invariant() { // For any positive price and positive discount, result should be in [0, price] for price in [1, 50, 100, 999, 10000] { for discount in [0, 1, 50, 100, 999, 10000, 999999] { let result = apply_discount(price, DiscountType::Fixed, discount); assert!( result >= 0 && result <= price, "Invariant violated: price={}, discount={}, result={}", price, discount, result ); } } } // ── Property-based tests (proptest) ── proptest::proptest! { #[test] fn prop_percentage_discount_in_range(price in 0..=1_000_000i32, pct in 0..=100i32) { let result = apply_discount(price, DiscountType::Percentage, pct); proptest::prop_assert!(result >= 0, "Result {} should be >= 0", result); proptest::prop_assert!(result <= price, "Result {} should be <= price {}", result, price); } #[test] fn prop_fixed_discount_in_range(price in 0..=1_000_000i32, discount in 0..=1_000_000i32) { let result = apply_discount(price, DiscountType::Fixed, discount); proptest::prop_assert!(result >= 0, "Result {} should be >= 0", result); proptest::prop_assert!(result <= price, "Result {} should be <= price {}", result, price); } #[test] fn prop_100_percent_discount_is_zero(price in 0..=1_000_000i32) { proptest::prop_assert_eq!(apply_discount(price, DiscountType::Percentage, 100), 0); } #[test] fn prop_0_percent_discount_is_identity(price in 0..=1_000_000i32) { proptest::prop_assert_eq!(apply_discount(price, DiscountType::Percentage, 0), price); } } }