//! 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::{DbPromoCode, DbPromoCodeWithNames}; 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(crate) 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 { // runtime-checked: binds a chrono DateTime param; a bind param's type can't be overridden in the macro when sqlx time+chrono features are unified. 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(crate) async fn get_promo_code_by_id( pool: &PgPool, id: PromoCodeId, ) -> Result> { let code = sqlx::query_as!( DbPromoCode, r#"SELECT id AS "id: PromoCodeId", creator_id AS "creator_id: UserId", code, code_purpose AS "code_purpose: super::CodePurpose", discount_type AS "discount_type: DiscountType", discount_value, min_price_cents, trial_days, item_id AS "item_id: ItemId", project_id AS "project_id: ProjectId", tier_id AS "tier_id: SubscriptionTierId", max_uses, use_count, expires_at AS "expires_at: chrono::DateTime", starts_at AS "starts_at: chrono::DateTime", created_at AS "created_at: chrono::DateTime", is_platform_wide FROM promo_codes WHERE id = $1"#, id as PromoCodeId, ) .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(crate) async fn get_promo_code_by_creator_and_code( pool: &PgPool, creator_id: UserId, code: &str, ) -> Result> { let promo_code = sqlx::query_as!( DbPromoCode, r#"SELECT id AS "id: PromoCodeId", creator_id AS "creator_id: UserId", code, code_purpose AS "code_purpose: super::CodePurpose", discount_type AS "discount_type: DiscountType", discount_value, min_price_cents, trial_days, item_id AS "item_id: ItemId", project_id AS "project_id: ProjectId", tier_id AS "tier_id: SubscriptionTierId", max_uses, use_count, expires_at AS "expires_at: chrono::DateTime", starts_at AS "starts_at: chrono::DateTime", created_at AS "created_at: chrono::DateTime", is_platform_wide FROM promo_codes WHERE creator_id = $1 AND upper(code) = upper($2)"#, creator_id as UserId, 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(crate) async fn get_promo_code_by_code( pool: &PgPool, code: &str, ) -> Result> { let promo_code = sqlx::query_as!( DbPromoCode, r#"SELECT id AS "id: PromoCodeId", creator_id AS "creator_id: UserId", code, code_purpose AS "code_purpose: super::CodePurpose", discount_type AS "discount_type: DiscountType", discount_value, min_price_cents, trial_days, item_id AS "item_id: ItemId", project_id AS "project_id: ProjectId", tier_id AS "tier_id: SubscriptionTierId", max_uses, use_count, expires_at AS "expires_at: chrono::DateTime", starts_at AS "starts_at: chrono::DateTime", created_at AS "created_at: chrono::DateTime", is_platform_wide FROM promo_codes WHERE upper(code) = upper($1) AND code_purpose = 'free_access'"#, 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(crate) async fn get_promo_codes_by_creator( pool: &PgPool, creator_id: UserId, ) -> Result> { // runtime-checked: dynamically-built SQL string (PROMO_CODE_WITH_NAMES_SELECT fragment). 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(crate) async fn get_promo_codes_by_project( pool: &PgPool, project_id: ProjectId, ) -> Result> { // runtime-checked: dynamically-built SQL string (PROMO_CODE_WITH_NAMES_SELECT fragment). 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(crate) async fn get_promo_codes_by_item( pool: &PgPool, item_id: ItemId, ) -> Result> { // runtime-checked: dynamically-built SQL string (PROMO_CODE_WITH_NAMES_SELECT fragment). 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(crate) async fn get_promo_codes_by_items( pool: &PgPool, item_ids: &[ItemId], ) -> Result>> { // runtime-checked: dynamically-built SQL string (PROMO_CODE_WITH_NAMES_SELECT fragment). 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(crate) 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())", id as PromoCodeId, ) .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(crate) 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", id as PromoCodeId, ) .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(crate) 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'", buyer_id as UserId, id as PromoCodeId, ) .execute(&mut *tx) .await?; sqlx::query!( "UPDATE promo_codes SET use_count = GREATEST(0, use_count - 1) WHERE id = $1", id as PromoCodeId, ) .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)] #[allow( clippy::option_option, reason = "tri-state PATCH semantics: outer None = field absent (leave unchanged), Some(None) = set to SQL NULL, Some(Some(v)) = set to value" )] pub(crate) 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); } // runtime-checked: dynamically-built SQL string (SET clause assembled from provided fields). 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(crate) 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()", creator_id as UserId, ) .execute(pool) .await?; Ok(result.rows_affected()) } /// Delete a promo code permanently. #[tracing::instrument(skip_all)] pub(crate) async fn delete_promo_code(pool: &PgPool, id: PromoCodeId) -> Result<()> { sqlx::query!("DELETE FROM promo_codes WHERE id = $1", id as PromoCodeId) .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(crate) 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(crate) 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!: chrono::DateTime", 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 COALESCE(t.completed_at, t.created_at) DESC LIMIT 500 "#, id as PromoCodeId, ) .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(crate) 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 { // runtime-checked: binds a chrono DateTime param; a bind param's type can't be overridden in the macro when sqlx time+chrono features are unified. 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) } /// Unforgeable proof that the once-per-renewal Fan+ credit slot for a /// `(stripe_sub_id, period_end)` was won by *this* webhook delivery. /// /// Returned only by [`try_claim_fan_plus_credit`], and required by reference by /// [`issue_fan_plus_credit_code`], the only path that mints the credit. The /// private field makes it unconstructable outside this module (the /// [`super::subscriptions::SubscriptionGate`] pattern), so "mint or email a Fan+ /// credit without first winning the idempotency slot" cannot be written. This /// turns the webhook-dedup invariant, a non-idempotent, money-moving side-effect /// must sit behind its own atomic claim, from review-time discipline into a /// compile-time guarantee. #[must_use] pub(crate) struct FanPlusCreditClaim { _seal: (), } /// Claim the once-per-renewal slot for a Fan+ monthly credit. /// /// Returns `Some(`[`FanPlusCreditClaim`]`)` if this `(stripe_sub_id, period_end)` /// was not yet claimed (row inserted, the caller holds the witness needed to /// mint and email the credit), `None` if a prior delivery of the same renewal /// already claimed it (the caller must do nothing). /// /// This is the DB-level idempotency guard that closes the duplicate-webhook /// double-credit race: `invoice.payment_succeeded` dedup at the webhook layer is /// a check-then-act read that two concurrent deliveries both pass, so the /// money-moving side-effect must serialize on its own atomic write. The /// `ON CONFLICT DO NOTHING` against the `(stripe_sub_id, period_end)` primary key /// makes exactly one of N concurrent deliveries win, and only that one gets a /// witness. #[tracing::instrument(skip_all)] pub(crate) async fn try_claim_fan_plus_credit( pool: &PgPool, stripe_sub_id: &str, period_end: i64, ) -> Result> { let result = sqlx::query!( "INSERT INTO fan_plus_credit_issuance (stripe_sub_id, period_end) \ VALUES ($1, $2) ON CONFLICT DO NOTHING", stripe_sub_id, period_end, ) .execute(pool) .await?; Ok((result.rows_affected() == 1).then_some(FanPlusCreditClaim { _seal: () })) } /// Mint the $5 single-use, platform-wide credit code for a won Fan+ renewal. /// /// Requires a [`FanPlusCreditClaim`] by reference: the only way to obtain one is /// to win [`try_claim_fan_plus_credit`], so this side-effect is structurally /// unreachable for a duplicate/redelivered webhook. The credit terms ($5 fixed, /// single use) are sealed here rather than spelled out at the call site, so every /// Fan+ credit is identical by construction. #[tracing::instrument(skip_all)] pub(crate) async fn issue_fan_plus_credit_code( _claim: &FanPlusCreditClaim, pool: &PgPool, creator_id: UserId, code: &str, expires_at: Option>, ) -> Result { create_platform_promo_code( pool, creator_id, code, super::CodePurpose::Discount, Some(DiscountType::Fixed), Some(500), // $5 credit 0, None, Some(1), // single use expires_at, ) .await } /// 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(crate) 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, r#"SELECT id AS "id: PromoCodeId", creator_id AS "creator_id: UserId", code, code_purpose AS "code_purpose: super::CodePurpose", discount_type AS "discount_type: DiscountType", discount_value, min_price_cents, trial_days, item_id AS "item_id: ItemId", project_id AS "project_id: ProjectId", tier_id AS "tier_id: SubscriptionTierId", max_uses, use_count, expires_at AS "expires_at: chrono::DateTime", starts_at AS "starts_at: chrono::DateTime", created_at AS "created_at: chrono::DateTime", is_platform_wide FROM promo_codes WHERE creator_id = $1 AND upper(code) = upper($2) AND is_platform_wide = true"#, user_id as UserId, code, ) .fetch_optional(pool) .await?; Ok(promo_code) } /// Look up a platform-wide free-trial code by code string (case-insensitive). /// /// Used to comp creator-tier subscriptions: anyone holding the code can redeem /// it at creator-tier checkout for `trial_days` free, after which the /// subscription rolls to the price chosen at checkout (founder price during the /// founder window). Scoped to `free_trial` + `is_platform_wide` so it can't /// collide with creator-scoped discount codes or per-user Fan+ credits. #[tracing::instrument(skip_all)] pub(crate) async fn get_platform_trial_code_by_code( pool: &PgPool, code: &str, ) -> Result> { let promo_code = sqlx::query_as!( DbPromoCode, r#"SELECT id AS "id: PromoCodeId", creator_id AS "creator_id: UserId", code, code_purpose AS "code_purpose: super::CodePurpose", discount_type AS "discount_type: DiscountType", discount_value, min_price_cents, trial_days, item_id AS "item_id: ItemId", project_id AS "project_id: ProjectId", tier_id AS "tier_id: SubscriptionTierId", max_uses, use_count, expires_at AS "expires_at: chrono::DateTime", starts_at AS "starts_at: chrono::DateTime", created_at AS "created_at: chrono::DateTime", is_platform_wide FROM promo_codes WHERE upper(code) = upper($1) AND code_purpose = 'free_trial' AND is_platform_wide = true"#, code, ) .fetch_optional(pool) .await?; Ok(promo_code) } /// Record that `user_id` redeemed `code_id`, enforcing once-per-individual. /// /// Returns `true` if this is the user's first redemption of the code (row /// inserted), `false` if they have already redeemed it (the `(code, user)` /// primary key conflicts). Atomic, the conflict resolution closes the /// double-submit race. #[tracing::instrument(skip_all)] pub(crate) async fn try_record_redemption( pool: &PgPool, code_id: PromoCodeId, user_id: UserId, ) -> Result { let result = sqlx::query!( "INSERT INTO promo_code_redemptions (promo_code_id, user_id) \ VALUES ($1, $2) ON CONFLICT DO NOTHING", code_id as PromoCodeId, user_id as UserId, ) .execute(pool) .await?; Ok(result.rows_affected() > 0) } /// Remove a per-user redemption record. Used to roll back a reservation when a /// later step (usage-limit reservation or the Stripe call) fails after the /// redemption row was inserted. #[tracing::instrument(skip_all)] pub(crate) async fn remove_redemption( pool: &PgPool, code_id: PromoCodeId, user_id: UserId, ) -> Result<()> { sqlx::query!( "DELETE FROM promo_code_redemptions WHERE promo_code_id = $1 AND user_id = $2", code_id as PromoCodeId, user_id as UserId, ) .execute(pool) .await?; Ok(()) } /// List all creator-tier comp codes (platform-wide free-trial), newest first. /// Powers the admin comp-codes dashboard. Capped at 500. #[tracing::instrument(skip_all)] pub(crate) async fn get_platform_trial_codes(pool: &PgPool) -> Result> { let codes = sqlx::query_as!( DbPromoCode, r#"SELECT id AS "id: PromoCodeId", creator_id AS "creator_id: UserId", code, code_purpose AS "code_purpose: super::CodePurpose", discount_type AS "discount_type: DiscountType", discount_value, min_price_cents, trial_days, item_id AS "item_id: ItemId", project_id AS "project_id: ProjectId", tier_id AS "tier_id: SubscriptionTierId", max_uses, use_count, expires_at AS "expires_at: chrono::DateTime", starts_at AS "starts_at: chrono::DateTime", created_at AS "created_at: chrono::DateTime", is_platform_wide FROM promo_codes WHERE code_purpose = 'free_trial' AND is_platform_wide = true ORDER BY created_at DESC LIMIT 500"#, ) .fetch_all(pool) .await?; Ok(codes) } /// 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(crate) 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(crate) 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(crate) fn id(&self) -> PromoCodeId { self.code.id } /// The maximum total platform credit MNW will transfer for a single /// redemption of this code, the credit's face value, or `None` when the /// code carries no spend-once balance. /// /// A platform-wide *fixed* credit (the $5 Fan+ renewal credit) is a monetary /// BALANCE, spent at most once across a multi-item cart, not a per-line /// coupon. `Some(budget)` lets the cart cap the cumulative discount+credit at /// that value so one single-use credit can't discount the buyer and reimburse /// the seller N times over a cart (ultra-fuzz Run 13 Payments SERIOUS). /// `None` means there is no balance to over-spend: a seller-funded code (its /// credit is always `0`) or a platform-wide *percentage* code (an intentional /// platform-funded sale that legitimately applies to every line). pub(crate) fn platform_credit_budget_cents(&self) -> Option { if !self.is_platform_wide { return None; } match ( self.code.code_purpose, self.code.discount_type, self.code.discount_value, ) { (CodePurpose::Discount, Some(DiscountType::Fixed), Some(value)) => { Some(i64::from(value.max(0))) } _ => None, } } } /// Spend a platform credit BALANCE across one cart line, capping it to the /// remaining budget. Returns the line's `(final_price_cents, platform_credit_cents)` /// and decrements `*budget` by the credit actually granted; the uncovered part of /// the discount reverts to the buyer's bill so a single-use credit is spent at /// most once across the whole cart (ultra-fuzz Run 13 Payments SERIOUS). /// `*budget == None` disables the cap (per-line: seller-funded or percentage). pub(crate) fn cap_line_to_credit_budget( applied: AppliedDiscount, budget: &mut Option, ) -> (i32, i64) { let mut final_price = applied.price_cents; let mut credit = i64::from(applied.funding.platform_credit_cents()); if let Some(remaining) = budget.as_mut() { let granted = credit.min(*remaining); // The buyer pays the discount the balance can no longer cover; price // rises back toward full, keeping `final_price == base - granted`. final_price += (credit - granted) as i32; credit = granted; *remaining -= granted; } (final_price, credit) } /// 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(crate) 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(crate) 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, } /// Who bears the cost of an applied discount. /// /// A seller's own code reduces that seller's payout, as intended. A platform-wide /// credit (the Fan+ renewal credit) is MNW's marketing perk: the creator must be /// reimbursed for `credit_cents` so they still net the full price, honouring the /// "0% platform fee, creators keep everything" promise. Carrying the funding source /// in the return type is what makes it impossible to apply a platform-wide credit to /// a connected-account charge without recording the reimbursement obligation, the /// item/cart divergence that produced Run 12 Payments SERIOUS + its cart sibling /// cannot recur, because both paths destructure the same `AppliedDiscount`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DiscountFunding { /// Seller-scoped code, the discount comes out of the seller's payout. CreatorFunded, /// Platform-wide credit, MNW owes the creator `credit_cents` (a platform→ /// connected transfer) so the creator nets the full pre-discount price. PlatformFunded { credit_cents: i32 }, } impl DiscountFunding { /// Cents MNW must transfer to the creator to make them whole (`0` when the /// discount is seller-funded). pub(crate) fn platform_credit_cents(self) -> i32 { match self { DiscountFunding::CreatorFunded => 0, DiscountFunding::PlatformFunded { credit_cents } => credit_cents, } } } /// A validated promo applied to one item: the post-discount price and who funds it. #[derive(Debug, Clone, Copy)] pub(crate) struct AppliedDiscount { /// The item's price after the code (`0` for free-access, discounted otherwise). pub price_cents: i32, /// Whether MNW or the seller absorbs the discount. pub funding: DiscountFunding, } /// Result of applying a validated promo to one item. pub(crate) enum PromoApplication { /// The code applies; carries the post-discount price and its funding source. Apply(AppliedDiscount), /// 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(crate) 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)); } } // Funding: a seller code is creator-funded; a platform-wide credit obliges MNW // to reimburse the creator the discounted amount (base - post-discount price). let funded = |price_cents: i32| -> AppliedDiscount { let funding = if validated.is_platform_wide { DiscountFunding::PlatformFunded { credit_cents: (base_price_cents - price_cents).max(0), } } else { DiscountFunding::CreatorFunded }; AppliedDiscount { price_cents, funding, } }; match code.code_purpose { CodePurpose::FreeAccess => Ok(PromoApplication::Apply(funded(0))), CodePurpose::Discount => { if !validated.is_platform_wide && base_price_cents < code.min_price_cents { return Ok(PromoApplication::Ineligible(PromoIneligible::BelowMinPrice)); } // KNOWN value-burn (R6-Pay-N2): a platform-wide fixed credit (e.g. the $5 // Fan+ renewal credit) bypasses `min_price_cents` and `apply_discount` // clamps it to the item price, so applying a $5 credit to a $1 item consumes // the full credit ($4 lost). No creator money loss, MNW funds the credit and // the creator is reimbursed the discounted amount (see `DiscountFunding`). The // fix for the burned fan-value is partial-balance redemption across all // platform credits, tracked as a launchplan feature ("Credit balances // (partial redemption)") rather than a promo-code patch here. let (Some(dt), Some(dv)) = (code.discount_type, code.discount_value) else { return Err(AppError::BadRequest( "This promo code is misconfigured. Please contact the creator.".to_string(), )); }; Ok(PromoApplication::Apply(funded(apply_discount( base_price_cents, dt, dv, )))) } // Rejected up front in `lookup_and_validate_promo`. CodePurpose::FreeTrial => Ok(PromoApplication::Apply(funded(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={price}, pct={pct}, result={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, 999_999] { let result = apply_discount(price, DiscountType::Fixed, discount); assert!( result >= 0 && result <= price, "Invariant violated: price={price}, discount={discount}, result={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); } } // Cart promo semantics: one redemption = one use (ultra-fuzz Run 10 Pay S1) /// Build a percentage-discount promo with no scope/min-price gating. fn unscoped_discount_promo(max_uses: Option) -> ValidatedPromo { ValidatedPromo { code: DbPromoCode { id: PromoCodeId::new(), creator_id: UserId::new(), code: "SAVE10".to_string(), code_purpose: CodePurpose::Discount, discount_type: Some(DiscountType::Percentage), discount_value: Some(10), min_price_cents: 0, trial_days: None, item_id: None, project_id: None, tier_id: None, max_uses, use_count: 0, expires_at: None, starts_at: None, created_at: chrono::Utc::now(), is_platform_wide: false, }, is_platform_wide: false, } } #[test] fn single_use_code_discounts_every_eligible_cart_line() { // A max_uses=1 code applied across a multi-item cart discounts EVERY // eligible line. This is intentional: the handler reserves exactly one // use per cart checkout (one redemption = one use), so the per-line // discounting below is not a use-count leak. Pin it so a future change // can't silently turn cart promos into per-line reservation. let promo = unscoped_discount_promo(Some(1)); for base in [1000, 2000, 4999] { let result = apply_promo_to_item(&promo, ItemId::new(), ProjectId::new(), base).unwrap(); let PromoApplication::Apply(applied) = result else { panic!("expected Apply for an eligible cart line at base {base}"); }; assert_eq!(applied.price_cents, base - base / 10); // A seller-scoped code is creator-funded, no platform reimbursement. assert_eq!(applied.funding, DiscountFunding::CreatorFunded); } // apply_promo_to_item never touches use_count; reservation is the // handler's once-per-checkout concern. assert_eq!(promo.code.use_count, 0); } // Platform credit is a spend-once balance (ultra-fuzz Run 13 Payments) /// Build a platform-wide fixed credit (the $5 Fan+ renewal credit shape). fn platform_fixed_credit(cents: i32) -> ValidatedPromo { ValidatedPromo { code: DbPromoCode { id: PromoCodeId::new(), creator_id: UserId::new(), code: "FANPLUS".to_string(), code_purpose: CodePurpose::Discount, discount_type: Some(DiscountType::Fixed), discount_value: Some(cents), min_price_cents: 0, trial_days: None, item_id: None, project_id: None, tier_id: None, max_uses: None, use_count: 0, expires_at: None, starts_at: None, created_at: chrono::Utc::now(), is_platform_wide: true, }, is_platform_wide: true, } } #[test] fn platform_fixed_credit_budget_is_face_value() { assert_eq!( platform_fixed_credit(500).platform_credit_budget_cents(), Some(500) ); } #[test] fn seller_and_percentage_codes_have_no_credit_budget() { // Seller-funded code: credit is always 0, no balance to cap. assert_eq!( unscoped_discount_promo(None).platform_credit_budget_cents(), None ); // Platform-wide *percentage*: an intentional platform-funded sale that // legitimately applies to every line, not a spend-once balance. let mut pct = platform_fixed_credit(500); pct.code.discount_type = Some(DiscountType::Percentage); pct.code.discount_value = Some(20); assert_eq!(pct.platform_credit_budget_cents(), None); } #[test] fn platform_fixed_credit_spent_once_across_cart() { // The $5 (500¢) Fan+ credit across three $10 (1000¢) lines must discount // the buyer and reimburse the seller a total of exactly 500¢, once, not // 500¢ per line (Run 13 SERIOUS: cart platform-credit multiplication). let promo = platform_fixed_credit(500); let mut budget = promo.platform_credit_budget_cents(); assert_eq!(budget, Some(500)); let mut total_credit = 0i64; let mut total_buyer_paid = 0i64; for _ in 0..3 { let PromoApplication::Apply(applied) = apply_promo_to_item(&promo, ItemId::new(), ProjectId::new(), 1000).unwrap() else { panic!("expected Apply for an eligible platform-credit line"); }; let (final_price, credit) = cap_line_to_credit_budget(applied, &mut budget); total_credit += credit; total_buyer_paid += i64::from(final_price); } assert_eq!( total_credit, 500, "MNW reimburses the seller exactly the face value, once" ); assert_eq!( total_buyer_paid, 3000 - 500, "buyer gets the $5 credit exactly once" ); assert_eq!(budget, Some(0), "balance fully spent"); } #[test] fn platform_fixed_credit_carries_balance_across_cheap_lines() { // A $5 credit on two $1 (100¢) items spends 100 then 100 (the balance // carries instead of burning the whole $5 on the first line); 300¢ remain. let promo = platform_fixed_credit(500); let mut budget = promo.platform_credit_budget_cents(); let mut total_credit = 0i64; for _ in 0..2 { let PromoApplication::Apply(applied) = apply_promo_to_item(&promo, ItemId::new(), ProjectId::new(), 100).unwrap() else { panic!("expected Apply"); }; let (final_price, credit) = cap_line_to_credit_budget(applied, &mut budget); assert_eq!(final_price, 0, "a $1 item is fully covered by the credit"); total_credit += credit; } assert_eq!(total_credit, 200); assert_eq!( budget, Some(300), "unspent balance carries to the rest of the cart" ); } }