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
@@ -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;
+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)
+86 -23
View File
@@ -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)',
productOptions, { value: '' })
// Policy picker: populated on-demand when a product is selected.
// Disabled when "Any product" is selected (a policy-scoped code
// requires a product scope per the server contract).
const policyScopeField = formSelect('policy_slug', 'Restrict to policy (optional)',
[{ value: '', label: '— Any policy —' }], { value: '' })
const policySel = policyScopeField.querySelector('select')
policySel.disabled = true
// Policy picker: multi-select pill picker populated on-demand when
// a product is selected. Operator picks zero or more policies:
// - 0 picked = code applies to any policy on the chosen product
// - 1 picked = code is single-policy scoped (mirrors v0.2.0:19)
// - 2+ picked = code applies to any of the picked policies
// Hidden when "Any product" is selected — a policy-scoped code
// 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) => {
const slug = e.target.value
policySel.innerHTML = ''
policySel.appendChild(el('option', { value: '' }, '— Any policy —'))
policyMultiHost._selected = new Set()
policyMultiHost._available = []
if (!slug) {
policySel.disabled = true
renderPolicyPills()
return
}
policySel.disabled = false
try {
const r = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(slug) +
'&include_inactive=true')
const pols = r.policies || []
pols.forEach((p) => policySel.appendChild(el('option', { value: p.slug }, p.name)))
policyMultiHost._available = (r.policies || []).map((p) => ({ slug: p.slug, name: p.name }))
} catch (_) {
// 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' }, [
@@ -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()
if (ps) body.product_slug = ps
const pol = create.querySelector('[name=policy_slug]').value.trim()
if (pol) body.policy_slug = pol
// Multi-policy scope: read from the pill host's _selected set.
// 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()
if (rl) body.referrer_label = rl
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).
// Product name comes from the products list we already fetched.
// Policy name requires fetching the product's policies, done
// lazily here when an actual policy_id is bound.
// Policy names require fetching the product's policies. We honor
// 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'
if (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
if (c.applies_to_policy_id && prod) {
let policyName = c.applies_to_policy_id
// Multi-policy scope wins over the legacy singular when non-empty.
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 {
const r = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(prod.slug) +
'&include_inactive=true')
const pol = (r.policies || []).find((p) => p.id === c.applies_to_policy_id)
if (pol) policyName = pol.name + ' (' + pol.slug + ')'
const pols = r.policies || []
resolvedNames = policyIds.map((pid) => {
const pol = pols.find((p) => p.id === pid)
return pol ? pol.name + ' (' + pol.slug + ')' : pid
})
} catch (_) {}
scopeLabel = 'Applies to: ' + productName + ' → ' + policyName
scopeLabel = 'Applies to: ' + productName + ' → ' + resolvedNames.join(', ')
} else {
scopeLabel = 'Applies to: ' + productName + ' (any policy)'
}
+17 -1
View File
@@ -58,6 +58,22 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here.
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.',
'',
'**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')
export const v0_2_0 = VersionInfo.of({
version: '0.2.0:19',
version: '0.2.0:20',
releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under