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