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:
@@ -212,6 +212,26 @@ pub async fn enforce_policy_cap(state: &AppState, product_id: &str) -> AppResult
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse to mark a policy as recurring unless the operator's self-tier
|
||||
/// carries the `recurring_billing` entitlement. Pro and Patron tiers
|
||||
/// have it; Creator does not. Called from both create-policy and
|
||||
/// update-policy paths so operators can't sneak past by patching a
|
||||
/// non-recurring policy to recurring after creation.
|
||||
pub async fn enforce_recurring_feature(state: &AppState) -> AppResult<()> {
|
||||
let tier = current(state).await;
|
||||
if tier.has("recurring_billing") {
|
||||
return Ok(());
|
||||
}
|
||||
Err(AppError::PaymentRequired {
|
||||
message: format!(
|
||||
"Recurring subscriptions require Pro or Patron. You're on {}. \
|
||||
Upgrade to enable monthly/annual billing.",
|
||||
tier.display_name
|
||||
),
|
||||
upgrade_url: UPGRADE_URL_PRO.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuse a new discount code if the operator is at the Creator-tier
|
||||
/// active-codes cap and lacks `unlimited_codes`. Counts only ACTIVE
|
||||
/// codes — operators can disable old codes to free up slots, which is
|
||||
|
||||
Reference in New Issue
Block a user