From 537172a4baa7840c17818be3ceedc1341e9eb6a7 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 25 Feb 2026 07:55:47 -0800 Subject: [PATCH] feat: tier system with FEATURES gate + can_use() + tier_label() --- app/wizard/__init__.py | 0 app/wizard/tiers.py | 67 ++++++++++++++++++++++++++++++++++++ tests/test_wizard_tiers.py | 69 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 app/wizard/__init__.py create mode 100644 app/wizard/tiers.py create mode 100644 tests/test_wizard_tiers.py diff --git a/app/wizard/__init__.py b/app/wizard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/wizard/tiers.py b/app/wizard/tiers.py new file mode 100644 index 0000000..cd100d4 --- /dev/null +++ b/app/wizard/tiers.py @@ -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" diff --git a/tests/test_wizard_tiers.py b/tests/test_wizard_tiers.py new file mode 100644 index 0000000..cc3a0ff --- /dev/null +++ b/tests/test_wizard_tiers.py @@ -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