From 094cf75e527b9f74538735d42cb4b62d0f0b3664 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 May 2026 14:01:51 -0500 Subject: [PATCH] =?UTF-8?q?v0.2.0:20=20=E2=80=94=20Multi-policy=20scope=20?= =?UTF-8?q?for=20discount=20codes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../0018_discount_codes_multi_policy.sql | 30 +++++ licensing-service/src/api/discount_codes.rs | 77 ++++++++++--- licensing-service/src/api/purchase.rs | 15 ++- licensing-service/src/db/repo.rs | 107 ++++++++++++----- licensing-service/src/models.rs | 27 +++++ licensing-service/web/index.html | 109 ++++++++++++++---- startos/versions/v0.2.0.ts | 18 ++- 7 files changed, 306 insertions(+), 77 deletions(-) create mode 100644 licensing-service/migrations/0018_discount_codes_multi_policy.sql diff --git a/licensing-service/migrations/0018_discount_codes_multi_policy.sql b/licensing-service/migrations/0018_discount_codes_multi_policy.sql new file mode 100644 index 0000000..ba21213 --- /dev/null +++ b/licensing-service/migrations/0018_discount_codes_multi_policy.sql @@ -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; diff --git a/licensing-service/src/api/discount_codes.rs b/licensing-service/src/api/discount_codes.rs index ad392be..d70e64c 100644 --- a/licensing-service/src/api/discount_codes.rs +++ b/licensing-service/src/api/discount_codes.rs @@ -40,8 +40,15 @@ pub struct CreateDiscountCodeReq { pub product_slug: Option, /// 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, + /// 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>, /// Optional free-form tag for tracking, e.g. 'launch-twitter', 'alice@example.com'. #[serde(default)] pub referrer_label: Option, @@ -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, Option>) = + 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", diff --git a/licensing-service/src/api/purchase.rs b/licensing-service/src/api/purchase.rs index eebb629..2c9a496 100644 --- a/licensing-service/src/api/purchase.rs +++ b/licensing-service/src/api/purchase.rs @@ -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(), )); diff --git a/licensing-service/src/db/repo.rs b/licensing-service/src/db/repo.rs index ae25e7d..c9d7f45 100644 --- a/licensing-service/src/db/repo.rs +++ b/licensing-service/src/db/repo.rs @@ -2071,6 +2071,15 @@ fn row_to_discount_code(row: sqlx::sqlite::SqliteRow) -> DiscountCode { .try_get::("featured") .map(|v| v != 0) .unwrap_or(false); + // Multi-policy scope JSON (0018). Parse to Vec; 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 = row + .try_get::, _>("applies_to_policy_ids_json") + .ok() + .flatten() + .and_then(|s| serde_json::from_str::>(&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::("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>, 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 = 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> { 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> { 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> { 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>, featured: Option, + // 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>, ) -> AppResult { // 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 = 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?; diff --git a/licensing-service/src/models.rs b/licensing-service/src/models.rs index 1f30359..1052749 100644 --- a/licensing-service/src/models.rs +++ b/licensing-service/src/models.rs @@ -321,6 +321,12 @@ pub struct DiscountCode { pub expires_at: Option, pub applies_to_product_id: Option, pub applies_to_policy_id: Option, + /// 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, pub referrer_label: Option, 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) diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index 64f5d0f..3229e1c 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -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)' } diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index afcc085..7d4e679 100644 --- a/startos/versions/v0.2.0.ts +++ b/startos/versions/v0.2.0.ts @@ -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//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