v0.2.0:20 — Multi-policy scope for discount codes

A discount code can now apply to a subset of policies on a product
(e.g. "Patron and Pro but not Creator") instead of being limited to
exactly one policy or the entire product.

- Migration 0018 adds `applies_to_policy_ids_json` (nullable JSON array
  of policy ids). Legacy `applies_to_policy_id` stays as the singular
  fallback when the JSON column is empty/NULL.
- `DiscountCode::allowed_policy_ids()` helper unifies multi + singular
  into one Vec. Purchase + preview scope checks consult it.
- `find_applicable_featured_discount` now narrows multi-policy
  candidates in Rust (small candidate set; index-friendly SQL would
  require json_each, deferred).
- Admin API: `POST /v1/admin/discount-codes` accepts `policy_slugs`
  (array) alongside the existing `policy_slug` (singular). Multi wins
  when both are present. PATCH does not allow scope edits — same rule
  as the singular field (disable + recreate to re-scope).
- UI: pill multi-select replaces the policy dropdown on the create
  form. Edit modal's scope label renders the comma-separated list.

UI + schema both back-compat: existing codes keep working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Grant
2026-05-11 14:01:51 -05:00
parent eb360a325e
commit 094cf75e52
7 changed files with 306 additions and 77 deletions
+60 -17
View File
@@ -40,8 +40,15 @@ pub struct CreateDiscountCodeReq {
pub product_slug: Option<String>,
/// Restrict to a single policy (by slug + product_slug). Omit for any policy.
/// Requires `product_slug` to be set if specified.
/// Superseded by `policy_slugs` when both are present.
#[serde(default)]
pub policy_slug: Option<String>,
/// Restrict to multiple policies (by slugs + product_slug). Omit
/// or pass an empty list for "any policy on the product". Requires
/// `product_slug` to be set if specified. Takes precedence over
/// `policy_slug` when both are provided.
#[serde(default)]
pub policy_slugs: Option<Vec<String>>,
/// Optional free-form tag for tracking, e.g. 'launch-twitter', 'alice@example.com'.
#[serde(default)]
pub referrer_label: Option<String>,
@@ -75,22 +82,51 @@ pub async fn create(
} else {
None
};
let policy_id = if let Some(slug) = req.policy_slug.as_deref() {
let pid = product_id.as_deref().ok_or_else(|| {
AppError::BadRequest("policy_slug requires product_slug".into())
})?;
let policy = repo::get_policy_by_slug(&state.db, pid, slug)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"policy '{slug}' for product '{}'",
req.product_slug.as_deref().unwrap_or("")
))
// Resolve policy scope. `policy_slugs` (multi) takes precedence over
// `policy_slug` (singular legacy field). Both require `product_slug`.
// Empty `policy_slugs` is treated as "no multi-scope" so the operator
// can clear an existing multi-scope by passing [].
let (policy_id, policy_ids_for_db): (Option<String>, Option<Vec<String>>) =
if let Some(slugs) = req.policy_slugs.as_ref() {
if slugs.is_empty() {
(None, Some(Vec::new()))
} else {
let pid = product_id.as_deref().ok_or_else(|| {
AppError::BadRequest("policy_slugs requires product_slug".into())
})?;
let mut ids = Vec::with_capacity(slugs.len());
for slug in slugs {
let policy = repo::get_policy_by_slug(&state.db, pid, slug)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"policy '{slug}' for product '{}'",
req.product_slug.as_deref().unwrap_or("")
))
})?;
ids.push(policy.id);
}
// For a single-policy choice, also populate the legacy
// singular column so old readers stay coherent.
let singular = if ids.len() == 1 { ids.first().cloned() } else { None };
(singular, Some(ids))
}
} else if let Some(slug) = req.policy_slug.as_deref() {
let pid = product_id.as_deref().ok_or_else(|| {
AppError::BadRequest("policy_slug requires product_slug".into())
})?;
Some(policy.id)
} else {
None
};
let policy = repo::get_policy_by_slug(&state.db, pid, slug)
.await?
.ok_or_else(|| {
AppError::NotFound(format!(
"policy '{slug}' for product '{}'",
req.product_slug.as_deref().unwrap_or("")
))
})?;
(Some(policy.id), None)
} else {
(None, None)
};
// Validate + normalize discount_currency. Accept SAT (default),
// USD, EUR. For 'percent' codes the currency is irrelevant (basis
@@ -121,6 +157,7 @@ pub async fn create(
req.expires_at.as_deref(),
product_id.as_deref(),
policy_id.as_deref(),
policy_ids_for_db,
req.referrer_label.as_deref(),
&req.description,
req.featured,
@@ -239,6 +276,11 @@ pub async fn update(
req.description.as_deref(),
req.referrer_label.as_ref().map(|opt| opt.as_deref()),
req.featured,
// Scope (product/policy) is intentionally not editable — see
// doc-comment on UpdateDiscountCodeReq. Disable + recreate to
// re-scope a code rather than silently invalidating distributed
// links.
None,
)
.await?;
@@ -432,9 +474,10 @@ pub async fn preview(
})));
}
}
if let Some(restricted_pid) = &code.applies_to_policy_id {
let allowed = code.allowed_policy_ids();
if !allowed.is_empty() {
if let Some(chosen) = &chosen_policy {
if restricted_pid != &chosen.id {
if !allowed.iter().any(|p| *p == chosen.id) {
return Ok(Json(json!({
"valid": false,
"reason": "wrong_tier",
+9 -6
View File
@@ -292,13 +292,16 @@ pub async fn start(
));
}
}
// If the code is restricted to a specific policy and a tier was
// selected, they must match. If no tier was selected, the code is
// implicitly applied to the product's default policy at issuance
// time, which we accept here (v0.1.0:27+).
if let Some(restricted_pid) = &code.applies_to_policy_id {
// If the code is restricted to one or more policies and a tier
// was selected, the chosen tier must be in the allowed set.
// `allowed_policy_ids()` unifies the multi-policy column (0018)
// and the legacy singular column. If no tier was selected, the
// code is implicitly applied to the product's default policy at
// issuance time, which we accept here (v0.1.0:27+).
let allowed = code.allowed_policy_ids();
if !allowed.is_empty() {
if let Some(chosen) = &chosen_policy {
if restricted_pid != &chosen.id {
if !allowed.iter().any(|p| *p == chosen.id) {
return Err(AppError::BadRequest(
"discount code does not apply to the selected tier".into(),
));
+77 -30
View File
@@ -2071,6 +2071,15 @@ fn row_to_discount_code(row: sqlx::sqlite::SqliteRow) -> DiscountCode {
.try_get::<i64, _>("featured")
.map(|v| v != 0)
.unwrap_or(false);
// Multi-policy scope JSON (0018). Parse to Vec<String>; non-array
// / malformed JSON / pre-0018 column → empty Vec (caller falls back
// to the singular `applies_to_policy_id`).
let applies_to_policy_ids: Vec<String> = row
.try_get::<Option<String>, _>("applies_to_policy_ids_json")
.ok()
.flatten()
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.unwrap_or_default();
DiscountCode {
id: row.get("id"),
code: row.get("code"),
@@ -2081,6 +2090,7 @@ fn row_to_discount_code(row: sqlx::sqlite::SqliteRow) -> DiscountCode {
expires_at: row.get("expires_at"),
applies_to_product_id: row.get("applies_to_product_id"),
applies_to_policy_id: row.get("applies_to_policy_id"),
applies_to_policy_ids,
referrer_label: row.get("referrer_label"),
description: row.get("description"),
active: row.get::<i64, _>("active") != 0,
@@ -2128,6 +2138,7 @@ pub async fn create_discount_code(
expires_at,
applies_to_product_id,
applies_to_policy_id,
None, // back-compat: legacy single-policy callers can't multi-scope
referrer_label,
description,
false, // not featured by default — backwards-compat for callers
@@ -2155,6 +2166,7 @@ pub async fn create_discount_code_with_currency(
expires_at: Option<&str>,
applies_to_product_id: Option<&str>,
applies_to_policy_id: Option<&str>,
applies_to_policy_ids: Option<Vec<String>>,
referrer_label: Option<&str>,
description: &str,
featured: bool,
@@ -2209,12 +2221,17 @@ pub async fn create_discount_code_with_currency(
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let stored_amount = if kind == "free_license" { 0 } else { amount };
// Persist multi-policy scope as a JSON array. None / empty Vec →
// NULL, so reads fall back to the singular `applies_to_policy_id`.
let policy_ids_json: Option<String> = applies_to_policy_ids
.filter(|v| !v.is_empty())
.map(|v| serde_json::to_string(&v).unwrap_or_else(|_| "[]".to_string()));
sqlx::query(
"INSERT INTO discount_codes
(id, code, kind, amount, discount_currency, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label,
applies_to_product_id, applies_to_policy_id, applies_to_policy_ids_json, referrer_label,
description, active, featured, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1, ?, ?, ?)",
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)",
)
.bind(&id)
.bind(&normalized)
@@ -2225,6 +2242,7 @@ pub async fn create_discount_code_with_currency(
.bind(expires_at)
.bind(applies_to_product_id)
.bind(applies_to_policy_id)
.bind(policy_ids_json)
.bind(referrer_label)
.bind(description)
.bind(featured as i64)
@@ -2249,7 +2267,7 @@ pub async fn get_discount_code_by_id(
) -> AppResult<Option<DiscountCode>> {
let row = sqlx::query(
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label,
applies_to_product_id, applies_to_policy_id, applies_to_policy_ids_json, referrer_label,
description, active, featured, created_at, updated_at
FROM discount_codes WHERE id = ?",
)
@@ -2266,7 +2284,7 @@ pub async fn get_discount_code_by_code(
let normalized = code.trim().to_uppercase();
let row = sqlx::query(
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label,
applies_to_product_id, applies_to_policy_id, applies_to_policy_ids_json, referrer_label,
description, active, featured, created_at, updated_at
FROM discount_codes WHERE code = ?",
)
@@ -2282,12 +2300,12 @@ pub async fn list_discount_codes(
) -> AppResult<Vec<DiscountCode>> {
let q = if only_active {
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label,
applies_to_product_id, applies_to_policy_id, applies_to_policy_ids_json, referrer_label,
description, active, featured, created_at, updated_at
FROM discount_codes WHERE active = 1 ORDER BY created_at DESC"
} else {
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label,
applies_to_product_id, applies_to_policy_id, applies_to_policy_ids_json, referrer_label,
description, active, featured, created_at, updated_at
FROM discount_codes ORDER BY created_at DESC"
};
@@ -2312,41 +2330,55 @@ pub async fn find_applicable_featured_discount(
policy_id: &str,
) -> AppResult<Option<DiscountCode>> {
let now = Utc::now().to_rfc3339();
// The SQL filters by featured + active + not-expired +
// remaining-uses, scopes to either the policy, the product, or
// global, and orders by specificity (policy match first) then
// created_at ascending. LIMIT 1 — we only need the winner.
let row = sqlx::query(
// Fetch all candidate featured codes that could possibly apply
// (correct product, or product-wide, or global). Multi-policy scope
// narrowing happens in Rust via DiscountCode::allowed_policy_ids()
// because the multi-policy JSON column isn't index-friendly. The
// candidate set is small (active featured codes only), so this is
// cheap.
let rows = sqlx::query(
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
applies_to_product_id, applies_to_policy_id, referrer_label,
applies_to_product_id, applies_to_policy_id, applies_to_policy_ids_json, referrer_label,
description, active, featured, created_at, updated_at
FROM discount_codes
WHERE featured = 1
AND active = 1
AND (expires_at IS NULL OR expires_at > ?)
AND (max_uses IS NULL OR used_count < max_uses)
AND (
applies_to_policy_id = ?
OR (applies_to_policy_id IS NULL AND applies_to_product_id = ?)
OR (applies_to_policy_id IS NULL AND applies_to_product_id IS NULL)
)
ORDER BY
CASE
WHEN applies_to_policy_id = ? THEN 0
WHEN applies_to_product_id = ? THEN 1
ELSE 2
END,
created_at ASC
LIMIT 1",
AND (applies_to_product_id = ? OR applies_to_product_id IS NULL)
ORDER BY created_at ASC",
)
.bind(&now)
.bind(policy_id)
.bind(product_id)
.bind(policy_id)
.bind(product_id)
.fetch_optional(pool)
.fetch_all(pool)
.await?;
Ok(row.map(row_to_discount_code))
// Score each candidate. Lower score = higher precedence:
// 0 = code names this policy explicitly (singular or in multi list)
// 1 = code is product-scoped (no policy restriction)
// 2 = code is global (no product, no policy)
// Within a tier, first-created wins (operator-set priority).
let mut best: Option<(u8, DiscountCode)> = None;
for row in rows {
let code = row_to_discount_code(row);
let allowed = code.allowed_policy_ids();
let score: u8 = if !allowed.is_empty() {
if allowed.iter().any(|p| *p == policy_id) {
0
} else {
continue; // code restricts to other policies; skip
}
} else if code.applies_to_product_id.is_some() {
1
} else {
2
};
match &best {
None => best = Some((score, code)),
Some((prev_score, _)) if score < *prev_score => best = Some((score, code)),
_ => {}
}
}
Ok(best.map(|(_, c)| c))
}
pub async fn set_discount_code_active(
@@ -2383,6 +2415,9 @@ pub async fn update_discount_code(
description: Option<&str>,
referrer_label: Option<Option<&str>>,
featured: Option<bool>,
// applies_to_policy_ids: None = no change, Some(vec) = overwrite
// (empty vec clears the column, falling back to singular column).
applies_to_policy_ids: Option<Vec<String>>,
) -> AppResult<DiscountCode> {
// Re-fetch to validate amount against the existing kind.
let existing = get_discount_code_by_id(pool, id)
@@ -2446,6 +2481,9 @@ pub async fn update_discount_code(
if featured.is_some() {
sets.push("featured = ?");
}
if applies_to_policy_ids.is_some() {
sets.push("applies_to_policy_ids_json = ?");
}
if sets.is_empty() {
return Ok(existing);
}
@@ -2474,6 +2512,15 @@ pub async fn update_discount_code(
if let Some(f) = featured {
q = q.bind(f as i64);
}
if let Some(ids) = applies_to_policy_ids {
// Empty list → store NULL (clear multi-scope). Non-empty → JSON.
let stored: Option<String> = if ids.is_empty() {
None
} else {
serde_json::to_string(&ids).ok()
};
q = q.bind(stored);
}
q = q.bind(&now).bind(id);
q.execute(pool).await?;
+27
View File
@@ -321,6 +321,12 @@ pub struct DiscountCode {
pub expires_at: Option<String>,
pub applies_to_product_id: Option<String>,
pub applies_to_policy_id: Option<String>,
/// Multi-policy scope (migration 0018). When non-empty, the code
/// applies only to policies in this list — the legacy singular
/// `applies_to_policy_id` is ignored. When empty, behavior falls
/// back to the singular column.
#[serde(default)]
pub applies_to_policy_ids: Vec<String>,
pub referrer_label: Option<String>,
pub description: String,
pub active: bool,
@@ -335,6 +341,27 @@ pub struct DiscountCode {
pub updated_at: String,
}
impl DiscountCode {
/// Effective allowed-policy set. Empty = no policy restriction (the
/// code applies to any policy in the product/global scope). Non-
/// empty = the buyer's chosen policy id must be in this list.
///
/// Multi-policy column (0018) takes precedence over the legacy
/// singular column; if both are absent the result is empty.
pub fn allowed_policy_ids(&self) -> Vec<&str> {
if !self.applies_to_policy_ids.is_empty() {
self.applies_to_policy_ids
.iter()
.map(|s| s.as_str())
.collect()
} else if let Some(pid) = self.applies_to_policy_id.as_deref() {
vec![pid]
} else {
Vec::new()
}
}
}
/// One row per (code, invoice) pair. Status transitions:
/// pending → redeemed (invoice settled, license issued)
/// pending → cancelled (invoice expired or invalidated)