Skip to main content

max / makenotwork

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