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
|
// Reflect new base price in the cert card. For fiat-priced
|
||||||
// products the unit cell ("sats" → "USD" / "EUR") also swaps.
|
// 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 t = TIERS[slug];
|
||||||
const fmt = formatTierPrice(t);
|
const fmt = formatTierPrice(t);
|
||||||
currentBaseFmt = fmt.amount;
|
currentBaseFmt = fmt.amount;
|
||||||
priceStrike.style.display = 'none';
|
priceStrike.style.display = 'none';
|
||||||
priceTag.style.display = 'none';
|
priceTag.style.display = 'none';
|
||||||
const unitEl = document.querySelector('.unit');
|
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;
|
if (priceLabel) priceLabel.textContent = 'Price · ' + t.name;
|
||||||
// Free tier: render "FREE", swap CTA to "Redeem license" so the
|
// Free tier: render "FREE", swap CTA to "Redeem license" so the
|
||||||
// buyer never sees "Pay with Bitcoin" for a 0-amount product.
|
// buyer never sees "Pay with Bitcoin" for a 0-amount product.
|
||||||
@@ -963,15 +977,55 @@ fn render_tier_picker(
|
|||||||
} else {
|
} else {
|
||||||
String::new()
|
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!(
|
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,
|
classes = classes,
|
||||||
slug = slug_attr,
|
slug = slug_attr,
|
||||||
popular_pill = popular_pill,
|
popular_pill = popular_pill,
|
||||||
name = name,
|
name = name,
|
||||||
price_fmt = price_fmt,
|
price_fmt = price_fmt,
|
||||||
price_unit = price_unit,
|
price_unit = price_unit,
|
||||||
|
cadence_suffix = cadence_suffix,
|
||||||
dur_html = dur_html,
|
dur_html = dur_html,
|
||||||
|
recurring_meta = recurring_meta,
|
||||||
|
trial_banner = trial_banner,
|
||||||
trial_meta = trial_meta,
|
trial_meta = trial_meta,
|
||||||
description_html = description_html,
|
description_html = description_html,
|
||||||
entitlements_html = entitlements_html,
|
entitlements_html = entitlements_html,
|
||||||
@@ -1017,6 +1071,9 @@ fn build_tiers_json(
|
|||||||
"price_sats": price_sats_value,
|
"price_sats": price_sats_value,
|
||||||
"price_currency": product.price_currency,
|
"price_currency": product.price_currency,
|
||||||
"price_value": price_value,
|
"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).
|
/// Free-form label for the tip recipient (audit/UI).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tip_label: Option<String>,
|
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 {
|
fn default_max_machines() -> i64 {
|
||||||
1
|
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(
|
pub async fn create(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -104,6 +163,28 @@ pub async fn create(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let tip_label = req.tip_label.as_deref().filter(|s| !s.trim().is_empty());
|
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(
|
let policy = repo::create_policy(
|
||||||
&state.db,
|
&state.db,
|
||||||
&product.id,
|
&product.id,
|
||||||
@@ -119,6 +200,7 @@ pub async fn create(
|
|||||||
tip_recipient,
|
tip_recipient,
|
||||||
req.tip_pct_bps,
|
req.tip_pct_bps,
|
||||||
tip_label,
|
tip_label,
|
||||||
|
recurring,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let _ = repo::insert_audit(
|
let _ = repo::insert_audit(
|
||||||
@@ -341,6 +423,17 @@ pub struct UpdatePolicyReq {
|
|||||||
pub entitlements: Option<Vec<String>>,
|
pub entitlements: Option<Vec<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub metadata: Option<serde_json::Value>,
|
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>
|
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(
|
let updated = repo::update_policy(
|
||||||
&state.db,
|
&state.db,
|
||||||
&id,
|
&id,
|
||||||
@@ -386,6 +514,7 @@ pub async fn update(
|
|||||||
req.price_sats_override,
|
req.price_sats_override,
|
||||||
req.entitlements.as_deref(),
|
req.entitlements.as_deref(),
|
||||||
req.metadata.as_ref(),
|
req.metadata.as_ref(),
|
||||||
|
recurring_update,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let _ = repo::insert_audit(
|
let _ = repo::insert_audit(
|
||||||
@@ -403,6 +532,10 @@ pub async fn update(
|
|||||||
"max_machines": req.max_machines,
|
"max_machines": req.max_machines,
|
||||||
"price_sats_override": req.price_sats_override,
|
"price_sats_override": req.price_sats_override,
|
||||||
"entitlements": req.entitlements,
|
"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;
|
.await;
|
||||||
@@ -492,6 +625,11 @@ pub async fn list_public_policies(
|
|||||||
"is_trial": p.is_trial,
|
"is_trial": p.is_trial,
|
||||||
"entitlements": p.entitlements,
|
"entitlements": p.entitlements,
|
||||||
"highlighted": highlighted,
|
"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();
|
.collect();
|
||||||
|
|||||||
@@ -212,6 +212,26 @@ pub async fn enforce_policy_cap(state: &AppState, product_id: &str) -> AppResult
|
|||||||
Ok(())
|
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
|
/// Refuse a new discount code if the operator is at the Creator-tier
|
||||||
/// active-codes cap and lacks `unlimited_codes`. Counts only ACTIVE
|
/// active-codes cap and lacks `unlimited_codes`. Counts only ACTIVE
|
||||||
/// codes — operators can disable old codes to free up slots, which is
|
/// codes — operators can disable old codes to free up slots, which is
|
||||||
|
|||||||
@@ -763,8 +763,33 @@ const POLICY_COLS: &str = "id, product_id, name, slug, duration_seconds, grace_s
|
|||||||
tip_recipient, tip_pct_bps, tip_label,
|
tip_recipient, tip_pct_bps, tip_label,
|
||||||
max_machines, is_trial, price_sats_override,
|
max_machines, is_trial, price_sats_override,
|
||||||
entitlements_json, metadata_json, active, public,
|
entitlements_json, metadata_json, active, public,
|
||||||
|
is_recurring, renewal_period_days, grace_period_days, trial_days,
|
||||||
created_at, updated_at";
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn create_policy(
|
pub async fn create_policy(
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
@@ -781,6 +806,7 @@ pub async fn create_policy(
|
|||||||
tip_recipient: Option<&str>,
|
tip_recipient: Option<&str>,
|
||||||
tip_pct_bps: i64,
|
tip_pct_bps: i64,
|
||||||
tip_label: Option<&str>,
|
tip_label: Option<&str>,
|
||||||
|
recurring: RecurringConfig,
|
||||||
) -> AppResult<Policy> {
|
) -> AppResult<Policy> {
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
@@ -792,8 +818,10 @@ pub async fn create_policy(
|
|||||||
"INSERT INTO policies
|
"INSERT INTO policies
|
||||||
(id, product_id, name, slug, duration_seconds, grace_seconds, max_machines,
|
(id, product_id, name, slug, duration_seconds, grace_seconds, max_machines,
|
||||||
is_trial, price_sats_override, entitlements_json, metadata_json, active, public,
|
is_trial, price_sats_override, entitlements_json, metadata_json, active, public,
|
||||||
tip_recipient, tip_pct_bps, tip_label, created_at, updated_at)
|
tip_recipient, tip_pct_bps, tip_label,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?, ?)",
|
is_recurring, renewal_period_days, grace_period_days, trial_days,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(product_id)
|
.bind(product_id)
|
||||||
@@ -809,6 +837,10 @@ pub async fn create_policy(
|
|||||||
.bind(tip_recipient)
|
.bind(tip_recipient)
|
||||||
.bind(tip_pct)
|
.bind(tip_pct)
|
||||||
.bind(tip_label)
|
.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)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(pool)
|
.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 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 admin endpoint (`set_policy_tip_config`) since they
|
||||||
/// have their own validation rules (basis points, paired recipient/pct).
|
/// 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<bool>,
|
||||||
|
pub renewal_period_days: Option<i64>,
|
||||||
|
pub grace_period_days: Option<i64>,
|
||||||
|
pub trial_days: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn update_policy(
|
pub async fn update_policy(
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
@@ -894,6 +937,7 @@ pub async fn update_policy(
|
|||||||
price_sats_override: Option<Option<i64>>,
|
price_sats_override: Option<Option<i64>>,
|
||||||
entitlements: Option<&[String]>,
|
entitlements: Option<&[String]>,
|
||||||
metadata: Option<&serde_json::Value>,
|
metadata: Option<&serde_json::Value>,
|
||||||
|
recurring: RecurringUpdate,
|
||||||
) -> AppResult<Policy> {
|
) -> AppResult<Policy> {
|
||||||
let mut sets: Vec<&str> = Vec::new();
|
let mut sets: Vec<&str> = Vec::new();
|
||||||
if name.is_some() {
|
if name.is_some() {
|
||||||
@@ -920,6 +964,18 @@ pub async fn update_policy(
|
|||||||
if metadata.is_some() {
|
if metadata.is_some() {
|
||||||
sets.push("metadata_json = ?");
|
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() {
|
if sets.is_empty() {
|
||||||
return get_policy_by_id(pool, id)
|
return get_policy_by_id(pool, id)
|
||||||
.await?
|
.await?
|
||||||
@@ -957,6 +1013,18 @@ pub async fn update_policy(
|
|||||||
meta_json = serde_json::to_string(m).unwrap_or_else(|_| "{}".into());
|
meta_json = serde_json::to_string(m).unwrap_or_else(|_| "{}".into());
|
||||||
q = q.bind(&meta_json);
|
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);
|
q = q.bind(&now).bind(id);
|
||||||
let rows = q.execute(pool).await?.rows_affected();
|
let rows = q.execute(pool).await?.rows_affected();
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
@@ -1006,6 +1074,12 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
|
|||||||
let active_int: i64 = row.get("active");
|
let active_int: i64 = row.get("active");
|
||||||
let is_trial_int: i64 = row.get("is_trial");
|
let is_trial_int: i64 = row.get("is_trial");
|
||||||
let public_int: i64 = row.try_get("public").unwrap_or(1);
|
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 {
|
Policy {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
product_id: row.get("product_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_recipient: row.get("tip_recipient"),
|
||||||
tip_pct_bps: row.get("tip_pct_bps"),
|
tip_pct_bps: row.get("tip_pct_bps"),
|
||||||
tip_label: row.get("tip_label"),
|
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"),
|
created_at: row.get("created_at"),
|
||||||
updated_at: row.get("updated_at"),
|
updated_at: row.get("updated_at"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,10 +166,32 @@ pub struct Policy {
|
|||||||
/// Defaults to true; migration 0007 adds this column.
|
/// Defaults to true; migration 0007 adds this column.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub public: bool,
|
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 created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_grace_period_days() -> i64 { 7 }
|
||||||
|
|
||||||
fn default_true() -> bool { true }
|
fn default_true() -> bool { true }
|
||||||
|
|
||||||
/// A machine activated under a license. One row per active install.
|
/// A machine activated under a license. One row per active install.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
//!
|
//!
|
||||||
//! State machine recap (full diagram in the design doc):
|
//! State machine recap (full diagram in the design doc):
|
||||||
//!
|
//!
|
||||||
|
//! ```text
|
||||||
//! ┌─────────┐ cycle ends ┌──────────┐
|
//! ┌─────────┐ cycle ends ┌──────────┐
|
||||||
//! │ active │ ────────────▶ │ past_due │
|
//! │ active │ ────────────▶ │ past_due │
|
||||||
//! └─────────┘ └──────────┘
|
//! └─────────┘ └──────────┘
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
//! ┌────────┐
|
//! ┌────────┐
|
||||||
//! │ lapsed │
|
//! │ lapsed │
|
||||||
//! └────────┘
|
//! └────────┘
|
||||||
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Cancellation can flip from `active` or `past_due` → `cancelled`
|
//! Cancellation can flip from `active` or `past_due` → `cancelled`
|
||||||
//! at any point (admin or buyer-initiated). Cancelled subs stop
|
//! at any point (admin or buyer-initiated). Cancelled subs stop
|
||||||
|
|||||||
@@ -1818,3 +1818,276 @@ async fn community_analytics_opt_in_and_privacy_contract() {
|
|||||||
"install_uuid must be wiped after reset: {body:?}"
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1373,6 +1373,53 @@ The request will be refused if there are licenses or invoices tied to it — use
|
|||||||
if (cb) cb.checked = true
|
if (cb) cb.checked = true
|
||||||
}, 0)
|
}, 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 status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px;' }, '')
|
||||||
const card = el('div', {
|
const card = el('div', {
|
||||||
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
|
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]),
|
el('div', { class: 'row-2' }, [graceField, machinesField]),
|
||||||
entField,
|
entField,
|
||||||
el('div', { class: 'row-2' }, [highlightField, trialField]),
|
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,
|
status,
|
||||||
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
|
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
|
||||||
el('button', { class: 'btn primary', onclick: async function () {
|
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
|
else delete newMetadata.highlight
|
||||||
const priceRaw = card.querySelector('[name=e_pol_price]').value.trim()
|
const priceRaw = card.querySelector('[name=e_pol_price]').value.trim()
|
||||||
const price_sats_override = priceRaw === '' ? null : Math.max(0, parseInt(priceRaw, 10) || 0)
|
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 = {
|
const body = {
|
||||||
name: card.querySelector('[name=e_pol_name]').value.trim(),
|
name: card.querySelector('[name=e_pol_name]').value.trim(),
|
||||||
duration_seconds,
|
duration_seconds,
|
||||||
@@ -1426,6 +1494,10 @@ The request will be refused if there are licenses or invoices tied to it — use
|
|||||||
entitlements: ents,
|
entitlements: ents,
|
||||||
metadata: newMetadata,
|
metadata: newMetadata,
|
||||||
price_sats_override,
|
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 })
|
await api('/v1/admin/policies/' + pol.id, { method: 'PATCH', body })
|
||||||
overlay.remove()
|
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.appendChild(card)
|
||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||||||
document.body.appendChild(overlay)
|
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 --------
|
// -------- 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)'),
|
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) ----------
|
// ---------- Tip recipient (optional) ----------
|
||||||
el('div', {
|
el('div', {
|
||||||
style: 'margin:18px 0 12px; padding-top:14px; border-top:1px dashed var(--border-1)',
|
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
|
body.tip_pct_bps = tipPctBps
|
||||||
if (tipLabel) body.tip_label = tipLabel
|
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 })
|
await api('/v1/admin/policies', { method: 'POST', body })
|
||||||
status.replaceWith(ok('Created. Reloading…'))
|
status.replaceWith(ok('Created. Reloading…'))
|
||||||
setTimeout(routes.policies, 600)
|
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)
|
presetEl.addEventListener('change', syncDurationCustom)
|
||||||
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
|
// When the product changes, prefill the price-override field with that
|
||||||
// product's base price. The operator can still edit afterward; this just
|
// product's base price. The operator can still edit afterward; this just
|
||||||
// saves them from looking up the price elsewhere.
|
// 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.duration_seconds === 0 ? 'perpetual' : pol.duration_seconds + 's'),
|
||||||
el('td', null, pol.grace_seconds + 's'),
|
el('td', null, pol.grace_seconds + 's'),
|
||||||
el('td', null, pol.max_machines === 0 ? '∞' : String(pol.max_machines)),
|
el('td', null, pol.max_machines === 0 ? '∞' : String(pol.max_machines)),
|
||||||
el('td', null, pol.is_trial
|
el('td', null,
|
||||||
? el('span', { class: 'badge b-warning' }, 'trial')
|
// Stack trial + recurring badges in one cell. Both can be set
|
||||||
: el('span', { class: 'muted' }, '–')),
|
// 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', { class: 'muted' }, (pol.entitlements || []).join(', ') || '–'),
|
||||||
el('td', null, el('span', { class: 'muted' }, String(byPolicy[pol.id] || 0))),
|
el('td', null, el('span', { class: 'muted' }, String(byPolicy[pol.id] || 0))),
|
||||||
el('td', null, activePill(pol.active)),
|
el('td', null, activePill(pol.active)),
|
||||||
|
|||||||
Reference in New Issue
Block a user