Skip to main content

max / makenotwork

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