diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs
index 84ac35f..b117ab7 100644
--- a/licensing-service/src/api/buy_page.rs
+++ b/licensing-service/src/api/buy_page.rs
@@ -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!(
+ "
{} day free trial
",
+ p.trial_days
+ )
+ } else {
+ String::new()
+ };
+ (
+ suffix,
+ format!("{}
", html_escape(&label)),
+ trial_banner,
+ )
+ } else {
+ ("", String::new(), String::new())
+ };
format!(
- r#"{popular_pill}
{name}
{price_fmt}{price_unit}
{dur_html}{trial_meta}{description_html}{entitlements_html}
"#,
+ r#"{popular_pill}
{name}
{price_fmt}{price_unit}{cadence_suffix}
{dur_html}{recurring_meta}{trial_banner}{trial_meta}{description_html}{entitlements_html}
"#,
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,
}),
);
}
diff --git a/licensing-service/src/api/policies.rs b/licensing-service/src/api/policies.rs
index 839393d..015af36 100644
--- a/licensing-service/src/api/policies.rs
+++ b/licensing-service/src/api/policies.rs
@@ -52,12 +52,71 @@ pub struct CreatePolicyReq {
/// Free-form label for the tip recipient (audit/UI).
#[serde(default)]
pub tip_label: Option,
+ /// 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,
+ /// 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,
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>,
#[serde(default)]
pub metadata: Option,
+ /// 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,
+ #[serde(default)]
+ pub renewal_period_days: Option,
+ #[serde(default)]
+ pub grace_period_days: Option,
+ #[serde(default)]
+ pub trial_days: Option,
}
fn deser_double_option_i64<'de, D>(de: D) -> Result