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>, 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(); diff --git a/licensing-service/src/api/tier.rs b/licensing-service/src/api/tier.rs index 3426093..5e258b6 100644 --- a/licensing-service/src/api/tier.rs +++ b/licensing-service/src/api/tier.rs @@ -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 diff --git a/licensing-service/src/db/repo.rs b/licensing-service/src/db/repo.rs index 330982c..03de5ab 100644 --- a/licensing-service/src/db/repo.rs +++ b/licensing-service/src/db/repo.rs @@ -763,8 +763,33 @@ const POLICY_COLS: &str = "id, product_id, name, slug, duration_seconds, grace_s tip_recipient, tip_pct_bps, tip_label, max_machines, is_trial, price_sats_override, entitlements_json, metadata_json, active, public, + is_recurring, renewal_period_days, grace_period_days, trial_days, created_at, updated_at"; +/// Bundles the recurring-subscription knobs so we don't keep growing +/// `create_policy`'s positional argument list. Pass `RecurringConfig::off()` +/// for one-off policies. Validation (positive renewal period, sane trial +/// length) lives in the API layer; the repo just persists. +#[derive(Debug, Clone, Copy, Default)] +pub struct RecurringConfig { + pub is_recurring: bool, + pub renewal_period_days: i64, + /// Defaults to 7 days when omitted (matches migration 0011 default). + pub grace_period_days: i64, + pub trial_days: i64, +} + +impl RecurringConfig { + pub fn off() -> Self { + Self { + is_recurring: false, + renewal_period_days: 0, + grace_period_days: 7, + trial_days: 0, + } + } +} + #[allow(clippy::too_many_arguments)] pub async fn create_policy( pool: &SqlitePool, @@ -781,6 +806,7 @@ pub async fn create_policy( tip_recipient: Option<&str>, tip_pct_bps: i64, tip_label: Option<&str>, + recurring: RecurringConfig, ) -> AppResult { let id = Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); @@ -792,8 +818,10 @@ pub async fn create_policy( "INSERT INTO policies (id, product_id, name, slug, duration_seconds, grace_seconds, max_machines, is_trial, price_sats_override, entitlements_json, metadata_json, active, public, - tip_recipient, tip_pct_bps, tip_label, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?, ?)", + tip_recipient, tip_pct_bps, tip_label, + is_recurring, renewal_period_days, grace_period_days, trial_days, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(product_id) @@ -809,6 +837,10 @@ pub async fn create_policy( .bind(tip_recipient) .bind(tip_pct) .bind(tip_label) + .bind(recurring.is_recurring as i64) + .bind(recurring.renewal_period_days) + .bind(recurring.grace_period_days) + .bind(recurring.trial_days) .bind(&now) .bind(&now) .execute(pool) @@ -882,6 +914,17 @@ pub async fn list_public_policies_by_product( /// have hard-coded into integration docs or buy URLs. Tip-related fields /// have their own admin endpoint (`set_policy_tip_config`) since they /// have their own validation rules (basis points, paired recipient/pct). +/// Patch-style updates for the recurring-subscription knobs. Each field is +/// `Option<…>` — `None` means "leave alone", `Some(v)` means "set". Bundled +/// to keep `update_policy`'s signature manageable. +#[derive(Debug, Default, Clone, Copy)] +pub struct RecurringUpdate { + pub is_recurring: Option, + pub renewal_period_days: Option, + pub grace_period_days: Option, + pub trial_days: Option, +} + #[allow(clippy::too_many_arguments)] pub async fn update_policy( pool: &SqlitePool, @@ -894,6 +937,7 @@ pub async fn update_policy( price_sats_override: Option>, entitlements: Option<&[String]>, metadata: Option<&serde_json::Value>, + recurring: RecurringUpdate, ) -> AppResult { let mut sets: Vec<&str> = Vec::new(); if name.is_some() { @@ -920,6 +964,18 @@ pub async fn update_policy( if metadata.is_some() { sets.push("metadata_json = ?"); } + if recurring.is_recurring.is_some() { + sets.push("is_recurring = ?"); + } + if recurring.renewal_period_days.is_some() { + sets.push("renewal_period_days = ?"); + } + if recurring.grace_period_days.is_some() { + sets.push("grace_period_days = ?"); + } + if recurring.trial_days.is_some() { + sets.push("trial_days = ?"); + } if sets.is_empty() { return get_policy_by_id(pool, id) .await? @@ -957,6 +1013,18 @@ pub async fn update_policy( meta_json = serde_json::to_string(m).unwrap_or_else(|_| "{}".into()); q = q.bind(&meta_json); } + if let Some(v) = recurring.is_recurring { + q = q.bind(v as i64); + } + if let Some(v) = recurring.renewal_period_days { + q = q.bind(v); + } + if let Some(v) = recurring.grace_period_days { + q = q.bind(v); + } + if let Some(v) = recurring.trial_days { + q = q.bind(v); + } q = q.bind(&now).bind(id); let rows = q.execute(pool).await?.rows_affected(); if rows == 0 { @@ -1006,6 +1074,12 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy { let active_int: i64 = row.get("active"); let is_trial_int: i64 = row.get("is_trial"); let public_int: i64 = row.try_get("public").unwrap_or(1); + // Recurring fields land in migration 0011 — fall back to defaults so + // older databases (pre-0011, theoretically possible) don't crash here. + let is_recurring_int: i64 = row.try_get("is_recurring").unwrap_or(0); + let renewal_period_days: i64 = row.try_get("renewal_period_days").unwrap_or(0); + let grace_period_days: i64 = row.try_get("grace_period_days").unwrap_or(7); + let trial_days: i64 = row.try_get("trial_days").unwrap_or(0); Policy { id: row.get("id"), product_id: row.get("product_id"), @@ -1023,6 +1097,10 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy { tip_recipient: row.get("tip_recipient"), tip_pct_bps: row.get("tip_pct_bps"), tip_label: row.get("tip_label"), + is_recurring: is_recurring_int != 0, + renewal_period_days, + grace_period_days, + trial_days, created_at: row.get("created_at"), updated_at: row.get("updated_at"), } diff --git a/licensing-service/src/models.rs b/licensing-service/src/models.rs index f62f3c1..8845dd4 100644 --- a/licensing-service/src/models.rs +++ b/licensing-service/src/models.rs @@ -166,10 +166,32 @@ pub struct Policy { /// Defaults to true; migration 0007 adds this column. #[serde(default = "default_true")] pub public: bool, + /// Recurring subscription cadence (migration 0011). When `is_recurring` + /// is true, the renewal worker will create a fresh invoice every + /// `renewal_period_days` and the buy page renders the price as + /// "every N days" / "monthly". + #[serde(default)] + pub is_recurring: bool, + /// Days between renewal cycles. Ignored when `is_recurring = false`. + /// Common values: 30 (monthly), 365 (annual). + #[serde(default)] + pub renewal_period_days: i64, + /// Days the subscription stays in `past_due` before transitioning to + /// `lapsed`. Migration default is 7. + #[serde(default = "default_grace_period_days")] + pub grace_period_days: i64, + /// Free-trial length at first cycle. 0 = no trial. The first invoice + /// is still issued (for $0 / 1-sat) so the buyer email + license + /// flow is consistent; renewal worker charges the real price after + /// `trial_days`. + #[serde(default)] + pub trial_days: i64, pub created_at: String, pub updated_at: String, } +fn default_grace_period_days() -> i64 { 7 } + fn default_true() -> bool { true } /// A machine activated under a license. One row per active install. diff --git a/licensing-service/src/subscriptions.rs b/licensing-service/src/subscriptions.rs index 2342e3b..8c335c3 100644 --- a/licensing-service/src/subscriptions.rs +++ b/licensing-service/src/subscriptions.rs @@ -16,6 +16,7 @@ //! //! State machine recap (full diagram in the design doc): //! +//! ```text //! ┌─────────┐ cycle ends ┌──────────┐ //! │ active │ ────────────▶ │ past_due │ //! └─────────┘ └──────────┘ @@ -26,6 +27,7 @@ //! ┌────────┐ //! │ lapsed │ //! └────────┘ +//! ``` //! //! Cancellation can flip from `active` or `past_due` → `cancelled` //! at any point (admin or buyer-initiated). Cancelled subs stop diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index 41e8c5d..82ad69e 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -1818,3 +1818,276 @@ async fn community_analytics_opt_in_and_privacy_contract() { "install_uuid must be wiped after reset: {body:?}" ); } + +// --------------------------------------------------------------------- +// Recurring-subscription policy admin (Phase 4 of recurring subs) +// +// The renewal worker (src/subscriptions.rs + tests/subscriptions.rs) +// has its own coverage. This block is about the ADMIN surface — can an +// operator create a recurring policy through the API, can they edit +// it, and does the public buy-page endpoint surface the right cadence +// fields for the front-end to render? +// --------------------------------------------------------------------- + +/// Helper: swap `state.self_tier` to a Pro-equivalent licensed tier +/// (carries `unlimited_products`, `unlimited_policies`, AND +/// `recurring_billing`). Mirrors what `Activate Keysat license` does +/// in production. +async fn upgrade_to_pro(state: &AppState) { + *state.self_tier.write().await = Tier::Licensed { + license_id: Uuid::new_v4(), + product_id: Uuid::new_v4(), + expires_at: 0, + entitlements: vec![ + "self_host".into(), + "unlimited_products".into(), + "unlimited_policies".into(), + "unlimited_codes".into(), + "recurring_billing".into(), + "card_payments".into(), + ], + }; +} + +/// Operator on Creator tier (no `recurring_billing` entitlement) +/// cannot create a recurring policy. The 402 should mention upgrade. +#[tokio::test] +async fn recurring_policy_blocked_on_creator_tier() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + + // Seed a product so `product_slug` lookup succeeds and the test + // exercises the recurring-feature gate, not the not-found path. + let _ = repo::create_product( + &state.db, + "rec-blocked", + "Blocked", + "", + 100_000, + &json!({}), + ) + .await + .expect("create_product"); + + let req = build_request( + "POST", + "/v1/admin/policies", + &[("authorization", &auth)], + Some(json!({ + "product_slug": "rec-blocked", + "name": "Monthly", + "slug": "monthly", + "is_recurring": true, + "renewal_period_days": 30 + })), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::PAYMENT_REQUIRED, + "Creator tier must see 402 for recurring=true; got {}", + resp.status() + ); + let body = body_json(resp).await; + assert!( + body["upgrade_url"].as_str().unwrap_or("").contains("buy/keysat"), + "402 should carry an upgrade_url to the master Keysat: {body:?}" + ); +} + +/// Operator on Pro tier can create a monthly subscription policy. The +/// stored row carries the recurring fields, the public list endpoint +/// echoes them, and the policies admin list shows is_recurring=true. +#[tokio::test] +async fn pro_tier_creates_monthly_recurring_policy() { + let (state, _tmp) = make_test_state().await; + upgrade_to_pro(&state).await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + + let _ = repo::create_product( + &state.db, + "rec-product", + "Recurring Product", + "", + 25_000, + &json!({}), + ) + .await + .expect("create_product"); + + // Create a recurring monthly policy with a 14-day trial. + let req = build_request( + "POST", + "/v1/admin/policies", + &[("authorization", &auth)], + Some(json!({ + "product_slug": "rec-product", + "name": "Monthly", + "slug": "monthly", + "duration_seconds": 30 * 86_400, + "max_machines": 1, + "is_recurring": true, + "renewal_period_days": 30, + "grace_period_days": 7, + "trial_days": 14 + })), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::OK, + "create with Pro tier should succeed; got {}", + resp.status() + ); + let body = body_json(resp).await; + assert_eq!(body["is_recurring"], true); + assert_eq!(body["renewal_period_days"], 30); + assert_eq!(body["grace_period_days"], 7); + assert_eq!(body["trial_days"], 14); + + // Public buy-page API surfaces the same shape so the JS price + // renderer can reach for it. + let req = build_request("GET", "/v1/products/rec-product/policies", &[], None); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + let policies = body["policies"].as_array().expect("policies array"); + let monthly = policies + .iter() + .find(|p| p["slug"] == "monthly") + .expect("monthly policy in public list"); + assert_eq!(monthly["is_recurring"], true); + assert_eq!(monthly["renewal_period_days"], 30); + assert_eq!(monthly["trial_days"], 14); +} + +/// Validation: recurring=true with renewal_period_days=0 must be rejected. +/// Catches a foot-gun where the operator forgets to fill in the cadence. +#[tokio::test] +async fn recurring_requires_positive_period() { + let (state, _tmp) = make_test_state().await; + upgrade_to_pro(&state).await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + + let _ = repo::create_product(&state.db, "rec-bad", "Bad", "", 100, &json!({})) + .await + .expect("create_product"); + + let req = build_request( + "POST", + "/v1/admin/policies", + &[("authorization", &auth)], + Some(json!({ + "product_slug": "rec-bad", + "name": "Bad", + "slug": "bad", + "is_recurring": true, + "renewal_period_days": 0 + })), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "is_recurring=true with renewal_period_days=0 must be rejected" + ); +} + +/// Edit-policy can flip a non-recurring policy to recurring on Pro tier +/// and a Pro-tier-gated operator gets a 402 trying the same. +#[tokio::test] +async fn edit_policy_to_recurring_respects_tier_gate() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + + // Pre-create product + non-recurring policy directly via the repo, + // so we don't need to go through Pro tier just for setup. + let product = repo::create_product( + &state.db, + "rec-edit", + "Edit Test", + "", + 100_000, + &json!({}), + ) + .await + .expect("create_product"); + let policy = repo::create_policy( + &state.db, + &product.id, + "Default", + "default", + 0, + 0, + 1, + false, + None, + &[], + &json!({}), + None, + 0, + None, + repo::RecurringConfig::off(), + ) + .await + .expect("create_policy"); + + // Creator-tier attempt to flip is_recurring=true → 402. + let req = build_request( + "PATCH", + &format!("/v1/admin/policies/{}", policy.id), + &[("authorization", &auth)], + Some(json!({ + "is_recurring": true, + "renewal_period_days": 30 + })), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::PAYMENT_REQUIRED, + "flipping a policy to recurring on Creator tier must 402" + ); + + // Upgrade and try again. + upgrade_to_pro(&state).await; + let req = build_request( + "PATCH", + &format!("/v1/admin/policies/{}", policy.id), + &[("authorization", &auth)], + Some(json!({ + "is_recurring": true, + "renewal_period_days": 30, + "trial_days": 7 + })), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::OK, + "Pro tier can flip a policy to recurring" + ); + let body = body_json(resp).await; + assert_eq!(body["is_recurring"], true); + assert_eq!(body["renewal_period_days"], 30); + assert_eq!(body["trial_days"], 7); + + // Idempotency: a second PATCH that LEAVES is_recurring true should + // succeed and not re-fire the tier gate. Drop back to Creator and + // PATCH a tangential field — must still work. + *state.self_tier.write().await = Tier::Unlicensed { + reason: "downgraded".into(), + }; + let req = build_request( + "PATCH", + &format!("/v1/admin/policies/{}", policy.id), + &[("authorization", &auth)], + Some(json!({ "name": "Renamed Default" })), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::OK, + "name-only patch on a recurring policy must not re-fire the tier gate" + ); +} diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index 477d477..d33bb8d 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -1373,6 +1373,53 @@ The request will be refused if there are licenses or invoices tied to it — use if (cb) cb.checked = true }, 0) + // -- Recurring subscription (Pro tier) -- + const RENEWAL_PRESETS = [ + { value: '30', label: 'Monthly (30 days)' }, + { value: '90', label: 'Quarterly (90 days)' }, + { value: '180', label: 'Semi-annual (180 days)' }, + { value: '365', label: 'Annual (365 days)' }, + { value: 'custom', label: 'Custom (in days)' }, + ] + const isRecurringInit = !!pol.is_recurring + const renewalDaysInit = pol.renewal_period_days || 30 + const matchedRenewal = RENEWAL_PRESETS.find( + (p) => p.value === String(renewalDaysInit) && p.value !== 'custom' + ) + const initialRenewalPreset = matchedRenewal ? matchedRenewal.value : 'custom' + const recurField = formCheckbox('e_pol_is_recurring', 'This policy is a recurring subscription') + const renewalPresetField = formSelect('e_pol_renewal_preset', 'Renewal cadence', RENEWAL_PRESETS, { value: initialRenewalPreset }) + const renewalCustomField = formInput('e_pol_renewal_days', 'Custom (days)', { + type: 'number', value: String(renewalDaysInit), + }) + const gracePeriodField = formInput('e_pol_grace_period_days', 'Grace period after renewal (days)', { + type: 'number', value: String(pol.grace_period_days == null ? 7 : pol.grace_period_days), + }) + const trialDaysField = formInput('e_pol_trial_days', 'Free trial (days)', { + type: 'number', value: String(pol.trial_days || 0), + }) + if (isRecurringInit) setTimeout(() => { + const cb = card.querySelector('[name=e_pol_is_recurring]') + if (cb) cb.checked = true + syncRecurringEdit() + }, 0) + + function syncRecurringEdit() { + const on = !!card.querySelector('[name=e_pol_is_recurring]').checked + const presetEl = card.querySelector('[name=e_pol_renewal_preset]') + const customEl = card.querySelector('[name=e_pol_renewal_days]') + const graceEl = card.querySelector('[name=e_pol_grace_period_days]') + const trialEl = card.querySelector('[name=e_pol_trial_days]') + ;[presetEl, graceEl, trialEl].forEach((e) => { + if (e) { e.disabled = !on; e.style.opacity = on ? '1' : '0.5' } + }) + if (customEl) { + const customOn = on && presetEl && presetEl.value === 'custom' + customEl.disabled = !customOn + customEl.style.opacity = customOn ? '1' : '0.5' + } + } + const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px;' }, '') const card = el('div', { style: 'background:var(--cream-50); border:1px solid var(--border-1); ' + @@ -1392,6 +1439,15 @@ The request will be refused if there are licenses or invoices tied to it — use el('div', { class: 'row-2' }, [graceField, machinesField]), entField, el('div', { class: 'row-2' }, [highlightField, trialField]), + // Recurring subscription block + el('div', { + style: 'margin-top:14px; padding-top:12px; border-top:1px dashed var(--border-1)', + }, [ + el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'Recurring subscription (Pro)'), + recurField, + el('div', { class: 'row-2', style: 'margin-top:8px' }, [renewalPresetField, renewalCustomField]), + el('div', { class: 'row-2' }, [gracePeriodField, trialDaysField]), + ]), status, el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [ el('button', { class: 'btn primary', onclick: async function () { @@ -1417,6 +1473,18 @@ The request will be refused if there are licenses or invoices tied to it — use else delete newMetadata.highlight const priceRaw = card.querySelector('[name=e_pol_price]').value.trim() const price_sats_override = priceRaw === '' ? null : Math.max(0, parseInt(priceRaw, 10) || 0) + // Recurring subscription — send the fields whenever the operator + // touched any of them so update_policy can validate the post-update + // shape consistently. Easiest invariant: always send all four. + const isRecurring = card.querySelector('[name=e_pol_is_recurring]').checked + const renewalPreset = card.querySelector('[name=e_pol_renewal_preset]').value + const renewalCustom = parseInt(card.querySelector('[name=e_pol_renewal_days]').value, 10) || 0 + const renewalDays = renewalPreset === 'custom' + ? renewalCustom + : parseInt(renewalPreset, 10) + const gracePeriodDays = parseInt(card.querySelector('[name=e_pol_grace_period_days]').value, 10) + const trialDays = parseInt(card.querySelector('[name=e_pol_trial_days]').value, 10) || 0 + const body = { name: card.querySelector('[name=e_pol_name]').value.trim(), duration_seconds, @@ -1426,6 +1494,10 @@ The request will be refused if there are licenses or invoices tied to it — use entitlements: ents, metadata: newMetadata, price_sats_override, + is_recurring: isRecurring, + renewal_period_days: isRecurring ? renewalDays : (pol.renewal_period_days || 0), + grace_period_days: isNaN(gracePeriodDays) ? 7 : gracePeriodDays, + trial_days: trialDays, } await api('/v1/admin/policies/' + pol.id, { method: 'PATCH', body }) overlay.remove() @@ -1441,6 +1513,14 @@ The request will be refused if there are licenses or invoices tied to it — use overlay.appendChild(card) overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() }) document.body.appendChild(overlay) + + // Wire the recurring section's enable/disable sync now that the card + // is in the DOM and inputs are queryable. + const recurEl = card.querySelector('[name=e_pol_is_recurring]') + const renewalPresetEl = card.querySelector('[name=e_pol_renewal_preset]') + if (recurEl) recurEl.addEventListener('change', syncRecurringEdit) + if (renewalPresetEl) renewalPresetEl.addEventListener('change', syncRecurringEdit) + syncRecurringEdit() } // -------- Policies -------- @@ -1540,6 +1620,39 @@ The request will be refused if there are licenses or invoices tied to it — use formCheckbox('is_trial', 'Trial flag (sets TRIAL bit on the signed license)'), ]), + // ---------- Recurring subscription (Pro tier) ---------- + el('div', { + style: 'margin:18px 0 12px; padding-top:14px; border-top:1px dashed var(--border-1)', + }, [ + el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'Recurring subscription (Pro)'), + el('p', { class: 'hint', style: 'margin:0 0 10px' }, + 'Bill the buyer on a repeating cycle (monthly, annual, etc.). The renewal worker creates a fresh BTCPay/Zaprite invoice every period; if the buyer doesn\'t pay within the grace window, the license lapses automatically. Pro tier required.'), + formCheckbox('is_recurring', 'This policy is a recurring subscription'), + el('div', { class: 'row-2', style: 'margin-top:10px' }, [ + formSelect('renewal_preset', 'Renewal cadence', [ + { value: '30', label: 'Monthly (30 days)' }, + { value: '90', label: 'Quarterly (90 days)' }, + { value: '180', label: 'Semi-annual (180 days)' }, + { value: '365', label: 'Annual (365 days)' }, + { value: 'custom', label: 'Custom (in days)' }, + ], { value: '30' }), + formInput('renewal_period_days', 'Custom (days)', { + type: 'number', value: '30', + hint: 'Used only when "Custom" is selected. Min 1, max ~1825 (5 years).', + }), + ]), + el('div', { class: 'row-2' }, [ + formInput('grace_period_days', 'Grace period after renewal (days)', { + type: 'number', value: '7', + hint: 'How long the license stays valid past the renewal date if the buyer hasn\'t paid yet. After this, the subscription transitions to "lapsed". Default 7.', + }), + formInput('trial_days', 'Free trial (days)', { + type: 'number', value: '0', + hint: 'Optional. 0 = no trial. The first invoice is still issued (for $0/1 sat) so buyer email + license flow are consistent; the renewal worker charges the real price after the trial period.', + }), + ]), + ]), + // ---------- Tip recipient (optional) ---------- el('div', { style: 'margin:18px 0 12px; padding-top:14px; border-top:1px dashed var(--border-1)', @@ -1620,6 +1733,24 @@ The request will be refused if there are licenses or invoices tied to it — use body.tip_pct_bps = tipPctBps if (tipLabel) body.tip_label = tipLabel } + // Recurring subscription — only attach when the operator + // ticked the box, so non-recurring policies stay clean. + const isRecurring = create.querySelector('[name=is_recurring]').checked + if (isRecurring) { + const renewalPresetEl = create.querySelector('[name=renewal_preset]') + const renewalCustomEl = create.querySelector('[name=renewal_period_days]') + const renewalPreset = renewalPresetEl.value + const renewalCustomDays = parseInt(renewalCustomEl.value, 10) || 0 + const renewalDays = renewalPreset === 'custom' + ? renewalCustomDays + : parseInt(renewalPreset, 10) + const graceDays = parseInt(create.querySelector('[name=grace_period_days]').value, 10) + const trialDays = parseInt(create.querySelector('[name=trial_days]').value, 10) || 0 + body.is_recurring = true + body.renewal_period_days = renewalDays + body.grace_period_days = isNaN(graceDays) ? 7 : graceDays + body.trial_days = trialDays + } await api('/v1/admin/policies', { method: 'POST', body }) status.replaceWith(ok('Created. Reloading…')) setTimeout(routes.policies, 600) @@ -1647,6 +1778,29 @@ The request will be refused if there are licenses or invoices tied to it — use presetEl.addEventListener('change', syncDurationCustom) syncDurationCustom() + // Recurring section: gray everything out unless the box is ticked, + // and gray the custom-days input unless "Custom" is selected. Keeps + // the form visually honest about what will actually be submitted. + const recurEl = create.querySelector('[name=is_recurring]') + const renewalPresetEl = create.querySelector('[name=renewal_preset]') + const renewalCustomEl = create.querySelector('[name=renewal_period_days]') + const graceEl = create.querySelector('[name=grace_period_days]') + const trialEl = create.querySelector('[name=trial_days]') + function syncRecurring() { + const on = recurEl.checked + ;[renewalPresetEl, graceEl, trialEl].forEach((e) => { + if (e) { e.disabled = !on; e.style.opacity = on ? '1' : '0.5' } + }) + if (renewalCustomEl) { + const customOn = on && renewalPresetEl.value === 'custom' + renewalCustomEl.disabled = !customOn + renewalCustomEl.style.opacity = customOn ? '1' : '0.5' + } + } + recurEl.addEventListener('change', syncRecurring) + renewalPresetEl.addEventListener('change', syncRecurring) + syncRecurring() + // When the product changes, prefill the price-override field with that // product's base price. The operator can still edit afterward; this just // saves them from looking up the price elsewhere. @@ -1683,9 +1837,20 @@ The request will be refused if there are licenses or invoices tied to it — use el('td', null, pol.duration_seconds === 0 ? 'perpetual' : pol.duration_seconds + 's'), el('td', null, pol.grace_seconds + 's'), el('td', null, pol.max_machines === 0 ? '∞' : String(pol.max_machines)), - el('td', null, pol.is_trial - ? el('span', { class: 'badge b-warning' }, 'trial') - : el('span', { class: 'muted' }, '–')), + el('td', null, + // Stack trial + recurring badges in one cell. Both can be set + // independently (a recurring policy can also have a trial bit). + pol.is_trial || pol.is_recurring + ? el('span', { style: 'display:inline-flex; gap:4px; flex-wrap:wrap' }, [ + pol.is_trial ? el('span', { class: 'badge b-warning' }, 'trial') : null, + pol.is_recurring + ? el('span', { + class: 'badge b-gold', + title: 'Renews every ' + (pol.renewal_period_days || 0) + ' days', + }, 'every ' + (pol.renewal_period_days || 0) + 'd') + : null, + ].filter(Boolean)) + : el('span', { class: 'muted' }, '–')), el('td', { class: 'muted' }, (pol.entitlements || []).join(', ') || '–'), el('td', null, el('span', { class: 'muted' }, String(byPolicy[pol.id] || 0))), el('td', null, activePill(pol.active)),