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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user