Recurring subs Phase 4 — admin UI + buy-page rendering + Pro-tier gate
Phase 4 surfaces the recurring-subscription schema (migration 0011) and
renewal-worker (Phase 2, commit 7007bf8) through every layer operators
and buyers actually see:
API
- Policy struct + repo gain is_recurring, renewal_period_days,
grace_period_days, trial_days. RecurringConfig / RecurringUpdate
helper structs keep create_policy / update_policy signatures
manageable.
- CreatePolicyReq + UpdatePolicyReq accept all four fields. Validation
rejects internally inconsistent combos (recurring=true with period=0,
trial > renewal period, period >5y, grace >90d).
- New tier::enforce_recurring_feature gate. Pro/Patron only — Creator
and Unlicensed get a 402 with upgrade_url. The gate fires on both
create-policy and the false→true transition in update-policy.
- list_public_policies now surfaces is_recurring, renewal_period_days,
trial_days so SDKs and the buy page can render cadence.
Admin UI (web/index.html)
- Create-policy form gets a "Recurring subscription (Pro)" section:
is_recurring checkbox + cadence preset (monthly/quarterly/etc/custom)
+ grace period + trial days. Live enable/disable: the inputs gray
out unless the box is ticked, and the custom-days input grays out
unless "Custom" is selected.
- Edit-policy modal mirrors the same section, pre-populated from the
policy's current values.
- Policies-list table shows a gold "every Nd" badge alongside the
trial badge so operators can see at a glance which policies renew.
Buy page (/buy/<slug>)
- Tier cards on a recurring policy render a "Renews monthly/annually/
every N days" meta line + a "/mo" / "/yr" / "/Nd" suffix on the
price unit, so the headline reads "$25 / mo" not just "$25".
- First-cycle trial banner shows when trial_days > 0.
- TIERS JSON map exposes is_recurring + renewal_period_days +
trial_days so the JS price-update path keeps the cadence suffix
in sync when the buyer clicks between tiers.
Tests (+4, total now 53)
- recurring_policy_blocked_on_creator_tier — 402 + upgrade_url
- pro_tier_creates_monthly_recurring_policy — full create + verify
via both admin GET and public list endpoint
- recurring_requires_positive_period — validator rejects period=0
- edit_policy_to_recurring_respects_tier_gate — Creator 402 on flip,
Pro 200 on same flip, name-only PATCH on already-recurring policy
doesn't re-fire the gate after downgrade
Drive-by: wrap the state-machine ASCII diagram in subscriptions.rs in
a ```text fence so cargo's doc-test runner stops trying to compile box
characters as Rust tokens.
This commit is contained in:
@@ -595,13 +595,27 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
|
||||
}}
|
||||
// Reflect new base price in the cert card. For fiat-priced
|
||||
// products the unit cell ("sats" → "USD" / "EUR") also swaps.
|
||||
// Recurring tiers: append a cadence suffix to the unit so the
|
||||
// headline price reads "$25 / mo" not just "$25".
|
||||
const t = TIERS[slug];
|
||||
const fmt = formatTierPrice(t);
|
||||
currentBaseFmt = fmt.amount;
|
||||
priceStrike.style.display = 'none';
|
||||
priceTag.style.display = 'none';
|
||||
const unitEl = document.querySelector('.unit');
|
||||
if (unitEl) unitEl.textContent = fmt.unit;
|
||||
let unitText = fmt.unit;
|
||||
if (t.is_recurring) {{
|
||||
const days = t.renewal_period_days || 0;
|
||||
const suffix = days === 7 ? ' / wk'
|
||||
: days === 30 ? ' / mo'
|
||||
: days === 90 ? ' / qtr'
|
||||
: days === 180 ? ' / 6mo'
|
||||
: days === 365 ? ' / yr'
|
||||
: days > 0 ? (' / ' + days + 'd')
|
||||
: '';
|
||||
unitText = fmt.unit + suffix;
|
||||
}}
|
||||
if (unitEl) unitEl.textContent = unitText;
|
||||
if (priceLabel) priceLabel.textContent = 'Price · ' + t.name;
|
||||
// Free tier: render "FREE", swap CTA to "Redeem license" so the
|
||||
// buyer never sees "Pay with Bitcoin" for a 0-amount product.
|
||||
@@ -963,15 +977,55 @@ fn render_tier_picker(
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
// Recurring-subscription cadence rendering:
|
||||
// - Tier card shows "Renews every N days" / "monthly" / "annually" beneath duration.
|
||||
// - The price unit gets a "/mo" / "/yr" / "/Nd" suffix so the headline price
|
||||
// reads as a subscription rate, not a one-time cost.
|
||||
// - First-cycle trial banner shows when trial_days > 0.
|
||||
let (cadence_suffix, recurring_meta, trial_banner) = if p.is_recurring {
|
||||
let days = p.renewal_period_days.max(0);
|
||||
let (suffix, label) = match days {
|
||||
7 => ("/wk", "Renews weekly".to_string()),
|
||||
30 => ("/mo", "Renews monthly".to_string()),
|
||||
90 => ("/qtr", "Renews quarterly".to_string()),
|
||||
180 => ("/6mo", "Renews semi-annually".to_string()),
|
||||
365 => ("/yr", "Renews annually".to_string()),
|
||||
other => (
|
||||
// Static lifetime suffix for non-canonical cadences
|
||||
// (use Box::leak only for predictable known values;
|
||||
// fall back to plain "" + custom meta text).
|
||||
"",
|
||||
format!("Renews every {other} days"),
|
||||
),
|
||||
};
|
||||
let trial_banner = if p.trial_days > 0 {
|
||||
format!(
|
||||
"<div class=\"tier-meta\" style=\"color:var(--gold-700); font-weight:600\">{} day free trial</div>",
|
||||
p.trial_days
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
(
|
||||
suffix,
|
||||
format!("<div class=\"tier-meta\">{}</div>", html_escape(&label)),
|
||||
trial_banner,
|
||||
)
|
||||
} else {
|
||||
("", String::new(), String::new())
|
||||
};
|
||||
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}</span></div>{dur_html}{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}<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>"#,
|
||||
classes = classes,
|
||||
slug = slug_attr,
|
||||
popular_pill = popular_pill,
|
||||
name = name,
|
||||
price_fmt = price_fmt,
|
||||
price_unit = price_unit,
|
||||
cadence_suffix = cadence_suffix,
|
||||
dur_html = dur_html,
|
||||
recurring_meta = recurring_meta,
|
||||
trial_banner = trial_banner,
|
||||
trial_meta = trial_meta,
|
||||
description_html = description_html,
|
||||
entitlements_html = entitlements_html,
|
||||
@@ -1017,6 +1071,9 @@ fn build_tiers_json(
|
||||
"price_sats": price_sats_value,
|
||||
"price_currency": product.price_currency,
|
||||
"price_value": price_value,
|
||||
"is_recurring": p.is_recurring,
|
||||
"renewal_period_days": p.renewal_period_days,
|
||||
"trial_days": p.trial_days,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,12 +52,71 @@ pub struct CreatePolicyReq {
|
||||
/// Free-form label for the tip recipient (audit/UI).
|
||||
#[serde(default)]
|
||||
pub tip_label: Option<String>,
|
||||
/// Recurring-subscription cadence (migration 0011). When `is_recurring`
|
||||
/// is true, the renewal worker re-invoices every `renewal_period_days`.
|
||||
/// Pro-tier feature.
|
||||
#[serde(default)]
|
||||
pub is_recurring: bool,
|
||||
#[serde(default)]
|
||||
pub renewal_period_days: i64,
|
||||
/// Days the subscription stays in `past_due` before lapsing. Defaults
|
||||
/// to 7 (matches migration default) when omitted on a recurring policy.
|
||||
#[serde(default)]
|
||||
pub grace_period_days: Option<i64>,
|
||||
/// Optional free-trial length at the first cycle. 0 = no trial.
|
||||
#[serde(default)]
|
||||
pub trial_days: i64,
|
||||
}
|
||||
|
||||
fn default_max_machines() -> i64 {
|
||||
1
|
||||
}
|
||||
|
||||
/// Centralised validation for the recurring-subscription knobs. Called
|
||||
/// from both create + update paths so the rules stay in one place. We
|
||||
/// reject internally inconsistent combos (recurring=true with period=0,
|
||||
/// trial>renewal period, etc.) so the renewal worker never has to
|
||||
/// defensively normalize bad rows.
|
||||
fn validate_recurring(
|
||||
is_recurring: bool,
|
||||
renewal_period_days: i64,
|
||||
grace_period_days: i64,
|
||||
trial_days: i64,
|
||||
) -> AppResult<()> {
|
||||
if !is_recurring {
|
||||
// Non-recurring policy: ignore the other knobs (they may be
|
||||
// carried in legacy callers).
|
||||
return Ok(());
|
||||
}
|
||||
if renewal_period_days <= 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"renewal_period_days must be > 0 when is_recurring=true".into(),
|
||||
));
|
||||
}
|
||||
if renewal_period_days > 366 * 5 {
|
||||
return Err(AppError::BadRequest(
|
||||
"renewal_period_days unreasonably large (>5 years)".into(),
|
||||
));
|
||||
}
|
||||
if grace_period_days < 0 {
|
||||
return Err(AppError::BadRequest("grace_period_days must be >= 0".into()));
|
||||
}
|
||||
if grace_period_days > 90 {
|
||||
return Err(AppError::BadRequest(
|
||||
"grace_period_days capped at 90 — operators wanting longer should disable lapsing manually".into(),
|
||||
));
|
||||
}
|
||||
if trial_days < 0 {
|
||||
return Err(AppError::BadRequest("trial_days must be >= 0".into()));
|
||||
}
|
||||
if trial_days > renewal_period_days {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"trial_days ({trial_days}) cannot exceed renewal_period_days ({renewal_period_days})"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -104,6 +163,28 @@ pub async fn create(
|
||||
));
|
||||
}
|
||||
let tip_label = req.tip_label.as_deref().filter(|s| !s.trim().is_empty());
|
||||
|
||||
// Recurring config: fall back to migration default (7 days grace) when
|
||||
// operator omits the field. Validation rejects inconsistent combos.
|
||||
let grace_period_days = req.grace_period_days.unwrap_or(7);
|
||||
validate_recurring(
|
||||
req.is_recurring,
|
||||
req.renewal_period_days,
|
||||
grace_period_days,
|
||||
req.trial_days,
|
||||
)?;
|
||||
// Pro-tier gate: only Pro/Patron can create recurring policies. Free
|
||||
// and Creator tiers see a 402 with an upgrade URL.
|
||||
if req.is_recurring {
|
||||
crate::api::tier::enforce_recurring_feature(&state).await?;
|
||||
}
|
||||
let recurring = repo::RecurringConfig {
|
||||
is_recurring: req.is_recurring,
|
||||
renewal_period_days: req.renewal_period_days,
|
||||
grace_period_days,
|
||||
trial_days: req.trial_days,
|
||||
};
|
||||
|
||||
let policy = repo::create_policy(
|
||||
&state.db,
|
||||
&product.id,
|
||||
@@ -119,6 +200,7 @@ pub async fn create(
|
||||
tip_recipient,
|
||||
req.tip_pct_bps,
|
||||
tip_label,
|
||||
recurring,
|
||||
)
|
||||
.await?;
|
||||
let _ = repo::insert_audit(
|
||||
@@ -341,6 +423,17 @@ pub struct UpdatePolicyReq {
|
||||
pub entitlements: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
/// Recurring-subscription knobs. Each is optional (`None` = "leave
|
||||
/// untouched"). Validation rejects internally inconsistent combos
|
||||
/// (e.g. flipping is_recurring=true while leaving renewal_period_days=0).
|
||||
#[serde(default)]
|
||||
pub is_recurring: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub renewal_period_days: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub grace_period_days: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub trial_days: Option<i64>,
|
||||
}
|
||||
|
||||
fn deser_double_option_i64<'de, D>(de: D) -> Result<Option<Option<i64>>, D::Error>
|
||||
@@ -375,6 +468,41 @@ pub async fn update(
|
||||
}
|
||||
}
|
||||
|
||||
// Validate recurring-subscription knobs *if* any are being touched. We
|
||||
// need to load the current row to fill in untouched fields so the
|
||||
// validator sees the post-update shape.
|
||||
if req.is_recurring.is_some()
|
||||
|| req.renewal_period_days.is_some()
|
||||
|| req.grace_period_days.is_some()
|
||||
|| req.trial_days.is_some()
|
||||
{
|
||||
let current = repo::get_policy_by_id(&state.db, &id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("policy '{id}'")))?;
|
||||
let next_is_recurring = req.is_recurring.unwrap_or(current.is_recurring);
|
||||
let next_renewal = req
|
||||
.renewal_period_days
|
||||
.unwrap_or(current.renewal_period_days);
|
||||
let next_grace = req.grace_period_days.unwrap_or(current.grace_period_days);
|
||||
let next_trial = req.trial_days.unwrap_or(current.trial_days);
|
||||
validate_recurring(next_is_recurring, next_renewal, next_grace, next_trial)?;
|
||||
// Pro-tier gate: refuse to flip a policy to recurring on a
|
||||
// tier without `recurring_billing`. We only check on a positive
|
||||
// transition (false → true) — patches that leave is_recurring
|
||||
// alone or turn it OFF are fine for everyone.
|
||||
let was_recurring = current.is_recurring;
|
||||
if !was_recurring && next_is_recurring {
|
||||
crate::api::tier::enforce_recurring_feature(&state).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let recurring_update = repo::RecurringUpdate {
|
||||
is_recurring: req.is_recurring,
|
||||
renewal_period_days: req.renewal_period_days,
|
||||
grace_period_days: req.grace_period_days,
|
||||
trial_days: req.trial_days,
|
||||
};
|
||||
|
||||
let updated = repo::update_policy(
|
||||
&state.db,
|
||||
&id,
|
||||
@@ -386,6 +514,7 @@ pub async fn update(
|
||||
req.price_sats_override,
|
||||
req.entitlements.as_deref(),
|
||||
req.metadata.as_ref(),
|
||||
recurring_update,
|
||||
)
|
||||
.await?;
|
||||
let _ = repo::insert_audit(
|
||||
@@ -403,6 +532,10 @@ pub async fn update(
|
||||
"max_machines": req.max_machines,
|
||||
"price_sats_override": req.price_sats_override,
|
||||
"entitlements": req.entitlements,
|
||||
"is_recurring": req.is_recurring,
|
||||
"renewal_period_days": req.renewal_period_days,
|
||||
"grace_period_days": req.grace_period_days,
|
||||
"trial_days": req.trial_days,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -492,6 +625,11 @@ pub async fn list_public_policies(
|
||||
"is_trial": p.is_trial,
|
||||
"entitlements": p.entitlements,
|
||||
"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,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -212,6 +212,26 @@ pub async fn enforce_policy_cap(state: &AppState, product_id: &str) -> AppResult
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse to mark a policy as recurring unless the operator's self-tier
|
||||
/// carries the `recurring_billing` entitlement. Pro and Patron tiers
|
||||
/// have it; Creator does not. Called from both create-policy and
|
||||
/// update-policy paths so operators can't sneak past by patching a
|
||||
/// non-recurring policy to recurring after creation.
|
||||
pub async fn enforce_recurring_feature(state: &AppState) -> AppResult<()> {
|
||||
let tier = current(state).await;
|
||||
if tier.has("recurring_billing") {
|
||||
return Ok(());
|
||||
}
|
||||
Err(AppError::PaymentRequired {
|
||||
message: format!(
|
||||
"Recurring subscriptions require Pro or Patron. You're on {}. \
|
||||
Upgrade to enable monthly/annual billing.",
|
||||
tier.display_name
|
||||
),
|
||||
upgrade_url: UPGRADE_URL_PRO.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuse a new discount code if the operator is at the Creator-tier
|
||||
/// active-codes cap and lacks `unlimited_codes`. Counts only ACTIVE
|
||||
/// codes — operators can disable old codes to free up slots, which is
|
||||
|
||||
Reference in New Issue
Block a user