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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user