v0.2.0:16 — Launch-special discount codes + marketing bullets
Major feature release.
Featured (launch-special) discount codes:
- New 'featured' flag on discount_codes (migration 0017). When true,
the buy page renders a diagonal LAUNCH SPECIAL ribbon + slashed
original price + new price for every applicable tier. Purchase
endpoint auto-applies the discount for buyers who don't type a
code. Operator-typed codes still win.
- find_applicable_featured_discount repo helper: most-specific match
(policy > product > global), tiebreak by created_at.
- GET /v1/products/<slug>/policies now returns featured_discount per
policy with the post-discount price computed server-side. SDK
consumers + the dynamic pricing page get this for free.
Marketing bullets on policies:
- metadata.marketing_bullets — operator-controlled copy that renders
as additional checkmarks above the entitlement bullets on both the
admin grid tier card and the buy page tier. For things like 'Up
to 5 products' or 'BTCPay integration' that aren't real
entitlement gates.
- Authored via textarea on draft + edit policy forms.
UI:
- 'Most popular' checkbox now on the draft tier card (was edit-only).
- Discount codes tab grouped by product (matching Licenses /
Subscriptions tabs). Each code row gets a 'featured' badge when
flagged.
All 87 tests still pass. Migration is additive, no SDK changes,
backwards-compatible.
This commit is contained in:
@@ -120,10 +120,29 @@ pub async fn render(
|
||||
.map(|p| p.slug.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Look up applicable featured (launch-special) discounts per
|
||||
// policy. The tier picker renders the ribbon + slashed price for
|
||||
// any policy with a match. Sequential because policy count is
|
||||
// small per product.
|
||||
let mut featured_by_policy: std::collections::HashMap<String, crate::models::DiscountCode> =
|
||||
std::collections::HashMap::new();
|
||||
for p in &public_policies {
|
||||
if let Ok(Some(code)) =
|
||||
repo::find_applicable_featured_discount(&state.db, &product.id, &p.id).await
|
||||
{
|
||||
featured_by_policy.insert(p.id.clone(), code);
|
||||
}
|
||||
}
|
||||
|
||||
// Server-render the tier picker HTML so the page is functional even
|
||||
// before JS runs. The picker only appears when the product has 2+
|
||||
// public policies; otherwise the existing single-price view is used.
|
||||
let tiers_html = render_tier_picker(&public_policies, &initial_policy, &product);
|
||||
let tiers_html = render_tier_picker(
|
||||
&public_policies,
|
||||
&initial_policy,
|
||||
&product,
|
||||
&featured_by_policy,
|
||||
);
|
||||
// Compact JSON map of {policy_slug: {price, name}} so the JS can update
|
||||
// the price card when the buyer clicks a different tier.
|
||||
let tiers_json = build_tiers_json(&public_policies, &product);
|
||||
@@ -319,17 +338,46 @@ h1 {{
|
||||
.tier-description {{
|
||||
font-size:13.5px; line-height:1.45; color:var(--ink-700); margin:0;
|
||||
}}
|
||||
.tier-entitlements {{
|
||||
/* Launch-special ribbon — diagonal banner anchored to the top-right
|
||||
corner of any tier with an active featured discount. Plus the
|
||||
strike-through original-price line that renders ABOVE the
|
||||
discounted price. */
|
||||
.tier.has-launch {{ overflow:hidden; }}
|
||||
.tier-launch-ribbon {{
|
||||
position:absolute; top:14px; right:-44px;
|
||||
background:var(--gold-500); color:var(--navy-950);
|
||||
font-family:var(--font-display); font-weight:700; font-size:10.5px;
|
||||
letter-spacing:0.14em; text-transform:uppercase;
|
||||
padding:4px 50px; transform:rotate(35deg);
|
||||
box-shadow:0 2px 6px rgba(14,31,51,0.15);
|
||||
z-index:2;
|
||||
}}
|
||||
.tier-launch-meta {{
|
||||
font-size:11.5px; color:var(--gold-700); font-weight:600;
|
||||
margin-top:4px;
|
||||
}}
|
||||
.tier-price-original {{
|
||||
font-family:var(--font-display); font-weight:500; font-size:14px;
|
||||
color:var(--ink-500); margin-top:4px;
|
||||
text-decoration:line-through; text-decoration-color:rgba(14,31,51,0.4);
|
||||
}}
|
||||
.tier-price-original-unit {{
|
||||
font-size:11.5px; margin-left:4px; color:var(--ink-500);
|
||||
}}
|
||||
.tier-entitlements, .tier-bullets {{
|
||||
list-style:none; padding:0; margin:6px 0 0;
|
||||
font-size:13px; color:var(--ink-700);
|
||||
}}
|
||||
.tier-entitlements li {{
|
||||
.tier-entitlements li, .tier-bullets li {{
|
||||
padding:3px 0 3px 18px; position:relative;
|
||||
}}
|
||||
.tier-entitlements li::before {{
|
||||
.tier-entitlements li::before, .tier-bullets li::before {{
|
||||
content:'✓'; position:absolute; left:0; top:3px;
|
||||
color:var(--gold-700); font-weight:700;
|
||||
}}
|
||||
/* Marketing bullets render above entitlements with a slightly tighter
|
||||
top margin so they read as one coherent feature list. */
|
||||
.tier-bullets + .tier-entitlements {{ margin-top:2px; }}
|
||||
.tier-select-btn {{
|
||||
margin-top:auto;
|
||||
padding:8px 12px;
|
||||
@@ -931,6 +979,7 @@ fn render_tier_picker(
|
||||
policies: &[crate::models::Policy],
|
||||
initial: &Option<crate::models::Policy>,
|
||||
product: &crate::models::Product,
|
||||
featured_by_policy: &std::collections::HashMap<String, crate::models::DiscountCode>,
|
||||
) -> String {
|
||||
if policies.len() < 2 {
|
||||
return String::new();
|
||||
@@ -949,14 +998,75 @@ fn render_tier_picker(
|
||||
// For SAT-currency products, the override is in sats; for
|
||||
// fiat-priced products it's in cents (USD/EUR). The price
|
||||
// unit cell renders in the right denomination either way.
|
||||
let (price_fmt, price_unit) = if product.price_currency == "SAT" {
|
||||
let price = p.price_sats_override.unwrap_or(product.price_sats);
|
||||
(format_thousands(price), "sats".to_string())
|
||||
let base_price_units: i64 = if product.price_currency == "SAT" {
|
||||
p.price_sats_override.unwrap_or(product.price_sats)
|
||||
} else {
|
||||
let cents = p.price_sats_override.unwrap_or(product.price_value);
|
||||
p.price_sats_override.unwrap_or(product.price_value)
|
||||
};
|
||||
// Featured discount (if any) — apply the same math the
|
||||
// purchase endpoint uses so the buyer sees the same number
|
||||
// here as at checkout. Note: for fiat products the units
|
||||
// are cents, but compute_discount is currency-agnostic
|
||||
// (works on any positive integer).
|
||||
let featured = featured_by_policy.get(&p.id);
|
||||
let discount_units = featured
|
||||
.map(|c| crate::api::purchase::compute_discount(&c.kind, c.amount, base_price_units))
|
||||
.unwrap_or(0);
|
||||
let post_discount_units = (base_price_units - discount_units).max(0);
|
||||
let (price_fmt, price_unit) = if product.price_currency == "SAT" {
|
||||
(format_thousands(post_discount_units), "sats".to_string())
|
||||
} else {
|
||||
let cents = post_discount_units;
|
||||
let main = format!("{}.{:02}", cents / 100, (cents.abs() % 100));
|
||||
(main, product.price_currency.clone())
|
||||
};
|
||||
// Original (pre-discount) price for the strikethrough.
|
||||
let original_fmt = if featured.is_some() {
|
||||
if product.price_currency == "SAT" {
|
||||
format_thousands(base_price_units)
|
||||
} else {
|
||||
format!("{}.{:02}", base_price_units / 100, (base_price_units.abs() % 100))
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
// Ribbon + slashed-original-price markup. Only emitted when
|
||||
// a featured discount actually applies.
|
||||
let (featured_ribbon, original_price_html) = if let Some(code) = featured {
|
||||
let tagline = if code.kind == "percent" {
|
||||
format!("{}% OFF", code.amount / 100)
|
||||
} else if code.kind == "free_license" {
|
||||
"FREE".to_string()
|
||||
} else if code.kind == "set_price" {
|
||||
"LIMITED PRICE".to_string()
|
||||
} else {
|
||||
"LAUNCH SPECIAL".to_string()
|
||||
};
|
||||
let remaining = code.max_uses.map(|m| (m - code.used_count).max(0)).unwrap_or(-1);
|
||||
let remaining_html = if remaining > 0 {
|
||||
format!(
|
||||
"<div class=\"tier-launch-meta\">Limited: {} of {} remaining</div>",
|
||||
remaining,
|
||||
code.max_uses.unwrap_or(0)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
(
|
||||
format!(
|
||||
"<div class=\"tier-launch-ribbon\">{}</div>{}",
|
||||
html_escape(&tagline),
|
||||
remaining_html,
|
||||
),
|
||||
format!(
|
||||
"<div class=\"tier-price-original\">{}<span class=\"tier-price-original-unit\">{}</span></div>",
|
||||
original_fmt,
|
||||
if product.price_currency == "SAT" { "sats" } else { product.price_currency.as_str() },
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(String::new(), String::new())
|
||||
};
|
||||
let description = p
|
||||
.metadata
|
||||
.get("description")
|
||||
@@ -982,6 +1092,29 @@ fn render_tier_picker(
|
||||
// If the product has an entitlements catalog, render
|
||||
// each policy entitlement using the catalog's display
|
||||
// name + description (as a tooltip). Falls back to the
|
||||
// Marketing bullets — operator-controlled copy from
|
||||
// metadata.marketing_bullets. Rendered as ✓ checkmarks
|
||||
// above the entitlement bullets. Skipped silently if
|
||||
// absent / wrong shape.
|
||||
let marketing_html = p
|
||||
.metadata
|
||||
.get("marketing_bullets")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
let lis: Vec<String> = arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| format!("<li>{}</li>", html_escape(s)))
|
||||
.collect();
|
||||
if lis.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("<ul class=\"tier-bullets\">{}</ul>", lis.join(""))
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// raw slug if the catalog is empty or the slug isn't in
|
||||
// it (legacy slugs that predate the catalog land here).
|
||||
let entitlements_html = if p.entitlements.is_empty() {
|
||||
@@ -1075,12 +1208,22 @@ fn render_tier_picker(
|
||||
} else {
|
||||
("", String::new(), String::new())
|
||||
};
|
||||
// Add `has-launch` to the card class when a featured
|
||||
// discount applies so the CSS can lift the price + draw
|
||||
// the diagonal ribbon.
|
||||
let classes = if featured.is_some() {
|
||||
format!("{} has-launch", classes)
|
||||
} else {
|
||||
classes.clone()
|
||||
};
|
||||
format!(
|
||||
r#"<div class="{classes}" data-policy-slug="{slug}">{popular_pill}<div class="tier-name">{name}</div><div class="tier-price">{price_fmt}<span class="tier-price-unit">{price_unit}{cadence_suffix}</span></div>{dur_html}{recurring_meta}{trial_banner}{trial_meta}{description_html}{entitlements_html}<button type="button" class="tier-select-btn">Select</button></div>"#,
|
||||
r#"<div class="{classes}" data-policy-slug="{slug}">{popular_pill}{featured_ribbon}<div class="tier-name">{name}</div>{original_price_html}<div class="tier-price">{price_fmt}<span class="tier-price-unit">{price_unit}{cadence_suffix}</span></div>{dur_html}{recurring_meta}{trial_banner}{trial_meta}{description_html}{marketing_html}{entitlements_html}<button type="button" class="tier-select-btn">Select</button></div>"#,
|
||||
classes = classes,
|
||||
slug = slug_attr,
|
||||
popular_pill = popular_pill,
|
||||
featured_ribbon = featured_ribbon,
|
||||
name = name,
|
||||
original_price_html = original_price_html,
|
||||
price_fmt = price_fmt,
|
||||
price_unit = price_unit,
|
||||
cadence_suffix = cadence_suffix,
|
||||
@@ -1089,6 +1232,7 @@ fn render_tier_picker(
|
||||
trial_banner = trial_banner,
|
||||
trial_meta = trial_meta,
|
||||
description_html = description_html,
|
||||
marketing_html = marketing_html,
|
||||
entitlements_html = entitlements_html,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -47,6 +47,12 @@ pub struct CreateDiscountCodeReq {
|
||||
pub referrer_label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Mark this as a "launch special" — publicly displayed on the buy
|
||||
/// page with a diagonal LAUNCH SPECIAL ribbon + original price
|
||||
/// struck through. Auto-applies for buyers who don't type any
|
||||
/// code. Operator-typed codes still win when the buyer pastes one.
|
||||
#[serde(default)]
|
||||
pub featured: bool,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
@@ -117,6 +123,7 @@ pub async fn create(
|
||||
policy_id.as_deref(),
|
||||
req.referrer_label.as_deref(),
|
||||
&req.description,
|
||||
req.featured,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -197,6 +204,10 @@ pub struct UpdateDiscountCodeReq {
|
||||
pub description: Option<String>,
|
||||
#[serde(default, deserialize_with = "deser_double_option", skip_serializing_if = "Option::is_none")]
|
||||
pub referrer_label: Option<Option<String>>,
|
||||
/// Toggle the launch-special public-display flag. `Some(true)` to
|
||||
/// promote, `Some(false)` to demote, omit to leave alone.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub featured: Option<bool>,
|
||||
}
|
||||
|
||||
/// Helper for `Option<Option<T>>` with serde — distinguishes "not present in
|
||||
@@ -227,6 +238,7 @@ pub async fn update(
|
||||
req.expires_at.as_ref().map(|opt| opt.as_deref()),
|
||||
req.description.as_deref(),
|
||||
req.referrer_label.as_ref().map(|opt| opt.as_deref()),
|
||||
req.featured,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -778,6 +778,22 @@ pub async fn list_public_policies(
|
||||
}
|
||||
let policies = repo::list_public_policies_by_product(&state.db, &product.id).await?;
|
||||
|
||||
// For each policy, look up an applicable active featured discount
|
||||
// (if any). The buy page + dynamic pricing page render the ribbon +
|
||||
// slashed price using this. Done as a sequential loop because the
|
||||
// policy count per product is small (≤ tier-cap = 5 on Creator,
|
||||
// unlimited on Pro but realistically <20). Switch to a single
|
||||
// batched SQL if profiling ever flags this.
|
||||
let mut featured_by_policy: std::collections::HashMap<String, crate::models::DiscountCode> =
|
||||
std::collections::HashMap::new();
|
||||
for p in &policies {
|
||||
if let Some(code) =
|
||||
repo::find_applicable_featured_discount(&state.db, &product.id, &p.id).await?
|
||||
{
|
||||
featured_by_policy.insert(p.id.clone(), code);
|
||||
}
|
||||
}
|
||||
|
||||
let policies_json: Vec<Value> = policies
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
@@ -797,7 +813,41 @@ pub async fn list_public_policies(
|
||||
.get("highlight")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
// Marketing bullets: operator-controlled copy that renders
|
||||
// as ✓ checkmarks ABOVE the entitlement bullets on the buy
|
||||
// page. Stored as an array of strings in metadata; passes
|
||||
// through to JSON unchanged.
|
||||
let marketing_bullets = p
|
||||
.metadata
|
||||
.get("marketing_bullets")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([]));
|
||||
let price_sats = p.price_sats_override.unwrap_or(product.price_sats);
|
||||
// Featured discount (if any) — compute the post-discount
|
||||
// price the buyer would actually pay if they bought right
|
||||
// now without typing any code. We mirror the same math
|
||||
// the purchase endpoint applies (compute_discount), and
|
||||
// floor the result at 0 so a 100%-off code doesn't go
|
||||
// negative.
|
||||
let featured = featured_by_policy.get(&p.id).map(|code| {
|
||||
let discount = crate::api::purchase::compute_discount(
|
||||
&code.kind, code.amount, price_sats,
|
||||
);
|
||||
let final_price = (price_sats - discount).max(0);
|
||||
let remaining = code.max_uses.map(|m| (m - code.used_count).max(0));
|
||||
json!({
|
||||
"code": code.code,
|
||||
"kind": code.kind,
|
||||
"amount": code.amount,
|
||||
"description": code.description,
|
||||
"expires_at": code.expires_at,
|
||||
"max_uses": code.max_uses,
|
||||
"used_count": code.used_count,
|
||||
"remaining_uses": remaining,
|
||||
"discount_applied_sats": discount,
|
||||
"discounted_price_sats": final_price,
|
||||
})
|
||||
});
|
||||
json!({
|
||||
"slug": p.slug,
|
||||
"name": p.name,
|
||||
@@ -807,12 +857,16 @@ pub async fn list_public_policies(
|
||||
"max_machines": p.max_machines,
|
||||
"is_trial": p.is_trial,
|
||||
"entitlements": p.entitlements,
|
||||
"marketing_bullets": marketing_bullets,
|
||||
"highlighted": highlighted,
|
||||
// Recurring-subscription cadence — buy page renders
|
||||
// "Renews every N days" / "$X/month" when is_recurring=true.
|
||||
"is_recurring": p.is_recurring,
|
||||
"renewal_period_days": p.renewal_period_days,
|
||||
"trial_days": p.trial_days,
|
||||
// Featured (launch-special) discount metadata —
|
||||
// null when no applicable featured code exists.
|
||||
"featured_discount": featured,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -249,8 +249,28 @@ pub async fn start(
|
||||
//
|
||||
// If C, D, or E fail after B succeeded, we call release_code_slot to
|
||||
// give the slot back.
|
||||
// Determine the effective discount code. If the buyer typed one,
|
||||
// honor it (operator-typed beats auto-applied). Otherwise, look up
|
||||
// the most applicable active featured discount for the chosen
|
||||
// policy and use it. This is the "launch special" auto-apply
|
||||
// path — operators can run a public promo without making buyers
|
||||
// type the code.
|
||||
let explicit_code: Option<String> = req
|
||||
.code
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let effective_code: Option<String> = if explicit_code.is_some() {
|
||||
explicit_code
|
||||
} else if let Some(pol) = chosen_policy.as_ref() {
|
||||
repo::find_applicable_featured_discount(&state.db, &product.id, &pol.id)
|
||||
.await?
|
||||
.map(|c| c.code)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (final_price, reservation, discount_applied) = if let Some(raw_code) =
|
||||
req.code.as_deref().filter(|s| !s.trim().is_empty())
|
||||
effective_code.as_deref().filter(|s| !s.trim().is_empty())
|
||||
{
|
||||
let code = repo::get_discount_code_by_code(&state.db, raw_code)
|
||||
.await?
|
||||
@@ -528,7 +548,9 @@ pub async fn start(
|
||||
|
||||
/// Apply the discount math. Returns the sats to subtract from `base`.
|
||||
/// Caller is responsible for clamping the result (and for floor enforcement).
|
||||
fn compute_discount(kind: &str, amount: i64, base_price_sats: i64) -> i64 {
|
||||
/// `pub(crate)` so the public policies endpoint can preview the post-
|
||||
/// discount price on tier cards for featured (auto-applied) codes.
|
||||
pub(crate) fn compute_discount(kind: &str, amount: i64, base_price_sats: i64) -> i64 {
|
||||
match kind {
|
||||
"percent" => {
|
||||
// amount is basis points (0..=10000). 5000 == 50%.
|
||||
|
||||
@@ -2064,6 +2064,13 @@ pub async fn list_audit(
|
||||
// ---------- Discount codes ----------
|
||||
|
||||
fn row_to_discount_code(row: sqlx::sqlite::SqliteRow) -> DiscountCode {
|
||||
// `featured` lands in migration 0017. Same fallback pattern as
|
||||
// tier_rank / archived_at — try_get + ok().flatten() so pre-0017
|
||||
// databases (theoretical) don't crash here.
|
||||
let featured: bool = row
|
||||
.try_get::<i64, _>("featured")
|
||||
.map(|v| v != 0)
|
||||
.unwrap_or(false);
|
||||
DiscountCode {
|
||||
id: row.get("id"),
|
||||
code: row.get("code"),
|
||||
@@ -2077,6 +2084,7 @@ fn row_to_discount_code(row: sqlx::sqlite::SqliteRow) -> DiscountCode {
|
||||
referrer_label: row.get("referrer_label"),
|
||||
description: row.get("description"),
|
||||
active: row.get::<i64, _>("active") != 0,
|
||||
featured,
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
}
|
||||
@@ -2122,6 +2130,7 @@ pub async fn create_discount_code(
|
||||
applies_to_policy_id,
|
||||
referrer_label,
|
||||
description,
|
||||
false, // not featured by default — backwards-compat for callers
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2148,6 +2157,7 @@ pub async fn create_discount_code_with_currency(
|
||||
applies_to_policy_id: Option<&str>,
|
||||
referrer_label: Option<&str>,
|
||||
description: &str,
|
||||
featured: bool,
|
||||
) -> AppResult<DiscountCode> {
|
||||
if !matches!(
|
||||
kind,
|
||||
@@ -2203,8 +2213,8 @@ pub async fn create_discount_code_with_currency(
|
||||
"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,
|
||||
description, active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1, ?, ?)",
|
||||
description, active, featured, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, 1, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&normalized)
|
||||
@@ -2217,6 +2227,7 @@ pub async fn create_discount_code_with_currency(
|
||||
.bind(applies_to_policy_id)
|
||||
.bind(referrer_label)
|
||||
.bind(description)
|
||||
.bind(featured as i64)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
@@ -2239,7 +2250,7 @@ pub async fn get_discount_code_by_id(
|
||||
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,
|
||||
description, active, created_at, updated_at
|
||||
description, active, featured, created_at, updated_at
|
||||
FROM discount_codes WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
@@ -2256,7 +2267,7 @@ pub async fn get_discount_code_by_code(
|
||||
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,
|
||||
description, active, created_at, updated_at
|
||||
description, active, featured, created_at, updated_at
|
||||
FROM discount_codes WHERE code = ?",
|
||||
)
|
||||
.bind(&normalized)
|
||||
@@ -2272,18 +2283,72 @@ pub async fn list_discount_codes(
|
||||
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,
|
||||
description, active, created_at, updated_at
|
||||
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,
|
||||
description, active, created_at, updated_at
|
||||
description, active, featured, created_at, updated_at
|
||||
FROM discount_codes ORDER BY created_at DESC"
|
||||
};
|
||||
let rows = sqlx::query(q).fetch_all(pool).await?;
|
||||
Ok(rows.into_iter().map(row_to_discount_code).collect())
|
||||
}
|
||||
|
||||
/// Find the most-applicable active featured discount for a given
|
||||
/// (product_id, policy_id) pair, if any. Returns `None` if no featured
|
||||
/// code applies, or if all matching codes are expired / exhausted /
|
||||
/// inactive. Precedence: policy-specific > product-specific > global,
|
||||
/// then by created_at ascending (operator-set priority).
|
||||
///
|
||||
/// Used by:
|
||||
/// - `GET /v1/products/<slug>/policies` to surface launch-special
|
||||
/// prices on the public buy page + dynamic pricing page.
|
||||
/// - `POST /v1/purchase` to auto-apply the featured code when the
|
||||
/// buyer doesn't type one.
|
||||
pub async fn find_applicable_featured_discount(
|
||||
pool: &SqlitePool,
|
||||
product_id: &str,
|
||||
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(
|
||||
"SELECT id, code, kind, amount, max_uses, used_count, expires_at,
|
||||
applies_to_product_id, applies_to_policy_id, 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",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(policy_id)
|
||||
.bind(product_id)
|
||||
.bind(policy_id)
|
||||
.bind(product_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(row_to_discount_code))
|
||||
}
|
||||
|
||||
pub async fn set_discount_code_active(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
@@ -2317,6 +2382,7 @@ pub async fn update_discount_code(
|
||||
expires_at: Option<Option<&str>>,
|
||||
description: Option<&str>,
|
||||
referrer_label: Option<Option<&str>>,
|
||||
featured: Option<bool>,
|
||||
) -> AppResult<DiscountCode> {
|
||||
// Re-fetch to validate amount against the existing kind.
|
||||
let existing = get_discount_code_by_id(pool, id)
|
||||
@@ -2377,6 +2443,9 @@ pub async fn update_discount_code(
|
||||
if referrer_label.is_some() {
|
||||
sets.push("referrer_label = ?");
|
||||
}
|
||||
if featured.is_some() {
|
||||
sets.push("featured = ?");
|
||||
}
|
||||
if sets.is_empty() {
|
||||
return Ok(existing);
|
||||
}
|
||||
@@ -2402,6 +2471,9 @@ pub async fn update_discount_code(
|
||||
if let Some(opt_r) = referrer_label {
|
||||
q = q.bind(opt_r);
|
||||
}
|
||||
if let Some(f) = featured {
|
||||
q = q.bind(f as i64);
|
||||
}
|
||||
q = q.bind(&now).bind(id);
|
||||
q.execute(pool).await?;
|
||||
|
||||
|
||||
@@ -324,6 +324,13 @@ pub struct DiscountCode {
|
||||
pub referrer_label: Option<String>,
|
||||
pub description: String,
|
||||
pub active: bool,
|
||||
/// When `true`, the buy page renders this code as a public "launch
|
||||
/// special" — striking the original price, showing the discounted
|
||||
/// price, with a "LAUNCH SPECIAL" diagonal ribbon. The purchase
|
||||
/// endpoint auto-applies it for buyers who don't type any code.
|
||||
/// Operator-typed codes still win if the buyer manually enters one.
|
||||
#[serde(default)]
|
||||
pub featured: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user