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:
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user