feat(license): add Lite SKU; remove user-facing free trial

Two coupled changes:

1. Lite tier
   - New Tier.LITE in src/license/schema.py.
   - FEATURES_BY_TIER[Tier.LITE] = {Deduplicator, Text Cleaner,
     Format Standardizer}. The three universally-useful tools that
     cover the most common bookkeeping / RevOps / Klaviyo prep
     workflows. Other six tools require Core.
   - i18n: license.tier_lite, license.feature_locked_title,
     license.feature_locked_body, license.upgrade_link,
     license.status_locked (en + es).
   - Per-tool feature gate at every GUI tool page
     (require_feature_or_render_upgrade) and every tool CLI
     (guard(feature=...)). A locked tool renders an upgrade
     prompt + Manage-license button (GUI) or exits with code 2
     (CLI).
   - Home grid: tool cards the user's tier doesn't unlock get a
     red 🔒 Locked badge in place of green Ready.

2. Trial removed
   - Activation form's "Start 1-year trial" button removed.
   - license_cli's `trial` subcommand removed.
   - activation.trial_button / activation.trial_help i18n keys
     dropped (pack parity test stays green).
   - Tier.TRIAL stays in the enum (back-compat with any field-
     tested trial licenses); LicenseManager._mint stays internal
     for tests and the seller's key generator.
   - Decision logged in DECISIONS §9b: a 1-year all-features
     trial undercuts paid Lite; paid-only keeps tier economics
     clean.

Tests (+29 net): +17 Lite-tier unit/guard tests + 13 Lite-tier
GUI tests + 1 trial-absent assertion - 2 trial CLI tests - 1
trial GUI button test. Total: 1995 → 2024.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 17:19:30 +00:00
parent e612c751a8
commit d32b58e61a
33 changed files with 621 additions and 153 deletions

View File

@@ -61,6 +61,16 @@ st.divider()
# ---------------------------------------------------------------------------
from src.gui.tools_registry import TOOLS, tool_description, tool_name
from src.license import get_manager
# Per-tier feature set for badging — Ready tools that the user's
# tier doesn't unlock get a red "🔒 Locked" pill instead of the green
# "Ready" so they see at a glance which tools an upgrade would
# unlock. Dev-mode skips the lock check so the home grid renders the
# full SKU during development.
_license_state = get_manager().current_state()
_unlocked_features = set(_license_state.features) if _license_state.valid else set()
_dev_mode = get_manager().dev_mode
# Render tool cards in a 3-column grid. Cards picked up by the analyzer get a
# coloured "N findings" badge so the user can see at a glance which tools
@@ -73,17 +83,31 @@ for row_start in range(0, len(TOOLS), 3):
break
tool = TOOLS[idx]
with col:
status_key = "status.ready" if tool.status == "Ready" else "status.coming_soon"
status_color = "green" if tool.status == "Ready" else "orange"
tool_locked = (
tool.status == "Ready"
and not _dev_mode
and tool.tool_id not in _unlocked_features
)
if tool_locked:
status_key = "license.status_locked"
status_color = "red"
elif tool.status == "Ready":
status_key = "status.ready"
status_color = "green"
else:
status_key = "status.coming_soon"
status_color = "orange"
badge = ""
n = findings_count_for_tool(tool.tool_id)
if n:
badge_key = "home.findings_badge_one" if n == 1 else "home.findings_badge_other"
badge = f" :red-background[**{t(badge_key, n=n)}**]"
lock_glyph = "🔒 " if tool_locked else ""
st.markdown(
f"### {tool.icon} {tool_name(tool.tool_id)}{badge}\n\n"
f"{tool_description(tool.tool_id)}\n\n"
f":{status_color}[**{t(status_key)}**]"
f":{status_color}[**{lock_glyph}{t(status_key)}**]"
)

View File

@@ -41,6 +41,7 @@ from . import _legacy as _legacy # noqa: F401 (keep for direct access)
from .activation import ( # noqa: F401 re-exported
render_activation_form,
render_license_status_sidebar,
require_feature_or_render_upgrade,
require_license_or_render_activation,
)
@@ -53,6 +54,7 @@ __all__ = [
# License gate + activation form
"render_activation_form",
"render_license_status_sidebar",
"require_feature_or_render_upgrade",
"require_license_or_render_activation",
# Dedup widgets
"config_panel",

View File

@@ -125,19 +125,12 @@ def render_activation_form(*, key_prefix: str = "act") -> None:
height=120,
)
col_activate, col_trial = st.columns(2)
with col_activate:
is_renewal = state.activated
label = (
_t("activation.renew_button") if is_renewal
else _t("activation.activate_button")
)
submit = st.form_submit_button(label, type="primary")
with col_trial:
trial = st.form_submit_button(
_t("activation.trial_button"),
help=_t("activation.trial_help"),
)
is_renewal = state.activated
label = (
_t("activation.renew_button") if is_renewal
else _t("activation.activate_button")
)
submit = st.form_submit_button(label, type="primary")
# Process the submission.
if submit:
@@ -159,17 +152,6 @@ def render_activation_form(*, key_prefix: str = "act") -> None:
except LicenseError as e:
st.error(f"{_t('activation.errors_heading')}: {e}")
if trial:
try:
lic = mgr.issue_trial(name=name, email=email)
st.success(_t(
"activation.success",
name=lic.name, expires=lic.expires_at[:10],
))
st.rerun()
except LicenseError as e:
st.error(f"{_t('activation.errors_heading')}: {e}")
# Deactivate (only useful when already activated).
if state.activated:
st.divider()
@@ -195,24 +177,61 @@ def require_license_or_render_activation(*, feature: Optional[str] = None) -> No
unlocked), this is a no-op. Otherwise it renders the activation
form in place of the page body and calls ``st.stop()``.
The chrome helper invokes this automatically so individual pages
don't have to call it; the explicit *feature* form is provided
for tools that want to bypass the global gate but enforce a tier
check (future SKU work).
Tool pages that need per-tool gating call
:func:`require_feature_or_render_upgrade` *after* this — that
keeps the global gate (activate / renew flow) separate from the
per-tier feature gate (upgrade flow).
"""
mgr = get_manager()
if mgr.dev_mode:
return
if mgr.is_valid():
# When feature gating gets enabled per-tool, this is the hook.
if feature is not None:
try:
mgr.require_feature(feature)
except LicenseError as e:
st.error(f"⚠️ {e}")
st.stop()
return
# Otherwise: render the activation form inline and stop the page.
render_activation_form(key_prefix="gate")
st.stop()
def require_feature_or_render_upgrade(feature: str) -> None:
"""Per-tool feature gate. Renders an upgrade prompt + stops the
page when the active license's tier doesn't unlock *feature*.
Call from tool pages after ``hide_streamlit_chrome``. The page
body should be wrapped after this call so the upgrade prompt
cleanly replaces the body when the user's tier is too low.
Distinguished from :func:`require_license_or_render_activation`
by what it offers the user: that one drops them into the
activation form (paste a paid blob to activate at all); this one
surfaces an upgrade prompt with a button to the Activate page
(paste a higher-tier blob to upgrade).
"""
from src.license import UnsupportedFeatureError
mgr = get_manager()
if mgr.dev_mode:
return
try:
mgr.require_feature(feature)
return
except UnsupportedFeatureError:
pass # render the upgrade prompt below
except LicenseError as e:
# Not-activated / expired / invalid — fall through to the
# global gate (which would already have run; this is belt
# and braces for callers that bypass it).
st.error(f"⚠️ {e}")
st.stop()
state = mgr.current_state()
tier_label = _t(f"license.tier_{state.tier}") or state.tier.title()
feature_names = ", ".join(
_t(f"tools.{f}.name") if f.startswith("0") and "_" in f else f
for f in state.features
)
st.warning(_t("license.feature_locked_title", tier=tier_label))
st.caption(_t("license.feature_locked_body", features=feature_names))
if st.button(_t("license.upgrade_link"), key="upgrade_to_activate"):
st.switch_page("pages/_Activate.py")
st.stop()

View File

@@ -22,11 +22,14 @@ from src.gui.components import (
hide_streamlit_chrome,
match_group_card,
pickup_or_upload,
require_feature_or_render_upgrade,
require_normalization_gate,
results_summary,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.DEDUPLICATOR)
require_normalization_gate()
# ---------------------------------------------------------------------------

View File

@@ -18,8 +18,10 @@ from src.gui.components import (
hide_streamlit_chrome,
pickup_or_upload,
render_hidden_aware_preview,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.license import FeatureFlag
from src.core.text_clean import (
PRESETS,
CleanOptions,
@@ -29,6 +31,7 @@ from src.core.text_clean import (
)
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.TEXT_CLEANER)
require_normalization_gate()

View File

@@ -17,6 +17,7 @@ if str(_project_root) not in sys.path:
from src.gui.components import (
hide_streamlit_chrome,
pickup_or_upload,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.core.format_standardize import (
@@ -25,8 +26,10 @@ from src.core.format_standardize import (
StandardizeOptions,
standardize_dataframe,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.FORMAT_STANDARDIZER)
require_normalization_gate()

View File

@@ -17,6 +17,7 @@ if str(_project_root) not in sys.path:
from src.gui.components import (
hide_streamlit_chrome,
pickup_or_upload,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.core.missing import (
@@ -26,8 +27,10 @@ from src.core.missing import (
handle_missing,
profile_missing,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.MISSING_HANDLER)
require_normalization_gate()

View File

@@ -17,6 +17,7 @@ if str(_project_root) not in sys.path:
from src.gui.components import (
hide_streamlit_chrome,
pickup_or_upload,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.core.column_mapper import (
@@ -27,8 +28,10 @@ from src.core.column_mapper import (
infer_mapping,
map_columns,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.COLUMN_MAPPER)
require_normalization_gate()

View File

@@ -11,9 +11,15 @@ _project_root = Path(__file__).resolve().parent.parent.parent.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from src.gui.components import hide_streamlit_chrome, require_normalization_gate
from src.gui.components import (
hide_streamlit_chrome,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.OUTLIER_DETECTOR)
require_normalization_gate()
# ---------------------------------------------------------------------------

View File

@@ -11,9 +11,15 @@ _project_root = Path(__file__).resolve().parent.parent.parent.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from src.gui.components import hide_streamlit_chrome, require_normalization_gate
from src.gui.components import (
hide_streamlit_chrome,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.MULTI_FILE_MERGER)
require_normalization_gate()
# ---------------------------------------------------------------------------

View File

@@ -11,9 +11,15 @@ _project_root = Path(__file__).resolve().parent.parent.parent.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from src.gui.components import hide_streamlit_chrome, require_normalization_gate
from src.gui.components import (
hide_streamlit_chrome,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.VALIDATOR_REPORTER)
require_normalization_gate()
# ---------------------------------------------------------------------------

View File

@@ -17,6 +17,7 @@ if str(_project_root) not in sys.path:
from src.gui.components import (
hide_streamlit_chrome,
pickup_or_upload,
require_feature_or_render_upgrade,
require_normalization_gate,
)
from src.core.pipeline import (
@@ -28,8 +29,10 @@ from src.core.pipeline import (
run_pipeline,
validate_pipeline,
)
from src.license import FeatureFlag
hide_streamlit_chrome()
require_feature_or_render_upgrade(FeatureFlag.PIPELINE_RUNNER)
require_normalization_gate()