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:
@@ -500,7 +500,8 @@ def _write_match_groups(result, original_df, path: Path) -> None:
|
||||
|
||||
def main():
|
||||
from src.cli_license_guard import guard
|
||||
guard()
|
||||
from src.license import FeatureFlag
|
||||
guard(feature=FeatureFlag.DEDUPLICATOR.value)
|
||||
app()
|
||||
|
||||
|
||||
|
||||
@@ -349,7 +349,8 @@ def _print_results(result, input_path: Path, options) -> None:
|
||||
|
||||
def main():
|
||||
from src.cli_license_guard import guard
|
||||
guard()
|
||||
from src.license import FeatureFlag
|
||||
guard(feature=FeatureFlag.COLUMN_MAPPER.value)
|
||||
app()
|
||||
|
||||
|
||||
|
||||
@@ -358,7 +358,8 @@ def standardize(
|
||||
|
||||
def main():
|
||||
from src.cli_license_guard import guard
|
||||
guard()
|
||||
from src.license import FeatureFlag
|
||||
guard(feature=FeatureFlag.FORMAT_STANDARDIZER.value)
|
||||
app()
|
||||
|
||||
|
||||
|
||||
@@ -30,9 +30,21 @@ def _is_help_invocation() -> bool:
|
||||
return any(arg in _HELP_FLAGS for arg in sys.argv[1:])
|
||||
|
||||
|
||||
def guard() -> None:
|
||||
"""Block startup if no valid license. No-op when license is valid,
|
||||
when called with ``--help``, or under ``DATATOOLS_DEV_MODE``."""
|
||||
def guard(feature: str | None = None) -> None:
|
||||
"""Block startup if no valid license.
|
||||
|
||||
*feature* — when supplied, also requires that the active license's
|
||||
tier unlocks the named feature flag (e.g.
|
||||
``"03_format_standardizer"``). A Lite-tier user running
|
||||
``cli_format`` would pass the global validity check but fail the
|
||||
feature check; we surface a clear "upgrade your tier" message
|
||||
rather than letting them hit a runtime error halfway through a
|
||||
job.
|
||||
|
||||
No-op when license is valid (and the feature is unlocked), when
|
||||
called with ``--help`` / ``-h`` / ``--version``, or under
|
||||
``DATATOOLS_DEV_MODE=1``.
|
||||
"""
|
||||
if _is_help_invocation():
|
||||
return
|
||||
# Lazy import so a broken license module doesn't fail ``--help``.
|
||||
@@ -40,6 +52,7 @@ def guard() -> None:
|
||||
ExpiredLicenseError,
|
||||
InvalidLicenseError,
|
||||
LicenseError,
|
||||
UnsupportedFeatureError,
|
||||
get_manager,
|
||||
)
|
||||
|
||||
@@ -48,15 +61,33 @@ def guard() -> None:
|
||||
return
|
||||
|
||||
try:
|
||||
if mgr.is_valid():
|
||||
return
|
||||
if not mgr.is_valid():
|
||||
state = mgr.current_state()
|
||||
_exit_with_message(state)
|
||||
if feature is not None:
|
||||
try:
|
||||
mgr.require_feature(feature)
|
||||
except UnsupportedFeatureError as e:
|
||||
_exit_with_feature_message(feature, str(e))
|
||||
return
|
||||
except LicenseError:
|
||||
# ``is_valid()`` swallows errors and returns False, but be
|
||||
# paranoid: fall through to the state-based diagnostic.
|
||||
pass
|
||||
state = mgr.current_state()
|
||||
_exit_with_message(state)
|
||||
|
||||
state = mgr.current_state()
|
||||
_exit_with_message(state)
|
||||
|
||||
def _exit_with_feature_message(feature: str, detail: str) -> NoReturn:
|
||||
"""Print the upgrade-tier diagnostic and exit. Mirrors the
|
||||
GUI's ``require_feature_or_render_upgrade`` UX in CLI form."""
|
||||
msg = (
|
||||
f"This command requires the {feature!r} feature, which is not "
|
||||
f"included in your current license tier.\n"
|
||||
f"Detail: {detail}\n"
|
||||
"Run ``python -m src.license_cli status`` to see your tier, "
|
||||
"then activate an upgrade blob with "
|
||||
"``python -m src.license_cli renew <blob>``."
|
||||
)
|
||||
print(f"Error: {msg}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def _exit_with_message(state) -> NoReturn:
|
||||
|
||||
@@ -374,7 +374,8 @@ def _print_results(result, input_path: Path, options) -> None:
|
||||
|
||||
def main():
|
||||
from src.cli_license_guard import guard
|
||||
guard()
|
||||
from src.license import FeatureFlag
|
||||
guard(feature=FeatureFlag.MISSING_HANDLER.value)
|
||||
app()
|
||||
|
||||
|
||||
|
||||
@@ -301,7 +301,8 @@ def run(
|
||||
|
||||
def main() -> None:
|
||||
from src.cli_license_guard import guard
|
||||
guard()
|
||||
from src.license import FeatureFlag
|
||||
guard(feature=FeatureFlag.PIPELINE_RUNNER.value)
|
||||
app()
|
||||
|
||||
|
||||
|
||||
@@ -371,7 +371,8 @@ def _print_results(result, input_path: Path, options) -> None:
|
||||
|
||||
def main():
|
||||
from src.cli_license_guard import guard
|
||||
guard()
|
||||
from src.license import FeatureFlag
|
||||
guard(feature=FeatureFlag.TEXT_CLEANER.value)
|
||||
app()
|
||||
|
||||
|
||||
|
||||
@@ -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)}**]"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -68,8 +68,6 @@
|
||||
"blob_help": "Begins with `DTLIC1:` — paste the entire string.",
|
||||
"activate_button": "Activate",
|
||||
"renew_button": "Apply renewal",
|
||||
"trial_button": "Start 1-year trial",
|
||||
"trial_help": "Skips the paid blob and self-issues a 1-year license tied to your name and email. Useful for evaluating before purchase.",
|
||||
"or_separator": "— or —",
|
||||
"success": "Activated! Welcome, {name}. Your license is valid until {expires}.",
|
||||
"renewed": "License renewed. New expiry: {expires}.",
|
||||
@@ -86,13 +84,18 @@
|
||||
"renewal_warning_30": "⚠️ License expires in {days} days. Renew soon to avoid interruption.",
|
||||
"renewal_warning_expired": "🛑 License expired on {date}. Renew to continue using DataTools.",
|
||||
"tier_trial": "Trial",
|
||||
"tier_lite": "Lite",
|
||||
"tier_core": "Core",
|
||||
"tier_pro": "Pro",
|
||||
"tier_enterprise": "Enterprise",
|
||||
"registered_to": "Registered to {name} · {email}",
|
||||
"expires_on": "Expires on {date}",
|
||||
"issued_on": "Issued on {date}",
|
||||
"view_details": "License details"
|
||||
"view_details": "License details",
|
||||
"feature_locked_title": "🔒 This tool isn't on your {tier} license",
|
||||
"feature_locked_body": "Your current license unlocks: {features}. Upgrade to access this tool.",
|
||||
"upgrade_link": "Manage license",
|
||||
"status_locked": "Locked"
|
||||
},
|
||||
"tools": {
|
||||
"01_deduplicator": {
|
||||
|
||||
@@ -68,8 +68,6 @@
|
||||
"blob_help": "Empieza con `DTLIC1:` — pega la cadena completa.",
|
||||
"activate_button": "Activar",
|
||||
"renew_button": "Aplicar renovación",
|
||||
"trial_button": "Iniciar prueba de 1 año",
|
||||
"trial_help": "Omite el código de pago y emite una licencia local de 1 año vinculada a tu nombre y correo. Útil para evaluar antes de comprar.",
|
||||
"or_separator": "— o —",
|
||||
"success": "¡Activado! Bienvenido, {name}. Tu licencia es válida hasta el {expires}.",
|
||||
"renewed": "Licencia renovada. Nueva fecha de caducidad: {expires}.",
|
||||
@@ -86,13 +84,18 @@
|
||||
"renewal_warning_30": "⚠️ La licencia caduca en {days} días. Renueva pronto para evitar interrupciones.",
|
||||
"renewal_warning_expired": "🛑 La licencia caducó el {date}. Renuévala para seguir usando DataTools.",
|
||||
"tier_trial": "Prueba",
|
||||
"tier_lite": "Lite",
|
||||
"tier_core": "Core",
|
||||
"tier_pro": "Pro",
|
||||
"tier_enterprise": "Enterprise",
|
||||
"registered_to": "Registrado a nombre de {name} · {email}",
|
||||
"expires_on": "Caduca el {date}",
|
||||
"issued_on": "Emitida el {date}",
|
||||
"view_details": "Detalles de la licencia"
|
||||
"view_details": "Detalles de la licencia",
|
||||
"feature_locked_title": "🔒 Esta herramienta no está incluida en tu licencia {tier}",
|
||||
"feature_locked_body": "Tu licencia actual incluye: {features}. Actualiza para acceder a esta herramienta.",
|
||||
"upgrade_link": "Gestionar licencia",
|
||||
"status_locked": "Bloqueado"
|
||||
},
|
||||
"tools": {
|
||||
"01_deduplicator": {
|
||||
|
||||
@@ -29,7 +29,26 @@ def _all() -> FrozenSet[FeatureFlag]:
|
||||
|
||||
|
||||
FEATURES_BY_TIER: dict[Tier, FrozenSet[FeatureFlag]] = {
|
||||
Tier.TRIAL: _all(),
|
||||
# TRIAL is no longer reachable from the activation UI (no free
|
||||
# trial in v1.6) but we leave the mapping in place so legacy
|
||||
# trial licenses on disk still load. Effectively LITE-equivalent
|
||||
# if anyone has one.
|
||||
Tier.TRIAL: frozenset({
|
||||
FeatureFlag.DEDUPLICATOR,
|
||||
FeatureFlag.TEXT_CLEANER,
|
||||
FeatureFlag.FORMAT_STANDARDIZER,
|
||||
}),
|
||||
# LITE — the cheap SKU. Three tools that cover the common
|
||||
# bookkeeping / RevOps workflow: clean text, standardise formats,
|
||||
# remove duplicates. Buyers who want missing-handler, column-
|
||||
# mapper, outlier-detector, validator, or the pipeline runner
|
||||
# upgrade to CORE.
|
||||
Tier.LITE: frozenset({
|
||||
FeatureFlag.DEDUPLICATOR,
|
||||
FeatureFlag.TEXT_CLEANER,
|
||||
FeatureFlag.FORMAT_STANDARDIZER,
|
||||
}),
|
||||
# CORE — the v1 full SKU. Every Ready tool.
|
||||
Tier.CORE: _all(),
|
||||
# Pre-wired for future SKUs. Today they mirror CORE so the gating
|
||||
# tests exercise the lookup path without making a marketing claim.
|
||||
|
||||
@@ -31,12 +31,19 @@ from typing import Any
|
||||
class Tier(str, Enum):
|
||||
"""License tier. Drives the feature set the active license unlocks.
|
||||
|
||||
Order matters: TRIAL < CORE < PRO < ENTERPRISE. A higher tier
|
||||
inherits every feature of the lower tiers — see
|
||||
Order matters for upgrade pricing: LITE < CORE < PRO < ENTERPRISE.
|
||||
Each higher tier inherits every feature of the lower tiers — see
|
||||
:data:`.features.FEATURES_BY_TIER`.
|
||||
|
||||
``TRIAL`` is retained for backward-compat with the brief trial-
|
||||
enabled build, but is no longer reachable from any user-facing
|
||||
entry point (the GUI trial button and the ``license_cli trial``
|
||||
subcommand were both removed in v1.6). The enum value stays so
|
||||
a buyer with a trial license on disk doesn't see a load failure.
|
||||
"""
|
||||
|
||||
TRIAL = "trial"
|
||||
LITE = "lite"
|
||||
CORE = "core"
|
||||
PRO = "pro"
|
||||
ENTERPRISE = "enterprise"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""CLI for license management.
|
||||
|
||||
Five commands:
|
||||
Four commands:
|
||||
|
||||
- ``activate BLOB --name NAME --email EMAIL``
|
||||
First-time activation. Verifies the signed blob, ensures the
|
||||
@@ -9,23 +9,23 @@ Five commands:
|
||||
- ``renew BLOB``
|
||||
Apply a renewal blob to the currently-active license. The blob's
|
||||
embedded name + email must match the active license; tier may
|
||||
differ (upgrade path).
|
||||
differ (upgrade path, e.g., Lite → Core).
|
||||
|
||||
- ``status [--json]``
|
||||
Print the current license state. Human-readable by default;
|
||||
``--json`` emits the same payload as a JSON document for piping
|
||||
into shell scripts / monitoring.
|
||||
|
||||
- ``trial --name NAME --email EMAIL [--years N]``
|
||||
Self-issue a trial license without a paid blob. Useful for
|
||||
evaluating the product or for support to repro a buyer's issue
|
||||
locally without needing a real key.
|
||||
|
||||
- ``deactivate``
|
||||
Remove the local license file.
|
||||
|
||||
This CLI is exempt from the guard that protects every tool CLI —
|
||||
otherwise a user with no license couldn't run ``activate``.
|
||||
|
||||
There is **no free-trial subcommand**: every license requires a paid
|
||||
blob from the seller. The internal ``LicenseManager._mint`` API is
|
||||
used by tests and by the seller's ``scripts/generate_license.py``;
|
||||
end users have no way to self-issue a license.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -134,25 +134,6 @@ def status(
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def trial(
|
||||
name: str = typer.Option(..., "--name", "-n"),
|
||||
email: str = typer.Option(..., "--email", "-e"),
|
||||
years: int = typer.Option(1, "--years", help="Trial length (default: 1 year)."),
|
||||
) -> None:
|
||||
"""Self-issue a trial license without a paid blob."""
|
||||
mgr = get_manager()
|
||||
try:
|
||||
lic = mgr.issue_trial(name=name, email=email, years=years)
|
||||
except LicenseError as e:
|
||||
typer.echo(f"Trial issuance failed: {e}", err=True)
|
||||
raise typer.Exit(code=2)
|
||||
typer.echo(
|
||||
f"Trial issued. Key: {lic.license_key} · "
|
||||
f"Expires: {lic.expires_at[:10]} ({lic.days_remaining()} days)"
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def deactivate(
|
||||
confirm: bool = typer.Option(
|
||||
|
||||
Reference in New Issue
Block a user