feat: tier system with FEATURES gate + can_use() + tier_label()
This commit is contained in:
parent
450bfe1913
commit
492f3a00dd
3 changed files with 136 additions and 0 deletions
0
app/wizard/__init__.py
Normal file
0
app/wizard/__init__.py
Normal file
67
app/wizard/tiers.py
Normal file
67
app/wizard/tiers.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""
|
||||
Tier definitions and feature gates for Peregrine.
|
||||
|
||||
Tiers: free < paid < premium
|
||||
FEATURES maps feature key → minimum tier required.
|
||||
Features not in FEATURES are available to all tiers (free).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
TIERS = ["free", "paid", "premium"]
|
||||
|
||||
# Maps feature key → minimum tier string required.
|
||||
# Features absent from this dict are free (available to all).
|
||||
FEATURES: dict[str, str] = {
|
||||
# Wizard LLM generation
|
||||
"llm_career_summary": "paid",
|
||||
"llm_expand_bullets": "paid",
|
||||
"llm_suggest_skills": "paid",
|
||||
"llm_voice_guidelines": "premium",
|
||||
"llm_job_titles": "paid",
|
||||
"llm_keywords_blocklist": "paid",
|
||||
"llm_mission_notes": "paid",
|
||||
|
||||
# App features
|
||||
"company_research": "paid",
|
||||
"interview_prep": "paid",
|
||||
"email_classifier": "paid",
|
||||
"survey_assistant": "paid",
|
||||
"model_fine_tuning": "premium",
|
||||
"shared_cover_writer_model": "paid",
|
||||
"multi_user": "premium",
|
||||
|
||||
# Integrations (paid)
|
||||
"notion_sync": "paid",
|
||||
"google_sheets_sync": "paid",
|
||||
"airtable_sync": "paid",
|
||||
"google_calendar_sync": "paid",
|
||||
"apple_calendar_sync": "paid",
|
||||
"slack_notifications": "paid",
|
||||
}
|
||||
|
||||
# Free integrations (not in FEATURES):
|
||||
# google_drive_sync, dropbox_sync, onedrive_sync, mega_sync,
|
||||
# nextcloud_sync, discord_notifications, home_assistant
|
||||
|
||||
|
||||
def can_use(tier: str, feature: str) -> bool:
|
||||
"""Return True if the given tier has access to the feature.
|
||||
|
||||
Returns True for unknown features (not gated).
|
||||
Returns False for unknown/invalid tier strings.
|
||||
"""
|
||||
required = FEATURES.get(feature)
|
||||
if required is None:
|
||||
return True # not gated — available to all
|
||||
try:
|
||||
return TIERS.index(tier) >= TIERS.index(required)
|
||||
except ValueError:
|
||||
return False # invalid tier string
|
||||
|
||||
|
||||
def tier_label(feature: str) -> str:
|
||||
"""Return a display label for a locked feature, or '' if free/unknown."""
|
||||
required = FEATURES.get(feature)
|
||||
if required is None:
|
||||
return ""
|
||||
return "🔒 Paid" if required == "paid" else "⭐ Premium"
|
||||
69
tests/test_wizard_tiers.py
Normal file
69
tests/test_wizard_tiers.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from app.wizard.tiers import can_use, tier_label, TIERS, FEATURES
|
||||
|
||||
|
||||
def test_tiers_list():
|
||||
assert TIERS == ["free", "paid", "premium"]
|
||||
|
||||
|
||||
def test_can_use_free_feature_always():
|
||||
# Features not in FEATURES dict are available to all tiers
|
||||
assert can_use("free", "some_unknown_feature") is True
|
||||
|
||||
|
||||
def test_can_use_paid_feature_free_tier():
|
||||
assert can_use("free", "company_research") is False
|
||||
|
||||
|
||||
def test_can_use_paid_feature_paid_tier():
|
||||
assert can_use("paid", "company_research") is True
|
||||
|
||||
|
||||
def test_can_use_paid_feature_premium_tier():
|
||||
assert can_use("premium", "company_research") is True
|
||||
|
||||
|
||||
def test_can_use_premium_feature_paid_tier():
|
||||
assert can_use("paid", "model_fine_tuning") is False
|
||||
|
||||
|
||||
def test_can_use_premium_feature_premium_tier():
|
||||
assert can_use("premium", "model_fine_tuning") is True
|
||||
|
||||
|
||||
def test_can_use_unknown_feature_always_true():
|
||||
assert can_use("free", "nonexistent_feature") is True
|
||||
|
||||
|
||||
def test_tier_label_paid():
|
||||
label = tier_label("company_research")
|
||||
assert "Paid" in label or "paid" in label.lower()
|
||||
|
||||
|
||||
def test_tier_label_premium():
|
||||
label = tier_label("model_fine_tuning")
|
||||
assert "Premium" in label or "premium" in label.lower()
|
||||
|
||||
|
||||
def test_tier_label_free_feature():
|
||||
label = tier_label("unknown_free_feature")
|
||||
assert label == ""
|
||||
|
||||
|
||||
def test_can_use_invalid_tier_returns_false():
|
||||
# Invalid tier string should return False (safe failure mode)
|
||||
assert can_use("bogus", "company_research") is False
|
||||
|
||||
|
||||
def test_free_integrations_are_accessible():
|
||||
# These integrations are free (not in FEATURES dict)
|
||||
for feature in ["google_drive_sync", "dropbox_sync", "discord_notifications"]:
|
||||
assert can_use("free", feature) is True
|
||||
|
||||
|
||||
def test_paid_integrations_gated():
|
||||
assert can_use("free", "notion_sync") is False
|
||||
assert can_use("paid", "notion_sync") is True
|
||||
Loading…
Reference in a new issue