Skip to main content

max / makenotwork

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