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:
@@ -0,0 +1,30 @@
|
|||||||
|
-- Migration 0018: multi-policy scope for discount codes
|
||||||
|
--
|
||||||
|
-- Until now, a discount code could be scoped to exactly one policy
|
||||||
|
-- (`applies_to_policy_id`) or "any policy on this product" / global.
|
||||||
|
-- That left a real gap: "this code is good for Patron OR Pro but not
|
||||||
|
-- Creator" required creating two distinct codes with different code
|
||||||
|
-- strings, which is operationally messy.
|
||||||
|
--
|
||||||
|
-- New column `applies_to_policy_ids_json`: a JSON-encoded array of
|
||||||
|
-- policy ids (TEXT uuids). Semantics:
|
||||||
|
--
|
||||||
|
-- NULL or '[]' → no multi-policy restriction. Fall back to the legacy
|
||||||
|
-- `applies_to_policy_id` (single-policy scope) or, if
|
||||||
|
-- that's also NULL, scope follows `applies_to_product_id`
|
||||||
|
-- (or global if that's NULL too). Identical behavior
|
||||||
|
-- to v0.2.0:19 and earlier.
|
||||||
|
--
|
||||||
|
-- '["id1","id2",...]' → the code applies if and only if the buyer's
|
||||||
|
-- chosen policy is in this array. Takes
|
||||||
|
-- precedence over `applies_to_policy_id` (the
|
||||||
|
-- legacy singular column is ignored when this
|
||||||
|
-- is non-empty).
|
||||||
|
--
|
||||||
|
-- Migration is purely additive. Existing rows have the new column NULL,
|
||||||
|
-- so all v0.2.0:19-and-earlier discount codes keep working unchanged.
|
||||||
|
-- New codes written via the admin API populate this column when the
|
||||||
|
-- operator picks 2+ policies; single-policy codes continue to write to
|
||||||
|
-- `applies_to_policy_id` for clarity.
|
||||||
|
|
||||||
|
ALTER TABLE discount_codes ADD COLUMN applies_to_policy_ids_json TEXT NULL;
|
||||||
@@ -40,8 +40,15 @@ pub struct CreateDiscountCodeReq {
|
|||||||
pub product_slug: Option<String>,
|
pub product_slug: Option<String>,
|
||||||
/// Restrict to a single policy (by slug + product_slug). Omit for any policy.
|
/// Restrict to a single policy (by slug + product_slug). Omit for any policy.
|
||||||
/// Requires `product_slug` to be set if specified.
|
/// Requires `product_slug` to be set if specified.
|
||||||
|
/// Superseded by `policy_slugs` when both are present.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub policy_slug: Option<String>,
|
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'.
|
/// Optional free-form tag for tracking, e.g. 'launch-twitter', 'alice@example.com'.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub referrer_label: Option<String>,
|
pub referrer_label: Option<String>,
|
||||||
@@ -75,22 +82,51 @@ pub async fn create(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let policy_id = if let Some(slug) = req.policy_slug.as_deref() {
|
// Resolve policy scope. `policy_slugs` (multi) takes precedence over
|
||||||
let pid = product_id.as_deref().ok_or_else(|| {
|
// `policy_slug` (singular legacy field). Both require `product_slug`.
|
||||||
AppError::BadRequest("policy_slug requires product_slug".into())
|
// Empty `policy_slugs` is treated as "no multi-scope" so the operator
|
||||||
})?;
|
// can clear an existing multi-scope by passing [].
|
||||||
let policy = repo::get_policy_by_slug(&state.db, pid, slug)
|
let (policy_id, policy_ids_for_db): (Option<String>, Option<Vec<String>>) =
|
||||||
.await?
|
if let Some(slugs) = req.policy_slugs.as_ref() {
|
||||||
.ok_or_else(|| {
|
if slugs.is_empty() {
|
||||||
AppError::NotFound(format!(
|
(None, Some(Vec::new()))
|
||||||
"policy '{slug}' for product '{}'",
|
} else {
|
||||||
req.product_slug.as_deref().unwrap_or("")
|
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)
|
let policy = repo::get_policy_by_slug(&state.db, pid, slug)
|
||||||
} else {
|
.await?
|
||||||
None
|
.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),
|
// Validate + normalize discount_currency. Accept SAT (default),
|
||||||
// USD, EUR. For 'percent' codes the currency is irrelevant (basis
|
// USD, EUR. For 'percent' codes the currency is irrelevant (basis
|
||||||
@@ -121,6 +157,7 @@ pub async fn create(
|
|||||||
req.expires_at.as_deref(),
|
req.expires_at.as_deref(),
|
||||||
product_id.as_deref(),
|
product_id.as_deref(),
|
||||||
policy_id.as_deref(),
|
policy_id.as_deref(),
|
||||||
|
policy_ids_for_db,
|
||||||
req.referrer_label.as_deref(),
|
req.referrer_label.as_deref(),
|
||||||
&req.description,
|
&req.description,
|
||||||
req.featured,
|
req.featured,
|
||||||
@@ -239,6 +276,11 @@ pub async fn update(
|
|||||||
req.description.as_deref(),
|
req.description.as_deref(),
|
||||||
req.referrer_label.as_ref().map(|opt| opt.as_deref()),
|
req.referrer_label.as_ref().map(|opt| opt.as_deref()),
|
||||||
req.featured,
|
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?;
|
.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 let Some(chosen) = &chosen_policy {
|
||||||
if restricted_pid != &chosen.id {
|
if !allowed.iter().any(|p| *p == chosen.id) {
|
||||||
return Ok(Json(json!({
|
return Ok(Json(json!({
|
||||||
"valid": false,
|
"valid": false,
|
||||||
"reason": "wrong_tier",
|
"reason": "wrong_tier",
|
||||||
|
|||||||
@@ -292,13 +292,16 @@ pub async fn start(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If the code is restricted to a specific policy and a tier was
|
// If the code is restricted to one or more policies and a tier
|
||||||
// selected, they must match. If no tier was selected, the code is
|
// was selected, the chosen tier must be in the allowed set.
|
||||||
// implicitly applied to the product's default policy at issuance
|
// `allowed_policy_ids()` unifies the multi-policy column (0018)
|
||||||
// time, which we accept here (v0.1.0:27+).
|
// and the legacy singular column. If no tier was selected, the
|
||||||
if let Some(restricted_pid) = &code.applies_to_policy_id {
|
// 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 let Some(chosen) = &chosen_policy {
|
||||||
if restricted_pid != &chosen.id {
|
if !allowed.iter().any(|p| *p == chosen.id) {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"discount code does not apply to the selected tier".into(),
|
"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")
|
.try_get::<i64, _>("featured")
|
||||||
.map(|v| v != 0)
|
.map(|v| v != 0)
|
||||||
.unwrap_or(false);
|
.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 {
|
DiscountCode {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
code: row.get("code"),
|
code: row.get("code"),
|
||||||
@@ -2081,6 +2090,7 @@ fn row_to_discount_code(row: sqlx::sqlite::SqliteRow) -> DiscountCode {
|
|||||||
expires_at: row.get("expires_at"),
|
expires_at: row.get("expires_at"),
|
||||||
applies_to_product_id: row.get("applies_to_product_id"),
|
applies_to_product_id: row.get("applies_to_product_id"),
|
||||||
applies_to_policy_id: row.get("applies_to_policy_id"),
|
applies_to_policy_id: row.get("applies_to_policy_id"),
|
||||||
|
applies_to_policy_ids,
|
||||||
referrer_label: row.get("referrer_label"),
|
referrer_label: row.get("referrer_label"),
|
||||||
description: row.get("description"),
|
description: row.get("description"),
|
||||||
active: row.get::<i64, _>("active") != 0,
|
active: row.get::<i64, _>("active") != 0,
|
||||||
@@ -2128,6 +2138,7 @@ pub async fn create_discount_code(
|
|||||||
expires_at,
|
expires_at,
|
||||||
applies_to_product_id,
|
applies_to_product_id,
|
||||||
applies_to_policy_id,
|
applies_to_policy_id,
|
||||||
|
None, // back-compat: legacy single-policy callers can't multi-scope
|
||||||
referrer_label,
|
referrer_label,
|
||||||
description,
|
description,
|
||||||
false, // not featured by default — backwards-compat for callers
|
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>,
|
expires_at: Option<&str>,
|
||||||
applies_to_product_id: Option<&str>,
|
applies_to_product_id: Option<&str>,
|
||||||
applies_to_policy_id: Option<&str>,
|
applies_to_policy_id: Option<&str>,
|
||||||
|
applies_to_policy_ids: Option<Vec<String>>,
|
||||||
referrer_label: Option<&str>,
|
referrer_label: Option<&str>,
|
||||||
description: &str,
|
description: &str,
|
||||||
featured: bool,
|
featured: bool,
|
||||||
@@ -2209,12 +2221,17 @@ pub async fn create_discount_code_with_currency(
|
|||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
let stored_amount = if kind == "free_license" { 0 } else { amount };
|
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(
|
sqlx::query(
|
||||||
"INSERT INTO discount_codes
|
"INSERT INTO discount_codes
|
||||||
(id, code, kind, amount, discount_currency, max_uses, used_count, expires_at,
|
(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)
|
description, active, featured, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&normalized)
|
.bind(&normalized)
|
||||||
@@ -2225,6 +2242,7 @@ pub async fn create_discount_code_with_currency(
|
|||||||
.bind(expires_at)
|
.bind(expires_at)
|
||||||
.bind(applies_to_product_id)
|
.bind(applies_to_product_id)
|
||||||
.bind(applies_to_policy_id)
|
.bind(applies_to_policy_id)
|
||||||
|
.bind(policy_ids_json)
|
||||||
.bind(referrer_label)
|
.bind(referrer_label)
|
||||||
.bind(description)
|
.bind(description)
|
||||||
.bind(featured as i64)
|
.bind(featured as i64)
|
||||||
@@ -2249,7 +2267,7 @@ pub async fn get_discount_code_by_id(
|
|||||||
) -> AppResult<Option<DiscountCode>> {
|
) -> AppResult<Option<DiscountCode>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
|
"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
|
description, active, featured, created_at, updated_at
|
||||||
FROM discount_codes WHERE id = ?",
|
FROM discount_codes WHERE id = ?",
|
||||||
)
|
)
|
||||||
@@ -2266,7 +2284,7 @@ pub async fn get_discount_code_by_code(
|
|||||||
let normalized = code.trim().to_uppercase();
|
let normalized = code.trim().to_uppercase();
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
|
"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
|
description, active, featured, created_at, updated_at
|
||||||
FROM discount_codes WHERE code = ?",
|
FROM discount_codes WHERE code = ?",
|
||||||
)
|
)
|
||||||
@@ -2282,12 +2300,12 @@ pub async fn list_discount_codes(
|
|||||||
) -> AppResult<Vec<DiscountCode>> {
|
) -> AppResult<Vec<DiscountCode>> {
|
||||||
let q = if only_active {
|
let q = if only_active {
|
||||||
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
|
"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
|
description, active, featured, created_at, updated_at
|
||||||
FROM discount_codes WHERE active = 1 ORDER BY created_at DESC"
|
FROM discount_codes WHERE active = 1 ORDER BY created_at DESC"
|
||||||
} else {
|
} else {
|
||||||
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
|
"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
|
description, active, featured, created_at, updated_at
|
||||||
FROM discount_codes ORDER BY created_at DESC"
|
FROM discount_codes ORDER BY created_at DESC"
|
||||||
};
|
};
|
||||||
@@ -2312,41 +2330,55 @@ pub async fn find_applicable_featured_discount(
|
|||||||
policy_id: &str,
|
policy_id: &str,
|
||||||
) -> AppResult<Option<DiscountCode>> {
|
) -> AppResult<Option<DiscountCode>> {
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
// The SQL filters by featured + active + not-expired +
|
// Fetch all candidate featured codes that could possibly apply
|
||||||
// remaining-uses, scopes to either the policy, the product, or
|
// (correct product, or product-wide, or global). Multi-policy scope
|
||||||
// global, and orders by specificity (policy match first) then
|
// narrowing happens in Rust via DiscountCode::allowed_policy_ids()
|
||||||
// created_at ascending. LIMIT 1 — we only need the winner.
|
// because the multi-policy JSON column isn't index-friendly. The
|
||||||
let row = sqlx::query(
|
// 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,
|
"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
|
description, active, featured, created_at, updated_at
|
||||||
FROM discount_codes
|
FROM discount_codes
|
||||||
WHERE featured = 1
|
WHERE featured = 1
|
||||||
AND active = 1
|
AND active = 1
|
||||||
AND (expires_at IS NULL OR expires_at > ?)
|
AND (expires_at IS NULL OR expires_at > ?)
|
||||||
AND (max_uses IS NULL OR used_count < max_uses)
|
AND (max_uses IS NULL OR used_count < max_uses)
|
||||||
AND (
|
AND (applies_to_product_id = ? OR applies_to_product_id IS NULL)
|
||||||
applies_to_policy_id = ?
|
ORDER BY created_at ASC",
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.bind(policy_id)
|
|
||||||
.bind(product_id)
|
.bind(product_id)
|
||||||
.bind(policy_id)
|
.fetch_all(pool)
|
||||||
.bind(product_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
.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(
|
pub async fn set_discount_code_active(
|
||||||
@@ -2383,6 +2415,9 @@ pub async fn update_discount_code(
|
|||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
referrer_label: Option<Option<&str>>,
|
referrer_label: Option<Option<&str>>,
|
||||||
featured: Option<bool>,
|
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> {
|
) -> AppResult<DiscountCode> {
|
||||||
// Re-fetch to validate amount against the existing kind.
|
// Re-fetch to validate amount against the existing kind.
|
||||||
let existing = get_discount_code_by_id(pool, id)
|
let existing = get_discount_code_by_id(pool, id)
|
||||||
@@ -2446,6 +2481,9 @@ pub async fn update_discount_code(
|
|||||||
if featured.is_some() {
|
if featured.is_some() {
|
||||||
sets.push("featured = ?");
|
sets.push("featured = ?");
|
||||||
}
|
}
|
||||||
|
if applies_to_policy_ids.is_some() {
|
||||||
|
sets.push("applies_to_policy_ids_json = ?");
|
||||||
|
}
|
||||||
if sets.is_empty() {
|
if sets.is_empty() {
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
}
|
||||||
@@ -2474,6 +2512,15 @@ pub async fn update_discount_code(
|
|||||||
if let Some(f) = featured {
|
if let Some(f) = featured {
|
||||||
q = q.bind(f as i64);
|
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 = q.bind(&now).bind(id);
|
||||||
q.execute(pool).await?;
|
q.execute(pool).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,12 @@ pub struct DiscountCode {
|
|||||||
pub expires_at: Option<String>,
|
pub expires_at: Option<String>,
|
||||||
pub applies_to_product_id: Option<String>,
|
pub applies_to_product_id: Option<String>,
|
||||||
pub applies_to_policy_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 referrer_label: Option<String>,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
@@ -335,6 +341,27 @@ pub struct DiscountCode {
|
|||||||
pub updated_at: String,
|
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:
|
/// One row per (code, invoice) pair. Status transitions:
|
||||||
/// pending → redeemed (invoice settled, license issued)
|
/// pending → redeemed (invoice settled, license issued)
|
||||||
/// pending → cancelled (invoice expired or invalidated)
|
/// pending → cancelled (invoice expired or invalidated)
|
||||||
|
|||||||
@@ -3757,32 +3757,78 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
const productScopeField = formSelect('product_slug', 'Restrict to product (optional)',
|
const productScopeField = formSelect('product_slug', 'Restrict to product (optional)',
|
||||||
productOptions, { value: '' })
|
productOptions, { value: '' })
|
||||||
|
|
||||||
// Policy picker: populated on-demand when a product is selected.
|
// Policy picker: multi-select pill picker populated on-demand when
|
||||||
// Disabled when "Any product" is selected (a policy-scoped code
|
// a product is selected. Operator picks zero or more policies:
|
||||||
// requires a product scope per the server contract).
|
// - 0 picked = code applies to any policy on the chosen product
|
||||||
const policyScopeField = formSelect('policy_slug', 'Restrict to policy (optional)',
|
// - 1 picked = code is single-policy scoped (mirrors v0.2.0:19)
|
||||||
[{ value: '', label: '— Any policy —' }], { value: '' })
|
// - 2+ picked = code applies to any of the picked policies
|
||||||
const policySel = policyScopeField.querySelector('select')
|
// Hidden when "Any product" is selected — a policy-scoped code
|
||||||
policySel.disabled = true
|
// requires a product scope per the server contract.
|
||||||
|
const policyMultiHost = el('div', {
|
||||||
|
'data-policy-pills': '1',
|
||||||
|
style: 'display:flex; flex-wrap:wrap; gap:6px; min-height:32px; ' +
|
||||||
|
'padding:6px 8px; border:1px solid var(--border-1); border-radius:8px; ' +
|
||||||
|
'background:var(--cream-50);',
|
||||||
|
}, [
|
||||||
|
el('span', { class: 'muted', style: 'font-size:12px; align-self:center' },
|
||||||
|
'Pick a product to choose policies.'),
|
||||||
|
])
|
||||||
|
const policyScopeField = el('div', { class: 'field' }, [
|
||||||
|
el('label', { class: 'lbl' }, 'Restrict to policies (optional)'),
|
||||||
|
policyMultiHost,
|
||||||
|
el('p', { class: 'muted', style: 'margin:4px 0 0; font-size:11.5px; line-height:1.4' },
|
||||||
|
'Click a tier to toggle. Pick 0 for "any policy on this product"; 2+ to scope the code to a specific subset (e.g. Patron AND Pro but not Creator).'),
|
||||||
|
])
|
||||||
|
// Stash the current set as a small reactive structure on the host.
|
||||||
|
// Read by the submit handler via `policyMultiHost._selected`.
|
||||||
|
policyMultiHost._selected = new Set()
|
||||||
|
policyMultiHost._available = []
|
||||||
|
|
||||||
|
function renderPolicyPills() {
|
||||||
|
policyMultiHost.innerHTML = ''
|
||||||
|
if (!policyMultiHost._available.length) {
|
||||||
|
policyMultiHost.appendChild(el('span', {
|
||||||
|
class: 'muted', style: 'font-size:12px; align-self:center',
|
||||||
|
}, 'Pick a product to choose policies.'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
policyMultiHost._available.forEach((p) => {
|
||||||
|
const on = policyMultiHost._selected.has(p.slug)
|
||||||
|
const pill = el('button', {
|
||||||
|
type: 'button',
|
||||||
|
'data-policy-slug': p.slug,
|
||||||
|
style: 'font-size:12px; padding:4px 12px; border-radius:999px; cursor:pointer; ' +
|
||||||
|
'border:1.5px solid ' + (on ? 'var(--gold-700)' : 'var(--border-1)') + '; ' +
|
||||||
|
'background:' + (on ? 'var(--navy-950)' : 'var(--cream-100)') + '; ' +
|
||||||
|
'color:' + (on ? 'var(--gold-500)' : 'var(--ink-700)') + '; ' +
|
||||||
|
'font-weight:' + (on ? '600' : '500') + ';',
|
||||||
|
}, p.name)
|
||||||
|
pill.addEventListener('click', () => {
|
||||||
|
if (policyMultiHost._selected.has(p.slug)) policyMultiHost._selected.delete(p.slug)
|
||||||
|
else policyMultiHost._selected.add(p.slug)
|
||||||
|
renderPolicyPills()
|
||||||
|
})
|
||||||
|
policyMultiHost.appendChild(pill)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
productScopeField.querySelector('select').addEventListener('change', async (e) => {
|
productScopeField.querySelector('select').addEventListener('change', async (e) => {
|
||||||
const slug = e.target.value
|
const slug = e.target.value
|
||||||
policySel.innerHTML = ''
|
policyMultiHost._selected = new Set()
|
||||||
policySel.appendChild(el('option', { value: '' }, '— Any policy —'))
|
policyMultiHost._available = []
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
policySel.disabled = true
|
renderPolicyPills()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
policySel.disabled = false
|
|
||||||
try {
|
try {
|
||||||
const r = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(slug) +
|
const r = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(slug) +
|
||||||
'&include_inactive=true')
|
'&include_inactive=true')
|
||||||
const pols = r.policies || []
|
policyMultiHost._available = (r.policies || []).map((p) => ({ slug: p.slug, name: p.name }))
|
||||||
pols.forEach((p) => policySel.appendChild(el('option', { value: p.slug }, p.name)))
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Silent: operator can still create a product-scoped code
|
// Silent: operator can still create a product-scoped code
|
||||||
// without a policy if the policy fetch fails.
|
// without picking policies if the policy fetch fails.
|
||||||
}
|
}
|
||||||
|
renderPolicyPills()
|
||||||
})
|
})
|
||||||
|
|
||||||
const create = el('details', { class: 'disclosure' }, [
|
const create = el('details', { class: 'disclosure' }, [
|
||||||
@@ -3924,8 +3970,16 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
}
|
}
|
||||||
const ps = create.querySelector('[name=product_slug]').value.trim()
|
const ps = create.querySelector('[name=product_slug]').value.trim()
|
||||||
if (ps) body.product_slug = ps
|
if (ps) body.product_slug = ps
|
||||||
const pol = create.querySelector('[name=policy_slug]').value.trim()
|
// Multi-policy scope: read from the pill host's _selected set.
|
||||||
if (pol) body.policy_slug = pol
|
// 0 picked = "any policy on the product" (omit field).
|
||||||
|
// 1 picked = send as singular policy_slug for legacy clarity.
|
||||||
|
// 2+ picked = send as policy_slugs array (server prefers this).
|
||||||
|
const pickedPolicySlugs = Array.from(policyMultiHost._selected)
|
||||||
|
if (pickedPolicySlugs.length === 1) {
|
||||||
|
body.policy_slug = pickedPolicySlugs[0]
|
||||||
|
} else if (pickedPolicySlugs.length > 1) {
|
||||||
|
body.policy_slugs = pickedPolicySlugs
|
||||||
|
}
|
||||||
const rl = create.querySelector('[name=referrer_label]').value.trim()
|
const rl = create.querySelector('[name=referrer_label]').value.trim()
|
||||||
if (rl) body.referrer_label = rl
|
if (rl) body.referrer_label = rl
|
||||||
const featured = create.querySelector('[name=featured]').checked
|
const featured = create.querySelector('[name=featured]').checked
|
||||||
@@ -3982,21 +4036,30 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
|
|
||||||
// Resolve scope names (read-only display — scope can't be edited).
|
// Resolve scope names (read-only display — scope can't be edited).
|
||||||
// Product name comes from the products list we already fetched.
|
// Product name comes from the products list we already fetched.
|
||||||
// Policy name requires fetching the product's policies, done
|
// Policy names require fetching the product's policies. We honor
|
||||||
// lazily here when an actual policy_id is bound.
|
// multi-policy scope when present (0.2.0:20+); legacy single-
|
||||||
|
// policy codes display as before.
|
||||||
let scopeLabel = 'Applies to: all products on this instance'
|
let scopeLabel = 'Applies to: all products on this instance'
|
||||||
if (c.applies_to_product_id) {
|
if (c.applies_to_product_id) {
|
||||||
const prod = productsForCreate.find((p) => p.id === c.applies_to_product_id)
|
const prod = productsForCreate.find((p) => p.id === c.applies_to_product_id)
|
||||||
const productName = prod ? prod.name : c.applies_to_product_id
|
const productName = prod ? prod.name : c.applies_to_product_id
|
||||||
if (c.applies_to_policy_id && prod) {
|
// Multi-policy scope wins over the legacy singular when non-empty.
|
||||||
let policyName = c.applies_to_policy_id
|
const multi = Array.isArray(c.applies_to_policy_ids) && c.applies_to_policy_ids.length > 0
|
||||||
|
const policyIds = multi
|
||||||
|
? c.applies_to_policy_ids
|
||||||
|
: (c.applies_to_policy_id ? [c.applies_to_policy_id] : [])
|
||||||
|
if (policyIds.length > 0 && prod) {
|
||||||
|
let resolvedNames = policyIds.slice()
|
||||||
try {
|
try {
|
||||||
const r = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(prod.slug) +
|
const r = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(prod.slug) +
|
||||||
'&include_inactive=true')
|
'&include_inactive=true')
|
||||||
const pol = (r.policies || []).find((p) => p.id === c.applies_to_policy_id)
|
const pols = r.policies || []
|
||||||
if (pol) policyName = pol.name + ' (' + pol.slug + ')'
|
resolvedNames = policyIds.map((pid) => {
|
||||||
|
const pol = pols.find((p) => p.id === pid)
|
||||||
|
return pol ? pol.name + ' (' + pol.slug + ')' : pid
|
||||||
|
})
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
scopeLabel = 'Applies to: ' + productName + ' → ' + policyName
|
scopeLabel = 'Applies to: ' + productName + ' → ' + resolvedNames.join(', ')
|
||||||
} else {
|
} else {
|
||||||
scopeLabel = 'Applies to: ' + productName + ' (any policy)'
|
scopeLabel = 'Applies to: ' + productName + ' (any policy)'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ const RELEASE_NOTES = [
|
|||||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||||
// append here.
|
// append here.
|
||||||
const ROUTINE_NOTES = [
|
const ROUTINE_NOTES = [
|
||||||
|
'0.2.0:20 — **Discount codes can apply to multiple policies, not just one.** Operator picks a subset (e.g. "Patron AND Pro but not Creator") on a single code instead of cloning the code under different names.',
|
||||||
|
'',
|
||||||
|
'**What changed.** Previously, a discount code\'s tier scope was a single policy (`applies_to_policy_id`) or "any policy on this product" / global. To offer the same discount across two of three tiers required creating two codes with distinct strings — operationally messy and harder for buyers. The form now has a tier multi-select pill picker: click tiers to toggle inclusion. 0 picked = "any policy on this product" (unchanged). 1 picked = single-policy scope (writes to the legacy column for clarity). 2+ picked = the code applies if and only if the chosen tier is in the picked set.',
|
||||||
|
'',
|
||||||
|
'**Migration 0018.** Additive: adds one nullable `applies_to_policy_ids_json` column to `discount_codes` for the multi-scope JSON array. Pre-existing codes have the column NULL and behave identically — the legacy singular column is still authoritative when the JSON column is empty.',
|
||||||
|
'',
|
||||||
|
'**Scope enforcement.** Both the public purchase endpoint and the admin "preview discount" endpoint now consult a unified `DiscountCode::allowed_policy_ids()` helper that returns the multi-policy list when non-empty or falls back to the legacy singular column. The featured-discount lookup also handles multi-policy: a featured code listing N policies surfaces correctly on the buy page for any of those tiers.',
|
||||||
|
'',
|
||||||
|
'**Edit form: scope still read-only.** Multi-policy scope is settable on creation and visible on the edit form (e.g. "Applies to: Keysat → Patron (patron), Pro (pro)") but, like all scope fields, isn\'t editable after the fact — operator disables + recreates to re-scope. Same constraint v0.2.0:17 introduced for the singular field; multi-policy follows the same rule to avoid silently invalidating distributed links.',
|
||||||
|
'',
|
||||||
|
'**SDK / API.** `POST /v1/admin/discount-codes` accepts an optional `policy_slugs: string[]` alongside the existing `policy_slug`. When both are present, `policy_slugs` wins. The list/get endpoints now include `applies_to_policy_ids: string[]` on every code (empty array when not multi-scoped). All other endpoints are unchanged; old SDKs that don\'t know about the field continue to work.',
|
||||||
|
'',
|
||||||
|
'**Test count: 87** (unchanged — scope logic is the same shape, just unifies over a Vec instead of a singleton).',
|
||||||
|
'',
|
||||||
|
'**Upgrade path.** v0.2.0:19 → v0.2.0:20 applies migration 0018 (additive). Existing codes keep their existing scope and behavior. No SDK breaking change.',
|
||||||
|
'',
|
||||||
'0.2.0:19 — **Marketing-bullets position: above or below the entitlements.** Tiny operator-control add: pick where the free-form ✓ checkmark copy renders on each tier card.',
|
'0.2.0:19 — **Marketing-bullets position: above or below the entitlements.** Tiny operator-control add: pick where the free-form ✓ checkmark copy renders on each tier card.',
|
||||||
'',
|
'',
|
||||||
'**The change.** Marketing bullets (`metadata.marketing_bullets`) have always rendered ABOVE the entitlement chips. That\'s usually right for "lifestyle" bullets like "Up to 5 products" / "BTCPay integration" — they sell the tier. But for tiers where the entitlements ARE the headline and the marketing bullets are caveats or fine-print, operators want them BELOW. New `metadata.marketing_bullets_position` field (`"above"` default, `"below"` opt-in) controls this per-policy. UI: small dropdown next to the bullets textarea on both create and edit forms. Renders consistently across the admin grid, the buy page, and the public `/v1/products/<slug>/policies` JSON (so SDK consumers stay in sync).',
|
'**The change.** Marketing bullets (`metadata.marketing_bullets`) have always rendered ABOVE the entitlement chips. That\'s usually right for "lifestyle" bullets like "Up to 5 products" / "BTCPay integration" — they sell the tier. But for tiers where the entitlements ARE the headline and the marketing bullets are caveats or fine-print, operators want them BELOW. New `metadata.marketing_bullets_position` field (`"above"` default, `"below"` opt-in) controls this per-policy. UI: small dropdown next to the bullets textarea on both create and edit forms. Renders consistently across the admin grid, the buy page, and the public `/v1/products/<slug>/policies` JSON (so SDK consumers stay in sync).',
|
||||||
@@ -346,7 +362,7 @@ const ROUTINE_NOTES = [
|
|||||||
].join('\n\n')
|
].join('\n\n')
|
||||||
|
|
||||||
export const v0_2_0 = VersionInfo.of({
|
export const v0_2_0 = VersionInfo.of({
|
||||||
version: '0.2.0:19',
|
version: '0.2.0:20',
|
||||||
releaseNotes: { en_US: ROUTINE_NOTES },
|
releaseNotes: { en_US: ROUTINE_NOTES },
|
||||||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||||||
// SQLite-level migrations live separately under
|
// SQLite-level migrations live separately under
|
||||||
|
|||||||
Reference in New Issue
Block a user