Skip to main content

max / makenotwork

56.7 KB · 1480 lines History Blame Raw
1 //! Unified promo code management: creation, validation, usage tracking, and deletion.
2 //!
3 //! Replaces the old `discount_codes` and `download_codes` modules. Supports three
4 //! code purposes: discount, free_access, and free_trial.
5
6 use sqlx::PgPool;
7
8 use super::enums::DiscountType;
9 use super::models::{DbPromoCode, DbPromoCodeWithNames};
10 use super::{CodePurpose, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, UserId};
11 use crate::error::{AppError, Result};
12
13 /// Create a new promo code for a creator.
14 #[allow(clippy::too_many_arguments)]
15 #[tracing::instrument(skip_all)]
16 pub(crate) async fn create_promo_code(
17 pool: &PgPool,
18 creator_id: UserId,
19 code: &str,
20 code_purpose: super::CodePurpose,
21 discount_type: Option<DiscountType>,
22 discount_value: Option<i32>,
23 min_price_cents: i32,
24 trial_days: Option<i32>,
25 max_uses: Option<i32>,
26 expires_at: Option<chrono::DateTime<chrono::Utc>>,
27 starts_at: Option<chrono::DateTime<chrono::Utc>>,
28 item_id: Option<ItemId>,
29 project_id: Option<ProjectId>,
30 tier_id: Option<SubscriptionTierId>,
31 ) -> Result<DbPromoCode> {
32 // runtime-checked: binds a chrono DateTime<Utc> param; a bind param's type can't be overridden in the macro when sqlx time+chrono features are unified.
33 let promo_code = sqlx::query_as::<_, DbPromoCode>(
34 r"
35 INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value,
36 min_price_cents, trial_days, max_uses, expires_at, starts_at, item_id, project_id, tier_id)
37 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
38 RETURNING *
39 ",
40 )
41 .bind(creator_id)
42 .bind(code)
43 .bind(code_purpose)
44 .bind(discount_type)
45 .bind(discount_value)
46 .bind(min_price_cents)
47 .bind(trial_days)
48 .bind(max_uses)
49 .bind(expires_at)
50 .bind(starts_at)
51 .bind(item_id)
52 .bind(project_id)
53 .bind(tier_id)
54 .fetch_one(pool)
55 .await?;
56
57 Ok(promo_code)
58 }
59
60 /// Fetch a promo code by primary key.
61 #[tracing::instrument(skip_all)]
62 pub(crate) async fn get_promo_code_by_id(
63 pool: &PgPool,
64 id: PromoCodeId,
65 ) -> Result<Option<DbPromoCode>> {
66 let code = sqlx::query_as!(
67 DbPromoCode,
68 r#"SELECT id AS "id: PromoCodeId", creator_id AS "creator_id: UserId", code,
69 code_purpose AS "code_purpose: super::CodePurpose",
70 discount_type AS "discount_type: DiscountType", discount_value, min_price_cents,
71 trial_days, item_id AS "item_id: ItemId", project_id AS "project_id: ProjectId",
72 tier_id AS "tier_id: SubscriptionTierId", max_uses, use_count,
73 expires_at AS "expires_at: chrono::DateTime<chrono::Utc>",
74 starts_at AS "starts_at: chrono::DateTime<chrono::Utc>",
75 created_at AS "created_at: chrono::DateTime<chrono::Utc>", is_platform_wide
76 FROM promo_codes WHERE id = $1"#,
77 id as PromoCodeId,
78 )
79 .fetch_optional(pool)
80 .await?;
81
82 Ok(code)
83 }
84
85 /// Look up a promo code by creator ID and code string (case-insensitive).
86 /// Used at checkout to validate discount codes.
87 #[tracing::instrument(skip_all)]
88 pub(crate) async fn get_promo_code_by_creator_and_code(
89 pool: &PgPool,
90 creator_id: UserId,
91 code: &str,
92 ) -> Result<Option<DbPromoCode>> {
93 let promo_code = sqlx::query_as!(
94 DbPromoCode,
95 r#"SELECT id AS "id: PromoCodeId", creator_id AS "creator_id: UserId", code,
96 code_purpose AS "code_purpose: super::CodePurpose",
97 discount_type AS "discount_type: DiscountType", discount_value, min_price_cents,
98 trial_days, item_id AS "item_id: ItemId", project_id AS "project_id: ProjectId",
99 tier_id AS "tier_id: SubscriptionTierId", max_uses, use_count,
100 expires_at AS "expires_at: chrono::DateTime<chrono::Utc>",
101 starts_at AS "starts_at: chrono::DateTime<chrono::Utc>",
102 created_at AS "created_at: chrono::DateTime<chrono::Utc>", is_platform_wide
103 FROM promo_codes WHERE creator_id = $1 AND upper(code) = upper($2)"#,
104 creator_id as UserId,
105 code,
106 )
107 .fetch_optional(pool)
108 .await?;
109
110 Ok(promo_code)
111 }
112
113 /// Look up a free_access promo code by code string (case-insensitive, cross-creator).
114 /// Used for free_access code claims where the buyer doesn't know the creator.
115 /// Scoped to free_access purpose to prevent cross-creator collision with discount codes.
116 #[tracing::instrument(skip_all)]
117 pub(crate) async fn get_promo_code_by_code(
118 pool: &PgPool,
119 code: &str,
120 ) -> Result<Option<DbPromoCode>> {
121 let promo_code = sqlx::query_as!(
122 DbPromoCode,
123 r#"SELECT id AS "id: PromoCodeId", creator_id AS "creator_id: UserId", code,
124 code_purpose AS "code_purpose: super::CodePurpose",
125 discount_type AS "discount_type: DiscountType", discount_value, min_price_cents,
126 trial_days, item_id AS "item_id: ItemId", project_id AS "project_id: ProjectId",
127 tier_id AS "tier_id: SubscriptionTierId", max_uses, use_count,
128 expires_at AS "expires_at: chrono::DateTime<chrono::Utc>",
129 starts_at AS "starts_at: chrono::DateTime<chrono::Utc>",
130 created_at AS "created_at: chrono::DateTime<chrono::Utc>", is_platform_wide
131 FROM promo_codes WHERE upper(code) = upper($1) AND code_purpose = 'free_access'"#,
132 code,
133 )
134 .fetch_optional(pool)
135 .await?;
136
137 Ok(promo_code)
138 }
139
140 /// SQL fragment for promo code listing queries: selects all promo_codes columns
141 /// plus LEFT JOINed item and project titles.
142 const PROMO_CODE_WITH_NAMES_SELECT: &str = r"
143 SELECT pc.*, i.title AS item_title, p.title AS project_title,
144 u.settlement_currency
145 FROM promo_codes pc
146 LEFT JOIN items i ON pc.item_id = i.id
147 LEFT JOIN projects p ON pc.project_id = p.id
148 JOIN users u ON u.id = pc.creator_id
149 ";
150
151 /// List all promo codes for a creator, newest first. Capped at 500.
152 #[tracing::instrument(skip_all)]
153 pub(crate) async fn get_promo_codes_by_creator(
154 pool: &PgPool,
155 creator_id: UserId,
156 ) -> Result<Vec<DbPromoCodeWithNames>> {
157 // runtime-checked: dynamically-built SQL string (PROMO_CODE_WITH_NAMES_SELECT fragment).
158 let query = format!(
159 "{PROMO_CODE_WITH_NAMES_SELECT} WHERE pc.creator_id = $1 ORDER BY pc.created_at DESC LIMIT 500"
160 );
161 let codes = sqlx::query_as::<_, DbPromoCodeWithNames>(&query)
162 .bind(creator_id)
163 .fetch_all(pool)
164 .await?;
165
166 Ok(codes)
167 }
168
169 /// List all promo codes scoped to a project, newest first. Capped at 500.
170 #[tracing::instrument(skip_all)]
171 pub(crate) async fn get_promo_codes_by_project(
172 pool: &PgPool,
173 project_id: ProjectId,
174 ) -> Result<Vec<DbPromoCodeWithNames>> {
175 // runtime-checked: dynamically-built SQL string (PROMO_CODE_WITH_NAMES_SELECT fragment).
176 let query = format!(
177 "{PROMO_CODE_WITH_NAMES_SELECT} WHERE pc.project_id = $1 ORDER BY pc.created_at DESC LIMIT 500"
178 );
179 let codes = sqlx::query_as::<_, DbPromoCodeWithNames>(&query)
180 .bind(project_id)
181 .fetch_all(pool)
182 .await?;
183
184 Ok(codes)
185 }
186
187 /// List all promo codes scoped to an item, newest first. Capped at 500.
188 #[tracing::instrument(skip_all)]
189 pub(crate) async fn get_promo_codes_by_item(
190 pool: &PgPool,
191 item_id: ItemId,
192 ) -> Result<Vec<DbPromoCodeWithNames>> {
193 // runtime-checked: dynamically-built SQL string (PROMO_CODE_WITH_NAMES_SELECT fragment).
194 let query = format!(
195 "{PROMO_CODE_WITH_NAMES_SELECT} WHERE pc.item_id = $1 ORDER BY pc.created_at DESC LIMIT 500"
196 );
197 let codes = sqlx::query_as::<_, DbPromoCodeWithNames>(&query)
198 .bind(item_id)
199 .fetch_all(pool)
200 .await?;
201
202 Ok(codes)
203 }
204
205 /// Batch-load item-scoped promo codes for multiple items, grouped by item_id.
206 #[tracing::instrument(skip_all)]
207 pub(crate) async fn get_promo_codes_by_items(
208 pool: &PgPool,
209 item_ids: &[ItemId],
210 ) -> Result<std::collections::HashMap<ItemId, Vec<DbPromoCodeWithNames>>> {
211 // runtime-checked: dynamically-built SQL string (PROMO_CODE_WITH_NAMES_SELECT fragment).
212 let query = format!(
213 "{PROMO_CODE_WITH_NAMES_SELECT} WHERE pc.item_id = ANY($1) ORDER BY pc.item_id, pc.created_at DESC"
214 );
215 let codes = sqlx::query_as::<_, DbPromoCodeWithNames>(&query)
216 .bind(item_ids)
217 .fetch_all(pool)
218 .await?;
219
220 let mut map: std::collections::HashMap<ItemId, Vec<DbPromoCodeWithNames>> =
221 std::collections::HashMap::new();
222 for pc in codes {
223 if let Some(item_id) = pc.item_id {
224 map.entry(item_id).or_default().push(pc);
225 }
226 }
227 Ok(map)
228 }
229
230 /// Atomically increment use_count, respecting the max_uses limit.
231 ///
232 /// Returns `true` if the increment succeeded, `false` if the code has already
233 /// reached its usage limit. The `WHERE` clause enforces the limit at the
234 /// database level, preventing TOCTOU races.
235 ///
236 /// Accepts any sqlx executor (`&PgPool`, `&mut Transaction`, etc.) so callers
237 /// can include this in a larger transaction when needed.
238 #[tracing::instrument(skip_all)]
239 pub(crate) async fn try_increment_use_count<'e>(
240 executor: impl sqlx::PgExecutor<'e>,
241 id: PromoCodeId,
242 ) -> Result<bool> {
243 let result = sqlx::query!(
244 "UPDATE promo_codes SET use_count = use_count + 1 \
245 WHERE id = $1 \
246 AND (max_uses IS NULL OR use_count < max_uses) \
247 AND (expires_at IS NULL OR expires_at > NOW()) \
248 AND (starts_at IS NULL OR starts_at <= NOW())",
249 id as PromoCodeId,
250 )
251 .execute(executor)
252 .await?;
253
254 Ok(result.rows_affected() > 0)
255 }
256
257 /// Release a reserved use_count slot (decrement, clamped to 0).
258 ///
259 /// Used in two places that must coordinate so the count doesn't drop twice
260 /// for the same reservation:
261 /// 1. Route handlers, when a Stripe checkout creation or pending-tx
262 /// insert fails AFTER the use_count was reserved. They call
263 /// `release_use_count_and_detach` (below) which also nulls the
264 /// `promo_code_id` on any pending transaction rows for this
265 /// reservation, so `cleanup_stale_pending` can't fire a second
266 /// release for the same buyer's promo hold.
267 /// 2. `cleanup_stale_pending` itself, when it deletes stale pending
268 /// rows past the 24h checkout-session expiry. Those rows still
269 /// carry their `promo_code_id`, so this plain function is the
270 /// right call from there.
271 ///
272 /// `GREATEST(0, ...)` makes a double-release harmless (count clamps at
273 /// zero) but the structural fix above prevents it from happening at all.
274 #[tracing::instrument(skip_all)]
275 pub(crate) async fn release_use_count(pool: &PgPool, id: PromoCodeId) -> Result<()> {
276 sqlx::query!(
277 "UPDATE promo_codes SET use_count = GREATEST(0, use_count - 1) WHERE id = $1",
278 id as PromoCodeId,
279 )
280 .execute(pool)
281 .await?;
282
283 Ok(())
284 }
285
286 /// Release a use_count slot AND detach the same promo_code_id from any
287 /// pending transactions for `buyer_id` so the scheduler's
288 /// `cleanup_stale_pending` doesn't release it a second time when those
289 /// stale rows eventually time out.
290 ///
291 /// Use this from route-level failure paths (Stripe session creation
292 /// failed, pending-tx insert failed mid-cart, etc). The detach is a
293 /// no-op when the failure happened BEFORE any pending row was inserted;
294 /// it's the safety net for when a partial pending row may have landed.
295 #[tracing::instrument(skip_all)]
296 pub(crate) async fn release_use_count_and_detach(
297 pool: &PgPool,
298 id: PromoCodeId,
299 buyer_id: UserId,
300 ) -> Result<()> {
301 let mut tx = pool.begin().await?;
302
303 sqlx::query!(
304 "UPDATE transactions SET promo_code_id = NULL \
305 WHERE buyer_id = $1 AND promo_code_id = $2 AND status = 'pending'",
306 buyer_id as UserId,
307 id as PromoCodeId,
308 )
309 .execute(&mut *tx)
310 .await?;
311
312 sqlx::query!(
313 "UPDATE promo_codes SET use_count = GREATEST(0, use_count - 1) WHERE id = $1",
314 id as PromoCodeId,
315 )
316 .execute(&mut *tx)
317 .await?;
318
319 tx.commit().await?;
320 Ok(())
321 }
322
323 /// Update editable fields on a promo code (expires_at, starts_at, max_uses).
324 #[tracing::instrument(skip_all)]
325 #[allow(
326 clippy::option_option,
327 reason = "tri-state PATCH semantics: outer None = field absent (leave unchanged), Some(None) = set to SQL NULL, Some(Some(v)) = set to value"
328 )]
329 pub(crate) async fn update_promo_code(
330 pool: &PgPool,
331 id: PromoCodeId,
332 expires_at: Option<Option<chrono::DateTime<chrono::Utc>>>,
333 starts_at: Option<Option<chrono::DateTime<chrono::Utc>>>,
334 max_uses: Option<Option<i32>>,
335 ) -> Result<DbPromoCode> {
336 // Build SET clauses for provided fields only
337 let mut sets = Vec::new();
338 let mut param_idx = 2u32; // $1 = id
339
340 if expires_at.is_some() {
341 sets.push(format!("expires_at = ${param_idx}"));
342 param_idx += 1;
343 }
344 if starts_at.is_some() {
345 sets.push(format!("starts_at = ${param_idx}"));
346 param_idx += 1;
347 }
348 if max_uses.is_some() {
349 sets.push(format!("max_uses = ${param_idx}"));
350 // Final SET clause; param_idx is never read after this point, so the
351 // increment is elided to avoid an unused_assignments warning. Restore
352 // it if a new optional field is added below.
353 }
354
355 if sets.is_empty() {
356 // Nothing to update, just return current state
357 return get_promo_code_by_id(pool, id)
358 .await?
359 .ok_or_else(|| crate::error::AppError::NotFound);
360 }
361
362 // runtime-checked: dynamically-built SQL string (SET clause assembled from provided fields).
363 let sql = format!(
364 "UPDATE promo_codes SET {} WHERE id = $1 RETURNING *",
365 sets.join(", ")
366 );
367 let mut query = sqlx::query_as::<_, DbPromoCode>(&sql).bind(id);
368
369 if let Some(val) = expires_at {
370 query = query.bind(val);
371 }
372 if let Some(val) = starts_at {
373 query = query.bind(val);
374 }
375 if let Some(val) = max_uses {
376 query = query.bind(val);
377 }
378
379 let code = query.fetch_one(pool).await?;
380 Ok(code)
381 }
382
383 /// Delete all expired promo codes for a creator. Returns number of rows deleted.
384 #[tracing::instrument(skip_all)]
385 pub(crate) async fn delete_expired_by_creator(pool: &PgPool, creator_id: UserId) -> Result<u64> {
386 let result = sqlx::query!(
387 "DELETE FROM promo_codes WHERE creator_id = $1 AND expires_at IS NOT NULL AND expires_at < NOW()",
388 creator_id as UserId,
389 )
390 .execute(pool)
391 .await?;
392
393 Ok(result.rows_affected())
394 }
395
396 /// Delete a promo code permanently.
397 #[tracing::instrument(skip_all)]
398 pub(crate) async fn delete_promo_code(pool: &PgPool, id: PromoCodeId) -> Result<()> {
399 sqlx::query!("DELETE FROM promo_codes WHERE id = $1", id as PromoCodeId)
400 .execute(pool)
401 .await?;
402
403 Ok(())
404 }
405
406 /// A single row in the "who redeemed this code" view.
407 ///
408 /// `display_name` / `username` are `None` for guest checkouts; `guest_email`
409 /// fills that gap. `item_title` is denormalized on the transaction row so
410 /// renaming an item later doesn't strand the audit trail.
411 #[derive(Debug, sqlx::FromRow, serde::Serialize)]
412 pub(crate) struct PromoRedemption {
413 pub redeemed_at: chrono::DateTime<chrono::Utc>,
414 pub display_name: Option<String>,
415 pub username: Option<String>,
416 pub guest_email: Option<String>,
417 pub item_title: Option<String>,
418 pub amount_cents: i32,
419 }
420
421 /// List redemptions of a single promo code, newest first.
422 ///
423 /// Joins through to `users` for buyer identity but falls back to the
424 /// transaction's `guest_email` for unauthenticated checkouts. Capped at 500
425 /// rows, promo codes that exceed that bound are an outlier worth its own
426 /// CSV-export flow rather than a paginated UI.
427 #[tracing::instrument(skip_all)]
428 pub(crate) async fn list_redemptions(
429 pool: &PgPool,
430 id: PromoCodeId,
431 ) -> Result<Vec<PromoRedemption>> {
432 let rows = sqlx::query_as!(
433 PromoRedemption,
434 r#"
435 SELECT
436 COALESCE(t.completed_at, t.created_at) AS "redeemed_at!: chrono::DateTime<chrono::Utc>",
437 u.display_name AS display_name,
438 u.username AS "username?",
439 t.guest_email AS guest_email,
440 t.item_title AS item_title,
441 t.amount_cents AS amount_cents
442 FROM transactions t
443 LEFT JOIN users u ON u.id = t.buyer_id
444 WHERE t.promo_code_id = $1
445 AND t.status = 'completed'
446 ORDER BY COALESCE(t.completed_at, t.created_at) DESC
447 LIMIT 500
448 "#,
449 id as PromoCodeId,
450 )
451 .fetch_all(pool)
452 .await?;
453
454 Ok(rows)
455 }
456
457 /// Create a platform-wide promo code (used for Fan+ monthly credits).
458 ///
459 /// Same as `create_promo_code` but sets `is_platform_wide = true`.
460 /// Platform-wide codes are not scoped to a specific creator's items.
461 #[allow(clippy::too_many_arguments)]
462 #[tracing::instrument(skip_all)]
463 pub(crate) async fn create_platform_promo_code(
464 pool: &PgPool,
465 creator_id: UserId,
466 code: &str,
467 code_purpose: super::CodePurpose,
468 discount_type: Option<DiscountType>,
469 discount_value: Option<i32>,
470 min_price_cents: i32,
471 trial_days: Option<i32>,
472 max_uses: Option<i32>,
473 expires_at: Option<chrono::DateTime<chrono::Utc>>,
474 ) -> Result<DbPromoCode> {
475 // runtime-checked: binds a chrono DateTime<Utc> param; a bind param's type can't be overridden in the macro when sqlx time+chrono features are unified.
476 let promo_code = sqlx::query_as::<_, DbPromoCode>(
477 r"
478 INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value,
479 min_price_cents, trial_days, max_uses, expires_at, is_platform_wide)
480 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true)
481 RETURNING *
482 ",
483 )
484 .bind(creator_id)
485 .bind(code)
486 .bind(code_purpose)
487 .bind(discount_type)
488 .bind(discount_value)
489 .bind(min_price_cents)
490 .bind(trial_days)
491 .bind(max_uses)
492 .bind(expires_at)
493 .fetch_one(pool)
494 .await?;
495
496 Ok(promo_code)
497 }
498
499 /// Unforgeable proof that the once-per-renewal Fan+ credit slot for a
500 /// `(stripe_sub_id, period_end)` was won by *this* webhook delivery.
501 ///
502 /// Returned only by [`try_claim_fan_plus_credit`], and required by reference by
503 /// [`issue_fan_plus_credit_code`], the only path that mints the credit. The
504 /// private field makes it unconstructable outside this module (the
505 /// [`super::subscriptions::SubscriptionGate`] pattern), so "mint or email a Fan+
506 /// credit without first winning the idempotency slot" cannot be written. This
507 /// turns the webhook-dedup invariant, a non-idempotent, money-moving side-effect
508 /// must sit behind its own atomic claim, from review-time discipline into a
509 /// compile-time guarantee.
510 #[must_use]
511 pub(crate) struct FanPlusCreditClaim {
512 _seal: (),
513 }
514
515 /// Claim the once-per-renewal slot for a Fan+ monthly credit.
516 ///
517 /// Returns `Some(`[`FanPlusCreditClaim`]`)` if this `(stripe_sub_id, period_end)`
518 /// was not yet claimed (row inserted, the caller holds the witness needed to
519 /// mint and email the credit), `None` if a prior delivery of the same renewal
520 /// already claimed it (the caller must do nothing).
521 ///
522 /// This is the DB-level idempotency guard that closes the duplicate-webhook
523 /// double-credit race: `invoice.payment_succeeded` dedup at the webhook layer is
524 /// a check-then-act read that two concurrent deliveries both pass, so the
525 /// money-moving side-effect must serialize on its own atomic write. The
526 /// `ON CONFLICT DO NOTHING` against the `(stripe_sub_id, period_end)` primary key
527 /// makes exactly one of N concurrent deliveries win, and only that one gets a
528 /// witness.
529 #[tracing::instrument(skip_all)]
530 pub(crate) async fn try_claim_fan_plus_credit(
531 pool: &PgPool,
532 stripe_sub_id: &str,
533 period_end: i64,
534 ) -> Result<Option<FanPlusCreditClaim>> {
535 let result = sqlx::query!(
536 "INSERT INTO fan_plus_credit_issuance (stripe_sub_id, period_end) \
537 VALUES ($1, $2) ON CONFLICT DO NOTHING",
538 stripe_sub_id,
539 period_end,
540 )
541 .execute(pool)
542 .await?;
543
544 Ok((result.rows_affected() == 1).then_some(FanPlusCreditClaim { _seal: () }))
545 }
546
547 /// Mint the $5 single-use, platform-wide credit code for a won Fan+ renewal.
548 ///
549 /// Requires a [`FanPlusCreditClaim`] by reference: the only way to obtain one is
550 /// to win [`try_claim_fan_plus_credit`], so this side-effect is structurally
551 /// unreachable for a duplicate/redelivered webhook. The credit terms ($5 fixed,
552 /// single use) are sealed here rather than spelled out at the call site, so every
553 /// Fan+ credit is identical by construction.
554 #[tracing::instrument(skip_all)]
555 pub(crate) async fn issue_fan_plus_credit_code(
556 _claim: &FanPlusCreditClaim,
557 pool: &PgPool,
558 creator_id: UserId,
559 code: &str,
560 expires_at: Option<chrono::DateTime<chrono::Utc>>,
561 ) -> Result<DbPromoCode> {
562 create_platform_promo_code(
563 pool,
564 creator_id,
565 code,
566 super::CodePurpose::Discount,
567 Some(DiscountType::Fixed),
568 Some(500), // $5 credit
569 0,
570 None,
571 Some(1), // single use
572 expires_at,
573 )
574 .await
575 }
576
577 /// Look up a platform-wide promo code by user ID and code string (case-insensitive).
578 ///
579 /// Used at checkout to validate Fan+ credits: the buyer owns the code, and it
580 /// applies to any item on the platform.
581 #[tracing::instrument(skip_all)]
582 pub(crate) async fn get_platform_promo_code_by_user_and_code(
583 pool: &PgPool,
584 user_id: UserId,
585 code: &str,
586 ) -> Result<Option<DbPromoCode>> {
587 let promo_code = sqlx::query_as!(
588 DbPromoCode,
589 r#"SELECT id AS "id: PromoCodeId", creator_id AS "creator_id: UserId", code,
590 code_purpose AS "code_purpose: super::CodePurpose",
591 discount_type AS "discount_type: DiscountType", discount_value, min_price_cents,
592 trial_days, item_id AS "item_id: ItemId", project_id AS "project_id: ProjectId",
593 tier_id AS "tier_id: SubscriptionTierId", max_uses, use_count,
594 expires_at AS "expires_at: chrono::DateTime<chrono::Utc>",
595 starts_at AS "starts_at: chrono::DateTime<chrono::Utc>",
596 created_at AS "created_at: chrono::DateTime<chrono::Utc>", is_platform_wide
597 FROM promo_codes WHERE creator_id = $1 AND upper(code) = upper($2) AND is_platform_wide = true"#,
598 user_id as UserId, code,
599 )
600 .fetch_optional(pool)
601 .await?;
602
603 Ok(promo_code)
604 }
605
606 /// Look up a platform-wide free-trial code by code string (case-insensitive).
607 ///
608 /// Used to comp creator-tier subscriptions: anyone holding the code can redeem
609 /// it at creator-tier checkout for `trial_days` free, after which the
610 /// subscription rolls to the price chosen at checkout (founder price during the
611 /// founder window). Scoped to `free_trial` + `is_platform_wide` so it can't
612 /// collide with creator-scoped discount codes or per-user Fan+ credits.
613 #[tracing::instrument(skip_all)]
614 pub(crate) async fn get_platform_trial_code_by_code(
615 pool: &PgPool,
616 code: &str,
617 ) -> Result<Option<DbPromoCode>> {
618 let promo_code = sqlx::query_as!(
619 DbPromoCode,
620 r#"SELECT id AS "id: PromoCodeId", creator_id AS "creator_id: UserId", code,
621 code_purpose AS "code_purpose: super::CodePurpose",
622 discount_type AS "discount_type: DiscountType", discount_value, min_price_cents,
623 trial_days, item_id AS "item_id: ItemId", project_id AS "project_id: ProjectId",
624 tier_id AS "tier_id: SubscriptionTierId", max_uses, use_count,
625 expires_at AS "expires_at: chrono::DateTime<chrono::Utc>",
626 starts_at AS "starts_at: chrono::DateTime<chrono::Utc>",
627 created_at AS "created_at: chrono::DateTime<chrono::Utc>", is_platform_wide
628 FROM promo_codes
629 WHERE upper(code) = upper($1) AND code_purpose = 'free_trial' AND is_platform_wide = true"#,
630 code,
631 )
632 .fetch_optional(pool)
633 .await?;
634
635 Ok(promo_code)
636 }
637
638 /// Record that `user_id` redeemed `code_id`, enforcing once-per-individual.
639 ///
640 /// Returns `true` if this is the user's first redemption of the code (row
641 /// inserted), `false` if they have already redeemed it (the `(code, user)`
642 /// primary key conflicts). Atomic, the conflict resolution closes the
643 /// double-submit race.
644 #[tracing::instrument(skip_all)]
645 pub(crate) async fn try_record_redemption(
646 pool: &PgPool,
647 code_id: PromoCodeId,
648 user_id: UserId,
649 ) -> Result<bool> {
650 let result = sqlx::query!(
651 "INSERT INTO promo_code_redemptions (promo_code_id, user_id) \
652 VALUES ($1, $2) ON CONFLICT DO NOTHING",
653 code_id as PromoCodeId,
654 user_id as UserId,
655 )
656 .execute(pool)
657 .await?;
658
659 Ok(result.rows_affected() > 0)
660 }
661
662 /// Remove a per-user redemption record. Used to roll back a reservation when a
663 /// later step (usage-limit reservation or the Stripe call) fails after the
664 /// redemption row was inserted.
665 #[tracing::instrument(skip_all)]
666 pub(crate) async fn remove_redemption(
667 pool: &PgPool,
668 code_id: PromoCodeId,
669 user_id: UserId,
670 ) -> Result<()> {
671 sqlx::query!(
672 "DELETE FROM promo_code_redemptions WHERE promo_code_id = $1 AND user_id = $2",
673 code_id as PromoCodeId,
674 user_id as UserId,
675 )
676 .execute(pool)
677 .await?;
678 Ok(())
679 }
680
681 /// List all creator-tier comp codes (platform-wide free-trial), newest first.
682 /// Powers the admin comp-codes dashboard. Capped at 500.
683 #[tracing::instrument(skip_all)]
684 pub(crate) async fn get_platform_trial_codes(pool: &PgPool) -> Result<Vec<DbPromoCode>> {
685 let codes = sqlx::query_as!(
686 DbPromoCode,
687 r#"SELECT id AS "id: PromoCodeId", creator_id AS "creator_id: UserId", code,
688 code_purpose AS "code_purpose: super::CodePurpose",
689 discount_type AS "discount_type: DiscountType", discount_value, min_price_cents,
690 trial_days, item_id AS "item_id: ItemId", project_id AS "project_id: ProjectId",
691 tier_id AS "tier_id: SubscriptionTierId", max_uses, use_count,
692 expires_at AS "expires_at: chrono::DateTime<chrono::Utc>",
693 starts_at AS "starts_at: chrono::DateTime<chrono::Utc>",
694 created_at AS "created_at: chrono::DateTime<chrono::Utc>", is_platform_wide
695 FROM promo_codes
696 WHERE code_purpose = 'free_trial' AND is_platform_wide = true
697 ORDER BY created_at DESC LIMIT 500"#,
698 )
699 .fetch_all(pool)
700 .await?;
701
702 Ok(codes)
703 }
704
705 /// Apply a discount to a price, returning the discounted price in cents (minimum 0).
706 /// Negative discount values are clamped to 0 to prevent price increases.
707 #[tracing::instrument(skip_all)]
708 pub(crate) fn apply_discount(
709 price_cents: i32,
710 discount_type: DiscountType,
711 discount_value: i32,
712 ) -> i32 {
713 let discount_value = discount_value.max(0);
714 match discount_type {
715 DiscountType::Percentage => {
716 let discount = (price_cents as i64 * discount_value as i64) / 100;
717 (price_cents as i64 - discount).max(0) as i32
718 }
719 // Subtract in i64 (like the Percentage arm) so a configuration where
720 // `discount_value > i32::MAX - price_cents` can't underflow before the
721 // `.max(0)` clamp catches it. discount_value is i32 so the sub is
722 // bounded; we cast for parity with the Percentage path.
723 DiscountType::Fixed => (price_cents as i64 - discount_value as i64).max(0) as i32,
724 }
725 }
726
727 // ── Shared checkout promo validation ─────────────────────────────────────────
728 //
729 // Every checkout path (single item, guest, cart ×2) needs the same promo logic:
730 // look the code up, run the code-level window/limit checks, then apply it to each
731 // item with scope + minimum-price + discount math. These two functions are that
732 // logic in one place, so a fix (the NULL-discount rejection, the min-price floor)
733 // can't land in three copies and miss the fourth.
734
735 /// A promo code that passed the code-level checks (exists, not a trial, inside
736 /// its active window, under its use limit). Apply it per item with
737 /// [`apply_promo_to_item`]; reserve it with [`try_increment_use_count`].
738 pub(crate) struct ValidatedPromo {
739 pub code: DbPromoCode,
740 /// A platform-wide Fan+ credit (valid on any seller's items) rather than a
741 /// seller-scoped code; gates the scope and minimum-price checks.
742 pub is_platform_wide: bool,
743 }
744
745 impl ValidatedPromo {
746 pub(crate) fn id(&self) -> PromoCodeId {
747 self.code.id
748 }
749
750 /// The maximum total platform credit MNW will transfer for a single
751 /// redemption of this code, the credit's face value, or `None` when the
752 /// code carries no spend-once balance.
753 ///
754 /// A platform-wide *fixed* credit (the $5 Fan+ renewal credit) is a monetary
755 /// BALANCE, spent at most once across a multi-item cart, not a per-line
756 /// coupon. `Some(budget)` lets the cart cap the cumulative discount+credit at
757 /// that value so one single-use credit can't discount the buyer and reimburse
758 /// the seller N times over a cart (ultra-fuzz Run 13 Payments SERIOUS).
759 /// `None` means there is no balance to over-spend: a seller-funded code (its
760 /// credit is always `0`) or a platform-wide *percentage* code (an intentional
761 /// platform-funded sale that legitimately applies to every line).
762 pub(crate) fn platform_credit_budget_cents(&self) -> Option<i64> {
763 if !self.is_platform_wide {
764 return None;
765 }
766 match (
767 self.code.code_purpose,
768 self.code.discount_type,
769 self.code.discount_value,
770 ) {
771 (CodePurpose::Discount, Some(DiscountType::Fixed), Some(value)) => {
772 Some(i64::from(value.max(0)))
773 }
774 _ => None,
775 }
776 }
777 }
778
779 /// Spend a platform credit BALANCE across one cart line, capping it to the
780 /// remaining budget. Returns the line's `(final_price_cents, platform_credit_cents)`
781 /// and decrements `*budget` by the credit actually granted; the uncovered part of
782 /// the discount reverts to the buyer's bill so a single-use credit is spent at
783 /// most once across the whole cart (ultra-fuzz Run 13 Payments SERIOUS).
784 /// `*budget == None` disables the cap (per-line: seller-funded or percentage).
785 pub(crate) fn cap_line_to_credit_budget(
786 applied: AppliedDiscount,
787 budget: &mut Option<i64>,
788 ) -> (i32, i64) {
789 let mut final_price = applied.price_cents;
790 let mut credit = i64::from(applied.funding.platform_credit_cents());
791 if let Some(remaining) = budget.as_mut() {
792 let granted = credit.min(*remaining);
793 // The buyer pays the discount the balance can no longer cover; price
794 // rises back toward full, keeping `final_price == base - granted`.
795 final_price += (credit - granted) as i32;
796 credit = granted;
797 *remaining -= granted;
798 }
799 (final_price, credit)
800 }
801
802 /// Look up and code-level-validate a checkout promo. Tries the seller's code
803 /// first; when `buyer_id` is `Some`, falls back to that buyer's platform-wide
804 /// Fan+ credit. Returns `Ok(None)` for a blank code, `Err` for an
805 /// unknown/not-yet-active/expired/exhausted/trial code. Per-item scope, minimum
806 /// price, and discount math are done by [`apply_promo_to_item`], not here.
807 #[tracing::instrument(skip_all)]
808 pub(crate) async fn lookup_and_validate_promo(
809 pool: &PgPool,
810 seller_id: UserId,
811 buyer_id: Option<UserId>,
812 raw_code: &str,
813 ) -> Result<Option<ValidatedPromo>> {
814 let code_str = raw_code.trim().to_uppercase();
815 if code_str.is_empty() {
816 return Ok(None);
817 }
818
819 let code = match get_promo_code_by_creator_and_code(pool, seller_id, &code_str).await? {
820 Some(pc) => pc,
821 None => match buyer_id {
822 Some(uid) => get_platform_promo_code_by_user_and_code(pool, uid, &code_str)
823 .await?
824 .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?,
825 None => return Err(AppError::BadRequest("Invalid promo code".to_string())),
826 },
827 };
828
829 if code.code_purpose == CodePurpose::FreeTrial {
830 return Err(AppError::BadRequest(
831 "Trial codes can only be used for subscriptions".to_string(),
832 ));
833 }
834 let now = chrono::Utc::now();
835 if let Some(starts) = code.starts_at
836 && starts > now
837 {
838 return Err(AppError::BadRequest(
839 "This promo code is not yet active".to_string(),
840 ));
841 }
842 if let Some(expires) = code.expires_at
843 && expires < now
844 {
845 return Err(AppError::BadRequest(
846 "This promo code has expired".to_string(),
847 ));
848 }
849 if let Some(max) = code.max_uses
850 && code.use_count >= max
851 {
852 return Err(AppError::BadRequest(
853 "This promo code has reached its usage limit".to_string(),
854 ));
855 }
856
857 let is_platform_wide = code.is_platform_wide;
858 Ok(Some(ValidatedPromo {
859 code,
860 is_platform_wide,
861 }))
862 }
863
864 /// Why a validated promo doesn't apply to a particular item (vs a hard error).
865 pub(crate) enum PromoIneligible {
866 /// The code is scoped to a different item or project.
867 ScopeMismatch,
868 /// The item's price is below the code's `min_price_cents` floor.
869 BelowMinPrice,
870 }
871
872 /// Who bears the cost of an applied discount.
873 ///
874 /// A seller's own code reduces that seller's payout, as intended. A platform-wide
875 /// credit (the Fan+ renewal credit) is MNW's marketing perk: the creator must be
876 /// reimbursed for `credit_cents` so they still net the full price, honouring the
877 /// "0% platform fee, creators keep everything" promise. Carrying the funding source
878 /// in the return type is what makes it impossible to apply a platform-wide credit to
879 /// a connected-account charge without recording the reimbursement obligation, the
880 /// item/cart divergence that produced Run 12 Payments SERIOUS + its cart sibling
881 /// cannot recur, because both paths destructure the same `AppliedDiscount`.
882 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
883 pub(crate) enum DiscountFunding {
884 /// Seller-scoped code, the discount comes out of the seller's payout.
885 CreatorFunded,
886 /// Platform-wide credit, MNW owes the creator `credit_cents` (a platform→
887 /// connected transfer) so the creator nets the full pre-discount price.
888 PlatformFunded { credit_cents: i32 },
889 }
890
891 impl DiscountFunding {
892 /// Cents MNW must transfer to the creator to make them whole (`0` when the
893 /// discount is seller-funded).
894 pub(crate) fn platform_credit_cents(self) -> i32 {
895 match self {
896 DiscountFunding::CreatorFunded => 0,
897 DiscountFunding::PlatformFunded { credit_cents } => credit_cents,
898 }
899 }
900 }
901
902 /// A validated promo applied to one item: the post-discount price and who funds it.
903 #[derive(Debug, Clone, Copy)]
904 pub(crate) struct AppliedDiscount {
905 /// The item's price after the code (`0` for free-access, discounted otherwise).
906 pub price_cents: i32,
907 /// Whether MNW or the seller absorbs the discount.
908 pub funding: DiscountFunding,
909 }
910
911 /// Result of applying a validated promo to one item.
912 pub(crate) enum PromoApplication {
913 /// The code applies; carries the post-discount price and its funding source.
914 Apply(AppliedDiscount),
915 /// The code doesn't apply to this item, cart skips it, single-item rejects.
916 Ineligible(PromoIneligible),
917 }
918
919 /// Apply a validated promo to one item's base price. A misconfigured Discount
920 /// code (NULL type/value) is a hard `Err` (never reserve-and-charge-full);
921 /// scope or minimum-price ineligibility is `Ok(Ineligible(_))` so cart checkout
922 /// can skip the item while single-item checkout turns it into an error.
923 pub(crate) fn apply_promo_to_item(
924 validated: &ValidatedPromo,
925 item_id: ItemId,
926 project_id: ProjectId,
927 base_price_cents: i32,
928 ) -> Result<PromoApplication> {
929 let code = &validated.code;
930
931 // Scope checks apply to seller codes only; a platform-wide credit is valid
932 // on any item.
933 if !validated.is_platform_wide {
934 if let Some(scoped_item) = code.item_id
935 && scoped_item != item_id
936 {
937 return Ok(PromoApplication::Ineligible(PromoIneligible::ScopeMismatch));
938 }
939 if let Some(scoped_project) = code.project_id
940 && project_id != scoped_project
941 {
942 return Ok(PromoApplication::Ineligible(PromoIneligible::ScopeMismatch));
943 }
944 }
945
946 // Funding: a seller code is creator-funded; a platform-wide credit obliges MNW
947 // to reimburse the creator the discounted amount (base - post-discount price).
948 let funded = |price_cents: i32| -> AppliedDiscount {
949 let funding = if validated.is_platform_wide {
950 DiscountFunding::PlatformFunded {
951 credit_cents: (base_price_cents - price_cents).max(0),
952 }
953 } else {
954 DiscountFunding::CreatorFunded
955 };
956 AppliedDiscount {
957 price_cents,
958 funding,
959 }
960 };
961
962 match code.code_purpose {
963 CodePurpose::FreeAccess => Ok(PromoApplication::Apply(funded(0))),
964 CodePurpose::Discount => {
965 if !validated.is_platform_wide && base_price_cents < code.min_price_cents {
966 return Ok(PromoApplication::Ineligible(PromoIneligible::BelowMinPrice));
967 }
968 // KNOWN value-burn (R6-Pay-N2): a platform-wide fixed credit (e.g. the $5
969 // Fan+ renewal credit) bypasses `min_price_cents` and `apply_discount`
970 // clamps it to the item price, so applying a $5 credit to a $1 item consumes
971 // the full credit ($4 lost). No creator money loss, MNW funds the credit and
972 // the creator is reimbursed the discounted amount (see `DiscountFunding`). The
973 // fix for the burned fan-value is partial-balance redemption across all
974 // platform credits, tracked as a launchplan feature ("Credit balances
975 // (partial redemption)") rather than a promo-code patch here.
976 let (Some(dt), Some(dv)) = (code.discount_type, code.discount_value) else {
977 return Err(AppError::BadRequest(
978 "This promo code is misconfigured. Please contact the creator.".to_string(),
979 ));
980 };
981 Ok(PromoApplication::Apply(funded(apply_discount(
982 base_price_cents,
983 dt,
984 dv,
985 ))))
986 }
987 // Rejected up front in `lookup_and_validate_promo`.
988 CodePurpose::FreeTrial => Ok(PromoApplication::Apply(funded(base_price_cents))),
989 }
990 }
991
992 #[cfg(test)]
993 mod tests {
994 use super::*;
995
996 #[test]
997 fn percentage_discount_50() {
998 assert_eq!(apply_discount(1000, DiscountType::Percentage, 50), 500);
999 }
1000
1001 #[test]
1002 fn percentage_discount_100() {
1003 assert_eq!(apply_discount(1000, DiscountType::Percentage, 100), 0);
1004 }
1005
1006 #[test]
1007 fn percentage_discount_10() {
1008 // 999 * 10 / 100 = 99 (integer), 999 - 99 = 900
1009 assert_eq!(apply_discount(999, DiscountType::Percentage, 10), 900);
1010 }
1011
1012 #[test]
1013 fn fixed_discount() {
1014 assert_eq!(apply_discount(1000, DiscountType::Fixed, 300), 700);
1015 }
1016
1017 #[test]
1018 fn fixed_discount_exceeds_price() {
1019 assert_eq!(apply_discount(100, DiscountType::Fixed, 500), 0);
1020 }
1021
1022 // Percentage discount edge cases
1023
1024 #[test]
1025 fn percentage_discount_0() {
1026 assert_eq!(apply_discount(1000, DiscountType::Percentage, 0), 1000);
1027 }
1028
1029 #[test]
1030 fn percentage_discount_over_100() {
1031 // 150% discount should clamp to 0
1032 assert_eq!(apply_discount(1000, DiscountType::Percentage, 150), 0);
1033 }
1034
1035 #[test]
1036 fn percentage_discount_1_percent() {
1037 // 1000 * 1 / 100 = 10, result = 990
1038 assert_eq!(apply_discount(1000, DiscountType::Percentage, 1), 990);
1039 }
1040
1041 #[test]
1042 fn percentage_discount_99_percent() {
1043 // 1000 * 99 / 100 = 990, result = 10
1044 assert_eq!(apply_discount(1000, DiscountType::Percentage, 99), 10);
1045 }
1046
1047 #[test]
1048 fn percentage_discount_rounding() {
1049 // 1 cent * 50 / 100 = 0 (integer division), result = 1
1050 assert_eq!(apply_discount(1, DiscountType::Percentage, 50), 1);
1051 // 3 * 33 / 100 = 0 (integer), result = 3
1052 assert_eq!(apply_discount(3, DiscountType::Percentage, 33), 3);
1053 // 199 * 50 / 100 = 99, result = 100
1054 assert_eq!(apply_discount(199, DiscountType::Percentage, 50), 100);
1055 }
1056
1057 // Fixed discount edge cases
1058
1059 #[test]
1060 fn fixed_discount_exact_price() {
1061 assert_eq!(apply_discount(500, DiscountType::Fixed, 500), 0);
1062 }
1063
1064 #[test]
1065 fn fixed_discount_zero_value() {
1066 assert_eq!(apply_discount(1000, DiscountType::Fixed, 0), 1000);
1067 }
1068
1069 #[test]
1070 fn fixed_discount_one_cent() {
1071 assert_eq!(apply_discount(1000, DiscountType::Fixed, 1), 999);
1072 }
1073
1074 // Zero price
1075
1076 #[test]
1077 fn zero_price_percentage() {
1078 assert_eq!(apply_discount(0, DiscountType::Percentage, 50), 0);
1079 }
1080
1081 #[test]
1082 fn zero_price_fixed() {
1083 assert_eq!(apply_discount(0, DiscountType::Fixed, 100), 0);
1084 }
1085
1086 // Negative values (defensive)
1087
1088 #[test]
1089 fn negative_discount_value_percentage() {
1090 // Negative discount values are clamped to 0, so price is unchanged
1091 assert_eq!(apply_discount(1000, DiscountType::Percentage, -50), 1000);
1092 }
1093
1094 #[test]
1095 fn negative_discount_value_fixed() {
1096 // Negative discount values are clamped to 0, so price is unchanged
1097 assert_eq!(apply_discount(1000, DiscountType::Fixed, -500), 1000);
1098 }
1099
1100 #[test]
1101 fn negative_price_percentage() {
1102 // Negative price with percentage discount, documents current behavior
1103 // -1000 * 50 / 100 = -500, -1000 - (-500) = -500, max(0) = 0
1104 assert_eq!(apply_discount(-1000, DiscountType::Percentage, 50), 0);
1105 }
1106
1107 #[test]
1108 fn negative_price_fixed() {
1109 // -1000 - 500 = -1500, max(0) = 0
1110 assert_eq!(apply_discount(-1000, DiscountType::Fixed, 500), 0);
1111 }
1112
1113 // Large values (overflow safety)
1114
1115 #[test]
1116 fn large_price_percentage_no_overflow() {
1117 // The function uses i64 intermediate to avoid overflow
1118 // i32::MAX = 2_147_483_647; 50% of that
1119 let price = i32::MAX;
1120 let result = apply_discount(price, DiscountType::Percentage, 50);
1121 assert_eq!(result, 1_073_741_824); // (MAX - MAX*50/100)
1122 }
1123
1124 // ── Adversarial (test-fuzz) ──
1125
1126 #[test]
1127 fn adversarial_percentage_max_price_max_percentage() {
1128 // i32::MAX price with 100% discount
1129 let result = apply_discount(i32::MAX, DiscountType::Percentage, 100);
1130 assert_eq!(result, 0, "100% discount on any price should be 0");
1131 }
1132
1133 #[test]
1134 fn adversarial_percentage_max_price_99_percent() {
1135 let result = apply_discount(i32::MAX, DiscountType::Percentage, 99);
1136 // i32::MAX * 99 / 100 via i64 = 2_125_999_810, remainder = 21_483_837
1137 // Exact: 2_147_483_647 * 99 = 212_600_881_053 / 100 = 2_126_008_810
1138 // 2_147_483_647 - 2_126_008_810 = 21_474_837
1139 assert_eq!(result, 21_474_837);
1140 assert!(result > 0, "99% discount should leave some remaining");
1141 }
1142
1143 #[test]
1144 fn adversarial_fixed_max_price_max_discount() {
1145 let result = apply_discount(i32::MAX, DiscountType::Fixed, i32::MAX);
1146 assert_eq!(result, 0);
1147 }
1148
1149 #[test]
1150 fn adversarial_both_negative() {
1151 // Both negative price and negative discount
1152 let result = apply_discount(-100, DiscountType::Fixed, -100);
1153 // -100 - (-100) = 0
1154 assert_eq!(result, 0);
1155 }
1156
1157 #[test]
1158 fn adversarial_percentage_discount_exactly_50_odd_price() {
1159 // Rounding: 1 cent * 50% = 0 (integer division), so result = 1
1160 assert_eq!(apply_discount(1, DiscountType::Percentage, 50), 1);
1161 // 3 cents * 50% = 1 (via i64: 3*50/100=1), result = 2
1162 assert_eq!(apply_discount(3, DiscountType::Percentage, 50), 2);
1163 }
1164
1165 #[test]
1166 fn adversarial_apply_discount_invariant() {
1167 // For any valid (positive) price and percentage 0-100,
1168 // result should be in [0, price]
1169 for price in [1, 50, 100, 999, 10000, 1_000_000] {
1170 for pct in [0, 1, 10, 25, 33, 50, 75, 99, 100] {
1171 let result = apply_discount(price, DiscountType::Percentage, pct);
1172 assert!(
1173 result >= 0 && result <= price,
1174 "Invariant violated: price={price}, pct={pct}, result={result}"
1175 );
1176 }
1177 }
1178 }
1179
1180 #[test]
1181 fn adversarial_fixed_discount_invariant() {
1182 // For any positive price and positive discount, result should be in [0, price]
1183 for price in [1, 50, 100, 999, 10000] {
1184 for discount in [0, 1, 50, 100, 999, 10000, 999_999] {
1185 let result = apply_discount(price, DiscountType::Fixed, discount);
1186 assert!(
1187 result >= 0 && result <= price,
1188 "Invariant violated: price={price}, discount={discount}, result={result}"
1189 );
1190 }
1191 }
1192 }
1193
1194 // ── Property-based tests (proptest) ──
1195
1196 proptest::proptest! {
1197 #[test]
1198 fn prop_percentage_discount_in_range(price in 0..=1_000_000i32, pct in 0..=100i32) {
1199 let result = apply_discount(price, DiscountType::Percentage, pct);
1200 proptest::prop_assert!(result >= 0, "Result {} should be >= 0", result);
1201 proptest::prop_assert!(result <= price, "Result {} should be <= price {}", result, price);
1202 }
1203
1204 #[test]
1205 fn prop_fixed_discount_in_range(price in 0..=1_000_000i32, discount in 0..=1_000_000i32) {
1206 let result = apply_discount(price, DiscountType::Fixed, discount);
1207 proptest::prop_assert!(result >= 0, "Result {} should be >= 0", result);
1208 proptest::prop_assert!(result <= price, "Result {} should be <= price {}", result, price);
1209 }
1210
1211 #[test]
1212 fn prop_100_percent_discount_is_zero(price in 0..=1_000_000i32) {
1213 proptest::prop_assert_eq!(apply_discount(price, DiscountType::Percentage, 100), 0);
1214 }
1215
1216 #[test]
1217 fn prop_0_percent_discount_is_identity(price in 0..=1_000_000i32) {
1218 proptest::prop_assert_eq!(apply_discount(price, DiscountType::Percentage, 0), price);
1219 }
1220 }
1221
1222 // ── Metamorphic: applying a discount and removing it again ───────────────
1223 //
1224 // Wiki `testing-posture`, phase 2. A metamorphic relation states how two
1225 // runs relate rather than what either returns, so it needs no table of
1226 // expected values and nothing has to be recomputed by hand when a price
1227 // changes. See Chen et al. 1998.
1228 //
1229 // The relation asked for was "apply then remove is the identity". It is,
1230 // for Fixed, and it is not for Percentage, because integer cents rounding
1231 // is not invertible. What replaces it is not a tolerance: the loss is an
1232 // exact quantity and the tests below pin it as one.
1233
1234 proptest::proptest! {
1235 /// Fixed is invertible wherever it does not clamp: the discount is a
1236 /// subtraction, and adding it back is the inverse of subtracting it.
1237 #[test]
1238 fn prop_removing_a_fixed_discount_restores_the_price_exactly(
1239 price in 0..=1_000_000i32,
1240 discount in 0..=1_000_000i32,
1241 ) {
1242 proptest::prop_assume!(discount <= price);
1243 let discounted = apply_discount(price, DiscountType::Fixed, discount);
1244 proptest::prop_assert_eq!(discounted + discount, price);
1245 }
1246
1247 /// Above the price it is not invertible, and that is the intended
1248 /// behaviour rather than a gap: the clamp to zero is what stops a
1249 /// generous coupon paying the buyer. Every price at or below the
1250 /// discount collapses to the same 0, so no inverse can tell them apart.
1251 #[test]
1252 fn prop_a_fixed_discount_over_the_price_destroys_it(
1253 price in 0..=1_000_000i32,
1254 excess in 0..=1_000_000i32,
1255 ) {
1256 let discount = price.saturating_add(excess);
1257 proptest::prop_assert_eq!(apply_discount(price, DiscountType::Fixed, discount), 0);
1258 }
1259
1260 /// What a percentage discount loses, stated exactly.
1261 ///
1262 /// `apply_discount` computes `price - (price * pct) / 100` with integer
1263 /// division, so writing `price * pct = 100q + r` with `0 <= r < 100`
1264 /// gives `discounted * 100 = price * (100 - pct) + r`. The remainder `r`
1265 /// is the whole of the round-trip loss and it is bounded by 100
1266 /// regardless of how large the price is.
1267 ///
1268 /// That identity is the tolerance the task asked to have pinned, and it
1269 /// is worth having as an equation rather than an epsilon: the error does
1270 /// not grow with the price, so a $10,000 sale is no less recoverable
1271 /// than a $1 one.
1272 #[test]
1273 fn prop_a_percentage_discount_loses_exactly_the_rounding_remainder(
1274 price in 0..=1_000_000i32,
1275 pct in 0..=100i32,
1276 ) {
1277 let discounted = apply_discount(price, DiscountType::Percentage, pct);
1278 let remainder = i64::from(discounted) * 100 - i64::from(price) * i64::from(100 - pct);
1279 proptest::prop_assert!(
1280 (0..100).contains(&remainder),
1281 "price={} pct={} discounted={} left remainder {}, outside [0, 100)",
1282 price, pct, discounted, remainder,
1283 );
1284 proptest::prop_assert_eq!(
1285 remainder,
1286 (i64::from(price) * i64::from(pct)) % 100,
1287 "the remainder is not the one integer division dropped",
1288 );
1289 }
1290
1291 /// So removal is exact exactly when nothing was dropped, which is when
1292 /// 100 divides `price * pct`. Constructing such a price is the point:
1293 /// this is the half of the original relation that does survive.
1294 #[test]
1295 fn prop_removing_a_percentage_discount_is_exact_when_it_divides_evenly(
1296 hundreds in 0..=10_000i32,
1297 pct in 0..=99i32,
1298 ) {
1299 let price = hundreds * 100;
1300 let discounted = apply_discount(price, DiscountType::Percentage, pct);
1301 // No remainder, so the inverse is the plain rational one.
1302 proptest::prop_assert_eq!(
1303 i64::from(discounted) * 100 / i64::from(100 - pct),
1304 i64::from(price),
1305 );
1306 }
1307 }
1308
1309 /// The one case where the relation cannot hold however the price is chosen.
1310 /// A full discount maps every price to 0, so removal has nothing to work
1311 /// from. Worth a named test rather than an `prop_assume!` that quietly skips
1312 /// it, since "free" is a real configuration and not an edge.
1313 #[test]
1314 fn removing_a_full_discount_is_impossible_by_construction() {
1315 for price in [0, 1, 99, 100, 101, 999, 1_000_000] {
1316 assert_eq!(apply_discount(price, DiscountType::Percentage, 100), 0);
1317 }
1318 }
1319
1320 // Cart promo semantics: one redemption = one use (ultra-fuzz Run 10 Pay S1)
1321
1322 /// Build a percentage-discount promo with no scope/min-price gating.
1323 fn unscoped_discount_promo(max_uses: Option<i32>) -> ValidatedPromo {
1324 ValidatedPromo {
1325 code: DbPromoCode {
1326 id: PromoCodeId::new(),
1327 creator_id: UserId::new(),
1328 code: "SAVE10".to_string(),
1329 code_purpose: CodePurpose::Discount,
1330 discount_type: Some(DiscountType::Percentage),
1331 discount_value: Some(10),
1332 min_price_cents: 0,
1333 trial_days: None,
1334 item_id: None,
1335 project_id: None,
1336 tier_id: None,
1337 max_uses,
1338 use_count: 0,
1339 expires_at: None,
1340 starts_at: None,
1341 created_at: chrono::Utc::now(),
1342 is_platform_wide: false,
1343 },
1344 is_platform_wide: false,
1345 }
1346 }
1347
1348 #[test]
1349 fn single_use_code_discounts_every_eligible_cart_line() {
1350 // A max_uses=1 code applied across a multi-item cart discounts EVERY
1351 // eligible line. This is intentional: the handler reserves exactly one
1352 // use per cart checkout (one redemption = one use), so the per-line
1353 // discounting below is not a use-count leak. Pin it so a future change
1354 // can't silently turn cart promos into per-line reservation.
1355 let promo = unscoped_discount_promo(Some(1));
1356 for base in [1000, 2000, 4999] {
1357 let result =
1358 apply_promo_to_item(&promo, ItemId::new(), ProjectId::new(), base).unwrap();
1359 let PromoApplication::Apply(applied) = result else {
1360 panic!("expected Apply for an eligible cart line at base {base}");
1361 };
1362 assert_eq!(applied.price_cents, base - base / 10);
1363 // A seller-scoped code is creator-funded, no platform reimbursement.
1364 assert_eq!(applied.funding, DiscountFunding::CreatorFunded);
1365 }
1366 // apply_promo_to_item never touches use_count; reservation is the
1367 // handler's once-per-checkout concern.
1368 assert_eq!(promo.code.use_count, 0);
1369 }
1370
1371 // Platform credit is a spend-once balance (ultra-fuzz Run 13 Payments)
1372
1373 /// Build a platform-wide fixed credit (the $5 Fan+ renewal credit shape).
1374 fn platform_fixed_credit(cents: i32) -> ValidatedPromo {
1375 ValidatedPromo {
1376 code: DbPromoCode {
1377 id: PromoCodeId::new(),
1378 creator_id: UserId::new(),
1379 code: "FANPLUS".to_string(),
1380 code_purpose: CodePurpose::Discount,
1381 discount_type: Some(DiscountType::Fixed),
1382 discount_value: Some(cents),
1383 min_price_cents: 0,
1384 trial_days: None,
1385 item_id: None,
1386 project_id: None,
1387 tier_id: None,
1388 max_uses: None,
1389 use_count: 0,
1390 expires_at: None,
1391 starts_at: None,
1392 created_at: chrono::Utc::now(),
1393 is_platform_wide: true,
1394 },
1395 is_platform_wide: true,
1396 }
1397 }
1398
1399 #[test]
1400 fn platform_fixed_credit_budget_is_face_value() {
1401 assert_eq!(
1402 platform_fixed_credit(500).platform_credit_budget_cents(),
1403 Some(500)
1404 );
1405 }
1406
1407 #[test]
1408 fn seller_and_percentage_codes_have_no_credit_budget() {
1409 // Seller-funded code: credit is always 0, no balance to cap.
1410 assert_eq!(
1411 unscoped_discount_promo(None).platform_credit_budget_cents(),
1412 None
1413 );
1414 // Platform-wide *percentage*: an intentional platform-funded sale that
1415 // legitimately applies to every line, not a spend-once balance.
1416 let mut pct = platform_fixed_credit(500);
1417 pct.code.discount_type = Some(DiscountType::Percentage);
1418 pct.code.discount_value = Some(20);
1419 assert_eq!(pct.platform_credit_budget_cents(), None);
1420 }
1421
1422 #[test]
1423 fn platform_fixed_credit_spent_once_across_cart() {
1424 // The $5 (500¢) Fan+ credit across three $10 (1000¢) lines must discount
1425 // the buyer and reimburse the seller a total of exactly 500¢, once, not
1426 // 500¢ per line (Run 13 SERIOUS: cart platform-credit multiplication).
1427 let promo = platform_fixed_credit(500);
1428 let mut budget = promo.platform_credit_budget_cents();
1429 assert_eq!(budget, Some(500));
1430
1431 let mut total_credit = 0i64;
1432 let mut total_buyer_paid = 0i64;
1433 for _ in 0..3 {
1434 let PromoApplication::Apply(applied) =
1435 apply_promo_to_item(&promo, ItemId::new(), ProjectId::new(), 1000).unwrap()
1436 else {
1437 panic!("expected Apply for an eligible platform-credit line");
1438 };
1439 let (final_price, credit) = cap_line_to_credit_budget(applied, &mut budget);
1440 total_credit += credit;
1441 total_buyer_paid += i64::from(final_price);
1442 }
1443 assert_eq!(
1444 total_credit, 500,
1445 "MNW reimburses the seller exactly the face value, once"
1446 );
1447 assert_eq!(
1448 total_buyer_paid,
1449 3000 - 500,
1450 "buyer gets the $5 credit exactly once"
1451 );
1452 assert_eq!(budget, Some(0), "balance fully spent");
1453 }
1454
1455 #[test]
1456 fn platform_fixed_credit_carries_balance_across_cheap_lines() {
1457 // A $5 credit on two $1 (100¢) items spends 100 then 100 (the balance
1458 // carries instead of burning the whole $5 on the first line); 300¢ remain.
1459 let promo = platform_fixed_credit(500);
1460 let mut budget = promo.platform_credit_budget_cents();
1461 let mut total_credit = 0i64;
1462 for _ in 0..2 {
1463 let PromoApplication::Apply(applied) =
1464 apply_promo_to_item(&promo, ItemId::new(), ProjectId::new(), 100).unwrap()
1465 else {
1466 panic!("expected Apply");
1467 };
1468 let (final_price, credit) = cap_line_to_credit_budget(applied, &mut budget);
1469 assert_eq!(final_price, 0, "a $1 item is fully covered by the credit");
1470 total_credit += credit;
1471 }
1472 assert_eq!(total_credit, 200);
1473 assert_eq!(
1474 budget,
1475 Some(300),
1476 "unspent balance carries to the rest of the cart"
1477 );
1478 }
1479 }
1480