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",